1. 从“PRT GI实践”说起:这套方案到底在解决什么问题
第一次看到“PRT GI实践”这个组合词,很多人会愣一下。PRT通常指代预计算辐射传输(Precomputed Radiance Transfer),GI则是全局光照(Global Illumination)的缩写。把这两个词放在一起,本质上是在讨论一件事:如何在实时渲染管线里,用预计算的手段把复杂的全局光照效果“固化”下来,再在运行时以极低成本还原出来。这背后对应的场景非常明确——游戏引擎、实时三维可视化、虚拟仿真、数字孪生这类对帧率有硬性要求的交互式应用。
我最早接触这套思路是在一个室内场景实时漫游的项目里。当时场景里有大量间接光照的反弹效果,如果直接用实时光追或者路径追踪去算,中端显卡根本扛不住,帧率直接掉到个位数。后来换成PRT的思路,把静态几何体的光照传输信息提前烘焙成系数,运行时只做简单的向量点积,帧率立刻回到60帧以上,而且间接光的柔和感保留得相当不错。这就是PRT GI实践的核心价值:用离线时间换在线性能,用存储空间换计算开销。
这篇文章适合谁看?如果你正在做实时渲染相关的项目,被全局光照的性能问题困扰,或者你听说过PRT但不知道实际怎么落地,那这篇内容就是为你准备的。我会从整体设计思路讲起,把核心原理拆开揉碎,再给出可复现的实操步骤和参数选择依据,最后把我踩过的坑和排查经验一并分享出来。整个内容基于常见的工程实践进行合理推演,具体参数需要根据你的场景规模灵活调整。
2. 整体设计思路与方案选型拆解
2.1 为什么选PRT而不是实时光追
做实时GI的方案其实不止一种。实时光线追踪效果最准,但对硬件要求极高,而且降噪算法本身就很复杂;光照贴图(Lightmap)简单直接,但它是静态的,动态物体没法用,而且高分辨率下存储开销惊人;球谐光照(Spherical Harmonics)是PRT的核心数学工具,它把光照和传输都投影到一组正交基函数上,用少量系数就能近似表达低频光照信息。
PRT GI实践之所以在工程上受欢迎,核心原因是它在效果、性能、存储三者之间找到了一个很好的平衡点。具体来说:
- 效果层面:PRT能表达低频的间接光照,包括漫反射反弹、软阴影、环境光遮蔽等,这些恰恰是人眼最敏感的部分。高频细节虽然丢失了,但在大多数场景里感知不明显。
- 性能层面:运行时只需要做球谐系数的点积运算,一个像素几十次乘加就搞定,GPU压力极小。
- 存储层面:每个顶点或每个像素存一组球谐系数,通常用RGB三通道各若干阶系数,压缩后开销可控。
注意:PRT不是万能的。它假设场景中的光照传输是线性的,也就是说材质必须是漫反射为主的。如果场景里有大量镜面反射、折射、焦散效果,PRT就力不从心了,这时候需要考虑其他方案或者混合方案。
2.2 球谐函数的阶数选择逻辑
球谐函数是PRT的数学基础,阶数决定了能表达的光照频率范围。常见的阶数选择有:
| 阶数 | 系数数量(每通道) | 可表达频率 | 适用场景 |
|---|---|---|---|
| 2阶 | 4个 | 极低频 | 环境光近似,效果较糊 |
| 3阶 | 9个 | 低频 | 大多数实时GI场景的甜点区 |
| 4阶 | 16个 | 中低频 | 对光照方向性要求较高的场景 |
| 5阶及以上 | 25个以上 | 中频 | 效果更好但存储和计算开销上升明显 |
我在实际项目里最常用的是3阶,也就是9个系数。为什么?因为3阶已经能表达出比较自然的方向性光照变化,比如从窗户射入的光在墙角形成的柔和渐变,而9个系数的存储和计算开销在绝大多数平台上都可以接受。4阶虽然效果更好,但系数数量几乎翻倍,对于顶点数动辄几十万的场景来说,存储压力不容忽视。
2.3 顶点级还是像素级
PRT GI的烘焙粒度有两个选择:顶点级和像素级(通常配合光照贴图UV)。
顶点级的优势是数据直接挂在顶点上,运行时插值自然,不需要额外的UV展开,适合几何密度较高的模型。缺点是如果模型面数不够,光照细节会丢失,出现明显的块状感。
像素级的优势是精度高,可以配合光照贴图的UV布局,在纹理空间里存储系数,适合大面积的静态场景比如地形、建筑。缺点是需要额外的UV展开工作,而且UV接缝处容易出现光照不连续。
我的经验是:角色、道具这类中小型物体用顶点级,大型静态场景用像素级。两者也可以混合使用,比如场景主体用像素级,场景里的可交互小物件用顶点级。
3. 核心细节解析与实操要点
3.1 预计算阶段:传输系数的烘焙
PRT的核心在于预计算阶段。这个阶段要做的事情是:对于场景中的每个采样点(顶点或像素),计算它接收到的来自各个方向的光照传输信息,然后把这组信息投影到球谐基函数上,得到一组传输系数。
具体来说,传输系数T的计算公式可以理解为:
T_lm = ∫ L(ω) * Y_lm(ω) dω
其中L(ω)是从方向ω来的光照,Y_lm是球谐基函数。在实际烘焙时,我们通常用蒙特卡洛积分来近似这个积分:在每个采样点周围发射若干条射线,记录每条射线是否被遮挡、传播距离等信息,然后累加投影到球谐基上。
这里有几个关键参数需要仔细选择:
- 射线数量:每条射线代表一个方向采样。射线越多,积分越准确,但烘焙时间越长。我一般用1024到4096条射线,具体取决于场景复杂度。简单场景1024条就够,复杂场景建议2048条以上。
- 射线最大距离:这个参数决定了间接光能传播多远。设得太短,远处物体的反弹光就丢失了;设得太长,烘焙时间增加且可能引入不必要的噪声。通常设为场景对角线长度的1/3到1/2比较合理。
- 反弹次数:PRT通常只计算一次或两次反弹。一次反弹就是直接光照打到物体后反射到采样点,两次反弹就是再经过一次反射。反弹次数越多效果越好,但计算量呈指数增长。大多数项目一次反弹就够用了。
实操心得:烘焙前一定要先检查场景的法线是否正确。我遇到过好几次烘焙出来的光照方向完全不对,排查半天发现是模型法线反了。这个坑很隐蔽,因为法线反了在直接光照下可能看不出来,但在PRT烘焙时会导致传输系数完全错误。
3.2 运行时阶段:球谐系数的重建
预计算完成后,每个采样点都有一组传输系数。运行时要做的事情是:根据当前的光照环境,计算出每个采样点的最终光照值。
具体流程是:
- 把当前环境光照(比如方向光、点光源、环境贴图)投影到球谐基上,得到一组光照系数L_lm。
- 对于每个采样点,计算光照系数和传输系数的点积:E = Σ L_lm * T_lm。
- 把结果作为间接光照贡献,叠加到直接光照上。
这个过程在GPU上实现非常高效。以3阶球谐为例,每个通道9个系数,RGB三个通道共27个系数。一个像素的计算量就是27次乘加,对于现代GPU来说几乎可以忽略不计。
这里有一个容易忽略的细节:球谐系数的坐标系。预计算时使用的坐标系和运行时使用的坐标系必须一致,否则光照方向会错乱。通常的做法是在世界空间下进行预计算,运行时也把光照投影到世界空间的球谐基上。如果场景中有旋转的物体,需要把光照方向转换到物体的局部空间,或者把传输系数转换到世界空间。
3.3 数据存储与压缩
PRT GI的数据量不容小觑。假设一个场景有10万个顶点,每个顶点3阶球谐RGB三通道共27个浮点数,那就是10万 * 27 * 4字节 = 10.8MB。这还只是3阶,如果用到4阶就是19.2MB。对于移动端或者内存敏感的平台,这个开销需要认真对待。
常见的压缩手段有:
- 量化:把浮点数从32位降到16位甚至8位。传输系数通常范围在0到1之间,8位量化后精度损失在可接受范围内。
- 主成分分析(PCA):把27个系数降到更少的维度,比如降到12维或8维,运行时再通过一个矩阵恢复。这个方法压缩率很高,但会增加运行时的计算量。
- 稀疏存储:对于传输系数接近零的采样点,可以跳过存储,运行时直接返回零。
我在一个移动端项目里用过8位量化加PCA的方案,把数据量压到了原来的1/6左右,效果损失肉眼几乎看不出来。但要注意,PCA的恢复矩阵需要额外存储,而且每个场景的矩阵不一样,需要针对性地训练。
4. 完整实操流程与核心环节实现
4.1 场景准备与检查清单
在开始烘焙之前,有几项准备工作必须做到位:
- 模型检查:确保所有模型的法线正确、没有非流形几何、没有重叠面。重叠面会导致射线求交出现歧义,烘焙结果出现黑斑或亮斑。
- UV展开:如果采用像素级方案,需要确保所有静态几何体有合理的光照贴图UV,UV不能重叠,接缝要尽量藏在视觉不敏感的区域。
- 材质分类:PRT对材质有要求,漫反射材质可以直接用,镜面材质需要特殊处理或者排除。建议在烘焙前把场景中的材质过一遍,标记出哪些参与PRT计算。
- 光照设置:预计算阶段的光照设置和运行时可以不同。预计算时通常用一个均匀的环境光或者一组方向光来激发传输系数,运行时再替换成实际的光照。
4.2 烘焙工具的选择与配置
市面上支持PRT烘焙的工具不算多,常见的有几种路线:
- 引擎内置工具:一些主流游戏引擎提供了PRT或类似功能的烘焙支持,通常集成在光照烘焙流程里,配置项比较直观。
- 离线渲染器扩展:有些离线渲染器可以通过插件或脚本实现PRT烘焙,灵活性更高但需要自己写一些代码。
- 自研烘焙器:对于有特殊需求的项目,自研烘焙器可以完全控制流程,但开发成本较高。
我个人的建议是:如果引擎内置工具能满足需求,优先用内置的,省时省力。如果内置工具不支持或者效果不理想,再考虑自研或扩展。
以常见的配置为例,一个典型的烘焙参数设置如下:
{ "sh_order": 3, "ray_count": 2048, "max_distance": 50.0, "bounce_count": 1, "sample_point": "vertex", "output_format": "float16", "compress": "pca", "pca_dim": 12 }这个配置在大多数中小型场景里都能跑出不错的效果。如果场景特别大,可以把max_distance调大,ray_count适当增加。
4.3 运行时集成与Shader实现
烘焙完成后,得到的数据需要集成到渲染管线里。核心的Shader逻辑大致如下:
// 球谐系数常量,实际使用时通过Uniform或纹理传入 uniform vec3 shLight[9]; // 光照球谐系数 uniform vec3 shTransfer[9]; // 传输球谐系数 vec3 evaluatePRT() { vec3 result = vec3(0.0); for (int i = 0; i < 9; i++) { result += shLight[i] * shTransfer[i]; } return max(result, vec3(0.0)); }这段代码看起来简单,但有几个细节需要注意:
- 系数顺序:球谐系数的排列顺序必须和烘焙时一致,否则结果完全错误。常见的顺序是l=0的1个系数,l=1的3个系数,l=2的5个系数,共9个。
- 归一化:球谐基函数有不同的归一化约定,烘焙和运行时必须统一。我建议在项目初期就确定好一套约定,写进文档里,避免后期混乱。
- 负值处理:球谐重建的结果可能出现负值,这在物理上是不合理的,需要clamp到零。但直接clamp可能导致能量损失,更好的做法是加一个环境光补偿项。
4.4 效果验证与调优
烘焙完成后,不要急着往项目里集成,先做几轮验证:
- 单光源验证:用一个纯方向光照射场景,观察间接光的方向是否正确。如果方向反了,大概率是坐标系或者法线问题。
- 能量守恒检查:把直接光照关掉,只保留间接光,观察场景亮度是否合理。如果太暗,可能是射线数量不够或者反弹次数太少;如果太亮,可能是归一化系数有问题。
- 接缝检查:对于像素级方案,重点检查UV接缝处的光照连续性。如果出现明显的接缝,需要调整UV布局或者增加接缝处的采样密度。
调优阶段最常见的调整是增加射线数量和提高球谐阶数。但这两个操作都会增加烘焙时间和存储开销,需要根据项目实际情况权衡。我的经验是先用低配置快速跑一版看整体效果,确认方向没问题后再逐步提高配置做最终烘焙。
5. 常见问题与排查技巧实录
5.1 烘焙结果出现黑斑或亮斑
这是最常见的问题之一。黑斑通常意味着某些采样点的传输系数计算错误,可能的原因包括:
- 射线求交时打到了物体内部,导致遮挡判断错误。检查模型是否有非流形几何或重叠面。
- 采样点法线方向错误,导致半球采样方向全部朝内。检查模型法线。
- 射线最大距离设得太小,导致间接光无法到达。适当增大max_distance。
亮斑则通常是能量不守恒导致的,比如球谐系数归一化错误,或者多个光源的贡献被重复累加。
5.2 光照方向明显不对
如果间接光的方向和预期相反,或者偏移了90度,基本可以确定是坐标系问题。排查步骤:
- 确认烘焙时使用的坐标系(世界空间还是局部空间)。
- 确认运行时传入的光照系数是在同一坐标系下计算的。
- 检查球谐基函数的定义是否一致,特别是l=1的三个系数对应的方向。
避坑技巧:在项目初期就写一个简单的测试场景,放一个方向光和一个球体,烘焙后旋转光源,观察球体上的间接光是否跟着旋转。这个测试能在几分钟内验证整个流程的正确性。
5.3 运行时性能不达预期
PRT的运行时代价通常很低,但如果出现性能问题,可能的原因有:
- 球谐阶数过高,导致每个像素的计算量过大。检查是否真的需要4阶或5阶。
- 数据传输带宽瓶颈。如果传输系数存在纹理里,检查纹理的格式和采样方式是否高效。
- Shader里的分支过多。尽量把PRT计算写成无分支的直线代码。
5.4 移动端适配的特殊考量
移动端GPU对浮点精度和纹理采样有额外限制。在移动端做PRT GI实践时,我建议:
- 使用半精度浮点(float16)存储球谐系数,大多数移动GPU对半精度有硬件加速。
- 避免在Shader里做复杂的循环,把球谐计算展开成直线代码。
- 如果使用PCA压缩,恢复矩阵的乘法要考虑移动GPU的向量化能力。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 黑斑 | 射线求交错误 | 检查模型几何 | 修复非流形几何 |
| 亮斑 | 能量不守恒 | 检查归一化 | 统一球谐归一化约定 |
| 方向错误 | 坐标系不一致 | 检查烘焙和运行时坐标系 | 统一到世界空间 |
| 接缝明显 | UV布局问题 | 检查UV接缝 | 调整UV或增加采样 |
| 性能差 | 阶数过高 | 检查球谐阶数 | 降低到3阶 |
| 效果糊 | 射线太少 | 检查ray_count | 增加到2048以上 |
6. 进阶扩展与混合方案思路
PRT GI实践并不是一个孤立的方案,它可以和其他技术组合使用,取长补短。我在几个项目里尝试过以下几种混合方案,效果都不错。
第一种是PRT加屏幕空间环境光遮蔽(SSAO)。PRT负责大范围的间接光反弹,SSAO负责近距离的接触阴影和细节遮蔽。两者叠加后,场景的层次感明显提升,而且SSAO的计算量也不大,整体性能仍然可控。
第二种是PRT加反射探针。PRT处理漫反射间接光,反射探针处理镜面反射。这样既保留了PRT的高效,又弥补了它在镜面反射上的不足。关键是要处理好两者的过渡区域,避免出现明显的分界线。
第三种是动态物体的处理。PRT本身只适用于静态场景,但场景里往往有动态物体。常见的做法是给动态物体用一套简化的光照模型,比如用球谐光照直接照射,或者用光照探针(Light Probe)来采样周围的光照环境。光照探针本质上就是把PRT的思想用在了动态物体上,只不过传输系数是实时插值的。
我在实际项目里最常用的组合是PRT加光照探针加SSAO。静态场景用PRT烘焙,动态物体用光照探针,近距离细节用SSAO补充。这套组合在效果和性能之间取得了很好的平衡,中端显卡上跑1080p分辨率轻松60帧。
最后分享一个小技巧:PRT的烘焙时间通常比较长,建议在夜间或者周末跑烘焙任务。如果项目周期紧张,可以先用低配置跑一版占位数据,等美术资产基本定型后再做最终烘焙。这样能避免反复烘焙浪费时间。另外,烘焙日志一定要保留,出问题时可以回溯是哪一步出了差错。