☰
GPU Driven植被渲染的SH光照坑:球谐、环境探针与动态天空GI实战解析
2026/10/2 10:40:37 网站建设 项目流程

项目把一整面山坡的草、灌木和低矮树丛改成 GPU Driven 实例化渲染之后,前两周一切都很顺:视锥剔除、距离剔除、按 LOD 切网格,跑起来 Draw Call 从几万掉到几百,心里还挺美。直到我打开黎明场景调试,整片草地泛着一种不自然的蓝紫色,树冠和树冠之间的光照像被人拿橡皮擦过一块,探针交界处还在不停闪烁。真正折磨人的根本不是实例化数量,而是 SH、Ambient Probe 和动态天空 GI 这三样东西怎么配合。这篇文章把这些坑一条条捋清楚,送给正在做实时渲染、TA、技术美术或者独立游戏渲染的同行。

1. GPU Driven Vegetation 为什么绕不开 SH 和 Ambient Probe

1.1 GPU Driven 管线只解决了一半问题

GPU Driven Vegetation 的核心思路,是把以往 CPU 上的裁剪、LOD 选择、实例拼接全部挪到 GPU 侧,通过 compute shader 维护一个实例缓冲,直接发给渲染管线。植被这种几万几十万的物体,靠这个方案可以把几何处理成本压得很低。但这套管线解决的只是“画什么、怎么画”的效率问题,没有解决“光从哪来”的问题。

光照方面,很多项目一开始只给植被写了平行光加阴影,结果一旦太阳角度变低,树林里和山的背阴面就黑成一片。草地在阴影里不是变暗,而是直接变成一块没有层次的黑毯。问题在于植被几乎分布在场景里最复杂的空间位置:坡上、林下、峡谷里、树干之间,环境光变化非常剧烈。如果不把环境光纳入 GPU Driven 渲染的实例属性,材质再漂亮也白搭。

所以我的结论是:GPU Driven 负责把植被高效地推上屏幕,但要把植被做得像长在场景里,必须另外处理低频环境光照,shader 里最终用到的往往是 SH(Spherical Harmonics,球谐)和探针数据。

1.2 植被法线太密,不适合高频环境映射

为什么偏偏选 SH?这和植被的几何特性密切相关。一丛草的叶片法线非常密,迎风摆动时法线还在不断变化,如果使用高分辨率的环境贴图去逐像素做反射或者蔽光,会很容易出现高频闪烁。而 SH 本质上是把入射光压到非常低的频段,用一个很小的系数集合去表达近似光照。草叶和树冠的表面大多是漫反射加一点次表面散射,对这种材质来说,低频环境光不仅够用,而且更稳定。

另一个现实原因是带宽。植被实例数量巨大,如果需要 ReadBack 高分辨率环境数据到每个实例,成本不可控。SH 每实例只占几个浮点,尤其在只使用 L1 或 L2 球谐时,可以非常方便地塞进实例缓冲、结构化缓冲或者探针体积纹理里。

说白了,GPU Driven 让大量实例渲染成为可能,SH 让这些实例的环境光照计算保持低廉且稳定。两者天然适合放在一起。

1.3 SH、Ambient Probe 和动态天空 GI 的分工

很多人一上来就混着用这三个词,我把它们拆开讲。

SH 是一种数据编码方式。它可以把整个球形方向上的低频辐射压缩成一组系数,比如 L1 有 4 个系数,L2 有 9 个系数。Ambient Probe 通常指场景中按位置摆放的探针,每个探针里存了一组 SH 系数,代表这个空间点的环境光。探针解决的是“位置”问题,让环境光在山谷、山坡、树冠下方有空间变化。动态天空 GI 解决的是“随时间变化”的问题,让环境光能跟随一天里的太阳高度、云层和天色变化。

具体到实现里,可以这样理解:

