Unity程序化生成低多边形地形:基于Voronoi图与水力侵蚀的实战指南 1. 项目概述与核心价值最近在做一个独立游戏项目需要生成一个风格化、低多边形Low Poly风格的地形世界。市面上虽然有不少地形生成工具但要么过于写实要么随机性太强很难控制生成地图的宏观结构比如大陆、海洋、岛屿的分布和微观细节比如河流走向、山脉脉络。于是我决定自己动手在Unity里实现一套基于多边形Polygonal的地图生成系统。这听起来有点硬核但实际做下来你会发现它的核心思想非常优雅而且可控性极高特别适合需要独特美术风格和玩法驱动地形的项目。简单来说这个项目就是利用程序化生成技术在Unity中创建一个由无数不规则多边形拼接而成的网格并基于此网格生成地形高度、生物群落、水文系统等。它不像传统的基于高度图Heightmap或体素Voxel的地形后者更偏向于连续的、平滑的表面。多边形地图的每个面都是独立的这带来了几个直接的好处一是能轻松实现鲜明的低多边形美术风格二是天然适合划分区域比如每个多边形可以是一个行政单位、一个生态区或一个玩法格子三是生成算法如Voronoi图或Delaunay三角剖分本身就有成熟的数学基础结果稳定可靠。如果你正在开发策略游戏、模拟经营、roguelike或者任何需要可玩性高、风格独特地图的游戏这套方案会给你带来巨大的灵活性。接下来我会从设计思路、核心算法、Unity实现细节再到实际应用中的坑和技巧完整地拆解一遍。2. 整体设计与核心思路拆解2.1 为什么选择多边形地图在决定技术方案前我对比了几种主流的地形生成方式。高度图是最常见的用一张灰度图表示海拔通过噪声函数如Perlin, Simplex生成优点是速度快、资源消耗低适合写实风格的大规模地形。但它的缺点是结构连续难以生成具有清晰、硬朗边界的地貌比如悬崖、平顶山也不容易将地图逻辑分区。体素如Minecraft提供了完全的构造自由但性能开销大且风格更偏向方块化。而多边形地图特别是基于Voronoi图的生成方式完美地折中了这两者。它通过一组随机点生成空间划分每个点对应一个多边形区域。这种划分天然具有“区域感”我们可以为每个多边形独立赋予属性高度、湿度、温度、生物群落再通过平滑算法处理多边形边界就能得到既有清晰区域划分又有自然过渡的地形。从游戏玩法角度看每个多边形可以视为一个“格子”非常适合回合制策略、大战略、文明类游戏的地图逻辑。从美术角度看低多边形风格渲染压力小且自带一种简洁、抽象的美感很容易形成项目独特的视觉标识。2.2 核心生成流程总览整个生成管线可以概括为四个主要阶段我把它画成了一个简单的流程图在脑子里现在用文字描述出来布点与空间划分首先在二维平面我们的地图范围内随机生成一系列点称为“站点”或“种子点”。然后使用算法通常是Voronoi图或Delaunay三角剖分将这些点所处的空间划分成一个个互不重叠的多边形区域。这一步决定了地图的基本骨架。地图属性生成为第一步生成的每个多边形站点计算并赋予一系列基础属性。最核心的是高度Elevation它将决定这里是海洋、平原还是山脉。通常我们会先生成一个粗略的高度场再通过噪声和侵蚀模拟进行细化。同时可能还会计算湿度Moisture和温度Temperature为后续的生物群落划分提供依据。边缘与网格处理Voronoi图生成的多边形直接用作渲染网格会显得过于尖锐和人工化。我们需要对多边形的边缘进行平滑处理比如使用Lloyd松弛算法迭代调整站点位置让多边形形状更均匀同时为了得到平滑的地形曲面我们需要基于站点的高度信息为多边形网格的每个顶点计算一个插值后的高度。Unity集成与渲染将处理好的网格数据顶点、三角形、UV、颜色等在Unity中生成Mesh并附上材质。为了提升视觉效果我们还需要根据多边形的属性高度、群落来设置不同的颜色或纹理并可能添加细节装饰如树木、岩石等。这个流程中步骤1和2是算法的核心决定了地图的“灵魂”步骤3和4是工程实现的关键决定了地图的“皮相”。下面我们就深入每个环节看看具体怎么做。3. 核心算法详解与实现3.1 基石Voronoi图与Delaunay三角剖分一切始于一组随机点。假设我们在一个矩形区域内撒了N个点。Voronoi图的定义是平面上的每个点都归属于离它最近的那个“种子点”所在的区域。所有到同一个种子点距离最近的点构成的区域就是一个Voronoi多边形也称为泰森多边形。直接计算Voronoi图比较麻烦。一个更巧妙且高效的方法是先计算它的对偶图——Delaunay三角剖分。Delaunay三角剖分是指用这些种子点构成一个三角形网格并且满足一个空圆特性任何一个三角形的外接圆内部不包含其他种子点。这个特性保证了生成的三角形尽可能“胖”避免出现极端狭长的三角形这对于后续计算非常有利。注意在Unity中我们不建议从头实现这些几何算法。有成熟且高效的库可用。我强烈推荐使用MIConvexHull这个库GitHub上可找到它稳定、快速并且提供了Delaunay三角剖分和Voronoi图的计算。将其DLL导入Unity的Plugins文件夹即可。有了Delaunay三角剖分生成Voronoi图就简单了每个Voronoi多边形的顶点就是其对应种子点周围所有Delaunay三角形的外接圆圆心。而多边形的边就是连接这些圆心的线段。实操心得生成种子点时不要用纯粹的随机分布Random.insideUnitCircle这会导致点分布不均有些区域密集有些区域稀疏。应该使用泊松圆盘采样Poisson Disk Sampling。它能保证任意两点之间的距离不小于一个指定半径从而让点均匀分布。网上有泊圆盘采样的现成代码集成进来后生成的多边形大小会更均匀地图看起来更自然。3.2 赋予灵魂高度图生成与模拟这是最有意思也最影响最终效果的一步。我们的目标是为每个种子点赋予一个高度值。一个简单但效果不佳的方法是直接对种子点坐标使用Perlin噪声采样。因为点是不规则分布的直接采样会导致高度信息支离破碎缺乏宏观的地形特征如连绵的山脉、广阔的大洋盆地。我采用的是一种更物理模拟化的方法灵感来自经典文章《Polygonal Map Generation for Games》。其核心步骤如下确定海洋与陆地首先我们需要决定地图的轮廓。一种方法是定义一个“陆地形状”的噪声或者更简单地随机选择一部分靠近地图中心的点作为“陆地种子点”。然后我们计算每个点到最近陆地种子点的距离或到地图边界的距离并设定一个阈值。距离小于阈值的点其初始高度设为正值陆地否则设为负值海洋。通过调整这个阈值可以控制陆地与海洋的比例。// 伪代码示例简单基于距离的初始高度设定 float maxDistance CalculateMaxDistanceToLand(seedPoints); foreach (var site in allSites) { float distance FindDistanceToNearestLandSeed(site); // 归一化距离并映射到初始高度 site.initialHeight 1.0f - (distance / maxDistance); // 越近陆地高度越高 if (site.initialHeight 0.3f) site.initialHeight -0.5f; // 设为海洋 }噪声叠加与山脉生成在初始的陆海划分基础上我们需要添加地形细节。可以对每个站点再次采样多层Perlin噪声不同频率和振幅叠加到其高度值上。为了生成更真实的山脉脉络可以模拟板块挤压随机生成几条“山脉线”让靠近这些线的陆地站点获得额外的高度加成。// 伪代码噪声叠加与山脉影响 float noiseValue PerlinNoise(site.position * frequency) * amplitude; site.height noiseValue; foreach (var mountainLine in mountainLines) { float influence 1.0f / (DistanceToLine(site, mountainLine) 1.0f); site.height mountainHeight * influence; }模拟降雨与侵蚀这是让地形从“塑料感”变得“自然”的关键。模拟水从高处流向低处的过程。我们可以进行多次迭代对于每个陆地站点假设一定量的“水”从其高度中“流出”分配到它相邻的通过Delaunay边连接且高度更低的邻居站点。这个过程会缓慢地削平高山、填充低谷形成河流流域和冲积平原的雏形。// 伪代码简化的水力侵蚀迭代 for (int i 0; i erosionIterations; i) { foreach (var site in landSites) { var lowerNeighbors site.neighbors.Where(n n.height site.height); if (lowerNeighbors.Any()) { float totalFlow (site.height - lowerNeighbors.Average(n n.height)) * erosionRate; site.height - totalFlow; // 将totalFlow按某种比例分配给更低的邻居 DistributeFlow(site, lowerNeighbors, totalFlow); } } }这个过程计算量较大迭代次数如50-100次和侵蚀速率需要仔细调参。迭代次数太少效果不明显太多则可能把地形完全夷平。平滑与归一化经过上述步骤高度值可能范围很广。我们需要将所有站点的最终高度值归一化到一个固定的范围内比如[-1, 1]或[0, 1]其中0代表海平面。同时为了在多边形之间获得平滑的高度过渡而不是阶梯状我们不能直接用站点高度作为其整个多边形区域的高度。这需要留到网格生成阶段通过顶点插值来解决。3.3 从点到面生物群落划分有了高度和模拟的“湿度”可以从侵蚀模拟中衍生水流经多的区域湿度高我们就可以用经典的Whittaker生物群落图的变种来为每个多边形上色。这是一个基于高度和湿度的二维查找表高度湿度低干旱中高湿润低海洋/海滩海洋海洋海洋中低平原/丘陵沙漠草原森林中高高山丘陵热带草原温带森林针叶林/雨林高山脉高山荒漠高山草甸雪山我们可以为每个生物群落定义一种颜色。在Unity中我们可以根据站点所属的群落为其分配一个颜色值后续在生成网格时将这个颜色插值到多边形的顶点上。注意事项生物群落的边界不应该是一条生硬的线。为了让过渡更自然我通常在计算站点群落时会考虑其相邻站点的属性进行轻微的模糊处理。或者在片段着色器中根据顶点颜色进行混合。4. Unity中的网格生成与渲染实战算法部分产出了我们需要的核心数据每个站点的位置、高度、颜色群落。现在我们需要在Unity中将其可视化。4.1 构建平滑的网格直接用Voronoi多边形的顶点构建网格会得到棱角分明的“细胞状”地形。我们需要两个关键处理Lloyd松弛在算法阶段我们可以对Voronoi的站点进行多次Lloyd松弛迭代。其原理是将每个站点移动到其所属Voronoi多边形的中心质心然后基于新的站点集重新计算Voronoi图。重复几次后多边形会变得更均匀、更接近规则六边形。这步通常在属性计算之前进行因为它会改变站点的位置。顶点高度插值一个多边形的多个顶点可能分别属于不同的原始Voronoi多边形。我们不能简单地将某个站点的高度赋给整个多边形。正确的方法是对于最终渲染网格的每一个顶点找到离它最近的几个原始站点例如通过Delaunay三角剖分找到它所在的三角形对应的三个站点。使用重心坐标插值或距离反比加权根据这几个站点的高度和颜色计算出该顶点的最终高度和颜色。这样即使多边形本身是低多边形的其表面的高度和颜色变化也是平滑的。实操步骤将处理后的所有站点包含位置、高度、颜色传入一个网格构建函数。使用MIConvexHull库根据站点位置生成Delaunay三角剖分。遍历所有Delaunay三角形每个三角形生成三个顶点。顶点位置就是三角形的三个角点站点位置但其Y坐标高度需要根据插值计算得到见上文。顶点颜色同样通过插值得到。顶点UV可以简单使用其XZ坐标归一化后的值用于后续贴图采样如果需要。收集所有顶点和三角形索引创建Unity的Mesh对象。将Mesh赋值给一个MeshFilter并为其创建或指定一个Material。// 简化版的网格生成代码片段 public Mesh GeneratePolygonMesh(ListSite sites) { Mesh mesh new Mesh(); ListVector3 vertices new ListVector3(); Listint triangles new Listint(); ListColor colors new ListColor(); // 假设delaunayTriangles是通过库计算得到的三角形列表 foreach (var tri in delaunayTriangles) { Site s1 tri.Vertices[0]; Site s2 tri.Vertices[1]; Site s3 tri.Vertices[2]; // 计算三角形顶点的插值后位置和颜色此处简化实际需用重心坐标 Vector3 v1Pos new Vector3(s1.Position.x, CalculateInterpolatedHeight(s1, s2, s3, s1.Position), s1.Position.y); Color v1Col CalculateInterpolatedColor(s1, s2, s3, s1.Position); // 将v1Pos, v2Pos, v3Pos加入vertices颜色加入colors // 将对应的三角形索引加入triangles } mesh.vertices vertices.ToArray(); mesh.triangles triangles.ToArray(); mesh.colors colors.ToArray(); mesh.RecalculateNormals(); // 重要用于光照计算 mesh.RecalculateBounds(); return mesh; }4.2 着色与材质最简单的渲染方式是使用顶点颜色。我们在上一步已经为每个顶点计算了颜色代表生物群落。在Unity的材质中使用一个简单的Unlit Shader或者Standard Shader并将Albedo源设置为Vertex Color就能看到按群落着色的地形。为了更丰富的视觉效果可以使用纹理混合根据顶点颜色或一个单独的生物群落索引作为混合权重在Shader中混合多张不同的地形纹理草地、沙地、岩石、雪地。添加细节贴图使用Triplanar Mapping等技术在斜坡上也能正确显示纹理避免拉伸。法线贴图与光照通过mesh.RecalculateNormals()得到的法线是平滑的。对于低多边形风格我们有时需要“面片化”的硬边效果。这可以通过保留顶点法线不进行平滑或者使用一个专门的法线计算算法来实现。另一种风格化的做法是使用Cel Shading卡通着色。4.3 性能优化考量LOD多层次细节如果地图很大需要实现LOD。一种针对多边形地图的简化方法是在远距离使用更少的站点来生成低精度的Voronoi图。这需要一套动态的站点生成和管理机制。分块加载将大地图划分为多个Chunk瓦片只生成和渲染玩家视野范围内的块。当玩家移动时动态加载和卸载。数据序列化生成一次地图可能耗时较长尤其是迭代侵蚀。可以将最终的所有站点数据位置、高度、颜色等序列化成二进制文件或ScriptableObject运行时直接加载避免每次重新生成。Job System与Burst如果需要在运行时动态修改地形如实时侵蚀、建筑影响可以将高度场计算、网格顶点更新等密集型任务放到Job System中并用Burst编译加速这对性能提升巨大。5. 常见问题、调试技巧与进阶扩展5.1 我踩过的那些坑地形出现尖锐的“ spikes”或深坑这通常是Delaunay三角剖分中出现了极端狭长的三角形导致外接圆圆心跑到很远的地方从而生成了非常长的Voronoi边。解决方案a) 使用泊松圆盘采样确保点分布均匀b) 在生成Voronoi图后过滤掉长度异常长的边或者将其“裁剪”到合理范围。海洋和陆地边界不自然初始的陆海划分太随机。解决方案使用一个平滑的噪声图如Perlin噪声作为“陆地掩码”而不是简单的距离阈值。让噪声值大于某阈值的区域成为陆地这样生成的大陆轮廓更有有机感。水力侵蚀导致地形过于平坦侵蚀迭代次数过多或流速太快。解决方案这是一个艺术大于科学的过程。需要仔细调参降低erosionRate如0.05减少erosionIterations如30。同时可以在侵蚀后再叠加一层低振幅、高频的噪声恢复一些小的细节。网格在Unity中显示为纯黑或粉色粉色通常意味着Shader编译错误或材质丢失。纯黑可能是法线计算错误。排查步骤a) 检查Mesh的normals是否已Recalculateb) 检查Material是否正确赋值Shader是否支持c) 在Scene视图的Shading Mode下切换为Shaded Wireframe查看网格结构是否正确。生成速度慢对于上万级别的站点Voronoi计算和侵蚀模拟可能成为瓶颈。优化a) 分帧生成使用Coroutine避免卡顿b) 将侵蚀等模拟算法移到计算完成后保存结果无需运行时计算c) 考虑使用更快的库或者为特定规模地图预计算参数。5.2 调试与可视化技巧在开发过程中可视化中间步骤至关重要绘制站点与边在OnDrawGizmos中用Gizmos.DrawSphere绘制所有站点用Gizmos.DrawLine绘制Voronoi边。可以给不同高度的站点赋予不同颜色如蓝色低绿色中红色高一眼看清高度分布。分步输出纹理将高度场、湿度场、群落索引分别渲染到一张Texture2D上每个站点对应一个像素块保存为图片查看。这比在3D视图里看更直观。实时参数调节将关键参数如噪声频率、侵蚀强度、海平面高度暴露为[Range]或[SerializeField]的公共变量在Unity Editor中运行时实时调节立即看到地图变化这是最有效的调参方式。5.3 玩法驱动的扩展思路多边形地图的潜力远不止于视觉表现。每个多边形都是一个天然的“游戏逻辑单元”。策略游戏每个多边形是一个省份拥有资源、所有权、军队容量等属性。地图生成时就可以分配基础资源丰富度。模拟经营多边形是地块不同的生物群落决定可建造的建筑类型和产出效率。Roguelike冒险每次生成一个新的多边形地图每个多边形房间通过边通道连接怪物和宝藏分布其中生成算法直接构成关卡。动态地形结合网格修改可以实现实时的地形变形比如挖渠引水改变相邻多边形的高度和湿度、修建道路标记特定的多边形边为可通行。实现这些玩法的关键是在数据结构中不仅存储站点的视觉属性位置、高度、颜色更存储其游戏逻辑属性所属玩家、资源量、建筑列表等并在生成阶段或游戏运行时进行维护和更新。从一堆随机点到一片有山川湖海、有生态逻辑、还能驱动玩法的游戏世界这个过程充满了挑战也充满了乐趣。这套多边形地图生成框架就像一套乐高基础组件搭建好后你可以根据自己的游戏创意拼装出无限可能。最重要的是它给了你作为开发者对游戏世界最根本的控制力从底层塑造独一无二的体验。