页面

顶点进入 GPU 后,怎样变成屏幕上的片元

← 返回兔子洞总览 · rendering 主题地图

Draw Call 只交代“用什么数据画”;GPU 还要读取并复用顶点、按拓扑装配图元、在齐次裁剪空间切割边界、完成透视除法和视口变换,最后在规则采样位置上判断三角形覆盖并生成片元。

我追问的链

  • drawIndexed(6) 是让顶点着色器执行 6 次,还是只处理 4 个不同顶点?
  • 索引只是编号,GPU 怎么知道每三个编号要组成一个三角形?
  • 相同三个顶点交换顺序后,为什么三角形可能消失?
  • 三角形一部分在视锥外时,是整个丢弃还是沿边界切开?
  • 裁剪为什么必须在透视除法之前?
  • NDC 的 [-1, 1] 怎样映射到实际屏幕?
  • 三角形只擦过一个像素角落,为什么这个像素可能不产生片元?
  • GPU 怎样判断采样点在三角形内部?
  • 公共边上的采样点归哪个三角形,怎样避免裂缝和重复覆盖?
  • 生成片元是不是等于最终写入像素?

1. 从一条具体绘制命令开始

用四个顶点组成一个矩形:

1
2
3
4
5
V0 ───────── V1
│ /│
│ / │
│ / │
V2 ───────── V3

顶点 Buffer 保存四份完整属性,索引 Buffer 是:

1
[0, 2, 1,  1, 2, 3]

当前图元拓扑设为 Triangle List,CPU 记录:

1
drawIndexed(6)

从这一刻开始,可以沿着 GPU 的逻辑管线往下追:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
读取索引

获取或计算顶点着色器输出

图元装配

剔除与裁剪

透视除法

视口变换

光栅化与属性插值

片元候选

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
2
3
4
5
6
7
8
第一次遇到索引 1
→ 读取顶点 1 的 Attribute
→ 执行顶点着色器
→ 缓存完整输出

再次遇到索引 1
→ 缓存命中
→ 直接复用输出

所以这个小矩形理想情况下是:

1
2
3
4
6 个索引引用
4 个不同顶点
4 次顶点着色器执行
2 个三角形

缓存的是什么

缓存的是顶点着色器执行后的结果,例如:

1
2
3
4
5
裁剪空间位置
传给后续阶段的 UV
颜色
变换后的法线
其他顶点输出

“变换后”不只表示乘过模型矩阵,而是泛指顶点着色器已经处理完成。

为什么只能说“理想情况下”

缓存容量有限。如果同一个索引两次出现之间夹着大量其他顶点,旧结果可能已经被淘汰,第二次仍需重新执行。

因此关系通常是:

1
2
3
顶点引用数
≥ 顶点着色器实际执行数
≥ 当前绘制中不同顶点数

不用索引而直接提交六份顶点记录时,即使两份记录数值完全相同,GPU 也没有“它们是同一个编号”的复用关系,通常仍按六个顶点处理。

3. 图元拓扑告诉 GPU 怎样连接顶点

只有索引流:

1
0, 2, 1, 1, 2, 3

还不能判断它代表点、线还是三角形。绘制状态还要包含 Primitive Topology(图元拓扑)

常见拓扑包括:

1
2
3
4
Point List      点列表
Line List 线段列表
Triangle List 三角形列表
Triangle Strip 三角形条带

当拓扑为 Triangle List 时,每三个引用组成一个三角形:

1
2
[0, 2, 1] → 三角形 A
[1, 2, 3] → 三角形 B

所以 Triangle List 中:

1
索引数量 ÷ 3 = 三角形数量

负责这次连接的是 Primitive Assembly(图元装配),不是顶点着色器。顶点着色器通常一次只知道当前顶点的输入和公共参数,并不知道另外两个顶点是谁。

1
2
3
4
5
顶点着色器输出
+ 索引顺序
+ 图元拓扑

完整图元

