Unity URP中CasualPRT全局光照:预计算辐射传递原理与工程实践

Unity URP中CasualPRT全局光照:预计算辐射传递原理与工程实践

1. 项目概述:为什么我们需要CasualPRT?

在Unity URP(通用渲染管线)里做项目,尤其是涉及到室内场景或者对光影氛围有高要求的项目时,全局光照(Global Illumination, GI)一直是个让人又爱又恨的话题。爱的是,它能让场景的光影关系变得无比真实,光线在物体间弹射、漫反射,形成柔和的间接光照,这是烘托场景氛围、提升画面质感的核心。恨的是,实现它的代价。

Unity自带的实时全局光照方案,比如Enlighten(已弃用)和Progressive GPU/CPU Lightmapper,效果确实不错,但要么烘焙时间长得让人怀疑人生(一个复杂场景动辄几小时),要么在运行时对性能的消耗让移动端和VR项目望而却步。而完全实时的方案,比如URP自带的屏幕空间全局光照(SSGI)或光线追踪(需要硬件支持),要么效果受限(屏幕外的信息无法计算),要么门槛太高。

这就是CasualPRT出现的背景。它不是一个全新的、颠覆性的技术,而是一个非常聪明且务实的“工程化”解决方案。它的核心思想是“预计算辐射传递”(Precomputed Radiance Transfer, PRT)。简单来说,就是把场景中复杂的、动态的光照交互,提前“算好”并存储成一张张轻量的数据“快照”。在游戏运行时,不再需要实时进行昂贵的光线追踪计算,而是通过查询这些预计算的数据,并配合一些简单的数学运算,来快速合成出高质量的全局光照效果。它瞄准的,就是那些需要良好光照质量,但又受限于性能或开发效率的项目。

我自己在几个中小体量的独立游戏和VR教育应用中用过类似的思路,实测下来,CasualPRT这类方案能在效果、性能和易用性之间找到一个非常舒服的平衡点。它特别适合固定场景(如室内、地下城)搭配动态光源(如玩家手持的火把、可开关的灯)这种经典组合。

2. 核心原理拆解:PRT到底“预计算”了什么?

要理解CasualPRT,必须先搞懂PRT的基本原理。别被“辐射传递”这个词吓到,我们可以用一个更生活化的类比来理解。

想象一个房间,墙壁是白色的,地板上有一块红色的地毯。当你打开天花板上的主灯(直接光照),整个房间会被照亮。但你会发现,不仅天花板正下方很亮,房间的角落、桌子的背面也会被一种柔和的、偏红的光微微照亮。这红光哪来的?就是主灯的光线照到红地毯上,被反射(弹射)出去,再照亮了墙壁和角落。这个过程就是间接光照。

PRT做的事情,就是把这个“光线弹射”的规律,提前算出来并记住。它主要预计算两样东西:

1. 光线的“传播路径”:对于场景中的每一个点(通常取模型顶点或纹理像素),计算从各个方向来的光线,经过场景中其他表面的反射后,最终到达这个点的“权重”是多少。这通常用球谐函数(Spherical Harmonics, SH)来编码。SH是一种数学工具,可以把一个球面上的复杂函数(比如来自各个方向的光照强度)用几个简单的系数(比如3阶SH就是9个系数)近似表示出来。预计算阶段,我们就为场景中的每个点计算出一组SH系数,这组系数描述了该点接收来自环境各个方向的间接光的能力。

2. 场景的“可见性”与“互反射”:光线传播不是无阻的,会被物体遮挡。PRT也会预计算遮挡关系。更高级的PRT还会计算光线在多个表面间多次弹射的效果(比如光从A弹到B,再弹到C),这就是“互反射”,能让间接光更加丰富柔和。

在CasualPRT的上下文中,这个预计算过程通常是离线完成的。你需要在Unity编辑器中,设定好场景的静态几何体(墙壁、地板、大型家具),然后运行一个烘焙过程。这个烘焙器会模拟光线在场景中的传播,并为指定的存储点(可能是顶点,也可能是贴在场景中的光照探针网格)生成PRT数据(主要是SH系数)。

运行时,当一个动态光源(比如点光源)移动时,我们不再需要重新进行光线追踪。我们只需要做:

  • 获取这个动态光源的位置、颜色、强度。
  • 对于需要着色的像素点,取出它预计算好的那组SH系数。
  • 将这组系数与动态光源的信息进行一系列数学运算(本质上是将光源投影到球谐基函数上),就能立刻得到该点受到此动态光源影响的间接光照结果。
  • 将这个间接光结果与直接光照、环境光等叠加,就完成了最终着色。

