如果你和我一样被一整片草原加上十几万棵树逼到用 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 存储也就是 halfL1 三个系数用 half3 存储L2 的 5 个系数如果对方向性细节要求不是特别高也可以用 half4 加一个 half或者干脆把 L2 压成 2 个 half2。实际项目里我用了“L0 一个 half L1 三个 half L2 五个 half”的布局一个实例的 SH 部分是 9 个 half也就是 18 字节。这个开销完全可以接受。如果把 L2 砍掉只剩下 L0L1每实例只有 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然后做三线性插值。StructuredBufferProbeData _ProbeBuffer; RWStructuredBufferInstanceData _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 系数和探针权重的日子都值了。