返回知识库

渲染

顶点与图元处理

顶点与图元处理 封面
WebGPU/WebGLShaderGLSL/WGSL渲染管线图形学

直觉问题:GPU 如何”看到”3D 世界?

想象你站在相机前,拍一张照片。你知道照片里是二维的,但现实世界是三维的。照片是怎么把三维世界”压扁”成二维图像的?更神奇的是,GPU 要每秒处理数百万个三角形,每个三角形只有 3 个顶点,它是如何知道每个像素该画什么颜色的?

这就涉及到 顶点与图元处理——GPU 渲染管线的”几何阶段”,负责把抽象的 3D 数据转换成屏幕可显示的二维图元。

NOTE

本篇聚焦于 Vertex Shader 到光栅化之前 的几何处理流程,包括顶点变换、图元装配、裁剪、透视除法、视口变换等关键环节。

核心概念:从 3D 到 2D 的坐标变换

GPU 处理 3D 渲染的核心思路是:把空间中的点从一个坐标系变换到另一个坐标系。每个顶点通常包含位置信息(vec3vec4),GPU 会通过矩阵变换把它从”世界空间”转换到”屏幕空间”。

坐标系变换链

flowchart LR
    A[对象空间<br>Object Space] --> B[世界空间<br>World Space] 
    B --> C[观察空间<br>View/Camera Space]
    C --> D[裁剪空间<br>Clip Space]
    D --> E[归一化设备坐标<br>NDC Space]
    E --> F[屏幕空间<br>Screen Space]
    
    style A fill:#e3f2fd,stroke:#2196f3
    style B fill:#bbdefb,stroke:#2196f3  
    style C fill:#90caf9,stroke:#2196f3
    style D fill:#64b5f6,stroke:#2196f3
    style E fill:#42a5f5,stroke:#2196f3
    style F fill:#2196f6,stroke:#0d47a1

每个空间的作用

空间名称坐标范围变换方式WebGPU vs WebGL 差异
对象空间Object Space局部坐标(任意)模型矩阵 MM无差异
世界空间World Space全局坐标(任意)模型矩阵 MM无差异
观察空间View Space相机为中心,zz 轴方向相反视图矩阵 VVWebGL 右手系,WebGPU 左手系
裁剪空间Clip Spacewxyzw-w \le xyz \le w投影矩阵 PPWebGL z 范围 [1,1][-1, 1],WebGPU z 范围 [0,1][0, 1]
NDC 空间Normalized Device CoordinatesWebGL xyz[1,1]xyz \in [-1, 1];WebGPU z[0,1]z \in [0, 1]透视除法 x/w,y/w,z/wx/w, y/w, z/wz 范围不同
屏幕空间Screen Spacex[0,width],y[0,height]x \in [0, width], y \in [0, height]视口变换无差异

顶点着色器(Vertex Shader)

