Draw Call 只交代“用什么数据画”;GPU 还要读取并复用顶点、按拓扑装配图元、在齐次裁剪空间切割边界、完成透视除法和视口变换,最后在规则采样位置上判断三角形覆盖并生成片元。
我追问的链
drawIndexed(6)是让顶点着色器执行 6 次,还是只处理 4 个不同顶点?- 索引只是编号,GPU 怎么知道每三个编号要组成一个三角形?
- 相同三个顶点交换顺序后,为什么三角形可能消失?
- 三角形一部分在视锥外时,是整个丢弃还是沿边界切开?
- 裁剪为什么必须在透视除法之前?
- NDC 的
[-1, 1]怎样映射到实际屏幕? - 三角形只擦过一个像素角落,为什么这个像素可能不产生片元?
- GPU 怎样判断采样点在三角形内部?
- 公共边上的采样点归哪个三角形,怎样避免裂缝和重复覆盖?
- 生成片元是不是等于最终写入像素?
1. 从一条具体绘制命令开始
用四个顶点组成一个矩形:
1 | V0 ───────── V1 |
顶点 Buffer 保存四份完整属性,索引 Buffer 是:
1 | [0, 2, 1, 1, 2, 3] |
当前图元拓扑设为 Triangle List,CPU 记录:
1 | drawIndexed(6) |
从这一刻开始,可以沿着 GPU 的逻辑管线往下追:
1 | 读取索引 |
2. drawIndexed(6) 不等于顶点着色器一定执行 6 次
六个索引产生六次顶点引用:
1 | 0、2、1、1、2、3 |
但不同顶点只有:
1 | 0、1、2、3 |
如果完全不复用,GPU 可以按引用执行:
1 | VS(0)、VS(2)、VS(1)、VS(1)、VS(2)、VS(3) |
顶点 1、2 被重复计算。可是在同一次绘制中,相同索引对应相同 Attribute,并使用相同的当前 Shader 与参数,因此结果相同。
GPU 通常使用可以通俗理解为变换后顶点缓存的结构:
1 | 第一次遇到索引 1 |
所以这个小矩形理想情况下是:
1 | 6 个索引引用 |
缓存的是什么
缓存的是顶点着色器执行后的结果,例如:
1 | 裁剪空间位置 |
“变换后”不只表示乘过模型矩阵,而是泛指顶点着色器已经处理完成。
为什么只能说“理想情况下”
缓存容量有限。如果同一个索引两次出现之间夹着大量其他顶点,旧结果可能已经被淘汰,第二次仍需重新执行。
因此关系通常是:
1 | 顶点引用数 |
不用索引而直接提交六份顶点记录时,即使两份记录数值完全相同,GPU 也没有“它们是同一个编号”的复用关系,通常仍按六个顶点处理。
3. 图元拓扑告诉 GPU 怎样连接顶点
只有索引流:
1 | 0, 2, 1, 1, 2, 3 |
还不能判断它代表点、线还是三角形。绘制状态还要包含 Primitive Topology(图元拓扑)。
常见拓扑包括:
1 | Point List 点列表 |
当拓扑为 Triangle List 时,每三个引用组成一个三角形:
1 | [0, 2, 1] → 三角形 A |
所以 Triangle List 中:
1 | 索引数量 ÷ 3 = 三角形数量 |
负责这次连接的是 Primitive Assembly(图元装配),不是顶点着色器。顶点着色器通常一次只知道当前顶点的输入和公共参数,并不知道另外两个顶点是谁。
1 | 顶点着色器输出 |
顶点位置像城市位置,索引像道路连接。城市不动,换一种连法也会得到完全不同的网络。
Triangle Strip 为什么索引更少
三角形条带让相邻图元复用前两个引用:
1 | 0, 2, 1, 3 |
可表达两个三角形。后续每增加一个顶点引用,就增加一个三角形;为维持一致绕序,组装规则还会交替调整顶点解释顺序。
Triangle List 虽然索引更多,却更容易独立组织、拆分和批处理,所以现代渲染中仍非常常见。
4. 索引顺序决定三角形的正反面
下面两组索引引用相同三个顶点:
1 | 0, 2, 1 |
但遍历方向相反:一个顺时针,一个逆时针。这叫 Winding Order(绕序)。
图形 API 会结合约定和当前状态,把一种绕序视为正面,另一种视为背面。开启 Back-face Culling(背面剔除) 后,指定朝向的三角形不会进入后续光栅化。
所以交换两个索引:
1 | 0, 2, 1 |
虽然覆盖的几何区域相同,却反转了正反面,三角形可能因此消失。
背面剔除的意义是减少封闭物体上注定看不到的表面工作。但双面材质、薄片、某些 UI 或透明对象可能关闭剔除,或选择不同剔除模式。
5. 裁剪不是把跨界三角形整个丢掉
图元装配后,顶点着色器已经为每个顶点输出裁剪空间位置:
1 | p_clip = (x, y, z, w) |
相机的视锥由左、右、上、下、近、远六个边界围成。经过投影后,常见裁剪条件类似:
1 | -w ≤ x ≤ w |
某些 API 的深度范围使用 0 ≤ z ≤ w。具体 z 约定不同,但核心相同:在齐次裁剪空间里,通过坐标分量与 w 比较判断边界内外。
三角形有三种基本情况:
1 | 完全在内 → 原样保留 |
不能只检查“有没有顶点在屏幕里”。一个巨大三角形的三个顶点都可能在屏幕外,但它仍覆盖整个视口。
6. 裁剪怎样生成新顶点
先看线段 A → B,A 在边界内,B 在边界外:
1 | A ●────────────×────────● B |
边上任意点可以写成:
1 | P(t) = A + t(B - A) |
其中 t=0 是 A,t=1 是 B。求出满足裁剪面方程的 t,即可得到交点 I。
例如二维右边界 x=1:
1 | A = (0.5, 0.2) |
由:
1 | 0.5 + t(1.5 - 0.5) = 1 |
得到 t=0.5,因此:
1 | I = (1, 0.5) |
新顶点不只有位置
裁剪交点还必须生成完整的顶点输出:
1 | 裁剪空间位置 |
若交点位于边的 50%:
1 | I.uv = A.uv + 0.5(B.uv - A.uv) |
这里是沿原图元边插值顶点着色器输出,而不是为交点重新执行一次顶点着色器。
一个三角形可能变成两个
若 A、B 在内,C 在外,边 A-C、B-C 分别产生交点 I1、I2:
1 | C 外 |
保留下来的是四边形 A-B-I2-I1,后续需要重新拆成两个三角形。
| 原三角形状态 | 结果 |
|---|---|
| 3 个顶点都在内 | 保留原三角形 |
| 3 个都在同一边界外 | 整体丢弃 |
| 1 个在内、2 个在外 | 产生 2 个交点,保留 1 个三角形 |
| 2 个在内、1 个在外 | 产生 2 个交点,得到四边形并拆成 2 个三角形 |
7. 为什么必须先裁剪,再透视除法
透视除法是:
1 | x_ndc = x_clip / w_clip |
NDC 水平边界 -1 ≤ x_ndc ≤ 1,在裁剪空间中对应:
1 | -w_clip ≤ x_clip ≤ w_clip |
所以 ±w 边界正是透视除法后 ±1 的齐次表达。
关键在于:除以 w 不是线性变换。
例如只看 (x,w):
1 | A = (0, 1) |
先取裁剪空间中点再除:
1 | M_clip = (1, 2.5) |
先分别除再取屏幕中点:
1 | A_ndc.x = 0 |
结果 0.4 ≠ 0.25。透视除法改变了沿线段的参数分布;如果先除再按普通线性关系裁剪,交点和属性可能错误。
更麻烦的是边穿过 w=0 附近时,x/w 可能趋向无穷、翻转或失去意义。因此正确顺序是:
1 | 顶点着色器输出 (x,y,z,w) |
这也是透视相机的近裁剪面不能设为 0 的原因之一;近裁剪面还会影响深度精度,后续讲深度缓冲时再展开。
8. 视口变换把 NDC 映射到实际屏幕
NDC 将投影结果归一化到固定范围,Viewport(视口)再把它映射到实际渲染目标的一块矩形。
水平方向:
1 | x_screen = viewportX |
若视口宽度为 800:
x_ndc |
x_screen |
|---|---|
| -1 | 0 |
| 0 | 400 |
| 1 | 800 |
垂直方向同理,但 y 轴向上或向下、原点位于左下还是左上,取决于 API、渲染目标和引擎约定,不应把某一套符号当作全部平台的定义。
例如 800 × 600 视口中的三个 NDC 顶点:
1 | A = (-0.5, -0.5) → (200, 150) |
屏幕顶点仍是连续坐标,可以是 (213.27, 148.61),而不是先四舍五入到某个像素。
Viewport 也不必覆盖整个窗口。分屏、小地图和编辑器多视图,本质上都可以让同一 NDC 范围映射到不同视口矩形。
9. 光栅化判断的是采样位置,不是像素方格
从存储角度,一个像素是颜色缓冲中的一个位置;从几何覆盖角度,GPU 要测试像素内部一个或多个规则采样位置。
单采样时可以先近似理解为像素中心:
1 | 像素 (0,0) → 采样点 (0.5, 0.5) |
因此:
1 | 三角形碰到像素方格 |
斜边可能只擦过像素一角,却没有覆盖中心采样点,这个像素就不产生覆盖样本。连续边缘被离散采样后呈阶梯状,这正是锯齿的来源之一。
若开启 4× MSAA,一个像素内可有四个覆盖采样位置。边缘只覆盖其中两个时,可以表达为部分覆盖,最终解析时获得更平滑的边缘。
10. GPU 不需要检查全屏所有像素
GPU 先计算三角形的轴对齐包围区域:
1 | minX、maxX |
只有包围区域里的采样位置才可能被覆盖。逻辑上可写成:
1 | 计算三角形屏幕包围区域 |
真实 GPU 会以 Tile、Block 或 Quad 等小块高度并行处理,并进行整块在内、整块在外的快速判断,不是像普通 CPU 双层循环那样逐点串行测试。
11. 边函数怎样判断点在三角形内部
对有方向的边 A → B 和测试点 P,可构造二维边函数:
1 | E(A,B,P) |
它本质是二维叉积或带方向面积:
1 | E > 0 → P 在边的一侧 |
具体哪个符号代表内部,取决于绕序、坐标方向和公式写法。真正重要的是:对于绕序一致的三角形,内部始终位于三条有向边约定的同一侧。
1 | 测试 E(A,B,P) |
可以把三条边看成三面有方向的围墙。测试点必须同时站在三面墙的“里面”。
包围盒只能快速排除明显不可能的位置;真正的三角形覆盖仍由边函数一类规则确定。
12. 公共边为什么不会裂开或重复覆盖
相邻两个三角形共享一条边。若采样点恰好落在边上,边函数为 0:
1 | 两个三角形都不认 → 产生裂缝 |
GPU 使用一致的边界归属规则,常被概括为 Top-left Rule(顶边与左边规则)。它根据边的方向和类别,让公共边上的采样点稳定地只归一个三角形。
这是一种人为约定,但一致约定让独立光栅化的相邻三角形能够无缝拼接。
13. 覆盖通过后,片元属性从哪里来
覆盖测试不仅回答“点是否在内部”,还可以得到 P 在三角形中的三个重心权重:
1 | α、β、γ |
于是顶点输出可以插值到采样位置:
1 | P.color = αA.color + βB.color + γC.color |
边函数的带方向面积与这些重心权重紧密相关。因此光栅化阶段实际连接了三个问题:
1 | 采样点是否被覆盖? |
但这里还留下一个关键坑:经历透视投影后,直接用屏幕空间重心权重线性插值 UV 会产生纹理扭曲。下一节将推导为什么必须进行透视正确插值。
14. 片元不等于最终像素
覆盖测试通过后产生的是 Fragment(片元候选),其中可能包含:
1 | 屏幕位置 |
它还可能因为以下原因无法写入颜色缓冲:
1 | 深度测试失败 |
同一个像素还可能先后收到地面、墙、角色、粒子等多个图元产生的片元。
所以要严格区分:
1 | 像素:帧缓冲中的存储位置 |
阶段锚点
本阶段已经把 CPU 提交后的链路推进到片元生成:
1 | drawIndexed |
当前最重要的闭环是:
GPU 不是把三角形“画成一张图片”,而是先保留顶点之间的拓扑关系,再把有效图元转换到屏幕连续坐标,最后在规则采样位置上做几何覆盖判断。片元是覆盖成立后的候选记录,不是最终像素。
下一步继续追问:
为什么屏幕上的重心坐标不能直接线性插值 UV?已经做过透视除法以后,GPU 怎样利用每个顶点原来的
w恢复正确的纹理透视?
关联
- 承接 CPU 怎样组织数据并命令 GPU 绘制:上一课提交的索引、顶点 Buffer、Topology 和 Draw Call 在这里开始被消费。
- 回看 顶点、三角形与插值:这一章把覆盖判断、边函数和插值正式放回 GPU 管线。
- 回看 坐标系与矩阵变换:裁剪空间、
w、透视除法、NDC 和视口变换在这里成为实际的先后步骤。
来源:与 Codex 的对话,2026-08。本章进行中。