
这次我们来看一个很值得动手的技术方向在 Unity 中结合 Blender程序化生成一颗带六边形地块的星球做出类似《文明》那种地块系统但放在一个可以环绕、可以旋转的 3D 球面世界模型里。很多 Unity 开发者第一次做这个需求都会卡在同一个地方平面六边形地图很好写一旦把网格搬到球面上就出现形变、拼接缝隙、地块数量对不上、寻路邻居关系混乱等问题。实际上六边形星球并不是“把一张平面六边形地图卷成球”而是需要先建立一套从坐标系统、球面细分到地块数据生成的整体方案。下面直接给出一套从 Blender 资源准备到 Unity 落地实现的工程拆解。重点覆盖坐标结构设计、球面细分方案、地表类型生成、地块渲染与拾取、项目工程组织以及常见踩坑问题。读者可以按这套路线从零跑通一个最小可用的程序化六边形星球原型。1. 核心能力速览能力项说明引擎要求Unity 2021 LTS 或 2022 LTS 均可不需要付费插件DCC 工具Blender 3.x 或 4.x用于地表装饰物、建筑模型的制作与导出核心功能程序化生成六边形地块星球包含坐标结构、地表类型、球面地块组织和 Blender 资产接入主要技术点六边形坐标系统、二十面体球面细分、噪声地形分配、Mesh 生成与实例化是否依赖第三方插件不依赖核心网格逻辑可自研做大批量优化时可引入 Unity Job System Burst适合人群Unity 游戏开发者、策略游戏原型开发者、程序化世界生成爱好者不适合场景不愿意处理几何拓扑、只想直接下载完整“太空 4X 游戏”模板的读者这里要先把期望值说清楚做一颗视觉上无缝贴合球面、每个地块都是严格等面积六边形的星球本身是一个计算几何问题复杂度高于普通玩法开发。本文给的是“能跑、能玩、能继续扩展的工程方案”不是数学上极致的网格算法教程。2. 六边形世界引擎到底要解决什么问题在动手之前先拆解需求。所谓“类似《文明》的六边形世界引擎”落到工程上实际包含四层第一层是空间网格层。你需要知道球面上有多少个地块、每个地块的中心在哪里、每个地块和哪些邻居相邻。第二层是地块数据层。每个地块要有类型例如海洋、平原、森林、沙漠、高山、冰原还要能挂载资源、单位、建筑等玩法数据。第三层是表现层。地块需要显示成可视化的六边形片、变化颜色或材质同时支持玩家点击、选中和边缘高亮。第四层是工具链层。这部分是 Blender 的主要职责雕刻地表装饰物、制作低模建筑、处理材质贴图然后导入 Unity。很多半途而废的项目错在把“先创建美术资源”当成第一步。正确顺序应该是先设计空间数据再决定渲染方式最后再考虑需要哪些 Blender 模型。另外需要意识到一个几何事实真正的封闭球面无法只由“形状完全一致的正六边形”铺满数学上通常会引入少量五边形才能闭合。所以工程中的常见做法是使用二十面体或类似测地多面体的细分结果作为球面地块组织的基础。这也是为什么“程序化六边形星球”比平面六边形地图更适合作为系统课程核心案例因为它能把坐标系统、程序化 Mesh、数据驱动玩法和 DCC 工具链全部串起来。3. 六边形坐标系统怎么选平面六边形地图常用三种坐标表示偏移坐标 Offset Coordinate、轴向坐标 Axial Coordinate、立方体坐标 Cube Coordinate。做策略游戏时地块 A* 寻路会频繁访问邻居立方体坐标在算法上最直观因为它把六边形邻居关系变成了类似“三维空间六个方向移动”的操作。偏移坐标适合直接存放在二维数组里方便做矩形区域遍历和地图编辑器。它的缺点是邻居偏移量会根据行列奇偶发生变化容易让人写迷糊。轴向坐标是偏移坐标去掉一个冗余轴后得到的结果逻辑上和立方体坐标等价但邻居公式稍许复杂。实际工程中比较推荐的做法是编辑器或存档层使用偏移坐标运行时数据结构里保存 Cube 坐标并在两者之间提供互转方法。下面给出一个很精简的 HexCoord 结构体using UnityEngine; [System.Serializable] public struct HexCoord { public int X; public int Y; public int Z; public HexCoord(int x, int y, int z) { X x; Y y; Z z; } // 从 offset 坐标转 cube 坐标 public static HexCoord FromOffset(int col, int row) { int x col; int z row - (col - (col 1)) / 2; int y -x - z; return new HexCoord(x, y, z); } public static int Distance(HexCoord a, HexCoord b) { return (Mathf.Abs(a.X - b.X) Mathf.Abs(a.Y - b.Y) Mathf.Abs(a.Z - b.Z)) / 2; } }在保留 cube 坐标的前提下还可以给每个地块分配一个持久化的自增 ID。球面项目真正的难点在于平面地图可以用“行列 坐标轴”直接算出全局位置球面不行因为球面网格的坐标不连续。每个地块最好在生成时就把自身数据存进一个HexCell对象不要指望每次运行时用公式反推邻居关系。一个比较合理的HexCell定义using System.Collections.Generic; using UnityEngine; public class HexCell { public int Id; public HexCoord CubeCoord; public Vector3 Center; public Vector3 Normal; public Listint NeighborIds new Listint(); public TerrainKind Terrain; public float Elevation; }TerrainKind可以先用枚举定义后续如果要支持多地形混合再改成 ScriptableObject 或自定义结构体。4. 球面化方案从二十面体网格开始平面六边形网格如果要“贴”到球面上最粗暴的做法是直接把平面坐标映射为球面经纬度。但这样两极区域会被严重压缩到高纬度时六边形会变成细长条还会出现接缝和方向翻转。更可靠的球面化基础是二十面体。二十面体拥有尽可能接近球面的三角面分布对二十面体做递归细分后再投影回单位球体能得到分布比较均匀的网格。这也是一般程序化星球项目采用的基础几何体。4.1 细分二十面体核心步骤分三步定义十二个二十面体顶点定义二十个三角面的索引对每个三角形做中点细分循环指定次数后把所有顶点 Normalize 到单位球面上。下面是一个以三角形列表为核心的细分思路示例重点看“中点细分 归一化”的模式using System.Collections.Generic; using UnityEngine; public static class IcosahedronHelper { public static Mesh CreateSubdividedIcosahedron(int subdivision) { // 这里只展示核心逻辑完整顶点和面表需要按二十面体公式填入 ListVector3 vertices new ListVector3(); Listint triangles new Listint(); // 1. 生成二十面体基础顶点 // 2. 生成二十面体二十个三角面索引 // 3. 对三角形做中点细分 for (int i 0; i subdivision; i) { int triCount triangles.Count; for (int t 0; t triCount; t 3) { int i0 triangles[t]; int i1 triangles[t 1]; int i2 triangles[t 2]; Vector3 v0 vertices[i0]; Vector3 v1 vertices[i1]; Vector3 v2 vertices[i2]; int m01 AddVertex(vertices, (v0 v1) * 0.5f); int m12 AddVertex(vertices, (v1 v2) * 0.5f); int m20 AddVertex(vertices, (v2 v0) * 0.5f); // 把当前三角形替换为 4 个子三角形具体索引顺序需按顶点环绕方向调整 // 实际开发时建议新开一个 List 避免在遍历过程中修改原集合 } } // 4. 归一化到单位球体 for (int i 0; i vertices.Count; i) { vertices[i] vertices[i].normalized; } Mesh mesh new Mesh(); mesh.SetVertices(vertices); mesh.SetTriangles(triangles, 0); mesh.RecalculateNormals(); mesh.RecalculateBounds(); return mesh; } private static int AddVertex(ListVector3 vertices, Vector3 v) { vertices.Add(v); return vertices.Count - 1; } }上面的代码是为了展示流程不建议直接照搬进生产环境。因为如果你在遍历原triangles时追加新三角形会引发索引错乱。正确做法是每一轮细分之前先保存旧三角形数量用原始三角形列表生成新数据放入另一个列表等本轮结束再替换。4.2 用 Voronoi 或 Dual 得到地块中心很多人会问二十面体细分出来的是三角形不是六边形怎么得到六边形地块一种思路是取细分后三角形网格的顶点作为“地块中心”。在二十面体细分形成的三角网格中大多数顶点周围相邻 6 个三角形所以这些顶点与邻居连接后可以形成六边形结构而原始二十面体的少数顶点周围只有 5 个三角形会形成五边形。这正是球面闭合结构里无法避免的结果。另一种思路是构造三角网格的对偶网格直接生成地块多边形边界。这个方法在数学上更严谨但需要做大量邻居点排序、多边形裁剪和公共边合并开发周期更长。对于游戏原型项目我更推荐一个简化组合方案用细分二十面体生成一系列地块中心点每个地块单独实例化一个平面六边形 Mesh把平面六边形的局部 Y 轴朝向地块中心点的法线方向把地块中心点沿法线外移微小距离避免和底层球体 Z-Fighting。这种方案的地块之间可能存在微小视觉缝隙但作为策略游戏原型完全够用。后续如果要做无缝地表可以再进入“多边形瓦片合并”阶段不建议上来就直接挑战完整几何融合。5. Blender 在整套流程中承担什么任务很多 Unity 程序化生成项目容易进入另一个误区把所有东西都用代码做完全不碰 Blender。这样会导致地表装饰物、建筑、资源点等美术内容全卡在程序几何上做出来的效果很单薄。Blender 的角色不是“运行时生成主体”而是“离线资产生产工具”。具体可以做三类事情。第一类制作单体模型。比如森林地块里的树木、矿山地块上的矿脉、海岸线上的礁石、草地上的低模石头。这类模型只需要小而干净不需要多高精度。第二类制作地块差异化装饰。玩家看到一张六边形地块时如果里面只贴了一张颜色游戏感会很弱。最有效率的做法是在 Blender 里做一个“带有地表装饰的六边形地块模板”用顶点色或材质通道区分草地、砂石、道路边缘再导出 FBX 或预制体。第三类制作 UI 或图标预览。很多地块属性如果想在信息面板中展示需要从特定角度渲染一张 2D 预览图。Blender 的摄像机渲染就能完成这件事。Blender 模型导出到 Unity 时有两个高频坑必须提前留意坑一是单位不一致。Blender 默认单位是米Unity 默认单位也是米但很多人在 Blender 里习惯直接拉一个 3.2 单位的立方体没有检查缩放。进入 Unity 后模型要么巨大要么极小。解决办法是在 Blender 里统一设置单位为米导出前选中模型并执行CtrlA - All Transforms把缩放应用掉。坑二是法线朝向错误。Blender 中看起来正常的表面导入 Unity 后出现黑色面片、透光、背面剔除错误多数是因为模型法线朝向不一致。在 Blender 编辑模式打开 Overlays - Face Orientation红色面就是反面。修正方法是选中所有面后执行ShiftN重算法线然后再导出。如果做透明叶片或边缘透明贴图还要留意纹理的 Alpha 边缘处理。很多“透明部分出现黑边”的问题都来自贴图预乘 Alpha 设置错误和建模本身没有关系。6. Unity 侧程序化生成管线6.1 地块 Mesh 创建想让每个地块显示为一个六边形平面片先要有一个能复用的六边形 Mesh。下面这个工具方法只生成单层六边形片可用于运行时实例化using UnityEngine; public static class HexMeshBuilder { public static Mesh CreateFlatHexMesh(float radius) { Vector3[] vertices new Vector3[6]; int[] triangles new int[18]; for (int i 0; i 6; i) { float angle i * Mathf.PI / 3f; // 让六边形平躺在 XZ 平面Y 轴朝上 vertices[i] new Vector3(Mathf.Sin(angle), 0f, Mathf.Cos(angle)) * radius; } for (int i 0; i 6; i) { triangles[i * 3] 0; triangles[i * 3 1] (i 1) % 6; triangles[i * 3 2] (i 2) % 6; } Mesh mesh new Mesh(); mesh.vertices vertices; mesh.triangles triangles; mesh.RecalculateNormals(); mesh.RecalculateBounds(); return mesh; } }如果地块最后显示方向反了只需要把顶点索引方向反过来或者在生成三角形时调整绕序。6.2 将地块片放置到球面当拿到地块中心点后无论地块中心来自二十面体顶点还是其他方案都可以用下面这类逻辑把平面六边形片放置到球面using UnityEngine; public class HexCellPlacer : MonoBehaviour { public GameObject cellPrefab; // 带 MeshFilter 和 MeshRenderer 的预制体 public Material oceanMaterial; public Material landMaterial; public void PlaceCell(HexCell cell, float offset 0.02f) { Vector3 center cell.Center.normalized; Vector3 position center * (1f offset); GameObject instance Instantiate(cellPrefab, position, Quaternion.identity, transform); instance.name Cell_ cell.Id; // 让平面六边形的局部 Y 轴对准地块中心点的外法线 instance.transform.rotation Quaternion.FromToRotation(Vector3.up, center); // 根据地块类型切换材质或颜色 MeshRenderer renderer instance.GetComponentMeshRenderer(); if (renderer ! null) { renderer.sharedMaterial cell.Terrain TerrainKind.Water ? oceanMaterial : landMaterial; } } }地块中心点数量如果很大实例化大量 GameObject 会带来比较多性能开销。很多“看起来卡死”的问题并不是算法问题而是每次创建独立 GameObject、每个 GameObject 都带独立 Mesh 导致 Draw Call 爆炸。原型阶段可以先用少量地块验证玩法等地块数量上到数百或数千时再考虑 GPU Instancing、Mesh.CombineMeshes 合并网格、或者使用 Graphics.DrawMeshInstanced。千万不要一开始就全局合并因为合并后地块的单独拾取和选中高亮会变复杂。6.3 地块类型分配有了地块中心下一步是决定每个地块属于什么地形。常见策略是用噪声函数生成高度采样再用纬度影响温度用高度与温度共同决定地貌。核心思路是不要直接为每个地块生成随机数。随机数会让地形变成“雪花噪点”完全看不出大陆轮廓。我们更希望海拔高低有连续性从而形成成片大陆和海洋。可以用 Unity 自带的Mathf.PerlinNoise做初步实验。因为 PerlinNoise 接收的是二维坐标而球面无法直接用经纬度作连续二维输入在接缝处会撕裂一种简化方式是把地块法线当作方向向量采样该方向的球面噪声或者先把球面投影到立方体六面展开图做噪声采样。下面给一个极简示意using UnityEngine; public enum TerrainKind { DeepWater, Water, Coast, Grass, Forest, Mountain, Snow } public static class TerrainAssigner { public static TerrainKind AssignTerrain(HexCell cell, int seed) { Vector3 n cell.Normal; float lat Mathf.Asin(Mathf.Clamp(n.y, -1f, 1f)); float lon Mathf.Atan2(n.x, n.z); // 实际项目建议自定义一个三维噪声函数这里仅展示思路 float landNoise Mathf.PerlinNoise(lon * 3f seed, lat * 3f - seed); if (Mathf.Abs(n.y) 0.75f) { return TerrainKind.Snow; } if (landNoise 0.35f) { return TerrainKind.DeepWater; } if (landNoise 0.45f) { return TerrainKind.Water; } return TerrainKind.Grass; } }这段代码的问题在于极地区域非常粗暴只是用高度做了判断会留下很生硬的纬线分界。实际项目可以再加一层“湿度噪声”让冰原地带边缘不规则一些。总体原则是用多个低频率噪声叠加再配合纬度渐变而不是只靠一个随机数。7. 交互拾取与地块可视化如果只是把地块片放到球面上游戏仍然无法交互。玩家需要点击某个六边形地块并选中它。最简单的方式是 Physics.Raycast。给每个地块 GameObject 挂上一个最简单碰撞体比如六边形 Mesh Collider。地块数量少时可以这样做数量多时 Mesh Collider 很消耗物理引擎资源需要评估。另一种更高效的做法是所有地块不挂物理碰撞体只在球面上做一次数学求交再把相交点转换为最近的地块中心。这个做法需要实现“球面最近点搜索”比单纯射线检查更复杂但性能更好适合地块数量大的情况。如果要做《文明》那种地块邻接选择和箭头寻路可以先用少量地块完成“点击地块、查邻居、遍历可达范围”的算法验证再决定是否做深度的空间索引。选中效果可以用高亮材质实现。给每个地块预留一个独立的 Renderer选中时切换高亮材质。使用 GPU Instancing 或者 MaterialPropertyBlock 时不能直接修改 sharedMaterial应该通过MaterialPropertyBlock为单个 Renderer 写入颜色或高亮强度避免实例化出大量材质副本。8. 工程组织与存档设计程序化星球最容易让人头晕的地方不是算法而是工程里同时存在“几何顶点”、“地块数据”和“场景物件”三层东西如果不分层管理调试时完全找不到问题根因。建议按这样的方案组织目录Scripts/Core放 HexCoord、HexCell、TerrainKind 等核心数据结构Scripts/Generation放二十面体生成、地块中心生成、地形分配逻辑Scripts/Rendering放地块片放置、材质组装、合并渲染Scripts/Input放点击拾取、地块选中逻辑BlenderAssets放从 Blender 导出的 FBX 和材质贴图Art/Textures放地表纹理和 UI 预览图。存档方面程序化生成的行星如果每次重启后都重新生成玩家进度无法保留。地块状态存盘时不要保存整个 Mesh 数据只需要保存 Id、地块类型、资源点信息、建筑 ID、单位 ID。再次加载时先重新生成几何再把存档数据回填到对应地块上。如果地块数量非常大还需要考虑资源流式加载。Unity Addressables 可以用来管理大型地表装饰物只加载玩家视点附近的地块资源移出视野时释放实例。这里要注意释放的是实例而不是共享 Mesh 和 Material共享资源的生命周期独立管理避免反复加载导致内存抖动或引用丢失。9. 常见问题与排查方法问题现象可能原因排查方式解决方案六边形片之间出现明显裂缝地块半径偏小或地块中心点偏移太大查看地块中心间距和 HexMesh 半径比例增大 HexMesh 半径或让地块片沿法线外移更明显Blender 模型导入后发黑或透明面异常法线方向不一致