第1节:渲染管线与坐标系统
在进入代码之前,我们需要建立起对 WebGL 的工业级心智模型。WebGL 并不是一个简单的绘图库,它是一套基于 GPU 硬件架构 设计的状态机(State Machine)和并行数据流流水线(Pipeline)。
🏗️ 核心哲学:状态机与流水线
1. 状态机模型(State Machine)
WebGL 的 API 风格非常特殊:你不是在“画一个圆”,而是在“配置一个工厂”。
- 配置阶段:通过
gl.useProgram、gl.bindBuffer、gl.enable(gl.DEPTH_TEST)等指令设置 GPU 的当前状态。 - 触发阶段:调用
gl.drawArrays或gl.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[屏幕像素]
- 顶点着色器 (Vertex Shader):计算顶点在 裁剪空间 的位置,处理光照、法线等逐顶点属性。
- 图元装配 (Primitive Assembly):将顶点连接成三角形、线段或点,并执行裁剪(Clipping)。
- 光栅化 (Rasterization):将连续的几何图形离散化为片元(Fragments),并对顶点属性进行透视正确插值。
- 片段着色器 (Fragment Shader):决定每个像素的最终颜色(纹理采样、光照计算)。
- 逐片元处理 (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. 变换链条
- 模型空间 (Model Space):物体自身的坐标系。
- 世界空间 (World Space):物体在场景中的位置(
Model Matrix)。 - 视图空间 (View Space):以相机为原点,相机看向 -Z 方向(
View Matrix)。 - 裁剪空间 (Clip Space):
gl_Position所在的坐标系,范围是 $[-w, w]$(Projection Matrix)。 - 屏幕空间 (Screen Space):最终的像素坐标(由
gl.viewport定义)。
2. 关键节点:NDC (Normalized Device Coordinates)
在裁剪空间之后,GPU 会自动执行齐次除法($xyz / w$),将坐标映射到 $[-1, 1]$ 的立方体内,这被称为 NDC。
深度缓冲的精度在 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 自动更新。
| 原生 WebGL | Three.js 对应物 | 封装价值 |
|---|---|---|
gl.createBuffer | BufferAttribute | 自动管理显存上传与释放 |
gl.uniformMatrix4fv | Object3D.matrixWorld | 自动维护复杂的矩阵链条 |
gl.useProgram | Material | 自动处理着色器缓存与重用 |
🎨 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 的封装对应关系
| 原生 WebGL | Three.js | 说明 |
|---|---|---|
getContext('webgl') | WebGLRenderer | 渲染器封装了上下文 |
createShader + compileShader | Material | 材质自动编译 Shader |
createProgram + linkProgram | Material | 材质自动链接程序 |
createBuffer + bufferData | BufferGeometry | 几何体自动管理 Buffer |
vertexAttribPointer | setAttribute | 几何体自动配置属性 |
drawArrays | render() | 渲染器自动调用绘制 |
🔍 原理对比分析
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
- 自动处理内存清理
📝 编程作业
基础作业(⭐)
任务:实现一个在屏幕中心显示红色点的程序
要求:
- 使用原生 WebGL 实现
- 使用 Three.js 实现
- 对比两种实现的代码量和复杂度
检查清单:
- 原生 WebGL 版本可以正常显示点
- Three.js 版本可以正常显示点
- 理解两种实现的对应关系
- 能够解释渲染管线的 5 个阶段
进阶作业(⭐⭐)
任务:绘制多个不同颜色的点
要求:
- 在屏幕的 4 个角落各绘制一个点
- 每个点使用不同的颜色
- 使用原生 WebGL 和 Three.js 两种方式实现
提示:
// 原生 WebGL:需要为每个点设置不同的颜色
// 可以使用 attribute 传递颜色,或使用 uniform 数组
// Three.js:可以使用 Points 和多个 BufferAttribute
检查清单:
- 4 个点都正确显示
- 每个点颜色不同
- 理解 varying 变量的插值(虽然点不插值,但为后续学习做准备)
挑战作业(⭐⭐⭐)
任务:实现一个坐标系统可视化工具
要求:
- 绘制 3 条坐标轴(X 红色、Y 绿色、Z 蓝色)
- 在轴上标注刻度
- 实现相机控制(可以用 OrbitControls 或自己实现)
- 显示当前坐标空间的信息
扩展:
- 实现坐标空间的切换(模型空间、世界空间、视图空间)
- 可视化 MVP 矩阵的变换过程
检查清单:
- 坐标轴正确显示
- 相机可以旋转和缩放
- 理解不同坐标空间的概念
- 能够解释 MVP 矩阵的作用
🎓 本节总结
关键概念
- 渲染管线:顶点数据 → 顶点着色器 → 图元装配 → 光栅化 → 片段着色器
- 坐标变换:模型空间 → 世界空间 → 视图空间 → 裁剪空间 → 屏幕空间
- MVP 矩阵:Model(模型变换)、View(视图变换)、Projection(投影变换)
原生 WebGL vs Three.js
| 方面 | 原生 WebGL | Three.js |
|---|---|---|
| 代码量 | 多(~100 行) | 少(~30 行) |
| 学习曲线 | 陡峭 | 平缓 |
| 控制力 | 完全控制 | 受限于框架 |
| 开发速度 | 慢 | 快 |
| 理解深度 | 深入底层 | 理解封装 |
🎓 深度思考
- 为什么 WebGL 顶点着色器必须输出
gl_Position? 因为它需要提供给图元装配阶段进行裁剪,并作为后续光栅化插值的依据。 - 为什么片元着色器默认没有精度声明?
顶点着色器默认
highp以保证坐标精度,而片元着色器为了性能权衡(尤其是移动端),强制开发者根据需求声明精度(通常mediump)。 - 带宽瓶颈通常出现在哪? 通常在片段着色器阶段。高分辨率、过度采样(Overdraw)以及复杂的纹理采样都会剧烈消耗显存带宽。