顶点位置像城市位置,索引像道路连接。城市不动,换一种连法也会得到完全不同的网络。

Triangle Strip 为什么索引更少

三角形条带让相邻图元复用前两个引用:

1
0, 2, 1, 3

可表达两个三角形。后续每增加一个顶点引用,就增加一个三角形;为维持一致绕序,组装规则还会交替调整顶点解释顺序。

Triangle List 虽然索引更多,却更容易独立组织、拆分和批处理,所以现代渲染中仍非常常见。

4. 索引顺序决定三角形的正反面

下面两组索引引用相同三个顶点:

1
2
0, 2, 1
0, 1, 2

但遍历方向相反:一个顺时针,一个逆时针。这叫 Winding Order(绕序)

图形 API 会结合约定和当前状态,把一种绕序视为正面,另一种视为背面。开启 Back-face Culling(背面剔除) 后,指定朝向的三角形不会进入后续光栅化。

所以交换两个索引:

1
2
3
0, 2, 1

0, 1, 2

虽然覆盖的几何区域相同,却反转了正反面,三角形可能因此消失。

背面剔除的意义是减少封闭物体上注定看不到的表面工作。但双面材质、薄片、某些 UI 或透明对象可能关闭剔除,或选择不同剔除模式。

5. 裁剪不是把跨界三角形整个丢掉

图元装配后,顶点着色器已经为每个顶点输出裁剪空间位置:

1
p_clip = (x, y, z, w)

相机的视锥由左、右、上、下、近、远六个边界围成。经过投影后,常见裁剪条件类似:

1
2
3
-w ≤ x ≤ w
-w ≤ y ≤ w
-w ≤ z ≤ w

某些 API 的深度范围使用 0 ≤ z ≤ w。具体 z 约定不同,但核心相同:在齐次裁剪空间里,通过坐标分量与 w 比较判断边界内外。

三角形有三种基本情况:

1
2
3
完全在内 → 原样保留
完全位于同一裁剪面外 → 整体丢弃
横跨边界 → 沿边界切开,只保留内部部分

不能只检查“有没有顶点在屏幕里”。一个巨大三角形的三个顶点都可能在屏幕外,但它仍覆盖整个视口。

6. 裁剪怎样生成新顶点

先看线段 A → B,A 在边界内,B 在边界外:

1
2
3
A ●────────────×────────● B
I
│ 裁剪面

边上任意点可以写成:

1
P(t) = A + t(B - A)

其中 t=0 是 A,t=1 是 B。求出满足裁剪面方程的 t,即可得到交点 I。

例如二维右边界 x=1

1
2
A = (0.5, 0.2)
B = (1.5, 0.8)

由:

1
0.5 + t(1.5 - 0.5) = 1

得到 t=0.5,因此:

1
I = (1, 0.5)

新顶点不只有位置

裁剪交点还必须生成完整的顶点输出:

1
2
3
4
5
裁剪空间位置
UV
颜色
法线
其他 Varying

若交点位于边的 50%:

1
I.uv = A.uv + 0.5(B.uv - A.uv)

这里是沿原图元边插值顶点着色器输出,而不是为交点重新执行一次顶点着色器。

一个三角形可能变成两个

若 A、B 在内,C 在外,边 A-CB-C 分别产生交点 I1、I2:

1
2
3
4
5
          C 外
╱ ╲
────────I1─I2──────── 裁剪面
╱ ╲
A───────B

保留下来的是四边形 A-B-I2-I1,后续需要重新拆成两个三角形。

原三角形状态 结果
3 个顶点都在内 保留原三角形
3 个都在同一边界外 整体丢弃
1 个在内、2 个在外 产生 2 个交点,保留 1 个三角形
2 个在内、1 个在外 产生 2 个交点,得到四边形并拆成 2 个三角形

7. 为什么必须先裁剪,再透视除法

透视除法是:

1
2
3
x_ndc = x_clip / w_clip
y_ndc = y_clip / w_clip
z_ndc = z_clip / w_clip

