这篇笔记主要写一下延迟渲染,以及 Metal 在 Apple Silicon 上能做而其它 API 做不到的那个版本。
为什么要延迟
前向渲染的代价是 O(物体数 × 光源数)。更糟的是,被遮挡的片元也要跑完整的光照计算,然后被深度测试丢掉——过绘制越严重,浪费越多。
延迟渲染把着色拆成两步:
- 几何 pass:把每个像素的表面属性(albedo、法线、粗糙度、深度)写进一组纹理,统称 G-Buffer。这一步只写属性,不算光照。
- 光照 pass:对屏幕上的每个像素,从 G-Buffer 读回属性,算一次光照。
这样每个像素只着色一次,与场景复杂度无关。复杂度变成 O(物体数 + 光源数 × 覆盖像素)。几百个动态光源忽然变得可行。
G-Buffer 的组织
G-Buffer 的设计是延迟渲染最核心的权衡:存得越多越灵活,但带宽开销线性增长。
一个务实的布局:
| 附件 | 格式 | 内容 |
|---|---|---|
| 0 | .rgba8Unorm_srgb | albedo.rgb, AO |
| 1 | .rgba16Float | normal.xy(八面体编码), roughness, metallic |
| 2 | .depth32Float | 深度 |
有几个值得注意的决定。
不存世界坐标。 位置可以从深度和屏幕坐标反推出来,存它是纯粹浪费 12 字节/像素。
MSLfloat3 reconstructWorldPos(float2 uv, float depth, float4x4 invViewProj) {
float4 ndc = float4(uv * 2.0 - 1.0, depth, 1.0);
ndc.y = -ndc.y;
float4 world = invViewProj * ndc;
return world.xyz / world.w;
}
法线用八面体编码。 单位向量只有两个自由度,存三个分量是冗余的。八面体映射把单位球面映射到一个正方形,用两个 16 位分量就能得到比三个 8 位分量好得多的精度:
MSLfloat2 octEncode(float3 n) {
n /= (abs(n.x) + abs(n.y) + abs(n.z));
float2 e = n.xy;
if (n.z < 0) e = (1.0 - abs(e.yx)) * sign(e);
return e * 0.5 + 0.5;
}
G-Buffer 的 storeAction。 如果光照 pass 在同一帧、同一个 encoder 之后立刻消费它们,那么在 Apple GPU 上可以设为 .dontCare——这正是下面要说的那件事。
光照 pass
光照 pass 画一个全屏三角形,读 G-Buffer,对每个光源累加。
用一个三角形而不是两个组成的四边形,是因为四边形的对角线上那些 quad 会被两个三角形各执行一次,产生一条浪费的接缝。一个覆盖整个屏幕的超大三角形没有这个问题,而且不需要顶点缓冲:
MSLvertex VertexOut fullscreen_vertex(uint vid [[vertex_id]]) {
float2 uv = float2((vid << 1) & 2, vid & 2);
VertexOut out;
out.position = float4(uv * float2(2, -2) + float2(-1, 1), 0, 1);
out.uv = uv;
return out;
}
对于有 range 的点光源和聚光灯,还可以进一步优化:不画全屏,而是画光源的包围球/包围锥,只有球内的像素才会被着色。配合模板测试可以避免对球外和球后的像素做无用功。
延迟渲染的代价
延迟渲染不是免费的:
- 半透明物体无法处理。 G-Buffer 每个像素只能存一个表面。透明物体必须在光照 pass 之后用前向渲染单独画一遍,于是引擎里要维护两套着色代码路径。
- MSAA 变得非常昂贵。 硬件 MSAA 在几何阶段工作,延迟渲染的着色发生在之后,要正确做 MSAA 就得在光照 pass 里对每个采样点着色。这也是延迟渲染的引擎普遍改用 FXAA 或 TAA 的原因。
- 带宽消耗大。 写 G-Buffer 再读回来,是一笔实打实的显存流量。在移动设备上这往往是决定性的劣势。
- 材质模型受限。 G-Buffer 的布局是固定的,很难支持需要额外参数的特殊材质(皮肤、头发、布料),通常要靠额外的 material ID 加分支。
Apple Silicon:单 pass 延迟渲染
前面那条"带宽消耗大"的劣势,在 Apple GPU 上可以几乎完全消除。
Apple 的 TBDR 架构里,每个 tile 的附件数据在渲染期间都在片上内存中。Metal 暴露了这一点:同一个 render pass 内的后续绘制可以直接读取 tile memory 里的附件值,不需要把 G-Buffer 写回显存再读回来。
这需要三件事配合:
1. G-Buffer 纹理声明为 memoryless。
Swiftd.storageMode = .memoryless
d.usage = .renderTarget
它们完全不占显存。
2. 几何 pass 和光照 pass 放在同一个 render pass descriptor 里,作为两组不同的绘制,中间不 endEncoding()。
3. 光照 pass 的片元函数用 [[color(n)]] 输入读回附件值,而不是用纹理采样:
MSLstruct GBufferData {
float4 albedo [[color(0)]];
float4 normal [[color(1)]];
float depth [[color(2)]];
};
fragment float4 deferred_lighting(VertexOut in [[stage_in]],
GBufferData gbuffer, // 直接从 tile memory 读
constant Light *lights [[buffer(13)]])
{
float3 n = octDecode(gbuffer.normal.xy);
// ... 光照计算 ...
}
结果是 G-Buffer 从来没有离开过芯片。带宽成本降到接近零,而且那 32 MB 的显存也省下来了。这是 Metal 相对其它 API 的一个真实优势——Vulkan 的 subpass 机制有类似意图,但在桌面 GPU 上并不会真的把数据留在片上。
更进一步,还可以用 tile shading:在同一个 pass 里插入 compute dispatch,直接对 tile memory 操作。分块光源剔除就可以这样实现——一个 tile 的光源列表算好之后立即被同一个 tile 的光照绘制消费,全程不碰显存。
什么时候不用延迟渲染
现在的趋势其实是往回走。Forward+(分块前向)保留了前向渲染对半透明和 MSAA 的支持,又用 compute 的光源剔除解决了多光源问题。在 Apple Silicon 上,因为硬件本来就按 tile 工作,Forward+ 的实现相当自然,往往是比延迟渲染更好的默认选择。
延迟渲染依然值得用的场景:光源数量极多且大部分是小范围的动态光(城市夜景),或者需要大量屏幕空间效果(SSAO、SSR、屏幕空间阴影)——这些效果本来就需要一份深度和法线缓冲,G-Buffer 顺手就有了。
下一篇讲粒子系统,会第一次用到 compute shader 做模拟。