Banner

PRTGI「预计算辐照度全局光照」:从理论到工程实践

一、引言:实时 GI 的圣杯之路

实时全局光照是计算机图形学的终极问题之一。一句话区分两个概念:

  • 直接光照(Direct Illumination):光源 → 着色点。计算简单,方案成熟(Shadow Map + Phong/PBR)。
  • 间接光照(Indirect Illumination):光源 → 场景 → 着色点。计算昂贵,是 GI 真正的难点。

下面四张图是 1 / 2 / 16 次反弹的对比,能直观感受到间接光对真实感的贡献:

直接光照 Direct illumination1 次反弹 One-bounce GI

2 次反弹 Two-bounce GI16 次反弹 Sixteen-bounce GI

1.1 既有方案的局限

方案 光线传输 光照计算 动态光源 动态物体 备注
Ray Tracing 运行时 运行时 性能开销巨大
Lightmap 离线 离线 烘焙后即固化
PRTGI 离线 运行时 部分支持 动态物体只接收不贡献 GI

Lightmap 把所有信息都烘进了一张贴图,运行时一次纹理采样就拿到结果,性能极佳——代价是光源和场景都必须保持静态。一旦场景需要日月轮替、可移动光源,Lightmap 就力不从心了。

PRTGI 的巧思在于把渲染方程的两个阶段拆开:耗时的”光线与场景求交”留在离线,廉价的”光照计算”留在运行时——这样动态光源就能参与 GI 计算。


二、理论基础:从渲染方程到 PRT

2.1 漫反射的渲染方程

漫反射表面的辐照度(Irradiance)可写为对入射 radiance 在半球上的积分:

其中 是 cosine 加权的传输项,本质上也是一个球面函数。

光线追踪求解这个积分时干两件事:

  1. 光线与场景求交:从着色点向半球随机采样若干方向,每条光线和场景求交得到 hit point。这一步是性能瓶颈。
  2. 光照计算(Relight):把每个 hit point 的属性(法线、位置、Albedo)代入光照模型算 radiance。这一步开销很小。

2.2 妥协的艺术:只送大脑

“Send Cerebra Only.” —— 刘慈欣《三体》

实时领域充满妥协。既然 N 次反弹算不过来,那就只算一次反弹。从着色点 A 出发的光线打到的 B 点集合作为间接光源,B 点本身只接受直接光照:

只算一次反弹示意

数学上:

翻译成工程语言:

  1. 计算 A 点的直接光照
  2. 从 A 发射若干方向,与场景求交得到点集
  3. 对每个 B ∈ Ω,计算其直接光照(带阴影)
  4. 加权求和

这个流程的关键洞察是:只要预先知道每个 B 点的法线、世界坐标和反照率,运行时就能用任意光源信息把这些点的直接光照算出来。这就是 “Surfel Cache”——把场景的几何属性记录成一团表面元素(surface elements)。

2.3 PRT 的核心拆分

阶段 干什么 数据
离线烘焙 在场景中均匀撒探针,每个探针用 CubeMap 捕获周围的 Albedo / Normal / WorldPos Surfel Cache
离线投影 把每个探针的方向相关数据投影到 SH 基函数 SH 系数(每个探针 9 维 × N 通道)
运行时 Relight 对当前光照条件,重建每个 surfel 的直接光照,再投影回 SH 动态 SH 系数
运行时着色 着色点采样附近探针的 SH,与法线方向点乘得到 irradiance 最终 GI

接下来的核心问题是:怎么用尽可能少的数据描述每个探针周围的方向相关信息?答案是 SH。


三、球谐函数:低频环境光的高效压缩

3.1 为什么是 SH

球谐函数(Spherical Harmonics)是定义在球面上的一组正交基函数。任何球面函数 都可以展开为:

其中 是投影系数。

