Skip to main content

第1节:渲染管线与坐标系统

在进入代码之前,我们需要建立起对 WebGL 的工业级心智模型。WebGL 并不是一个简单的绘图库,它是一套基于 GPU 硬件架构 设计的状态机(State Machine)并行数据流流水线(Pipeline)


🏗️ 核心哲学:状态机与流水线

1. 状态机模型(State Machine)

WebGL 的 API 风格非常特殊:你不是在“画一个圆”,而是在“配置一个工厂”。

  • 配置阶段:通过 gl.useProgramgl.bindBuffergl.enable(gl.DEPTH_TEST) 等指令设置 GPU 的当前状态。
  • 触发阶段:调用 gl.drawArraysgl.drawElements,GPU 会根据当前全套配置进行一次批量渲染。
性能金律

减少状态切换(State Switching)是 WebGL 优化的核心。频繁在两次 Draw Call 之间切换着色器程序或纹理会导致 GPU 流水线停顿(Stall),降低渲染效率,所以最好把相同状态的对象排在一起画(相同几何体、材质或者纹理等等都可以视为同状态)

2. 渲染管线(The Pipeline)

渲染管线是将 3D 数据转换为屏幕像素的标准化过程。

graph LR
A[顶点数据] --> B[顶点着色器]
B --> C[图元装配]
C --> D[光栅化]
D --> E[片段着色器]
E --> F[测试与混合]
F --> G[屏幕像素]
  1. 顶点着色器 (Vertex Shader):计算顶点在 裁剪空间 的位置,处理光照、法线等逐顶点属性。
  2. 图元装配 (Primitive Assembly):将顶点连接成三角形、线段或点,并执行裁剪(Clipping)
  3. 光栅化 (Rasterization):将连续的几何图形离散化为片元(Fragments),并对顶点属性进行透视正确插值
  4. 片段着色器 (Fragment Shader):决定每个像素的最终颜色(纹理采样、光照计算)。
  5. 逐片元处理 (Per-Fragment Processing):执行深度测试、模板测试和 Alpha 混合。

🧭 数据流转:Attribute, Uniform, Varying

理解数据如何在 CPU 与 GPU、以及着色器阶段之间传递至关重要。

类型定义传递路径用途
Attribute逐顶点变量CPU → Vertex Shader位置、颜色、UV、法线
Uniform全局统一变量CPU → VS & FS变换矩阵、时间、纹理采样器
Varying插值变量VS → FS传递并插值后的颜色、UV、坐标
关于插值

顶点着色器输出 3 个顶点的颜色,光栅化后,片段着色器拿到的颜色是这 3 个点根据像素位置加权平均后的值。


🧭 坐标系统:MVP 的数学旅程

将模型从原始坐标转换到屏幕像素需要经过 5 个空间的变换,统称为 MVP 变换

1. 变换链条

  1. 模型空间 (Model Space):物体自身的坐标系。
  2. 世界空间 (World Space):物体在场景中的位置(Model Matrix)。
  3. 视图空间 (View Space):以相机为原点,相机看向 -Z 方向(View Matrix)。
  4. 裁剪空间 (Clip Space)gl_Position 所在的坐标系,范围是 $[-w, w]$(Projection Matrix)。
  5. 屏幕空间 (Screen Space):最终的像素坐标(由 gl.viewport 定义)。

2. 关键节点:NDC (Normalized Device Coordinates)

在裁剪空间之后,GPU 会自动执行齐次除法($xyz / w$),将坐标映射到 $[-1, 1]$ 的立方体内,这被称为 NDC

Z-Fighting

深度缓冲的精度在 NDC 空间中是非线性的。离相机越近,精度越高;越远,精度越稀疏。这会导致远处的物体发生闪烁(Z-Fighting)。 深度缓冲(Depth Buffer)存储的值是非线性的,通常正比于 $1/z$。

  • 对策:尽可能增大 near 平面的值(不要设为 0),减小 far 平面。

D. 透视投影(Perspective) vs 正交投影(Orthographic)

  • 透视:近大远小,$w$ 分量随距离变化。适用于 3D 游戏、仿真。
  • 正交:平行投影,$w$ 分量通常为 1。适用于 2D UI、工程制图、CAD。