NDC 水平边界 -1 ≤ x_ndc ≤ 1,在裁剪空间中对应:

1
-w_clip ≤ x_clip ≤ w_clip

所以 ±w 边界正是透视除法后 ±1 的齐次表达。

关键在于:除以 w 不是线性变换

例如只看 (x,w)

1
2
A = (0, 1)
B = (2, 4)

先取裁剪空间中点再除:

1
2
M_clip = (1, 2.5)
M_ndc.x = 1 / 2.5 = 0.4

先分别除再取屏幕中点:

1
2
3
A_ndc.x = 0
B_ndc.x = 0.5
中点 = 0.25

结果 0.4 ≠ 0.25。透视除法改变了沿线段的参数分布;如果先除再按普通线性关系裁剪,交点和属性可能错误。

更麻烦的是边穿过 w=0 附近时,x/w 可能趋向无穷、翻转或失去意义。因此正确顺序是:

1
2
3
4
5
6
7
顶点着色器输出 (x,y,z,w)

在齐次裁剪空间判断和求交

只对保留顶点做透视除法

得到 NDC

这也是透视相机的近裁剪面不能设为 0 的原因之一;近裁剪面还会影响深度精度,后续讲深度缓冲时再展开。

8. 视口变换把 NDC 映射到实际屏幕

NDC 将投影结果归一化到固定范围,Viewport(视口)再把它映射到实际渲染目标的一块矩形。

水平方向:

1
2
x_screen = viewportX
+ (x_ndc + 1) × 0.5 × viewportWidth

若视口宽度为 800:

x_ndc x_screen
-1 0
0 400
1 800

垂直方向同理,但 y 轴向上或向下、原点位于左下还是左上,取决于 API、渲染目标和引擎约定,不应把某一套符号当作全部平台的定义。

例如 800 × 600 视口中的三个 NDC 顶点:

1
2
3
A = (-0.5, -0.5) → (200, 150)
B = ( 0.5, -0.5) → (600, 150)
C = ( 0.0, 0.5) → (400, 450)

屏幕顶点仍是连续坐标,可以是 (213.27, 148.61),而不是先四舍五入到某个像素。

Viewport 也不必覆盖整个窗口。分屏、小地图和编辑器多视图,本质上都可以让同一 NDC 范围映射到不同视口矩形。

9. 光栅化判断的是采样位置,不是像素方格

从存储角度,一个像素是颜色缓冲中的一个位置;从几何覆盖角度,GPU 要测试像素内部一个或多个规则采样位置。

单采样时可以先近似理解为像素中心:

1
2
像素 (0,0) → 采样点 (0.5, 0.5)
像素 (1,0) → 采样点 (1.5, 0.5)

因此:

1
2
三角形碰到像素方格
≠ 采样点一定被三角形覆盖

斜边可能只擦过像素一角,却没有覆盖中心采样点,这个像素就不产生覆盖样本。连续边缘被离散采样后呈阶梯状,这正是锯齿的来源之一。

若开启 4× MSAA,一个像素内可有四个覆盖采样位置。边缘只覆盖其中两个时,可以表达为部分覆盖,最终解析时获得更平滑的边缘。

10. GPU 不需要检查全屏所有像素

GPU 先计算三角形的轴对齐包围区域:

1
2
minX、maxX
minY、maxY

只有包围区域里的采样位置才可能被覆盖。逻辑上可写成:

1
2
3
4
5
计算三角形屏幕包围区域

枚举可能相关的采样位置

执行三条边的内部测试

真实 GPU 会以 Tile、Block 或 Quad 等小块高度并行处理,并进行整块在内、整块在外的快速判断,不是像普通 CPU 双层循环那样逐点串行测试。

11. 边函数怎样判断点在三角形内部

对有方向的边 A → B 和测试点 P,可构造二维边函数:

1
2
3
E(A,B,P)
= (Pₓ-Aₓ)(Bᵧ-Aᵧ)
- (Pᵧ-Aᵧ)(Bₓ-Aₓ)