这个过程计算量极小,主要就是一些向量点乘和矩阵运算,GPU非常擅长这个,所以性能开销极低。

3. CasualPRT在URP中的实现架构

理解了原理,我们来看CasualPRT如何集成到URP管线中。URP是一个可编程渲染管线,它提供了清晰的渲染阶段和扩展接口,这是我们能够注入自定义全局光照逻辑的基础。

3.1 数据准备与烘焙流程

CasualPRT的第一步是生成预计算数据。这通常通过一个自定义的编辑器窗口或菜单工具来完成。

1. 场景准备:

  • 静态物体标记:将参与全局光照计算的、不会移动的几何体(如建筑结构)标记为Static。这步和传统光照烘焙类似,告诉系统这些物体的位置和形状是固定的,可以用于预计算。
  • PRT探针布局:这是关键。你需要在场景中放置一系列“探针”(Probe),形成一个3D网格。这些探针就是预计算数据的采样点。布局的密度直接影响效果和内存:探针太疏,光照变化剧烈的地方(如墙角)会出现瑕疵;探针太密,烘焙时间和内存占用会飙升。我的经验是,在走廊、房间门口等光照变化大的区域手动加密探针,在开阔均匀的区域放疏一些。

2. 烘焙参数设置:

  • 射线数量:每个探针在烘焙时会向四面八方发射大量射线来探测环境。射线越多,结果越精确,时间越长。对于大多数室内场景,每探针发射1000-2000根射线是一个不错的起点。
  • 反弹次数:决定光线弹射多少次。1次反弹只计算直接光照经一次反射形成的间接光;2次或3次反弹能捕捉更细腻的漫反射效果,但计算量呈指数增长。室内场景通常2次反弹已足够。
  • 球谐阶数:决定光照方向性的精度。3阶SH(9个系数)足以表达柔和的漫反射间接光;如果想支持高频的方向性阴影(类似环境光遮蔽),可能需要更高阶数,但存储和计算成本也会增加。CasualPRT通常使用3阶,在效果和性能间取得平衡。

3. 执行烘焙:点击烘焙按钮后,后台会启动计算。这个过程可能是CPU并行的,也可能利用GPU加速。最终输出的是一个或多个数据资产文件,里面存储了每个探针位置的球谐系数集。

注意:烘焙时间可能从几分钟到半小时不等,取决于场景复杂度和探针数量。建议在项目中期,场景布局相对稳定后再进行PRT烘焙,避免频繁重复劳动。

3.2 运行时渲染集成

烘焙好的数据如何在URP的每一帧渲染中发挥作用?CasualPRT需要实现一个自定义的RenderFeature

1. 创建PRT RenderFeature:在URP渲染器资产中,添加一个自定义的ScriptableRenderFeature,例如PRTGlobalIlluminationFeature。这个Feature决定了在渲染流程的哪个阶段插入我们的PRT计算。

2. 选择注入点:通常,全局光照计算需要在主要光照计算之后、后处理之前进行。一个常见的注入点是AfterRenderingOpaques阶段。在这个阶段,不透明物体的渲染已经完成,深度和法线信息都已在G-Buffer中(如果启用了),我们可以获取到屏幕像素的世界位置和法线信息。

3. 实现PRT RenderPass:RenderFeature下创建一个ScriptableRenderPass,例如PRTGlobalIlluminationPass。这个Pass的核心工作是:

  • 配置渲染目标:它通常需要将计算结果输出到一张临时渲染纹理(RenderTexture)中,这张纹理存储了屏幕空间的间接光照强度。
  • 设置着色器与材质:使用一个专门编写的Unity Shader Graph或HLSL着色器。这个着色器是核心。
  • 执行绘制命令:通常通过CommandBuffer.DrawProceduralBlit全屏绘制一个四边形,对屏幕上的每个像素执行PRT着色器。