Vertex Shader 是管线中第一个可编程阶段,它对每个顶点执行一次,主要职责是:

  1. 坐标变换:将顶点位置从对象空间变换到裁剪空间(gl_Position / @builtin(position)
  2. 逐顶点数据传递:计算法线、纹理坐标、颜色等逐顶点属性
  3. 逐顶点计算:顶点动画、变形、顶点光照计算(Phong 模型)

TIP

Vertex Shader 的执行次数 = 输入顶点数量。比如一个 1000 个顶点的模型,Vertex Shader 会被调用 1000 次,每次处理一个顶点。

图元装配(Primitive Assembly)

在 Vertex Shader 处理完所有顶点后,GPU 会根据绘图命令(如 glDrawArrays / draw)将顶点组装成图元:

  • (GL_POINTS):每个顶点渲染为一个点
  • 线(GL_LINES):每两个顶点组成一条线段
  • 三角形(GL_TRIANGLES):每三个顶点组成一个三角形(最常用)

裁剪(Clipping)

在透视投影下,视锥体外的顶点需要被裁剪掉。裁剪空间中的顶点若满足以下条件,则保留: wxw,wyw,wzw-w \leq x \leq w, \quad -w \leq y \leq w, \quad -w \leq z \leq w

如果三角形部分在视锥体外,GPU 会生成新的顶点(三角形细分)来确保裁剪后的图元正确。

透视除法(Perspective Division)

这是从 裁剪空间NDC 空间 的关键一步: xndc=xw,yndc=yw,zndc=zwx_{ndc} = \frac{x}{w}, \quad y_{ndc} = \frac{y}{w}, \quad z_{ndc} = \frac{z}{w}

为什么需要除以 w 这模拟了透视效果——离相机越远的物体看起来越小。透视投影矩阵会设置 w=zvieww = -z_{view},所以除以 ww 相当于除以深度值,产生”近大远小”的效果。

视口变换(Viewport Transform)

最后,NDC 坐标被映射到屏幕像素坐标: xscreen=xndc+12×widthx_{screen} = \frac{x_{ndc} + 1}{2} \times width yscreen=1yndc2×heighty_{screen} = \frac{1 - y_{ndc}}{2} \times height zdepth=zndc+12×(fn)+nz_{depth} = \frac{z_{ndc} + 1}{2} \times (f - n) + n

其中 (0,0)(0, 0) 是屏幕左上角,(width,height)(width, height) 是右下角。

数学原理:矩阵变换与齐次坐标

MVP 矩阵的数学表达

顶点变换的完整公式: vclip=P×V×M×vobjectv_{clip} = P \times V \times M \times v_{object}

其中:

  • MM:模型矩阵(Model Matrix),从对象空间到世界空间
  • VV:视图矩阵(View Matrix),从世界空间到观察空间
  • PP:投影矩阵(Projection Matrix),从观察空间到裁剪空间

齐次坐标(Homogeneous Coordinates)

为什么顶点坐标是 vec4 而不是 vec3?因为齐次坐标可以统一表示平移、旋转、缩放等仿射变换,并且可以表示无穷远点(w=0w = 0)。

齐次坐标性质

  • (x,y,z,1)(x, y, z, 1):表示有限点
  • (x,y,z,0)(x, y, z, 0):表示方向向量(无穷远点)
  • (x,y,z,w)(x, y, z, w):透视除法后得到 (x/w,y/w,z/w,1)(x/w, y/w, z/w, 1)

透视投影矩阵

透视投影矩阵将观察空间的视锥体映射到 NDC 立方体:

P=[faspectn0000f0000(f+n)fn2fnfn0010]P = \begin{bmatrix} \frac{f}{aspect \cdot n} & 0 & 0 & 0 \\ 0 & f & 0 & 0 \\ 0 & 0 & \frac{-(f + n)}{f - n} & \frac{-2fn}{f - n} \\ 0 & 0 & -1 & 0 \end{bmatrix}

其中 nn 是近裁剪面距离,ff 是远裁剪面距离,aspect=widthheightaspect = \frac{width}{height}

WARNING

不同 API 的 NDC z 范围不同!WebGL/GLSL 的 NDC z 范围是 [1,1][-1, 1],而 Vulkan/WebGPU 的 NDC z 范围是 [0,1][0, 1](更符合硬件实现)。

GLSL vs WGSL 代码对照

基础 Vertex Shader 对比

GLSL (WebGL 2.0)

#version 300 es
precision mediump float;

layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
layout(location = 2) in vec2 a_texcoord;

uniform mat4 u_modelMatrix;
uniform mat4 u_viewMatrix;
uniform mat4 u_projectionMatrix;

out vec3 v_normal;
out vec2 v_texcoord;

void main() {
    // 顶点位置变换:Model → View → Clip
    mat4 mvpMatrix = u_projectionMatrix * u_viewMatrix * u_modelMatrix;
    gl_Position = mvpMatrix * vec4(a_position, 1.0);
    
    // 逐顶点数据传递
    v_normal = mat3(u_modelMatrix) * a_normal;
    v_texcoord = a_texcoord;
}

WGSL (WebGPU)

struct Uniforms {
    modelMatrix: mat4x4<f32>,
    viewMatrix: mat4x4<f32>,
    projectionMatrix: mat4x4<f32>,
}

@group(0) @binding(0) var<uniform> uniforms: Uniforms;

struct VertexInput {
    @location(0) position: vec3<f32>,
    @location(1) normal: vec3<f32>,
    @location(2) texcoord: vec2<f32>,
}

struct VertexOutput {
    @builtin(position) clip_position: vec4<f32>,
    @location(0) normal: vec3<f32>,
    @location(1) texcoord: vec2<f32>,
}

@vertex
fn vertex_shader(input: VertexInput) -> VertexOutput {
    var output: VertexOutput;
    
    // 顶点位置变换:Model → View → Clip
    let mvpMatrix = uniforms.projectionMatrix * uniforms.viewMatrix * uniforms.modelMatrix;
    output.clip_position = mvpMatrix * vec4<f32>(input.position, 1.0);
    
    // 逐顶点数据传递
    output.normal = mat3x3<f32>(uniforms.modelMatrix) * input.normal;
    output.texcoord = input.texcoord;
    
    return output;
}

语法差异对比表

概念GLSLWGSL差异说明
内置输出变量gl_Position@builtin(position)WGSL 使用 builtin 属性,GLSL 使用全局变量
输入属性in vec3 a_position + layout(location = 0)@location(0) position: vec3<f32>WGSL 在结构体中统一声明
Uniform 声明uniform mat4 u_matrixvar<uniform> uniforms: UniformsWGSL 使用结构体 + Binding Group
精度修饰符precision mediump floatWGSL 默认 32 位浮点,无精度修饰符
矩阵乘法mat * vecmat * vec语法相同,但 WGSL 矩阵是列主序
函数返回void main()fn main() -> OutputWGSL 使用结构化返回值
向量类型vec3, vec4vec3<f32>, vec4<f32>WGSL 显式指定精度类型

实例:顶点动画效果

GLSL 顶点动画(波浪效果):

#version 300 es
precision mediump float;

layout(location = 0) in vec3 a_position;
uniform float u_time;
uniform mat4 u_mvpMatrix;

out float v_height;

void main() {
    vec3 position = a_position;
    
    // 基于 y 坐标和时间的波浪动画
    float wave = sin(position.y * 5.0 + u_time * 2.0) * 0.1;
    position.x += wave;
    
    gl_Position = u_mvpMatrix * vec4(position, 1.0);
    v_height = wave;
}

WGSL 顶点动画(相同效果):

struct Uniforms {
    time: f32,
    mvpMatrix: mat4x4<f32>,
}

@group(0) @binding(0) var<uniform> uniforms: Uniforms;

struct VertexInput {
    @location(0) position: vec3<f32>,
}

struct VertexOutput {
    @builtin(position) clip_position: vec4<f32>,
    @location(0) height: f32,
}

@vertex
fn vertex_shader(input: VertexInput) -> VertexOutput {
    var output: VertexOutput;
    var position = input.position;
    
    // 基于 y 坐标和时间的波浪动画
    let wave = sin(position.y * 5.0 + uniforms.time * 2.0) * 0.1;
    position.x = position.x + wave;
    
    output.clip_position = uniforms.mvpMatrix * vec4<f32>(position, 1.0);
    output.height = wave;
    
    return output;
}

GPU 分块与分簇(Tiling & Clustering)

现代 GPU 为了提高并行效率,会在屏幕空间上进行分块(Tiling)或在深度空间上进行分簇(Clustering):

graph TD
    A[屏幕空间] --> B[Tile 0: 0-63px]
    A --> C[Tile 1: 64-127px]  
    A --> D[Tile 2: 128-191px]
    A --> E[Tile 3: 192-255px]
    
    style A fill:#f3f9ff,stroke:#2196f3
    style B fill:#e3f2fd,stroke:#2196f3
    style C fill:#bbdefb,stroke:#2196f3
    style D fill:#90caf9,stroke:#2196f3
    style E fill:#64b5f6,stroke:#2196f3

Tiled Rendering(分块渲染):

  • 将屏幕划分为多个小块(如 32×32 像素)
  • 每个 Tile 独立处理,提高缓存命中率
  • 移动 GPU(如 Mali、Adreno)常用此技术

Clustered Rendering(分簇渲染):

  • 在深度空间(Z 轴)上划分层级
  • 结合 Tiling 实现 3D 空间的精细划分
  • 用于延迟渲染、光照计算等场景

NOTE

Tiled/Clustered 渲染是 GPU 硬件优化的核心手段,普通开发者无需手动控制,但理解其原理有助于优化渲染性能。

常见误区与陷阱

1. 坐标系混淆:左手系 vs 右手系

误区:认为所有坐标系都是右手的。

正确理解

  • 观察空间:WebGL/GLSL 是右手系(+Z 指向屏幕外),Vulkan/WebGPU 是左手系(+Z 指向屏幕内)
  • NDC 空间:WebGL/GLSL z 范围是 [1,1][-1, 1],Vulkan/WebGPU z 范围是 [0,1][0, 1]
  • 屏幕空间:通常是左手系(+X 向右,+Y 向下)

建议:使用一致的数学库(如 gl-matrix)处理坐标变换,避免手动计算矩阵。

2. 矩阵乘法顺序错误

误区:认为矩阵乘法是左乘(v×Mv \times M)。

正确理解

  • GLSL/WGSL 都是列主序矩阵,向量和矩阵相乘时向量在前:vclip=P×V×M×vobjectv_{clip} = P \times V \times M \times v_{object}
  • 如果使用数学库,注意其矩阵表示方式(如 Three.js 使用列主序,某些库可能使用行主序)

示例

// 正确的 MVP 变换顺序
mat4 mvpMatrix = projectionMatrix * viewMatrix * modelMatrix;
gl_Position = mvpMatrix * vec4(position, 1.0);

// 错误:矩阵乘法顺序错误
// gl_Position = modelMatrix * viewMatrix * projectionMatrix * vec4(position, 1.0);

3. 忘记除以 w(透视除法)

误区:在 Vertex Shader 中手动除以 wwzz 来模拟透视。

正确理解

  • 透视除法由 GPU 硬件自动完成(在光栅化之前)
  • Vertex Shader 的 gl_Position / @builtin(position) 应该是裁剪空间坐标(w0w \neq 0
  • 不要在 Vertex Shader 中手动除以 ww,除非你需要特定的线性深度值

错误示例

// 错误:不要手动除以 w
vec4 clipPos = mvpMatrix * vec4(position, 1.0);
gl_Position = vec4(clipPos.xyz / clipPos.w, 1.0); // 错误!

4. 法线变换忘记使用逆转置矩阵

误区:直接使用模型矩阵变换法线。

正确理解

  • 法线是方向向量(w=0w = 0),只受旋转和缩放影响,不受平移影响
  • 如果模型矩阵包含非均匀缩放,法线变换需要使用逆转置矩阵(Inverse Transpose Matrix)
  • 公式:ntransformed=normalize((M1)T×noriginal)n_{transformed} = \text{normalize}((M^{-1})^T \times n_{original})

正确做法

// 方法 1:使用 mat3 提取 3x3 部分(无缩放时可用)
vec3 transformedNormal = mat3(modelMatrix) * normal;

// 方法 2:使用逆转置矩阵(有非均匀缩放时)
mat3 normalMatrix = transpose(inverse(mat3(modelMatrix)));
vec3 transformedNormal = normalize(normalMatrix * normal);

5. 忽略 NDC z 范围差异

误区:认为 WebGL 和 WebGPU 的 NDC z 范围相同。

正确理解

  • WebGL/GLSL:NDC z 范围是 [1,1][-1, 1]z=1z = -1 是近裁剪面,z=1z = 1 是远裁剪面
  • WebGPU/WGSL:NDC z 范围是 [0,1][0, 1]z=0z = 0 是近裁剪面,z=1z = 1 是远裁剪面
  • 从 WebGL 迁移到 WebGPU 时,需要调整深度值或投影矩阵

迁移建议

// WebGPU 中调整 WebGL 投影矩阵的 z 范围
let z_range_correction = mat4x4<f32>(
    vec4<f32>(1.0, 0.0, 0.0, 0.0),
    vec4<f32>(0.0, 1.0, 0.0, 0.0),
    vec4<f32>(0.0, 0.0, 0.5, 0.0),  // z 缩放 0.5
    vec4<f32>(0.0, 0.0, 0.5, 1.0)   // z 平移 0.5
);
let corrected_projection = z_range_correction * projectionMatrix;

延伸阅读与自测

权威资料索引

NOTE

以上资料通过 WebSearch 于 2026-01-18 检索。

自测题

  1. 基础概念:为什么顶点着色器的输出变量 gl_Position / @builtin(position) 需要 4 个分量而不是 3 个?齐次坐标的 ww 分量有什么作用?

  2. 数学推导:给定一个透视投影矩阵 PP,请推导如何将观察空间的视锥体 [n,f][n, f] 映射到 NDC 的 [1,1][-1, 1](WebGL)或 [0,1][0, 1](WebGPU)。

  3. 实践应用:在 Vertex Shader 中实现一个”呼吸效果”:让模型沿着法线方向周期性膨胀和收缩,膨胀幅度为 0.1×(1+sin(time))0.1 \times (1 + \sin(time))。请写出 GLSL 和 WGSL 的实现代码。

  4. 性能优化:假设你需要渲染 10,000 个相同的模型(实例化渲染),如何减少 Vertex Shader 的计算量?提示:考虑共享数据和预计算。

  5. 调试技巧:你的三角形渲染在屏幕上看不到,请列出至少 3 个可能的调试步骤。提示:检查坐标空间、矩阵乘法、深度测试。

参考答案(简要)

  1. 齐次坐标ww 分量用于透视除法(x/w,y/w,z/wx/w, y/w, z/w),实现”近大远小”效果。w=1w = 1 表示有限点,w=0w = 0 表示方向向量(无穷远点)。

  2. 透视投影推导:观察空间的 z[n,f]z \in [n, f] 映射到 NDC 的 z[1,1]z \in [-1, 1](WebGL)。公式:zndc=2fnfn1zview+(f+n)fnz_{ndc} = \frac{-2fn}{f - n} \cdot \frac{1}{z_{view}} + \frac{-(f + n)}{f - n}。WebGPU 的 z[0,1]z \in [0, 1] 需要额外平移和缩放。

  3. 呼吸效果

    • GLSL:position = position + normal * (0.1 * (1.0 + sin(u_time)));
    • WGSL:position = position + input.normal * (0.1 * (1.0 + sin(uniforms.time)));
  4. 实例化优化:使用 gl_InstanceID / @builtin(instance_index) 区分实例,将模型矩阵放入 Uniform Buffer 或 Texture 读取,避免逐顶点重复计算。

  5. 调试步骤

    • 检查 gl_Position 是否在裁剪空间范围内(wx,y,zw-w \leq x,y,z \leq w
    • 检查 MVP 矩阵乘法顺序(P×V×MP \times V \times M 而不是 M×V×PM \times V \times P
    • 临时禁用深度测试(gl.disable(gl.DEPTH_TEST))确认是否被遮挡
    • 使用 RenderDoc 或 Spector.js 截取绘制调用,查看中间状态

下一篇04 · 片元与像素处理 —— 光栅化如何将图元转换为像素,以及深度测试、模板测试、多重采样等片元处理技术。