T.TAO
返回博客
/8 min read/Graphics Engine

Metal #18 光线追踪(二)阴影与光照

#ComputerGraphics#GraphicsEngine#Metal

这是光线追踪三篇的第二篇,讲怎么用射线算出阴影和全局光照。

阴影射线

阴影是光追最容易见效的应用:从着色点朝光源发一条射线,中间有东西就是在阴影里。

MSLbool isOccluded(float3 origin, float3 normal, float3 lightPos,
                instance_acceleration_structure accel)
{
    float3 toLight = lightPos - origin;
    float  dist    = length(toLight);

    ray r;
    r.origin       = origin + normal * 0.001;   // 沿法线偏移,避免自相交
    r.direction    = toLight / dist;
    r.min_distance = 0.001;
    r.max_distance = dist - 0.001;              // 不要撞到光源本身

    intersector<instancing> i;
    i.accept_any_intersection(true);            // 只问有没有,不问是哪个
    return i.intersect(r, accel).type != intersection_type::none;
}

accept_any_intersection(true) 是这里的关键优化。阴影射线不需要知道最近的交点是什么,只需要知道有没有交点——遍历可以在第一次命中时立刻返回,不必继续找更近的。这通常能让阴影射线比普通射线快一倍。

shadow map 相比,光追阴影没有分辨率问题、没有偏移调参、没有级联接缝、没有 Peter Panning。它就是正确的。

软阴影

真实的阴影有半影,因为光源有面积。做法是对光源表面采样,而不是射向一个点:

MSLfloat3 sampleSphereLight(Light light, float2 xi) {
    float3 dir = uniformSampleSphere(xi);
    return light.position + dir * light.radius;
}

// 每个像素一条射线,靠时序累积去噪
float shadow = isOccluded(p, n, sampleSphereLight(light, rand2(gid, frame)), accel) ? 0.0 : 1.0;

半影的宽度由光源大小和遮挡物到接收面的距离自然决定——不需要任何参数。这是光追相对 PCF 最直观的优势:PCF 的软化半径是常数,而真实半影在遮挡物接触地面处是锐利的,远离时才变宽。

单采样的结果噪声很大,靠时序累积(TAA 或专门的阴影去噪器)恢复。

下一事件估计

朴素的路径追踪只按 BRDF 采样,然后希望射线偶然撞到光源。对于小光源,这个概率极低,图像会长时间充满噪点。

下一事件估计(NEE,也叫直接光采样)在每个路径顶点显式地向光源发一条阴影射线:

MSLfloat3 directLighting(float3 p, float3 n, float3 wo, Material m,
                      constant Light *lights, uint lightCount,
                      instance_acceleration_structure accel, float2 xi)
{
    // 均匀选一个光源,之后要除以选中概率
    uint  li    = min(uint(xi.x * lightCount), lightCount - 1);
    float pdfL  = 1.0 / float(lightCount);

    float3 lp   = sampleLightSurface(lights[li], xi);
    float3 wi   = normalize(lp - p);
    float  dist = length(lp - p);

    if (dot(n, wi) <= 0.0) return 0.0;
    if (isOccluded(p, n, lp, accel)) return 0.0;

    float3 brdf = evaluateBRDF(m, n, wi, wo);
    float  G    = dot(n, wi) / (dist * dist);
    return brdf * lights[li].color * G / pdfL;
}

有了 NEE,即使只有一次反弹,直接光照也是干净的——噪声只留在间接光里。这是路径追踪从"理论上正确"变成"实际可用"的关键一步。

NEE 和 BRDF 采样应该用 MIS 结合:NEE 对小光源好,BRDF 采样对大光源和镜面反射好,两者加权组合覆盖全部情况。

路径追踪循环

完整的路径追踪把这些组合起来,循环而不是递归(GPU 上没有栈可以递归):

