本节摘要:顶点着色器是管线的第一个可编程阶段,职责核心是 MVP 三连乘——模型矩阵、观察矩阵、投影矩阵一次乘完,把顶点从模型空间送进裁剪空间;图元装配把顶点按索引组装成三角形,随后硬件完成透视除法与视口映射。本节用一段完整可运行的着色器代码把第 1、4 章的矩阵收拢落地,并讲顶点属性的输出打包与实例化绘制。它是像素出生前的最后一程"行政手续"。
阅读完本节,你应当能够:
顶点着色器对每个顶点执行一次,输入是顶点属性(位置、法线、UV),输出是裁剪空间坐标与片元阶段需要的插值属性。核心就一次矩阵乘法:
// WGSL 风格顶点着色器(示意) struct VertexOutput { clip_pos: vec4f, // 裁剪空间坐标,内置语义 normal: vec3f, // 世界空间法线,供片元插值 uv: vec2f, // 纹理坐标,供片元插值 } @vertex fn vs_main(in: VertexInput) -> VertexOutput { let world = u_model * vec4f(in.position, 1.0); // 模型 → 世界(1.2/4.1) let view = u_view * world; // 世界 → 相机(4.2 look_at) let clip = u_proj * view; // 相机 → 裁剪(4.3 透视) var out: VertexOutput; out.clip_pos = clip; // 注意:不做除法! out.normal = (u_model * vec4f(in.normal, 0.0)).xyz; // w=0:方向不平移 out.uv = in.uv; return out; }
三个常量矩阵由 CPU 侧每帧算好一次性传入(相机动了只更新 u_view)。有个细节值得放大:着色器只输出裁剪坐标,不做透视除法——除法由固定功能硬件在图元装配之后统一执行。为什么这样分工?因为裁剪(比较 |x|≤w 等)必须在除法之前做(4.3 节的设计),硬件把"装配 → 齐次裁剪 → 透视除法 → 视口映射"做成一整段流水,开发者无权也无需介入。
法线那行同样暗藏 1.2 节的知识:w 分量填 0,让平移自动失效;严格来说非均匀缩放要用逆转置矩阵,引擎会在 CPU 侧把法线矩阵预计算好传入。
图元装配把"三个顶点编号"变成一个真正的三角形:取索引、连顶点、备好供光栅化插值的属性。随后视口映射把 NDC 的 [-1,1] 立方体线性映射到屏幕像素区间:
screen_x = (ndc_x + 1) / 2 × 屏幕宽 + 视口偏移x screen_y = (1 - ndc_y) / 2 × 屏幕高 + 视口偏移y (y 翻转:NDC 上为正,像素向下) depth = (ndc_z + 1) / 2 (深度也压到 0~1 供深度缓冲)
小窗渲染、分屏游戏(左右各半屏)就是靠视口偏移与尺寸实现的,同一批顶点变换一次、装配两次即可。
森林、人群、弹幕——上万个重复网格若逐个提交绘制命令,CPU 命令队列先撑不住。实例化绘制的思路:网格数据提交一次,附一张"每实例属性"缓冲(位置、缩放、颜色差异),顶点着色器按实例编号取用:
import numpy as np def instance_matrix(i, instance_buf): # 每个实例一个小变换(位置+旋转),着色器里按 gl_InstanceIndex 取 return instance_buf[i] # 十万棵树的草稿:网格顶点一份,实例缓冲十万条 trees = 100000 instance_buf = [T(x, z) @ R(angle) @ S(scale) for x, z, angle, scale in tree_layout(trees)] # 绘制调用:一次 draw call,顶点着色器跑 网格顶点数 × 100000 次 # 顶点数不变,但 CPU 侧命令从 100000 条降到 1 条
实例化把 CPU 瓶颈转成 GPU 顶点吞吐,通常净赚。配合 6.3 节会提到的"小三角形片元利用率低"问题,远处树换成公告牌(billboard,两个三角形贴图假装立体)是标准组合拳。
顶点车间的成本按顶点数计价,典型症状与药方:
| 症状 | 根因 | 药方 |
|---|---|---|
| 顶点着色器成为热点 | 骨骼动画每顶点矩阵太多 | 顶点着色器缓存复用、减骨骼影响数 |
| 小三角形满天飞 | 片元利用率低、装配开销大 | 网格 LOD、远处换公告牌 |
| CPU 提交拖帧 | draw call 太多 | 实例化、合批、显存直读网格 |
💡 关键直觉:顶点数与像素数是两本账。一块远处的小石头模型可能有五千顶点却只占 4 个像素——顶点成本照付、像素收益为零,这就是 LOD(细节层次)存在的意义:距离换精度,顶点预算花在刀刃上。
问:三个矩阵为什么不合并成一个传给着色器?
答:可以合并,且引擎常这么做(预乘出 MVP 或 MV)。但拆开传更灵活:法线只要模型矩阵(转世界空间)、阴影计算要世界坐标、某些效果需要单独的观察或投影参数。折中方案是传 MVP 与 M(或 MV)两个,其余派生。多传一个矩阵是 64 字节的常量,代价可以忽略,损失的灵活性才是真代价。
问:顶点着色器里能知道相邻顶点吗?
答:不能,这正是顶点阶段的并行本质:每个顶点独立执行,互相看不见。需要邻居信息的操作(网格平滑、细分)要么在图元阶段(几何/曲面细分着色器,天然有邻接信息),要么预计算进顶点数据。把"无邻居"当作设计约束来想,很多管线的取舍就顺理成章了。
顶点已就位,6.3 节进入最热闹的车间:三角形洪水如何变成一个个等着色的片元。