ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

GPU Driven植被渲染:球谐光照与探针插值的实战排坑

GPU Driven植被渲染:球谐光照与探针插值的实战排坑 去年我们把项目里的植被渲染从 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.2msGPU 利用率只有 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 个 uintindexCountPerInstance、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 个四阶多项式项能表达更多细节。实际项目中常用 L0L1L2 共 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/(l1) 的衰减曲线降低 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 dataGPU 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_InstanceIDGPU 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.1msCompute 剔除阶段 0.4msSH 评估和探针插值在顶点着色器里增加的时间平均不到 0.15ms。帧率从约 47fps 提升到约 91fps而且渲染线程的开销不再随植被数量线性增长。这个结果说明一件事GPU Driven 的真正收益不是移除 CPU 瓶颈而是把每一个实例的“可见性光照”决策都放到 GPU 上做让 CPU 只剩下场景管理和上层逻辑。反射到光照侧SH 和 Ambient Probe 的价值也不再是“烘焙一次用一年”而是变成了一种动态数据流和 GPU Driven 的绘制参数一样每一帧都可以重新生成、重新评估。如果只能留一条经验我会说迁移到 GPU Driven 时一定要先把“数据语义”理清楚。哪些数据是静态语义几何法线、AO哪些数据是动态语义天空 SH、探针权重哪些数据是半动态LOD、可见性混乱的数据流最终都会以某种诡异的画面 bug 拍在你脸上。上面记录的这些坑每一个都是我们拿真机画面换来的希望你能绕开。
返回列表