SH 之所以适合 GI,理由有三:

  1. 漫反射 BRDF 是低频的。Ramamoorthi & Hanrahan 在 2001 年证明:用 L=2(前 9 项)SH 重建的 irradiance,平均误差 < 1%。
  2. 正交性带来积分简化。两个 SH 函数的卷积变成系数点积:。运行时着色时这点尤其重要。
  3. 旋转不变性。SH 系数在旋转下的变换是线性的,可以通过 Wigner 矩阵高效完成。

3.2 实数 SH 基函数(L ≤ 2)

工程上用得最多的是 L=2 的 9 个实数 SH 基函数

索引 函数表达式 含义
0 (0, 0) 常数项(环境光)
1 (1,-1) y 轴方向梯度
2 (1, 0) z 轴方向梯度
3 (1, 1) x 轴方向梯度
4 (2,-2) 二阶混合项
5 (2,-1) 二阶混合项
6 (2, 0) 沿 z 的二阶项
7 (2, 1) 二阶混合项
8 (2, 2) 二阶各向异性

下面这个交互式可视化把 9 个基函数都画出来——用鼠标拖拽可以旋转视角,正值显示为暖色(红),负值显示为冷色(蓝):

⚡ Three.js · SH 基函数实时渲染 (可交互)
拖拽旋转视图 · 滚轮缩放

3.3 投影与重建

把一个 cubemap 上的 radiance 投影到 SH,本质就是离散化的内积求和:

其中 是该像素对应的立体角。重建时:

更妙的是,漫反射卷积可以在 SH 系数空间直接完成。Ramamoorthi 给出了 cosine lobe 的 SH 系数(),漫反射 irradiance 直接通过逐项乘法得到:

0
1
2

这意味着运行时着色就是 9 次 MAD(multiply-add):几乎免费


四、离线预计算阶段

4.1 探针布置策略(Probe Placement)

最直观的方式是均匀网格(Uniform Grid),但实际工程中纯粹的均匀网格非常浪费——墙体内部、天花板上方的探针完全用不到。常见策略:

策略 优点 缺点 适用场景
Uniform Grid 实现简单,运行时插值快 内存浪费严重 室内小场景、demo
NavMesh-driven 只在可达区域撒探针 需要导航网格数据 室内 / 关卡式场景
Voxelization-driven 用场景体素化结果剔除内部探针 需要离线体素管线 大世界、地形
自适应密度 在几何复杂处加密 运行时需要 KD-Tree / BVH 查询 离线渲染或下一代引擎

工程上一种实用做法是:先撒均匀网格,再用 raycast 剔除”被几何包裹”的无效探针,最后给探针打有效性标志位。运行时着色时如果命中无效探针就 fallback 到附近的有效探针。

4.2 探针数据采集(Surfel Cache)

每个探针位置渲染一张低分辨率 CubeMap(典型 32×32×6 或 64×64×6),但记录的不是 radiance,而是 G-Buffer 通道

  • WorldPos:用于运行时计算阴影、距离衰减
  • WorldNormal:用于光照计算
  • Albedo:表面反照率(可能含 emission)
  • (可选)Depth:用于 Chebyshev 漏光防御

存储形式上,把 6 个 cubemap face 展开成一张 octahedral encoded 的 2D 贴图(octahedron mapping)能让运行时采样更快,且省掉 cubemap 的边缘处理。

1
2
3
4
5
6
7
8
9
// Octahedron encode: dir on unit sphere -> uv in [0,1]^2
float2 OctEncode(float3 n)
{
n /= (abs(n.x) + abs(n.y) + abs(n.z));
float2 uv = n.xy;
if (n.z < 0)
uv = (1.0 - abs(uv.yx)) * (uv >= 0 ? 1.0 : -1.0);
return uv * 0.5 + 0.5;
}

4.3 SH 投影的 Compute Shader 实现

烘焙时给每个探针起一个 dispatch,把 surfel cache 投影到 SH。关键是要把”光照”和”几何”分开存——光照部分(直接光的 radiance)会在运行时重建,所以这里只投影几何(其实就是把每个 surfel 的方向作为基函数采样点,把 surfel 索引按方向打包)。

