探索 Metal 光线追踪混合渲染
Explore hybrid rendering with Metal ray tracing
2021年6月9日
一句话判断
这场 Session 是上一场(10149)的工程实践篇——如果你理解了光追 API 但不知道怎么把它塞进已有的渲染管线里,这里给了具体的混合渲染架构方案。
这场 Session 讲了什么
Session 聚焦于如何将 Metal Ray Tracing 和传统光栅化渲染结合,构建一个混合渲染管线(Hybrid Rendering Pipeline)。重点不是光追 API 本身,而是管线设计:哪些渲染效果用光栅化做、哪些用光追做、两者之间如何传递数据。
混合渲染的核心思路是”光栅化做主,光追做辅”:主要的可见性判定(哪些物体在屏幕上可见)仍然用光栅化,因为它的效率远高于光追。光追负责那些光栅化很难做好或者做不了的效果:硬阴影、反射、环境光遮蔽(AO)、全局光照(GI)。Session 展示了完整的管线流程:光栅化生成 G-Buffer -> 从 G-Buffer 发射光线做阴影检测 -> 从 G-Buffer 发射光线做反射 -> 合成最终画面。这种架构的优势是每帧只需少量光线(通常每个像素 1-4 条),就能获得显著的视觉提升。
值得深挖的点
G-Buffer 到光追的数据传递
混合渲染的关键是 G-Buffer(几何缓冲区)。光栅化 pass 将每个像素的世界坐标、法线、材质信息写入 G-Buffer 纹理。光追的 compute shader 直接读取这些纹理作为光线的起点和方向。这里有一个细节:G-Buffer 的法线纹理应该用 RGB10A2Unorm 而不是 RGBA16Float——法线不需要高精度,节省带宽更重要。世界坐标可以用 RGBA16Float,因为坐标值的范围远大于 [-1, 1]。
时域复用(Temporal Reuse)降低光追噪声
每个像素只发射 1-4 条光线的结果会非常噪。时域复用方案利用上一帧的光追结果来降低当前帧的噪声:通过 motion vector 找到当前像素在上一帧的位置,混合两帧的结果。但这需要处理画面中新增或移除的物体(disocclusion),否则会出现拖影。MPSSVGF denoiser 自动处理了这个问题,但如果你需要更精细的控制,可以自己实现基于 MTLFence 的时域缓冲管理。
代码片段
// 混合渲染管线的 pass 编排
func renderFrame(commandBuffer: MTLCommandBuffer) {
// Pass 1: 光栅化生成 G-Buffer
encodeGBufferPass(commandBuffer: commandBuffer)
// Pass 2: 光追阴影检测
// 从 G-Buffer 读取像素位置,向光源发射阴影光线
encodeShadowRayPass(commandBuffer: commandBuffer)
// Pass 3: 光追反射
// 从 G-Buffer 读取法线,沿反射方向发射光线
encodeReflectionRayPass(commandBuffer: commandBuffer)
// Pass 4: 降噪处理
// 使用 MPS denoiser 消除光追产生的噪点
encodeDenoisePass(commandBuffer: commandBuffer)
// Pass 5: 最终合成
// 将光栅化的基础着色、光追阴影和反射合成最终画面
encodeCompositePass(commandBuffer: commandBuffer)
}
// 从 G-Buffer 发射阴影光线的 compute shader
kernel void shadowRayKernel(
uint2 tid [[thread_position_in_grid]],
texture2d<float, access::read> gBufferPosition [[texture(0)]],
texture2d<float, access::read> gBufferNormal [[texture(1)]],
texture2d<float, access::write> shadowOutput [[texture(2)]],
constant LightData& light [[buffer(0)]],
acceleration_structure<instancing> sceneAS [[buffer(1)]]
) {
float3 worldPos = gBufferPosition.read(tid).xyz;
float3 normal = gBufferNormal.read(tid).xyz;
// 计算到光源的方向和距离
float3 toLight = light.position - worldPos;
float dist = length(toLight);
float3 lightDir = toLight / dist;
// 从表面沿光线方向偏移一点,避免自相交
float3 origin = worldPos + normal * 0.001;
// 发射阴影光线
ray shadowRay(origin, lightDir, 0.001, dist);
ray_query q;
q.reset(shadowRay, sceneAS);
// 如果有交点,说明被遮挡,处于阴影中
float shadow = q.next() ? 0.0 : 1.0;
shadowOutput.write(float4(shadow, 0, 0, 1), tid);
}
// 使用 MPS denoiser 处理光追噪点
func encodeDenoisePass(commandBuffer: MTLCommandBuffer) {
let denoiser = MPSSVGF(
device: device,
textureType: .float,
temporalReuseWeight: 0.8
)
// 输入:带噪的光追结果 + G-Buffer 法线 + 深度
// 输出:降噪后的干净画面
denoiser.encode(
to: commandBuffer,
noisyColor: rayTracedColorTexture,
normal: gBufferNormalTexture,
depth: gBufferDepthTexture,
motionVector: motionVectorTexture,
output: denoisedColorTexture
)
}
最佳实践
- 光追的渲染分辨率可以低于屏幕分辨率(比如 1/2 或 1/4),然后用时域累积逐步补全。这样每帧的光线数量不变,但渲染成本降低到原来的 1/4 或 1/16。
- 阴影和反射用分开的 compute pass 处理,不要在一个 pass 里同时做——阴影的光线方向固定(指向光源),反射的方向各异,两者的光线一致性不同,分开处理可以分别优化。
MPSSVGF的temporalReuseWeight参数建议从 0.8 开始调:值越高降噪越强但拖影越明显,值越低噪声越多但响应更快。
还有什么值得关注
- 混合渲染管线中的 motion vector 可以复用 TAA(Temporal Anti-Aliasing)已经生成的数据,不需要额外计算。
MTLAccelerationStructure支持实例化(instancing),一个加速结构可以被多个物体引用,适合场景中重复出现的几何体。- Apple 建议在 debug 模式下可视化光追的光线分布,确保阴影和反射的光线没有集中在少数像素上。