名词作用数据形态
SH球谐系数,低频光照的编码方式4 或 9 个浮点系数
Ambient Probe在某个空间位置采样到的环境 SH探针数组里的 SH 系数组
动态天空 GI从实时天空数据生成 SH 系数,让远处环境光动态变化一份全局 SH 系数,随时间更新

后面整个系统的光照结构就变成这样:近处的植被用空间探针来区分区域差异,远处和天空相关的部分用动态天空 SH 来做全局变化。两者再根据地面的遮挡程度、高度和 AO 进行混合。这个分层想清楚了,后面实现起来就不会乱。

2. SH 球谐在植被光照里的实现要点

2.1 SH 系数从哪来

项目里常见两种来源。第一种是离线烘焙,美术在编辑器里摆放探针,引擎把周围的光照烘焙成 SH 系数,运行时直接读。第二种是运行时从天空 Cubemap 生成,后面会单独讲动态天空 GI。

要注意的是不同引擎的 SH 系数缩放约定不一样。Unity、UE 和自己写的引擎,对球谐基函数做归一化的方式可能有差别。所以不要直接把网上抄来的 SH 数组塞进自己的 shader,一定要确认烘焙端和 Shader 端用的是同一套系数缩放。我项目里就出现过一次:烘焙用的 LC 探针系数非常正常,切到我手写的 HLSL 之后整片植被偏绿,查了半天才发现两边对 Y 轴的符号约定相反。

2.2 在顶点着色器里评估 SH

植被的实例数量太大,一般不会在像素着色器里做很重的 SH 评估。常见做法是把 SH 数据放进 StructuredBuffer,按实例索引取出系数,在顶点着色器里用世界法线评估一次,然后作为顶点颜色传递下去。一个简化的 L1 写法长这样:

// 每个实例从 InstanceBuffer 取一组 SH 系数 // 这里展示简化的 L1 求值,系数已经包含球谐归一化因子 float3 EvaluateAmbientSH(float3 normal, float4 coeffs[4]) { float3 col = coeffs[0].rgb; // DC 项,平均环境光 col += coeffs[1].rgb * normal.y; // Y 轴一阶,影响上下半球差异 col += coeffs[2].rgb * normal.x; // X 轴一阶 col += coeffs[3].rgb * normal.z; // Z 轴一阶 return max(0, col); }

实际项目如果是要做探针插值,通常不会只用 L1,而是 L2 九个系数。原理一样,只是多了五个高阶项,代码就是逐项乘到法线组合上,没有本质区别。重点是评估时用的是世界空间法线,而且系数是引擎已经按 SH 基函数缩放好的。

2.3 叶片法线、双面渲染和风动

植被材质最麻烦的是法线。草的叶子很多做成双面渲染,如果背面真的取反法线去评估 SH,会把上半球的天光和下半球的地面反射反过来,结果就是草叶背面发暗或者发脏。我的习惯是,双面叶片在 SH 评估阶段统一用叶片正面的法线近似,而不是简单翻转。

另外 GPU Driven 的植被系统通常会叠加风力摆动。风动你可以在顶点阶段把顶点位移掉,世界法线需要同步更新。如果法线是在所有实例共享的静态顶点数据里预计算的,风动之后法线就对不上了,SH 评估在移动的叶片上就会产生一块块明暗变化。最稳的办法是先算风位移后的修正法线,再进入 SH 取样。

顶点色在这里也非常有用。你要做 AO、风动权重、叶片翻转信息,都可以提前烘焙到顶点色里。AO 不能直接乘到 SH 结果上,否则树下环境光会被压成黑色,后面再细说。

2.4 颜色空间和负能量

SH 评估结果是很典型的线性光。如果材质节点后面接的是 sRGB 输出,千万别在 Gamma 空间里直接叠加。我踩过最明显的坑是:早晨的阳光非常弱,但 SH 里有一项 DC 系数偏高,结果场景整体泛白;后来发现是因为烘焙工具把结果写成了 sRGB,我又在 Shader 里转了一次线性,等于把亮度抬高了。