不同流派做法不同。一种典型实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// SH basis evaluation, L=2, 9 coefficients
void EvalSH9(float3 dir, out float sh[9])
{
sh[0] = 0.282095f;
sh[1] = -0.488603f * dir.y;
sh[2] = 0.488603f * dir.z;
sh[3] = -0.488603f * dir.x;
sh[4] = 1.092548f * dir.x * dir.y;
sh[5] = -1.092548f * dir.y * dir.z;
sh[6] = 0.315392f * (3.0f * dir.z * dir.z - 1.0f);
sh[7] = -1.092548f * dir.x * dir.z;
sh[8] = 0.546274f * (dir.x * dir.x - dir.y * dir.y);
}

// Project a sample direction's radiance into SH coefficients
void ProjectRadianceToSH(float3 dir, float3 radiance, float dOmega, inout float3 c[9])
{
float sh[9];
EvalSH9(dir, sh);
[unroll] for (int i = 0; i < 9; ++i)
c[i] += radiance * sh[i] * dOmega;
}

4.4 数据打包与存储

L=2 SH 每通道 9 个系数,RGB 三通道就是 27 个 float。每个探针 27 floats = 108 bytes。

  • Texture3D 打包:天然支持硬件三线性插值。把 9 个 SH 系数拆成 3 张 RGBA Texture3D(前 3 张存 R/G/B 各 4 个系数,第 4 张补齐)。运行时单个 SAMPLE_TEXTURE3D 自动完成 8 个角点的三线性混合。
  • StructuredBuffer 打包:灵活,但插值要手写。适合非均匀探针布局。

工业界 Unity 的 APV(Adaptive Probe Volume)和 UE 的 LPV 都是后者的变体。一个可行的 Texture3D 打包格式:

通道布局 存储内容
Tex3D_0 RGBA (R0, R1, R2, R3)
Tex3D_1 RGBA (R4, R5, R6, R7)
Tex3D_2 RGBA (R8, G0, G1, G2)
Tex3D_3 RGBA (G3, G4, G5, G6)
Tex3D_4 RGBA (G7, G8, B0, B1)
Tex3D_5 RGBA (B2, B3, B4, B5)
Tex3D_6 RGB (B6, B7, B8)

7 张 Texture3D、108 bytes/probe,对一个 64×16×64 的探针体积就是约 7 MB——完全可接受。


五、实时重光照阶段(Relight Pass)

5.1 着色点 → 探针的插值

着色点 P 落在某个探针格子里,需要从 8 个角点探针拿数据加权混合。两种主流插值方式:

三线性插值(Trilinear)

最简单,也是 Texture3D 硬件天然支持的。8 个角点权重为:

下面这个交互可视化:拖拽中间的红色着色点,能看到 8 个角点探针的权重实时变化(球大小代表权重):

⚡ Three.js · 探针三线性插值 (可交互)
拖动滑块查看权重分配 · 拖拽旋转视图
X轴位置: Y轴位置: Z轴位置:

四面体插值(Tetrahedral)

适合非规则探针布局。把空间用 Delaunay 四面体化,着色点落在某个四面体内,用四面体的重心坐标做权重插值(4 个探针)。Unity Light Probe Group 用的就是这套。优点是探针布置自由,缺点是查询开销大(需要预构建四面体邻接表)。

5.2 重建直接光照

拿到当前光源的方向、强度和阴影信息后,对每个探针的 surfel 重新计算直接光:

1
2
3
4
5
6
7
8
9
10
11
// Pseudo-code: per-probe relight
for each surfel s in probe.surfels:
// 1. 阴影测试(用当前主光的 ShadowMap)
float visibility = SampleShadowMap(s.worldPos, mainLightDir);
// 2. NdotL 漫反射
float3 directRadiance = mainLightColor * visibility
* saturate(dot(s.normal, -mainLightDir))
* s.albedo / PI;
// 3. 投影到 SH(方向是 surfel 相对探针中心的方向)
float3 dirFromProbe = normalize(s.worldPos - probe.center);
ProjectRadianceToSH(dirFromProbe, directRadiance, s.dOmega, probe.shCoeffs);