4. PRT着色器核心逻辑:着色器里是算法的具体实现,对于每个像素:

  • 采样G-Buffer:读取该像素的世界坐标(World Position)和法线(World Normal)。
  • 三线性插值:根据像素的世界坐标,找到包围它的8个最近的PRT探针。利用这8个探针存储的球谐系数,进行三线性插值,得到该像素点“所属”的球谐系数集。这保证了光照在空间中是连续平滑变化的。
  • 动态光源贡献计算:获取场景中所有启用的、影响全局光照的动态光源(如点光源、聚光灯)列表。对于每个光源,根据其位置、颜色、强度和衰减,计算它对该像素点的“光源球谐投影向量”。
  • 点积求和:将插值得到的球谐系数(代表该点接收光照的“传输函数”)与每个光源的球谐投影向量进行点积运算。这个点积的结果,就是这个光源对该点产生的间接光照贡献。累加所有光源的贡献。
  • 输出间接光:将累加的结果(一个RGB颜色值)输出到临时渲染纹理。

5. 光照合成:PRT Pass输出的是一张纯间接光照的纹理。我们需要将它和直接光照结果叠加。这可以通过另一个简单的全屏着色器(或合并到后处理中)实现,将间接光纹理按一定权重(通常考虑表面粗糙度,粗糙表面间接光影响更强)叠加到主颜色纹理上。

整个架构的优势在于,动态光源的数量对性能的影响是线性的(O(n)),且每个光源的计算成本极低(几个点积运算)。这与实时射线追踪的指数级复杂度(O(n^2))形成鲜明对比。

4. 关键技术与参数深度解析

要让CasualPRT真正工作得好,不仅仅是把流程跑通,更需要理解并调优几个关键技术和参数。

4.1 球谐函数(SH)的阶数选择与内存权衡

球谐函数是PRT的数学基石。阶数(Order)决定了它能表达的光照模式的复杂程度。

  • 1阶SH(4个系数):只能表达一个均匀的、无方向性的环境光。基本没用。
  • 3阶SH(9个系数):这是最常用的配置。它可以很好地表达低频的、平滑变化的漫反射光照,能区分出基本的“光从左边来还是右边来”的方向性。对于大多数室内间接光,3阶SH已经能产生非常可信的效果。每个探针存储9个RGB系数,就是27个float。
  • 5阶SH(25个系数):可以表达更高频的细节,比如更锐利的阴影边界或方向性更强的光源产生的间接光。但存储成本接近3阶的3倍,计算量也更大。除非你的场景有非常强烈的、方向性明确的间接光(比如透过百叶窗的条形光斑),否则收益不大。

内存计算示例:假设你的场景有10x10x10=1000个探针,使用3阶SH(RGB各9个float,共27个float),精度用半精度浮点数(Half,2字节)。那么总内存占用约为:1000 probes * 27 floats/probe * 2 bytes/float = 54,000 字节 ≈ 52.7 KB。这个开销对于现代游戏来说微乎其微。即使探针数量增加到1万个,也才约500KB。这是PRT方案巨大的优势之一。

4.2 探针布局策略与漏光处理

探针布局是艺术也是科学。均匀网格是最简单的,但效率低下。

策略:

  1. 重要性驱动布局:在光照变化剧烈的区域手动增加探针密度。例如:墙角、门窗边缘、家具交接处、楼梯下方。
  2. 几何体内部剔除:确保探针只放置在可行走/可见的空间内。放置在墙体或家具内部的探针会产生错误的光照信息,导致“漏光”——即光线似乎穿过了厚墙。可以在烘焙前运行一个预处理,将位于碰撞体内部的探针剔除或禁用。
  3. 自适应细分:实现一个算法,在烘焙过程中根据相邻探针光照信息的差异度,自动在差异大的地方细分网格。但这会增加工具复杂度。

漏光处理实战:漏光是PRT的常见问题。除了上述的探针布局优化,还可以在着色器中加入“几何体距离场”或“屏幕空间环境光遮蔽(SSAO)”进行修正。

  • 距离场:预计算一张存储场景中每个点到最近物体表面距离的3D纹理(SDF)。在着色时,如果当前像素点与用于插值的某个探针之间,存在一个距离场值为负的区域(即中间隔着物体),则降低或忽略该探针的权重。这能有效阻挡“穿墙”的光。
  • SSAO叠加:在最终合成光照时,乘以一个屏幕空间的AO因子。SSAO能根据深度信息计算出像素周围的遮挡程度,减弱被遮挡区域的间接光亮度,这可以在一定程度上掩盖漏光,同时增加场景的立体感。

4.3 动态光源的管理与优化

