先把一句话摆在最前面:
顶点阶段只是把“点”算好了位置,
图元装配 & 裁剪这一步,干的事情就是:
把这些点按索引连成三角形,然后把跑出摄像机视野外的那部分三角形切掉或扔掉。
你可以把它想象成:
- 顶点阶段:一个个灯泡都拧好了,位置对了;
- 图元装配:根据电路图,把这些灯泡连成一串串“灯带”(三角形);
- 裁剪:把掉到舞台外、遮挡掉的那些灯带剪掉不要,再交给后面去点亮(光栅化、着色)。
这一章节我们专门讲这两步:
图元装配(Primitive Assembly):
顶点 → 点/线/三角形;重点讲三角形怎么从“顶点 + 索引”装配起来。裁剪(Clipping):
哪些三角形完全看不到直接扔掉;
哪些三角形一半在视野里一半在外面,要在边界上“切一刀”,只留里面那半。
目标是:用大白话 + 足够细节,让你脑海里能出现一个清晰的动画:
“顶点变完位置之后,GPU 是怎么把它们连成一个个三角形,再在视锥体的边界上剪来剪去的。”
一、先回顾一下“流水线”:图元装配 & 裁剪在第几道工序?
把整条渲染流水线简单复习一遍(只看传统光栅化管线):
CPU 阶段
- 准备顶点数据(位置、法线、UV、索引……);
- 设置各种状态(Shader、纹理、RenderTarget 等);
- 调用 DrawXxx:告诉 GPU,“把这堆数据画出来”。
顶点阶段(Vertex Shader)
- 对每个顶点:
local → world → view → clip,算出裁剪空间坐标(x, y, z, w); - 顺带把法线、UV 等也处理一下,传下去。
- 对每个顶点:
图元装配(Primitive Assembly)← 我们要讲的第一个主角
- 按照指定方式(点列表/线列表/三角列表/三角条带……),
把一个个顶点“组装”成一个个图元(通常是三角形)。
- 按照指定方式(点列表/线列表/三角列表/三角条带……),
裁剪(Clipping)← 第二个主角
- 判断三角形是否在可见体(视锥 / 裁剪盒子 / NDC 盒子)里;
- 完全在外:扔掉;
- 部分在内:沿裁剪平面把三角形切开,保留可见部分。
透视除法 & 视口变换(Perspective Divide & Viewport Transform)
ndc = clip / w,得到 [-1,1] 立方体坐标;- 映射到具体屏幕像素坐标。
光栅化(Rasterization)
- 把每个三角形变成一堆像素(片元),准备逐像素着色。
片元着色器(Fragment / Pixel Shader)
- 对每个像素算颜色、光照、贴图、阴影。
测试 & 混合(Depth/Stencil/Blend)
- 深度测试、模板测试、颜色混合(透明等),写入最终帧缓冲。
所以:
图元装配 & 裁剪
正好夹在“顶点变换”和“光栅化”之间,
起到“从点到面,并剪掉不需要的面”的承上启下作用。
二、图元装配:把点连成三角形这一步到底是怎么做的?
2.1 顶点只是“散点”,要靠“连接规则”变成几何形状
CPU 提供给 GPU 的数据主要有两块:
顶点缓冲(Vertex Buffer):
- 一条数组:
[v0, v1, v2, v3, v4, v5, ...]; - 每个 v 里有 position、normal、uv、color 等。
- 一条数组:
索引缓冲(Index Buffer):
- 另一条数组:
[i0, i1, i2, i3, i4, i5, ...]; - 每三个索引组成一个三角形:
(i0, i1, i2)→ 三角形 0(i3, i4, i5)→ 三角形 1- …
- 另一条数组:
GPU 在图元装配阶段要做的就是:
按照你指定的“图元类型(primitive type)”,
用索引把顶点排排坐,拼成一个个图元。
2.2 常见几种图元类型:点 / 线 / 三角形
最常见的是三种:
点列表(Point List)
- 每个顶点独立画一个点;
- 用得不多,更多是调试或点云渲染。
线列表 / 线条(Line List / Line Strip)
- 每两个顶点画一条线;
- 或者头尾相接画连续的折线。
三角形列表 / 条带(Triangle List / Triangle Strip)
- 三角形列表:每 3 个顶点一个三角形;
- 三角形条带:复用前两个顶点,每增一个点多出一个三角形。
游戏里绝大多数可见物体,都是用三角形表示的。
所以我们重点说三角形。
2.3 三角形列表(Triangle List):最常用、最直观
假设:
- 顶点数组:
vertices = [v0, v1, v2, v3, v4, v5, ...]; - 索引数组:
indices = [0, 1, 2, 2, 3, 0, ...]; - 绘制模式:
TRIANGLES(三角形列表)。
图元装配的逻辑大概是:
for(k = 0; k < indices.length; k += 3): i0 = indices[k] i1 = indices[k+1] i2 = indices[k+2] 三角形 T = (vertices[i0], vertices[i1], vertices[i2])这样:
- (0,1,2) → 第一个三角形;
- (2,3,0) → 第二个三角形;
- ……
每个顶点已经在顶点着色器里被变换到了 Clip Space(有 x,y,z,w),
图元装配时,只是把它们按索引分组。
2.4 三角形条带(Triangle Strip):节省顶点数据的一种方式
为了节省带宽,有时候会用 Triangle Strip:
顶点顺序:
v0, v1, v2, v3, v4, v5...;构造方式:
- 三角形 0:
(v0, v1, v2) - 三角形 1:
(v1, v2, v3) - 三角形 2:
(v2, v3, v4) - …
- 三角形 0:
优点:
- 共边的三角形能共享两个顶点,每增加一个顶点就多一个三角形;
- 对一些规则网格来说很适合。
缺点:
- 拓扑受限制;
- 现在很多引擎直接用 Triangle List 了,简单粗暴又直观。
你理解了 List,再知道 Strip 是“共享前两个”的变种就够了。
2.5 顶点阶段和图元装配的边界:谁干啥?
- 顶点阶段:对每一个顶点独立计算它的位置(和携带的其他数据);
- 图元装配:按指定方式把这些独立的顶点组装成图元(线/三角形)。
想象一下:
- 顶点阶段像是“给每个演员排好站位”;
- 图元装配就是“导演拿着剧本,说你们三个组成一组跳这个舞、你们三个组下一个”。
三、裁剪(Clipping):把视野外的三角形剪掉或切一刀
装好三角形之后,并不是全都要画。
有些完全在视野外,有些只露出一点点。
裁剪阶段就是:
根据一个“标准裁剪体”(Clip Volume),
把“三角形 + 视野”的交集算出来:
- 完全在外:扔掉;
- 完全在内:原样保留;
- 部分交叉:在边界上切开,生成新的三角形(或多边形),只保留里面的部分。
3.1 裁剪空间里的“标准盒子”
前面顶点阶段,我们把点变到了Clip Space,然后 GPU 做透视除法得到 NDC(Normalized Device Coordinates):
- NDC 中:
- x、y、z大概都在 [-1,1] 范围内的点是可见的;
- 超出这个范围的就是视野外。
裁剪的时候,有两种实现方式:
- 在 Clip Space 下直接裁剪:
- 使用约束:
-w <= x <= w,-w <= y <= w,z 同理;
- 使用约束:
- 或在透视除法后,在 NDC 下裁剪:
- 使用:
-1 <= x <= 1,-1 <= y <= 1,z 范围等。
- 使用:
具体取决于 GPU 内部实现细节;
对我们而言,可以简单理解为:
有一个“长方体视野盒子”,超出这盒子外的三角形都要处理一下。
3.2 裁剪的六个平面:左、右、上、下、近、远
无论是视锥还是标准裁剪盒子,最终都会变成六个裁剪平面:
- 左平面;
- 右平面;
- 上平面;
- 下平面;
- 近裁剪面(near plane);
- 远裁剪面(far plane)。
裁剪的逻辑就是对每个三角形依次判断它对这几个平面的关系。
简单来说,对于一个平面:
- 三角形三个顶点都在平面的“内侧”:
- 这个三角形对这个平面来说是“完全在内”的;
- 三个都在“外侧”:
- 对这个平面来说“完全在外”,可以整个扔掉;
- 有的在内、有的在外:
- 需要在平面和三角形的边的交点处切开。
对所有平面都处理完,剩下的就是实际会被绘制的部分。
3.3 完全在外:扔掉就完了(剪掉整个三角形)
最简单的情况:
- 比如一个三角形完全在相机视野的左边之外;
- 对“左裁剪平面”来说,它三个点的 x 坐标全都 < -w(Clip Space)或者 < -1(NDC);
- 这种直接丢弃:
这个三角形连光栅化都不需要,不会参与后面任何计算。
这就是 GPU 为什么能很高效:
很多你以为“存在的”三角形,其实根本不会走到像素阶段。
3.4 完全在内:原样送去下一步
如果三个顶点都在所有裁剪平面的“内侧”,那这个三角形:
- 不需要裁剪;
- 直接交给后续透视除法 + 光栅化;
也就是:完整三角形原样保留。
3.5 部分在内、部分在外:要在边界上“切一刀”
最麻烦的是中间情况:
- 有一个或两个顶点在视野内,剩下的在外;
- 三角形一部分在可见空间,有一部分伸出去了。
解决方案:
把“在内”的部分保留,把“在外”的部分切掉,
在裁剪平面和三角形边的交点处,创建新的顶点。
这样,原来的一个三角形,会变成:
- 一个新的三角形,或者
- 两个新的三角形(具体看哪几条边和裁剪平面相交)。
下面我们用直观例子仔细讲。
四、详细拆解“裁剪三角形”的过程(带具体情况)
为了好讲,我们只考虑在某一个平面上的裁剪(比如左平面),
然后理解 GPU 是怎么“切三角形”的。
记号:
- I:inside,顶点在平面内侧(可见空间内);
- O:outside,在平面外侧。
三角形有三个点,可能有这些情况:
- I I I → 全在内:保留,不切;
- O O O → 全在外:扔掉;
- I I O → 两个内一个外;
- I O O → 一个内两个外;
- O I O → 仍旧是“一内两外”的组合,只是顺序不同;
- 还有其他组合,但本质就是“一个内两个外”或“两个内一个外”。
我们分别说 3 和 4。
4.1 情况一:两内一外(I I O) → 三角形变成两个小三角形
假设顶点是:
- v0:内;
- v1:内;
- v2:外。
三角形 (v0, v1, v2)。
边有三条:
- v0-v1:全在内,无需切;
- v1-v2:穿过平面;
- v2-v0:穿过平面。
在两个穿过平面的边上,会产生两个交点:
- i1:v1-v2 与平面的交点;
- i2:v2-v0 与平面的交点。
原三角形的一部分被切掉,剩下的那部分几何形状其实是一个“凸四边形”,
GPU 会把它再拆为两个三角形:
- T1 = (v0, v1, i1)
- T2 = (v0, i1, i2)
这样:
- 原来的三角形变成了两个新的三角形;
- 而这两个三角形都完全在平面内侧(可见空间内)。
4.2 情况二:一内两外(I O O) → 三角形变成一个小三角形
假设:
- v0:内;
- v1:外;
- v2:外。
三条边:
- v0-v1:穿过平面;
- v0-v2:穿过平面;
- v1-v2:全在外,无需理会。
两个穿过平面的边上产生两个交点:
- i1:v0-v1 与平面的交点;
- i2:v0-v2 与平面的交点。
可见的那部分是一个小三角形:
- T = (v0, i1, i2)
所以:
- 原来的大三角形被平面“切掉大部分”,只剩下面向内侧的小三角形 T。
4.3 交点坐标怎么算?——线性插值
三角形的边是线段,比如边 v1-v2:
- v1 在内,v2 在外或相反;
- 平面可以表示为一个方程:
ax + by + cz + d = 0(或 N·p + d = 0)。
我们可以用参数 t 描述从 v1 向 v2 的点:
P(t) = v1 + t * (v2 - v1), t ∈ [0,1]把 P(t) 代入平面方程,解出 t,就得到交点的位置。
但在实际硬件实现中,Clip Space / NDC 的裁剪范围比较规整,
可以用更简单的方式算 t —— 比如对某一维(x 或 y 或 z)做插值。
举个简单例子:
假设我们在 NDC 中对 x = 1 的平面裁剪(右平面),
v1.x = 0.5(内侧),v2.x = 2.0(外侧):
我们想要 P.x = 1 P = v1 + t * (v2 - v1) P.x = v1.x + t * (v2.x - v1.x) = 0.5 + t * (2.0 - 0.5) = 1 → t = (1 - 0.5) / (2.0 - 0.5) = 0.5 / 1.5 = 1/3于是:
交点 P = v1 + (1/3) * (v2 - v1)同理,顶点的其他属性(UV、颜色、法线等)也要用同样的 t 做插值,
这样裁剪后的顶点仍然有合理的纹理和光照信息。
裁剪 = 在视野盒子的边上,沿着边线找到“过界点”,
然后用线性插值算出新的顶点(包括 position、UV、color 等)。
五、裁剪的结果:三角形数量会变化,顶点数量也会增加
裁剪的一个直接影响:三角形数量可能变多,顶点数量会“临时增加”。
举例:
- 原来只有一个三角形 I I O 的情况,被裁剪成两个三角形;
- 裁剪过程中产生的新顶点不会回写到原始顶点缓冲,而是作为流水线中的临时数据,继续往下传。
这一步一般都在 GPU 的固定功能(或高度优化的模块)里完成,
对我们编程的人来说:
你看不到“裁剪后的新索引缓冲”,
但可以知道:
“经过裁剪以后,被送入光栅化阶段的三角形,都是完整在视野内的”。
六、裁剪存在的意义:省算力、避免乱画、保证正确显示
为什么要费劲裁剪?直接全部三角形往下画不行吗?
从三个角度看它的重要性。
6.1 性能:提前扔掉看不到的三角形
如果不裁剪:
- 所有三角形都送去光栅化 → 每个三角形都要算覆盖哪些像素 → 再跑片元 Shader;
- 即使这些三角形完全在屏幕外,仍然浪费大量计算。
裁剪后:
- 完全在外的,直接扔掉;
- 部分在内的,只把在内的那一部分交给后面。
节省像素级计算,是高性能渲染的关键之一。
6.2 正确性:防止奇怪的投影或错误插值
当一个三角形大部分在视野外,一小部分在内,如果不正确裁剪:
- 插值后的 UV/深度可能会出现畸形;
- 最严重的可能导致将视野外的东西错误地绘制到屏幕上(artifact)。
裁剪时正确计算交点 & 插值,能保证:
- 三角形的可见部分在边界上被“干净利落地切断”;
- 没有超出视野的碎片跑进来捣乱。
6.3 为了后面透视除法和深度计算顺利进行
记得:
- 在 Clip Space 中,我们用规则
-w <= x <= w等作为裁剪标准; - 透视除法后,我们只希望 NDC 在 [-1,1] 的立方体里,
后面的视口映射和深度测试都建立在这个基础上。
如果不在 Clip/NDC 阶段裁剪,把非法的点交给后面:
- 透视除法可能得到无限大(比如 w→0);
- 视口映射可能把东西打到屏幕远外的某个鬼位置;
- 深度缓冲也会被写入不可预期的值。
裁剪阶段的存在,保证了:
“进入光栅化阶段的所有三角形,都处在一个合理的合法空间范围内。”
七、和背面剔除(Backface Culling)怎么区分?
裁剪(Clipping)和剔除(Culling)容易混淆,简单区分一下:
7.1 剔除(Culling):不看背面就别画
背面剔除是在图元装配后、光栅化前的另一道操作:
- 根据三角形顶点在屏幕 / NDC 中的顺序(顺时针/逆时针),判断它是面向相机还是背对相机;
- 默认只画“正面”,背面直接丢掉;
- 减少绘制负担;
- 同时符合“物体内部看不到”的直觉。
它不修改三角形形状,只决定画不画整个三角形。
7.2 裁剪(Clipping):针对视野边界“几何切割”
- 裁剪会根据视锥/裁剪盒的边界平面,切割三角形;
- 可能把一个三角形拆成零个(全外)、一个(一内两外)、两个(两内一外);
- 修改的是几何形状本身。
可以这么记:
剔除:判断“要不要画这个三角形”;
裁剪:判断“这个三角形在视野的哪部分要画、哪部分不要”。
八、从开发者的视角:你能“控制”或“感知”这一阶段的什么?
虽然图元装配 & 裁剪是 GPU 内部的“固定功能阶段”,
但我们可以从几个方向理解或影响它。
8.1 通过 DrawCall 的模式影响图元装配
比如在 OpenGL 中:
glDrawElements(GL_TRIANGLES,...);// 三角形列表glDrawArrays(GL_TRIANGLE_STRIP,...);// 三角形条带glDrawArrays(GL_LINES,...);// 线glDrawArrays(GL_POINTS,...);// 点在 DirectX / Vulkan 中也是类似,传入一个PrimitiveTopology(图元拓扑)。
你指定了拓扑,就等于告诉 GPU:
“图元装配阶段,请按照我给你的这种规则,把顶点连起来。”
8.2 控制裁剪平面(特别是 near / far)
Projection 矩阵参数(near, far, fov, aspect)会影响裁剪范围:
- near 过大:离相机太近的东西被裁掉(可能造成“模型被截断”的效果);
- near 过小:深度缓冲精度变差,Z-fighting 增多;
- far 过小:远处的物体提前被裁掉(突然消失);
- far 过大:同样影响深度精度。
注意:
near 不要设得太小,比如 0.001;
一般游戏中 near 用 0.1 / 0.3 / 1 之类比较合理。
8.3 使用裁剪平面做特殊效果(镜子、水面、门户)
有些高级渲染技巧会显式地设置裁剪平面,比如:
- 渲染镜子反射,只想画镜子平面“另一侧”的世界;
- 水面反射:只渲染水面以上或水面以下;
- 传送门:定义一个自定义裁剪平面,只渲染通过门框看到的部分。
虽然细节实现会因 API 而异,但本质上就是:
给 GPU 增加一个(或多个)裁剪平面,让裁剪阶段帮我们“切掉不想画的空间”。
九、把整个“图元装配 & 裁剪”的过程缩成一条直观的“动画”
把这两个阶段串起来,你可以在脑海里想象这样一段动画:
顶点阶段结束时:
- 空中漂浮着许多点,每个点都有一个 Clip Space 坐标 (x,y,z,w);
- 它们各自带着法线、UV、颜色等信息。
图元装配:
- GPU 拿着索引缓冲:
“0,1,2 你们三组成一个三角形;
2,3,0 你们也是;
4,5,6 你们再来一组……” - 一堆三角形在“裁剪空间”里被连成网。
- GPU 拿着索引缓冲:
裁剪阶段:
- 想象有一个透明的长方体(-w…w 或 -1…1 的盒子),
所有三角形都漂在这个盒子里,有的完全在盒子外,有的穿过边界,有的完好地在盒子里面。 - GPU 开始检查每个三角形:
- 完全在外:整块扔掉;
- 完全在内:保留;
- 穿过盒子:在盒子边缘的地方“切一刀”,把伸出去那部分削掉,只保留盒子里面的多边形(再拆成三角形)。
- 想象有一个透明的长方体(-w…w 或 -1…1 的盒子),
最终结果:
- 被送往光栅化阶段的三角形,全部都老老实实待在裁剪盒子范围内;
- 每个三角形都精准地只覆盖它在屏幕上“该出现的那一块区域”。
十、最后,用几句话再把这两步刻进脑子里
图元装配(Primitive Assembly)
- 把已经在 Clip Space 的顶点,按拓扑(TRIANGLES / STRIP 等)组装成图元;
- 关键数据来源是:顶点缓冲 + 索引缓冲;
- 输出的是:一个个三角形(以及线/点)。
裁剪(Clipping)
- 针对每个三角形,检查它与“可见空间盒子”的关系:
- 全在内:保留;
- 全在外:丢弃;
- 一部分在内一部分在外:在边界平面算交点,切出新的三角形。
- 交点用线性插值计算,顶点的所有属性(position/UV/color 等)都一起插值。
- 针对每个三角形,检查它与“可见空间盒子”的关系:
为什么要有它们?
图元装配:
顶点阶段只算点,不知道谁跟谁是一组;
图元装配帮你“连线成形”,把数学点变成几何面。裁剪:
防止“视野外的面”浪费后续计算;
保证进入光栅化的几何都在合法范围内;
避免奇怪投影和深度错误。
你在代码里能看到什么?
- 通过 DrawCall 设定 PrimitiveTopology(点/线/三角);
- 通过 Projection(near/far/fov)间接影响裁剪范围;
- 有时会用额外裁剪平面做高级效果(镜子、水面、传送门)。
如果用一句特别形象的话收尾:
顶点阶段是“点位算好了”,
图元装配是“把这些点连出三角形来”,
裁剪是“拿剪刀沿着视野盒子的边把跑出去的部分咔嚓掉”,
剩下的干干净净的三角形,才会被送去变成一堆像素,
最终在屏幕上组成你看到的那一帧画面。