两年多前团队要做一个开放世界原型一张4km×4km的野外大图角色要步行也要开车偶尔还要飞起来看全貌。一开始谁都没想到地形会变成最大的坎直接丢了一个Unity内置Terrain进去分辨率拉到1025从编辑界面开始就卡运行时帧率一路从60往下掉主线程和渲染线程的等待时间肉眼可见地飙升。也是从那次之后我开始把重心转到Unity大世界方案的地形渲染上最终落地了一套GPUTerrain实现。这篇内容把当时的选型逻辑、核心原理、实现细节和踩坑点都摊开讲一遍如果你也在做大世界或者正被内置Terrain的性能瓶颈卡住可以把它当成一份前置参考。先说一句关键的GPUTerrain不是一个开箱即用的插件它是一整套“把地形Mesh的生成从CPU搬GPU”的思路。落到不同项目里分块方式、LOD策略、Shader写法都不一样没有标准答案。文章里写的算是我验证过、并且修到能稳定跑的做法你可以直接参考但更建议理解之后针对自己的地形数据重新做一遍。1. 项目起点当内置Terrain撑不起大世界时很多团队选内置Terrain理由很简单拖进来就能刷导出AssetBundle也方便。但它的问题在“大世界”这个前提下会被快速放大。我当时做了一张2km×2km的测试地形还只开了四分之一块地形本身加上树木、贴花、阴影DrawCall直接冲到2000多。这还是在PC上放到移动端基本不用测必挂。具体痛点其实集中在三块顶点数据量1025×1025的Mesh意味着百万级顶点Unity需要把这些顶点从CPU侧提交给GPUMesh上传本身就有几毫秒的开销更别提编辑器里刷高度时整块Mesh都要更新。DrawCall与批次内置Terrain会根据纹理层、树、细节草自动拆批次听起来智能实际上世界一大shadow pass、base pass、depth pass叠加起来批次数量完全失控。加载与卸载粒度Terrain对象一多场景序列化和运行时加载都很重。想做流式加载得自己切块但切完每一块的LOD如何衔接、如何避免边缘接缝内置方案并没有给出很好的答案。我当时对比了三条路继续用内置Terrain、买第三方地形插件、自研GPUTerrain。维度Unity内置Terrain第三方商业地形插件自研GPUTerrain顶点生成位置CPU生成Mesh并上传多为CPU/GPU混合全部在GPU侧生成LOD调度内置黑白盒多数已封装完全自控流式加载配合度一般看实现高可按Chunk定制优先级学习成本低中高需要能改Shader性能上限一般较好上限最高但完全看实现最终选了自研GPUTerrain主要原因是团队对地形的要求很明确要有高度的、连续的、基于灰度图的高度场要支持运行时根据相机位置在毫秒级切换LOD还要能配合Addressables做流式加载。这些需求用第三方插件也能满足大半但调试起来的黑盒成本远高于自己控制一套代码。提示如果你的项目只有1km以内的范围而且平台是PC内置Terrain完全够用。别看到“大世界”就上GPUTerrain它只是适配大场景地形渲染不是银弹。2. 渲染管线拆分GPU地形快在哪又贵在哪理解GPUTerrain的关键是先理解内置Terrain为何慢。内置Terrain生成地形时CPU会根据Heightmap采样构建出Mesh顶点坐标然后把Mesh扔给渲染管线。绘制时顶点着色器只做MVP变换。这里的问题在于地形越大CPU需要上传的顶点数据就越庞大而LOD切换时要重建的部分越多主线程就越容易被拖死。GPU地形的思路则相反CPU永远不生成完整Mesh它只维护地形Chunk的管理信息位置、尺寸、高度图引用真正的顶点生成交给GPU侧的顶点着色器。渲染一个Chunk网格时顶点着色器用顶点在网格里的局部UV去采样高度图从灰度值中还原高度Y然后在GPU里一步到位完成位移和投影。这个“把高度从纹理里读出来”的步骤在图形学里叫Vertex Texture Fetch缩写VTF。它的好处很直观CPU侧要传输的数据变成了一小块固定密度网格的顶点索引以及一张高度图纹理远小于上传完整地形Mesh的数据量。而绘制不同LOD只需要让不同密度网格去采同一张高度图。2.1 内置Terrain的瓶颈到底在哪用性能分析器抓数据时会发现内置Terrain慢的往往不是GPU渲染本身而是CPU侧的地形更新逻辑。Unity官方对Terrain做了不少优化比如按Tile拆分、区域内增量更新但它的更新粒度始终是“一整块Mesh区域”只要美术在编辑器里动了笔刷或者运行时修改了Heightmap整块区域的重计算就得在CPU上跑一遍。大世界里还有另一个隐蔽问题地形要参与阴影投射。内置Terrain在shadow pass里同样需要完整顶点数据等于把地形的全部三角形又提交了一遍。射线检测、NavMesh烘焙又会继续压榨CPU。最终一个看似普通的地形CPU侧帧时间能吃掉8到10毫秒。2.2 两条路线VTF方案与Compute Mesh方案在实际实现GPUTerrain时通常有两条技术路线VTF 固定网格细分每个Chunk使用固定分辨率的网格比如65×65顶点顶点着色器采样高度图确定位置。LOD通过调整网格步长或预生成多套索引来实现。这是较经典的做法实现简单兼容性好。Compute Shader生成Mesh把高度采样和LOD裁剪放在Compute Shader里先出一份顶点Buffer再交给普通管线绘制。这种方案更灵活可以做到按距离精确决定每个顶点密度但跨平台兼容性稍差移动端尤其要谨慎。2.3 我为什么选了VTF方案我们最终选了VTF方案。原因有三第一项目需要同时兼容PC和部分移动端Compute Shader在不同GPU驱动上的表现差异很大尤其是大范围Buffer的调度和同步出问题很难查第二VTF方案里地形的渲染Pass只有一次抽样和一次位移计算Shader结构非常清晰后续想调整Splat混合、法线采样都很容易第三团队里没有专职图形程序员VTF方案的调试成本比Compute Mesh低得多。代价也有固定网格的分辨率上限意味着地形细节不能靠无限细分来做高峰岩壁这类造型很难只靠Heightmap表达清楚。但我们的美术资产本身就是高度图这个限制可以接受。3. 分块与LOD调度网格管理是一切的前提GPUTerrain的GPU部分其实只负责把高度“捏”出来真正决定性能的是CPU侧怎么管理这些Chunk。我见过不少照着教程写完Shader就跑起来很顺、一旦地图扩大到4km就开始卡成PPT的案例——问题都出在Chunk调度上。3.1 一个Chunk该多大尺寸选择的经验值Chunk尺寸要同时满足两个条件一是网格密度对相机距离足够二是数量不能多到让CPU遍历吃力。我们最终把4km×4km的地图切成256m×256m的Chunk每块是65×65顶点这样全图共16×16256个Chunk。在近距离每个顶点对应的网格间距约为4m配合Splat贴图可以接受一旦相机贴地再靠Tessellation或高密度贴图在Shader层做细节。这个经验值也不是拍脑袋定的。如果Chunk太大比如512m近距离顶点密度不够需要靠更高的内部网格分辨率但总三角形数会急剧上升如果Chunk太小比如64m纹理重复次数、DrawCall数量会成倍增加四叉树深度也会变深遍历成本跟着上来。256m在绝大多数中大型地图上是一个折中值。3.2 四叉树更新逻辑与对象池复用CPU侧维护一棵四叉树根节点覆盖4km每个节点按四等分拆开叶子节点才真正拥有渲染用的Chunk。选LOD时我写了一个很朴素的规则根据相机到节点的距离和节点尺寸算出一个阈值小于阈值就继续细分否则直接让当前节点生成Chunk。private void TraverseNode(Bounds nodeBounds, int depth) { float distSqr (camPos - nodeBounds.center).sqrMagnitude; float threshold nodeBounds.size.x / (4f * (depth 1f)); if (distSqr threshold * threshold depth maxDepth) { for (int i 0; i 4; i) TraverseNode(GetChildBounds(nodeBounds, i), depth 1); return; } chunkManager.RequestChunk(nodeBounds, depth); }Chunk本身需要复用。我维护了一个按LOD等级分组的对象池相机移动时要更新的Chunk不是直接销毁重建而是通过状态机从“隐藏”切到“显示”。每帧只处理有限数量的变更请求比如最多8个这样可以把CPU帧时间的波动控制住。如果你让所有Chunk在同一帧内全部重生顶点Buffer重新上传的开销会瞬间打满一帧。3.3 裙边与Stitch最容易被忽视的裂缝问题当相邻两个Chunk的LOD级别不同边缘的顶点密度不一致地形就会出现裂缝。愈合裂缝最常见的两个办法裙边Skirt在Chunk边缘下方额外生成一圈下垂的三角形带利用它遮挡缝隙。实现简单但在地形高低差大的区域会看到明显的垂直“帘子”。Stitch索引缝合让低LOD边缘顶点的采样密度对齐高LOD边缘顶点然后修改索引跳过多余顶点。效果好但需要针对不同LOD组合预生成多套索引表。我们实际用的是Stitch方案的变体子块边缘与其父块相邻时子块的边缘顶点采用父块的采样坐标索引表在初始化时一次性生成按LOD等级和邻居关系查表即可。这样既避免了裂缝也避免了运行时动态改索引带来的GC。4. 顶点高度、法线与多层SplatShader侧核心战场VTF方案里Shader是最核心的部分。它不是随便一个地形Shader改改就行有太多细节决定了画面能不能看、性能能不能扛。4.1 顶点着色器里如何“捏”出高度最基础的实现里顶点着色器拿到的是Chunk网格的局部坐标我用局部坐标除以Chunk尺寸得到范围在0到1的UV然后去采样高度图。float4 heightColor tex2Dlod(_HeightMap, float4(localUV, 0, 0)); float height heightColor.r; float3 worldPos float3(localPos.x, height * _HeightScale, localPos.z);这里第一个大坑就是mipmap。默认情况下一张带mipmap的高度图在远处采样时会自动落到低分辨率层远山的高度细节会被抹平看起来像整块地形在塌陷。解决办法有两个不用mipmap直接采样level 0或者手动计算一个合适的mip级。用tex2Dlod并把第四个分量固定为0就相当于采样level 0我最后选了这种简单方案。代价是高度图纹理变大因为不能享受mip带来的显存优化但只要高度图是单通道R16格式4km地图也就几十MB能接受。4.2 法线的两种来源与远处失真法线不能直接用顶点位置做叉乘因为顶点网格太粗糙算出来的法线都是折面。独立做法有两种预计算法线贴图或者运行时用屏幕导数ddx/ddy。预计算法线贴图简单高效在CPU侧用Sobel算子从高度图生成一张法线图挂在材质上顶点着色器里直接采样。我在项目里就是这么做的配合高度图的统一UV法线和高度完全对齐。屏幕导数法线看似更“GPU友好”但它在远处的表现非常差一旦地形像素小于一个屏幕像素导数计算会得出噪声般的法线方向导致高光疯狂闪烁。除非你的地形范围很小否则不推荐在生产环境用屏幕导数法线。4.3 四层Splat混合的Shader实现纹理混合我用了经典的Splat Map方案。每个Chunk有一张控制图RGBA分别表示石头、泥土、草地、雪地的混合权重再配合四张漫反射贴图。在顶点着色器里我先把每个纹理层的采样UV算好片元着色器里做一次Lerp。float4 splat tex2D(_SplatControl, worldUV * _SplatTiling); float3 color lerp(color, _LayerRock.rgb, splat.r * 0.5); color lerp(color, _LayerGrass.rgb, splat.g * 0.5); // 依次叠加泥土、雪地这里容易踩的坑是远处纹理闪烁。原因是Splat控制图和四个层纹理的mipmap采样不一致某一层在某个距离上突然采样到高对比度像素就会形成斑驳的“蝴蝶结”条纹。我最后的做法是把控制图mipmap上限锁到8×8并给层纹理开启三线性过滤配合World UV的连续映射远处的整体观感会柔顺很多。另外千万别在片元里做过多纹理采样。地形本身就是全屏覆盖的重负载对象四层Splat加控制图就是五次采样如果上升到八层混合帧率会立刻对你不客气。5. 物理、阴影与静态光照GPU地形之外的系统衔接渲染跑通了游戏逻辑才会跟着找上门。GPUTerrain最大的问题在于它不是Unity的Terrain对象很多本来“白给”的系统都失效了。5.1 物理高度系统没有Collider的地形依然可以站人给动态生成的Chunk挂MeshCollider是灾难。每个Chunk的Mesh本身就是GPU侧临时生成的CPU侧并没有一份可以直接用于碰撞的Mesh数据。有的实现会为了碰撞再在CPU侧生成一份同样的Mesh那等于前面省下的CPU开销全部吐了回去。我的做法是完全放弃MeshCollider改成一个高度查询服务。所有需要落地或跟随地形的逻辑比如角色移动、载具悬挂、草摆放都统一调用一个接口public float GetTerrainHeightAt(Vector3 worldPos) { float u (worldPos.x - originX) / worldWidth; float v (worldPos.z - originZ) / worldDepth; return _heightMap.GetPixelBilinear((int)(u * _heightMapTexWidth), (int)(v * _heightMapTexHeight)).r * _heightScale; }这个查询在CPU侧直接读Heightmap纹理单次调用开销极低。角色站在地形上只需要每次移动后做一次高度校正。子弹、射线检测这类场景我再另做一份低分辨率碰撞图比如64×64用于射线交叉检测。Gameplay层根本不用关心地形有没有Collider。5.2 阴影投射策略为何关掉CastShadow反而更快GPUTerrain如果开启Cast ShadowShadowMap pass里GPU会再次遍历整个地形Shader生成阴影深度的代价和主渲染几乎一样。4km×4km的地形投射阴影ShadowMap分辨率根本承载不了反而会导致大面积shadow acne和阴影抖动。更划算的方案是地形材质接收阴影但不投射阴影。用远处低模代理或周围的山体等少数物体去投射视觉上玩家感知不到差别。如果你一定要地形投阴影比如黄昏长影可以单独做一个低分辨率版本的GPUTerrain只在Shadow pass里用更粗的LOD渲染但我个人不建议在第一个版本里做这个功能收益低、成本高。5.3 烘焙、AO与静态光照的妥协Unity的Lightmap烘焙基于静态Mesh的UV而GPUTerrain的高度图UV和Mesh UV并不适合直接烘焙。我的处理是把地形视作全局AO的一个整体来烘焙用一张低分辨率AO图比如1024×1024在Shader里与光照结果相乘。方向光的阴影完全走实时方案只开近距离。如果你做的是写实大世界这套方案的短板会在晚间表现明显没有烘焙GI地表颜色会偏平。解决方案也明确要么预先烘焙一张间接光采样Texture3D用于全局查询要么接受只在白天光照下发布。我们当时选了后者节省了大量迭代时间。6. 跑分与踩坑从黑缝、毛刺到相机瞬移方案落地不是一蹴而就的我们至少踩了四五个比较深的地形坑每一个都花了一两天定位。6.1 远山塌陷与抖动这是最早遇到的大问题。开着车往远处看山的轮廓总在一帧一帧地跳。定位后发现还是mipmap的问题高度图采样mip级别未固定GPU在LOD切换和像素覆盖变化时选了不同的mip层。修复方式是把tex2Dlod固定为level 0并且不生成mip链。抖动立刻消失。6.2 分块边缘的黑缝Chunk边缘出现一条条黑线常见原因有两种高度图用压缩格式后边缘像素插值异常或者UV重复边界没设Clamp。我把高度图改成R16浮点格式Sampler Wrap Mode设为Clamp再把相邻Chunk的边界UV做一次半像素偏移黑缝基本消失。注意如果用了压缩高度图尽量用BC4/ASTC单通道格式不要在RGB通道里存高度。RGB转灰的插值在GPU上会和CPU不一致边缘会有微小的数值裂缝。6.3 相机瞬移时的Chunk延迟玩家传送或高速飞行时新的Chunk有时候会延迟一两帧出现露出现空地底。理论上LOD调度是按相机位置实时算的但创建新Chunk需要时间。我在Camera位置预判上做了一点偏移根据当前速度计算未来位置再基于未来位置提前生成Chunk。这样传送后第一帧就能看到基本完整的地形轮廓。6.4 开启TAA后的毛刺引擎的TAA会拿前后帧采样做混合而GPUTerrain的顶点位置是每帧动态生成的LOD边界处的顶点在TAA混合时会产生可见毛刺。我的处理是直接关掉TAA换用FXAA或者仅在静态截图时临时开TAA。如果你的管线一定要TAA需要额外给地形输出稳定的法线和深度那不是一个小工程。下面是性能对照的参考数据来自一台中端PC4km×4km地图载具从山谷向山顶移动的同一路径指标内置TerrainGPUTerrain主线程帧耗时9.5ms2.1ms渲染线程帧耗时6.3ms3.4ms总DrawCall2720约380总三角形数5.4M2.2M长时间帧率稳定性有明显掉帧基本稳定60从数据上看GPUTerrain的收益不是一星半点。但要注意三角形数之所以只降了一半是因为我们把省下来的预算用在了更细的LOD0上近距离画面比原来更清晰。7. 后续扩展思路与个人体会如果你看完也想在当前项目里搞GPUTerrain我建议先做两件事把所有地形资产统一成单通道高度图然后写一个最简版本的Chunk渲染Demo别一上来就上四叉树和Splat。跑通了再逐层加复杂逻辑这样定位问题会轻松很多。我们之后还在这个方案上接了两样东西一是结合Addressables做流式加载按Chunk与相机的距离决定高度图和各层贴图的加载优先级二是用低分辨率高度图生成NavMesh直接把寻路数据烘焙出来。前者对超大地图至关重要后者则是让Gameplay可以真正在地形上跑起来的前提。我个人的体会是GPUTerrain把压力从CPU侧转移到了GPU侧听起来省事但它换来的是一大堆需要自己兜底的问题碰撞、阴影、光照、序列化粒度。团队里如果没有人能读懂地形Shader并且愿意在GPU调试器里一帧一帧查问题我建议先谨慎评估。可反过来一旦这套东西跑通它带来的画面上限和加载灵活性远不是内置Terrain能比的。