去年我们把项目里的植被渲染从 CPU 实例化管线整体迁到 GPU Driven Vegetation,目标地块是一片一平方公里的草原加林地:2 万棵桦树,50 万根草叶。性能压力很直白,CPU 视锥剔除做完仍然有 1.2 万个 draw call,渲染线程稳定在 8.2ms 下不去。迁移本身不难,难的是迁移之后的光照接管。GPU Driven 之后,原来藏在静态烘焙里的 SH、Ambient Probe、动态天空 GI 的缺陷全部浮出水面,而且每一条都直接反应在屏幕上:草地里出现黑色“斑块”、树冠下亮得不合理、黄昏时全场景亮度像在呼吸。这篇文章不打算讲“如何上 GPU Driven”的完整框架,重点记录我们和球谐光照、天空可见度、探针插值以及间接绘制参数死磕的过程。
1. 从静态植被到GPU Driven:我们为什么把自己的渲染逼上了绝路
1.1 项目背景与迁移动机
项目原本是一套非常传统的植被渲染:CPU 侧把场景里所有植被按网格和材质分组,然后用 GPU Instancing 批量提交。前期地块小的时候一切正常,等放到真正的“一平方公里”级别后,问题开始叠加。
先说我记得最清楚的一组数字:2 万棵桦树,每棵树平均包含树干、主枝、叶片三个子网格,再加 50 万根草叶实例。CPU 视锥剔除确实能把大部分草剔除掉,但剩余可见对象仍然产生 1.2 万个 draw call。渲染线程 8.2ms,GPU 利用率只有 40% 左右,瓶颈完全卡在提交上。美术那边还在说要加“地表裸露岩石上的灌木”和“野花层”,按照当时的增长曲线,再翻一倍配置也救不回来。
所以迁移到 GPU Driven Vegetation 不是炫技,是被数据逼的。我们的目标是把渲染线程的固定开销压到 1ms 以内,把高速变化的 instance 数据从 CPU 的逐帧更新中剥离出来,把剔除、排序和绘制参数生成全部交给 GPU。
刚开始团队里有人认为“GPU Driven 就是 ComputeShader 里做一下视锥剔除,然后 DrawMeshInstancedIndirect”。真正落地之后才发现,它至少要接管三层数据流:实例存在性、实例可见性、实例光照数据。第三层才是我们后续所有踩坑的源头。
1.2 GPU Driven管线的三层拆解:Culling、Sorting、DrawArgs各管什么
最终的管线可以拆成三个阶段,每个阶段对应一个 ComputeShader Pass 或者一个间接绘制入口。
第一阶段做粗粒度剔除。我们按 32x32 米的地形块建立 cluster,提交 cluster 的 AABB 到视锥,存活后把 cluster 内的实例索引写入 AppendBuffer。这一步主要是减少后续阶段的无效计算,但注意 cluster 的 AABB 会远远大于真实植被密度,所以它只负责“大势”,不负责精确。
第二阶段做细粒度剔除。这里我们踩了个经典坑:用 AABB 做测试时,树木模型在 Y 轴上的中心点在地面以下,AABB 底边贴地,摄像机在地表附近移动时经常出现树尖被误剔除但整棵树没被剔除的“悬浮树”。后来把每个实例的边界体积从 AABB 改成端点优化的 OBB,并且在不同 LOD 之间共用一个外扩后的包围球,剔除稳定性才算达标。
第三阶段是排序和参数生成。我们按相机距离对存活实例排序,然后生成 DrawIndexedIndirect 需要的 5 个 uint:indexCountPerInstance、instanceCount、startIndexLocation、baseVertexLocation、startInstanceLocation。排序的必要性显式说明了 GPU Driven 的一个反直觉点:它不只是剔除,还把 CPU 原本“按材质分批再提交”的逻辑彻底翻转成“按实例一次性提交,内部顺序由 GPU 决定”。
光照数据则和这三层并行:每个实例的数据里带了一组探针索引和权重,以及烘焙好的 AO 和法线方向。渲染时顶点着色器负责采样 SH 系数、混合动态天空 GI,片元阶段再做 AO 和半透明处理。这个架构决定了后面所有光照 bug 都不是单一原因,而是“剔除范围、实例布局、探针烘焙、天空更新”四个环节互相污染。
2. SH烘焙与半透明树冠的恩怨:Ambient Probe插入位置的决定性影响
2.1 SH函数基础:为什么L0/L1/L2对植被是“够用但微妙”
先给完全没接触过球谐光照的读者补齐背景:SH 把环境光分解成一组基函数,对漫反射辐照度来说,L0 是一个常数项,代表整球平均亮度;L1 是三个方向分量,代表主要光照方向;L2 是 5 个四阶多项式项,能表达更多细节。实际项目中常用 L0+L1+L2 共 9 个系数,已经能覆盖“天空蓝+太阳方向+地面对比”这种典型户外场景。
问题是植被不是普通表面。草叶是片状几何,树冠是大量半透明叶片组成的体积。SH 是极低频率的表示,它天然适合“远处天空”“大面积阴影”这种平滑场,但不适合表达“树冠内部的一块高密度遮挡”。你把一个探针放进树冠里,它记录到的辐照度不应该被当作这个树冠本身接收的环境光,因为那部分遮挡通常应该由 AO 贴图或自定义可见度函数处理。真实项目里探针和 AO 的分工一旦混淆,画面立刻“脏”。
我们的第一版 SH 烘焙工具是典型的“功能正确但语义错误”:探针位置由美术手动摆放,直接采样天空盒和场景遮挡,然后烘焙成 L2 阶系数。摆放良好的露天空旷处没问题,但一棵树如果有两三个探针放进树冠的叶片层之间,那棵树的底部就会被这些“自带阴影”的探针染成灰绿色,和旁边没有探针的树完全不同。
2.2 探针位置与树冠遮挡:我们第一批探针为什么让草地里长满“黑斑”
迁移完成后第一次全场景跑起来,最显眼的 bug 不是帧率,而是地面的草在树荫外仍然亮,树荫内却出现一圈一圈的黑色“斑块”。用调试着色器把所有实例的 SH L0 项可视化之后,发现这些黑斑的形状和探针体积的插值网格完美重合。
排查过程是这样的:先怀疑探针烘焙时把树冠叶片当成了“永久遮挡物”。我们的探针烘焙确实对场景里所有物体都做了 visibility 测试,包括桦树的叶片 mesh。当探针位置刚好在树冠中下部时,它上方的叶片密度高,半球可见度会掉到 0.2 以下;而它的 L1 方向项还被叶片形状带偏,最终评估出来的 sky illumination 就被压暗了。可真正的问题是,这棵树自己的叶片渲染用的是另一套顶点法线和裁剪逻辑,它接收环境光时用的应该是叶片 ad 处的天空可见度,而不是“探针位置处的球谐平均值”。
第二次怀疑是插值方式:我们用三线性插值把探针体积内的 8 个探针系数混合,但树冠内部的探针和树冠外部的探针在空间上距离只有 1 米,系数差异却非常大。三线性权重在这 1 米内没有过渡,导致黑斑边界硬得像棋盘格。
最终修复分两步。第一步,烘焙探针时增加一个“探针语义标签”:植被探针只采样远场环境光和天空可见度,不采样近处植被几何,树冠自遮挡统一走 AO 纹理。第二步,探针插值从三线性改成“影响力半径内的加权融合”,权重用平滑核并且强制归一化。这一步让草地里所有黑斑消失,但代价是插值计算量上涨,我们在后续把探针融合挪到了 GPU 侧。
2.3 误差清单与修正方法表
| 现象 | 根因 | 修复 | 验证方式 |
|---|---|---|---|
| 树荫下草地出现硬边黑斑 | 探针插值三线性权重不匹配,相邻探针系数差异过大 | 改用平滑核加权融合,并对权重做归一化 | 用 SH L0 可视化着色器逐探针检查过渡带 |
| 树冠底部整体发黑 | 探针位于树冠内部,烘焙时把树冠叶片当成不可见遮挡 | 给植被探针打语义标签,不采样近处植被几何 | 对比关闭可见度测试后的 L0 分布 |
| 山脊处的草颜色随相机移动跳动 | 探针采样点离地形表面高度不一致,斜坡过渡带权重突变 | 探针烘焙前做地形高度场修正,采样点吸附到地表 | 用权重可视化,检查斜坡上权重等高线是否平行于地形等高线 |
| 树冠轮廓周围出现灰色光环 | L2 项在高频边缘产生振铃 | 对 SH 系数加衰减窗口,并在输出前重新归一化 | 用低阶 SH 只保留 L0/L1 对比看边缘 |
那个“灰色光环”问题后面会在动态天空 GI 部分再次放大,先记在这里:SH 的 L2 项不是越高越好,尤其是植被这种边缘不规则的物体。
3. 动态天空GI的逐帧更新:低阶SH跃迁、归一化和探针淡化的踩坑链路
3.1 天空SH的获取、投影与振铃抑制
动态天空 GI 是我们迁移的一半动力:原来植被烘焙的是静态 SH,一天 24 小时全靠预烘焙几套关键帧再插值。这个方案在阴天看起来还行,但只要太阳落山,阴影方向和亮度完全对不上。
所以我们改成动态更新:每一帧用当前大气散射模型的天空辐照度,投影到 L0/L1/L2 的 9 个 SH 系数,然后把这组系数写进探针体积,让所有植被在顶点着色器里采样。理论很简单,实践却卡在天空盒的 SH 投影质量上。
第一次实现直接把 128x128 的天空辐照度立方图交给一个 SHProject ComputeShader,用蒙特卡洛积分算系数。结果得到的东西让所有物体都出现“塑料感”:天空边缘、太阳周围出现一圈圈负亮度带。这是因为天空辐照度在太阳方向有一个非常亮的窄峰,而 L2 阶 SH 只有 9 个系数,根本表达不了这种高频。
处理方式有两个关键点。第一,投影前先用一个窄的高斯核对天空立方图做预卷积,把太阳方向的光晕平滑掉;第二,对最终 SH 系数做“Lmax 衰减”,也就是按照 1/(l+1) 的衰减曲线降低 L1 和 L2 项。做完后天空的 L2 项虽然不够锐利,但不会再出现负亮度洞。
3.2 问题1:逐帧更新SH导致全场景“呼吸”
第一次把动态天空 SH 接进来时,最诡异的现象是:阳光从西边逐渐落下,所有植被的整体亮度却不是平滑下降,而是每隔几秒突然暗一档,然后又亮回来,像整个场景在呼吸。
排查了整整一天,最后定位到是一个数值细节:我们把太阳圆盘的方向和强度直接塞进了 L1 系数里。SH L1 是三个带符号的方向分量,它描述的其实是一个余弦瓣,如果把太阳方向建模成“点光源方向”,L1 会在太阳跨过某个边界时翻转符号。符号翻转不是输出负数,而是把光照方向瞬间反了 180 度,视觉上就是亮度先掉一档,然后校正回来。
解决方法分两层。第一层是把太阳方向从 SH 系数里剥离出来,改成独立且一直可用的定向光,SH 只负责环境光和天空漫反射部分;第二层是把 SH 系数从“逐帧覆盖”改成“缓动插值”,用上一帧系数和当前帧系数做 clamp 增量更新,每帧最大变化率限制在 0.05 到 0.1 之间。这样既保留了动态天空的响应速度,又不会出现方向翻转的锐利突变。
3.3 问题2:低阶SH置换与L1/L2混插
另一个和“呼吸”类似但根因完全不同的问题是:夜晚来临时,天空的 L0 项持续下降,L1 项因为月光的出现和消失不断做低频切换。我们最初把 9 个系数全部做线性插值,结果黎明和黄昏时,植被被染成奇怪的蓝紫色,而且颜色变化的速率不统一。
这里涉及一个 SH 表示的本质:L0、L1、L2 并不是物理单位的同一个“亮度”的三个维度,它们分别对应“平均辐照度”“一阶方向”“四阶分布”。直接对 9 个系数做同样的插值权重,等于假设它们的时间导数相同,这在 24 小时动态天空里是错的。
我们的做法是把 9 个系数分成三组,分别做时间常数不同的插值:L0 插值速度最快,保证早晚亮度变化跟得上;L1 插值用 0.3 秒的时间常数;L2 用 1.2 秒的时间常数,并且强制 L2 的模长不超过 L1 的 40%。这套参数改完后,天空变化过程中的色彩过渡终于稳定,至少不会再出现“天已经黑了但草地还亮着”的割裂感。
3.4 动态天空与探针淡化的同步问题
动态天空更新还会影响探针的位置语义。我们有些探针放在树冠上方,有些放在灌木丛里,它们的天空可见度差异巨大。天空 SH 更新后,探针的相对权重不变,但绝对亮度变了。
这里有一个隐藏 bug:四探针权重归一化时,我们最初没有处理“某个探针的权重小到接近 0”的情况。当权重被 clamp 到一个极小值后,另外几个探针的权重被强行补位,导致实例从“完全由探针 A 照明”突然跳成“探针 B 和 C 混合”。这个跳变通常只影响一两个像素宽度,但会在植被边缘形成一条 1 帧的亮线。
修复方式是在探针权重归一化前加一个 mask:如果某个探针的可见度和贡献度低于 0.02,就把它从混合组里移除,剩下的探针重新归一化。这个 mask 还顺带解决了探针边界两侧权重和不为 1 导致的亮度缩放问题。
4. GPU实例剔除与SH数据传递:StructuredBuffer布局和间接绘制参数的血泪教训
4.1 StructuredBuffer的内存布局:16字节对齐与混乱的instance data
GPU Driven 的数据流核心是一张 StructuredBuffer ,每个实例除了 Transform,还要带 SH 系数、AO、法线、探针索引和权重。这里最容易犯的错误是结构体布局不齐导致整张 buffer 里数据全部错位。
我们最初的结构体是这样的:
struct VegetationInstanceData { float4x4 worldMat; // 64 bytes float3 normal; // 12 bytes float ao; // 4 bytes uint probeIndex0; // 4 bytes uint probeIndex1; // 4 bytes float probeWeight0; // 4 bytes float probeWeight1; // 4 bytes float4 shL0; // 16 bytes float4 shL1A; // 16 bytes float4 shL1B; // 16 bytes };看起来逻辑是对的,但 GPU 读 StructuredBuffer 时默认要 16 字节对齐。normal 加 ao 是 16 字节没问题,接着的 probeIndex0、probeIndex1、probeWeight0、probeWeight1 加起来是 16 字节,也没有问题。问题出在我们后面加了一个uint lod;字段时的粗心:它在 float4 shL0 之前,导致 shL0 的起始地址被推到偏移 80 字节,而不是 64 字节对齐的位置。渲染管线没有任何报错,但草叶的 SH 值全部随机。
这个问题的教训非常老套但值得重复:GPU 侧结构体必须按 16 字节对齐,并且最好用 float4 作为基本单位来做所有载荷。后来我们把结构体改成了五个 float4 的平铺布局,并且在每个字段之间用显式的 padding 保证总长度是 80 字节的整数倍。你可以在 CPU 侧做一次Marshal.SizeOf和 GPU 侧sizeof()的对比断言,任何不一致都会在开发头几天暴露,而不是等到上线后变成某种偶发闪烁。
4.2 Indirect Args的“三个坑”:参数顺序、基址偏移与SV_InstanceID
GPU Driven 绘制最著名的地雷是 IndirectArgs 的 5 个 uint,用的是DrawIndexedIndirect时参数顺序是:
uint indexCountPerInstance; uint instanceCount; uint startIndexLocation; int baseVertexLocation; uint startInstanceLocation;我们曾经把第一个参数下意识写成 instanceCount,结果所有草叶在三帧内不停消失又出现。这个 bug 属于“参数顺序反了”的低级错误,但更隐蔽的坑在后面:当场景里有多个 LOD 层时,同一个 argsBuffer 不能简单地在每帧覆盖。一棵树的部分实例可能属于 LOD0,另一部分属于 LOD1,它们的 index buffer 尺寸不同,所以每组绘制有自己独立的 args。
另一个和 SH 相关的坑是SV_InstanceID。我们第一批植被实例化时,把树干、树枝、叶片三个子网格放进同一个 mesh,然后用SV_InstanceID区分材质。但SV_InstanceID从 0 开始,它只代表“当前 draw call 内第几个实例”,不是“整个 buffer 内第几个实例”。启用startInstanceLocation后,顶点着色器里拿到的SV_InstanceID仍然是从 0 开始的局部值,需要用SV_StartInstanceLocation或者传一个全局偏移才能正确索引到实例数据。我们当时没注意这个偏移,导致所有实例的 SH 系数错位了一个批次,视觉表现就是部分树突然亮、部分树突然暗,而且和探针位置完全无关。
4.3 从“缺一半草”到“所有草都在屏幕外飘”的排错路径
如果你也遇到类似情况,我建议的排错路径如下:
先确认不是“绘制参数”问题:把 argsBuffer 回读到 CPU,打印 instanceCount,和 CPU 侧统计的可见实例数对比。如果 count 是对的,问题一定出在数据读取上。
然后确认不是“数据索引”问题:给每个实例的顶点着色器输出一个instanceID % 8的伪彩色,看到底是哪条数据通路坏了。如果颜色是整齐的 8 色块但位置错乱,说明 SV_InstanceID 和 buffer 索引不匹配;如果颜色是随机的,说明 layout 或指针有问题。
最后再回头检查 SH 数据本身:用一个只输出 L0 项的调试着色器,排除 L1/L2 方向影响。如果 L0 看起来正常,但 L1/L2 表现为随机方向,几乎可以断定是数据打包或对齐问题,而不是烘焙逻辑问题。
5. 光照方向的隐性开关:法线重建、双面照明和地形高度场的耦合问题
5.1 草叶法线的“假装半球可见性”手法
GPU Driven 的实例数据里,我们最开始直接使用几何法线。草叶 mesh 是 xz 平面上的片条,几何法线基本指向侧面或者朝下。用这样的法线和 SH 的 L1/L2 做点积,草叶接收到的天空光会少得可怜,因为 SH 的漫反射评估是dot(normal, irradiance_vector)一类的结果,草叶法线一旦偏向水平,L0 的“天空来自上方”优势就被削弱了。
我们最终的做法是把草叶法线视为“混合法线”:以几何法线为基础,加入一个由叶片高度决定的向上分量。具体公式是:
float3 finalNormal = normalize(lerp(geometryNormal, float3(0, 1, 0), saturate(height * 2.0)));高度 0 代表草根,高度 0.5 以上基本完全朝上。这套近似让草地整体亮度提升约 25%,而且没有明显违和感。它本质上是“假装所有草叶都能看到上方天空”,符合视觉直觉,但对于悬挂在树冠下的草,这种方法会让它亮得脱离环境。所以我们额外加了一个“树冠 AO 系数”约束:当实例的位置位于树冠投影半径内时,这个向上混合的强度会乘以一个遮挡衰减。
5.2 双面光照与Alpha-to-Coverage的染色偏差
草叶和树叶几乎必然要双面渲染,否则背面是透明的。开通Cull Off之后,背面三角形会用反转的法线计算光照,SH 评估结果会完全不同。因为 SH 的 L1 方向项是有符号的,背面法线方向如果和正面法线方向几乎相反,评估出来的辐照度会明显偏暗或偏亮。
更麻烦的是,双面渲染和 Alpha-to-Coverage 叠在一起时,同一片草叶的正面和背面在深度缓冲中互相自遮挡。深度写入开启的状态下,背面像素可能被判为被正面遮挡,AlphaTest 的覆盖率会随相机角度连续变化,产生草叶边缘的“闪烁毛刺”。
我们的解法是:草叶和树冠进入一个“植被双面 pass”,该 pass 关闭深度写入,只做深度测试;光照统一用正面法线计算,背面只把正面的光照结果传给片元。SH 系数不再按背面重新评估。片元阶段用 Alpha-to-Coverage 输出覆盖率,但由于深度不写,叶片前后层之间没有自遮挡,边缘毛刺消失了。
代价是植被和地面之间可能出现遮挡排序错误。这个排序问题我们通过额外绘制一层深度预 pass 解决:预 pass 里所有草叶按固定 alpha 阈值写入深度,最终 pass 根据深度预 pass 的结果做像素级测试。代价是每帧多一次植被深度渲染,但比颜色闪烁稳定得多。
5.3 地面高度场对探针插值的“拉升”扰动
探针体积是规则网格,但地形有高差。如果一个植被实例正好落在 30 度斜坡上,它的实例位置在地表,但探针插值采样点可能是三维体积坐标。我们发现斜坡上的草颜色会呈现“分层条纹”:山坡顶部亮,山坡底部暗,但分界线完全和地形等高线平行,这明显不正常。
根因是探针体积的 Y 轴没有跟随地形。规则探针网格在平面上是均匀的,但在地形起伏大的地方,同一个水平坐标对应的垂直高度会跨越好几个探针层。插值时把实例的地表高度当成了体积坐标的 Y 值,导致它总是偏向下方探针,而下方探针可能处于背光区,于是山坡下段的草越走越暗。
修复方案有两个:一是在实例生成时,把探针采样的 Y 坐标修正为距离地表 1.2 米处的“植被光照高度”,而不是地表点本身;二是为探针网格增加一个按地形高度偏移的“地形跟随模式”,让探针层的中心线尽量贴合地形表面。两种方式都有效,最终我们同时用了,因为单独地形跟随会让探针在悬崖处出现跳变,而单独高度修正会让树下低矮灌木的光照偏高。
6. 调试与验收:从“看起来亮”到“实测达标”的完整工具链
6.1 调试着色器的可视化通道设计
任何 GPU Driven 植被管线上线前,都应该先把“可见性数据”和“光照数据”分开验证。我们设计了三套调试着色器,分别解决三类问题。
第一套是“实例索引可视化”,把每个实例的 LOD 等级和可见性状态映射成不同颜色:绿色代表 LOD0 存活,蓝色代表 LOD1,红色代表被遮挡剔除。这套着色器可以快速暴露“树冠 LOD 切换位置的黑斑”和“错误剔除导致的实例缺口”。
第二套是“SH 分量可视化”,把 SH L0 输出成灰度、L1 的 x/y/z 两两通道映射到 RGB、L2 的某个分量映射到额外通道。它用于检查探针插值是否平滑,以及动态天空更新时是否出现系数跳变。
第三套是“探针权重可视化”,对每个实例输出四个探针权重中最小的那一个。如果某个区域最小权重经常小于 0.01,说明探针覆盖密度不足,需要加密探针或增大插值半径。
这套工具链看似简单,实际价值极大。它可以瞬间把“草地里为什么有个黑点”这一类抽象问题,变成“当前顶点采样的是哪个探针、权重是多少、SH 系数是多少”这种可以直接检查的具体数据。
6.2 RenderDoc回读与ShaderDebug输出的使用时机
遇到“所有草都在屏幕外飘”的诡异现象时,我们使用 RenderDoc 回读 argsBuffer,而不是一帧帧去猜。做法是在绘制前把 Buffer 的内容 copy 回一张 CPU 可读的 StagingBuffer,然后在 CPU 侧记录。这种回读的代价是破坏 GPU 流水线,不能用在发布版本,但针对疑难杂症很有效。
一个实用技巧:在 compute shader 里写一个DebugIndex变量,把单个实例的完整数据 dump 到一张 debug buffer 里。我们曾经用它定位了一个极难发现的 bug:探针权重在 GPU 侧被 clamp 后,4 个权重之和大于 1,导致 SH 系数整体被乘了 1.3 倍。肉眼完全不同,因为这是在零附近的位移,但用 debug buffer 一打印,所有系数都比烘焙值大了 1.3 倍。
还有一个建议:不要在 RenderDoc 里同时看多个 frame。GPU Driven 有时候的“闪烁”是帧间数据竞争问题,而不是单帧错误。我们遇到过一次因为上一帧的 culling output 没有正确 clear,导致当前帧的实例数是上一帧实例数和当前帧实例数的混合。单帧看数据完全正常,连续看两帧立刻露馅。
6.3 性能验收数据和最终优化结果
最终的优化后数据如下:在目标测试机(GPU 为列在配置清单中的中端显卡,CPU 为 6 核处理器)上,2 万棵桦树和 50 万根草叶场景,渲染线程从 8.2ms 降到 1.1ms,Compute 剔除阶段 0.4ms,SH 评估和探针插值在顶点着色器里增加的时间平均不到 0.15ms。帧率从约 47fps 提升到约 91fps,而且渲染线程的开销不再随植被数量线性增长。
这个结果说明一件事:GPU Driven 的真正收益不是移除 CPU 瓶颈,而是把每一个实例的“可见性+光照”决策都放到 GPU 上做,让 CPU 只剩下场景管理和上层逻辑。反射到光照侧,SH 和 Ambient Probe 的价值也不再是“烘焙一次用一年”,而是变成了一种动态数据流,和 GPU Driven 的绘制参数一样,每一帧都可以重新生成、重新评估。
如果只能留一条经验,我会说:迁移到 GPU Driven 时,一定要先把“数据语义”理清楚。哪些数据是静态语义(几何法线、AO),哪些数据是动态语义(天空 SH、探针权重),哪些数据是半动态(LOD、可见性),混乱的数据流最终都会以某种诡异的画面 bug 拍在你脸上。上面记录的这些坑,每一个都是我们拿真机画面换来的,希望你能绕开。