CasualPRT支持动态光源,但需要高效管理。

  • 光源裁剪(Culling):不是每个像素都需要计算所有光源的贡献。对于每个像素(或一组像素块),根据光源的范围(Range)和位置进行裁剪。只计算那些可能对该像素区域产生影响的动态光源。这可以大幅减少计算量。
  • 贡献衰减:在PRT着色器中,光源的贡献应根据标准的光源衰减公式(如平方反比衰减)进行计算,并且当光源与像素点的距离超过光源范围时,贡献应为零。这需要将光源的衰减模型正确地整合到球谐投影的计算中。
  • 光源分组:可以将光源按类型(点光、聚光)或重要性分组,使用不同的计算策略。例如,对主要光源使用精确计算,对远处或次要光源使用更简化的近似。

5. 性能分析与优化实战

说一千道一万,方案再好,跑不动也是白搭。下面是我在实际项目中使用类似CasualPRT方案时的性能分析和优化点。

1. 性能开销分解:

  • 烘焙阶段(离线):耗时,但只执行一次。优化重点在于并行计算(利用所有CPU核心)和智能探针布局减少不必要的探针数量。
  • 运行时(每帧)
    • CPU端:管理光源列表、执行光源裁剪、准备传递给GPU的常量缓冲区数据。开销很小。
    • GPU端:这是主要开销所在。全屏PRT着色器的执行成本。开销取决于:屏幕分辨率、动态光源数量(经过裁剪后)、球谐阶数、以及着色器指令的复杂程度(如是否包含距离场查询)。

2. 优化技巧:

  • 降低计算频率:全局光照不需要每帧都全精度更新。可以考虑每2帧或每4帧计算一次PRT间接光,中间帧复用上一帧的结果。因为间接光本身变化缓慢(除非光源剧烈运动),玩家通常察觉不到。
  • 降低渲染分辨率:将PRT计算在一个较低的分辨率(如1/2或1/4屏幕分辨率)的渲染目标上进行,然后再上采样到屏幕分辨率。这能显著减少像素着色器的调用次数。由于间接光是低频信息,降分辨率带来的模糊感不明显,可以通过双线性或双三次上采样来改善。
  • 分块计算(Tiled):将屏幕划分为多个小块(例如16x16像素的Tile)。在Compute Shader中,为每个Tile计算一个光源列表(列出所有影响该Tile的光源)。然后在PRT着色器中,每个像素只需读取所在Tile的光源列表进行计算。这避免了每个像素遍历所有光源,极大地提升了效率,尤其适合光源数量较多的场景。
  • 简化远处光源:对于距离摄像机很远的光源,可以使用更低阶的SH(甚至用1阶近似为一个均匀颜色)来计算其间接光贡献,或者完全忽略其对远处像素的影响。

3. 目标平台考量:

  • PC/主机:可以开启较高阶的SH(如3阶或5阶),使用全分辨率计算,支持更多动态光源。
  • 移动端/VR:必须激进优化。建议使用3阶SH,开启1/2分辨率渲染,严格限制每帧计算的动态光源数量(例如不超过4个),并启用分块计算。在Quest 2或高端手机上,维持72fps或90fps是可行的。
  • WebGL:需要特别注意内存和带宽。PRT数据资产的大小要控制,避免初始加载时间过长。可以使用纹理压缩格式来存储SH系数(如RGBAHalf纹理),减少下载体积和GPU内存占用。

6. 与URP原生及第三方方案对比

在Unity URP生态里,实现全局光照不止CasualPRT一条路。了解它的定位,才能做出正确选择。

特性CasualPRT (PRT方案)URP 烘焙光照贴图 (Baked Lightmap)URP 屏幕空间全局光照 (SSGI)第三方方案 (如 Bakery GPU Lightmapper)
光照质量。准确的漫反射全局光照,支持动态光源的间接光。。烘焙质量最高,支持复杂的光照和阴影。中低。依赖屏幕深度/法线信息,屏幕外无效果,易有瑕疵。极高。通常使用路径追踪烘焙,电影级质量。
性能开销运行时极低。主要是简单的纹理采样和数学运算。运行时极低。仅采样光照贴图纹理。运行时中高。每像素进行屏幕空间射线步进,开销较大。烘焙时高。高质量的路径追踪烘焙非常耗时。
动态支持优秀。完美支持动态物体和动态光源的实时间接光照。。静态场景+静态光源。动态物体需搭配光照探针。一般。支持动态场景,但效果受屏幕限制。依赖配置。通常烘焙为光照贴图,动态支持类似原生烘焙。
内存占用。存储PRT探针数据,体积小。。存储光照贴图纹理,分辨率越高占用越大。。仅需屏幕缓冲区。。高质量光照贴图体积巨大。
工作流程。需要放置探针并烘焙PRT数据,但烘焙速度通常快于光照贴图。繁琐。需处理UV、设置烘焙参数,烘焙时间长。简单。在URP渲染器设置中一键开启。复杂/高效。第三方工具通常功能强大但需学习,烘焙可能更快或更慢。
适用场景动态光照室内场景、VR体验、移动端高质量GI需求。纯静态场景、对光照质量有极致要求的过场或背景。补充效果、快速原型、对屏幕外效果不敏感的场景。追求极致烘焙质量的静态场景,对烘焙时间不敏感的项目。

