做动态物体光照的时候,很多刚接触实时渲染的朋友会卡在一个地方:墙壁、地面这些静态物体可以烘焙光照贴图,效果又好又省性能,可一旦换成角色、载具、可移动的箱子这类动态物体,光照贴图就完全派不上用场了。这时候就会出现一种很尴尬的场景——角色站在刷了光照贴图的墙边,身上却是干干净净的纯色,跟周围的环境完全割裂。光照探针(Light Probe)加球谐光照(Spherical Harmonics)这套组合,就是专门解决这个问题的。今天这篇我用大白话把它讲透,从原理到烘焙,从实时采样到踩坑经验,争取让你看完就能明白这套系统是怎么转起来的,以及在自己项目里该怎么配置、怎么排查问题。
1. 光照探针到底在干什么
1.1 光贴图搞不定的动态物体
先理解一下光照贴图的问题。烘焙光照贴图的时候,引擎把场景里静态物体的表面划分成一个个纹素,每个纹素记录一个从四面八方照射过来的最终光照结果。这个结果跟物体本身的位置、形状是绑定的,所以静态物体用起来非常合适。但动态物体不一样,它每一帧都可能移动、旋转,你总不可能给一个角色预先准备好全地图每个位置的“皮肤”,那样内存和工期都受不了。
那怎么办?思路其实很朴素:既然动态物体的位置不固定,那我们就别只存一张“贴图”,改成一堆离散的采样点散落在场景里,每个采样点记录它所在位置附近的“光的环境”。角色走到哪里,就找周围最近的几个采样点,把它们记录的光照数据按距离或权重混合一下,当作角色当前环境光照。这堆采样点就是光照探针。
这个思路很像你在房间里走动时判断光线亮不亮:你不需要提前画一张整个房间的亮度分布图,只需要记住几个典型位置的亮度,比如窗边亮、沙发暗,那站在窗边和沙发之间时,你就能凭感觉估一个“大概亮度”。探针干的就是这件事,只不过它记得的不仅是亮度,还包括光从哪个方向来、大概什么颜色。
1.2 探针的“采样”本质
每个探针在烘焙阶段做的事情可以理解成:把自己想像成一个漂浮在场景里的小球,往四面八方射出视线,收集来自各方向的光线辐射,然后把这些方向性的光信息压缩存储下来。烘焙完成后,探针就不再参与计算,只保留一组预计算好的系数。
这里有个容易混淆的点:探针存的是“某个点位的环境光信息”,而不是“某个物体表面的光照结果”。它不区分这个位置是一个箱子、一个角色还是一辆卡车,它只描述“站在这个点,往各个方向看,大概能接收到多少光”。至于最终这个光怎样呈现在模型表面,那是后面实时渲染阶段,根据模型表面法线方向去系数里提取结果的事。这种拆分让同一组探针可以被任何动态物体复用。
1.3 两类常见的探针来源
在 Unity 里,探针一般分成两种来源:
- 烘焙探针(Baked Light Probe):由光照烘焙阶段自动计算生成,数据来自场景里灯光的直接光照和间接光照,这是最常用的类型。烘焙质量高,但不能在运行时改变。
- 自定义探针(Custom Light Probe):可以在运行时手动写入或覆盖数据。适合程序化生成的场景、某些需要特定光照效果的位置或者动态切换的关卡。
我自己的经验是,绝大多数项目只需要烘焙探针就够了。自定义探针用得最多的是开放世界里的“时间天气系统”——白天天色、夜晚灯光氛围需要动态变化,烘焙探针数据不能实时改,这时候在关键位置放几个自定义探针,再把系数按世界时间混一下,效果很直观。
2. 球谐光照:探针数据为什么长这样
2.1 一个等式和一套“基函数”
如果探针要存“每一个方向上的亮度”,那它需要的存储量是无穷大的,这不现实。球谐光照(Spherical Harmonics,简称 SH)就是用来把“方向性的光场”压缩成有限个系数的一种数学工具。
它的核心思想可以这样理解:任意一个分布在球面上的函数(比如“从任意方向看过去的光亮度”),都可以拆解成一组标准波形函数的加权和。这组波形函数叫球谐基函数,每个基函数都有固定的“形状”——有的像一束沿着某个轴的亮斑,有的像一坨更复杂的双叶或多瓣花样。你只需要记住“每个方向的光 = 一堆基础形状按不同音量叠加”,就能理解后面的所有内容。
用公式表示就是:
L(n) = Σ c_i * Y_i(n)其中 L(n) 是朝 n 这个方向看过去的光照亮度,Y_i(n) 是第 i 个球谐基函数在方向 n 上的值,c_i 是烘焙时算出来的系数。一旦有了这些系数,任意方向的光照结果都可以通过这组固定的基函数查出来。
2.2 L0、L1、L2 分别代表什么
球谐基函数是按“阶数(band)”分组的。第 0 阶 L0 只有 1 个基函数,形状是一个均匀的球,描述的是“从所有方向平均下来的整体亮度”。这一个系数就代表了环境光的“底色”。
第 1 阶 L1 有 3 个基函数,形状是沿 x、y、z 三个轴的“哑铃”状,描述的是“从哪个方向来的光更多”。这三个系数组合起来可以表示一个有偏向性的光照,比如“上方亮、下方暗”的室外天光。
第 2 阶 L2 有 5 个基函数,形状开始变得复杂,能表示类似“头顶正上方比较亮、地面方向很暗、但水平方向有一个额外的补光”这样的细节。每加一阶,能描述的光照细节就更丰富,但同时要存储和计算的系数也更多。
如果只用一个圆球来类比:L0 大概相当于给你一个平均颜色的纯色球,L1 相当于一个上亮下暗的渐变球,L2 能描绘出更丰富的明暗层次和方向分布。现实中大多数室内外光照,用到 L2 就已经能给出相当可信的大效果。
2.3 为什么常用三阶
你可能会问,那为什么不一直往高阶加?加到 L5、L6,不就能做出更细腻的镜面反射了吗?答案是成本。你把每一阶的系数数量加起来看看:
| 阶数 | 系数个数 | 三个颜色通道合计(RGB) |
|---|---|---|
| L0 | 1 | 3 |
| L1 | 3 | 9 |
| L2 | 5 | 15 |
| L3 | 7 | 21 |
| L4 | 9 | 27 |
| L5 | 11 | 33 |
从 L0 到 L2,每个探针只需要 9 个系数,乘上 RGB 三个通道就是 27 个 float,也就是 108 字节左右。如果把探针数量做到一两百个,这内存是完全能接受的。再往上到 L3、L4,内存还是小事,关键是运行时采样和插值的计算量,以及烘焙时蒙特卡洛积分收敛的难度都会快速上升。
而且球谐描述的是“低频的光照变化”,它天生就不是为了记录锐利的阴影、清晰的反射边缘这种高频信息准备的。那些高频信息应该交给反射探针(Reflection Probe)或屏幕空间反射去处理。所以实际项目里几乎统一用 L2,也就是三阶球谐,这是质量和性能经过无数次验证的平衡点。少数要求特别高的主机项目可能会用 L3,但移动端我基本没见过。
2.4 两个让实时渲染活下来的性质
球谐光照有两个性质,在我看来是整个方案能落地的关键。
第一个是旋转不变性。你旋转球面上光照的方向,不需要重新烘焙,只要把系数重新组合一下就能得到旋转后的结果。这看起来像数学魔法,实际上在工程里特别实用。比如角色原地旋转时,引擎只需要变换 SH 系数的方向向量,不需要重新采样和插值,性能开销极小。
第二个是线性叠加性。两个光源各自烘焙出来的 SH 系数可以直接相加,得到合成后的光照结果。这个性质让烘焙工具可以把直接光照、间接光照、天光分离开,需要调整哪一部分就只重新烘焙那部分,互不污染。不然每次改一盏灯都要全部重新烘焙,项目迭代效率会低到没法用。
3. 烘焙与SH系数是怎么算出来的
3.1 探针烘焙流程
Unity 或 Unreal 的烘焙器在执行光照烘焙时,对每个探针大致会做这样几步:
- 把场景里的几何体、材质、光照一起加载进烘焙器。
- 对探针位置,从球面均匀采样很多方向,通常是几百到上千条视线。
- 对每条视线做光线追踪或基于光子图的估算,得到这个方向入射的辐射亮度。
- 把所有这些方向采样结果,用球谐基函数做积分,得到 L0 到 L2 各阶系数。
- 把系数压缩、编码后写进场景数据文件。
这个过程对性能不敏感,因为只在编辑期跑一次,预览时看到的等待时间主要就是这些视线追踪的耗时。
3.2 积分与采样的伪代码
这里我用伪代码展示一下核心逻辑。假设我们沿球面均匀采样 N 个方向,对方向 dir 打出射线得到 radiance,然后累积到各阶系数:
for each probe: initialize coeff[9] = 0 for i in 0..N-1: dir = uniformSampleSphere(i, N) radiance = traceRay(probe.position, dir) // 获取该方向的入射光 weight = 4.0 * PI / N // 立体角权重 // 累加 L0 coeff[0] += radiance * SH_0_0(dir) * weight // 累加 L1 coeff[1] += radiance * SH_1_m1(dir) * weight coeff[2] += radiance * SH_1_0(dir) * weight coeff[3] += radiance * SH_1_p1(dir) * weight // 累加 L2 coeff[4] += radiance * SH_2_m2(dir) * weight coeff[5] += radiance * SH_2_m1(dir) * weight coeff[6] += radiance * SH_2_0(dir) * weight coeff[7] += radiance * SH_2_p1(dir) * weight coeff[8] += radiance * SH_2_p2(dir) * weight采样方向越多,积分结果越稳定。我在实际调烘焙参数时,通常会先不用超高质量,用 64 或 128 个方向快速看个大概;确认场景布局和探针位置没问题后,再切到 512 甚至 1024 个方向做最终烘焙。移动端项目用 128 个方向已经能获得很干净的结果,没必要把时间全耗在烘焙上。
3.3 内存与压缩存储
探针数据在磁盘和内存里有不同保存格式。烘焙后计算机会把系数编码成 R8G8B8A8 的纹理或特定二进制结构,降低磁盘占用。运行时再解码乘回 float。
需要留意的一点是,探针系数里其实包含了负值。负值不是错误,它表示“这个方向的光被遮挡后的净效果”,是做插值和重建必要的数学信息。所以存储格式必须能保留正负号,直接用无符号纹理存储会把数据弄坏,很多漏光、偏色问题就是这么来的。至少要用带符号的纹理格式,或者在 CPU 侧做重映射。
3.4 实操心得:探针间距和漏光
探针摆放密度很考验经验。放太稀,光照在空间上的变化不够平滑,角色从一个亮的探针过渡到暗的探针时会出现明显的跳跃感;放太密,烘焙数据量大,而且探针之间插值权重会互相拉扯,出现颜色“混脏”的现象。
我通常按光照变化幅度来布点:走廊、门口、窗户边这种明暗剧烈变化的地方隔 1 到 2 米放一颗,空旷大厅隔 3 到 5 米放一颗,室外大草地甚至可以 10 米一颗。如果场景里有竖直落差,比如二楼阳台和一楼地面,一定要分段布点,不要让二楼探针的影响漏到一楼来,否则会出现角色站在楼下却背着楼上灯光的诡异效果。
4. 实时采样:从世界坐标到颜色
4.1 找到身边的探针
运行时,GPU 需要一个人类的视角来理解这个过程:角色处在场景某个位置,它的世界坐标是已知的。引擎要做的事是找到影响这个位置的若干颗探针,算出它们各自的权重,然后加权得到一组 SH 系数,最后用这组合成的系数计算着色。
Unity 的 Light Probe Group 组件里默认使用四面体插值(Tetrahedral Interpolation)。你可以把它理解成把探针摆成一个个共享面的三角锥,角色所在的锥体的四个顶点就是它附近的四颗探针,权重由空间点在锥体内的重心坐标决定。这种方法的优点是完全避免探针之间互相渗透,颜色不会糊成一片,缺点是需要额外计算四面体索引和重心坐标。
另一种常用方式是六面体/贝塞尔插值,适合探针按规则网格摆放的场景。实现更简单,权重计算也更稳定,代价是穿墙漏光的情况会多一些。我的建议是:室内场景用四面体插值更踏实,开放式大世界用规则网格插值效率更高。两者在画质上的差异,其实远比很多文档里说的要小。
4.2 权重和最终光照颜色计算
假设已经找到四颗探针的 SH 系数 C0、C1、C2、C3 和权重 w0、w1、w2、w3,那么当前位置的 SH 系数就是:
C = w0 * C0 + w1 * C1 + w2 * C2 + w3 * C3权重加起来等于 1,每个权重反映角色到对应探针的相对距离。之后用这组 C 和物体表面法线 N 计算最终光照颜色:
color = EvaluateSH(C, N)EvaluateSH 内部做的事情就是之前那个求和公式:把 9 个系数各自乘以对应基函数在方向 N 上的值,再全部加起来。由于这些基函数的计算在 GPU 上只是几个常见的乘加指令,性能开销非常低,这也是光照探针能在手机游戏上大面积使用的原因。
4.3 一个最小可用的 Shader 采样示例
用 Unity 的 ShaderLab 写起来大概是这种感觉(简化版,忽略环境光参数扰动):
float3 EvaluateSH(float3 normal, float4 shCoeffs[9]) { float3 result = 0; // L0 result += shCoeffs[0].xyz * 0.282095; // L1 result += shCoeffs[1].xyz * 0.488603 * normal.y; result += shCoeffs[2].xyz * 0.488603 * normal.z; result += shCoeffs[3].xyz * 0.488603 * normal.x; // L2(这里给出前几项示意,完整版有 5 项) float nx = normal.x; float ny = normal.y; float nz = normal.z; result += shCoeffs[4].xyz * 1.092548 * nx * nz; result += shCoeffs[5].xyz * 1.092548 * nz * ny; result += shCoeffs[6].xyz * 0.315392 * (nx * nx - ny * ny); // ... 剩余 L2 项 return max(result, 0); }Unity 引擎内部提供的 ShadeSH9 函数比这个优化得更彻底,还会乘上环境光亮度参数,但核心逻辑就是这个样子。如果你要手写一个渲染器,对着这个思路去实现就对了。
4.4 常被搞反的系数顺序
我在给别人 review 代码时,见过不止一次把 SH 系数的索引顺序搞反的问题。球谐基函数在不同引擎、不同库里有不同的排列习惯,有的按 band 从低到高排,有的按带符号的 m 值从负到正排。一旦顺序错了,最终光照方向会完全错乱,角色光照会从头部跑到脚底,像是被地图上随机一个位置的光照亮。
解决这个问题没有捷径,就是老实地写一个“方向到系数”的对照测试:在一个已知环境光方向的场景里,用代码去采样 4.3 里那个 EvaluateSH 函数,打印结果和预想方向对比,确认顺序完全一致再往下做。我曾经在这个环节踩过一次坑,当时以为 Unity 和手写渲染器用的顺序一样,结果整个傍晚的光照方向都反了,花了半天才发现是索引顺序问题。
5. 把探针放进真实项目
5.1 Unity 的 Light Probe Group 配置
在 Unity 中创建探针不需要写代码,直接在场景里创建一个 Light Probe Group 对象,然后用编辑窗口里的“编辑探针”模式去摆放。摆放时可以同时选中多个探针做批量移动,也可以按住某个探针单独拖动。
参数上有两个东西值得注意:
- 探针之间间距尽量均匀。间距悬殊会让插值权重不均匀,造成光照抖动。
- 探针不要放进模型内部。放进去的话,烘焙器会在那个位置采到内部全黑的结果,插值时会把黑暗带出来。如果不小心放进去,烘焙预览里那个位置会有明显的黑斑,宁可多花点时间重新摆也不要用后期补救。
烘焙前记得把静态物体标记成 Static,并开启 Contribution Lighting 相关选项,否则探针采样到的只是空荡荡的背景,烘焙出来的效果会差很多。
5.2 和反射探针搭配补高频
球谐擅长低频,但镜面反射、光滑地板上的反光属于高频信息,探针处理不了。所以大型项目基本都是“光照探针 + 反射探针”双管齐下:光照探针负责漫反射和弱镜面的环境色,反射探针负责镜面反射的实时采样。运行时候选 6 面方向渲染低分辨率场景,或使用既有的反射捕捉数据,再按粗糙度分多个 mip 级别。
我还试过用体积光代理体(Light Probe Proxy Volume)来扩展探针的作用范围,让一个很大的动态物体能获得更准确的光照空间变化,而不是只取一个点的 SH。这个功能对在水面、半透明车窗、特殊角色皮肤上呈现大面积渐变特别有用,缺点是 GPU 开销会明显增加,移动端慎用。
5.3 移动端与主机端的性能预算
移动端的话,我用的是“探针数量控制 + L1 降级”策略。当场景里探针数量超过 300 颗时,跑在低端机上帧率会出现明显波动。这时候可以有两种选择:减少探针并接受光照差异;或者把运行时参与插值的阶数砍到 L1,只保留整体亮度和主要方向光,把 9 个系数的计算减到 4 个,一帧里省下的 ALU 相当可观。手机屏幕其实很难察觉出 L1 和 L2 的差距,除非场景里有明显的多方向补光。
主机端则相反,我一般直接把探针数量做到上千颗,配合更高精度的反射探针,让角色在场景移动时始终有平滑连续的环境光照。主机 GPU 的 ALU 吞吐量高,这点开销完全在预算内。
5.4 配置与适用场景速查
| 使用场景 | 方案选型 | 备注 |
|---|---|---|
| 室内走廊、有门缝的墙体 | 光照探针 + 四面体插值 | 间距 1-3 米,避免探针贴墙太近 |
| 开放大世界的草地、天空 | 光照探针 + 规则网格插值 | 间距 5-10 米,用 L1 保性能 |
| 水面、半透明大物体 | Light Probe Proxy Volume | 需考虑额外 GPU 开销 |
| 光滑地面对高频反射 | 反射探针优先 | 光照探针负责漫反射色即可 |
| 时间天气动态切换 | 自定义探针 + 运行时系数混合 | 预先烘焙多套数据再插值 |
6. 项目中踩过的坑与排查套路
6.1 漏光:墙挡不住的光
漏光可能是光照探针最常见的视觉问题。角色站在一堵墙的另一侧,受光面的朝向却被墙后的探针影响,导致墙体像不存在一样被穿光照亮。
我排查这类问题,第一步不是动探针位置,而是先打开烘焙预览和探针可视化,看哪些探针穿透了墙体。探针的采样是无视几何体碰撞的,它只依赖烘焙器里那张不可见的光照图,所以一旦墙体旁边有一颗探针,它就把墙后的光也算进去了。解决办法是:
- 让所有探针离墙至少一个探针直径以上,无法满足时在墙后单独减少探针密度。
- 在探针烘焙阶段使用“裁剪区域”工具,把墙后的探针限定在不参与插值的层里。
- 试过有效的小技巧:把墙体加一层极黑的不可见材质,专门用来挡烘焙裸射线的方向,这样探针在墙后采到的就是受遮挡后的结果。这个做法有点 hack,但在老项目里真的救过场的。
6.2 角色发紫、发绿:世界杯级别的偏色
如果角色整体色调突然漂移,比如皮肤发紫、衣服发绿,最先怀疑的就是 SH 系数里某个通道的数据异常。多数情况是某个探针的烘焙数据在某一个颜色通道里出现了偏大的值,比如一个蓝色屏风近距离超高亮反光被探针采样到,那这颗探针在烘焙时就会给蓝色通道灌入一个巨大的值,角色走到附近时自然被染蓝。
排查方法是把探针 SH 系数导出来,用 CPU 把每个探针的 RGB 三通道分别列出来,看哪个通道的大值分布跟视觉异常吻合。找到问题探针后,要么调整材质让红绿蓝的反光比例更均衡,要么直接手动修改这颗探针的系数上限值。现在的引擎基本都提供探针数据覆写口,直接改不会触发重新烘焙,很省时间。
6.3 覆盖不全导致的闪烁
探针没有覆盖完整空间时,角色移动到探针包络之外,引擎会拿不到合法权重,常见的表现是光照忽然跳一下或者干脆变黑。这个问题在多层楼房、地下通道这种多层面结构场景里特别容易出现。
我建议在搭建场景时直接做一步“探针覆盖检查”:把探针可视化打开,让一个测试小球跑遍所有玩家能到达的位置,凡是小球周围四颗探针缺失的区域,补放探针或调整现有探针位置。这个操作看着麻烦,但能避免上线后玩家到处乱跑时看到一堆闪烁,投到排查的时间远比省下的烘焙时间多。
6.4 排查路径速查表
| 现象 | 排查方向 | 常用手段 |
|---|---|---|
| 动态物体没有环境光 | 探针是否烘焙、物体是否走探针采样 | 打开 Light Probe 可视化 |
| 明暗过渡生硬 | 探针间距过大、权重插值模式不合适 | 缩小间距、换四面体插值 |
| 角色在移动中跳光 | 插值权重不稳定、探针数量突变 | 均匀化间距、排除多余探针 |
| 颜色偏色 | SH 系数通道数据异常、材质反射率 | 导出系数检查单通道数值 |
| 漏光照亮 | 探针穿墙、墙体不规则 | 调整探针位置、加遮挡层 |
| 性能波动 | 探针数量过大、L2 计算成本高 | 降 L1、减少探针、用 LPPV 替代 |
6.5 一个我常用的排查小技巧
最后分享一个我自己实际操作中总结出来的习惯。排查光照疑问时,我一般先在编辑器里把探针的可视化网格打开,然后拖一个低面数的球体在场景里移动,同时在材质球上把光照法线方向暴露出来。当球体表面显示的颜色和场景里实际光影不符时,多半是探针数据本身的问题;当颜色正确但边界过渡不顺畅,多半是插值权重或探针密度问题。学会区分这两种情况,排查效率能提高一大截,不会陷入“改一堆参数但不知道改哪”的困境。