Explore hybrid rendering with Metal ray tracing
Graphics & Games 高级 26m

探索 Metal 光线追踪混合渲染

Explore hybrid rendering with Metal ray tracing

2021年6月9日

在 Apple 官方观看视频

一句话判断

这场 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 里同时做——阴影的光线方向固定(指向光源),反射的方向各异,两者的光线一致性不同,分开处理可以分别优化。
  • MPSSVGFtemporalReuseWeight 参数建议从 0.8 开始调:值越高降噪越强但拖影越明显,值越低噪声越多但响应更快。

还有什么值得关注

  • 混合渲染管线中的 motion vector 可以复用 TAA(Temporal Anti-Aliasing)已经生成的数据,不需要额外计算。
  • MTLAccelerationStructure 支持实例化(instancing),一个加速结构可以被多个物体引用,适合场景中重复出现的几何体。
  • Apple 建议在 debug 模式下可视化光追的光线分布,确保阴影和反射的光线没有集中在少数像素上。
WWDC 2021