Base-渲染管线
渲染管线(Rendering Pipeline)描述的是:一组场景数据,如何一步步变成屏幕上的像素。
传统实时渲染管线通常可以从概念上分成三个阶段:
- 应用阶段(Application Stage):CPU 决定“画什么、以什么顺序画”。
- 几何阶段(Geometry Stage):GPU 把模型顶点变换成屏幕上的三角形。
- 光栅化阶段(Rasterization Stage):GPU 找出三角形覆盖的采样点,计算颜色并写入渲染目标。
这是一种便于理解的划分。真正的 Direct3D、OpenGL、Vulkan 或 Metal 管线会把它继续拆成更多子阶段,并且不同 GPU 的内部实现也不完全相同。
1. 应用阶段:决定画什么
应用阶段主要运行在 CPU 上,它并不是 GPU 固定管线中的某一个硬件阶段,而是游戏引擎在提交绘制命令之前所做工作的统称。
这一阶段通常包括:
- 更新动画、Transform、粒子和游戏逻辑。
- 根据相机做视锥剔除、遮挡剔除和 LOD 选择。
- 收集可见的 Renderer,并按照材质、渲染队列或深度排序。
- 准备网格、材质、纹理和 Shader 参数。
- 设置渲染状态,例如 Cull、ZTest、ZWrite 和 Blend。
- 生成 Draw Call,并通过图形 API 提交给 GPU。
应用阶段的输入是整个场景,输出则是一串 GPU 可以执行的命令。可以把它理解为导演在开拍前整理演员、道具和拍摄顺序,GPU 负责真正把画面拍出来。
1.1 可见性判断
最基础的是视锥剔除:物体的包围盒完全处于相机视锥外时,没有必要提交给 GPU。
除此之外,现代引擎还可能使用:
- 遮挡剔除:物体被其他物体完全挡住时不绘制。
- LOD:距离越远,使用顶点更少或计算更简单的版本。
- 距离剔除:小到无法产生视觉贡献的物体直接忽略。
剔除越早,后面所有阶段的压力就越小。但剔除本身也需要 CPU 或 GPU 计算,因此不是规则越复杂越好。
1.2 排序、合批与 Draw Call
CPU 每次要求 GPU 绘制一批几何体,通常就会产生一次绘制提交。大量 Draw Call、频繁切换材质和渲染状态,会让 CPU 花很多时间组织命令。
常见优化包括:
- 合并使用相同材质的物体。
- 使用 GPU Instancing 绘制大量相同网格。
- 按材质或管线状态排序,减少状态切换。
- 使用 SRP Batcher、间接绘制或 GPU Driven Rendering。
透明物体通常还需要从远到近排序。这里也能看出:透明渲染并不只是一个 Blend 设置,它同时依赖应用阶段的排序和光栅化末端的混合。
2. 几何阶段:把顶点变成屏幕三角形
几何阶段主要处理顶点和图元。图元(Primitive)是 GPU 能够光栅化的基础几何形状,实时渲染中最常见的是三角形。
这一阶段的核心流程是:
1 | |
2.1 顶点输入
网格数据一般保存在 Vertex Buffer 和 Index Buffer 中。
Vertex Buffer 保存:
- 模型空间位置。
- 法线和切线。
- UV。
- 顶点色。
- 骨骼索引与权重等自定义数据。
Index Buffer 保存三角形引用哪些顶点。多个三角形可以复用同一个顶点,减少重复数据和顶点着色计算。GPU 内部还可能使用顶点缓存复用已经处理过的结果。
2.2 顶点着色器
顶点着色器(Vertex Shader)至少需要计算顶点的裁剪空间位置。最典型的坐标变换是:
1 | |
用矩阵表示就是:
1 | |
除了位置,顶点着色器还会输出 UV、颜色、世界空间法线等数据。这些输出会在三角形内部插值,再交给片元着色器。
顶点着色器适合做:
- 顶点变换。
- 骨骼蒙皮。
- 顶点动画和风摆动。
- 计算传给片元阶段的数据。
它通常一次只知道当前顶点,不能直接知道整个三角形最终覆盖了哪些像素。
2.3 可选的细分和几何处理
在传统可编程管线中,顶点着色器之后还可能存在:
- 曲面细分阶段(Tessellation):把较粗的图元细分成更多三角形。
- 几何着色器(Geometry Shader):以完整图元为输入,可以丢弃或生成图元。
它们不是每次绘制都必须使用。移动端尤其要谨慎使用,因为生成大量几何体可能迅速增加带宽、缓存和光栅化压力。更新的硬件还可能提供 Mesh Shader,用另一种方式组织几何处理,但它不属于所有平台都支持的传统基础管线。
2.4 图元装配
图元装配(Primitive Assembly)根据索引,把处理后的顶点组合成点、线或三角形。
例如索引序列 0, 1, 2 表示第一个三角形引用 0、1、2 三个顶点。顶点绕序还会决定哪一面是正面,从而支持背面剔除。
2.5 裁剪与剔除
这两个概念很容易混淆:
- 剔除(Culling):整个图元不需要绘制,例如背面剔除。
- 裁剪(Clipping):图元有一部分超出可见范围,需要在边界上生成新顶点,留下可见部分。
GPU 会在齐次裁剪空间中判断三角形是否处于可见体内。完全在外面的三角形被丢弃,跨越边界的三角形被切开。
必须在透视除法之前裁剪。特别是穿过相机或近裁剪面的三角形,如果先除以 w,坐标可能变得无穷大或方向翻转。
2.6 透视除法与 NDC
顶点着色器输出的裁剪空间位置是四维齐次坐标 (x, y, z, w)。裁剪后执行透视除法:
1 | |
结果进入 NDC(Normalized Device Coordinates,归一化设备坐标)。透视投影中,远处物体之所以显得更小,关键就来自除以 w。
不同图形 API 对 NDC 的 Y 轴方向和 Z 范围存在差异,例如 Z 可能是 [-1, 1] 或 [0, 1]。使用引擎提供的变换函数,通常可以避免手动处理这些平台差异。
2.7 视口变换
视口变换把 NDC 映射到渲染目标的屏幕坐标:
1 | |
到这里,GPU 已经知道三角形三个顶点在屏幕上的位置。几何阶段的输出不是最终颜色,而是一批等待光栅化的屏幕空间图元,以及随后需要插值的顶点属性。
3. 光栅化阶段:把三角形变成片元
光栅化不是“给模型贴一张图”这么简单。它首先回答一个非常具体的问题:这个屏幕空间三角形覆盖了哪些采样点?
3.1 三角形设置与覆盖测试
GPU 根据三个屏幕空间顶点建立三角形的边方程,并遍历它可能覆盖的区域。采样点满足三条边的内外判断时,就被认为处于三角形内部。
GPU 并不一定逐个像素笨拙地判断,而会以并行的小块进行处理。屏幕上的三角形越大,覆盖的采样点越多,后续片元着色工作的上限也越高。
3.2 Fragment 不完全等于 Pixel
光栅化产生的是 Fragment(片元),可以理解为“可能对某个像素采样点产生贡献的一次候选记录”。它包含:
- 屏幕位置和深度。
- 插值后的 UV、法线、颜色等属性。
- 正反面信息。
- 多重采样时的覆盖信息。
片元不一定成为最终像素,因为它可能在深度测试、模板测试或 discard 中被丢弃;多个透明片元也可能混合到同一个像素中。开启 MSAA 后,一个像素还可能包含多个采样点。
3.3 属性插值
三角形只有三个顶点,但内部会产生大量片元。光栅化器使用重心坐标,对顶点着色器输出的数据进行插值。
普通线性插值可以写成:
1 | |
其中 λ0 + λ1 + λ2 = 1。
UV 等属性不能简单地在屏幕空间线性插值,否则透视下的纹理会扭曲。因此 GPU 默认使用透视正确插值:
1 | |
这也是顶点阶段传少量数据,就能在整个三角形表面得到连续 UV 和法线的原因。
3.4 片元着色器 / 像素着色器
片元着色器(OpenGL/Vulkan 常用叫法)和像素着色器(Direct3D 常用叫法)在这里可以看作同一阶段。
它通常会:
- 采样纹理。
- 计算材质颜色与光照。
- 使用法线贴图重建法线。
- 计算透明度并决定是否
discard。 - 输出一个或多个 Render Target,例如延迟渲染的 GBuffer。
顶点着色器决定三角形在哪里,片元着色器决定三角形覆盖区域呈现什么结果。一般的片元着色器不能再改变三角形的形状,但某些 API 允许它修改片元深度。
3.5 Early-Z、深度和模板测试
深度测试比较当前片元深度和 Depth Buffer 中已有的深度,从而决定谁更靠近相机。模板测试则根据 Stencil Buffer 中的值控制片元是否通过。
硬件可能在运行片元着色器之前执行 Early-Z。被遮挡的片元可以提前丢弃,省去昂贵的着色计算。
但下面这些行为可能限制 Early-Z:
- 片元着色器主动修改深度。
- 使用
discard或 Alpha Test。 - 某些会产生读写顺序依赖的操作。
因此“最终没显示出来”不代表它一定没有运行片元着色器。大量被遮挡表面仍然执行了着色,就是常说的 Overdraw。
3.6 混合与输出合并
通过测试的片元会进入输出合并阶段(Output Merger)。这里会综合:
- 片元着色器输出。
- Render Target 中已有的颜色。
- Depth Buffer 和 Stencil Buffer。
- Blend、Color Mask、ZWrite 等固定管线状态。
常见 Alpha 混合近似为:
1 | |
不透明物体通常开启深度写入并关闭混合;透明物体通常关闭深度写入、保留深度测试并开启混合。透明表面的结果与绘制顺序有关,所以通常要在应用阶段从远到近排序。
最终结果被写入颜色缓冲、GBuffer、阴影贴图或其他 Render Texture。它不一定立刻显示到屏幕,也可能成为后续渲染 Pass 的输入。
4. 哪些阶段可编程,哪些是固定功能
传统 GPU 管线不是每一步都由 Shader 编写。
| 阶段 | 是否通常可编程 | 主要工作 |
|---|---|---|
| 顶点输入 / 图元装配 | 固定功能 | 读取顶点并组成三角形 |
| Vertex Shader | 可编程 | 顶点变换、蒙皮、顶点动画 |
| Tessellation / Geometry | 可选、可编程 | 细分、生成或修改图元 |
| 裁剪 / 透视除法 / 视口变换 | 固定功能 | 获得屏幕空间图元 |
| 光栅化 / 插值 | 固定功能 | 生成片元并插值属性 |
| Fragment / Pixel Shader | 可编程 | 材质、纹理和光照计算 |
| 深度、模板、混合 | 固定功能状态 | 可见性判断与输出合并 |
所谓“固定功能”不是不能配置,而是我们通过状态选择行为,不需要自己实现对应硬件算法。例如 Cull Back、ZTest LEqual、ZWrite On 和 Blend SrcAlpha OneMinusSrcAlpha 都是在配置固定功能单元。
5. 在 Unity Shader 中如何对应
一段简化的 Unity HLSL,大致能对应到以下流程:
1 | |
它们在管线中的位置是:
1 | |
Unity 中常见概念的对应关系:
MeshRenderer、剔除和排序:主要属于应用阶段。Mesh、Vertex Buffer、Index Buffer:几何输入。Vert:顶点着色器。Frag:片元着色器。Cull、ZTest、ZWrite、Stencil、Blend:固定功能状态。RenderTexture、Camera Color、GBuffer:渲染目标。- 一个 Shader 中的多个 Pass:多次经过管线,而不是一次管线里自动完成所有效果。
6. 从性能问题反推阶段
知道工作发生在哪个阶段,优化才不会凭感觉乱改。
应用阶段瓶颈
常见表现:CPU Render Thread 或 Main Thread 耗时高,GPU 没有吃满。
优先检查:
- Draw Call 和状态切换是否过多。
- 剔除和 LOD 是否有效。
- 是否存在大量小物体、频繁更新的 Renderer。
- 合批、Instancing 或命令生成成本。
几何阶段瓶颈
常见表现:顶点量、蒙皮或曲面细分增加后,GPU 时间明显上升。
优先检查:
- 实际提交的顶点和三角形数量。
- 顶点着色器是否过重。
- 骨骼数量、蒙皮顶点和额外顶点属性。
- 细分倍率是否失控。
- 小三角形是否过多。小到接近像素尺寸的三角形会让光栅化效率也变差。
光栅化 / 片元阶段瓶颈
常见表现:降低分辨率后帧率明显改善,或屏幕覆盖面积越大越慢。
优先检查:
- 片元着色器的指令、纹理采样和分支。
- 透明物、粒子和多层 UI 导致的 Overdraw。
- Render Target 数量、格式和带宽。
- MSAA 采样数。
- 全屏后处理次数。
判断瓶颈时最好使用 Unity Profiler、Frame Debugger、RenderDoc,以及对应平台的 GPU Profiler。只看三角形数量或 Draw Call 其中一个数字,很容易误判。
7. 最容易混淆的几个结论
- 应用阶段不等于某个 Shader 阶段。 它主要是 CPU 和引擎组织渲染工作的过程。
- 顶点着色器的输出不是屏幕像素。 中间还要经过装配、裁剪、透视除法和光栅化。
- 光栅化不等于片元着色。 光栅化负责产生片元和插值数据,片元着色器才计算材质结果。
- 片元不等于最终像素。 它可能被测试丢弃,也可能和其他片元混合。
- 深度测试不保证一定发生在片元着色之后。 硬件可以使用 Early-Z 提前排除遮挡片元。
- 透明渲染跨越多个阶段。 应用阶段负责排序,片元阶段计算颜色和透明度,输出阶段负责混合。
8. 总结
传统渲染管线可以记成一句话:
CPU 选择并提交物体,GPU 把顶点变成三角形,把三角形变成片元,再经过深度、模板和混合写入渲染目标。
进一步压缩就是:
1 | |
理解这条链路以后,很多看似独立的概念——Draw Call、坐标空间、裁剪、插值、Overdraw、Early-Z、透明排序和 Blend——都会回到各自正确的位置。