CasualPRT的定位非常清晰:它不是为了取代需要极致真实感的离线渲染方案,也不是一个“偷懒”的屏幕空间方案。它是一个为实时应用量身定制的、在动态性和性能之间取得绝佳平衡的生产级解决方案。当你需要场景中的火炬、手电筒、爆炸火光能实时地照亮周围墙壁,并且还要在移动设备上流畅运行的时候,CasualPRT几乎是当前URP管线下的最优解之一。

7. 常见问题与调试技巧实录

在实际集成和使用CasualPRT的过程中,肯定会遇到各种问题。下面是我踩过的一些坑和解决方法。

问题1:烘焙后,场景出现明显的“色块”或光照不连续。

  • 可能原因:PRT探针密度不足,尤其是在光照梯度很大的区域(如明暗交界处)。
  • 排查:在编辑器中可视化探针网格,观察问题区域是否探针稀疏。
  • 解决:手动在问题区域增加探针密度。或者,检查烘焙参数中的“射线数量”是否太低,导致单个探针采样不准确,可以适当提高。

问题2:动态物体(如角色)身上没有间接光,或间接光错误。

  • 可能原因:动态物体着色时,采样PRT数据的方式不对。通常,动态物体需要通过其所在位置,手动查询周围的PRT探针(通过脚本或着色器函数),而不是像静态表面那样在烘焙时固定数据。
  • 解决:确保你的动态物体着色器包含了查询PRT探针的逻辑。通常需要将物体世界坐标传递给着色器,在着色器中实现三线性插值采样。CasualPRT应该提供一个通用的函数或着色器变体供动态物体使用。

问题3:移动光源时,间接光有延迟或闪烁。

  • 可能原因:PRT计算频率太低(如每4帧一次),在光源快速移动时,间接光更新跟不上。或者是光源裁剪过于激进,导致光源在影响边界处时隐时现。
  • 解决:对于快速移动的光源,可以尝试提高PRT计算频率(如每帧计算)。调整光源的裁剪范围,确保有一个平滑的过渡区域。

问题4:在特定角度或位置,间接光突然消失或变弱。

  • 可能原因:探针布局不合理,该位置周围可能没有有效的探针(例如,探针被放置在了墙体内部并被剔除)。或者是着色器中的探针插值代码存在边界条件Bug。
  • 排查:实现一个调试视图,将每个像素采样到的PRT探针索引或权重可视化出来。检查问题区域是否采样到了无效的探针索引或权重为零。
  • 解决:调整探针布局,确保所有需要光照的空间都被探针覆盖。检查着色器代码中处理“找不到有效探针”情况的逻辑,应回退到某种默认环境光,而不是黑色。

问题5:项目构建后,PRT效果消失。

  • 可能原因:PRT的烘焙数据资产(如ScriptableObject或二进制文件)没有被打包进构建中。
  • 解决:确保包含PRT数据资产的Resources文件夹或Addressable地址组被正确包含在构建里。在运行时初始化时,检查数据是否成功加载。

调试这类系统,一个强大的可视化调试工具至关重要。建议在开发CasualPRT时,就内置一些调试模式,比如:显示探针网格、用颜色编码显示每个探针的SH系数强度、显示当前像素采样的探针索引等。这能让你快速定位问题是出在数据、算法还是集成环节。

最后,我想说的是,CasualPRT代表的是一种务实的图形学工程思想——用预计算的智慧换取运行时的性能。它可能没有实时光线追踪那么“炫酷”和物理精确,但对于绝大多数需要平衡效果与性能的真实项目来说,它提供的解决方案是极其可靠和高效的。真正吃透它的原理,根据项目需求灵活调整探针布局、SH阶数和优化策略,你就能在URP管线中打造出既有高质量动态全局光照,又能畅快运行在各种平台上的游戏世界。这其中的调试和优化过程,本身就是一次对光照和渲染管线的深度理解之旅。