它本质是二维叉积或带方向面积:

1
2
3
E > 0 → P 在边的一侧
E < 0 → P 在另一侧
E = 0 → P 在边所在直线上

具体哪个符号代表内部,取决于绕序、坐标方向和公式写法。真正重要的是:对于绕序一致的三角形,内部始终位于三条有向边约定的同一侧。

1
2
3
4
5
6
7
测试 E(A,B,P)
测试 E(B,C,P)
测试 E(C,A,P)

三个条件都满足内部规则

P 被三角形覆盖

可以把三条边看成三面有方向的围墙。测试点必须同时站在三面墙的“里面”。

包围盒只能快速排除明显不可能的位置;真正的三角形覆盖仍由边函数一类规则确定。

12. 公共边为什么不会裂开或重复覆盖

相邻两个三角形共享一条边。若采样点恰好落在边上,边函数为 0:

1
2
3
两个三角形都不认 → 产生裂缝
两个三角形都认 → 重复覆盖,透明时可能颜色加深
只归其中一个 → 无缝且不重复

GPU 使用一致的边界归属规则,常被概括为 Top-left Rule(顶边与左边规则)。它根据边的方向和类别,让公共边上的采样点稳定地只归一个三角形。

这是一种人为约定,但一致约定让独立光栅化的相邻三角形能够无缝拼接。

13. 覆盖通过后,片元属性从哪里来

覆盖测试不仅回答“点是否在内部”,还可以得到 P 在三角形中的三个重心权重:

1
2
α、β、γ
α + β + γ = 1

于是顶点输出可以插值到采样位置:

1
2
P.color = αA.color + βB.color + γC.color
P.uv = αA.uv + βB.uv + γC.uv

边函数的带方向面积与这些重心权重紧密相关。因此光栅化阶段实际连接了三个问题:

1
2
3
4
5
采样点是否被覆盖?

它在三角形中的相对位置是什么?

它应该得到怎样的插值属性?

但这里还留下一个关键坑:经历透视投影后,直接用屏幕空间重心权重线性插值 UV 会产生纹理扭曲。下一节将推导为什么必须进行透视正确插值

14. 片元不等于最终像素

覆盖测试通过后产生的是 Fragment(片元候选),其中可能包含:

1
2
3
4
5
屏幕位置
深度
插值后的 UV、颜色、法线
正反面信息
其他输入

它还可能因为以下原因无法写入颜色缓冲:

1
2
3
4
5
深度测试失败
模板测试失败
被片元 Shader discard
超出 Scissor 区域
透明混合与已有颜色合成

同一个像素还可能先后收到地面、墙、角色、粒子等多个图元产生的片元。

所以要严格区分:

1
2
像素:帧缓冲中的存储位置
片元:某个图元对某个采样位置的一次候选贡献

阶段锚点

本阶段已经把 CPU 提交后的链路推进到片元生成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
drawIndexed
↓ 索引引用与顶点缓存
顶点着色器输出裁剪空间位置和属性
↓ 索引顺序 + Primitive Topology
图元装配
↓ 绕序判断、背面剔除、齐次裁剪
有效三角形
↓ 透视除法
NDC
↓ Viewport Transform
屏幕空间三角形
↓ 包围区域 + 边函数 + 公共边规则
被覆盖的采样位置
↓ 重心权重
片元候选及插值属性

当前最重要的闭环是:

GPU 不是把三角形“画成一张图片”,而是先保留顶点之间的拓扑关系,再把有效图元转换到屏幕连续坐标,最后在规则采样位置上做几何覆盖判断。片元是覆盖成立后的候选记录,不是最终像素。

下一步继续追问:

为什么屏幕上的重心坐标不能直接线性插值 UV?已经做过透视除法以后,GPU 怎样利用每个顶点原来的 w 恢复正确的纹理透视?

关联


来源:与 Codex 的对话,2026-08。本章进行中。