这是光线追踪三篇的第二篇,讲怎么用射线算出阴影和全局光照。
阴影射线
阴影是光追最容易见效的应用:从着色点朝光源发一条射线,中间有东西就是在阴影里。
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(时空方差引导滤波)作为现成的实现,它把两者结合,并用方差估计来决定滤波强度——噪声大的区域滤得更狠。
下一篇讲光追的性能优化。