Skip to content

第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 │ ...             │
└───────────────────────────────────────────────────┘
javascript
// 分离式布局代码
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 (偏移) = 属性在顶点中的起始位置
javascript
// 交错式布局代码
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 顶点着色器详解

顶点着色器是第一个可编程阶段,它对每个顶点执行一次。

顶点着色器的职责

  1. 坐标变换:将顶点从模型空间变换到裁剪空间
  2. 属性计算:计算需要传递给片段着色器的数据
  3. 逐顶点光照(如果使用 Gouraud 着色)
glsl
// 完整的顶点着色器示例
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 是顶点着色器必须输出的内置变量:

glsl
// 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
    
    图元数量 = 顶点数 - 2

2.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 使用索引缓冲

对于复杂模型,使用索引缓冲可以避免顶点重复:

javascript
// 不使用索引:绘制两个共享边的三角形
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% 的渲染工作(对于封闭模型)
javascript
// 启用面剔除
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 空间中均匀分布的纹理
在屏幕上也显示正确。
javascript
// 在 GLSL 中,varying 默认是透视校正的
varying vec2 v_texCoord;  // 自动透视校正

// 如果需要禁用透视校正(WebGL 2.0)
// 使用 flat 或 noperspective 关键字
flat out vec3 v_color;           // 不插值,使用第一个顶点的值
noperspective out vec2 v_coord;  // 线性插值,不做透视校正

2.5.6 光栅化代码示例

javascript
// 创建一个彩色三角形,展示颜色插值
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 片段着色器详解

片段着色器对光栅化产生的每个片段执行一次,决定片段的最终颜色。

片段着色器的输入

glsl
// 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: 是否是正面

片段着色器的输出

glsl
// 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 纹理采样

纹理采样是片段着色器最常见的操作之一:

glsl
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 光照计算

片段着色器可以实现各种光照模型:

glsl
// 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 关键字丢弃片段:

glsl
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 提供片段在窗口中的坐标:

glsl
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 应用示例

glsl
// 棋盘格图案
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 场景遮挡关系的关键:

javascript
// 启用深度测试
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 模板测试详解

模板测试使用模板缓冲进行像素级的条件渲染:

javascript
// 启用模板测试
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    // 按位取反

模板测试应用示例:镜面反射

javascript
// 步骤 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 混合详解

混合用于实现透明效果:

javascript
// 启用混合
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

常用混合模式

javascript
// 标准 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 完整渲染示例

让我们创建一个展示管线各阶段的完整示例:

javascript
/**
 * 渲染管线完整示例
 * 绘制一个带颜色插值和深度测试的 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 练习题

基础练习

  1. 颜色插值:创建一个三角形,三个顶点分别是红、绿、蓝色,观察插值效果

  2. 多图元:使用不同的图元类型(POINTS, LINES, LINE_STRIP)绑制相同的顶点数据

  3. 深度测试:创建两个重叠的三角形,验证深度测试的效果

进阶练习

  1. 面剔除:创建一个旋转的三角形,启用面剔除,观察正反面的显示

  2. Alpha 混合:创建半透明的重叠三角形,验证不同的混合模式

挑战练习

  1. 模板镂空:使用模板测试创建一个"窗口"效果,只在特定区域显示内容

下一章预告:在第3章中,我们将深入学习 GLSL 着色器语言,掌握 GPU 编程的核心技能。


文档版本:v1.0
字数统计:约 16,000 字
代码示例:35+ 个

如有转载或 CV 的请标注本站原文地址