ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity体素地形+Marching Cubes:打造War3风格2D游戏地图编辑器

Unity体素地形+Marching Cubes:打造War3风格2D游戏地图编辑器 站在一个Unity开发者的角度我第一次在GDC相关分享里看到这种“用体素做War3风格地形”的思路时第一反应是“这不是杀鸡用牛刀吗”但等我真正把地表刷和悬崖刷跑起来才发现这个组合确实能解决传统高度图地形编辑器最头疼的一批问题。今天要聊的这个项目是用Unity引擎配合Marching Cubes算法去实现一个对标War3 Editor地表/悬崖编辑能力的2D游戏地图编辑器。它表面上是2D地图底层走的却是3D体素网格的路线这套方案能让你在俯视角的2D游戏里拥有真正可自由雕刻的悬崖、坡道和立体层次同时保留War3那种“按地形纹理笔刷涂抹地表”的经典手感。如果你是做俯视角2D游戏、策略类游戏、类War3地图编辑器玩法或者单纯想在Unity里做一个“能雕刻地形”的内部工具这篇文章会把它背后的技术逻辑、关键参数、踩坑点和可复现的实践路径完整拆给你。我不会绕弯子直接讲清楚体素地形怎么做、Marching Cubes在Unity里怎么落、地表纹理混合和悬崖分层怎么实现以及那些文档里永远查不到的教训。1. 整体设计与思路拆解1.1 为什么2D地图编辑器要用Marching Cubes来做先解决一个很多人第一时间会问的问题2D游戏地图老老实实用一张高度图或者Tilemap不就行了吗为什么要上体素为什么要用Marching Cubes这种通常出现在3D医学重建或体积云里的算法War3给人印象最深的其实是它的地形层次感。你从低坡走向高地会经过一个斜坡过渡悬崖边有垂直的岩壁你可以站在高地边缘往下俯瞰一片低地。这种层次不是简单的2D贴图拼接能做到的。传统Tilemap做楼层还行但要做平滑的斜坡、弯曲的悬崖、可嵌套的洞穴和凹陷地形编辑器和运行时表现都会变得非常繁琐。而高度图只能表达“地表高度”它天然做不了倒悬、洞穴、多层悬崖结构——除非你叠加多层高度图并写一堆特判规则。Marching Cubes的思路是把整个地图区域变成一个三维体素网格每个体素记录一个密度值然后通过提取“密度值为某个阈值”的等值面来生成地形网格。换句话说地形不再是一张贴图或一个高度函数而是一个真正的体积模型。你往地面上堆一铲子土就是让一堆体素的密度值增加挖一个洞就是让它们的密度值下降。悬崖、斜坡、洞穴、浮空岛全都能用同一套数据模型自然表达不需要针对地形结构写任何特判。这里的关键点是虽然我们的地图最终是以俯视角2D方式呈现的但底层用3D体素来做地形数据的组织和编辑反而让2D地图获得了充足的深度层次。固定视角的相机把3D地形投影成2D画面玩家看到的是熟悉的俯视角地图编辑器里编辑的却是一个真正有体积的世界。War3其实也是差不多的思路——底层地形是有高度概念的网格只是表现层固定了视角。1.2 如何对标War3Editor的地表和悬崖功能War3的地形编辑器核心就两件事地表纹理绘制和悬崖/高度雕刻。它上手极简地图作者打开工具左边选一个纹理草地、泥地、碎石右边选一个刷子形状和尺寸按住左键在场景里涂抹地表纹理就混合上去了。而悬崖编辑则通过抬升/降低地形高度形成层级分明的陡坎和斜坡。回到U3D这套编辑器我把它拆成三个相互独立但又联动工作的模块地表纹理层系统、体素密度编辑系统、地形网格生成与渲染系统。地表纹理层系统管的是“地表看起来是什么”悬岩/高度编辑管的是“地表几何形状长什么样”网格生成系统负责把密度数据变成可见可碰撞的三角形网格。三个模块分离有两个明显好处。第一编辑逻辑互不阻塞——你在涂抹草地纹理的时候不需要每帧重新生成一遍地形网格只有几何编辑操作才需要触发昂贵的网格重建。第二数据可以独立序列化——纹理层数据可以存成一张低分辨率的多层混合贴图几何数据存成三维数组或二进制体素文件加载和保存都很轻。实际开发里我还额外分了一个“地形材质球”模块负责根据地形高度、坡度和纹理权重混合图动态采样材质。一次网格生成只需要输出顶点位置、法线和纹理坐标具体表现交给材质Shader去处理。这种解耦让后续想把这套编辑器扩展到3D游戏场景时改动成本大幅降低。1.3 适用场景和技术选型说明这套方案适合三类场景。第一类是类War3的自定义地图工具需要给玩家提供自然的地形编辑体验同时要支持复杂的地形结构。第二类是俯视角策略游戏和模拟经营游戏的地图生成管线用体素数据做程序化生成很有优势随便加噪声就能形成自然起伏的地貌。第三类是想要2D/2.5D视觉表现但有3D地形交互需求的游戏比如俯视角射击游戏需要掩体高度和斜坡视野遮挡。技术选型方面核心渲染用Unity的高版本内置渲染管线URP也可以但编辑器工具用内置管线开发速度最快。Marching Cubes算法用C#实现为了性能把体素分块Chunk处理每个Chunk负责16x16x16或32x32x32个体素格子网格重建放在子线程或Job System里最后把Mesh数据提交回主线程。选C#而不是用C原生插件主要考虑是工具类项目对性能要求没有那么极端而C#的迭代效率高、调试方便。如果你的地图尺寸非常大或者需要实时编辑流畅度极高那就改成C插件或者Compute Shader方案但一半项目的规模C#配合多线程已经完全扛得住。2. 地表纹理绘制与悬崖编辑器的核心机制2.1 体素数据模型密度场与高度场的取舍先聊底盘。地形几何的数据模型我采用的是三维密度场而不是传统的高度图。密度场是一个三维数组每个格子存储一个float值。值大于0表示该点是“实心”的小于0表示“空气”等于0就是等值面穿过的位置。Marching Cubes要做的东西就是在一个个小立方格里根据8个顶点的密度值正负关系插值生成三角形面片拼出完整的地形表面。为什么不直接用高度场加悬崖信息高度场本质上是二维数组加一个高度值表达效率高、GPU友好做平滑丘陵非常顺手但一旦出现悬崖倒挂、悬空岩石、洞穴入口这类体积结构高度图就完全没辙。你只能通过一些hack——比如把悬崖表达成高度差阈值斜坡强行限制坡度范围——而且每个hack都绑死一组前提扩展性很差。密度场就没有这个问题。悬崖本质上是密度值在狭窄空间内从正到负的剧烈变化斜坡则是密度变化的渐变过渡洞穴更直接——把洞内一段空间的密度值全部压成负数即可。编辑器里你只需要一个笔刷函数在三维空间里以笔刷中心为原点按距离衰减来增减体素密度值就能涵盖War3编辑器中“升高”“降低”“平滑”“斜坡”这些操作。一个要注意的坑密度场数据量比高度图大得多。假设地图是512x512格子的范围高度图只要512x512个float也就是1MB左右但如果按每格4个体素高度来算密度场就是512x512x4128MB的float数组内存直接爆炸。所以体素必须分块且只对编辑过的区域分配数据块未编辑区域直接视作默认密度或者用一个轻量的高度函数按需计算。2.2 地表纹理混合层的设计地表纹理不要直接往Mesh上贴。正确做法是存一张和地图尺寸对应、分辨率较低的多通道纹理每个通道对应一种地表材质。这个思路和War3的“地层”机制几乎一致地图上某一点的草、泥、石、雪四个权重值决定最终显示效果Shader在运行时把这几个通道采样出来和对应的四张纹理做混合。纹理图分辨率建议比地形精度低一个数量级比如地形精度是1米一格纹理图每2x2像素对应1个地形格子就足够了。低分辨率的好处第一是省内存第二是减少“纹理打架”——当你在两种地表间反复涂抹时低分辨率的权重图会自动形成柔和的过渡边缘避免出现高频噪声。绘制算法就是一个标准的喷枪笔刷鼠标射线打到地形网格上得到UV或网格坐标然后以命中点为圆心半径范围内所有纹理像素加上一个衰减权重。关键细节是修改完权重之后要自动重归一化保证四种材质权重和为1.0否则Shader混合出来的效果会出现发灰或过曝的情况。我在实现时是在Substance精度下写了一个GPU像素着色器做实时绘制把鼠标移动的路径插值成多个点避免鼠标移动太快时出现断裂的虚线笔画。2.3 悬崖识别与层级控制策略War3悬崖有意思的地方在于它是有明确层级的——不是连续坡而是台阶状提升。从第0级悬崖到第1级悬崖之间是一段垂直的岩壁到第2级又是一层。我在实现的时候没有直接用笔刷做纯距离衰减而是引入了一个“目标高度”的概念。具体做法是地形编辑不是直接改密度场数值而是维护了一张和地形分辨率相同的高度图以及一层悬崖标号图。编辑器里选择“悬崖层级提升”提升的其实是这块区域的目标高度和悬崖等级。密度场只是在同步阶段根据这个高度信息重新生成或者我们在Marching Cubes的密度采样函数里把“当前坐标点所在高度”和“目标高度”做差值转换成密度值。这样做的好处是悬崖的层级逻辑完全独立于体素精度编辑器里显示的是清晰的数字层级底层体素只用负责渲染表现。斜坡操作则是对高度图做模糊插值让相邻区域的梯度差变缓。因为Marching Cubes的输入是密度场我只要在密度生成时用“高度差值平滑过渡带”的方式把高度信息填进密度场网格自然长成带斜坡的悬崖形态。这套设计里最重要的参数是悬崖高度判定阈值的权衡。阈值太小地形碎成一片台阶阈值太大想要一段小陡坡的时候梯度过冲直接把地形捅穿。我调试下来每级悬崖对应3到5个体素高度过渡带宽度占4到6个体素表现最接近War3的景观。3. 实操过程与核心环节实现3.1 密度场生成与基础地形初始化实际写代码时我习惯先做一个简单的“初始化地形”工具生成一个16x16x8的密度场密度值直接由高度函数决定。这里我用了一个非常经典的双重噪声高度函数——低频噪声决定大尺度山脉中频噪声叠加在山脉上产生峡谷和丘陵细节。public float GetDensity(int x, int y, int z, int chunkSize) { float height GetBaseHeight(x, z); float noise Mathf.PerlinNoise(x * 0.01f, z * 0.01f) * 4f; float density height noise - y; return density; }上面简化版本的逻辑很好理解当y坐标小于目标高度时密度为正实心y大于目标高度时为负空气。这样初始化出来的地形就是一块平滑起伏的山地。如果你想要War3那种平台分层的地图可以先让高度函数针对指定的平台区域返回常量、区域边缘加一段平滑过渡这会直接生成带悬崖的平台结构。现代实现更推荐用Perlin噪声加随机种子生成多张细节层的叠加然后根据编辑器里配置的“悬崖等级图”去修正。我封装好了之后还会做一次简单的内存池优化——只加载玩家编辑范围内相邻的9个Chunk其余数据序列化到磁盘避免大场景内存占用爆炸。3.2 Marching Cubes网格生成的落地实现网格生成是这套系统最核心也比较容易写崩的部分。Marching Cubes的原理是遍历每个体素立方格对它的8个顶点做密度正负判断根据256种情况查表得出内部要生成的三角形组合然后通过线性插值把三角形顶点放到密度等于0的等值面上。实现的时候有一个最容易出错的点三角形顶点索引必须按固定绕序逆时针否则法线方向会反。表面上看网格生成成功了但表面全黑或者背面剔除之后消失。Marching Cubes的标准查找表和边表索引规则都是公开资源网上一搜一大把我建议直接复用验证过的表不要自己推导因为推导过程中一个符号写错排查一整天是常有的事。Mesh构建的伪流程大概是这样foreach (var chunk in activeChunks) { var meshData new MeshData(chunkSize * chunkSize * chunkSize); for (int x 0; x chunkSize; x) for (int y 0; y chunkSize; y) for (int z 0; z chunkSize; z) { DensityCube cube GetCubeData(x, y, z); int caseIndex cube.GetCaseIndex(); int[] triangles MarchingCubesTables.Triangles[caseIndex]; for (int i 0; i triangles.Length; i 3) { // 根据边索引得到实际顶点位置 Vector3 v1 InterpolateCubeEdge(cube, triangles[i]); Vector3 v2 InterpolateCubeEdge(cube, triangles[i 1]); Vector3 v3 InterpolateCubeEdge(cube, triangles[i 2]); meshData.AddTriangle(v1, v2, v3); } } // 计算法线提交Mesh }在Unity里为了提高性能建议使用UnityEngine.Rendering.MeshData或者直接操作NativeArray并行构网。如果你图省事用List 和List 先跑通再优化成Job System也不迟。先保证逻辑正确再谈性能。3.3 地表笔刷、悬崖笔刷的交互实现笔刷交互是一个人机工程重点。War3的编辑手感之所以好是它做了两件事光标预览和即时反馈。鼠标移动时场景里会实时显示一个半透明的圆形或方形刷子范围按住绘制时地形立刻变化而不是等松开鼠标才刷新。我的实现是鼠标射线与地形网格做碰撞检测得到命中点坐标然后转换到体素空间。笔刷函数分三类——升高调整目标高度、降低反向调整目标高度、平滑局部模糊目标高度。核心参数有笔刷半径、力度、Falloff曲线、悬崖层级。这些参数全部做成可调滑杆方便编辑器里实时试手感。纹理笔刷的交互逻辑类似不过操作的是纹理权重纹理。绘制时用圆形距离衰减在GPU端累加权重边缘会自然柔化。我测试下来衰减函数用smoothstep比线性衰减的手感优秀得多——它边缘过渡更自然不会出现明显的接缝。悬崖笔刷是这里面比较特殊的一个。它不是直接刷高度而是把“目标悬崖等级”写进悬崖标号图然后再通过一个滤波算法把等级边界处理成斜坡。如果中间某块区域标号改变了相邻的密度场要重新生成这也意味着整个Chunk要重建。为了避免重建频率过高我会加一个150毫秒的延迟期间记录所有待更新Chunk延迟结束统一Rebuild一次这样连续拖动鼠标也不会卡顿。3.4 法线计算与地形渲染材质的关键参数Marching Cubes生成的是裸网格顶点法线不能直接用Unity的RecalculateNormals()因为生成的三角面太碎时RecalculateNormals在共享顶点上会得到噪点严重的法线地形看起来会像砂纸一样。更稳的做法是直接用相邻体素采样梯度来计算法线也就是用中心差分法Vector3 CalculateNormal(float x, float y, float z) { float eps 0.1f; float dx GetDensity(x eps, y, z) - GetDensity(x - eps, y, z); float dy GetDensity(x, y eps, z) - GetDensity(x, y - eps, z); float dz GetDensity(x, y, z eps) - GetDensity(x, y, z - eps); return new Vector3(dx, dy, dz).normalized; }这样得到的法线和地形表面本身的密度的梯度一致表现非常平滑。在渲染层材质Shader按像素去采样纹理权重图根据权重混合四张地表纹理再用法线贴图生成细节。加上高度相关的雾化和AO之后俯视角下地形的立体感一下就出来了。材质里有一个经常被忽略的参数地表纹理的平铺Tiling。如果整张地图共享同一个Tiling远景会看起来糊成一团近景又缺乏细节。我的做法是给材质提供两套UV一套用来采样大尺度纹理一套用来采样细节纹理细节层Tiling给到基础层的8到16倍。渲染效果和War3的强化地形细节模式很像。4. 常见问题与排查技巧实录4.1 Marching Cubes产生孔洞和裂缝第一次把多个Chunk拼在一起的时候大概率会遇到Chunk边界上的地形裂缝。原因是相邻Chunk共享边上的体素如果两边各自的密度采样不一致或者网格生成时边界顶点没有被共享就会出现“物理接缝”和渲染裂口。排查顺序很重要。先确认各Chunk的密度数据在边界处是否一致再检查网格顶点位置是否严格落在相同世界坐标。如果两边数据一致但依然裂缝那基本是三角化索引没对齐。我的解决方案是在每个Chunk生成Mesh时额外保留一圈与邻居重叠的“Ghost体素”用于密度采样生成网格时只保留本Chunk范围内的顶点。这样共享边上的计算结果完全一致裂缝问题根除。4.2 体素分辨率导致的性能卡顿与内存激增编辑中等尺寸地图时如果每个体素都参与更新帧率会跳水。这是个体素方案逃不掉的问题但有几个办法可以明显缓解。第一只在编辑影响范围内做局部Chunk重建第二给编辑操作加延迟合并第三使用多线程。我用Unity的Job System把重建单块Chunk放到了工作线程配合NativeArray存储体素数据很轻松就达到了与主线程编辑互不阻塞的效果。内存方面密度场float数组换成字节数组用-127到127代表密度范围内存直接降为1/4重建速度反而更快因为缓存命中率提升了。这是低风险高收益的优化建议一开始就做。4.3 UGUI文字模糊和编辑器界面适配问题热搜词里提到的“u3d文字很模糊”在我们这种编辑器工具里太典型了。编辑器顶部要放一堆参数面板如果用的UGUI默认设置在低分辨率或者编辑器窗口缩放后所有文字都会变成一团糊。这个问题的根源是UGUI的Text组件字体纹理没有为当前DPI生成合适的动态字体图集或者RectTransform在缩放时做了非整数倍缩放导致采样落在字体像素之间。解决办法主要有四个。第一给Canvas设置合适的Scale Factor方案最好改成“Scale With Screen Size”并配合理想分辨率。第二所有UI文字所在的RectTransform尺寸和FontSize统一用整数避免亚像素缩放。第三关闭不必要的Rich Text和Raycast Target——这两个选项会在某些GPU驱动上引发文本渲染重采样。第四如果问题顽固把动态字体改成开源的像素字体或者直接给字体生成大字号静态图集效果立竿见影。另外场景里如果需要显示地形信息文字比如坐标、悬崖层级别建议直接用World Space Canvas的TextMeshPro替代旧版Text。TMP的处理方式是基于距离场光栅化无限放大也不会糊编辑器里显示小标签非常稳定。4.4 编辑操作撤销/重做系统的数据时机地形编辑器如果少了撤销重做基本没法给别人用。但体素地形每笔操作数据量都很大不能每帧都存全量快照。我用的方案是增量命令模式编辑器每次笔刷操作完成后记录受影响的体素区域的原值和新值存入Undo栈。这个增量占用的内存只有局部一块Chunk的一小条数据比存全量省得多而且Rebuild的时候也可以恢复到旧值。需要特别注意一个容易踩的坑纹理笔刷和几何笔刷共用一套撤销栈时撤销顺序和操作顺序不一致会导致地形纹理和几何对不上。我后来把纹理操作和几何操作分成两条命令栈各自维护独立索引用的时候再按编辑器里的“操作类型”筛选彻底解决了混乱问题。5. 数据序列化、存档设计与后续扩展5.1 编辑器数据与运行时数据的压缩存储编辑器的存档结构可以简单清晰一点分成三个文件地形元信息文件维度、体素大小、种子、体素数据文件按Chunk组织的密度数组、地表纹理权重文件。体素数据用二进制格式为了省空间可以用RLE压缩或者直接对密度值做一次量化和LZ4压缩。我实测过一张256x256格子的地图地形高度大概8格密度数据压缩后大约几MB加载时间在1秒左右。运行时载入不需要一次把所有Chunk都读进内存可以先加载全部元信息再按玩家可见范围动态流式加载Chunk。这样游戏的运行内存能保持在一个非常健康的水平。5.2 编辑器扩展到程序化地图生成做完整套编辑器之后你会有一个特别舒服的额外收获因为底层是密度场程序化生成地形变得非常直接。写一个Perlin噪声山脉函数直接往密度场里填值就能出山写一套分形噪声叠加就能生成丘陵、盆地、大陆架。和随机游戏逻辑结合时只要多暴露几个可调参数山脉强度、海平面高度、悬崖层级就能让程序化生成结果和手动编辑结果完美共存。我把这套编辑器做成了一个Prefab加一套静态类的工具集内部通过事件系统向外部通知网格更新这样不管是做运行时动态地形变化还是对接任务系统做“地震后地形改变”的演出效果都只用调密度编辑接口即可。有了稳健的地形内核功能扩展完全挡不住。5.3 关于“2D但不止于2D”的使用感受说到底这是一个“2D游戏地图编辑器”的项目但它远不止处理2D。俯视角2D游戏的地图如果只有平面感很多玩法动作根本施展不开——高地优势、视野遮挡、悬崖坠落、跳跃攀爬这些都需要地形有真正的几何体积。用Marching Cubes把地形变成可编辑的体素其实是用3D的技术方案为2D玩法的深度铺路。我实际用它在内部项目里做过一张带多层悬崖和地下洞窟的俯视角地图美术出图效果紧张感很足程序这边的实现也没有被“2D限制”捆住手脚。这套方案兼容了WAR3编辑器的经典交互又给了底层足够的扩展空间真心推荐给所有需要自己做地形工具的团队。最后提醒一点Marching Cubes的表建议用公开验证过的版本不要从零推导。整个工程最痛苦的Bug往往不在算法本身而在你自信满满手写的查表数据里埋着的那一两个符号错误。先把工具跑通再一步步优化这条路走下来你会比照着文档抄代码理解深得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进