图形学渲染管线全解:从顶点到像素
第一遍:骨架(30 秒版本)
渲染管线= 把3D 顶点数据变成屏幕上像素颜色的一条流水线。
[顶点数据 + Shader + 纹理] ↓ ┌──────────────────┐ │ 1. Vertex Shader │ 处理每个顶点(坐标变换) └──────────────────┘ ↓ ┌──────────────────┐ │ 2. 图元装配 │ 把顶点组装成三角形 └──────────────────┘ ↓ ┌──────────────────┐ │ 3. 裁剪 + 剔除 │ 丢掉屏幕外/背面的三角形 └──────────────────┘ ↓ ┌──────────────────┐ │ 4. 光栅化 │ 三角形 → 一堆像素(fragment) └──────────────────┘ ↓ ┌──────────────────┐ │ 5. Fragment Shader│ 每个像素算颜色 └──────────────────┘ ↓ ┌──────────────────┐ │ 6. 逐片元操作 │ 深度测试、混合、写入 FB └──────────────────┘ ↓ [屏幕像素]核心洞察:管线是"漏斗形"的——数据量越往后越大(1 个三角形能生成几万个 fragment),所以越靠后的阶段越贵,优化空间越大。
第二遍:每个阶段的详细扒开
阶段 0:输入 —— 我们有什么原料?
在管线开始前,GPU 已经通过 binding point 拿到了这些"原料":
📦 VBO(顶点数据): [顶点1: pos, uv, normal, color] [顶点2: pos, uv, normal, color] [顶点3: pos, uv, normal, color] ... 📦 EBO(索引数据): [0, 1, 2, 2, 3, 0, ...] ← 每 3 个索引组成一个三角形 🖼️ 纹理(在纹理工位上) 📜 Shader Program(在 shader 工位上) 📋 Uniform 数据(matrices、光源、参数...)阶段 1:Vertex Shader(顶点着色器)
输入:一个顶点(位置 + 各种属性)
输出:变换后的顶点(裁剪空间位置 + 传给下一阶段的属性)
主要做什么?
核心工作:把顶点从模型空间变换到裁剪空间。
一个顶点的坐标变换旅程,是图形学最经典的"三次变换":
[模型空间] → M(Model 矩阵) → [世界空间] [世界空间] → V(View 矩阵) → [观察空间] [观察空间] → P(Projection 矩阵) → [裁剪空间] 一步到位: gl_Position = P * V * M * vec4(vertexPos, 1.0);用现实类比
把这个过程想象成给一个模型拍照:
- 模型空间:模型自己内部的坐标系(比如一个人的模型,原点在脚下)
- 世界空间:把模型摆到场景里的某个位置(比如人站在房子门口)
- 观察空间:相机的视角(以相机为原点)
- 裁剪空间:把 3D 投影到 2D 的准备阶段(近大远小的透视)
关键点:并行执行
每个顶点独立跑一遍 Vertex Shader,可以完全并行。GPU 上几千个核心同时算不同的顶点。
Vertex Shader 里能做什么?
- 坐标变换(主要工作)
- 顶点动画(骨骼蒙皮 skinning、顶点位移)
- 计算传给 fragment shader 的插值属性(法线、切线、世界坐标等)
- 顶点光照(便宜但粗糙的光照方案)
阶段 2:图元装配(Primitive Assembly)
输入:一堆变换后的顶点
输出:一堆三角形(或点、线,取决于 draw 模式)
做什么?
按索引把顶点组装成三角形。
举例:
顶点:[v0, v1, v2, v3] 索引:[0, 1, 2, 0, 2, 3] 组装出两个三角形: 三角形A:(v0, v1, v2) 三角形B:(v0, v2, v3)这个阶段还有个功能:处理图元的连接方式(TRIANGLES / TRIANGLE_STRIP / TRIANGLE_FAN)。
这个阶段基本上是硬件自动完成的,你没什么控制权(几何着色器/曲面细分是例外)。
阶段 3:裁剪 + 剔除(Clipping + Culling)
输入:一堆三角形
输出:留下"值得画"的三角形
3.1 视锥裁剪(Frustum Clipping)
问题:相机看不到的三角形,我们别浪费性能去光栅化了。
裁剪空间里,可见区域是一个[-1, 1]³的立方体(NDC 之前)。三角形分三种情况:
情况 A:完全在视锥内 → 保留 情况 B:完全在视锥外 → 丢弃 情况 C:部分在视锥内 → 切开!生成新的顶点情况 C 是最麻烦的——三角形被视锥边界切开时,GPU 会生成新的顶点,可能把 1 个三角形变成 2 个甚至 3 个。
3.2 背面剔除(Back-face Culling)
问题:一个封闭模型(比如一个球),背面(远离相机的一半)看不见,不用画。
判断方法:根据三角形顶点的顺时针/逆时针方向判断朝向。
if(三角形是背对相机的)→ 丢弃Unity 里Cull Back / Cull Front / Cull Off就是控制这个。
3.3 透视除法(Perspective Divide)
裁剪空间 → NDC(Normalized Device Coordinates):
NDC = ClipSpace.xyz / ClipSpace.w这一步把"近大远小"的效果落实到坐标上。
3.4 视口变换(Viewport Transform)
NDC(范围 [-1, 1])映射到屏幕坐标(比如 [0, 1920] × [0, 1080])。
至此,每个三角形的顶点已经是"屏幕上的位置"了。
阶段 4:光栅化(Rasterization)—— 管线的"炸弹阶段"
输入:屏幕空间的三角形
输出:一大堆 fragment(片元 = 潜在的像素)
什么是 fragment?
Fragment ≠ Pixel。
- Pixel:屏幕上的物理像素(最终显示的东西)
- Fragment:三角形覆盖某个像素后产生的候选颜色,一个 pixel 可能被多个 fragment 竞争
光栅化做什么?
判断"三角形覆盖了屏幕上的哪些像素",并为每个覆盖的像素生成一个 fragment。
三角形: v0 ▲ / \ / \ / \ /_______\ v1 v2 光栅化后生成: [fragment @ (100, 200)] [fragment @ (101, 200)] [fragment @ (100, 201)] ... 可能几千个 fragment关键步骤
4.1 覆盖测试(Coverage Test)
对屏幕上每个像素,判断"我在不在这个三角形内?"
开了 MSAA 4x:判断像素里的 4 个采样点分别在不在 → 得到 4-bit 的coverage mask(还记得吗?)
4.2 属性插值(Attribute Interpolation)
这一步至关重要。
Vertex Shader 输出的属性(uv、法线、颜色等),只有 3 个顶点上有值。但三角形内部的 fragment 需要各自的属性。
做法:根据 fragment 在三角形里的位置,用重心坐标插值:
v0 (uv = 0,0) ▲ /|\ / | \ / × \ ← × 处的 uv 是 v0/v1/v2 的加权平均 /___|___\ v1 (uv=1,0) v2 (uv=0.5,1)所有 vertex shader 输出的varying/TEXCOORD,都在这一步被插值。
4.3 透视校正插值
一个常被忽略但很重要的细节:插值不能是简单的线性插值,必须考虑透视效果(近大远小对纹理坐标的影响)。GPU 硬件自动处理,但底层实现涉及除以 w。
阶段 5:Fragment Shader(片元着色器)
输入:一个 fragment(带着插值好的属性)
输出:一个颜色(可能还有深度)
主要做什么?
为每个 fragment 计算最终颜色。这是整个管线里最贵的阶段,因为:
- Fragment 数量巨大(一个屏幕百万级)
- 光照、纹理采样、复杂计算都在这里
常见工作
half4 frag(v2f i) : SV_Target { // 1. 采样纹理 half4 albedo = tex2D(_MainTex, i.uv); // 2. 采样法线贴图 half3 normal = UnpackNormal(tex2D(_BumpMap, i.uv)); // 3. 光照计算(diffuse + specular) half3 lighting = ComputeLighting(normal, i.worldPos); // 4. 混合、后处理 half4 finalColor = albedo * lighting; return finalColor; }为什么 Fragment Shader 这么贵?
规模的问题:
- 一个 1080p 屏幕 = 200 万像素
- 一个物体覆盖屏幕一半 = 100 万个 fragment 要跑 shader
- 场景有几十个物体 + 重叠 = 几百万到上亿次 shader 执行 / 帧
这就是"移动端最怕 Overdraw"、"Fragment Shader 优化收益最大"的原因。
Early-Z(早期深度测试)
优化:如果 GPU 能提前知道"这个 fragment 反正会被深度测试丢弃",就不用跑 fragment shader 了。
触发条件:
- Shader 里不
discard - Shader 里不修改深度(
SV_Depth) - 不开启 alpha blend
这也是为什么discard(Alpha Test)、写深度都会破坏 Early-Z 优化。
阶段 6:逐片元操作(Per-Fragment Operations)
输入:Fragment Shader 输出的颜色
输出:最终写入 framebuffer 的像素
这是一系列测试和混合操作,按顺序执行:
6.1 剪裁测试(Scissor Test)
只在指定的矩形区域内渲染(比如小地图、UI 面板范围)。
6.2 模板测试(Stencil Test)
有一个"模板缓冲区"(stencil buffer),可以基于它做各种"遮罩"效果:
- 镜面反射
- 描边
- 阴影体积
- UI 遮罩
if(stencil_value_at_pixel 满足某条件)通过;else丢弃 fragment;6.3 深度测试(Depth Test)
核心的可见性判断。
if(fragment.depth<depthBuffer[pixel])// 更近通过,可能更新深度缓冲else丢弃 fragmentUnity 里ZTest Less / Greater / LEqual / Always就是控制比较方式,ZWrite On/Off控制是否更新深度缓冲。
这是 Alpha Blend 物体必须关 ZWrite 的原因(否则透明物体会遮挡后面的透明物体)。
6.4 混合(Blending)
如果 fragment 没被丢弃,和 framebuffer 里已有的颜色混合:
finalColor=srcFactor*shaderColor+dstFactor*frameBufferColor;这就是 Alpha Blend 的实现:
Blend SrcAlpha OneMinusSrcAlpha= 半透明混合Blend One One= 加法混合(粒子、发光)Blend DstColor Zero= 乘法混合(阴影)
6.5 写入 Framebuffer
最终颜色写入 framebuffer 对应的像素。这才是"像素被画出来"的时刻。
阶段 7:(可选)MSAA Resolve
如果开了 MSAA,framebuffer 存的是每个像素的多个采样点颜色。最后需要"resolve"成最终显示的图:
每个像素的 4 个 sample 颜色 → 平均 → 最终像素颜色在 TBDR 手机 GPU 上,这一步发生在片上缓存内,只把 resolve 后的结果写到系统内存。这就是"手机 MSAA 免费"的核心。
一张完整流程图
[顶点数据] [索引] [纹理] [Uniforms] │ │ │ │ ▼ │ │ │ ┌────────────────────────────────┐ │ 1. Vertex Shader(每顶点1次) │ ← 可编程 │ 坐标变换 + 属性计算 │ └────────────────────────────────┘ │ ▼ ┌────────────────────────────────┐ │ 2. 图元装配 │ ← 硬件固定 │ 按索引组装三角形 │ └────────────────────────────────┘ │ ▼ ┌────────────────────────────────┐ │ 3. 裁剪 + 剔除 │ ← 硬件固定 │ 视锥裁剪、背面剔除 │ │ 透视除法、视口变换 │ └────────────────────────────────┘ │ ▼ ┌────────────────────────────────┐ │ 4. 光栅化(生成大量 fragment) │ ← 硬件固定 │ 覆盖测试(coverage mask) │ │ 属性插值 │ └────────────────────────────────┘ │ ┌─────┴─────┐ ▼ │ ┌────────────────┐ │ │ Early-Z 测试 │────┘(可能提前丢弃) │(优化路径) │ └────────────────┘ │ ▼ ┌────────────────────────────────┐ │ 5. Fragment Shader(每像素1次) │ ← 可编程,最贵 │ 采样纹理、光照、颜色计算 │ └────────────────────────────────┘ │ ▼ ┌────────────────────────────────┐ │ 6. 逐片元操作 │ ← 硬件固定 │ Scissor → Stencil → Depth │ │ → Blend → Framebuffer │ └────────────────────────────────┘ │ ▼ ┌────────────────────────────────┐ │ 7. MSAA Resolve(可选) │ │ 多采样平均 → 最终像素 │ └────────────────────────────────┘ │ ▼ [屏幕像素]关键"元规律"—— 从管线推导性能
现在你有了完整管线图,可以推导出图形学所有的性能规律:
规律 1:“越靠后越贵”
- Vertex Shader:每顶点 1 次,通常几万次/帧
- Fragment Shader:每像素 1 次,可能几千万次/帧
结论:优化的 80% 收益来自减少 Fragment Shader 的执行次数。
规律 2:"减少 Fragment Shader 执行"的方法
- 视锥剔除(阶段 3):越早剔除越好
- 遮挡剔除(游戏引擎实现):不画被遮挡的物体
- Early-Z(阶段 5 前):不该跑的 fragment 提前丢
- LOD:远处用低模,顶点少 → 光栅化产生的 fragment 也少
- 不 discard、不写深度:保证 Early-Z 生效
- 合理排序:不透明物体从前到后画,增加 Early-Z 剔除率
规律 3:“减少状态切换”
- 阶段 0 的 binding point 切换 = SetPass Call
- 合批= 减少 binding 切换 = 减少 draw 之间的开销
规律 4:“移动端的额外规律”(TBDR)
- 阶段 6 写入 framebuffer 时,数据先写到片上 Tile 缓存,再一次性写到系统内存
- MSAA 在片上缓存内做,几乎免费
- 切换 RenderTarget 极贵(触发 Tile Store/Load)
- discard 破坏 HSR(手机版 Early-Z)
规律 5:"Alpha 相关的所有雷区"都来自管线
- Alpha Test:阶段 5 里 discard → 破坏 Early-Z / HSR
- Alpha Blend:阶段 6 里需要读 framebuffer 混合 → 不能 Early-Z、需要排序
- A2C:利用阶段 4 的 coverage mask → 绕过上面两个问题
一个完整的例子:画一个带纹理的立方体
来看看一个立方体的一个顶点、一个像素具体经历了什么:
顶点的旅程
顶点数据: position = (1.0, 1.0, 1.0) (立方体的一个角,模型空间) uv = (1.0, 1.0) normal = (0.577, 0.577, 0.577) ↓ 阶段 1:Vertex Shader pos_clip = P * V * M * (1, 1, 1, 1) = (0.8, 0.6, 0.5, 2.0) (裁剪空间) ↓ 阶段 3.3:透视除法 pos_ndc = pos_clip.xyz / 2.0 = (0.4, 0.3, 0.25) ↓ 阶段 3.4:视口变换 pos_screen = (0.4, 0.3) → (1152, 216) (1080p 屏幕坐标) 这个顶点在屏幕上的位置是 (1152, 216)一个 fragment 的旅程
光栅化(阶段 4): 三角形覆盖了像素 (500, 400) 在这个像素处,重心坐标插值得到: uv = (0.6, 0.4) normal = (0.7, 0.5, 0.5) worldPos = (2.3, 1.5, 4.0) ↓ 阶段 5:Fragment Shader albedo = tex2D(_MainTex, (0.6, 0.4)) = (0.8, 0.3, 0.3) (红色) lighting = dot(normal, lightDir) = 0.75 finalColor = albedo * lighting = (0.6, 0.22, 0.22) ↓ 阶段 6:深度测试 fragment.depth = 0.6 depthBuffer[(500, 400)] = 0.7 0.6 < 0.7 → 通过,更新 depthBuffer[(500, 400)] = 0.6 ↓ 阶段 6:写入 framebuffer framebuffer[(500, 400)] = (0.6, 0.22, 0.22) 至此,像素 (500, 400) 变成暗红色一个思考题(如果你能答上,说明彻底理解了)
问题:
一个场景有 1000 个不透明立方体,每个 12 个三角形,平均每个立方体在屏幕上占 100×100 像素。
- Vertex Shader 大约执行多少次?
- 光栅化生成多少 fragment?
- Fragment Shader 大约执行多少次?
- 如果你想优化性能,应该优先减少哪个数字?怎么减?
- VS:1000 × 8 顶点 =8,000 次(立方体共享顶点,实际约 8 个/立方体)
- 光栅化产生:1000 × 100×100 =10,000,000 个 fragment
- FS 理论上执行 10M 次,但实际因为Overdraw(立方体前后重叠)和 Early-Z(前面挡住后面),真实执行次数可能在 2M ~ 30M 之间
- 优先优化 FS 执行次数。方法:
- 从前到后排序渲染(增加 Early-Z 命中)
- 遮挡剔除(不画完全被挡的立方体)
- LOD(远处用小 quad 代替立方体)
- 简化 Fragment Shader
总结:这张管线图是你理解一切的地图
从今天起,当你看任何图形学/渲染相关的文章、教程、优化技巧、Unity 特性,都可以在这张管线图上定位:
- “Batching” = 减少管线开始前的状态切换
- “MSAA” = 阶段 4 的 coverage 多采样
- “Early-Z” = 阶段 5 前的智能剔除
- “Alpha Blend” = 阶段 6 的 blending
- “Post-Processing” = 阶段 6 之后,把 framebuffer 当纹理再走一遍完整管线
- “Compute Shader” = 完全绕开管线,直接编程 GPU
- “Deferred Rendering” = 分两次管线执行,第一次只算 G-Buffer
