第2章:渲染管线详解
2.1 章节概述
在第一章中,我们成功绘制了第一个三角形。你可能会好奇:从三个顶点坐标到屏幕上的橙色三角形,中间到底发生了什么? 这就是本章要深入探讨的内容——GPU 渲染管线(Rendering Pipeline)。
理解渲染管线是掌握 WebGL 的关键。它不仅帮助你理解代码为什么这样写,更能让你在遇到问题时知道如何调试,在需要优化时知道从何下手。
本章将学习:
- 渲染管线的整体架构:可编程阶段 vs 固定功能阶段
- 顶点处理阶段:顶点着色器如何工作
- 图元装配阶段:点、线、三角形的构建
- 光栅化阶段:几何图形如何变成像素
- 片段处理阶段:每个像素的颜色计算
- 帧缓冲输出阶段:深度测试、混合、最终写入
2.2 渲染管线概述
2.2.1 什么是渲染管线?
渲染管线是 GPU 处理图形数据的流水线,类似于工厂的生产线。原材料(顶点数据)从一端输入,经过多个加工环节,最终在另一端输出成品(屏幕上的像素)。
想象一个汽车工厂:
汽车生产线类比
原材料 ─→ 冲压 ─→ 焊接 ─→ 涂装 ─→ 总装 ─→ 检验 ─→ 成品车
│ │ │ │ │ │
│ │ │ │ │ │
钢板 车身 车架 喷漆 组装 质检 可销售
零件 成型 上色 完成 通过 的汽车
GPU 渲染管线
顶点数据 ─→ 顶点 ─→ 图元 ─→ 光栅化 ─→ 片段 ─→ 输出 ─→ 像素
着色器 装配 着色器 测试
│ │ │ │ │ │
│ │ │ │ │ │
原始 变换 组装 转换 计算 深度 最终
坐标 位置 三角形 像素 颜色 混合 显示2.2.2 现代 GPU 渲染管线架构
WebGL 使用的是可编程渲染管线,与早期 OpenGL 的固定功能管线不同。
固定功能管线(旧)vs 可编程管线(新)
固定功能管线(OpenGL 1.x):
┌─────────────────────────────────────────────────────┐
│ │
│ 输入 ─→ [固定变换] ─→ [固定光照] ─→ [固定纹理] ─→ 输出 │
│ ▲ ▲ ▲ │
│ │ │ │ │
│ 只能配置 只能配置 只能配置 │
│ 参数 参数 参数 │
│ │
└─────────────────────────────────────────────────────┘
开发者只能调整预设功能的参数,无法自定义算法
可编程管线(OpenGL ES 2.0+ / WebGL):
┌─────────────────────────────────────────────────────┐
│ │
│ 输入 ─→ [顶点着色器] ─→ [固定] ─→ [片段着色器] ─→ 输出 │
│ ▲ ▲ │
│ │ │ │
│ 完全自定义 完全自定义 │
│ GLSL 代码 GLSL 代码 │
│ │
└─────────────────────────────────────────────────────┘
开发者可以编写着色器程序,实现任意算法2.2.3 WebGL 渲染管线完整流程
让我们看一个完整的渲染管线图:
WebGL 渲染管线详细流程
JavaScript 代码
│
│ gl.bindBuffer()
│ gl.bufferData()
▼
┌─────────────────────────────────────────────────────────────────┐
│ 顶点数据(GPU 显存) │
│ │
│ 位置: [x0,y0,z0, x1,y1,z1, x2,y2,z2, ...] │
│ 颜色: [r0,g0,b0, r1,g1,b1, r2,g2,b2, ...] │
│ 纹理坐标: [u0,v0, u1,v1, u2,v2, ...] │
│ 法线: [nx0,ny0,nz0, ...] │
│ │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ ╔═══════════════════════╗ │
│ ║ 顶点着色器 ║ ← 可编程阶段 │
│ ║ Vertex Shader ║ │
│ ╚═══════════════════════╝ │
│ │
│ 输入:顶点属性(attribute) │
│ 处理:位置变换、属性计算 │
│ 输出:gl_Position(裁剪空间坐标) │
│ varying 变量(传递给片段着色器) │
│ │
│ 每个顶点独立执行,可并行 │
│ │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ ┌───────────────────────┐ │
│ │ 图元装配 │ ← 固定功能阶段 │
│ │ Primitive Assembly │ │
│ └───────────────────────┘ │
│ │
│ 将顶点组装成图元: │
│ - gl.POINTS: 每1个顶点 = 1个点 │
│ - gl.LINES: 每2个顶点 = 1条线 │
│ - gl.TRIANGLES: 每3个顶点 = 1个三角形 │
│ │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ ┌───────────────────────┐ │
│ │ 裁剪 │ ← 固定功能阶段 │
│ │ Clipping │ │
│ └───────────────────────┘ │
│ │
│ 裁剪掉视锥体外的部分: │
│ - 完全在外:整个图元被丢弃 │
│ - 部分在外:图元被裁剪,可能生成新顶点 │
│ - 完全在内:保持不变 │
│ │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ ┌───────────────────────┐ │
│ │ 透视除法 │ ← 固定功能阶段 │
│ │ Perspective Division │ │
│ └───────────────────────┘ │
│ │
│ 将裁剪坐标转换为 NDC: │
│ x_ndc = x_clip / w_clip │
│ y_ndc = y_clip / w_clip │
│ z_ndc = z_clip / w_clip │
│ │
│ 结果范围:[-1, 1] │
│ │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ ┌───────────────────────┐ │
│ │ 视口变换 │ ← 固定功能阶段 │
│ │ Viewport Transform │ │
│ └───────────────────────┘ │
│ │
│ 将 NDC 转换为屏幕坐标: │
│ x_screen = (x_ndc + 1) / 2 * viewport.width + viewport.x │
│ y_screen = (y_ndc + 1) / 2 * viewport.height + viewport.y │
│ │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ ┌───────────────────────┐ │
│ │ 光栅化 │ ← 固定功能阶段 │
│ │ Rasterization │ │
│ └───────────────────────┘ │
│ │
│ 将几何图元转换为片段(Fragment): │
│ │
│ 三角形 光栅化后 │
│ △ ■ ■ ■ │
│ ╱ ╲ ■ ■ ■ ■ │
│ ╱ ╲ ■ ■ ■ ■ ■ │
│ ───── ■ ■ ■ ■ ■ ■ ■ │
│ │
│ 每个 ■ 是一个片段,包含: │
│ - 屏幕坐标 (x, y) │
│ - 深度值 z │
│ - 插值后的 varying 变量 │
│ │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ ╔═══════════════════════╗ │
│ ║ 片段着色器 ║ ← 可编程阶段 │
│ ║ Fragment Shader ║ │
│ ╚═══════════════════════╝ │
│ │
│ 输入:varying 变量(已插值)、uniform、纹理 │
│ 处理:颜色计算、纹理采样、光照计算 │
│ 输出:gl_FragColor(片段颜色) │
│ │
│ 每个片段独立执行,可并行 │
│ │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ ┌───────────────────────┐ │
│ │ 逐片段操作 │ ← 固定功能阶段 │
│ │ Per-Fragment Ops │ │
│ └───────────────────────┘ │
│ │
│ 1. 像素所有权测试:像素是否属于当前窗口 │
│ 2. 剪切测试:是否在剪切矩形内 │
│ 3. 模板测试:模板缓冲检查 │
│ 4. 深度测试:深度缓冲检查 │
│ 5. 混合:与帧缓冲现有颜色混合 │
│ 6. 抖动:可选的颜色抖动 │
│ │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 帧缓冲(Framebuffer) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 颜色缓冲 │ │ 深度缓冲 │ │ 模板缓冲 │ │
│ │ RGBA │ │ Depth │ │ Stencil │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
└────────────────────────────┬────────────────────────────────────┘
│
▼
屏幕显示2.3 顶点处理阶段
2.3.1 顶点数据的组织
在 WebGL 中,所有的几何图形都由顶点组成。每个顶点可以包含多种属性(Attribute):
顶点属性示例
一个完整的3D顶点可能包含:
┌─────────────────────────────────────────────────────┐
│ 顶点 0 │
├─────────────────────────────────────────────────────┤
│ 位置 (position): [x, y, z] = [1.0, 2.0, 3.0] │
│ 颜色 (color): [r, g, b, a] = [1.0, 0.0, 0.0, 1.0] │
│ 纹理坐标 (texCoord): [u, v] = [0.5, 0.5] │
│ 法线 (normal): [nx, ny, nz] = [0.0, 1.0, 0.0] │
│ 切线 (tangent): [tx, ty, tz] = [1.0, 0.0, 0.0] │
│ 骨骼权重 (weights): [w0, w1, w2, w3] = [0.5, 0.3, 0.2, 0.0] │
│ 骨骼索引 (indices): [i0, i1, i2, i3] = [0, 1, 2, 0] │
└─────────────────────────────────────────────────────┘2.3.2 顶点数据的内存布局
顶点数据在 GPU 显存中有两种常见的排列方式:
方式一:分离式布局(Separate)
每种属性单独存储在不同的缓冲区
位置缓冲区:
┌───────────────────────────────────────────────────┐
│ x0 y0 z0 │ x1 y1 z1 │ x2 y2 z2 │ x3 y3 z3 │ ... │
└───────────────────────────────────────────────────┘
颜色缓冲区:
┌───────────────────────────────────────────────────┐
│ r0 g0 b0 │ r1 g1 b1 │ r2 g2 b2 │ r3 g3 b3 │ ... │
└───────────────────────────────────────────────────┘
纹理坐标缓冲区:
┌───────────────────────────────────────────────────┐
│ u0 v0 │ u1 v1 │ u2 v2 │ u3 v3 │ ... │
└───────────────────────────────────────────────────┘// 分离式布局代码
const positions = new Float32Array([
0.0, 0.5, 0.0, // 顶点 0
-0.5, -0.5, 0.0, // 顶点 1
0.5, -0.5, 0.0 // 顶点 2
]);
const colors = new Float32Array([
1.0, 0.0, 0.0, // 顶点 0:红色
0.0, 1.0, 0.0, // 顶点 1:绿色
0.0, 0.0, 1.0 // 顶点 2:蓝色
]);
// 创建两个缓冲区
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
const colorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, colors, gl.STATIC_DRAW);
// 设置属性指针
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.vertexAttribPointer(positionLoc, 3, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.vertexAttribPointer(colorLoc, 3, gl.FLOAT, false, 0, 0);方式二:交错式布局(Interleaved)
所有属性交错存储在同一个缓冲区
单一缓冲区:
┌─────────────────────────────────────────────────────────────────┐
│ x0 y0 z0 r0 g0 b0 u0 v0 │ x1 y1 z1 r1 g1 b1 u1 v1 │ ... │
│ 顶点 0 │ 顶点 1 │ │
└─────────────────────────────────────────────────────────────────┘
stride (步长) = 每个顶点的总字节数
offset (偏移) = 属性在顶点中的起始位置// 交错式布局代码
const vertices = new Float32Array([
// 位置 (3) 颜色 (3) 纹理坐标 (2)
0.0, 0.5, 0.0, 1.0, 0.0, 0.0, 0.5, 1.0, // 顶点 0
-0.5, -0.5, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, // 顶点 1
0.5, -0.5, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0 // 顶点 2
]);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
// 计算步长和偏移
const FSIZE = vertices.BYTES_PER_ELEMENT; // 4 字节
const stride = FSIZE * 8; // 每个顶点 8 个浮点数
// 设置属性指针
gl.vertexAttribPointer(positionLoc, 3, gl.FLOAT, false, stride, 0);
gl.vertexAttribPointer(colorLoc, 3, gl.FLOAT, false, stride, FSIZE * 3);
gl.vertexAttribPointer(texCoordLoc, 2, gl.FLOAT, false, stride, FSIZE * 6);两种布局的比较:
| 方面 | 分离式布局 | 交错式布局 |
|---|---|---|
| 缓存效率 | 较低 | 较高(数据局部性好) |
| 更新灵活性 | 高(可单独更新某属性) | 低(需更新整块数据) |
| 内存使用 | 可能有对齐浪费 | 紧凑 |
| 代码复杂度 | 简单 | 稍复杂 |
| 适用场景 | 属性需要独立更新 | 静态几何体 |
2.3.3 顶点着色器详解
顶点着色器是第一个可编程阶段,它对每个顶点执行一次。
顶点着色器的职责:
- 坐标变换:将顶点从模型空间变换到裁剪空间
- 属性计算:计算需要传递给片段着色器的数据
- 逐顶点光照(如果使用 Gouraud 着色)
// 完整的顶点着色器示例
attribute vec3 a_position; // 顶点位置(模型空间)
attribute vec3 a_normal; // 顶点法线
attribute vec2 a_texCoord; // 纹理坐标
attribute vec3 a_color; // 顶点颜色
uniform mat4 u_modelMatrix; // 模型矩阵:模型空间 → 世界空间
uniform mat4 u_viewMatrix; // 视图矩阵:世界空间 → 相机空间
uniform mat4 u_projMatrix; // 投影矩阵:相机空间 → 裁剪空间
uniform mat3 u_normalMatrix; // 法线矩阵(模型矩阵逆转置的 3x3)
varying vec3 v_position; // 传递给片段着色器的世界坐标
varying vec3 v_normal; // 传递给片段着色器的法线
varying vec2 v_texCoord; // 传递给片段着色器的纹理坐标
varying vec3 v_color; // 传递给片段着色器的颜色
void main() {
// 计算世界坐标
vec4 worldPosition = u_modelMatrix * vec4(a_position, 1.0);
v_position = worldPosition.xyz;
// 变换法线到世界空间
v_normal = normalize(u_normalMatrix * a_normal);
// 直接传递纹理坐标和颜色
v_texCoord = a_texCoord;
v_color = a_color;
// 计算裁剪空间坐标
// MVP 变换:Model × View × Projection
gl_Position = u_projMatrix * u_viewMatrix * worldPosition;
}2.3.4 坐标空间变换
理解坐标空间变换是掌握 3D 图形的关键:
坐标空间变换流程
模型空间 (Model Space)
│
│ × 模型矩阵 (Model Matrix)
▼
世界空间 (World Space)
│
│ × 视图矩阵 (View Matrix)
▼
相机空间 (Camera/View Space)
│
│ × 投影矩阵 (Projection Matrix)
▼
裁剪空间 (Clip Space)
│
│ ÷ w (透视除法)
▼
NDC 空间 (Normalized Device Coordinates)
│
│ × 视口变换
▼
屏幕空间 (Screen Space)各空间详解:
1. 模型空间 (Model Space)
- 物体自身的坐标系
- 原点通常在物体中心
- 艺术家建模时使用的坐标
示例:一个立方体
↑ Y
│ ┌───┐
│ ╱ ╱│
│ └───┘ │
│ │ │ │
────┼─│───│─┼────→ X
│ │ │╱
│ └───┘
│
↓
Z
2. 世界空间 (World Space)
- 场景的全局坐标系
- 所有物体放置在同一个空间中
- 物体可以有不同的位置、旋转、缩放
示例:场景中的多个物体
↑ Y
│
│ [树] [房子]
│ 🌲 🏠
────┼──────────────────────→ X
│ [车]
│ 🚗
│
↓
3. 相机空间 (Camera/View Space)
- 以相机为中心的坐标系
- 相机在原点,看向 -Z 方向(OpenGL 约定)
- X 向右,Y 向上
示例:相机视角
↑ Y
│
┌──────┼──────┐
│ │ │
│ 🏠│🌲 │ ← 场景在相机前方
│ │ │
└──────┼──────┘
──────────(0)──────────→ X
│
│
↓ -Z (相机看向的方向)
4. 裁剪空间 (Clip Space)
- 投影后的齐次坐标空间
- 范围:x,y,z ∈ [-w, w]
- 用于裁剪判断
┌─────────────────────────────┐
│ │
│ 投影矩阵将 3D 场景 │
│ "压缩"到一个立方体中 │
│ │
│ ┌─────────────┐ │
│ ╱│ ╱│ │
│ ╱ │ ╱ │ │
│ └──│──────────┘ │ │
│ │ │ │ │ │
│ │ └──────────│──┘ │
│ │ ╱ │ ╱ │
│ │╱ │╱ │
│ └─────────────┘ │
│ -w ≤ x,y,z ≤ w │
│ │
└─────────────────────────────┘
5. NDC 空间 (Normalized Device Coordinates)
- 透视除法后的空间
- 范围:x,y,z ∈ [-1, 1]
- 与屏幕尺寸无关
↑ Y (+1)
│
────┼────→ X (+1)
(-1) │
│
↓ (-1)
6. 屏幕空间 (Screen Space)
- 像素坐标
- 原点在左下角(WebGL)
- 范围由视口决定
(0, height)┌────────────┐(width, height)
│ │
│ │
│ │
(0, 0) └────────────┘ (width, 0)2.3.5 gl_Position 深入理解
gl_Position 是顶点着色器必须输出的内置变量:
// gl_Position 是一个 vec4
gl_Position = vec4(x, y, z, w);
// 分量含义:
// x, y, z: 裁剪空间中的坐标
// w: 齐次坐标分量(透视除法的除数)为什么需要齐次坐标(w 分量)?
齐次坐标允许我们用矩阵乘法表示透视投影,这是普通 3D 坐标做不到的。
透视投影的数学原理
在透视投影中,远处的物体看起来更小。
这意味着 x_screen 和 y_screen 需要除以深度 z:
x_screen = x / z
y_screen = y / z
但这不是线性变换,无法用矩阵表示。
解决方案:使用齐次坐标
[x_clip] [投影矩阵] [x_eye]
[y_clip] = [ ... ] × [y_eye]
[z_clip] [ ... ] [z_eye]
[w_clip] [ ... ] [ 1 ]
投影矩阵将 z 值编码到 w 分量中。
透视除法:x_ndc = x_clip / w_clip
y_ndc = y_clip / w_clip
这样就能用矩阵实现透视效果!2.4 图元装配阶段
2.4.1 什么是图元?
图元(Primitive) 是 GPU 能够渲染的基本几何形状。WebGL 支持以下图元类型:
| 图元类型 | 常量 | 说明 |
|---|---|---|
| 点 | gl.POINTS | 独立的点 |
| 线段 | gl.LINES | 独立的线段 |
| 线带 | gl.LINE_STRIP | 连续的折线 |
| 线环 | gl.LINE_LOOP | 封闭的折线 |
| 三角形 | gl.TRIANGLES | 独立的三角形 |
| 三角带 | gl.TRIANGLE_STRIP | 共享边的三角形带 |
| 三角扇 | gl.TRIANGLE_FAN | 共享顶点的三角形扇 |
2.4.2 图元装配详解
图元装配示例
输入顶点:V0, V1, V2, V3, V4, V5
gl.POINTS
每个顶点单独绘制为一个点
V0 V1 V2 V3 V4 V5
● ● ● ● ● ●
gl.LINES
每 2 个顶点组成一条线段
V0────V1 V2────V3 V4────V5
图元数量 = 顶点数 / 2
gl.LINE_STRIP
顶点依次连接成折线
V0────V1────V2────V3────V4────V5
图元数量 = 顶点数 - 1
gl.LINE_LOOP
折线 + 首尾相连
V0────V1────V2
│ │
V5────V4────V3
图元数量 = 顶点数
gl.TRIANGLES
每 3 个顶点组成一个三角形
V1 V4
╱ ╲ ╱ ╲
╱ ╲ ╱ ╲
V0────V2 V3────V5
图元数量 = 顶点数 / 3
gl.TRIANGLE_STRIP
共享边的三角形带(顶点复用)
V0────V2────V4
╲ ╱ ╲ ╱
V1────V3
三角形 0: V0, V1, V2
三角形 1: V2, V1, V3 (注意顶点顺序!)
三角形 2: V2, V3, V4
图元数量 = 顶点数 - 2
gl.TRIANGLE_FAN
以第一个顶点为中心的扇形
V1
╱│╲
╱ │ ╲
V2─V0─V4
╲ │ ╱
╲│╱
V3
三角形 0: V0, V1, V2
三角形 1: V0, V2, V3
三角形 2: V0, V3, V4
图元数量 = 顶点数 - 22.4.3 三角带的顶点顺序
三角带有一个微妙之处:相邻三角形的绑制顺序交替变化。
三角带顶点顺序详解
顶点:0, 1, 2, 3, 4, 5
0───2───4
╲ │╲ │╲
╲│ ╲│ ╲
1───3───5
三角形索引:
- 三角形 0: (0, 1, 2) → 逆时针
- 三角形 1: (2, 1, 3) → 顺时针(注意顺序变了!)
- 三角形 2: (2, 3, 4) → 逆时针
- 三角形 3: (4, 3, 5) → 顺时针
规律:
- 偶数三角形:(n, n+1, n+2)
- 奇数三角形:(n+2, n+1, n+3) 或等价于 (n+1, n+2, n+3) 然后翻转
这种交替是为了保持所有三角形的正面朝向一致!2.4.4 使用索引缓冲
对于复杂模型,使用索引缓冲可以避免顶点重复:
// 不使用索引:绘制两个共享边的三角形
const vertices = new Float32Array([
// 三角形 1
0.0, 0.5, 0.0,
-0.5, -0.5, 0.0,
0.5, -0.5, 0.0,
// 三角形 2 (有两个顶点重复)
0.0, 0.5, 0.0, // 重复
0.5, -0.5, 0.0, // 重复
0.5, 0.5, 0.0
]);
// 共 6 个顶点,18 个浮点数
// 使用索引
const vertices = new Float32Array([
0.0, 0.5, 0.0, // 顶点 0
-0.5, -0.5, 0.0, // 顶点 1
0.5, -0.5, 0.0, // 顶点 2
0.5, 0.5, 0.0 // 顶点 3
]);
// 只需 4 个顶点,12 个浮点数
const indices = new Uint16Array([
0, 1, 2, // 三角形 1
0, 2, 3 // 三角形 2
]);
// 索引只需 6 个整数,12 字节
// 创建索引缓冲
const indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
// 使用索引绘制
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);2.4.5 面剔除
面剔除(Face Culling)是一种优化技术,跳过不可见的面:
面剔除原理
从观察者的角度看:
观察者 👁️
│
▼
┌─────────┐
│╲ ╱│ ← 正面(可见)
│ ╲ A ╱ │
│ ╲ ╱ │
│ ╲ ╱ │
│ V │
│ ╱ ╲ │
│ ╱ ╲ │
│ ╱ B ╲ │ ← 背面(不可见,被 A 遮挡)
│╱ ╲│
└─────────┘
如果启用背面剔除,三角形 B 不会被渲染
这可以节省约 50% 的渲染工作(对于封闭模型)// 启用面剔除
gl.enable(gl.CULL_FACE);
// 设置要剔除的面
gl.cullFace(gl.BACK); // 剔除背面(默认)
// gl.cullFace(gl.FRONT); // 剔除正面
// gl.cullFace(gl.FRONT_AND_BACK); // 全部剔除(什么都不画)
// 定义正面(顶点绕序)
gl.frontFace(gl.CCW); // 逆时针为正面(默认)
// gl.frontFace(gl.CW); // 顺时针为正面如何确定正面?
从观察者角度看三角形的顶点绕序:
逆时针 (CCW) = 正面 顺时针 (CW) = 背面
1 1
╱ ╲ ╱ ╲
╱ → ╲ 顶点顺序: ╱ ← ╲ 顶点顺序:
╱ ╲ 0 → 1 → 2 ╱ ╲ 0 → 2 → 1
0───────2 0───────2
逆时针旋转 顺时针旋转2.5 光栅化阶段
2.5.1 什么是光栅化?
光栅化(Rasterization) 是将连续的几何图形(三角形、线段、点)转换为离散的**片段(Fragment)**的过程。这是从"矢量"到"位图"的关键转换。
光栅化过程示意
矢量三角形 离散片段
╱╲ ■ ■
╱ ╲ ■ ■ ■ ■
╱ ╲ → ■ ■ ■ ■ ■ ■
╱ ╲ ■ ■ ■ ■ ■ ■ ■ ■
────────── ■ ■ ■ ■ ■ ■ ■ ■ ■ ■
无限精度的 像素级别的
几何描述 离散表示2.5.2 片段 vs 像素
这两个概念经常被混淆,让我们明确区分:
| 概念 | 说明 |
|---|---|
| 片段(Fragment) | 光栅化产生的"候选像素",包含位置、深度、颜色等数据 |
| 像素(Pixel) | 屏幕上的最终显示单元 |
一个像素位置可能对应多个片段(重叠的三角形),经过深度测试和混合后,只有一个片段的颜色会成为最终的像素值。
片段与像素的关系
同一位置可能有多个片段:
片段 A (z=0.3, 红色) ─┐
片段 B (z=0.5, 蓝色) ├─→ 深度测试 → 选择 A → 像素 (红色)
片段 C (z=0.8, 绿色) ─┘
如果启用混合(透明效果):
片段 A + 片段 B → 混合 → 像素 (紫色)2.5.3 属性插值
光栅化的一个关键功能是属性插值。顶点着色器输出的 varying 变量会在三角形内部进行线性插值:
属性插值示例
三角形三个顶点的颜色:
V0: 红色 (1, 0, 0)
V1: 绿色 (0, 1, 0)
V2: 蓝色 (0, 0, 1)
V1 (绿)
╱╲
╱ ╲
╱ ● ╲ ← 这个片段的颜色?
╱ ╲
╱ ╲
V0────────V2
(红) (蓝)
使用重心坐标插值:
假设 ● 的重心坐标是 (α, β, γ) = (0.33, 0.33, 0.34)
颜色 = α × V0颜色 + β × V1颜色 + γ × V2颜色
= 0.33 × (1,0,0) + 0.33 × (0,1,0) + 0.34 × (0,0,1)
= (0.33, 0.33, 0.34)
≈ 灰色(带轻微蓝色调)2.5.4 重心坐标
重心坐标(Barycentric Coordinates) 是描述三角形内部点位置的坐标系统:
重心坐标系
对于三角形 ABC 内的任意点 P:
P = α × A + β × B + γ × C
其中 α + β + γ = 1,且 α, β, γ ≥ 0
B
╱╲
╱ ╲
╱ P ╲
╱ ╲
╱ ╲
A──────────C
P 的重心坐标 (α, β, γ) 表示 P 到三个顶点的"亲近程度":
- α 接近 1:P 靠近 A
- β 接近 1:P 靠近 B
- γ 接近 1:P 靠近 C
- α = β = γ = 1/3:P 在三角形中心2.5.5 透视校正插值
在透视投影中,简单的线性插值会产生错误的结果。WebGL 自动进行透视校正插值:
为什么需要透视校正?
想象一条铁轨延伸向远方:
屏幕上看起来:
╲ ╱ ← 远端(看起来窄)
╲ ╱
╲ ╱
╲ ╱
╲╱ ← 近端(看起来宽)
如果简单线性插值纹理坐标,
铁轨中点的纹理会出现在屏幕中点,
但实际上应该偏向近端!
透视校正插值考虑了深度因素,
确保 3D 空间中均匀分布的纹理
在屏幕上也显示正确。// 在 GLSL 中,varying 默认是透视校正的
varying vec2 v_texCoord; // 自动透视校正
// 如果需要禁用透视校正(WebGL 2.0)
// 使用 flat 或 noperspective 关键字
flat out vec3 v_color; // 不插值,使用第一个顶点的值
noperspective out vec2 v_coord; // 线性插值,不做透视校正2.5.6 光栅化代码示例
// 创建一个彩色三角形,展示颜色插值
const vertexShaderSource = `
attribute vec2 a_position;
attribute vec3 a_color;
varying vec3 v_color;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
v_color = a_color; // 颜色会被光栅化器插值
}
`;
const fragmentShaderSource = `
precision mediump float;
varying vec3 v_color; // 接收插值后的颜色
void main() {
gl_FragColor = vec4(v_color, 1.0);
}
`;
// 顶点数据:位置和颜色交错存储
const vertices = new Float32Array([
// x, y, r, g, b
0.0, 0.5, 1.0, 0.0, 0.0, // 顶点 0:红色
-0.5, -0.5, 0.0, 1.0, 0.0, // 顶点 1:绿色
0.5, -0.5, 0.0, 0.0, 1.0 // 顶点 2:蓝色
]);
// 渲染后,三角形内部会呈现红-绿-蓝的渐变混合2.6 片段处理阶段
2.6.1 片段着色器详解
片段着色器对光栅化产生的每个片段执行一次,决定片段的最终颜色。
片段着色器的输入:
// 1. varying 变量(从顶点着色器插值得到)
varying vec3 v_normal;
varying vec2 v_texCoord;
varying vec3 v_worldPos;
// 2. uniform 变量(全局常量)
uniform vec3 u_lightPos;
uniform vec3 u_lightColor;
uniform sampler2D u_texture;
// 3. 内置变量
// gl_FragCoord.xy: 片段在窗口中的坐标
// gl_FragCoord.z: 深度值
// gl_FrontFacing: 是否是正面片段着色器的输出:
// WebGL 1.0
gl_FragColor = vec4(r, g, b, a); // 单一输出
// WebGL 2.0
out vec4 fragColor; // 使用 out 声明
layout(location = 0) out vec4 outColor; // 多输出(MRT)
layout(location = 1) out vec4 outNormal;2.6.2 纹理采样
纹理采样是片段着色器最常见的操作之一:
precision mediump float;
varying vec2 v_texCoord;
uniform sampler2D u_texture;
void main() {
// texture2D 函数从纹理中采样颜色
// v_texCoord 是纹理坐标,范围通常是 [0, 1]
vec4 texColor = texture2D(u_texture, v_texCoord);
gl_FragColor = texColor;
}纹理坐标系:
纹理坐标 (UV)
(0, 1)────────────(1, 1)
│ │
│ 纹理图像 │
│ │
│ │
(0, 0)────────────(1, 0)
U: 水平方向,0 = 左,1 = 右
V: 垂直方向,0 = 下,1 = 上
注意:V 轴向上,与很多图像格式相反!2.6.3 光照计算
片段着色器可以实现各种光照模型:
// Phong 光照模型
precision mediump float;
varying vec3 v_normal;
varying vec3 v_worldPos;
uniform vec3 u_lightPos;
uniform vec3 u_lightColor;
uniform vec3 u_viewPos;
uniform vec3 u_objectColor;
void main() {
// 环境光
float ambientStrength = 0.1;
vec3 ambient = ambientStrength * u_lightColor;
// 漫反射
vec3 norm = normalize(v_normal);
vec3 lightDir = normalize(u_lightPos - v_worldPos);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = diff * u_lightColor;
// 镜面反射
float specularStrength = 0.5;
vec3 viewDir = normalize(u_viewPos - v_worldPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0);
vec3 specular = specularStrength * spec * u_lightColor;
// 最终颜色
vec3 result = (ambient + diffuse + specular) * u_objectColor;
gl_FragColor = vec4(result, 1.0);
}2.6.4 discard 丢弃片段
片段着色器可以使用 discard 关键字丢弃片段:
precision mediump float;
varying vec2 v_texCoord;
uniform sampler2D u_texture;
uniform float u_alphaThreshold;
void main() {
vec4 texColor = texture2D(u_texture, v_texCoord);
// Alpha 裁切:丢弃透明度低于阈值的片段
if (texColor.a < u_alphaThreshold) {
discard; // 这个片段不会写入帧缓冲
}
gl_FragColor = texColor;
}discard 的应用场景:
1. Alpha 裁切(如树叶、草地纹理)
原始纹理: 渲染结果:
┌──────────┐
│ 🌿 │ → 🌿 (只有叶子部分可见)
│ │
└──────────┘
(包含透明区域)
2. 程序化镂空效果(如网格、栅栏)
3. 条件渲染(如只渲染特定区域)2.6.5 gl_FragCoord 的使用
gl_FragCoord 提供片段在窗口中的坐标:
precision mediump float;
uniform vec2 u_resolution; // 画布尺寸
void main() {
// gl_FragCoord.xy 是窗口坐标(像素)
// gl_FragCoord.z 是深度值 [0, 1]
// gl_FragCoord.w 是 1/w(透视除法的倒数)
// 归一化坐标 [0, 1]
vec2 uv = gl_FragCoord.xy / u_resolution;
// 创建渐变效果
gl_FragColor = vec4(uv.x, uv.y, 0.5, 1.0);
}gl_FragCoord 应用示例:
// 棋盘格图案
void main() {
vec2 pos = floor(gl_FragCoord.xy / 50.0);
float checker = mod(pos.x + pos.y, 2.0);
gl_FragColor = vec4(vec3(checker), 1.0);
}
// 径向渐变
void main() {
vec2 center = u_resolution / 2.0;
float dist = distance(gl_FragCoord.xy, center);
float radius = min(u_resolution.x, u_resolution.y) / 2.0;
float t = clamp(dist / radius, 0.0, 1.0);
gl_FragColor = vec4(vec3(1.0 - t), 1.0);
}2.7 帧缓冲输出阶段
2.7.1 逐片段操作概述
片段着色器执行后,片段还要经过一系列测试才能写入帧缓冲:
逐片段操作流程
片段着色器输出
│
▼
┌─────────────┐
│ 像素所有权 │ ← 检查像素是否属于当前 WebGL 上下文
│ 测试 │
└──────┬──────┘
│
▼
┌─────────────┐
│ 剪切测试 │ ← 检查是否在 gl.scissor 定义的区域内
│ (Scissor) │
└──────┬──────┘
│
▼
┌─────────────┐
│ 模板测试 │ ← 与模板缓冲比较
│ (Stencil) │
└──────┬──────┘
│
▼
┌─────────────┐
│ 深度测试 │ ← 与深度缓冲比较
│ (Depth) │
└──────┬──────┘
│
▼
┌─────────────┐
│ 混合 │ ← 与现有颜色混合(透明效果)
│ (Blending) │
└──────┬──────┘
│
▼
┌─────────────┐
│ 抖动 │ ← 可选的颜色抖动
│ (Dithering) │
└──────┬──────┘
│
▼
写入帧缓冲2.7.2 深度测试详解
深度测试是处理 3D 场景遮挡关系的关键:
// 启用深度测试
gl.enable(gl.DEPTH_TEST);
// 设置深度函数
gl.depthFunc(gl.LESS); // 默认:深度更小的通过
// 深度函数选项:
gl.NEVER // 永不通过
gl.LESS // < 新深度小于旧深度时通过
gl.EQUAL // == 相等时通过
gl.LEQUAL // <= 小于等于时通过
gl.GREATER // > 大于时通过
gl.NOTEQUAL // != 不等时通过
gl.GEQUAL // >= 大于等于时通过
gl.ALWAYS // 总是通过
// 设置深度写入
gl.depthMask(true); // 允许写入深度(默认)
gl.depthMask(false); // 禁止写入深度深度测试工作原理:
深度测试示意
场景中有两个重叠的三角形:
- 红色三角形:深度 0.3
- 蓝色三角形:深度 0.6
观察者 👁️
│
│ 近
│
┌────┼────┐ z = 0.3 (红色)
│ │ │
│ │ │
└────┼────┘
│
┌────┼────┐ z = 0.6 (蓝色)
│ │ │
│ │ │
└────┼────┘
│
│ 远
使用 gl.LESS 深度函数:
1. 清空深度缓冲,所有值设为 1.0
2. 绘制红色三角形:0.3 < 1.0,通过,深度更新为 0.3
3. 绘制蓝色三角形:0.6 < 0.3? 不通过,被丢弃
结果:只有红色三角形可见(正确的遮挡关系)2.7.3 模板测试详解
模板测试使用模板缓冲进行像素级的条件渲染:
// 启用模板测试
gl.enable(gl.STENCIL_TEST);
// 设置模板函数
gl.stencilFunc(func, ref, mask);
// func: 比较函数(与深度函数类似)
// ref: 参考值
// mask: 掩码,会与 ref 和缓冲值都做 AND 运算
// 设置模板操作
gl.stencilOp(fail, zfail, zpass);
// fail: 模板测试失败时的操作
// zfail: 模板通过但深度失败时的操作
// zpass: 两者都通过时的操作
// 操作选项:
gl.KEEP // 保持不变
gl.ZERO // 设为 0
gl.REPLACE // 设为 ref 值
gl.INCR // 增加 1(饱和)
gl.INCR_WRAP // 增加 1(溢出绕回)
gl.DECR // 减少 1(饱和)
gl.DECR_WRAP // 减少 1(溢出绕回)
gl.INVERT // 按位取反模板测试应用示例:镜面反射
// 步骤 1:绘制镜子区域到模板缓冲
gl.enable(gl.STENCIL_TEST);
gl.stencilFunc(gl.ALWAYS, 1, 0xFF); // 总是通过
gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE); // 写入 1
gl.colorMask(false, false, false, false); // 不写颜色
gl.depthMask(false); // 不写深度
drawMirror(); // 绘制镜子形状
// 步骤 2:只在模板值为 1 的地方绘制反射内容
gl.stencilFunc(gl.EQUAL, 1, 0xFF); // 只有 1 通过
gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP); // 不修改模板
gl.colorMask(true, true, true, true); // 恢复颜色写入
gl.depthMask(true); // 恢复深度写入
drawReflectedScene(); // 绘制翻转的场景
// 步骤 3:正常绘制场景
gl.disable(gl.STENCIL_TEST);
drawScene();2.7.4 混合详解
混合用于实现透明效果:
// 启用混合
gl.enable(gl.BLEND);
// 设置混合函数
gl.blendFunc(sfactor, dfactor);
// sfactor: 源因子(新片段的系数)
// dfactor: 目标因子(帧缓冲现有颜色的系数)
// 混合方程:
// result = src * sfactor + dst * dfactor
// 常用因子:
gl.ZERO // 0
gl.ONE // 1
gl.SRC_COLOR // 源颜色
gl.ONE_MINUS_SRC_COLOR // 1 - 源颜色
gl.DST_COLOR // 目标颜色
gl.ONE_MINUS_DST_COLOR // 1 - 目标颜色
gl.SRC_ALPHA // 源 alpha
gl.ONE_MINUS_SRC_ALPHA // 1 - 源 alpha
gl.DST_ALPHA // 目标 alpha
gl.ONE_MINUS_DST_ALPHA // 1 - 目标 alpha
gl.CONSTANT_COLOR // 常量颜色
gl.CONSTANT_ALPHA // 常量 alpha常用混合模式:
// 标准 Alpha 混合(最常用)
// result = src * src.a + dst * (1 - src.a)
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
// 加法混合(发光效果)
// result = src + dst
gl.blendFunc(gl.ONE, gl.ONE);
// 乘法混合(暗化效果)
// result = src * dst
gl.blendFunc(gl.DST_COLOR, gl.ZERO);
// 预乘 Alpha
// 假设 src.rgb 已经乘以 src.a
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);透明物体的绘制顺序:
透明物体渲染的正确顺序
场景:
观察者 👁️
│
▼
┌───────┐ z=0.3, alpha=0.5 (红色玻璃)
└───────┘
│
▼
┌───────┐ z=0.6, alpha=0.5 (蓝色玻璃)
└───────┘
│
▼
█████████ z=0.9 (不透明的墙)
正确的绘制顺序(从后到前):
1. 先绘制不透明物体(墙)
2. 关闭深度写入 gl.depthMask(false)
3. 从后到前绘制透明物体:
- 先蓝色玻璃 (z=0.6)
- 后红色玻璃 (z=0.3)
4. 恢复深度写入 gl.depthMask(true)2.8 完整渲染示例
让我们创建一个展示管线各阶段的完整示例:
/**
* 渲染管线完整示例
* 绘制一个带颜色插值和深度测试的 3D 场景
*/
// 顶点着色器
const vertexShaderSource = `
attribute vec3 a_position;
attribute vec3 a_color;
uniform mat4 u_mvpMatrix;
varying vec3 v_color;
void main() {
gl_Position = u_mvpMatrix * vec4(a_position, 1.0);
v_color = a_color;
}
`;
// 片段着色器
const fragmentShaderSource = `
precision mediump float;
varying vec3 v_color;
void main() {
gl_FragColor = vec4(v_color, 1.0);
}
`;
function main() {
// 初始化
const canvas = document.getElementById('glCanvas');
canvas.width = 800;
canvas.height = 600;
const gl = canvas.getContext('webgl2');
if (!gl) {
alert('WebGL 2 不可用');
return;
}
// 创建着色器程序
const program = createProgram(gl, vertexShaderSource, fragmentShaderSource);
// 创建两个三角形(不同深度)
const vertices = new Float32Array([
// 三角形 1(近处,红黄橙)
// 位置 (x, y, z) 颜色 (r, g, b)
0.0, 0.8, -0.3, 1.0, 0.0, 0.0,
-0.8, -0.4, -0.3, 1.0, 1.0, 0.0,
0.8, -0.4, -0.3, 1.0, 0.5, 0.0,
// 三角形 2(远处,蓝绿青)
0.0, 0.4, -0.7, 0.0, 0.0, 1.0,
-0.4, -0.8, -0.7, 0.0, 1.0, 0.0,
0.4, -0.8, -0.7, 0.0, 1.0, 1.0,
]);
// 创建缓冲区
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
// 设置属性
const FSIZE = vertices.BYTES_PER_ELEMENT;
const stride = FSIZE * 6;
const positionLoc = gl.getAttribLocation(program, 'a_position');
gl.enableVertexAttribArray(positionLoc);
gl.vertexAttribPointer(positionLoc, 3, gl.FLOAT, false, stride, 0);
const colorLoc = gl.getAttribLocation(program, 'a_color');
gl.enableVertexAttribArray(colorLoc);
gl.vertexAttribPointer(colorLoc, 3, gl.FLOAT, false, stride, FSIZE * 3);
// 获取 uniform 位置
const mvpMatrixLoc = gl.getUniformLocation(program, 'u_mvpMatrix');
// 设置渲染状态
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0.1, 0.1, 0.15, 1.0);
gl.clearDepth(1.0);
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LESS);
// 创建 MVP 矩阵(使用 gl-matrix 库)
const mvpMatrix = mat4.create();
const projMatrix = mat4.create();
const viewMatrix = mat4.create();
mat4.perspective(projMatrix, Math.PI / 4, canvas.width / canvas.height, 0.1, 100);
mat4.lookAt(viewMatrix, [0, 0, 2], [0, 0, 0], [0, 1, 0]);
mat4.multiply(mvpMatrix, projMatrix, viewMatrix);
// 渲染
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.useProgram(program);
gl.uniformMatrix4fv(mvpMatrixLoc, false, mvpMatrix);
// 绘制两个三角形
gl.drawArrays(gl.TRIANGLES, 0, 6);
console.log('渲染完成');
}
main();2.9 本章小结
渲染管线阶段总结
| 阶段 | 类型 | 输入 | 输出 | 作用 |
|---|---|---|---|---|
| 顶点着色器 | 可编程 | 顶点属性 | 裁剪坐标 | 变换位置 |
| 图元装配 | 固定 | 顶点 | 图元 | 组装几何体 |
| 裁剪 | 固定 | 图元 | 可见图元 | 剔除不可见部分 |
| 光栅化 | 固定 | 图元 | 片段 | 生成像素候选 |
| 片段着色器 | 可编程 | 片段数据 | 颜色 | 计算颜色 |
| 深度测试 | 固定 | 深度值 | 通过/丢弃 | 处理遮挡 |
| 混合 | 固定 | 颜色 | 最终颜色 | 处理透明 |
关键概念
| 概念 | 说明 |
|---|---|
| NDC | 标准化设备坐标,范围 [-1, 1] |
| 齐次坐标 | 4D 向量,支持透视投影 |
| 重心坐标 | 三角形内插值的基础 |
| 片段 | 像素候选,包含颜色和深度 |
| 深度缓冲 | 存储每像素的深度值 |
| 模板缓冲 | 像素级条件渲染 |
| 混合 | 透明效果的实现方式 |
2.10 练习题
基础练习
颜色插值:创建一个三角形,三个顶点分别是红、绿、蓝色,观察插值效果
多图元:使用不同的图元类型(POINTS, LINES, LINE_STRIP)绑制相同的顶点数据
深度测试:创建两个重叠的三角形,验证深度测试的效果
进阶练习
面剔除:创建一个旋转的三角形,启用面剔除,观察正反面的显示
Alpha 混合:创建半透明的重叠三角形,验证不同的混合模式
挑战练习
- 模板镂空:使用模板测试创建一个"窗口"效果,只在特定区域显示内容
下一章预告:在第3章中,我们将深入学习 GLSL 着色器语言,掌握 GPU 编程的核心技能。
文档版本:v1.0
字数统计:约 16,000 字
代码示例:35+ 个
