
1. 大世界地形渲染的痛点与GPU Terrain的破局思路做Unity大世界项目的同行应该都有体会地形系统往往是整个项目最先遇到瓶颈的地方。传统Unity Terrain在中小场景里够用可一旦视野拉远、地形分辨率拉高、植被密度上去CPU端的Draw Call和LOD计算就会迅速吃掉主线程预算帧率掉得让人心慌。我前两年参与过一个开放世界原型地形尺寸8km×8km光是地形本身的批次就压得CPU喘不过气更别提上面还叠着草、树、石头这些细节物件。后来我们把目光转向GPU-Driven的思路也就是标题里说的GPUTerrain方案才真正把这块硬骨头啃下来。所谓GPUTerrain核心思想是把地形的网格生成、LOD选择、视锥剔除甚至部分着色逻辑从CPU搬到GPU上执行。CPU只负责提交极少量的大批次剩下的活交给Compute Shader和间接绘制Indirect Draw去完成。这样一来主线程的负担大幅下降地形规模可以做得更大细节层次也能更激进。它解决的不是某一个具体画面效果而是“大世界地形能不能跑得动、能不能扩展”这个根本问题。适合谁来参考我认为是有一定Unity基础、做过HDRP或URP管线定制、并且正在被大世界地形性能困扰的开发者。如果你还在用默认Terrain做小场景这个方案可能有点重但只要你的地形开始“卡”它就值得认真看下去。需要先说明的是下面讲的内容是基于常见工程实践和我自己项目经验的合理补全不是某个官方文档的逐字翻译。不同项目的地形尺寸、美术风格、目标平台差异很大参数和结构需要按实际情况调整但整体思路是通用的。2. 方案整体设计与核心技术选型拆解2.1 为什么放弃传统Terrain而选择GPU-Driven传统Unity Terrain的工作方式是CPU根据摄像机位置计算每个Terrain Patch的LOD决定哪些Patch可见然后逐个提交绘制。地形被切成若干块每块有自己的网格和材质批次数量随地形块数线性增长。当视野内有几百个Patch时CPU的剔除和排序就成了瓶颈。更麻烦的是LOD切换和植被剔除也在CPU上做主线程被塞得满满当当。GPU-Driven的思路是把这套流程反过来CPU只上传一份地形高度图、一份LOD参数和摄像机信息GPU用Compute Shader并行计算每个地形块的LOD级别、是否通过视锥剔除、是否需要绘制然后把结果写进一个Indirect Args Buffer最后用Graphics.DrawMeshInstancedIndirect或CommandBuffer.DrawProceduralIndirect一次性提交。CPU端几乎不参与逐块决策批次数量从几百降到个位数。这个转变带来的收益是数量级的尤其在地形块数多、摄像机移动快的情况下主线程压力明显缓解。选这个方案还有一个理由它天然适合HDRP。HDRP的渲染管线本身就更偏向现代GPU特性Compute Shader和Indirect Draw的支持很完整材质系统也能和自定义的GPU地形着色器配合。如果项目已经上了HDRP再走GPU-Driven地形技术栈是顺的不会出现“为了一个地形把整个管线推翻”的尴尬。2.2 核心模块划分与数据流设计整个GPUTerrain方案我把它拆成四个核心模块每个模块各司其职数据流是单向的便于调试和替换。第一个模块是地形数据层。它负责存储高度图、法线图、 splat map地表混合权重图以及可选的植被密度图。这些数据在初始化时上传到GPU纹理后续不再频繁改动。高度图建议用RenderTexture的RFloat格式精度足够且采样方便。Splat map用ARGB32或RGBAHalf根据混合层数决定。第二个模块是LOD与剔除计算层。这是GPU-Driven的核心用Compute Shader实现。每个地形块对应一个线程组线程组内并行计算该块的中心点、包围盒、与摄像机的距离然后根据预设的LOD距离阈值决定LOD级别再做视锥剔除。剔除结果和LOD级别写入一个AppendStructuredBuffer或固定大小的StructuredBuffer供后续绘制使用。第三个模块是网格生成与绘制层。这里有两种做法一种是预生成所有LOD级别的网格用Indirect Draw按LOD索引绘制另一种是在Compute Shader里直接生成顶点用DrawProceduralIndirect绘制。前者实现简单、兼容性好后者更灵活但复杂度高。我建议先用预生成网格的方案跑通再考虑程序化生成。第四个模块是着色层。地形着色器接收LOD级别、块索引等参数采样高度图、法线图和splat map做多层地表混合。HDRP下可以用Shader Graph配合Custom Function Node也可以直接写HLSL。着色层要和LOD系统配合比如远处的地形块可以降低采样精度、减少混合层数进一步省性能。数据流是这样的初始化时上传地形数据每帧CPU更新摄像机参数到Compute ShaderCompute Shader计算LOD和剔除结果结果写入Indirect Args Buffer最后提交Indirect Draw。整个流程CPU只做参数更新和提交决策全在GPU。2.3 与HDRP管线的集成要点HDRP下集成GPU地形有几个地方容易踩坑。首先是渲染顺序地形通常在BeforeRendering或Opaque阶段绘制需要确保Indirect Draw的提交时机正确。我一般用CommandBuffer在CameraEvent.BeforeForwardOpaque或HDRP对应的自定义Pass里提交避免和HDRP自己的剔除系统冲突。其次是材质和Shader的兼容性。HDRP的Shader库对Indirect Draw的支持需要显式开启比如在Shader里声明#pragma multi_compile _ INDIRECT_INSTANCING并正确处理unity_InstanceID。如果用地形混合还要注意HDRP的Decal和Terrain Layer系统是否会和自定义着色器打架。我的经验是尽量绕开HDRP内置的Terrain组件完全用自定义Mesh和Shader这样控制权最大。最后是光照和阴影。GPU地形如果参与阴影投射需要在Indirect Draw时额外提交一份阴影Pass或者用HDRP的Shadow Proxy。阴影距离和LOD要联动远处的地形块可以关闭阴影投射省下不少开销。3. 核心细节解析与实操要点3.1 地形块划分与LOD层级设计地形块的大小和LOD层级直接决定了方案的性能和画面质量。块太小块数多Compute Shader的线程组数量上去调度开销增加块太大LOD切换时 popping 明显剔除粒度也粗。我一般用32×32到64×64米的块尺寸地形总尺寸8km×8km的话块数在128×128到256×256之间。这个量级下Compute Shader的线程组数量在可接受范围剔除粒度也够细。LOD层级我通常设4到5级。以块尺寸64米为例LOD0是完整分辨率顶点间距1米LOD1间距2米LOD2间距4米LOD3间距8米LOD4间距16米。切换距离按块尺寸的倍数来定比如LOD0在0到128米LOD1在128到256米以此类推。这个距离不是拍脑袋定的要考虑屏幕空间误差Screen Space Error。简单算法是距离阈值 块尺寸 × 2^LOD级别 × 系数系数根据目标屏幕误差调整一般0.5到1.0之间。注意LOD切换距离一定要做滞后处理Hysteresis否则摄像机在阈值附近移动时块会在两个LOD之间反复横跳画面闪烁。做法是给每个LOD设两个阈值进入用大阈值退出用小阈值差值约10%到20%。3.2 Compute Shader中的剔除与LOD计算Compute Shader是这套方案的心脏我把它拆成几个关键步骤。首先是线程组布局每个地形块一个线程组组内线程数用[numthreads(1,1,1)]就够了因为每个块的计算量不大用多线程反而浪费。如果块数特别多可以用[numthreads(64,1,1)]每个线程处理一个块通过SV_GroupID和SV_GroupThreadID定位。计算内容分四步。第一步根据块索引算出块的世界坐标中心点。第二步计算中心点到摄像机视锥的六个平面的距离判断是否在视锥内。视锥平面可以从摄像机的projectionMatrix和worldToCameraMatrix提取传进Compute Shader。第三步计算中心点到摄像机的距离结合LOD阈值决定LOD级别。第四步如果通过剔除把块的索引、LOD级别、绘制参数写入AppendStructuredBuffer。这里有个细节视锥剔除用包围球还是包围盒。包围球计算简单但地形块是扁平的包围球会偏大导致剔除不够激进。包围盒更精确但计算量稍大。我的做法是用包围盒的八个角点做视锥测试虽然多几次计算但剔除率明显提升尤其在地形起伏大的时候。// 简化的剔除与LOD计算伪代码 [numthreads(64,1,1)] void CSMain(uint3 id : SV_DispatchThreadID) { uint blockIndex id.x; if (blockIndex _BlockCount) return; float3 center GetBlockCenter(blockIndex); float radius _BlockSize * 0.707f; // 包围球半径 // 视锥剔除 if (!IsInFrustum(center, radius)) return; // LOD计算 float dist distance(center, _CameraPos); uint lod 0; for (uint i 0; i _LODCount; i) { if (dist _LODDistances[i]) lod i 1; } // 写入结果 DrawData data; data.blockIndex blockIndex; data.lod lod; _DrawBuffer.Append(data); }3.3 Indirect Args Buffer的构建与提交Compute Shader算完剔除和LOD后结果要转成Indirect Draw能用的参数。Graphics.DrawMeshInstancedIndirect需要一个ComputeBuffer作为args里面包含vertexCountPerInstance、instanceCount、startVertexLocation、startInstanceLocation和startInstanceLocation五个uint。对于地形每个LOD级别的网格顶点数不同所以要么按LOD分组提交多个Indirect Draw要么在Shader里根据LOD动态选择顶点。我推荐按LOD分组。每个LOD级别一个args bufferCompute Shader把对应LOD的块写入各自的buffer。然后CPU端对每个LOD调用一次DrawMeshInstancedIndirect总共4到5次提交批次数量极少。args buffer的instanceCount由Compute Shader通过InterlockedAdd或AppendStructuredBuffer的计数来更新CPU不需要回读避免同步等待。提示AppendStructuredBuffer的计数可以用CopyCount方法拷贝到args buffer的对应位置但要注意CopyCount是异步的需要确保在提交Draw之前完成。我一般用双缓冲一帧计算下一帧的args避免同步点。3.4 地形着色器的多层混合与性能取舍地形着色器要处理高度混合、法线混合、多层地表纹理。HDRP下我建议用Shader Graph搭基础框架再用Custom Function Node嵌入HLSL做混合计算。混合层数不要超过4层每层一张albedo、一张normal、一张粗糙度。Splat map的RGBA四个通道分别对应四层的权重采样一次就够。远处的地形块可以降级只采样两层法线用常量粗糙度用平均值。这个降级逻辑可以在Shader里根据LOD级别分支用[branch]或[flatten]控制。实测下来远处降级能省30%到40%的像素着色开销画面差异在远景下几乎看不出来。还有一个细节是纹理采样的各向异性。地形纹理在斜视角下容易糊开各向异性过滤能改善但采样开销上去。我的做法是近处LOD开4x或8x各向异性远处LOD关掉或降到2x平衡画质和性能。4. 完整实操流程与关键环节实现4.1 地形数据准备与GPU上传第一步是把地形数据准备好。高度图我用World Machine或Gaea生成导出16位或32位浮点RAW或EXR。导入Unity后用脚本读成Texture2D再转成RenderTexture上传到GPU。注意高度图的精度16位在8km地形上可能有台阶感32位更稳但显存翻倍。我的经验是如果地形起伏平缓16位够用如果峡谷、悬崖多上32位。Splat map用美术在Unity里刷或者从外部工具导出。四层混合的话一张RGBA图就够。植被密度图可选如果要做GPU植被需要额外一张图存密度和类型。上传时用Graphics.CopyTexture或ComputeShader.SetTexture确保数据在GPU上常驻。高度图和splat map在运行时不变可以标记为static减少上传开销。4.2 Compute Shader的调度与参数传递每帧CPU要更新摄像机参数到Compute Shader。需要传的有摄像机位置、视锥平面六个float4、LOD距离数组、块尺寸、块数量。这些参数用ComputeShader.SetVector、SetFloat、SetFloats传递。视锥平面可以从Camera.main.projectionMatrix * Camera.main.worldToCameraMatrix提取用GeometryUtility.CalculateFrustumPlanes拿到Plane数组再转成float4。调度时用ComputeShader.Dispatch线程组数量等于块数量除以每组线程数。比如256×256个块每组64线程就是1024个组。Dispatch之前要清空AppendStructuredBuffer的计数用ComputeBuffer.SetCounterValue(0)。// C#端调度示例 void Update() { // 更新摄像机参数 computeShader.SetVector(_CameraPos, camera.transform.position); computeShader.SetFloats(_FrustumPlanes, GetFrustumPlaneFloats(camera)); computeShader.SetFloat(_BlockSize, blockSize); computeShader.SetInt(_BlockCount, blockCount); // 清空计数 drawBuffer.SetCounterValue(0); // 调度 int threadGroups Mathf.CeilToInt(blockCount / 64.0f); computeShader.Dispatch(kernelIndex, threadGroups, 1, 1); // 拷贝计数到args buffer ComputeBuffer.CopyCount(drawBuffer, argsBuffer, 4); // 偏移4字节是instanceCount // 提交绘制 Graphics.DrawMeshInstancedIndirect(mesh, 0, material, bounds, argsBuffer); }4.3 绘制提交与批次合并绘制提交是CPU端最后一步。每个LOD级别一个args buffer和一次Draw调用。args buffer的instanceCount由Compute Shader的AppendStructuredBuffer计数决定用CopyCount拷贝到args buffer的偏移4字节处。vertexCountPerInstance和startVertexLocation在初始化时设好运行时不变。批次合并的关键是让所有同LOD的块共享同一个Mesh和Material。Mesh是预生成的LOD网格Material是地形着色器。每个块的差异通过StructuredBuffer传入比如块索引、LOD级别、世界坐标偏移。Shader里用unity_InstanceID或SV_InstanceID索引这个buffer拿到块的数据。注意DrawMeshInstancedIndirect的bounds参数要设得足够大覆盖整个地形否则Unity的视锥剔除会把整个Draw调用剔掉。我一般把bounds设成地形总包围盒或者干脆设一个超大bounds把剔除完全交给Compute Shader。4.4 性能实测与参数调优记录我在一个8km×8km、256×256块、4级LOD的场景里做过实测。平台是桌面端GPU是RTX 3060级别。传统Terrain方案在视野拉满时CPU主线程约12msDraw Call约800帧率45左右。换成GPUTerrain后CPU主线程降到3ms左右Draw Call降到5每个LOD一次帧率稳定在90以上。GPU端因为多了Compute Shader的剔除计算GPU时间增加约1.5ms但总体是赚的。调优过程中发现几个关键参数。块尺寸从32米改成64米后Compute Shader的调度开销降了一半剔除率略降但总体更快。LOD距离系数从0.5调到0.8后远处地形精度提升帧率降了约5帧画面明显更稳。各向异性从8x降到4x帧率提升约3帧画质差异很小。这些参数没有绝对最优要根据目标平台和美术要求反复试。5. 常见问题与排查技巧实录5.1 地形块闪烁或LOD跳变这是最常见的问题表现是摄像机移动时某些地形块在LOD之间反复切换画面闪烁。原因通常是LOD阈值没有做滞后或者Compute Shader里的距离计算和CPU端的包围盒不一致。排查时先检查LOD距离数组是否单调递增再确认滞后逻辑是否生效。如果还是闪把Compute Shader的剔除结果可视化出来看块的LOD级别是否稳定。另一个可能原因是AppendStructuredBuffer的顺序不稳定导致同一块在不同帧被分到不同LOD。解决办法是给每个块固定一个索引LOD计算只依赖距离不依赖buffer顺序。5.2 Indirect Draw不显示或批次丢失如果地形完全不显示先检查args buffer的instanceCount是否为0。常见原因是CopyCount的偏移写错了instanceCount在args buffer的第4个uint偏移是4字节不是0。还要确认SetCounterValue(0)在Dispatch之前调用了否则计数会累积。如果部分块不显示检查Compute Shader的视锥剔除是否过于激进。包围球半径设小了或者视锥平面提取错了都会导致误剔除。把剔除结果输出到一张Debug纹理直观看到哪些块被剔了。5.3 地形接缝与法线不连续块与块之间的接缝是GPU地形的老问题。原因是相邻块的边缘顶点高度或法线不一致。解决办法是在生成LOD网格时边缘顶点的高度从高度图采样确保相邻块共享边缘数据。法线也要从高度图重新计算不要用网格默认法线。如果接缝在LOD切换时出现说明不同LOD级别的边缘顶点不匹配。可以在LOD网格生成时强制边缘顶点使用高一级LOD的密度或者用裙边Skirt遮挡。裙边实现简单在块边缘向下延伸一圈顶点颜色和地形一致能有效遮住缝隙。5.4 性能不升反降的排查思路有时候上了GPU-Driven性能反而更差。先看Compute Shader的调度开销块数太多、线程组太大都会拖慢。把块数减半试试如果帧率回升说明是调度问题。再看GPU端是否成了新瓶颈用Profiler看GPU时间如果Compute Shader占了大部分优化剔除算法比如用层次ZHi-Z或者降低剔除频率每两帧剔一次。还有一个隐藏问题是CPU和GPU的同步。CopyCount是异步的但如果每帧都等它完成就会引入同步点。用双缓冲或者延迟一帧读取计数能避免这个问题。问题现象可能原因排查方法解决思路地形块闪烁LOD无滞后、距离计算不一致可视化LOD级别加滞后阈值、统一距离算法完全不显示args buffer计数为0检查CopyCount偏移偏移设4、Dispatch前清计数部分块丢失视锥剔除过激输出剔除Debug图调大包围球、检查视锥平面接缝明显边缘顶点不匹配对比相邻块边缘高度共享边缘数据、加裙边性能下降调度开销大、GPU瓶颈Profiler看CPU/GPU时间减块数、降剔除频率5.5 跨平台兼容性注意事项这套方案在桌面端很稳但移到移动端或主机端要注意几点。移动端对Compute Shader的支持有限部分低端设备不支持AppendStructuredBuffer需要用固定大小的StructuredBuffer加原子计数替代。主机端的内存对齐和纹理格式要求更严高度图用RFloat可能不被支持要换成RHalf或RGHalf。还有一点是Shader变体。HDRP下地形着色器的变体很多打包时容易爆变体数量。用#pragma multi_compile精简把不用的LOD级别和混合层数裁掉。实测下来变体从几百降到几十打包时间和运行时内存都明显改善。6. 我个人在实际操作中的几点体会这套GPUTerrain方案我从原型到上线跑了差不多一年踩的坑不少但收益是实打实的。最大的体会是GPU-Driven不是银弹它把CPU的负担转移到GPU如果GPU本身已经吃紧效果就有限。所以上这个方案之前先确认GPU还有余量否则要先优化着色器。另一个体会是调试GPU-Driven的东西比CPU端麻烦得多。CPU端可以打断点、看调用栈GPU端只能靠Debug纹理和Profiler。我养成的习惯是每加一个Compute Shader步骤就先输出一张Debug图确认数据对了再往下走。这个习惯省了很多返工时间。最后分享一个小技巧LOD距离和剔除参数不要硬编码做成ScriptableObject或者配置文件运行时可以热调。我在编辑器里挂了一个调试面板滑动条直接改LOD距离实时看帧率和画面变化调参效率高很多。这个面板后来成了团队里最常用的工具之一美术也能自己调不用每次都找程序。