💻 实践:原生 WebGL 绘制一个点

通过这段代码,我们可以直观看到“状态配置”与“数据绑定”的过程。

// 1. 获取上下文并清屏
const gl = canvas.getContext("webgl");
gl.clearColor(0.1, 0.1, 0.1, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);

// 2. 定义着色器源码
const vsSource = `
attribute vec4 aPos;
void main() {
gl_Position = aPos; // 已经在裁剪空间
gl_PointSize = 30.0;
}
`;
const fsSource = `
precision mediump float;
void main() {
gl_FragColor = vec4(1.0, 0.5, 0.0, 1.0); // 橙色
}
`;

// 3. 编译与链接程序 (省略辅助函数)
const program = createProgram(gl, vsSource, fsSource);
gl.useProgram(program);

// 4. 数据绑定 (VBO)
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 0]), gl.STATIC_DRAW);

// 5. 告知 GPU 如何解析 Buffer
const aPosLoc = gl.getAttribLocation(program, "aPos");
gl.enableVertexAttribArray(aPosLoc);
gl.vertexAttribPointer(aPosLoc, 3, gl.FLOAT, false, 0, 0);

// 6. 执行绘制 (Draw Call)
gl.drawArrays(gl.POINTS, 0, 1);

🎨 Three.js 的工程化封装

Three.js 将上述复杂的底层细节封装成了更符合开发直觉的对象:

  • WebGLRenderer:封装了 gl.getContext 和渲染状态管理。
  • Scene:封装了物体层级树。
  • Camera:封装了 View 和 Projection 矩阵。
  • BufferGeometry:封装了 VBO 和 vertexAttribPointer
  • Material:封装了着色器编译、Uniform 自动更新。
原生 WebGLThree.js 对应物封装价值
gl.createBufferBufferAttribute自动管理显存上传与释放
gl.uniformMatrix4fvObject3D.matrixWorld自动维护复杂的矩阵链条
gl.useProgramMaterial自动处理着色器缓存与重用

🎨 Three.js 对比实现

Three.js 封装了所有底层操作,代码简洁很多:

<!DOCTYPE html>
<html>
<head>
<title>Three.js 渲染管线演示</title>
<style>
body {
margin: 0;
}
canvas {
display: block;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>

<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"
}
}
</script>

<script type="module">
import * as THREE from "three";

// 1. 创建场景(对应 WebGL 的上下文管理)
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);

// 2. 创建相机(对应 View 和 Projection 矩阵)
const camera = new THREE.PerspectiveCamera(
75, // FOV
window.innerWidth / window.innerHeight, // 宽高比
0.1, // 近裁剪面
1000, // 远裁剪面
);
camera.position.z = 5;

// 3. 创建渲染器(封装了 WebGL 上下文)
const renderer = new THREE.WebGLRenderer({
canvas: document.getElementById("canvas"),
});
renderer.setSize(window.innerWidth, window.innerHeight);

// 4. 创建几何体(封装了 Buffer 和 vertexAttribPointer)
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array([0, 0, 0]);
geometry.setAttribute(
"position",
new THREE.BufferAttribute(positions, 3),
);

// 5. 创建材质(封装了 Shader 编译和链接)
const material = new THREE.PointsMaterial({
color: 0xff0000, // 红色
size: 20,
});

// 6. 创建网格(封装了 Program 和绘制调用)
const points = new THREE.Points(geometry, material);
scene.add(points);

// 7. 渲染(内部调用 gl.drawArrays)
renderer.render(scene, camera);
</script>
</body>
</html>

Three.js 的封装对应关系

原生 WebGLThree.js说明
getContext('webgl')WebGLRenderer渲染器封装了上下文
createShader + compileShaderMaterial材质自动编译 Shader
createProgram + linkProgramMaterial材质自动链接程序
createBuffer + bufferDataBufferGeometry几何体自动管理 Buffer
vertexAttribPointersetAttribute几何体自动配置属性
drawArraysrender()渲染器自动调用绘制

