Unity+Blender程序化生成六边形星球世界引擎详解 之前在做类似《文明》风格的六边形地块世界时最痛苦的不是写单位移动逻辑而是“地图怎么来、地块怎么生成、改动一块地形后要不要回到建模软件里返工”。后来把整个流程切换到 Unity 程序化生成 Blender 辅助资产之后开发节奏明显顺了很多。这篇文章会把 Unity Blender 打造程序化星球生成器、从零搭建六边形世界引擎的核心思路、坐标算法、Mesh 生成、噪声地形、常见坑点完整拆出来。新手可以按顺序理解整套体系有基础的开发者可以直接跳去第 5、6 节抄代码和配置。1. 为什么需要六边形世界引擎1.1 从《文明》看六边形地图设计《文明》系列从第 5 代开始全面转向六边形地块。相比传统方格六边形一格与周围六格的距离更均匀移动距离不会因为“走斜线”出现问题。四方向的方格地图里单位从中心格走到相邻格和对角格真实距离不一致八方向网格则让相邻关系变得太宽松很多时候玩家很难判断边境、控制和城市辐射范围。六边形天然把复杂的地块邻接问题简化成“6 个等距邻居”这对寻路、范围判断、区域划分和战争迷雾都更加友好。放到星球生成器里六边形还有另一个好处每个地块是同一块 Tile Mesh内存和绘制上都能复用。只要你有一套轴向坐标转换、一套寻路规则、一套地块数据表就可以在任何球面或平面表现层上换皮。1.2 程序化星球生成器的模块拆分很多初学者一想到“星球生成器”就以为只是跑一个噪声算法生成山体高度再塞进 Unity 的 Terrain 系统。但要做到类《文明》那种可以承载国家、城市、单位、资源的地图其实需要拆成多个相互独立的子模块地块数据层保存每个格子的轴向坐标、地形类型、海拔、湿度、资源标记。六边形算法层负责坐标换算、邻居访问、距离计算、寻路。表现层负责把地块数据变成可见的地形例如六棱柱 Tile、Mesh、水面、草地、山脉。网络与区块管理当格子数量很多时要按区块分页生成、卸载、序列化。程序化噪声系统用 PerlinNoise、SimplexNoise 等控制高度、温度、湿度。美术资产层Blender 或其它 DCC 软件用来制作特殊地貌、建筑、植被和细节模型。这篇文章的主要目标是要把前四块打通让你能在 Unity 工程里看到一个由六边形地块构成的世界原型。2. 环境准备与版本说明2.1 Unity 与 Blender 版本配置本文示例以 Unity 2021.3 LTS 或 Unity 2022.3 LTS 为参考环境使用内置渲染管线最容易跑通。如果你用的是 URP代码中的 Mesh 生成逻辑不需要改只有着色器设置需要额外检查。Blender 这边以 3.x 及以上版本为例Blender 2.8 以上也可以核心操作基本一致。版本不要盲目追新尤其是 Unity 长期支持版本LTS比年度版稳定。程序化生成项目通常要写大量 Editor 调试代码也会频繁 Build用 LTS 能少踩很多内部 API 变动带来的坑。2.2 项目目录结构为了保证后续扩展建议在一开始就用清晰的目录。新建 Unity 工程后在 Assets 下创建这些文件夹Assets/ ├─ Scenes/ ├─ Scripts/ │ ├─ Core/ │ ├─ Hex/ │ ├─ Generation/ │ └─ Editor/ ├─ Art/ │ ├─ Models/ │ ├─ Materials/ │ └─ Textures/ ├─ Data/ │ └─ TileConfigs/ └─ Prefabs/不要把脚本全部丢在根目录。等程序化逻辑逐渐增多你会发现“核心算法、Unity 生命周期、编辑器工具、数据配置”必须分开否则排查问题会非常痛苦。2.3 开发路线建议程序化星球生成器的复杂度非常高不建议直接从“完美球面六边形铺满”开始。比较好的路径是先实现平面六边形地图原型验证地块数据和游戏逻辑。再实现 Blender 基础 Tile 资产导入 Unity 确认单位与缩放。然后实现程序化 Mesh 生成和噪声地形。最后才处理球面映射、多面体投影、区块加载和性能优化。3. 六边形网格坐标系统与基础算法六边形地图最容易劝退新手的点就是坐标。因为你不能像方格地图一样直接使用(x, y)行列索引。同一个视觉位置在不同的坐标体系里差异很大。3.1 三种常见坐标表示六边形坐标系主要有三种表示法。Offset 坐标仍然使用行列(col, row)但每隔一行让格子错位半个宽度。优点是接近传统二维数组缺点是相邻关系需要判断奇偶行算法并不漂亮。Axial 坐标使用(q, r)q 代表列方向r 代表行方向去掉了奇偶判断是工程中最推荐的数据层坐标。Cube 坐标使用三维坐标(x, y, z)满足x y z 0。这个坐标本身是对称的距离、旋转、邻居算法都极其优雅。我建议数据层统一使用轴向坐标(q, r)只在计算距离和寻路时临时转成立方体坐标。3.2 轴向坐标与邻居计算先准备一个 HexMath 静态工具类。下面这段代码可以作为整个六边形世界引擎的“地基”。// 文件路径: Assets/Scripts/Hex/HexMath.cs using UnityEngine; public static class HexMath { // 轴向坐标 (q, r) 的六个邻居方向 // 这里按照 pointy-top 布局给出 public static readonly Vector2Int[] AxialDirections { new Vector2Int(1, 0), new Vector2Int(1, -1), new Vector2Int(0, -1), new Vector2Int(-1, 0), new Vector2Int(-1, 1), new Vector2Int(0, 1) }; public static Vector2Int GetNeighbor(Vector2Int axial, int direction) { int dir ((direction % 6) 6) % 6; return axial AxialDirections[dir]; } public static Vector2Int[] GetNeighbors(Vector2Int axial) { Vector2Int[] neighbors new Vector2Int[6]; for (int i 0; i 6; i) { neighbors[i] GetNeighbor(axial, i); } return neighbors; } public static Vector3Int AxialToCube(Vector2Int axial) { int x axial.x; int z axial.y; int y -x - z; return new Vector3Int(x, y, z); } public static int Distance(Vector2Int a, Vector2Int b) { Vector3Int ac AxialToCube(a); Vector3Int bc AxialToCube(b); return (Mathf.Abs(ac.x - bc.x) Mathf.Abs(ac.y - bc.y) Mathf.Abs(ac.z - bc.z)) / 2; } public static Vector3 AxialToWorld(Vector2Int axial, float tileSize) { float sqrt3 Mathf.Sqrt(3f); float x sqrt3 * (axial.x axial.y * 0.5f) * tileSize; float z 1.5f * axial.y * tileSize; return new Vector3(x, 0f, z); } }这里为什么要用AxialToCube因为在轴向坐标系里两点距离公式比较绕用立方体坐标后距离就是三个坐标差值绝对值的最大值也等于差值绝对值之和的一半。代码里用的正是立方体坐标的切比雪夫距离变体。你可能会疑惑方向常量为什么是这个顺序。pointy-top 六边形的一圈邻居按方向依次是右、右下、左下、左、左上、右上。这个顺序不强求只要生成邻居时保持一致即可。关键是后续寻路、范围判断都复用同一套方向别在某个函数里手写另一套易错的偏移量。3.3 把距离、包围圈和寻路准备好有了Distance函数就可以快速实现“单位在半径 N 内能到达哪些格子”的逻辑public static ListVector2Int GetCellsInRange(Vector2Int center, int range) { ListVector2Int results new ListVector2Int(); for (int q -range; q range; q) { int rMin Mathf.Max(-range, -q - range); int rMax Mathf.Min(range, -q range); for (int r rMin; r rMax; r) { results.Add(new Vector2Int(center.x q, center.y r)); } } return results; }q和r的双层循环是基于立方体坐标推导出来的。你可以暂时不理解数学细节但需要记住只要转换成 Cube 坐标后限制条件是x、y、z的绝对值都小于等于 range。这个实现能保证你得到一个漂亮的六边形范围而不是一个矩形。4. 在 Blender 中准备六边形资源4.1 为什么不是全部在 Unity 中手写有人会觉得“都程序化生成了为什么还需要 Blender”真正做项目时Unity 用代码生成 Tile 和地形基础还行但山脉、废墟、植被、建筑这类复杂度较高的装饰性资产用 Blender 建模再导入效率高得多。程序化星球的正确做法通常是混合使用基础地块网格由 Unity 代码重复生成特殊地貌、建筑、植物由美术在 Blender 里制作成 Prefab再按地块数据随机摆放。这样既保证了地图规模又不牺牲细节表现。4.2 快速制作六棱柱 Tile打开 Blender 后先删除默认的 Cube按快捷键Shift A添加一个圆柱体把它命名为Hex_Tile。在添加圆柱体的面板里把 Vertices 从默认的 32 改成 6Radius 可以根据需要填 1Depth 先填 0.2。这样得到的六棱柱非常适合当作最基础的六边形地块。如果你想制作山丘、丘陵等不同高度地形可以进入编辑模式按Tab选择顶面后使用E键挤出通过上下移动顶点做出起伏。不过要注意高度不同的地块如果直接做成不同模型美术资产量会很大。更推荐的方式是在 Blender 中只制作“六棱柱地块基础底”高度、颜色、纹理交给 Unity 程序化处理。这样可以用一个 Tile Mesh 实例化几百个地块再通过缩放或顶点高度生成不同地形效果。4.3 导出与单位问题Blender 导出 FBX 时经常出现“导入到 Unity 后模型变得很大或很小”的现象。这是因为 Blender 与 FBX 的单位换算规则比较特殊。解决方法如下在 Blender 中选中模型按N打开右侧边栏确认“场景单位”中长度单位是米缩放为 1。导出时选择 FBX 格式。在 Unity 中选中导入的模型检查Scale Factor。如果模型边长应该是 1 米但导入后不是 1就把 Scale Factor 调成对应比例。如果你的六边形 Tile 半径本来就是 1建议在 Blender 中建模时也按真实比例做不要随便缩小成 0.01。否则 Unity 光照、物理碰撞和程序化寻路都会出现单位不一致的问题。5. Unity 程序化生成从 Mesh 到星球5.1 Unity Mesh 概念简述在 Unity 中一个可见模型本质上是 MeshFilter 加 MeshRenderer。MeshFilter 保存网格数据MeshRenderer 负责绘制。网格数据由顶点数组、三角形索引数组、UV 数组等组成。顶点数组是空间中的点列表三角形索引告诉引擎每三个顶点连成一个三角形面UV 用于采样贴图。要程序化生成一个六边形 Tile本质上就是自己填充顶点数组和三角形索引数组。5.2 球面网格方案Cube Sphere 还是 Icosahedron真正要做星球表面无缝六边形网格时绝大多数方案不会直接在“经纬度球面”上平铺六边形。经纬线在极点会汇聚导致高纬度的六边形形状被压扁甚至无法闭合。业界常用的两个方向Cube Sphere把立方体六个面细分再把每个顶点方向归一化到球面上。这种做法没有极点奇点纹理和 Tile 逻辑也容易在六个面内展开但跨面接缝处需要额外处理。Icosahedron 细分从正二十面体开始不断细分三角形再对偶成六边形网格。这种方案更接近真正无缝的球面六边形像 H3 这样离散全球网格系统正是基于类似的几何理论。工程上如果你只做演示或课程原型使用 Cube Sphere 投影就能得到很不错的星球轮廓。如果你希望达到类似商业策略游戏里“地表无遮挡、格子天然跨边界连通”的效果则要认真学习二十面体网格生成和对偶算法。5.3 在 C# 里生成六边形 Mesh这里先实现一个程序化生成六棱柱 Mesh 的函数。即使最后你要生成球面地块这个基础 Mesh 也会反复使用。// 文件路径: Assets/Scripts/Generation/HexMeshBuilder.cs using UnityEngine; public static class HexMeshBuilder { public static Mesh CreateHexPrism(float radius, float thickness) { Mesh mesh new Mesh(); Vector3[] vertices new Vector3[14]; Vector2[] uv new Vector2[14]; // 生成 6 个下半圆环顶点和 6 个上半圆环顶点 for (int i 0; i 6; i) { float angle Mathf.Deg2Rad * (60f * i 30f); float x Mathf.Cos(angle) * radius; float z Mathf.Sin(angle) * radius; vertices[i] new Vector3(x, -thickness * 0.5f, z); vertices[i 6] new Vector3(x, thickness * 0.5f, z); uv[i] new Vector2(x / (radius * 2f) 0.5f, z / (radius * 2f) 0.5f); uv[i 6] uv[i]; } // 底部中心点和顶部中心点 vertices[12] new Vector3(0f, -thickness * 0.5f, 0f); vertices[13] new Vector3(0f, thickness * 0.5f, 0f); uv[12] new Vector2(0.5f, 0.5f); uv[13] new Vector2(0.5f, 0.5f); int[] triangles new int[72]; int tri 0; // 侧面 for (int i 0; i 6; i) { int next (i 1) % 6; triangles[tri] i; triangles[tri] i 6; triangles[tri] next 6; triangles[tri] i; triangles[tri] next 6; triangles[tri] next; } // 顶面 for (int i 0; i 6; i) { int next (i 1) % 6; triangles[tri] 13; triangles[tri] i 6; triangles[tri] next 6; } // 底面 for (int i 0; i 6; i) { int next (i 1) % 6; triangles[tri] i; triangles[tri] 12; triangles[tri] next; } mesh.vertices vertices; mesh.triangles triangles; mesh.uv uv; mesh.RecalculateNormals(); mesh.RecalculateBounds(); return mesh; } }这段代码的核心逻辑是先准备好 14 个顶点包括上下各 6 个六边形顶点和上下两个中心点然后分别生成侧面、顶面、底面的三角形。创建完 Mesh 后必须调用RecalculateNormals()重新计算法线否则模型在光照下可能完全是暗的。如果你只需要地块底盘可以省略顶面和底面只生成侧面。但作为策略游戏地块一个带厚度的六棱柱观感更接近现实中的“格子”。5.4 噪声驱动海拔PerlinNoise地形不能是平的否则星球就是一颗光滑的台球。Unity 内置了Mathf.PerlinNoise可以直接用来生成高度图它是经典的分形噪声基础。PerlinNoise 接收两个输入值并返回 0 到 1 之间的结果。为了让地形有自然感不能只采样一次而要叠加多个不同频率和振幅的噪声这就是“分形布朗运动”思路。下面的代码可以在运行时给一个地块赋予高度public static float SampleHeight(int q, int r, float noiseScale, float amplitude) { float n1 Mathf.PerlinNoise(q * noiseScale, r * noiseScale); float n2 Mathf.PerlinNoise(q * noiseScale * 2.1f 17f, r * noiseScale * 2.1f 3f); float finalNoise (n1 n2 * 0.5f) / 1.5f; return