还有一个问题是 SH 系数在某些方向会出现负数,尤其是 L1 只有一个方向很强的时候。评估后直接输出的负值会导致植被某个朝向的叶片完全黑掉。谨慎的做法是在最终环境光上做一次饱和或者 clamp,但也不要无脑 clamp 到零,那样会丢掉阴影半影里的层次。比较好的思路是:先分通道 clamp 到最小可接受值,再做色调映射。

3. Ambient Probe 的空间布局和 GPU 采样

3.1 探针密度与存储策略

在 GPU Driven 植被里,最忌讳给每个实例塞一整份 SH 数组。如果每个实例传 9 个 float3,那就是 108 字节的额外数据,实例缓冲直接膨胀,带宽压力全部转移到 GPU 侧。通常的做法是按簇或者按地形块组织探针数据。一个地形块共享一组 SH 系数,块内部的实例再根据位置做微量插值。

我实际用的方案是,把探针烘焙到一块 3D 纹理里,或者也叫探针体积。每个纹理像素存储 L2 SH 的 9 个 RGB 系数。植被 shader 拿到世界位置后,换算到体积坐标,直接利用纹理硬件的三线性过滤得到插值后的 SH。这样做的好处是把空间插值的计算从顶点着色器循环里抽走,交给纹理采样单元,而且不用为每个实例单独传大量属性。

3.2 三线性插值边界的黑边问题

探针体积最经典的坑就是插值边界。你把它当成规则网格切分后,每个 cell 的边缘如果正好被山脉切开,或者某个 cell 跨越了两个明暗差异极大的区域,GPU 三线性插值就会在两个探针之间取到很偏的权重,结果出现一条条黑线或亮线。

我有一版探针体积是横平竖直覆盖整张地形图,结果在山腰上出现了一条很明显的弧形暗带。排查后发现问题出在:地形高度变化剧烈的地方,就近的几个探针空间位置差别太大,插值出来等于把悬崖上和峡谷底的光照做了平均。后来把探针按山坡分层布置,同一层内用规则网格,层与层之间用高度权重混合,这个暗带才消失。

经验是要看着探针盒布线,而不是用 Excel 式的均匀网格去硬切。山谷、山脊、树冠高度落差大的地方,需要单独加密探针。

3.3 上下半球分离与高度混合

植被场景里,草贴近地面,受到的地面反射远大于天光;树冠顶端则几乎只能看见天空。只用单一 SH 做高度插值很难同时适应这两个位置。所以我会把探针数据分成上下两个半球系数,或者更直接一点,每个采样点存两份 SH:一份主要表现天空贡献,一份表现地面反射。

在 GPU Driven 的实例数据里,可以额外存一个高度因子,代表这个实例位于低矮草层还是高树冠层。最终评估时,用这个高度因子在“天空贡献 SH”和“地面贡献 SH”之间做一次平滑混合。这个方法在做植被时非常实用,效果比单纯升维插值自然得多。

要注意的是,这个高度因子不要用绝对世界高度,最好基于地表高度做归一化。也就是说要存一个地皮高度到实例属性里,不然换关卡或者改地形,所有参数都要重调。

3.4 静态遮挡和 AO 的乘法顺序

探针反映的是环境辐射,它通常不包括局部的自遮挡。草叶之间有大量叶片互相挡住天空,但你如果把烘焙好的 AO 纹理直接乘到整套环境光上,树冠深处就会彻底死黑,因为 AO 不仅挡住了直接光,也把天光和周围反射一起抹掉了。

我后来改成两路处理:直接光那一部分使用 AO 做阴影衰减,环境光这一部分只使用一个很轻的“环境可见度”,并且和高度混合后的 SH 结果相乘,保留一点暗部层次。这个细节看起来不动声色,但真实项目中,它决定了一片树荫下的草地到底是“暗得有颜色”,还是“一团黑”。

4. 动态天空 GI 接入植被系统

4.1 从天空 Cubemap 更新 SH 系数

