从第一次接触三维地形构建到现在,我最大的感受是:这项技术最迷人的地方恰恰也是最容易让人翻车的地方。很多朋友看了几篇教程,兴冲冲地打开引擎,拖一个地形插件,调一堆噪声参数,截了张很唬人的图发朋友圈,结果一导入项目就露馅——要么山体稀稀拉拉不像貌,要么贴图糊成一团,要么走两步帧率掉一半。原因其实很简单:地形不是一张贴在平面上的纹理,它是由数据、网格、材质和渲染策略组合出来的完整系统,任何一环掉链子,最终画面都会把钱花在脸上。这期我结合自己做写实场景和开放世界原型的经验,把三维地形构建从数据准备、程序化生成、网格处理、贴图混合到性能优化这套流程完整拆开讲一遍,内容是实打实干过活之后沉淀下来的东西,适合刚入门的同学也适合已经踩过一些坑的同行作为查漏补缺的参考。
1. 先把地形当数据,而不是当风景:数据源与初始参数的决定性影响
1.1 三种主要数据来源和它们的“脾气”
很多人上来就问“用什么算法生成地形”,实际上在动手之前,有一件事更值得先花时间想清楚:这片地形的数据到底从哪来。我总结下来无非三条路,各有各的脾气,选错了后面至少多返工两周。
第一条路是拿真实世界的高程数据。像SRTM、ASTER GDEM、ALOS这样的卫星遥感数据,或者更高精度的LiDAR点云,都是可以直接拾取的素材。它们的优点不言而喻:真实、可信、自带地理特征,适合做数字孪生、飞行模拟、历史遗址复现这类对“像不像”有要求的项目。缺点也明显——投影坐标、坐标系转换、大范围数据的裁剪拼接,这些预处理工作一点不轻松。你拿到一份GeoTIFF,可能它用的是经纬度坐标,你还要把经纬度换算成引擎里的世界坐标,再处理缺失区域的插值,光这一步如果没有GIS基础就容易卡壳。
第二条路是纯程序化生成。噪声函数、侵蚀模拟、分形叠加,所有地形都由算法算出来。这条路的优势在于完全可控,想要峡谷就有峡谷,想要环形山就有环形山,而且还自带无限扩展性,适合做游戏地图、影视预演、概念场景。问题在于,程序化噪声生成的东西很容易“一眼假”,山体千篇一律、沟壑分布均匀得像流水线产品,缺了真地形里那种“随机但有因果”的质感,所以后期还得靠侵蚀模拟和手工修饰去补救。
第三条路是手工雕刻。像ZBrush、Unity的Terrain Tools、World Machine里都有笔刷式的雕刻工具,你可以像捏泥巴一样把高地压下去、把河谷拉出来。手雕的好处是精细、可控,适合做关键地标、主城区域或叙事场景的核心地块;代价也非常直观——大场景纯手工雕能把你雕到怀疑人生,随便一张2048分辨率的区域图,刷子刷完一遍几小时就没了。
我把三种来源常用数据的取舍整理了一下,方便对照:
| 数据来源 | 优势 | 代价 | 典型场景 |
|---|---|---|---|
| 真实高程数据 | 可信度高、天然具备地理逻辑 | 坐标系处理繁琐、分辨率受来源限制 | 数字孪生、航拍类项目 |
| 程序化生成 | 速度快、覆盖范围大、参数可控 | 特征容易重复、缺乏自然随机性 | 游戏大地图、概念场景 |
| 手工雕刻 | 可控性最强、艺术表达直接 | 耗时巨大、难以铺满大范围 | 关卡核心区域、近距离交互地面 |
1.2 开工前先确定网格尺度,别等贴图做完才发现采不了样
不管选哪条路,有一件事必须在管线启动前拍板,那就是地形范围、高度图分辨率和顶点间距。这三者是互相锁死的:假设你要做一块4096米乘4096米的地图,高度图用1024乘1024,那每个顶点间距就是4米,这意味着小于4米的地形起伏根本表现不出来——不是算法不行,是采样率不支持。很多新手把高度图分辨率拍脑袋设成512,贴图却狂堆8K,结果地形起伏粗糙得跟砂纸一样,再高清的贴图也救不回来。
我的经验是:先定“最小地形特征尺寸”,再反推分辨率。比如游戏里角色身高1.7米,他要能在山坡上自然行走,这个坡度变化至少在2到3米级别才有感觉,那顶点间距最好控制在2米以内。如果地图总大小两公里,那高度图至少得1024出头的分辨率才够用。还有一个坑是高度图位深。项目刚开始我图省事用了8位PNG,导出后山体梯度断层非常严重,斜坡上能看到一圈圈像等高线一样的色阶。后来全部换成16位灰度图或EXR,问题直接消失。只要项目允许,高度图一律用16位起步,8位只配做预览缩略图。
2. 从噪声图到起伏地表:程序化地形里那套“塑形”手法
2.1 噪声不是越复杂越好,先理解Perlin、Simplex和Voronoi的分工
程序化地形绕不开噪声,但很多教程把噪声讲得太玄了。我换个方式说:不同的噪声函数本质上是不同风格的“随机数画笔”。Perlin噪声画出来的是柔和连续的山丘,起伏频率稳定,适合做大面积丘陵和缓坡;Simplex噪声和Perlin类似但均匀性更好,网格扭曲更少,适合做需要更自然分布的地表;而Voronoi噪声天生自带多边形分割感,拿来生成悬崖、断壁、裂谷特别合适——只要把Voronoi图里的距离值取反或者做阈值处理,就能得到一个个形状清晰的石柱和壁面。
真正让地形像回事的,很少只靠单层噪声,通常用的是分形布朗运动,简单理解就是把多层不同频率、不同振幅的噪声叠在一起,低频率决定大山脉骨架,高频率负责表面碎石和崎岖感。公式大概是:
float fbm(vec2 p) { float value = 0.0; float amplitude = 0.5; float frequency = 1.0; for (int i = 0; i < 6; i++) { value += amplitude * noise(p * frequency); frequency *= 2.0; amplitude *= 0.5; } return value; }叠个五六层就够了,再往上叠收益会明显衰减,反而增加性能开销。还有一个特别实用的小技巧叫域扭曲——在对位置做噪声采样前,先把坐标本身塞进另一个噪声函数里扭曲一遍。效果就是山脊被“揉”出了自然的曲线,而不是直愣愣的等高线,这在做山脉地形时几乎是标配。
2.2 用海拔重映射把噪声“捏”成地貌特征
有了基础噪声值还不能直接当高度,因为那样出来的地形只有起起伏伏的波浪,没有平原、海岸、高原这种分明的层次。这里需要做一件事,叫海拔重映射。说白了就是拿一条曲线或一个函数,把噪声值重新分配到各个海拔区间。
我想做一个海岛地形时,大概的处理逻辑是这样的:底部0附近是浅海大陆架,所以0到0.15这一段要压平拉缓,让海底平缓延伸;0.15到0.35是沙滩和低地,坡度可以稍微有点变化但不要陡;0.35到0.7是丘陵腹地,这一段保留噪声的大部分起伏;超过0.7以后,逐渐向山峰收拢,再加一点幂函数让山体更尖锐。用代码表达式写,就是用smoothstep、clamp、pow组合出一段分段函数,或者直接在高程曲线上拉控制点。这一步几乎决定了地形的“性格”,比起调噪声参数,改重映射曲线的反馈更直观。
做完基础高度之后,我强烈建议再做一步简单的水力侵蚀模拟。不需要很复杂的粒子系统,只要用地形领域常用的那种“雨滴从高处滚落,沿坡度带走泥沙,在低洼处沉积”的简化算法,跑上几十次迭代,就能看到山坡上出现细碎的冲沟、山谷底部出现扇状堆积,原本生硬的山体瞬间多了岁月感。我在Houdini里用Heightfield自带的水力侵蚀节点,或者用开源的侵蚀算法库,效果差别都不大,关键是这一步真的能让“程序化”三个字变得不那么程序化。
3. 网格不能只拉高度,还要接得住细节:地形网格生成与分块思路
3.1 从高度图到网格:拓扑选择决定后续所有编辑的余地
高度图本质上是一个矩形数组,每个格子存一个高度值,要变成可渲染的几何体,就要把数组转换成网格。最简单的办法是按顺序生成顶点网格,每行每列各留一个顶点,间隔就是上面算好的步长,然后按相邻四个顶点组成两个三角形。这种矩形网格看起来简单,但它的问题在三角形形状上:如果山体某处起伏特别夸张,瘦长的三角形会出现非常明显的棱线和锯齿,光照看着特别脏。
所以我后来在做要求高的场景时,会更偏向用细分算法给网格提前做一轮优化,或者在地形编辑器里设更高的网格密度密度,再通过减面算法把平坦区域的顶点降下来。你可以理解成:把顶点预算优先分配给地形特征最丰富的地方,而不是平均洒在整个地图上。这个过程类似摄影里的“对焦区域”,焦点在主体上,背景虚化掉,画面才好看。同理,地形网格要重点照顾山谷、崖壁、河流切割面这些细节密集区,大平地上稀疏一点完全不影响观感,还能省下大量渲染资源。
3.2 大地图必须分块,分块就要处理接缝,接缝是地形项目里最经典的坑
当你的地图大到超出单个网格能承载的精度时,不可避免地要把它切成若干个区块。大多数引擎和工具也是这么干的,比如Unity的Terrain分块、Unreal的Landscape分成多个Component。区块化带来的直接后果就是接缝问题:两个相邻区块如果网格密度不同、顶点法线不同、甚至纹理坐标在边界上各走各的,中间就会出现一条肉眼可见的线。
处理接缝有几种常见思路。一种是做裙边(skirt),也就是在区块每个边缘额外生成一圈向下延伸的窄三角带,把缝隙盖住,虽然技术上有取巧嫌疑但成本很低,很多实时渲染方案都在用。一种是让相邻区块在共享边界上用完全相同的网格细分规则,这要求LOD切换时边界顶点位置必须一致,复杂度高一些但效果最干净。还有一种是在纹理采样上解决,把高度图做几个像素的外扩,连纹理都不暴露缺口。
我的建议是:如果你用的是成熟引擎,优先用引擎自带的地形系统处理接缝,不要自己手写网格拼块。自己写的网格拼接代码在小的原型里看着没问题,一旦地图规模上来,边界锯齿、顶点法线跳变、阴影接缝这些妖魔鬼怪会一起找你算账。把精力留给地形生成算法本身,渲染层面的接缝交给经过大量用户验证的引擎机制去兜底。
4. 地表贴皮的质感密码:三平面映射、坡度遮罩与纹理混合的实操配方
4.1 陡坡贴图拉伸是常态,三平面映射是根治法
地形高度起伏之后,贴图遇到的头号问题是拉伸畸变。一张草地贴图平铺在平整地面时挺正常,一旦贴到垂直的崖壁上,纹理立刻被拉成一道道长条。原因很简单:普通UV投影是从上往下盖在地表上的,斜坡越接近90度,单位面积上能分到的纹素就越多,纹理自然被拉长。
站得住脚的做法是三平面映射。思路非常直接:分别按X轴、Y轴、Z轴三个方向对模型做一次纹理投影,然后根据表面法线的方向,计算出三种投影的混合权重。比如地面朝向接近垂直的Y轴,那Y方向投影权重最大,水平投影基本不影响;如果是崖壁,法线指向横向,那么对应轴向的投影接管权重。这样无论坡多陡,纹理都能以正视角覆盖表面,不再拉伸。在主流引擎里,Unity的Terrain有自动三平面选项,Unreal的Material函数库里也有现成节点,用起来不复杂,效果却立竿见影。
4.2 材质混合不是画渐变,而是用坡度、曲率和噪声做“做旧”
做好了基础映射,接下来是地表材质的混合。一个真实地形场景很少只有一张草地贴图,通常要混上泥土、岩石、砂砾、积雪甚至湿地。很多新手做混合时,直接画一张splatmap(权重图),用画笔在哪儿抹一下岩石就在哪儿出现。这样做出来的结果,材质边界干净得像用圆规画的,假得不行。
真实山体上,材质分布是有自然逻辑的:平地长草,陡坡露岩,山脊因风吹日晒更容易裸出碎石,凹地容易积水所以颜色更深。把这些逻辑写成算法,混合效果比自己手刷好得多:
- 坡度遮罩:法线方向越接近水平,草地权重越高;越陡峭,岩石权重越高。
- 曲率遮罩:地形凸起的地方(山脊、岩棱)更容易露土,凹进去的沟谷更容易长植被。
- 噪声扰动:在遮罩计算之后,叠一层低频噪声,把硬边界打散,让两种材质之间有犬牙交错的过渡,这才是“做旧”的关键。
我做过一个小实验,同一段山体,一半用纯手绘splatmap,一半用坡度加曲率遮罩加噪声自动生成,远看差别不大,镜头一拉近、阳光一斜,自动生成的那一半明显更像真山。因为它保留了材质之间随机但符合物理直觉的过渡,而手绘很容易陷入“这涂一块那涂一块”的布面感。
4.3 细节贴图与高度混合:解决远景糊、近景平的双重困境
地形材质还有个两难问题:远景需要大面积重复。都好说,近景又需要足够多的石头纹路、草根泥土这些细节。单一贴图分辨率再高也没法同时满足。我的做法是拆两层:一层是大尺度颜色层,负责草地、岩石、积雪的基本色块分布,分辨率可以低一些;另一层是重复细节贴图,铺在地表上面,用三平面映射叠加一个小尺度噪声纹理,让近看时表面有颗粒感。两层之间再靠高度混合来过渡——细节贴图越靠近高海拔地区越明显,越往湿地越低下去,这样就避免了近景一片光滑的“塑料感”。
前面说了三种,高度贴图合数我们结合来看,无人机。
5. 大型地形的性能账本:LOD、剔除和相机原点这些绕不开的事
5.1 LOD不是用来省显存的,是用来避免“地形一望无际帧率直接见底”的
地形面积一大,就算网格再优化,顶点数也会爆炸式增长。一块2公里乘2公里的地形,如果顶点间距2米,那么将近一百万个顶点,而且这些顶点全都在同一帧里需要处理,任何显卡都扛不住。所以大型地形系统几乎无一例外地要上LOD(多层次细节)。核心思路很简单:离相机近的地方用高密度网格,离相机远的地方用低密度网格,你看不清的地方就不需要那么多三角形去堆。
常见的方案有好几种。一种是四叉树分块LOD,把地形切成方形区块,每个区块根据离相机的距离选择不同细分级别,越远越粗糙;一种是Clipmap,以相机为中心生成一圈圈环形的LOD层,像等高线一样套在周边,这种做法实现起来性能最好但边界处理更麻烦;还有一种是基于GPU tessellation的动态细分,让显卡实时决定三角形密度,最省顶点但编写难度最高。
我不建议新手从底层自己撸LOD系统。杀鸡用牛刀,而且容易引入各种边缘裂缝、LOD弹出、阴影接缝。直接用引擎地形系统自带的LOD,或者用Unity的Terrain LOD、Unreal Landscape预设,体验会舒服非常多。等到你对这套机制实在了解透了,再考虑自己定制,那时候你已经知道哪些参数会影响弹出感、哪些会在远处闪动了。
5.2 大世界坐标漂移和浮点精度流失,比LOD更隐蔽
当你的地图大到十公里级别,另一个隐藏杀手就出现了:浮点精度流失。游戏引擎常用的32位浮点数,精度在小数值区间表现很好,但一旦坐标数值变大,比如角色走到离世界原点几千上万米的地方,浮点数能表达的最小步长会越来越大,结果就是你眼睁睁看着地形和角色开始抖动、穿模,模型边缘出现肉眼可见的“咯咯”感。这跟地形构建没有直接关系,但它会在你地形做到一定规模后突然冒出来,打你个措手不及。
行业通行解决方案是浮动原点(floating origin)或者世界原点重定位:让相机保持在世界坐标原点附近,当地图内容相对相机偏移时,把整个场景整体做平移,而不是移动相机到远处。这样做相机坐标数值永远保持在游戏引擎精度较高的范围。很多开放世界项目都靠这套机制避免抖动。如果你做的是卫星级大地图,还要考虑用双精度存储地形块的位置数据,渲染阶段再转成相机空间的相对坐标。
5.3 剔除策略:不是所有地方都值得画,判断“看不见”才是真优化
静态地形本身其实不太需要复杂的剔除优化,因为它没有骨骼动画、不涉及蒙皮。但地形是场景里最大的几何体之一,而且会遮挡后面一大片内容。如果你不把地形按区块组织成独立的剔除单元,那么站在山脚下时,相机测不到山背后很大一片天空和建筑,没必要的绘制开销全都白白支付了。所以做地形性能优化,第一件事就是确保地形区块参与视锥剔除和遮挡剔除。
引擎会自动处理一部分视锥剔除,但遮挡剔除的表现就看地形切块的方式了。我习惯把地形区块切得跟场景的Multiplayer空间一致,这样遮挡查询的效率最高。另外,地形上的植被、石头这类实例物体也尽量用GPU实例化批量绘制,别一棵树一个Draw Call。你每天呼叫独立进程,地形本身跑得再快,也架不住成千上万个物体一起拖后腿。
6. 程序化快,但别让它“地球统一”:程序化结果与手工雕刻的协作流程
6.1 从程序基底到人工收尾的三段式工作流
程序化生成最大的优势是快,最大的劣势是“千篇一律”。同一个噪声种子生成出来两张地图,只要参数差不多,看半天愣是分辨不出区别,山的形状、谷的位置、河流的走向全都像是同一个模子倒出来的。真实世界的地貌充满偶然性,而这种偶然性恰好是程序化算法最难提供的。
我打磨过一套组合工作流,效果比较稳,叫“程序打底、手工修型、程序收尾”三段式:
- 程序打底:先用噪声、侵蚀、海拔重映射生成整个地形的基础骨架,这一步要保证大格局正确:山脉在哪、盆地在哪、海岸线基本轮廓如何。
- 手工修型:在关键区域用手工笔刷调整。比如把主城所在的高地铲平一点、把河流入海口拓宽、把某座标志性山峰的轮廓拉得更尖锐。这个阶段工具选择很重要,World Machine里可以配合Mask单独修改局部区域,Unity和Unreal的笔刷做局部推拉也很直观。
- 程序收尾:修型之后,重新跑一遍细节噪声、材质混合、植被分布的自动化流程。这样手工的调整能无缝嵌回整个系统,材质过渡、植被密度这些又会自动匹配新的形状。
这套流程的核心逻辑是:程序化负责宏观效率和全图一致性,手工负责叙事需要和视觉焦点,程序化再负责把手工的痕迹融合进全图。三个环节缺一个,结果都不完美。
6.2 分层数据管理:高度图、权重图、生物群系遮罩分开存储
为了保证上面这套工作流能在项目的生命周期里持续修改而不崩溃,地形数据一定要分图层管理。我见过太多项目把所有地形信息塞进一张高度图加一张灰度图,美其名曰“管线简单”,结果美术想调整森林分布时,只能在石头权重图上面抹来抹去,稍不注意就把山体轮廓也改了,一发牵全身,项目不到一半就乱套了。
我建议至少分成这几层:
- 高度图层:只存地形几何数据。
- 材质遮罩层:草地、岩石、沙地、积雪的权重分布。
- 生物群系层:记录区域类型,比如森林区、荒漠区、湿地、城市区域。
- 地表属性层:硬度、湿度、坡度这些会影响后续植被摆放和物理交互的数据。
这样每一层都能独立修改、独立重算。改完高度图,材质层会自动重采样;改完生物群系层,植被分布程序可以跟着变化。配合版本管理工具时,每个图层也最好单独提交,避免一个二进制大文件的所有修改都堆在一个提交里让同事没法Review。
6.3 程序化地形的“灵感随机化”技巧:用种子管理而不是手动硬调
最后再分享一个我常用的效率技巧:地形种子(seed)管理。在做早期探索时,我会写一套参数化脚本,把所有关键的地形生成参数——噪声频率、振幅、侵蚀迭代次数、海拔曲线控制点、湿度权重——全部暴露成可调参数,然后用种子值驱动一次完整生成。每次只需要换一个种子数字,就能得到一种新的地形方案,把几种方案并排放一起截图对比,比手动拖动滑杆来回调快得多。
有段时间我探索开放世界地图概念阶段,用这套方法一个下午生成了几十种地形概念方案,最后挑了三个特别有潜力的交给美术做手工深化。相比从零手雕,这种“算法先出几十个草图、人再从中选优”的做法,更像是地形版的“灵感板”,非常出活。
7. 我在三维地形项目里踩过的三个坑(附排查思路)
7.1 坑一:高度图导出格式不对,山体梯田化严重
第一次做写实地形时,我把高度图导出成8位PNG,加载进引擎后整个山体像被刀切过一层层台阶,尤其在缓坡地带特别明显。我一开始以为是噪声分辨率不够,把噪声层的细节加到远超过需求的地步,还是不见好。后来静下来排查,把生成好的高度图在图像工具里单独拉出来看,才发现数据本身的连续性是完好无损的,问题出在导出时被压缩成了8位离散值,256级灰阶根本不够描述细腻的高度变化,相邻两个高度值之间的落差直接被量化成了可见的台阶。
排查思路提炼一下:先分辨问题出在“生成”还是“存取”。把中间数据单独导出、单独放大检查,就能区分是算法的问题还是格式的锅。解决方式是换成16位PNG或EXR,高度图数据量虽然翻倍了,但换来的是完全没有梯度断层的地形表面,值。
7.2 坑二:贴图接缝在远处看不出来,近处灯光一打就现形
另一个让我头疼很久的问题,是地形区块贴图接缝。白天在头顶阳光环境下看着还正常,一旦夕阳西下,低角度的光斜着打过来,接缝处就出现一条细细的暗线,怎么调LOD都压不掉。排查过程比较曲折:我先怀疑是法线问题,重新计算了一遍法线贴图;又怀疑是阴影贴图分辨率,提到4096还是没解决;最后偶然把两个D外纹理的边框像素展开对比,发现两个相邻区块在同一样纹理坐标上采到了不同的纹素值——因为我在导出贴图时每个区块都单独处理了边缘像素,没有做纹理外扩,导致边缘采样出现半个像素的偏差。
解决办法其实很简单:导出地形贴图时,在区块边界做4到8像素的外扩填充,也就是让每个块边缘多存一段邻居区块的纹理信息,这样采样时边缘不会缺数据。踩过这个坑之后,我后续所有分块纹理导出都默认带上边缘外扩,再也没被这种细如发丝的暗线折磨过。
7.3 坑三:法线方向算反了,整个地形表面看起来像“倒模”
还有一次特别尴尬,我生成的地形整体光照感觉不对,山体向阳面和背阳面的明暗关系完全反了,看起来像把模具的内外翻了个面。我检查灯光方向,觉得没问题;检查材质参数,也觉得没问题;最后一筹莫展时把网格法线可视化出来,才发现大量顶点的法线指向反方向——问题出在我用的高度图是按“越亮越高”的约定生成的,但网格生成代码里读取灰度值后做的高度换算公式少了个符号,导致地形整体被压成了镜像的负空间。
这件事给我的教训是:法线方向这种事,不要靠猜,更不要靠肉眼调灯光来找补,直接把法线可视化出来看一眼就全明白了。可视化线网、法线、UV这些Debug手段,看起来土,但永远是排查三维渲染问题最直接的路。
如果只让我留一条建议给正在学地形构建的朋友,那就是:把地形当作一套完整的数据系统去对待,高度、材质、光照、遮挡每一环都要单独验证,别等串起来之后才回头找问题。地形是所有场景资产里最不直观的一个,但也是当你把光影、贴图、起伏、细节全部对齐之后最有成就感的一个。这期的内容都是我一次次生成、导出、渲染、返工之后换来的实际操作经验,希望能帮你在三维地形构建这条路上少掉几次头发。