HybridPBR 光线追踪着色器优化——基础篇

在实时渲染与离线渲染的边界日益模糊的今天,在 GPU 上实现一个高效的路径追踪器(Path Tracer)已经成为图形学爱好者的必修课。本文将基于一段核心的 GLSL 代码,剖析一个简易但功能完备的 PBR 路径追踪器的实现细节。我们将探讨如何利用蒙特卡洛积分、重要性采样以及"俄罗斯轮盘赌"等技术,在有限的计算资源下画出逼真的光影。

一、 随机数的艺术:Wang Hash

在 GPU 这种大规模并行计算环境中,生成高质量的伪随机数是蒙特卡洛积分的基石。传统的 rand() 函数在 Shader 中不可用,我们需要一个确定性雪崩效应(Avalanche Effect)极好的哈希函数。

核心代码

uint WangHash(uint seed) {
    seed = (seed ^ 61) ^ (seed >> 16);
    seed *= 9;
    seed = seed ^ (seed >> 4);
    seed *= 0x27d4eb2d;
    seed = seed ^ (seed >> 15);
    return seed;
}

为什么选择 Wang Hash? 1. 速度快:全是位运算和乘法,没有昂贵的三角函数或开方。 2. 雪崩效应:输入的微小变化(如像素坐标 x, y 的差异)会导致输出产生巨大的、不可预测的变化,这对于消除图像中的波纹(Pattern)至关重要。 3. 状态无关:它是一个纯函数,不需要维护全局状态,非常适合并行计算。

配合 RandomFloat 将 uint 映射到 [0, 1] 区间,我们就有了构建整个随机世界的基石。


二、 物理渲染的核心:Cook-Torrance BRDF

为了让材质看起来真实,我们采用了标准的 Cook-Torrance 微表面模型。它由三部分组成:法线分布函数(D)、几何遮蔽函数(G)和菲涅尔方程(F)。

1. 法线分布 (NDF) - GGX

描述微表面法线的朝向分布。表面越粗糙,法线越乱,高光越散。

float DistributionGGX(vec3 N, vec3 H, float roughness) {
    float a = roughness * roughness;
    float a2 = a * a;
    float NdotH = max(dot(N, H), 0.0);
    float NdotH2 = NdotH * NdotH;
    float num = a2;
    float denom = (NdotH2 * (a2 - 1.0) + 1.0);
    return num / (PI * denom * denom + 1e-6);
}

2. 几何遮蔽 (Geometry) - Smith

描述微表面之间相互遮挡(Shadowing)和掩蔽(Masking)的概率。

float GeometrySchlickGGX(float NdotV, float roughness) {
    float r = (roughness + 1.0);
    float k = (r * r) * 0.125; 
    float num = NdotV;
    float denom = NdotV * (1.0 - k) + k;
    return num / (denom + 1e-6);
}

3. 菲涅尔 (Fresnel) - Schlick近似

描述反射率随视角的变化。掠射角(视角与法线垂直)时反射率趋近于 1。

vec3 FresnelSchlick(float cosTheta, vec3 F0) {
    return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

三、 直接光照优化:随机光源采样

这是路径追踪中最关键的优化之一。如果我们场景里有 100 个光源,每一帧对每个像素都循环计算 100 次光照简直是灾难。

蒙特卡洛策略: 我们只随机挑选 1 个光源进行计算,但将结果乘以光源总数(lightCount)。

数学原理

直接光照积分原本是求和: 现在的估计量是: 如果均匀采样,选到任意一个灯的概率

代码解析

// 1. 随机挑一个
int lightIndex = int(RandomFloat(seed) * float(lightCount));
// ... 计算 L, attenuation, shadow ...

// 2. 计算光照贡献 Lo
vec3 Lo = (kD * surf.albedo * INV_PI + specular) * radiance * NdotL;

// 3. 权重补偿:期望正确
return Lo * float(lightCount);

通俗解释:就像请客吃饭。为了省事(省算力),我只随机请 一个 光源吃饭,但在结账时,我按 所有人头(lightCount) 实行 AA 制。虽然单次看账单波动很大(有时抽到大灯,有时抽到没电的灯),但在时间积累下,期望账单和请所有人吃饭是一模一样的。


四、 路径追踪主循环 (TracePath)

这是渲染器的引擎。它不再使用递归(GLSL 对递归支持很差),而是使用 for 循环进行迭代。

核心流程图

  1. 射线求交:没打中物体 -> 返回天空色。
  2. 直接光采样 (NEE):主动连接光源,计算直接照明。
  3. 俄罗斯轮盘赌:决定光线是否“死亡”。
  4. BSDF 采样:计算下一条光线的反弹方向。

关键技术点

1. 俄罗斯轮盘赌 (Russian Roulette)

随着光线不断弹射,能量 throughput 会越来越小。继续计算微弱的光线对画面贡献很低,却浪费算力。

float p = max(max(throughput.r, throughput.g), throughput.b);
if (RandomFloat(seed) > p) break; // 概率性死亡
throughput /= p; // 生存下来的光线能量加倍补偿,保证无偏
这保证了路径长度是有限的,但数学期望上能量不会损失。

2. 下一跳方向的选择

代码根据菲涅尔项(F)动态决定光线是发生镜面反射还是漫反射

  • 镜面概率:取决于材质的金属度和当前的菲涅尔反射率。
  • 漫反射:使用余弦加权采样(Cosine Weighted Sampling),即 normalize(N + RandomSphere)
  • 镜面:使用重要性采样,朝反射方向附近抖动(粗糙度决定抖动范围)。
if (RandomFloat(seed) < specProb) {
    // 镜面反射分支:完美反射方向 + 粗糙度扰动
    vec3 reflectDir = reflect(-V, shadingNormal);
    nextDir = normalize(reflectDir + RandomInUnitSphere(seed) * surf.roughness);
    throughput *= F; // 能量乘以菲涅尔项
} else {
    // 漫反射分支
    nextDir = normalize(shadingNormal + RandomInUnitSphere(seed));
    throughput *= surf.albedo; // 能量乘以反照率
}

五、 时间积累 (Temporal Accumulation)

单帧的路径追踪充满了噪点(Noise)。为了得到平滑的图像,我们利用时间的维度。

if (frameNumber == 0) {
    // 第一帧直接写入
    outputPixels[pixelIndex] = vec4(currentColor, 1.0);
} else {
    // 后续帧:当前颜色与历史颜色混合
    vec3 history = accumulationPixels[pixelIndex].rgb;
    // 混合权重 1/(N+1),也就是累加平均
    vec3 newColor = mix(history, currentColor, 1.0 / float(frameNumber + 1)); 
    outputPixels[pixelIndex] = vec4(newColor, 1.0);
}

通过 mix 函数,我们将每一帧的结果平均化。随着 frameNumber 增加,新的一帧权重越来越小,画面逐渐收敛,噪点消失,最终得到一张完美的渲染图。


总结

这段 Shader 代码展示了一个现代 GPU 路径追踪器的最小闭环: * 用 Wang Hash 制造混沌。 * 用 PBR 约束光影的物理规律。 * 用 随机光源采样 解决直接光照的性能瓶颈。 * 用 俄罗斯轮盘赌时间积累 平衡效率与质量。

掌握这些基础,你就在写出自己的“Cyberpunk”渲染器的路上迈出了坚实的一步.