动态天空 GI 的作用,是让环境光的低频部分跟随天空实时变化。实现上通常是把天空 Cubemap 投影到球谐基上,得到一组新的 SH 系数。有很多现成方案,但原理基本一致:在天空 Cubemap 上按球面均匀采样,每个采样方向计算对应的球谐基函数值,再用采样能量做加权累加。

伪代码大致是这样:

for (int i = 0; i < sampleCount; i++) { float3 dir = FibonacciSphereDirection(i, sampleCount); float3 radiance = skyCube.SampleLevel(linearClamp, dir, mip).rgb; for (int l = 0; l < 9; l++) { ShanghaiCoefficients[l] += radiance * SHBasis(dir, l) * solidAngle; } }

这里要提醒的是,不要每帧都重新生成整组 L2 SH。天空低频变化没那么快,可以把更新频率降到每 0.25 秒一次,甚至一秒一次,然后把新旧系数之间做几十帧的平滑插值。这样做能明显降低 GPU 和 CPU 的波动,也不会被云层快速变化搞到闪烁。

4.2 晨昏变化与动态光照衔接

动态天空 SH 最明显的效果在清晨和黄昏。太阳高度角低的时候,天空的上半部分可能还是蓝的,但地平线附近已经很红、很暖,L1 的 Y 轴系数会发生明显偏移。如果你直接把这份系数塞给植被,草地会瞬间变成蓝紫色或红色,而且很容易和主光的方向不一致。

处理办法是在天空 SH 生成时,把它当成一个“整体环境”,而不是单独的局部颜色,然后与主平行光的太阳方向做粗略对齐。太阳方向主要贡献给直接光,天空 SH 里的半球权重不要反向。做动态天空 GI 时,我会把更新结果先送入滤波函数,抑制过大的方向突变,保证肤色和植被不会因为一朵云突然变色。

4.3 动态天空 GI 与 Ambient Probe 的融合,避免双重计算

动态天空 GI 和本地探针之间,最容易出的问题是双重计算。如果探针烘焙的时候已经把一部分天空天光也算进去,运行时再把动态天空 SH 加一遍,整体环境光会偏亮、发灰。

我的做法是,探针烘焙时固定使用一张基准天空图,运行时动态天空作为低频修正,而不是直接加法叠加。融查权重取决于实例的天空可见度:树冠上方的草可见天空多,就多偏向动态天空;低矮草丛和峡谷里的植被,天空可见度低,就保持探针为主。

一个很朴素的代码结构:

float3 probeLight = EvaluateAmbientSH(probeSH, normal); float3 skyLight = EvaluateAmbientSH(skySH, normal); float skyWeight = saturate((skyVisibility - 0.25f) * 1.5f); float3 ambient = lerp(probeLight, skyLight, skyWeight);

这个 lerp 是方向性的参考,不要当成万能公式。核心原则是探针负责局部,天空 SH 负责整体颜色随时间变化,两者权重再随遮挡变化。

4.4 和主光、阴影的级联顺序

植被最终的输出以下面这个顺序为准:

直接光 = 主光颜色 × 阴影 × NdotL 环境光 = SH/探针结果 × 环境可见度 最终颜色 = (直接光 + 环境光)× 表面漫反射

阴影只应该衰减直接光,不能衰减环境光。否则树荫底下没有任何散射光,画面就会变成一片死黑。真正看起来漂亮的植被暗部,是环境光提供了微妙的蓝、绿、紫的过渡。

如果发现整片山坡在树影里发黑,优先检查是不是把阴影乘到了最终混合色上,而不是只乘在主光项上。

5. 实战踩坑记录:渲染问题与工具链 sh 脚本

5.1 视觉伪影清单

整理一下我在项目里遇到过的几类和 SH、探针、天空 GI 有关的视觉问题:

树冠边缘出现闪烁。多半是 SH 更新频率太高,或者法线精度不够。把 L1/L2 系数改成半精度存储后问题缓解了不少,顶点法线也顺手提了精度。