🔍 原理对比分析

1. 渲染管线的封装

原生 WebGL

  • 需要手动管理每个阶段
  • 代码冗长,但完全可控
  • 适合学习底层原理

Three.js

  • 自动管理整个管线
  • 代码简洁,但隐藏了细节
  • 适合快速开发

2. 坐标变换的封装

原生 WebGL

// 需要手动计算 MVP 矩阵
const mvpMatrix = mat4.multiply(
projectionMatrix,
mat4.multiply(viewMatrix, modelMatrix),
);
gl.uniformMatrix4fv(mvpLocation, false, mvpMatrix);

Three.js

// 自动计算和传递矩阵
// Object3D 自动更新 modelMatrix
// Camera 自动更新 viewMatrix 和 projectionMatrix
// Material 自动接收这些矩阵

3. 数据管理的封装

原生 WebGL

  • 需要手动创建、绑定、上传 Buffer
  • 需要手动配置 vertexAttribPointer
  • 需要手动管理内存

Three.js

  • BufferGeometry 自动管理所有 Buffer
  • 自动处理数据类型和 stride
  • 自动处理内存清理

📝 编程作业

基础作业(⭐)

任务:实现一个在屏幕中心显示红色点的程序

要求

  1. 使用原生 WebGL 实现
  2. 使用 Three.js 实现
  3. 对比两种实现的代码量和复杂度

检查清单

  • 原生 WebGL 版本可以正常显示点
  • Three.js 版本可以正常显示点
  • 理解两种实现的对应关系
  • 能够解释渲染管线的 5 个阶段

进阶作业(⭐⭐)

任务:绘制多个不同颜色的点

要求

  1. 在屏幕的 4 个角落各绘制一个点
  2. 每个点使用不同的颜色
  3. 使用原生 WebGL 和 Three.js 两种方式实现

提示

// 原生 WebGL:需要为每个点设置不同的颜色
// 可以使用 attribute 传递颜色,或使用 uniform 数组

// Three.js:可以使用 Points 和多个 BufferAttribute

检查清单

  • 4 个点都正确显示
  • 每个点颜色不同
  • 理解 varying 变量的插值(虽然点不插值,但为后续学习做准备)

挑战作业(⭐⭐⭐)

任务:实现一个坐标系统可视化工具

要求

  1. 绘制 3 条坐标轴(X 红色、Y 绿色、Z 蓝色)
  2. 在轴上标注刻度
  3. 实现相机控制(可以用 OrbitControls 或自己实现)
  4. 显示当前坐标空间的信息

扩展

  • 实现坐标空间的切换(模型空间、世界空间、视图空间)
  • 可视化 MVP 矩阵的变换过程

检查清单

  • 坐标轴正确显示
  • 相机可以旋转和缩放
  • 理解不同坐标空间的概念
  • 能够解释 MVP 矩阵的作用

🎓 本节总结

关键概念

  1. 渲染管线:顶点数据 → 顶点着色器 → 图元装配 → 光栅化 → 片段着色器
  2. 坐标变换:模型空间 → 世界空间 → 视图空间 → 裁剪空间 → 屏幕空间
  3. MVP 矩阵:Model(模型变换)、View(视图变换)、Projection(投影变换)

原生 WebGL vs Three.js

方面原生 WebGLThree.js
代码量多(~100 行)少(~30 行)
学习曲线陡峭平缓
控制力完全控制受限于框架
开发速度
理解深度深入底层理解封装

🎓 深度思考

  1. 为什么 WebGL 顶点着色器必须输出 gl_Position 因为它需要提供给图元装配阶段进行裁剪,并作为后续光栅化插值的依据。
  2. 为什么片元着色器默认没有精度声明? 顶点着色器默认 highp 以保证坐标精度,而片元着色器为了性能权衡(尤其是移动端),强制开发者根据需求声明精度(通常 mediump)。
  3. 带宽瓶颈通常出现在哪? 通常在片段着色器阶段。高分辨率、过度采样(Overdraw)以及复杂的纹理采样都会剧烈消耗显存带宽。

📚 延伸阅读