这一步通常在 Compute Shader 里跑,每帧或每隔几帧更新一次(大世界场景可以做时间分片,每帧只更新一部分探针)。

5.3 着色点的最终采样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 在物体的 Forward / Deferred Shading Pass 里
float3 SampleProbeGI(float3 worldPos, float3 worldNormal)
{
// Texture3D 自动完成 8 个角点的三线性插值
float3 uvw = (worldPos - probeVolumeMin) / probeVolumeSize;
float3 sh[9];
SampleProbeSH(uvw, sh); // 7 次 Texture3D 采样

// SH dot N(cosine lobe 卷积,已包含 Â_l 系数)
float n[9];
EvalSH9CosineWeighted(worldNormal, n);

float3 irradiance = 0;
[unroll] for (int i = 0; i < 9; ++i)
irradiance += sh[i] * n[i];
return max(irradiance, 0) * albedo;
}

5.4 时域累加:模拟多次反弹

只算一次反弹会让画面显得”浅”。一个聪明的做法:把上一帧 Relight 的结果作为这一帧 surfel 的 emission 输入

1
2
3
4
5
6
7
8
Frame N:
surfel.directLight = Relight(mainLight, surfel)
surfel.indirect = SampleProbeGI(surfel.worldPos, surfel.normal) * 0.9 // 上一帧
surfel.totalRadiance = surfel.directLight + surfel.indirect
ProjectToSH(surfel.totalRadiance)

Frame N+1:
...

这样数学上等价于一个无穷级数:第 N 帧的探针值 ≈ 1 次反弹 + 0.9·(2 次反弹) + 0.9²·(3 次反弹) + …

实测下来 5–10 帧之内就收敛到接近多次反弹的视觉效果。这是 PRTGI 看起来”亮”的关键技巧。需要注意能量守恒——衰减系数(这里的 0.9)应该由材质平均反照率推导,避免能量爆炸。


六、工程痛点与解决方案

6.1 漏光与漏影(Light / Shadow Leaking)

这是 PRTGI 最讨厌的伪影。问题根源在于:插值权重只考虑了空间距离,不考虑几何遮挡

典型漏光场景

典型场景:薄墙两侧,墙内一侧的探针被点亮,光照通过插值”穿透”到墙外暗面。

解法 1:法线权重剔除(Normal Weight)

最简单,但效果有限。在权重里乘上 ,剔除掉”在探针背面”的贡献。

解法 2:Chebyshev 可见性测试

借鉴 VSM(Variance Shadow Map)的思路。每个探针不只存 SH 系数,还存一张距离场:从探针中心到周围最近表面的平均距离 和方差 。运行时着色点 P 到探针的距离为 ,用切比雪夫不等式:

下面这个交互可视化展示了 Chebyshev 在 1D 情况下的可见性曲线(拖动滑块改变 μ 和 σ²,看 P(visible) 怎么变化):

⚡ Canvas 2D · Chebyshev 可见性测试 (可交互)
鼠标在区域内左右滑动更改距离 d
μ (均值深度): 100 σ² (深度方差): 400

直觉解释:

  • ,说明着色点离探针比探针看到的最近表面还近 → 没有遮挡 → 可见
  • ,”被遮挡”的概率随 增长,但 (深度方差)大时表面起伏大,依然可能可见

实际工程中通常会再加一个 light bleeding reduction:p = saturate((p - 0.2) / 0.8),把弱可见性直接 clamp 到 0,进一步抑制漏光。

解法 3:探针有效性(Validity)+ 重投影

The Division 的方案:烘焙时给每个探针打 validity 标记(探针在墙体内 → invalid),运行时着色点会跳过 invalid 探针并重新分配权重。

