基于Marching Squares的Unity 2D地形与悬崖编辑器实现解析

发布时间:2026/10/4 5:25:13
基于Marching Squares的Unity 2D地形与悬崖编辑器实现解析 在U3D里复刻一个War3Editor风格的地表/悬崖编辑器是我最近一直在折腾的项目。标题挂的是“基于MarchingCubes实现的2d游戏地图编辑器”但说句实话真正落到2D平面时MarchingCubes会退化成Marching Squares思想完全是同一条路。这篇就来聊聊怎么从零把地表和悬崖编辑工具做出来包括数据模型、网格生成、笔刷手感和几个让我差点放弃的坑。如果你也想自己写一个类似War3地形编辑器的工具或者对程序化地形感兴趣应该能从里面捞到不少实操经验。不做不知道一做才发现War3那种地表和悬崖的无缝过渡根本不是堆美术素材能解决的。下面我按自己项目推进的思路把完整方案拆开讲。1. 为什么放着现成的Tilemap不用偏要搞Marching Squares1.1 自动瓦片在悬崖面前有多狼狈Unity的TilemapRuleTile确实是2D地图最快的落地方案尤其是做像素风、方块风地图时效率很高。但我尝试用它做类似War3的悬崖时很快就放弃了。原因很简单War3的地表不是“格子”思维而是“连续地形”思维。你从一块平地把鼠标拖过去地形会形成一个带坡度的边缘草地和泥地之间有自然的过渡带。RuleTile能处理9宫格、16宫格甚至48宫格的拼接但前提是美术得画出足够的过渡块而且悬崖层级一旦到两层、三层规则数量和漏图风险都是指数级上升。另外一个更致命的问题是Tilemap本质上是离散网格。你画出来的是“某块地皮是草地还是泥地”而不是“这块地皮的海拔是多少”。做悬崖时你需要的不是两块地皮之间的贴图变化而是高度差形成的立面。Tilemap在这个模型下只能靠贴图模拟视觉效果经不起细看更别说运行时动态修改了。1.2 Marching Squares把地形当作等高线而不是格子所以我把底层模型直接换成了密度场。所谓密度场就是给地图上的每个采样点存一个浮点数数值大于0表示地表存在小于0表示空地等于0就是地形边界。然后利用Marching Squares在相邻四格之间插值出等值线再把等值线围成的区域渲染出来。把地形当成等高线而不是格子是一个挺关键的思想转换。你手里拿的不再是一张像素地图而是一张灰度图。鼠标画的是灰度值的高低屏幕上呈现的却是平滑的地形轮廓。很多2D游戏里做随机岛屿、洞穴、湖泊都用类似方案。它天然支持平滑边界不会出现像素锯齿。1.3 原版War3Editor的“手感”来自连续标量场War3里用笔刷刷地形每一笔都有圆形渐变范围中心抬升多、边缘抬升少在悬崖边上刷会形成台阶状的高差。这个手感本质上就是在编辑连续标量场。如果用布尔格子的“有/无”模型根本做不出这种效果。因此在编辑器设计的一开始就要明确一个核心原则地形数据的存储必须支持浮点渐变渲染出的网格则通过Marching Squares自动生成。这样后续做地表笔刷、悬崖笔刷、平滑笔刷全部都在同一套数据上做运算不需要额外维护多套手工拼接规则。2. 数据模型设计密度场、悬崖层与分块存储确定了用密度场之后接下来要想清楚地图数据到底怎么组织。这一层如果设计得不好后面所有笔刷、网格重建、Undo/Redo都会很难受。2.1 一张二维密度数组是地基我一开始只用一个float[,] density来存整张地图所有地表、悬崖都往这一个数组上叠。结果画悬崖时发现根本分不清楚哪里是“高一层的地表”哪里是“低一层的空地”。后来改成多密度层方案低层地表一个数组高层地表另一个数组两者之间通过一个额外的高度掩码来关联。public class TerrainChunk { public const int Size 64; public float[,] layer0; // 低层地表密度场 public float[,] layer1; // 高层地表密度场用于悬崖 public float[,] cliffMask; // 0到1过渡控制悬崖壁的软化 }layer0是地形的基础高度layer1表示在这个基础上额外抬升的高地。普通地表笔刷只改layer0悬崖笔刷会把layer1抬高同时让cliffMask在笔刷边缘形成一个环形过渡带。这样悬崖壁不是垂直生硬的而是有一点点斜度看起来更接近War3的视觉效果。2.2 分块存储是性能的保障地图如果只有512×512个采样点全图遍历一次也要几十万次运算鼠标移动过程中每秒几十帧肯定扛不住。我一开始没分块画一笔全图重建一次网格Editor直接卡成PPT。后来把地图切成64×64的Chunk每个Chunk独立保存密度数据和生成的Mesh。笔刷按坐标计算影响到了哪些Chunk只把对应Chunk标记为脏。在Update时只重建脏Chunk。这个设计让编辑器的刷新效率提升了一个数量级。2.3 Undo/Redo不能直接存全图Undo是个容易被低估的坑。如果把整个Chunk的数组快照都放进Undo栈几次操作下来内存就爆了。我后来改成稀疏记录每次笔刷操作时用一个HashSetVector2Int记录所有被修改的采样点Undo栈里只存这些点的旧值和新值。回退时只恢复稀疏数据然后重建受影响的Chunk。这样即便画一个超大笔刷Undo内存也是可控的。下面是数据模型的核心字段抽象数据结构作用说明layer0基础地表密度正数表示地面负数表示空地layer1悬崖高层地表画悬崖时抬升这部分值cliffMask悬崖过渡权重控制垂直面与坡面的柔和程度DirtySet脏坐标集合笔刷影响范围记录用于局部重建Chunk64×64分块独立Mesh、独立碰撞体便于局部刷新这套模型看上去比Tilemap复杂但它换来的是完全自由的连续地形编辑能力。Trail map只能做平面地表而这套模型天然支持多层高度差。3. 把密度场变成MeshMarching Squares实现的关键细节数据模型只是基础真正的重头戏是把密度场变成能在Scene视图里看到、能生成碰撞的Mesh。这部分我踩了很多坑单独拎出来详细讲。3.1 16种构型与线性插值Marching Squares遍历每一个四格单元Cell四格分别对应左上、右上、右下、左下四个角的密度值。每个角的值大于0还是小于0决定了这个Cell的16种构型之一。把2D版本的四种角用4位二进制表示就能查表得到该画哪几条边。int GetCellConfig(float lb, float rb, float rt, float lt, float isoLevel) { int idx 0; if (lt isoLevel) idx | 1; if (rt isoLevel) idx | 2; if (rb isoLevel) idx | 4; if (lb isoLevel) idx | 8; return idx; }但只判断正负还不够直接连中点会产生明显的尖锐拐角。要生成平滑边界必须沿边做线性插值找到真正等值点位置。比如一条边两端密度分别是0.7和-0.3等值点就在沿这条边70%的位置而不是中点。这一步是马赛克与平滑地形之间的本质差别。3.2 从轮廓线到地形Mesh只输出等高线在Scene里看起来是一堆线段没法做填充。要做成War3那样的实心地表得把等高线围成的多边形三角化成Mesh。U3D环境里最直接的做法是使用开源三角化库LibTessDotNet。先把Marching Squares生成的所有轮廓段拼接成闭环多边形然后交给三角化库生成三角形。也碰到过轮廓没闭合的情况多半是边界Chunk没处理好后面会讲。// 伪代码示例对每个cell生成轮廓段 ListEdge edges new ListEdge(); for (int y 0; y cellCountY - 1; y) { for (int x 0; x cellCountX - 1; x) { int config GetCellConfig(...); int[] edgeStart, edgeEnd; MarchingSquaresTable.GetEdges(config, out edgeStart, out edgeEnd); // edgeStart/edgeEnd 是 0~7 的边索引 // 对每条边线性插值得到顶点 // 收集到 edges 列表 } } var contour BuildContours(edges); var triangles LibTessDotNet.Tessellator.Tessellate(contour);这套流程跑通后地表本身的形状就已经很接近War3了连续、平滑、边界圆润。3.3 生成碰撞体和贴图UVMesh生成之后还需要碰撞。2D游戏一般用PolygonCollider2D但直接拿密集轮廓点生成会导致碰撞精度极高、性能极差。所以要进行顶点简化我用的是Ramer-Douglas-Peucker算法把轮廓误差控制在0.05个格子单位内。简化后碰撞体既贴合地形边界又不会拖累物理性能。贴图UV不能直接用世界坐标否则物体移动时贴图会跟着滑。我采用的是SplatMap思路给每个顶点一个0~1的值表示它属于“地表混合区域”还是“悬崖壁区域”。地表采样一张可平铺草地纹理悬崖壁采样另一张岩石纹理两者在过渡带用cliffMask做混合。这个效果在视觉上非常接近War3地图编辑器里的“地表纹理悬崖纹理”叠加。3.4 Chunk间裂缝如何解决多Chunk最经典的坑就是裂缝。相邻两个Chunk各自生成Mesh如果密度场在不同Chunk之间有细微数值差边界上就容易出现缺口。这个问题我单独踩了很久最终解决方案是相邻Chunk共享同一份密度数据引用重建时把周围一圈采样点一起参与计算。重建函数会读取目标Chunk周围Extra边界我取1个采样点的padding然后把这个范围的等值线一起生成。渲染Mesh时依然只用当前Chunk的顶点但算法计算时能看到邻居的信息这样即使密度场在边界处发生变化两边生成的位置也是完全一致的。3.5 增量重建与Job System早期每帧全量重建Chunk哪怕只画了一小笔重算范围也是整个64×64块。后来改为笔刷只把影响到的采样点存入DirtySet在Update里只对DirtySet对应的Chunk做部分重建。如果配置允许可以把Marching Squares的cell遍历放到IJobParallelFor里并行计算编辑器在鼠标拖拽时基本能保持60帧以上。注意如果用Job System要避免在Job里调用Unity的Mesh API正确做法是先并行计算顶点数组回到主线程后再一次性提交Mesh。4. 地表笔刷和悬崖笔刷的手感调校数据层和网格层搞定之后真正的“编辑器”才刚开始。War3Editor最吸引人的是笔刷手感我花了很多精力去调各种衰减参数。4.1 地表笔刷圆形渐变衰减才是灵魂地表笔刷的逻辑很简单在笔刷中心把密度值往上抬。但具体怎么抬手感差别非常大。我试过用均匀抬升画出来边界特别生硬试过高斯衰减过渡很圆滑但中心容易抬过头。最后用得最舒服的是二次方衰减void ApplyTerrainBrush(TerrainChunk chunk, int cx, int cy, float radius, float strength, bool raise) { int intRadius Mathf.CeilToInt(radius); for (int y -intRadius; y intRadius; y) { for (int x -intRadius; x intRadius; x) { float dist Mathf.Sqrt(x * x y * y) / radius; if (dist 1f) continue; float falloff (1f - dist) * (1f - dist); float delta strength * falloff; if (!raise) delta -delta; int px cx x; int py cy y; chunk.layer0[px, py] delta; chunk.dirtySet.Add(new Vector2Int(px, py)); } } }这段代码看起来平淡无奇但它解决了一个细节用距离的平方做衰减让笔刷边缘不是线性斜坡而是更陡的过渡。War3里刷地形的感觉就是“中间已经很高了边缘还余着一点点尾巴”这样多刷几次也不太会形成一个生硬的凸起。4.2 悬崖笔刷多层密度场的联动悬崖笔刷要复杂一些。画悬崖并不是简单地把密度值抬起来而是要在低层地表和高层地表之间形成一个陡峭但平滑的过渡。我用的是两层密度场联动在笔刷中心区域抬升layer1在边缘区域同时降低layer0并让cliffMask在两者之间形成渐变带。void ApplyCliffBrush(TerrainChunk chunk, int cx, int cy, float radius, float strength) { int intRadius Mathf.CeilToInt(radius); for (int y -intRadius; y intRadius; y) { for (int x -intRadius; x intRadius; x) { float dist Mathf.Sqrt(x * x y * y) / radius; if (dist 1f) continue; float center 1f - dist; float falloff center * center; chunk.layer1[cx x, cy y] strength * falloff; // 悬崖边缘的遮罩靠近笔刷边缘时让cliffMask变小形成坡面 chunk.cliffMask[cx x, cy y] Mathf.Clamp01(1f - dist * 1.5f); chunk.dirtySet.Add(new Vector2Int(cx x, cy y)); } } }这种写法的效果是笔刷中心的高层地表抬升明显四周的低层地表也会被压出一点点凹痕悬崖壁在两层之间形成一个斜坡带。如果要更陡峭的悬崖就缩小cliffMask的过渡范围要更平缓的山坡就把过渡范围放大。4.3 平滑工具和随机工具地图编辑器没有平滑工具基本没法用因为手刷出来的密度场必然会有细微噪声。我的平滑工具就是经典拉普拉斯平均float SmoothAt(float[,] density, int x, int y) { float sum 0f; int count 0; for (int j -1; j 1; j) { for (int i -1; i 1; i) { if (x i 0 || y j 0 || x i TerrainChunk.Size || y j TerrainChunk.Size) continue; sum density[x i, y j]; count; } } return sum / Mathf.Max(1, count); }随机工具则是给密度场叠加Perlin噪声用来生成自然起伏。需要注意随机工具不能直接不加衰减地叠加否则会把地面搞得坑坑洼洼。我会在噪声采样后乘上一个整体强度系数并且只在cliffMask较低的区域作用避免破坏悬崖边缘。4.4 笔刷交互的实时反馈编辑器内的交互频率很高鼠标移动、拖拽、松开每次都要触发Mesh重建。我采用的策略是OnSceneGUI里只判断鼠标所在Chunk是否已经被标记脏真正重建放到EditorApplication.update里批量处理。这样可以避免一帧内重复重建十几次。另外如果希望有一个和War3一样的“笔刷范围预览”可以直接在Scene视图里绘制一个半透明圆环用Handles.DrawWireDisc实现。这个圆环的颜色根据当前笔刷类型切换地表是绿色悬崖是红色平滑是蓝色。不要小看这个细节它对编辑手感的提升非常明显。5. 开发中踩得最深的几个坑这部分是我的“血泪史”每一个坑都花了不少时间排查。写出来供参考能帮你少走弯路。5.1 2D里到底能不能叫Marching Cubes标题写的是“基于MarchingCubes实现的War3Editor”这个概念引来了不少讨论。Marching Cubes是三维算法在2D中严格对应的是Marching Squares。我最初是参考3D的Marching Cubes实现把它推导到2D平面时算法结构大部分保留只是从“体素立方体”变成“四格单元”。所以2D项目里叫Marching Squares更准确。如果你不想在概念上被抬杠可以这么表述这套编辑器底层是Marching Cubes的降维版本在2D平面上的具体形态就是Marching Squares。其实算法核心都一样——在一个离散标量场里找等值面只不过维度不同。5.2 悬崖边界破洞排查网格顺序问题这是我花时间最多的一个Bug。表现是在两层密度场交界处有些地方会莫名其妙出现一条缝甚至是一个小三角形空洞。一开始以为是Chunk边界问题后来发现不是。排查链路是这样的先把产生破洞的Chunk单独拿出来打印用Gizmos把生成的所有轮廓段画出来。发现破洞附近总是出现两条垂直的短线段方向还相反。继续跟踪后发现我在生成高层地表Mesh时把该区域内的低层地表也生成了但生成顺序是先低层后高层导致高层轮廓把低层轮廓的一部分覆盖掉中间留了一条窄缝。解决方案是把两层密度场在同一个MarchingSquares遍历里处理而不是分别生成两个Mesh后再合并。对于每个cell同时判断layer0和layer1的等值线然后把两条等值线之间的三角带作为悬崖壁。这样从几何上就杜绝了破洞因为高层和低层共用顶点不会再出现缝隙。5.3 Unity编辑器里的文字模糊问题开发Editor时遇到一个很恼人的现象EditorWindow里的文字总是发糊尤其在高DPI屏或者Windows缩放比例不是100%的时候。这个和标题里“u3d文字很模糊”搜索热词完全对上了说明很多人遇到过。我排查后发现原因基本有这么几个第一个是IMGUI自定义样式没处理DPI缩放。直接设置fontSize 12在125%缩放下会被拉伸文字就糊了。解决办法是设置字号时乘上EditorGUIUtility.pixelsPerPointGUIStyle style new GUIStyle(EditorStyles.label); style.fontSize Mathf.RoundToInt(14 * EditorGUIUtility.pixelsPerPoint);第二个原因是EditorWindow没有标记为支持高DPI。需要给类加一个[EditorWindow]属性或者重写OnEnable里设置EditorApplication.update之类的操作但最根本的是使用UI Toolkit。UI Toolkit对DPI适配做得好很多如果是从零开发新EditorWindow建议直接上UI Toolkit不要用IMGUI画复杂面板。第三个坑是我自己造成的给自定义控件写了GUI.Label却没有设置wordWrap长文本跑到控件边界外字体被裁切后显得又虚又脏。加wordWrap true并给固定宽度后明显改善。如果你遇到的是Game视图里的文字模糊不是编辑器窗口那通常是Text组件被放到了非整数坐标或者RectTransform的pivot设置导致亚像素渲染。把anchoredPosition和scale都保证是整数再开启Pixel Perfect通常能解决。5.4 大笔刷操作的卡顿优化笔刷半径从10调到50甚至100时全图重算会卡到没法用。我最初的代码是每次修改后全量重建所有Chunk后来拆成脏矩形和局部重建但还是卡。最终优化是三层一是只重建被笔刷命中的采样点所在Chunk 二是把Marching Squares的cell遍历放到Job System里并行处理 三是Mesh提交从每帧多次改为按帧合并一帧内最多只重建两个Chunk其余的排队到下一帧。这三层优化做完后100半径的大笔刷也基本能保持30帧以上的编辑器体验。对地图编辑器来说这个流畅度已经足够应付日常使用了。5.5 和Unity Tilemap的互操作很多已有项目的地图数据已经存在Tilemap里完全推倒重来不现实。所以我写了一个转换工具把Tilemap的每个格子的碰撞标志转换为一个初始密度值有碰撞的格子设为1没有碰撞的格子设为-1然后跑一次Marching Squares平滑。转换后能保留大致地形轮廓但会丢失原有Tilemap的精确方块感不过本来就用MarchingSquares做连续地形的场景这种损失可以接受。反过来也可以把编辑好的密度场按阈值栅格化输出成Tilemap的格子。这个功能适合做“先用程序化连续地形设计再导回传统Tilemap逻辑”的流水线。6. 后续扩展以及我对这类编辑器的理解编辑器做到现在已经能刷地表、刷悬崖、平滑、随机也能导出运行时数据。后续我准备继续加几个功能难度不大但很实用。6.1 水面、瀑布与动态地形破坏因为底层是密度场水面可以同样用一套Marching Squares来生成只是把密度阈值从0调到某个负值比如-0.2表示“地形低于这个阈值的区域是水面”。水面纹理做半透明质感和动态UV滚动悬崖可以生成瀑布粒子这些都是纯表现层的东西不影响现有地形数据。6.2 运行时同算法解析编辑器生成的数据最终要导出给运行时使用。我用的是非常紧凑的格式把layer0、layer1和cliffMask分别编码成Texture2D的R、G、B通道关卡加载时直接读取纹理数据然后复用编辑器里同一套Marching Squares算法生成Mesh。这样地图体积很小且运行时也能支持动态地形破坏——只要修改纹理中的密度值再重建局部Mesh即可。6.3 悬崖高度对寻路和视线的影响2D俯视角游戏里悬崖往往既是视觉元素也是玩法元素。下一步我会基于密度场输出一个简化的格子阻挡图把悬崖壁标记为不可通行同时为高于某个阈值的区域标记为高地。这样寻路和视线遮挡都能吃到地形高低差的信息而不只是视觉上好看。做这个编辑器的过程中我最大的体会是一切手感问题归根结底都是数据连续性问题。只要密度场是连续的、平滑的Mesh生成和笔刷操作自然就能收获不错的效果反过来如果你在底层数据里用了太多离散标记后面所有阶段都会被生硬感拖累。另外编辑器工具的UI适配一定不要忽略DPI那次文字模糊问题差点让我怀疑自己显示器坏了。如果你也想玩这个方向我建议先不要急着做完整笔刷面板先把Marching Squares的网格生成跑通再往上叠功能。毕竟地形轮廓够不够顺滑一眼就能看出来这是整个编辑器最核心的体验。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询