探针交界处有黑线。这是三线性插值在边界上取到了极端权重,导致颜色跳变。修复方法是把探针盒分组,并且在地形剧烈起伏的地方按高度分层加密,而不是依赖规则网格。

草地在黎明变成蓝紫色。最先怀疑的不是探针,而是天空 SH 的 Y 轴系数和主光方向脱节。给天空 SH 做平滑插值之后,颜色过渡自然多了。

背面叶片偏暗。双面叶片的背面还在用反法线,尝试统一用正向法线评估 SH,效果好很多。

5.2 性能与带宽优化

GPU Driven 植被的实例数据再怎么省,带宽都容易失控。SH 系数尽量用 half 精度存储,评估时再扩展回 float,对植被这种高频视觉细节来说,半精度导致的误差完全不明显。

另外一个节省带宽的动作是不要给每个实例都复制完整探针数据,而是让实例存一个索引,去访问一块很小的 SH 数组。如果采用 3D 纹理方案,存一个位置换算信息就够了,甚至可以在顶点着色器里直接采样纹理。

天空 SH 的更新一定要放到低优先级,放在后台线程处理好。这样可以避免操作系统中被探针烘焙的高峰期把主线程拖垮,也不至于在角色拐弯时看到环境突变。

5.3 配套 sh 脚本的环境坑:权限、换行与死循环

其实这套工程里的另一半痛苦,和 Rust 或 C++ 引擎源码编译、探针烘焙的批处理脚本有很大的关系。探针烘焙时我写了很多.sh脚本,sh 脚本语法本身不算难,难在开发机是 Windows、执行机是 Linux,两边环境差异非常折腾。

从仓库拉下来直接在 Linux 上跑的时候,最容易看到这样的报错:

/bin/sh: line 1: .//incdefs.sh: Permission denied make: .//version.sh: Permission denied

这跟 SH 球谐一点关系没有,但会把烘焙流程直接卡死在第一步。原因基本都是 Windows 上拉下来的文件丢了可执行权限,或者行尾换行符是 CRLF。修复命令很简单:

chmod +x incdefs.sh version.sh sed -i 's/\r$//' incdefs.sh version.sh

之后用显式bash而不是./执行:

bash bake_gi.sh

在 Windows 的 OpenSSH 会话里执行这种脚本时,最稳的就是调用bash,不要依赖默认 sh。否则脚本内部用到的变量、路径,经常因为 shell 解释器的差异而出问题。

烘焙批次大了以后,我还会先用du -sh */扫一下缓存目录,不然几轮迭代下来磁盘容易爆。其次是给每个需要长时间运行的脚本套一个timeout:

timeout 600 ./bake_gi.sh

万一脚本写错了进入死循环,服务器 CPU 被吃到 100%,直接pkill -f bake_gi把任务杀掉,不要让 CI 一直挂着烧机器。

5.4 问题排查速查表

现象常见原因处理思路
探针交界面黑线三线性插值权重异常加密探针,分层布置,不跨越剧烈地形
树冠边缘闪烁SH 更新频率太高或法线精度不足降低更新频率,SH/法线改半精度
清晨植被蓝紫天空 SH 与主光方向脱节平滑天空 SH,做新旧系数插值
背面叶片发黑背面法线取反导致 SH 半球颠倒双面统一正向法线
阴影里死黑阴影乘到了环境光上阴影只作用于直接光
脚本报 Permission denied文件缺少可执行权限chmod +x
脚本换行报错Windows CRLF 行尾sed -i 's/\r$//'

最后说一个自己的习惯:每次改完地形或植被密度,我都会把探针显示打开,跑一遍关键角度,并且检查清晨、正午、傍晚三个时间点的场景。如果发现某片山坡的草在午后偏暖和的时候发闷,我会先怀疑探针插值或者上下半球权重,而不是急着改贴图。这套 SH、Ambient Probe、动态天空 GI 的方案,每个模块单看都不复杂,但合在一起就特别考验细节,希望上面这些记录能让你少烧几根头发。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询