6.2 性能开销与时间分片

一个 64×16×64 = 65536 个探针的体积,每帧全量 Relight 不现实。常见优化:

优化 思路 收益
时间分片更新 每帧只更新 1/N 的探针 Relight 开销线性下降
距离 LOD 远处探针用更低频率(甚至只用静态烘焙) 大世界必备
视锥剔除 只更新视锥内的探针 第三人称游戏可省 50%+
GPU Indirect Dispatch 烘焙数据预处理时按 active probe 打包 省掉 cull 分支
半精度存储 SH 系数用 fp16 显存减半,画质几乎无损

一个参考数字:在 Unity URP + RTX 3070 上,65k 探针、每帧更新 1/8 + 着色 2.5K × 1440p,整个 Relight Pass 约 0.4 ms,最终 GI Sample 约 0.3 ms。

6.3 动态物体的处理

PRTGI 的根本限制是动态物体不参与光线传输——它们能”被照亮”,但不会贡献 GI。常见的折中:

  • 接收方向:动态物体在着色时直接采样 Probe Volume,自然吃到 GI
  • 贡献方向:用一个轻量的运行时方案补充(比如屏幕空间的 SSGI、或者反射探针)
  • 影子与遮挡:动态物体的接触阴影靠 Capsule Shadow / SSAO 兜底

6.4 内存布局陷阱

Texture3D 的内存布局是 swizzled 的,如果探针数据按 (x, y, z) 线性写入,Compute Shader 的访存模式会非常差。建议烘焙输出时就用 Morton 编码(Z-order curve)排列,运行时 Texture3D 采样会自动命中 cache。


七、效果对比与适用场景

烘培
PRTGI

虽然两张图的太阳光方向不一致,但仍然能明显看出 PRTGI相比传统 无GI 方案 在阴影处的暗部细节更丰富、过渡更柔和——这正是间接光在起作用。引用闫令琪教授的话:“评估 GI 好不好,看画面亮不亮、暗部细节明度对比丰不丰富”

适用性总结

场景类型 PRTGI 适用度 备注
室内静态关卡 ⭐⭐⭐ Lightmap 也行,PRTGI 优势在动态光
日夜循环开放世界 ⭐⭐⭐⭐⭐ PRTGI 几乎是最佳选择
大量动态物体 ⭐⭐ 需要配合 SSGI / 实时探针更新
实时场景破坏 Surfel cache 离线烘焙的硬伤
程序化生成关卡 没法离线烘焙

与下一代技术的关系

PRTGI 的思路在 2024 年后依然有生命力,但被进一步改造:

  • Unity APV (Adaptive Probe Volume):自适应密度的探针体积,本质是 PRTGI 的工程化版本
  • UE5 Lumen:屏幕空间 + 距离场的 hybrid GI,可以看作 “运行时 surfel cache”
  • RTXGI / DDGI:用硬件 RT 实时更新探针的 radiance,绕过了”只能一次反弹”的限制

如果你的项目硬件门槛要兼容到 RTX 2060 以下,PRTGI 仍然是性价比最高的方案。


📚 参考文献与延伸阅读

Precomputed Radiance Transfer

[1]

PRT 原始论文 | “Precomputed Radiance Transfer for Real-Time Rendering in Dynamic, Low-Frequency Lighting Environments” — Sloan et al., SIGGRAPH 2002

[2]

SH 辐照度环境映射 | “An Efficient Representation for Irradiance Environment Maps” — Ramamoorthi & Hanrahan, SIGGRAPH 2001

工业界实现

[3]

全境封锁 GI 实现 | “Global Illumination in Tom Clancy’s The Division” — Nikolay Stefanov, GDC 2016

[4]

社区文章

SH 球谐函数学习路径

[8]

Stupid SH Tricks | “Stupid Spherical Harmonics (SH) Tricks” — Peter-Pike Sloan (SH 工程实践圣经)

[9]

本站相关文章 | SH 球谐环境光照理论