渲染
顶点与图元处理
直觉问题:GPU 如何”看到”3D 世界?
想象你站在相机前,拍一张照片。你知道照片里是二维的,但现实世界是三维的。照片是怎么把三维世界”压扁”成二维图像的?更神奇的是,GPU 要每秒处理数百万个三角形,每个三角形只有 3 个顶点,它是如何知道每个像素该画什么颜色的?
这就涉及到 顶点与图元处理——GPU 渲染管线的”几何阶段”,负责把抽象的 3D 数据转换成屏幕可显示的二维图元。
NOTE
本篇聚焦于 Vertex Shader 到光栅化之前 的几何处理流程,包括顶点变换、图元装配、裁剪、透视除法、视口变换等关键环节。
核心概念:从 3D 到 2D 的坐标变换
GPU 处理 3D 渲染的核心思路是:把空间中的点从一个坐标系变换到另一个坐标系。每个顶点通常包含位置信息(vec3 或 vec4),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 | 局部坐标(任意) | 模型矩阵 | 无差异 |
| 世界空间 | World Space | 全局坐标(任意) | 模型矩阵 | 无差异 |
| 观察空间 | View Space | 相机为中心, 轴方向相反 | 视图矩阵 | WebGL 右手系,WebGPU 左手系 |
| 裁剪空间 | Clip Space | 投影矩阵 | WebGL z 范围 ,WebGPU z 范围 | |
| NDC 空间 | Normalized Device Coordinates | WebGL ;WebGPU | 透视除法 | z 范围不同 |
| 屏幕空间 | Screen Space | 视口变换 | 无差异 |
顶点着色器(Vertex Shader)
Vertex Shader 是管线中第一个可编程阶段,它对每个顶点执行一次,主要职责是:
- 坐标变换:将顶点位置从对象空间变换到裁剪空间(
gl_Position/@builtin(position)) - 逐顶点数据传递:计算法线、纹理坐标、颜色等逐顶点属性
- 逐顶点计算:顶点动画、变形、顶点光照计算(Phong 模型)
TIP
Vertex Shader 的执行次数 = 输入顶点数量。比如一个 1000 个顶点的模型,Vertex Shader 会被调用 1000 次,每次处理一个顶点。
图元装配(Primitive Assembly)
在 Vertex Shader 处理完所有顶点后,GPU 会根据绘图命令(如 glDrawArrays / draw)将顶点组装成图元:
- 点(GL_POINTS):每个顶点渲染为一个点
- 线(GL_LINES):每两个顶点组成一条线段
- 三角形(GL_TRIANGLES):每三个顶点组成一个三角形(最常用)
裁剪(Clipping)
在透视投影下,视锥体外的顶点需要被裁剪掉。裁剪空间中的顶点若满足以下条件,则保留:
如果三角形部分在视锥体外,GPU 会生成新的顶点(三角形细分)来确保裁剪后的图元正确。
透视除法(Perspective Division)
这是从 裁剪空间 到 NDC 空间 的关键一步:
为什么需要除以 w? 这模拟了透视效果——离相机越远的物体看起来越小。透视投影矩阵会设置 ,所以除以 相当于除以深度值,产生”近大远小”的效果。
视口变换(Viewport Transform)
最后,NDC 坐标被映射到屏幕像素坐标:
其中 是屏幕左上角, 是右下角。
数学原理:矩阵变换与齐次坐标
MVP 矩阵的数学表达
顶点变换的完整公式:
其中:
- :模型矩阵(Model Matrix),从对象空间到世界空间
- :视图矩阵(View Matrix),从世界空间到观察空间
- :投影矩阵(Projection Matrix),从观察空间到裁剪空间
齐次坐标(Homogeneous Coordinates)
为什么顶点坐标是 vec4 而不是 vec3?因为齐次坐标可以统一表示平移、旋转、缩放等仿射变换,并且可以表示无穷远点()。
齐次坐标性质:
- :表示有限点
- :表示方向向量(无穷远点)
- :透视除法后得到
透视投影矩阵
透视投影矩阵将观察空间的视锥体映射到 NDC 立方体:
其中 是近裁剪面距离, 是远裁剪面距离,。
WARNING
不同 API 的 NDC z 范围不同!WebGL/GLSL 的 NDC z 范围是 ,而 Vulkan/WebGPU 的 NDC z 范围是 (更符合硬件实现)。
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;
}
语法差异对比表
| 概念 | GLSL | WGSL | 差异说明 |
|---|---|---|---|
| 内置输出变量 | gl_Position | @builtin(position) | WGSL 使用 builtin 属性,GLSL 使用全局变量 |
| 输入属性 | in vec3 a_position + layout(location = 0) | @location(0) position: vec3<f32> | WGSL 在结构体中统一声明 |
| Uniform 声明 | uniform mat4 u_matrix | var<uniform> uniforms: Uniforms | WGSL 使用结构体 + Binding Group |
| 精度修饰符 | precision mediump float | 无 | WGSL 默认 32 位浮点,无精度修饰符 |
| 矩阵乘法 | mat * vec | mat * vec | 语法相同,但 WGSL 矩阵是列主序 |
| 函数返回 | void main() | fn main() -> Output | WGSL 使用结构化返回值 |
| 向量类型 | vec3, vec4 | vec3<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 范围是 ,Vulkan/WebGPU z 范围是
- 屏幕空间:通常是左手系(+X 向右,+Y 向下)
建议:使用一致的数学库(如 gl-matrix)处理坐标变换,避免手动计算矩阵。
2. 矩阵乘法顺序错误
误区:认为矩阵乘法是左乘()。
正确理解:
- GLSL/WGSL 都是列主序矩阵,向量和矩阵相乘时向量在前:
- 如果使用数学库,注意其矩阵表示方式(如 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 中手动除以 或 来模拟透视。
正确理解:
- 透视除法由 GPU 硬件自动完成(在光栅化之前)
- Vertex Shader 的
gl_Position/@builtin(position)应该是裁剪空间坐标() - 不要在 Vertex Shader 中手动除以 ,除非你需要特定的线性深度值
错误示例:
// 错误:不要手动除以 w
vec4 clipPos = mvpMatrix * vec4(position, 1.0);
gl_Position = vec4(clipPos.xyz / clipPos.w, 1.0); // 错误!
4. 法线变换忘记使用逆转置矩阵
误区:直接使用模型矩阵变换法线。
正确理解:
- 法线是方向向量(),只受旋转和缩放影响,不受平移影响
- 如果模型矩阵包含非均匀缩放,法线变换需要使用逆转置矩阵(Inverse Transpose Matrix)
- 公式:
正确做法:
// 方法 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 范围是 , 是近裁剪面, 是远裁剪面
- WebGPU/WGSL:NDC z 范围是 , 是近裁剪面, 是远裁剪面
- 从 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;
延伸阅读与自测
权威资料索引
- GLSL 规范:The OpenGL Shading Language (Version 4.60)
- WGSL 规范:WebGPU Shading Language (WGSL) Specification
- WebGPU MVP 变换:WebGPU Coordinate Systems
- 透视投影数学:3D Math Primer for Graphics and Game Development, Chapter 5
NOTE
以上资料通过 WebSearch 于 2026-01-18 检索。
自测题
-
基础概念:为什么顶点着色器的输出变量
gl_Position/@builtin(position)需要 4 个分量而不是 3 个?齐次坐标的 分量有什么作用? -
数学推导:给定一个透视投影矩阵 ,请推导如何将观察空间的视锥体 映射到 NDC 的 (WebGL)或 (WebGPU)。
-
实践应用:在 Vertex Shader 中实现一个”呼吸效果”:让模型沿着法线方向周期性膨胀和收缩,膨胀幅度为 。请写出 GLSL 和 WGSL 的实现代码。
-
性能优化:假设你需要渲染 10,000 个相同的模型(实例化渲染),如何减少 Vertex Shader 的计算量?提示:考虑共享数据和预计算。
-
调试技巧:你的三角形渲染在屏幕上看不到,请列出至少 3 个可能的调试步骤。提示:检查坐标空间、矩阵乘法、深度测试。
参考答案(简要)
-
齐次坐标: 分量用于透视除法(),实现”近大远小”效果。 表示有限点, 表示方向向量(无穷远点)。
-
透视投影推导:观察空间的 映射到 NDC 的 (WebGL)。公式:。WebGPU 的 需要额外平移和缩放。
-
呼吸效果:
- GLSL:
position = position + normal * (0.1 * (1.0 + sin(u_time))); - WGSL:
position = position + input.normal * (0.1 * (1.0 + sin(uniforms.time)));
- GLSL:
-
实例化优化:使用
gl_InstanceID/@builtin(instance_index)区分实例,将模型矩阵放入 Uniform Buffer 或 Texture 读取,避免逐顶点重复计算。 -
调试步骤:
- 检查
gl_Position是否在裁剪空间范围内() - 检查 MVP 矩阵乘法顺序( 而不是 )
- 临时禁用深度测试(
gl.disable(gl.DEPTH_TEST))确认是否被遮挡 - 使用 RenderDoc 或 Spector.js 截取绘制调用,查看中间状态
- 检查
下一篇:04 · 片元与像素处理 —— 光栅化如何将图元转换为像素,以及深度测试、模板测试、多重采样等片元处理技术。