☰
Unity大世界地形性能优化:GPU-Driven地形渲染方案与HDRP集成实践
2026/9/29 18:29:02 网站建设 项目流程

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 Shader;Compute 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 buffer,Compute 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主线程约12ms,Draw 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占了大部分,优化剔除算法,比如用层次Z(Hi-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距离,实时看帧率和画面变化,调参效率高很多。这个面板后来成了团队里最常用的工具之一,美术也能自己调,不用每次都找程序。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询