两年多前,团队要做一个开放世界原型:一张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侧提交给GPU,Mesh上传本身就有几毫秒的开销,更别提编辑器里刷高度时整块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×16=256个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. 顶点高度、法线与多层Splat:Shader侧核心战场
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 Shadow,ShadowMap 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,需要额外给地形输出稳定的法线和深度,那不是一个小工程。
下面是性能对照的参考数据,来自一台中端PC,4km×4km地图,载具从山谷向山顶移动的同一路径:
| 指标 | 内置Terrain | GPUTerrain |
|---|---|---|
| 主线程帧耗时 | 9.5ms | 2.1ms |
| 渲染线程帧耗时 | 6.3ms | 3.4ms |
| 总DrawCall | 2720 | 约380 |
| 总三角形数 | 5.4M | 2.2M |
| 长时间帧率稳定性 | 有明显掉帧 | 基本稳定60 |
从数据上看,GPUTerrain的收益不是一星半点。但要注意三角形数之所以只降了一半,是因为我们把省下来的预算用在了更细的LOD0上,近距离画面比原来更清晰。
7. 后续扩展思路与个人体会
如果你看完也想在当前项目里搞GPUTerrain,我建议先做两件事:把所有地形资产统一成单通道高度图,然后写一个最简版本的Chunk渲染Demo,别一上来就上四叉树和Splat。跑通了再逐层加复杂逻辑,这样定位问题会轻松很多。
我们之后还在这个方案上接了两样东西:一是结合Addressables做流式加载,按Chunk与相机的距离决定高度图和各层贴图的加载优先级;二是用低分辨率高度图生成NavMesh,直接把寻路数据烘焙出来。前者对超大地图至关重要,后者则是让Gameplay可以真正在地形上跑起来的前提。
我个人的体会是,GPUTerrain把压力从CPU侧转移到了GPU侧,听起来省事,但它换来的是一大堆需要自己兜底的问题:碰撞、阴影、光照、序列化粒度。团队里如果没有人能读懂地形Shader并且愿意在GPU调试器里一帧一帧查问题,我建议先谨慎评估。可反过来,一旦这套东西跑通,它带来的画面上限和加载灵活性,远不是内置Terrain能比的。

7316

被折叠的 条评论
为什么被折叠?



