尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

GPU Driven Terrain实战:大世界地形渲染从CPU到GPU的迁移与优化

发布时间:2026/9/29 1:23:45

资讯中心
01
ARTICLE

GPU Driven Terrain实战:大世界地形渲染从CPU到GPU的迁移与优化

GPU Driven Terrain实战:大世界地形渲染从CPU到GPU的迁移与优化
1. 为什么大世界地形非得走GPU Driven这条路做开放世界或者大场景地图的团队几乎都绕不开一个经典矛盾地形精度和渲染开销之间的拉锯。传统Unity Terrain在中小场景里够用但一旦把视野拉到几公里甚至十几公里问题就集中爆发了。我最早做的一个2km×2km的野外场景用Unity原生Terrain光是地形本身的Draw Call在复杂视角下就能冲到三四百加上草、树、石头这些细节物件CPU端的剔除和提交直接把主线程吃满帧率从90掉到40出头而且这还是在一台配置不错的开发机上。问题的根源在于传统Terrain的LOD切换、剔除、绘制提交这些活儿基本都在CPU侧完成。地形被切成一块块Patch每块Patch要不要画、画哪一级LODCPU得挨个判断然后一个个提交Draw Call。场景一大Patch数量上去CPU就成了瓶颈。更麻烦的是Unity原生Terrain的LOD是整块切换的你没法做到同一块地形里近处精细、远处粗糙的平滑过渡视觉上容易出现明显的跳变。GPU Driven Terrain的思路就是把这一整套逻辑搬到GPU上去。地形的高度图、法线、权重贴图全部以纹理形式常驻显存Compute Shader负责视锥剔除、LOD选择、Patch生成最后通过Graphics.DrawProceduralIndirect或者RenderPrimitivesIndirect一次性提交。CPU这边几乎不参与每帧的地形决策只负责更新相机参数和少量全局数据。这样一来Draw Call从几百降到个位数主线程压力骤减帧率能稳定在高位。这套方案特别适合几类人一是做开放世界、大地图生存、沙盒建造的团队二是做数字孪生、GIS可视化这类需要加载真实地形数据的项目三是想在移动端或中低端设备上跑大场景、对性能极度敏感的开发者。如果你只是做个几百米见方的小关卡那原生Terrain完全够用没必要上这套。但只要你的地形尺度上了公里级或者需要动态加载、无限延伸的地形GPU Driven基本是绕不过去的选择。关键词里提到的HDRP、Compute Shader、GPU-Driven Terrain其实指向的是同一件事用GPU的并行能力替代CPU的串行决策用间接绘制替代逐物体提交。下面我会把这套方案从数据组织到Shader实现再到实际踩过的坑完整拆一遍。2. 地形数据的组织方式Heightmap、SplatMap与Patch切分2.1 为什么不用Unity Terrain的数据格式很多人第一反应是直接拿Unity Terrain的TerrainData来用毕竟高度图、SplatMap都是现成的。但我实测下来原生TerrainData在GPU Driven场景下有几个硬伤。第一它的高度图分辨率是固定的比如2049×2049你没法按需动态加载不同精度的块第二SplatMap的通道布局是Unity内部约定的你想在Compute Shader里自定义采样逻辑会很别扭第三TerrainData的LOD信息是CPU侧维护的GPU拿不到。所以更稳妥的做法是自己定义一套地形数据格式。我通常会把整个大世界切成固定大小的Tile比如每块Tile覆盖256米×256米高度图用R16或R32格式存成Texture2DArray每个Tile一层。SplatMap同理用RGBA通道存四种地表权重草、石、沙、雪之类需要更多层就再加一张。这样在Compute Shader里采样时只需要根据世界坐标算出Tile索引和UV直接Texture2DArray.SampleLevel就行非常干净。这里有个细节值得说高度图的精度选择。R16能表示0到65535的高度值如果地形高差在几百米以内精度完全够用显存占用还小。但如果你的场景有深海到高山的极端高差或者需要做非常精细的微地形那就得上R32。我一般会先算一下假设地形最大高差是H米R16的量化步长是H/65535如果这个步长小于你想要的精度比如0.1米那R16就够。否则换R32。2.2 Patch的切分粒度与LOD层级设计地形不能一整块丢给GPU画得切成Patch。Patch的大小直接影响剔除精度和LOD切换的平滑度。Patch太大剔除粒度粗远处一堆不需要的三角形还在画Patch太小Patch数量爆炸Compute Shader的调度开销和间接绘制的索引缓冲都会变大。我的经验值是每个Patch覆盖32米到64米具体看你的地形精度需求。假设你的高度图分辨率是每米1个顶点那32米的Patch就是32×32的网格1024个顶点这个粒度在剔除和绘制之间比较平衡。LOD层级我一般设4到5级。以64米Patch为例LOD0是64×64网格每米1顶点LOD1是32×32每2米1顶点LOD2是16×16LOD3是8×8LOD4是4×4。每级LOD的顶点数按4倍递减这样在视觉上过渡比较自然。关键是LOD的选择要基于屏幕空间误差而不是简单的距离阈值。屏幕空间误差的计算方式是Patch的几何误差比如相邻LOD之间的高度差投影到屏幕上占多少像素。如果小于1个像素就可以用更低一级的LOD。这样在不同分辨率、不同FOV下都能自适应。2.3 用Texture2DArray还是Virtual Texture数据组织上还有一个选择是用Texture2DArray把整个世界的Tile都加载进来还是用Virtual Texture按需流式加载。前者实现简单但显存占用随世界大小线性增长一个10km×10km的世界如果每块Tile是256米那就是40×401600块每块高度图2049×2049的R16光高度图就接近13GB显然不现实。所以大世界必须走Virtual Texture或者分块流式加载的路子。我的做法是维护一个Tile的LRU缓存根据相机位置和视野方向预测接下来可能需要的Tile异步加载到Texture2DArray的空闲槽位里。Compute Shader里通过一个Indirection Table间接索引表把逻辑Tile坐标映射到物理槽位。这样显存里只保留当前需要的几十块Tile世界可以无限大。这个Indirection Table本身也是一张纹理每个像素对应一个Tile的逻辑坐标值是该Tile在物理数组里的索引。当Tile被换出时把对应像素的值设为一个无效标记Compute Shader采样时判断到无效就跳过或者用低精度Fallback。这套机制听起来复杂但一旦跑通扩展性非常好。3. Compute Shader里的剔除、LOD与间接绘制3.1 视锥剔除在Compute Shader里怎么写视锥剔除的核心是判断Patch的包围盒是否与相机视锥相交。在Compute Shader里每个线程处理一个Patch读取Patch的世界坐标和包围盒高度然后做6个平面的相交测试。这里有个优化点不要用Patch的完整AABB而是用一个保守的包围球或者简化包围盒。因为地形Patch的高度变化通常不会太剧烈用一个中心点加上最大高度的包围盒就够计算量小很多。具体实现上我会把相机的6个视锥平面参数法线距离作为一个ConstantBuffer传给Compute Shader。每个线程计算Patch中心到6个平面的有符号距离如果所有距离都小于负的包围盒半径就说明完全在视锥外剔除。如果有一个平面的距离在正负半径之间说明部分可见保留。这个测试非常快一个线程几纳秒就能完成。但光有视锥剔除还不够遮挡剔除Occlusion Culling在大世界里同样重要。比如你站在山谷里前面的山挡住了后面大片地形这些被挡住的地形不应该画。GPU Driven的遮挡剔除通常用Hi-ZHierarchical Z或者软件光栅化的方式做。我一般会先用上一帧的深度缓冲生成Hi-Z金字塔然后在Compute Shader里对每个Patch的包围盒做层级深度测试。这套东西实现起来有一定门槛但如果你的场景遮挡关系复杂收益非常明显。3.2 LOD选择的屏幕空间误差计算LOD选择我前面提到了屏幕空间误差这里展开说下具体怎么算。假设Patch的几何误差是E米相机到Patch的距离是D米相机的垂直FOV是F弧度屏幕高度是H像素。那么投影到屏幕上的误差像素数大约是errorPixels (E / D) * (H / (2 * tan(F/2)))如果errorPixels小于某个阈值比如1.5像素就可以降一级LOD。这个计算每个Patch做一次然后根据结果选择对应的LOD网格。实际实现时我会把每个LOD层级的几何误差预计算好存在一个常量数组里。Compute Shader里算出errorPixels后从高到低遍历LOD层级找到第一个满足误差要求的。这样比逐个距离判断要精确得多而且能自适应不同分辨率和FOV。有个坑要注意LOD切换时的裂缝Crack问题。相邻Patch如果LOD不同边界处的顶点会对不齐出现缝隙。解决办法有两种一是用Stitching缝合在边界处生成过渡三角形二是用Morphing形变把高LOD Patch的边界顶点逐渐向低LOD的位置插值。我一般用Morphing实现简单视觉上也更平滑。具体做法是在顶点着色器里根据顶点到Patch边界的距离计算一个morph系数把顶点位置向低LOD的对应位置插值。3.3 间接绘制的参数怎么组织GPU Driven的核心是间接绘制也就是DrawProceduralIndirect或DrawMeshInstancedIndirect。这些API需要一个Indirect Args Buffer里面包含顶点数、实例数、起始顶点、起始实例等参数。Compute Shader在剔除和LOD选择完成后用InterlockedAdd往这个Buffer里追加可见Patch的信息。具体来说我会维护两个Buffer一个是visiblePatchBuffer存每个可见Patch的索引和LOD层级另一个是indirectArgsBuffer存绘制参数。Compute Shader里每个线程处理一个Patch如果可见就用InterlockedAdd获取一个写入位置把Patch信息写进去同时更新indirectArgsBuffer里的实例数。这里有个细节不同LOD层级的Patch网格顶点数不同不能混在一个间接绘制里。所以我会按LOD层级分组每个LOD一个indirectArgsBuffer最后分别调用DrawProceduralIndirect。这样虽然多了几次绘制调用但每次都是批量的总Draw Call数还是很少通常5次以内。顶点着色器里根据SV_VertexID和SV_InstanceID从visiblePatchBuffer里读出Patch索引和LOD层级然后从对应的网格模板里取顶点位置再根据Patch的世界坐标偏移到正确位置。高度值从高度图纹理里采样法线可以预计算存在法线贴图里也可以从高度图用差分算出来。4. HDRP下的材质、光照与地形融合4.1 HDRP的Shader Graph与自定义HLSL怎么选HDRP下做地形材质很多人第一反应是用Shader Graph拖拖拽拽就能出效果。但GPU Driven Terrain的顶点逻辑是自定义的Shader Graph对顶点修改的支持有限尤其是需要从Compute Shader读取Buffer的时候Shader Graph基本做不了。所以核心的地形Shader必须用HLSL手写Shader Graph只适合做后处理或者一些简单的表面效果。手写HLSL的好处是完全可控。我会在顶点着色器里做高度采样、LOD Morphing、Patch偏移在片元着色器里做SplatMap混合、法线计算、PBR光照。HDRP的Lighting.hlsl和BSDF.hlsl可以直接Include进来用它的PBR光照模型这样地形和场景里其他物体的光照是一致的。有个细节HDRP的材质系统对自定义Shader有一些约定比如必须声明_MaterialID、_StencilRef这些属性否则渲染时可能出问题。我踩过一次坑地形渲染出来是纯黑查了半天发现是没设置_MaterialIDHDRP的渲染管线把它当成无效材质跳过了。所以手写HDRP Shader时最好找一个HDRP自带的Lit Shader作为模板把必要的属性都保留。4.2 SplatMap混合与三平面映射的取舍地形材质混合通常用SplatMap每个通道对应一种地表纹理的权重。标准的做法是在片元着色器里采样四张地表纹理然后按SplatMap的权重加权平均。但这里有个问题当视角接近水平时UV拉伸会非常严重远处的纹理糊成一片。解决办法是三平面映射Triplanar Mapping也就是在X、Y、Z三个方向上分别采样纹理然后按法线的权重混合。这样无论视角怎么变纹理都不会拉伸。但三平面映射的代价是采样次数翻三倍性能开销不小。我的折中方案是近处用标准UV映射远处用三平面映射中间用距离做过渡。具体来说在片元着色器里算一个blend系数根据像素到相机的距离在两种采样结果之间插值。这样近处保持性能远处保持视觉质量。实测下来这个方案在2km视距下地形材质的性能开销比纯三平面低40%左右视觉上几乎看不出区别。4.3 地形与场景物体的光照一致性地形作为大场景的基底它的光照必须和场景里的物体一致否则会显得浮在上面。HDRP下地形的光照主要来自方向光、环境光、以及可能的反射探针。我会确保地形的Shader里正确采样了HDRP的Lighting环境包括阴影、SSAO、SSR这些效果。有个容易忽略的点地形的法线精度。如果法线是从高度图差分算出来的在低LOD层级下法线会非常粗糙光照看起来一块一块的。解决办法是在低LOD下用预计算的法线贴图或者对法线做平滑滤波。我一般会在生成地形数据时顺便生成一张法线贴图每个LOD层级一张这样光照质量稳定很多。另外地形和场景物体的阴影交互也要注意。HDRP的阴影级联Cascade Shadow在大场景下可能会有精度问题远处地形的阴影会闪烁。我会把阴影距离适当调大或者对远处地形用屏幕空间阴影Screen Space Shadow替代。5. 实测中的性能数据与踩坑记录5.1 从CPU Terrain迁移到GPU Driven的帧率对比我在一个2km×2km的场景里做了对比测试硬件是i7-10700K RTX 3070分辨率2560×1440HDRP管线。原生Terrain在复杂视角下能看到大量地形和植被的帧率是42-55 FPS主线程耗时18-22ms其中地形相关的Draw Call约320个。迁移到GPU Driven后同样的视角帧率稳定在85-95 FPS主线程耗时降到6-8ms地形Draw Call降到4个按LOD分组。GPU侧的耗时增加了Compute Shader的剔除和LOD选择大约占1.5ms间接绘制的顶点处理占2-3ms。但总体来看CPU的瓶颈解除了帧率提升非常明显。而且这个提升在场景越大、视角越复杂时越显著。移动端我也试过用骁龙870的设备跑1km×1km的场景GPU Driven方案能稳定在30 FPS左右原生Terrain只有15-20 FPS。不过移动端的Compute Shader支持有限有些特性比如Hi-Z遮挡剔除需要降级处理。5.2 Compute Shader线程组大小的选择线程组大小numthreads对Compute Shader的性能影响很大。我试过64、128、256三种配置在RTX 3070上128的配置综合表现最好。64的配置线程利用率不够256的配置在某些驱动上会有调度开销。具体来说我会把线程组设成[numthreads(8, 8, 1)]每个线程处理一个Patch一个线程组处理64个Patch。这样在大多数GPU上都能跑满。如果你的Patch数量特别大可以适当增大线程组但不要超过256否则会有寄存器压力。还有个细节Compute Shader的Dispatch数量要控制。如果Patch总数是10万按64个一组需要Dispatch 1563次。这个数量有点多我会把多个Patch合并到一个线程里处理比如每个线程处理4个Patch这样Dispatch次数降到391次开销小很多。5.3 高度图采样的精度与性能平衡高度图采样是顶点着色器里的高频操作每个顶点都要采一次。如果高度图是R32格式采样带宽是R16的两倍。我实测下来在2km视距下R16和R32的帧率差异大约3-5 FPS视觉上R16的精度完全够用。所以除非有特殊需求优先用R16。另外高度图采样要用SampleLevel而不是Sample因为顶点着色器里没有隐式导数用Sample会报错或者行为不确定。SampleLevel需要指定mip层级我一般用0级因为高度图不需要mipmap。但如果你的地形有远处简化需求可以用mipmap来做自动LOD这样采样开销更小。还有个坑高度图的Filter Mode。如果用Point采样地形会有明显的块状感用Bilinear或Trilinear边缘会平滑但可能过度模糊。我一般用Bilinear然后在Shader里手动做一次锐化效果比较好。5.4 间接绘制Buffer的同步问题间接绘制的Buffer需要在Compute Shader写完后GPU才能读。如果同步没做好会出现画面撕裂或者Patch闪烁。Unity里可以用ComputeBuffer的SetData和GetData做同步但这样会强制CPU等待GPU性能很差。正确的做法是用Graphics.ExecuteCommandBuffer或者CommandBuffer的依赖关系让GPU自己管理同步。具体来说我会在CommandBuffer里先Dispatch Compute Shader然后直接调用DrawProceduralIndirect中间不加任何CPU同步。Unity的渲染管线会自动处理依赖只要Buffer的创建和销毁时机正确。有个细节Buffer的创建要用ComputeBufferType.IndirectArguments否则间接绘制的参数读不出来。这个我踩过一次坑Buffer类型设错了绘制出来全是乱码查了好久才发现。6. 大世界地形的扩展方向与个人经验6.1 动态加载与无限地形的实现思路前面提到的Virtual Texture和Tile流式加载是无限地形的基础。但要做到真正的无限还需要解决几个问题一是Tile的生成如果地形是程序化生成的需要在后台线程异步生成高度图和SplatMap二是Tile的卸载要根据相机位置和内存预算及时释放不再需要的Tile三是Tile的接缝处理相邻Tile的高度和材质要无缝衔接。我的做法是维护一个Tile池每个Tile有加载中、已加载、待卸载三种状态。相机移动时根据视野范围计算需要的Tile列表与当前已加载的Tile做差集缺的异步加载多的标记待卸载。异步加载用C#的Task或者Unity的Job System在后台线程生成数据然后回主线程上传到GPU。接缝处理上我会在生成Tile时让每个Tile的高度图边缘多生成一圈重叠数据采样时相邻Tile的边缘能对上。材质同理SplatMap的边缘也要重叠。这样虽然多了一点显存开销但接缝问题彻底解决。6.2 地形与植被、建筑的GPU Driven整合地形只是大世界的一部分植被、建筑、石头这些物件同样需要GPU Driven。我的思路是把它们也做成间接绘制用同一套剔除和LOD逻辑。植被可以用GPU Instancing每个实例的位置、旋转、缩放存在ComputeBuffer里Compute Shader做剔除后用DrawMeshInstancedIndirect绘制。建筑和石头这类大物件可以用类似地形的Patch管理方式每个物件一个包围盒Compute Shader做视锥和遮挡剔除。这样整个场景的绘制都在GPU侧完成CPU只负责逻辑更新。有个细节不同物件的LOD策略不同。植被的LOD可以很激进远处直接换成Billboard或者剔除建筑的LOD要保守一些因为形状变化大会导致视觉跳变。我会给每类物件配不同的LOD参数在Compute Shader里根据物件类型选择。6.3 我在实际项目中的几点体会第一不要一开始就追求完美。GPU Driven Terrain的实现复杂度很高我建议先跑通最基本的版本固定LOD、无视锥剔除、无遮挡剔除就单纯用Compute Shader生成Patch然后间接绘制。跑通后再逐步加剔除、加LOD、加Virtual Texture。这样每步都有可验证的结果不会一上来就被复杂度淹没。第二调试工具要提前准备。GPU Driven的调试比CPU侧难得多因为很多逻辑在GPU里没法直接打断点。我会写一些Debug Shader把Patch的LOD层级、剔除状态用颜色可视化出来这样一眼就能看出哪里有问题。Unity的Frame Debugger也能用但对Compute Shader的支持有限。第三性能优化要数据驱动。不要凭感觉猜哪里是瓶颈用GPU Profiler比如RenderDoc或者Unity的Profiler实际测。我遇到过好几次以为瓶颈在Compute Shader结果测下来是顶点着色器的高度图采样以为瓶颈在绘制结果是Buffer的同步。数据不会骗人。第四移动端要特别小心。移动GPU的Compute Shader支持参差不齐有些设备不支持InterlockedAdd有些设备的线程组大小有限制。我会在移动端用更保守的配置比如线程组降到64禁用Hi-Z遮挡剔除用简单的距离LOD。宁可效果差一点也要保证兼容性。这套方案我从最早的原型到最终上线前前后后改了七八个版本踩过的坑能写一整篇。但每次解决一个问题看到帧率往上跳一截那种成就感还是很实在的。如果你也在做大世界地形希望这些经验能帮你少走点弯路。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。