做过策略游戏或者研究过《文明》这类六边形回合制的人,可能都有过一个共同冲动:能不能自己从零写一套地图生成器,让开局不是固定关卡,而是每个存档都生成一颗独一无二的行星?这个冲动很容易栽跟头。市面上关于 Unity 网格生成、Blender 建模的教程并不少,但把它们串成“程序化六边形世界引擎”时,却很少有人讲清楚最核心的一件事:你要先做的不是美术资产,而是一套稳定的坐标系统和地图数据结构。
这篇文章想给出的判断是:做六边形世界引擎,最大的工作量不在 3D 模型,而在“逻辑层”。网格怎么渲染、地形长什么样,是数据问题;Blender 里做多少漂亮砖块,都不如先让一个六边形地图能稳定生成、能按坐标寻路、能靠柏林噪声产出大陆和海洋更重要。这个顺序反过来,项目大概率会烂尾。
读完这篇文章,你会得到一条完整的起步路线:六边形坐标数学、平面地图与球面星球两种实现路径的选择、Unity 与 Blender 的最小工具链配置、一段可直接扩展到策略玩法的 C# 地图生成代码,以及从地形数据到美术资产管线的衔接方式。无论你是打算照着视频课程做,还是想独立开发策略游戏原型,这套方法都能帮你避开最常见的架构坑。
1. 动手之前,先避开的三个大坑
1.1 不要把“六边形网格”当成美术编辑器
很多新手的第一反应,是在场景里手动摆六边形网格,或者从 Asset Store 下载一个漂亮模型,然后开始往里面填数据。这个思路的问题在于:一旦地图尺寸从 10x10 扩大到 100x100,手动摆放完全不可维护。真实项目中,网格必须是程序化生成出来的逻辑结构,所有格子的坐标、邻居关系、地形属性都来自数据,而不是场景里某个物体的位置。
1.2 不要让玩法逻辑住在 GameObject 上
第二坑是在每个六边形上挂一个 MonoBehaviour,把地形类型、海拔、单位这几样状态都塞进组件里。前期原型非常爽,后面一旦要做存档、联网同步、地图重新加载,就会非常难受。更合理的做法是:用独立的普通 C# 类保存地块数据,渲染层只是“读数据、画表现”的消费方。这样你甚至可以在地图数据生成后,先不渲染任何 3D 物体,直接跑单元测试验证寻路和刷怪逻辑。
1.3 不要一上来就做“完美六边形星球”
这是最隐蔽的坑。从平面六边形网格走到球面时,拓扑学会告诉你:一个球面不可能全部由正六边形铺满。如果要求每个顶点恰好三条边、用均匀六边形覆盖球面,就必然出现至少 12 个五边形。这也是为什么很多“程序化星球”看到的网格并不是整齐蜂窝状。最稳妥的路径是先做平面逻辑网格,再把视觉表现映射到球面,或者接受二十面体加五边形的方案。课程型项目通常都应该先跑通平面版本,再谈球面。
2. 六边形网格坐标系统:核心数学与刚需概念
2.1 三种常见坐标系对比
做六边形网格,跳不过坐标系问题。常见方案有三类:
| 坐标系 | 表达方式 | 优点 | 缺点 |
|---|---|---|---|
| 偏移坐标 Offset | (row, col),类似二维数组 | 直观,适合美术布局和地图编辑 | 邻居计算依赖奇偶行,容易出错 |
| 轴向坐标 Axial | (q, r) | 紧凑,适合存储数据 | 不直观,坐标范围是斜的 |
| 立方体坐标 Cube | (x, y, z),其中 x + y + z = 0 | 对称性好,数学公式简洁 | 比轴向多一个坐标,冗余 |
如果你只想要一套坐标作为主存储格式,推荐轴向坐标。它在平面地图上的几何关系很干净,六个方向的邻居可以通过固定方向表计算出来,不需要像偏移坐标那样分“奇数行”和“偶数行”写两套逻辑。
2.2 轴向坐标与邻居方向表
在轴向坐标中,常用的六方向向量可以定义为:
// 文件路径:Assets/_Project/Scripts/HexMath/HexMath.cs using UnityEngine; public static class HexMath { // 轴向六个邻居方向 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) }; // 轴向坐标转世界坐标(Pointy Top 布局) public static Vector3 AxialToLocalPosition(Vector2Int axial, float size) { float x = size * (Mathf.Sqrt(3f) * axial.x + Mathf.Sqrt(3f) / 2f * axial.y); float z = size * 1.5f * axial.y; return new Vector3(x, 0f, z); } }轴向坐标转世界坐标时,size表示六边形中心到顶点的半径。公式中1.5f * axial.y出自六边形纵向排列的几何关系:两行中心之间的纵向距离是sqrt(3)倍边长,而这里竖直方向使用的是z分量。
实际场景里要注意 Unity 的坐标系:如果你希望地图铺在 XZ 平面,那么六边形的“上下”就是 Z 轴方向,高度轴是 Y 轴。多数地面策略游戏都会采用这种布局,方便后续连接导航和碰撞体。
2.3 邻居计算与距离公式
拿到邻居方向表后,取一个格子的邻居就非常简单:
public static Vector2Int GetNeighbor(Vector2Int axial, int directionIndex) { Vector2Int dir = AxialDirections[directionIndex]; return new Vector2Int(axial.x + dir.x, axial.y + dir.y); }同理,两个轴向坐标的曼哈顿距离公式为:
public static int AxialDistance(Vector2Int a, Vector2Int b) { int dq = a.x - b.x; int dr = a.y - b.y; return (Mathf.Abs(dq) + Mathf.Abs(dr) + Mathf.Abs(dq + dr)) / 2; }这个方法会成为后续寻路、单位攻击范围、区域扩张的基础。如果坐标系没有统一,这一步所有代码都要写两遍。
3. 平面六边形逻辑与“星球”显示层如何取舍
3.1 经典策略游戏为什么大多数用平面逻辑
观察成熟策略游戏的开发经验会发现,玩法逻辑通常是二维六边形网格,而不是严格三维球面网格。原因很实际:生成地图、寻路、区域合并、战争迷雾,这些玩法系统在平面网格上做,算法简洁且易于验证。所谓的“星球”更多是视觉表现层,让你觉得自己在操作一颗行星。
3.2 两条球面路线对比
如果你真的希望让地图看起来是球体,常见路线有两条。
第一条是“平面逻辑 + 球面投影显示”。逻辑层继续使用轴向坐标,渲染层通过把二维坐标映射到球面经纬度,生成立体球面上的地块。优点是玩法系统简单,缺点是高纬度地区会有明显畸变,极点的表现很难处理。
第二条是“二十面体细分 + 五边形修正”。从正二十面体出发,把每个三角形面细分后再映射到球面,大部分格子会是六边形近似,但会留下少量五边形。优点是在球面上分布相对均匀,视觉效果好;缺点是坐标系统、邻居关系、寻路都要自己重新设计,开发量明显更大。
| 路线 | 开发成本 | 玩法友好度 | 视觉真实度 | 推荐阶段 |
|---|---|---|---|---|
| 平面逻辑 + 球面投影 | 低 | 高 | 中 | 原型期 |
| 二十面体网格 | 高 | 低 | 高 | 进阶重构期 |
如果你要跟着课程做项目,更稳妥的安排是:第一阶段用平面六边形网格把核心玩法跑通,第二阶段再做球面显示层。不要在一个周末里既想搞定完整寻路,又想实现完美球面网格,那是两个独立的大工程。
4. Unity 与 Blender 工具链准备和项目骨架
4.1 环境要求
开始之前,确认本机具备以下基础条件:
- 安装了较新的 Unity Editor,建议使用 LTS 版本,避免长期支持版本切换带来的兼容问题。
- 安装 Blender 时选择与你系统匹配的稳定版,建议同为长期支持版本。
- 电脑显卡驱动保持更新,因为 Blender 的 Cycles 渲染和 Unity 的 Shader 编译都依赖驱动。
如果打开 Unity Hub 后提示许可证相关错误,先检查许可证是否已经激活,再检查是否使用了非激活账号。不要使用任何非官方激活方式,正规项目只需要在 Unity Hub 中登录你的账号并激活 Personal License。
4.2 项目目录结构
建议在一开始就把目录规划清楚,否则随着代码量增长,整个工程会变成一团乱麻:
Assets/ _Project/ Scripts/ HexMath/ Data/ MapGeneration/ Debugger/ Meshes/ Prefabs/ Materials/ Scenes/ ThirdParty/Unity 工程中Assets根目录尽量只放第三方插件和系统生成文件,自己的所有工程文件统一放到_Project下。这样如果你后续接入版本控制或者清理临时文件,范围会非常清晰。
4.3 Unity 与 Blender 的单位约定
一个非常常见的坑是 Blender 中模型导入 Unity 后尺寸不对。这个问题的根源通常是单位设置不一致。Blender 内部默认虽然不是严格等同于现实世界单位,但你应该在项目一开始就约定:Blender 中 1 米 = Unity 中 1 个单位。
在 Blender 中设置场景单位:
Scene Properties -> Units - Unit System: Metric - Length: 1.000 - Rotation: DegreesUnity 默认的 3D 场景单位是米。导出模型时,建议在 Blender 的 FBX 导出面板中检查 Scale 是否为 1.00。若发现导入后尺寸异常,优先检查 Blender 的 Unit Scale 值,而不是在 Unity 中盲目缩放模型。经常遇到“Blender 导入 UE5 后单位不一致”的开发者,基本也都是这个原因。
5. C# 实现:从地图数据到柏林噪声地形生成
5.1 地块数据类
先把“地块”抽象成数据类。每个格子不需要一开始就挂 MonoBehaviour,只需要持有自己的轴向坐标和生成结果:
// 文件路径:Assets/_Project/Scripts/Data/HexTileData.cs using UnityEngine; public enum TerrainType { DeepOcean, Ocean, Coast, Plain, Hill, Mountain, Snow } public class HexTileData { public Vector2Int Axial; public float Elevation; public float Moisture; public float Temperature; public TerrainType Terrain; public HexTileData(Vector2Int axial) { Axial = axial; } }HexTileData只负责存数据。它不知道自己在哪个 GameObject 上,也不关心渲染效果。后续做存档时,你可以把这个列表序列化成 JSON 或二进制;做多人同步时,只同步这个数据结构即可。
5.2 地图生成器与柏林噪声
地形生成的核心算法是柏林噪声。Unity 提供了内置的Mathf.PerlinNoise,虽然它只能处理二维采样,但对平面六边形地图来说足够搭建原型。
// 文件路径:Assets/_Project/Scripts/MapGeneration/HexMapData.cs using System.Collections.Generic; using UnityEngine; public class HexMapData { public List<HexTileData> Tiles = new List<HexTileData>(); public int Seed; public int Radius; public void Generate(int radius, int seed, float cellSize) { Radius = radius; Seed = seed; Tiles.Clear(); System.Random random = new System.Random(seed); float noiseScale = 0.015f; float offsetX = (float)(random.NextDouble() * 10000f); float offsetZ = (float)(random.NextDouble() * 10000f); for (int q = -radius; q <= radius; q++) { int rMin = Mathf.Max(-radius, -q - radius); int rMax = Mathf.Min(radius, -q + radius); for (int r = rMin; r <= rMax; r++) { Vector2Int axial = new Vector2Int(q, r); Vector3 samplePos = HexMath.AxialToLocalPosition(axial, cellSize); float elevationNoise = Mathf.PerlinNoise( offsetX + samplePos.x * noiseScale, offsetZ + samplePos.z * noiseScale ); HexTileData tile = new HexTileData(axial); tile.Elevation = Mathf.Pow(elevationNoise, 1.5f); tile.Terrain = ElevationToTerrain(tile.Elevation); Tiles.Add(tile); } } } private TerrainType ElevationToTerrain(float elevation) { if (elevation < 0.30f) return TerrainType.DeepOcean; if (elevation < 0.42f) return TerrainType.Ocean; if (elevation < 0.50f) return TerrainType.Coast; if (elevation < 0.65f) return TerrainType.Plain; if (elevation < 0.82f) return TerrainType.Hill; if (elevation < 0.92f) return TerrainType.Mountain; return TerrainType.Snow; } }这段代码有几个关键点值得解释。
第一个是rMin和rMax的计算。六边形区域在轴向坐标中不是一个普通矩形。如果你用双重循环去遍历-radius到radius,会得到边缘缺角的正方形。正确约束是r的边界由q决定:-q - radius到-q + radius。
第二个是Mathf.Pow(elevationNoise, 1.5f)。柏林噪声的输出在 0 到 1 之间,而且中间值出现的概率相对较高。直接拿它当海拔会得到大量“浅海和陆地交界”模糊地带。做一次高次幂压缩后,中间海拔会向海平面以下偏移,地形会出现更多明确的海洋与大陆,视觉上更像自然地图。
第三个是噪声频率。noiseScale决定了大陆的尺寸。这个值越小,地形越平缓,可能出现一整块大陆;这个值越大,地形越破碎,会出现群岛效果。课程项目建议先固定一个值,等生成状态符合预期后再把它做成可调参数。
5.3 用调试渲染器把地图画出来
地图数据生成后,如果直接进美术,会很难判断效果。用一堆原始圆柱体做调试渲染,是最快的验证方式:
// 文件路径:Assets/_Project/Scripts/Debugger/HexMapDebugViewer.cs using UnityEngine; public class HexMapDebugViewer : MonoBehaviour { public int radius = 8; public int seed = 20240601; public float cellSize = 1f; void Start() { HexMapData map = new HexMapData(); map.Generate(radius, seed, cellSize); foreach (HexTileData tile in map.Tiles) { Vector3 pos = HexMath.AxialToLocalPosition(tile.Axial, cellSize); GameObject cell = CreateCellVisual(tile); cell.transform.SetParent(transform, false); cell.transform.localPosition = new Vector3(pos.x, 0f, pos.z); } } private GameObject CreateCellVisual(HexTileData tile) { GameObject cylinder = GameObject.CreatePrimitive(PrimitiveType.Cylinder); float height = 0.5f + tile.Elevation * 2f; cylinder.transform.localScale = new Vector3(0.85f, height, 0.85f); Renderer renderer = cylinder.GetComponent<Renderer>(); renderer.material.color = GetTerrainColor(tile.Terrain); return cylinder; } private Color GetTerrainColor(TerrainType terrain) { switch (terrain) { case TerrainType.DeepOcean: return new Color(0.1f, 0.2f, 0.5f); case TerrainType.Ocean: return new Color(0.2f, 0.4f, 0.7f); case TerrainType.Coast: return new Color(0.8f, 0.8f, 0.5f); case TerrainType.Plain: return new Color(0.3f, 0.7f, 0.3f); case TerrainType.Hill: return new Color(0.6f, 0.5f, 0.2f); case TerrainType.Mountain: return new Color(0.5f, 0.45f, 0.4f); case TerrainType.Snow: return Color.white; default: return Color.magenta; } } }这段代码中,最基本的调试和渲染利用了 Unity 内置的圆柱体。把一个空物体放到场景中,挂上HexMapDebugViewer,点击 Play,就能看到蜂窝状色块。不同颜色代表海洋、平原、丘陵、山脉和雪地。
验证要点是:地图整体应当出现若干块“大陆”,而不是满屏杂乱无章的噪点。如果满屏都是海洋,降低noiseScale;如果满屏都是陆地,提高noiseScale,或者调整ElevationToTerrain中的阈值。
5.4 从平面地图到行星显示层
当二维地图逻辑稳定后,如果你想要“星球”效果,可以在视觉层加一个映射函数。最简单的演示是把六边形中心点投影到球面上:
public Vector3 MapToSphere(Vector3 localPos, float planetRadius) { Vector3 dir = localPos.normalized; return dir * planetRadius; }这个方案只是视觉演示。高纬度格子会明显压缩,因此它适合看“行星轮廓”,不建议直接拿来做六边形寻路。真正实现可游玩的球面策略地图,需要回到二十面体方案,那属于进阶重构。
6. Blender 侧美术资产:六边形单元块与导出规范
6.1 为什么美术资产不需要每个格子都做
很多初学者会在 Blender 里建一个非常精细的六边形地块,然后在 Unity 中给每个格子实例化一个对象。如果只是展示十几格,问题不大;但一旦地图扩大到上千格,性能会迅速恶化。更合理的方案是:把美术资产作为“模板”,像搭积木一样按数据坐标摆放,同一类地块尽量共用材质和 Mesh。
6.2 在 Blender 中制作基础六边形块
可以在 Blender 中执行以下步骤:
- 删除默认 Cube。
Shift + A,添加 Cylinder。- 在左下角属性面板中把 Vertices 设为 6,Radius 设为 1,Depth 设为 0.2。
- 进入编辑模式,选中所有面,执行
Mesh -> Normals -> Recalculate Outside,确保法线一致朝外。 - 给顶面单独指定一个材质槽,这样后续导入 Unity 后可以单独控制顶面纹理。
- 如果要做低多边形风格,不需要加太多细分,直接用 Edit Mode 的倒角工具把棱边磨出一点弧度即可。
如果遇到 Blender 中“面朝向不起作用”的问题,通常是因为没有在 Solid 着色模式下开启 Face Orientation 检查。开启后,蓝色表示法线朝外,红色表示法线朝内。发现红色面,进入编辑模式全选网格,重新执行“Recalculate Outside”即可。
如果需要给六边形地块贴图,展 UV 时特别注意透明纹理的黑边问题。透明纹理如果边缘缺少 padding,导入 Unity 后在 mipmap 缩小阶段会出现半透明黑边。解决办法是:在贴图周围预留 4 到 8 像素的透明边距,或者在 Unity 导入设置中关闭 Generate Mip Maps。
6.3 从 Blender 导出到 Unity
建议导出时使用 FBX 格式:
Blender File -> Export -> FBX (.fbx) Object Types: - Mesh: On - Armature: Off Transform: - Scale: 1.00 - Apply Scalings: FBX Units Scale - Forward: -Z - Up: Y这个 Forward 和 Up 设置来自 Blender 与 Unity 的坐标系约定:Unity 使用左手坐标系,Z 轴朝前,Y 轴朝上;Blender 的 FBX 导出默认通常也是 -Z Forward、Y Up。如果模型导入后朝向不对,优先检查这组设置。
模型导入 Unity 后,建议在 Project 窗口选中模型,在 Inspector 中把 Scale Factor 设置为 1,并检查材质是否需要手动指定。实际项目中经常出现“模型一到游戏里就巨大”的情况,多数是 Blender 场景单位和 FBX Scale 设置冲突。
7. 运行结果判断与常见问题排查
7.1 如何科学地验证生成结果
程序化地图不是“能跑就行”,你要有一组可量化的验证标准:
| 检查项 | 预期结果 | 不通过时的排查点 |
|---|---|---|
| 格子密度 | 蜂窝排列无重叠、无缺格 | 检查AxialToLocalPosition公式和 radius 边界 |
| 大陆面积 | 有 1 到 5 块明显大陆 | 调整noiseScale和Mathf.Pow指数 |
| Seed 稳定性 | 相同 seed 每次生成结果一致 | 检查随机数生成顺序是否发生变化 |
| 性能表现 | radius 8 以内秒开 | 如果卡顿,先看是不是每个格子都独立材质 |
Seed 稳定是地图系统最重要的属性之一。一旦存档记录了 seed,玩家无论是否调整过设置,重开同一存档都应该得到完全一样的地图。