这次我们来看一个很值得动手的技术方向:在 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 List<int> NeighborIds = new List<int>(); 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) { // 这里只展示核心逻辑,完整顶点和面表需要按二十面体公式填入 List<Vector3> vertices = new List<Vector3>(); List<int> triangles = new List<int>(); // 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(List<Vector3> 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 里统一设置单位为米,导出前选中模型并执行Ctrl+A -> All Transforms,把缩放应用掉。
坑二是法线朝向错误。Blender 中看起来正常的表面导入 Unity 后出现黑色面片、透光、背面剔除错误,多数是因为模型法线朝向不一致。在 Blender 编辑模式打开 Overlays -> Face Orientation,红色面就是反面。修正方法是选中所有面后执行Shift+N重算法线,然后再导出。
如果做透明叶片或边缘透明贴图,还要留意纹理的 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.GetComponent<MeshRenderer>(); 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 模型导入后发黑或透明面异常 | 法线方向不一致 |