MSLfloat3 pathTrace(ray r, instance_acceleration_structure accel,
                 constant Scene &scene, thread uint &seed)
{
    float3 radiance   = 0.0;
    float3 throughput = 1.0;     // 累积的 BRDF / pdf

    for (uint bounce = 0; bounce < MAX_BOUNCES; bounce++) {
        auto hit = trace(r, accel);
        if (hit.type == intersection_type::none) {
            radiance += throughput * sampleEnvironment(r.direction);
            break;
        }

        SurfaceData s = unpackSurface(hit, scene);

        // 直接光(NEE)
        radiance += throughput * directLighting(s.position, s.normal, -r.direction,
                                                s.material, scene.lights, scene.lightCount,
                                                accel, rand2(seed));

        // 采样下一个方向
        float3 wi; float pdf;
        float3 brdf = sampleBRDF(s.material, s.normal, -r.direction, rand2(seed), wi, pdf);
        if (pdf <= 0.0) break;

        throughput *= brdf * abs(dot(s.normal, wi)) / pdf;

        // 俄罗斯轮盘赌
        if (bounce > 2) {
            float q = max(throughput.x, max(throughput.y, throughput.z));
            if (rand(seed) > q) break;
            throughput /= q;      // 补偿,保持无偏
        }

        r.origin    = s.position + s.normal * 0.001;
        r.direction = wi;
    }
    return radiance;
}

俄罗斯轮盘赌值得单独说明。简单地在固定深度截断路径会引入偏差——丢掉的那些长路径的能量就永远没了,画面会偏暗。俄罗斯轮盘赌以概率 1-q 终止路径,但把存活路径的贡献除以 q 来补偿。期望值不变,所以估计仍然无偏,同时平均路径长度大幅下降。

q 取 throughput 的最大分量是标准做法:贡献已经很小的路径更容易被终止。

混合渲染

纯路径追踪在实时预算里跑不动。现在的实际做法是混合:光栅化负责主可见性,光追负责它擅长的那几件事。

Plain TextG-Buffer(光栅化) ─┬─> 光追阴影 ──────┐
                    ├─> 光追反射 ──────┼─> 去噪 ─> 合成 ─> 后处理
                    └─> 光追 AO/GI ────┘

这样安排的理由很实在:主可见性用光栅化几乎没有噪声、成本可预测,而阴影、反射、AO 恰好是屏幕空间技术失效最明显的地方。

从 G-Buffer 出发发射射线,比从相机发射便宜得多——第一次求交已经由光栅化完成了。而且 G-Buffer 提供的法线和粗糙度可以直接用来决定每个像素该发几条射线:光滑表面的反射需要的射线少,粗糙表面可以退回 IBL。

去噪

1 spp 的光追结果基本是噪声。让它可用的是去噪器,这部分的重要性不亚于光追本身。

两个方向:

时序累积。 用运动矢量把上一帧的结果重投影到当前帧,与新样本混合。这实际上把有效采样数乘上了几十倍。难点在于判断历史样本是否还有效——遮挡关系变化、光照变化时必须丢弃历史,否则会出现拖影(ghosting)。

空间滤波。 用邻域的样本补充,但必须是边缘感知的:只和法线接近、深度接近的邻居混合,否则会把不同表面的光照糊在一起。À-trous 小波滤波用逐次增大的步长做多次滤波,以 O(n) 的代价达到大范围滤波的效果。

Apple 提供了 MPSSVGF(时空方差引导滤波)作为现成的实现,它把两者结合,并用方差估计来决定滤波强度——噪声大的区域滤得更狠。

下一篇讲光追的性能优化。

本系列文章

Metal
  1. 01Metal #0 Swift 回顾
  2. 02Metal #1 初始化
  3. 03Metal #2 渲染管线
  4. 04Metal #3 顶点函数
  5. 05Metal #4 片元函数
  6. 06Metal #5 纹理
  7. 07Metal #6 摄像机与交互
  8. 08Metal #7 光照
  9. 09Metal #8 材质
  10. 10Metal #9 渲染通道
  11. 11Metal #10 阴影
  12. 12Metal #11 延迟渲染
  13. 13Metal #12 粒子系统
  14. 14Metal #13 曲面细分
  15. 15Metal #14 后处理
  16. 16Metal #15 反射与折射
  17. 17Metal #16 动画
  18. 18Metal #17 光线追踪(一)渲染算法
  19. 19Metal #18 光线追踪(二)阴影与光照
  20. 20Metal #19 光线追踪(三)性能优化
  21. 21Metal #21 [附录] 计算着色器
  22. 22Metal #22 [附录] SwiftUI 中的 Metal