如果你和我一样,被一整片草原加上十几万棵树逼到用 GPU Driven Vegetation,就会明白:把 CPU 端的实例剔除搬到 GPU 只是万里长征第一步。真正让人挠头的是,怎么让这些成千上万的草叶和树枝拥有合理的环境光照——不是那种镜头一拉远、整片树林全都一个灰绿色的“伪GI”,而是会随着天空颜色变化、随着山坳遮挡变化、镜头靠近树冠时能看到明暗过渡的真实感。这篇文章就记录我在 GPU Driven 植被方案里接入 SH(球谐)、Ambient Probe 探针体积,以及动态天空 GI 的完整踩坑过程。
这些坑涉及数据链路怎么改、SH 系数在实例缓冲里怎么打包、为什么草叶背面总是死黑、动态天空更新时探针又为什么闪烁。如果你正在做大地图植被渲染,或者打算把环境光从“全场景一个值”升级成“每棵树都有自己的探针光”,这篇文章应该能帮你少走不少弯路。
1. 整体思路:为什么 GPU Driven 植被需要一套独立的 GI 数据流
1.1 百万实例的实时渲染,瓶颈往往不在绘制而在环境光
先交代一下项目的基线。我做的是一张 2km×2km 的大地图,植被主要由三部分组成:大面积草地、散布的灌木,以及成片的树。数量级是草约 300 万实例、灌木约 20 万、树约 8 万。早期版本走的是 CPU 侧网格实例化:每个植被类型先做视锥剔除和距离 LOD,再把可见实例的矩阵塞进一个 InstanceBuffer,用间接绘制提交。这个方案在实例数 5 万以下没问题,一旦超过 20 万,CPU 每帧要做的事情就多了:排序、生成矩阵、管理 LOD 和剔除结果,不仅帧率波动明显,而且 Draw Call 虽然不多,CPU 耗时却一直在涨。
后来把剔除搬到了 Compute Shader,也就是典型的 GPU Driven 流程:Compute 里做视锥剔除、Hism 遮挡剔除、距离 LOD,然后直接输出 DrawIndexedIndirect 参数。效果立竿见影,CPU 端每帧的耗时降到了几乎可以忽略的程度,Draw Call 也稳住了。
但很快暴露了新的问题:场景里每棵树的“环境光”完全一样。打开帧调试器一看,Shader 里采的还是引擎默认的全局 SH——也就是相机所在位置的那个探针的环境光。不管镜头走到山脚还是山顶,不管这棵树是站在开阔地还是深谷里,它拿到的环境光都是同一组 SH 系数。结果就是:整片树林看起来像一张贴图,毫无 GI 层次感。
所以说,GPU Driven 解决的是“怎么把这么多实例画出来”,但它没有回答“怎么让这么多实例各自拥有正确的光照”。如果想要高质量植被,必须给每棵树、每簇草一条独立的 GI 数据流。这就是引入 SH + Ambient Probe + 动态天空 GI 的原因。
1.2 SH、Ambient Probe 与动态天空 GI 的组合逻辑
先说 Ambient Probe。它本质是在场景里布置一组空间探针,每个探针记录该位置的环境光场。渲染时根据实例的世界坐标,在探针网格里做三线性插值或者最近邻插值,得到这个位置的环境光。问题是 300 万个草叶不可能每个都去插值一次再计算完整光照,必须把探针数据压缩成很小的结构。
SH(球谐)就是用来做这个压缩的。环境光在空间上的分布看起来复杂,但低频部分很少,用 L0、L1、L2 这些阶数就能重建出足够好的漫反射效果。L0 一个常数表示平均亮度,L1 三个系数表示方向性,L2 五个系数补充细节。对漫反射来说,L2 基本够用,数据量很小,很适合塞进实例的自定义数据里。
动态天空 GI 则是让这套系统“活”起来的关键。如果只有静态探针,天空从正午变成傍晚时植被环境光不变,就很出戏。所以需要一套机制,让每帧变化的天空 SH 系数能及时作用到所有可见植被实例上。这里的难点在于:探针数量动辄几百上千个,不可能每帧全部重新烘焙;而每个实例又需要读出属于自己的、包含天空变化的插值结果。因此需要设计一个低成本的更新链路。
一句话总结我的方案:探针负责采集、SH 负责压缩、GPU 剔除阶段负责按实例插值写入、动态天空更新负责让全局环境光随时间流转。四者串起来,才是完整的数据流。
2. 数据链路改造:从探针体积到每个植被实例
2.1 剔除阶段采样探针,还是片段着色阶段采样?
第一个要决定的问题:探针插值应该放在哪一步执行。
我试过两种做法。第一种是在顶点/片元着色器里,根据世界坐标实时从探针体积采样。好处是数据永远是最新的,不需要额外的存储;但坏处很明显:植被实例数量巨大,一个草叶 4 个顶点可能都分布在不同的探针权重区域,每个顶点都做三线性插值会浪费很多计算;更麻烦的是,植被往往有顶点动画(草随风摆),顶点坐标会动,探针插值结果也跟着抖,很容易出现光照闪烁。
第二种是在 GPU 剔除阶段做探针采样。Compute Shader 里拿到每个实例的世界坐标,去探针体积插值出 SH 系数,写进实例数据缓冲,绘制时直接用。这个方案我当时觉得最干净:每帧只需要处理可见实例,数量通常在几万到几十万之间,插值开销远比逐顶点小得多。而且数据是稳定的,同一棵树只要位置不变,探针插值结果就不变,不会出现逐顶点闪烁。
最终我选了第二种。具体流程是:Visibility 剔除 → 距离 LOD → 计算实例的探针 SH → 写入 InstanceBuffer → IndirectDraw。探针插值和剔除放在同一个 Compute Shader 里完成,这样只需要一次 Dispatch,数据流最短。
2.2 实例自定义数据的打包与带宽考虑
既然每实例都要带 SH 系数,就得重新设计 InstanceBuffer 的布局。我们原来的实例数据包含位置、旋转、缩放以及一些动画参数。SH 系数怎么塞进去,是个需要仔细算的问题。
以 L2 阶 SH 为例,完整系数是 9 个 float。如果每实例直接加 9 个 float,算上原有的位置旋转缩放,一个实例可能 48 字节变 84 字节。假设一帧可见 30 万实例,每帧更新一次缓冲,也就是约 25MB 的写入带宽,在桌面级 GPU 上还能接受,但在主机或者低带宽设备上就会成为瓶颈。
可以做几个压缩:
- L0 用半个 float 存储,也就是 half;
- L1 三个系数用 half3 存储;
- L2 的 5 个系数,如果对方向性细节要求不是特别高,也可以用 half4 加一个 half,或者干脆把 L2 压成 2 个 half2。
实际项目里我用了“L0 一个 half + L1 三个 half + L2 五个 half”的布局,一个实例的 SH 部分是 9 个 half,也就是 18 字节。这个开销完全可以接受。如果把 L2 砍掉,只剩下 L0+L1,每实例只有 8 字节,但表现上树冠的明暗层次会弱一些,草地尤其明显。所以我不建议无条件砍 L2。
另外要注意数据对齐。很多 GPU 的 buffer 读取是按 16 字节对齐的,half4 一组一组地塞,比零散 half 更友好。我最终把实例数据定成如下这种结构,便于读取:
struct InstanceData { float4 posScale; // xyz 位置, w 缩放 float4 quat; // 旋转四元数 float4 shL0L1; // x: L0, yzw: L1 (已经是辐照度卷积后的值) float4 shL2A; // L2 阶的前两个系数 float4 shL2B; // L2 阶的后三个系数,第四分量可放动画相位 };这套数据在 Compute 里一次性写好后,绘制阶段的 Shader 只需要按SV_InstanceID读取对应下标,不再做任何探针计算。带宽方面,如果可见实例数控制在 40 万以内,整份 InstanceBuffer 大约 40MB 每帧,配合 GPU Driven 剔除后实际写入量通常更少,在 PC 上跑完全没问题。
2.3 一个可供参考的 Compute Shader 探针采样片段
下面这段逻辑是从项目里抽出来的,只保留核心。探针体积在场景里是一个包围盒,里面规则排列 nX×nY×nZ 个探针,每个探针存 L2 SH 系数。Compute 里按实例坐标算出归一化 UVW,然后做三线性插值。
StructuredBuffer<ProbeData> _ProbeBuffer; RWStructuredBuffer<InstanceData> _InstanceBuffer; float3 GetProbeSH(int index, float3 dir) { // 这里简化了,实际会展开 L0/L1/L2 三组 return _ProbeBuffer[index].shL1.rgb + dir; } [numthreads(64, 1, 1)] void CSMain(uint3 id : SV_DispatchThreadID) { uint instanceCount; _InstanceBuffer.GetDimensions(instanceCount); if (id.x >= instanceCount) return; float3 worldPos = _InstanceBuffer[id.x].posScale.xyz; float3 uvw = (worldPos - _ProbeVolumeMin) * _ProbeVolumeInvSize; // 采样 8 个邻近探针并做三线性插值 float3 p000 = floor(uvw); float3 frac = saturate(uvw - p000); // ... 访问 _ProbeBuffer,按权重混合出 SH 系数 float4 shL0L1 = ...; float4 shL2A = ...; float4 shL2B = ...; _InstanceBuffer[id.x].shL0L1 = shL0L1; _InstanceBuffer[id.x].shL2A = shL2A; _InstanceBuffer[id.x].shL2B = shL2B; }代码本身不复杂,但要注意探针体积的边界。如果实例坐标落在包围盒外,我会用 saturate 钳制 UVW 到 [0,1],而不是直接丢弃。最外层的植被哪怕环境光不太准确,也比漆黑一片强。
3. 核心坑:SH 重建、半球光与动态天空更新
3.1 第一次接入 SH,整个植被层偏亮还发灰
这是所有接 SH 的人几乎都会踩的坑,我也不例外。第一次把探针的 SH 系数塞进实例数据,满心期待地拉高摄像机一看,草地整体发灰,树冠亮得不正常,阴影面反而泛白,整个画面像是盖了一层雾。
排查到最后,问题出在我把“光照度 SH”直接当成了“辐照度 SH”来用。环境贴图本身可以用 SH 表示,但那表示的是各个方向的入射光亮度,是一组“光照度系数”。可渲染植被需要的是对法线方向做半球积分的辐照度,这两者之间差了一组卷积核。
简单说,L0 对应的卷积权重是 π,L1 及更高阶对应的是 2π/3 之类。如果你拿到的 SH 是环境贴图直接投影来的,需要先乘上卷积系数再用法线采样;否则重建出来的光会比真实辐照度偏亮或者偏平。Unity、UE 内置的ShadeSH9这类函数通常会帮你做卷积,但我在自定义 Compute 链路里完全绕过了引擎 API,所以只能自己算。
这个坑给了我一个教训:不要相信“接口返回 SH 就能直接用”,先确认这个 SH 到底是用于描述光照度还是辐照度。最好的验证方法是在一个小场景里放一个标准球,把 SH 重建出来的光照和直接烘焙的球体贴图对比,差异一眼就能看出来。
3.2 草叶背面总是死黑,原来是半球光的锅
另一个让我印象深刻的坑是草叶背面死黑。我的草地 Shader 是双面渲染的,明明开了 Cull Off,但叶片背面的环境光几乎为零,尤其是中午太阳高挂的时候,草叶下表面黑得像被墨水泡过。
一开始我以为是法线问题,后来逐步分析才发现是 SH 重建模型的问题。探针存储的环境光是全空间分布,但草地上方的叶片主要接收来自天空半球的光,地面以下半球的贡献应该很小。理想情况下,草叶法线朝上时,背面光照应该来自半球积分,而不是简单的整球积分。
直接用法线点乘全空间 SH,相当于把来自地面方向的“负贡献”也算进去了。这会导致两个问题:一是背面被低估,二是叶片在侧面旋转时亮度变化特别突兀。解决办法是做一个朝向法线方向的“上半球 SH 重建”,也就是把 SH 重建时的权重函数从全空间余弦改成朝向法线的半球余弦。
实际操作里,我没有在渲染阶段做复杂的半球积分,而是用了近似:把 SH 的 L0 项稍微提高,并给草叶加一个基于saturate(dot(N, up))的补光因子。这个技巧效果还不错,既避免了死黑,又不需要每像素做昂贵计算。
3.3 动态天空 GI:低频更新与线性组合更新
动态天空 GI 是整个链路里最折腾的部分。场景里有一个可以随时间变化的天空,从清晨到黄昏,天空 SH 系数每帧都在变。最简单的做法是把全局天空 SH 每帧传到 GPU,所有实例的环境光都加上这个变量。这确实能让植被“动起来”,但效果很假:大树下、山谷里明明看不到天空,却也跟着天空一起亮。
要做得正确,需要把每个探针的“天空可见性”考虑进去。每个探针所在位置能看到多少天空,决定了动态天空 SH 对它有多大贡献。这就是把探针数据拆成两部分:一部分是静态的场景反弹光(树、山体、地面之间互相反射),另一部分是动态的天空直达光。静态部分不需要频繁更新,动态部分则可以由当前天空 SH 和探针预计算的“天空可见性矩阵”相乘得到。
我最终实现的方式是:每个探针额外存一个 3×N 的传递矩阵,N 取决于天空 SH 阶数。每帧只需要做一次矩阵乘,把当前天空 SH 转换成该探针的动态天空贡献,加上静态项,就是最终探针 SH。一千个探针每帧做几百次浮点运算,在 GPU 上几乎可以忽略不计。
如果预算更低,也可以不做矩阵,直接把更新频率降到每 0.5 秒一次,然后在帧间用指数平滑过渡。这个方法能挡住 90% 的可见闪烁问题,代价是快速旋转视角时,环境光变化会轻微滞后,但大部分玩家不会注意到。
4. 实战排查:四个必踩的现象与解决记录
4.1 现象一:整片植被环境光完全一样,毫无空间变化
- 表现:镜头从山脚拉到山顶,树的环境光亮度没有任何变化,打开颜色调试,所有实例的 SH 都相同。
- 原因:最常见的是 Shader 里还在使用全局的
unity_SHAr、unity_SHAgB这些变量,实例自定义 SH 虽然写进了 Buffer,但片元着色器根本没有读它。另一个原因是探针插值成功,但由于探针数量太少或间距太大,插值结果趋于一致。 - 排查顺序:先在 Compute Shader 里输出几个已知坐标点的插值 SH 到调试纹理,再用 RenderDoc 抓取 Shader 输入,确认实例 Buffer 里的 SH 是否随位置变化。最后检查 Shader 中是否把全局 SH 直接覆盖到了输出。
解决方案是给实例数据增加一个“允许引擎覆盖 SH”的标记位,在 Debug 阶段开启动态分支,把全局 SH 传进去做对比,发布版本则完全走实例 Buffer。
4.2 现象二:探针过渡带出现明显的明暗跳变
- 表现:植被在探针网格边界处,环境光从亮突然跳到暗,线条感很重,尤其在山坡上特别明显。
- 原因:我只用了最近探针的 SH,没有做三线性插值。某些引擎的 Light Probe 系统在某个轴向上自动完成了插值,但自定义链路里如果只拿最近的一个探针,就会在边界出现阶梯跳变。
- 解决:改成三线性插值后,跳变基本消失。但这里又冒出一个新问题:地形高低起伏大的斜坡上,竖直方向如果只有一层探针,山脚和山顶的插值会互相拉扯,出现“阴阳脸”。我的做法是给探针体积增加海拔层数,至少两层,层间距离控制在 20 米以内。
如果探针是不规则分布而不是规则网格,就要用更复杂的加权插值。我后来给草地单独用了一套规则网格的探针体积,树林则用不规则探针,两套数据在同一个 Compute 里合并,权重各占一半,过渡效果比较自然。
4.3 现象三:天空切换时,近景草地亮度闪烁
- 表现:动态天空从正午切到傍晚时,远处的树是平滑过渡的,近景草地却在一两帧内出现明暗闪烁,甚至局部偏蓝偏红。
- 原因:查了很久,最后发现是探针更新和实例 Buffer 更新不同步。动态天空 SH 每帧都会变,但实例 Buffer 里的 SH 可能还是上一帧的数据,两者叠加后会产生高频抖动。而且我一开始是逐帧瞬时更新目标系数,没有做时间上的平滑,导致 L1 方向分量在帧间跳变。
- 解决:先把 InstanceBuffer 改成双缓冲,写入端和读取端分开,避免帧间数据竞争。再对动态天空 SH 做指数平滑,系数大约取
0.15到0.3之间。实测下来,闪烁基本消失,动态感觉保留得很好。
这里还有一个容易被忽略的点:太阳方向的变化要用直射光阴影去表达,不要把它混进 SH 里。如果 SH 里出现了高频的方向性变化,后期很难靠平滑搞定,最好源头就不要让 SH 承载太阳的尖锐高光。
4.4 现象四:背光面死黑与 AO 过强
- 表现:树木的背光面太黑,草叶朝下的部分完全分辨不出细节,哪怕把环境光调亮,也只是均匀提亮,缺少层次。
- 原因:第一个是 GPU 植被 Shader 开启了 Alpha Test 后,背面法线被翻转,导致 SH 重建出来的是地面以下的光,几乎为零;第二个是 AO 贴图采样过于粗暴,在植被密度大的地方 AO 直接乘了个 0.3,把环境光压没了。
- 解决:草类植被统一走双面渲染,并在 Shader 里对背面法线做
-normal处理,让 SH 重建基于正确的向外法线。AO 方面,我给植被设定了最小的环境光下限,比如保证至少有 15% 的 SH L0 项保留,不会让背光面全黑。这让树冠内部和草丛底部仍然有可辨识的轮廓。
4.5 问题排查速查表
| 现象 | 常见原因 | 排查建议 | 解决方向 |
|---|---|---|---|
| 所有实例 SH 一样 | Shader 仍在读全局 SH,或探针插值未接入实例 Buffer | 用 RenderDoc 查看实例 Buffer 的 SH 值是否随坐标变化 | 实例 Buffer 中显式写入 SH,并禁用全局 SH 覆盖 |
| 探针边界跳变 | 只用最近探针,没做三线性插值 | 在 Compute 中输出插值权重热力图 | 升级为三线性插值,探针体积增加海拔分层 |
| 动态天空切换闪烁 | 探针更新与实例 Buffer 不同步 | 检查双缓冲是否接入,对比帧间 SH 差异 | 双缓冲实例数据,动态 SH 做时间平滑 |
| 草叶背面死黑 | 法线翻转,SH 重建模型不当 | 观察背面法线,检查 SH 分量是否含半球权重 | 双面渲染加背面法线修正,AO 设置亮度下限 |
最后说一点个人体会
这套方案从立项到最终效果稳定,前后折腾了将近三周。回头再看,最大的浪费不是数学公式理解不对,而是没有在一开始就规划好“探针、实例数据、动态天空”这三者的数据契约。如果你也想做类似的东西,我建议先在一个 200m×200m 的小场景里,把“探针采样 → InstanceBuffer → 动态天空更新”这条链路完整跑通,确认视觉质量没问题,再往大地图上铺。不要像我一样,直接把几百万实例的缓冲推倒重来,调试成本非常高。
另外,SH 这一块,最好找一张现成的参考实现,先把标准球测试跑通,再谈植被接入。你可以在项目里临时写一个简单的反射球,把探针 SH 重建的漫反射颜色显示在球上,和真实烘焙结果做对比,任何系数问题都会立刻暴露。
现在每次打开场景,看到整片山坳的草色会随着云层移动和傍晚天色缓慢变化,就觉得那些熬夜调 SH 系数和探针权重的日子,都值了。