分享一套从零实现“六边形行星生成器引擎”的完整教程,覆盖 Unity 侧 C# 网格生成、球面映射、噪声地形,以及 Blender 侧六边形资源制作与导出。文章按“数学基础 → 代码实测 → 美术协作 → 关卡设计 → 排错 → 工程建议”的顺序展开,新手可以从零复现,有经验的开发者也可以直接拿走核心算法和排查清单。
1. 背景与核心概念
做策略游戏、模拟经营或者太空题材项目时,很多开发者会遇到同一个问题:如何在球面上放置规则格子。如果用常见的方形网格做星球,最直观的后果是两极区域格子严重变形,贴图拉伸,单位寻路也不自然。而使用六边形网格,可以让整个星球表面看起来更均匀,也更接近“蜂巢”“行星”“棋盘”这类视觉语言。
六边形网格并不是一个新概念,桌游和策略游戏里已经用了很多年。它的核心优势有两个:
- 邻居关系均匀:每个六边形只有 6 个相邻格子,所有邻居到中心的距离基本一致,路径计算和范围判定都比方形网格简单。
- 视觉友好:六边形拼合起来没有方形网格那种明显的“十字缝”,在低多边形风格里非常好看。
所谓“行星生成器引擎”,本质上是把程序化生成(Procedural Generation)和六边形网格结合到一起:先用数学方法生成一套完整的六边形网格坐标,再把平面网格映射到球面上,最后用噪声函数决定地形高度,从而得到一颗可以反复随机生成的“程序化星球”。
本文的路线如下:
- 在 Unity 中用 C# 从零生成六边形网格 Mesh;
- 把平面六边形网格映射为球面,并用噪声生成山脉、海洋、雪地;
- 在 Blender 中制作六边形美术 Tile 并导出供项目使用;
- 把生成的星球接入关卡设计逻辑,包括地形类型、邻居查找和 A* 寻路;
- 最后整理高频排错方案和工程化建议。
如果你是第一次接触程序化生成,这篇文章可以帮你打通“数学坐标 → C# 三角网格 → 球面投影 → 游戏数据”这条完整链路。如果你已经能写出基础的 Mesh 生成逻辑,重点可以放在第 5、7、8、9 节。
2. 环境准备与版本说明
先说明一下,本文不会绑定某个具体 Unity 小版本,因为 Mesh 生成和 Shader 代码属于基础 API,近几个 LTS 版本都完全兼容。
| 工具 | 建议版本 | 用途 |
|---|---|---|
| Unity | 2021 LTS 或 2022 LTS,任意 3D 模板 | 运行 C# 网格生成与球面渲染 |
| Blender | 3.x 或 4.x | 制作六边形 Tile 模型、导出 FBX |
| C# | Unity 内置,支持 C# 8 语法即可 | 核心代码 |
| Shader | Unity ShaderLab | 顶点色显示,用于地形着色 |
为了减少实验成本,本文的 Unity 项目使用默认 URP 或 Built-in Render Pipeline都可以。示例中的顶点色 Shader 是纯 Unlit 实现,不依赖 SRP 专有节点,直接放入 Assets 文件夹就能使用。
建议项目结构如下:
Assets/ ├── Scripts/ │ ├── HexMetrics.cs │ ├── PlanetGenerator.cs │ ├── HexCell.cs │ └── HexGameLogic.cs ├── Shaders/ │ └── VertexColorUnlit.shader └── Prefabs/ └── 六边形Tile.prefab(Blender 导出后放入)如果你使用的是更高版本的 Unity,只需要注意Mesh.RecalculateNormals()的行为基本一致,不要被 Unity 的“图元单位(Unit)”默认值影响即可。
3. 六边形网格的数学基础
在写代码之前,我们先消化掉六边形网格用到的坐标系。很多人第一次接触六边形网格会被三种坐标系劝退,但只要理清它们的关系,后面所有算法都很流畅。
3.1 三种坐标系
六边形网格最常用有三种坐标系:
- 偏移坐标(Offset):类似方形网格的
(column, row),但奇偶行错开半格。这种坐标适合地图编辑器和美术制图。 - 轴向坐标(Axial):用
(q, r)表示,其中r是“行”,q是“列”,计算邻居非常方便。 - 立方体坐标(Cube):用
(x, y, z)表示,满足x + y + z = 0。很多距离公式在立方体坐标下最简单。
本文主推轴向坐标,因为它比偏移坐标容易计算邻居,又比立方体坐标少一个维度。你可以把轴向坐标看成立方体坐标去掉y轴的版本。
轴向坐标中,一个六边形的 6 个邻居方向是固定的:
( 1, 0), ( 1, -1), ( 0, -1) (-1, 0), (-1, 1), ( 0, 1)这个方向表会直接写进HexMetrics里。
3.2 六边形的几何尺寸
假设六边形外接圆半径为R,那么:
- 内切圆半径:
R * sqrt(3) / 2 - 六边形的中心到任意顶点的距离:
R - 平铺时中心间距:
- 水平方向:
R * sqrt(3) - 垂直方向:
R * 1.5
- 水平方向:
在 Unity 中,我习惯把六边形放到 XZ 平面,Y 轴朝上。这样后续做球面映射时,可以直接借助 Y 分量把平面顶点“拱起”成球面。
3.3 轴向坐标与世界坐标
轴向坐标(q, r)转世界坐标的公式如下:
x = R * sqrt(3) * (q + r / 2) z = R * 1.5 * r下面直接写成 C# 工具类:
using UnityEngine; public static class HexMetrics { // 六边形外接圆半径 public const float OuterRadius = 1f; // 六边形内切圆半径 public const float InnerRadius = OuterRadius * 0.866025404f; // 轴向坐标 (q, r) -> 世界坐标(XZ 平面) public static Vector3 AxialToWorld(int q, int r) { float x = (Mathf.Sqrt(3f) * q + Mathf.Sqrt(3f) / 2f * r) * OuterRadius; float z = 1.5f * r * OuterRadius; return new Vector3(x, 0f, z); } // 六边形的 6 个顶点坐标(本地坐标,Y=0) public static Vector3[] Corners() { Vector3[] corners = new Vector3[6]; for (int i = 0; i < 6; i++) { float angle = Mathf.Deg2Rad * (60f * i); corners[i] = new Vector3( Mathf.Sin(angle) * OuterRadius, 0f, Mathf.Cos(angle) * OuterRadius ); } return corners; } // 轴向坐标的 6 个邻居方向 public static readonly int[,] NeighborDirections = { { 1, 0 }, { 1, -1 }, { 0, -1 }, { -1, 0 }, { -1, 1 }, { 0, 1 } }; }这里有几个细节值得注意:
Corners()从 0° 开始生成顶点,也就是正 Z 方向是第一个顶点。这样平面六边形的朝向是统一的。- 在 XZ 平面中,Y 始终是 0。后续做球面映射时,我们会给 Y 分量一个“虚拟高度”,再归一化到球面方向。
- 外接圆半径
OuterRadius越小,单个六边形越小,星球表面的瓦片越密。
3.4 六边形半径网格的判断条件
如果要生成一个“半径为 N”的六边形网格区域,最直观的写法是遍历q和r,判断|q + r|是否大于 N:
for (int q = -gridRadius; q <= gridRadius; q++) { for (int r = -gridRadius; r <= gridRadius; r++) { if (Mathf.Abs(q + r) > gridRadius) continue; // 这个格子属于六边形半径 gridRadius 的覆盖范围 } }这个条件来自立方体坐标约束x + y + z = 0。轴向坐标中,q + r的绝对值不能超过半径,否则点会落在六边形范围外。
4. Unity 端:C# 生成六边形网格
有了数学工具类,我们开始生成真正的 Mesh。这里的核心思路是:对六边形覆盖范围内的每个格子,生成一个独立的六边形面片,每个面片由 7 个顶点组成(中心点 + 6 个角点),再由 6 个三角形拼成六边形。
4.1 生成单个六边形
单个六边形 Mesh 的三角形结构如图所示:
- 中心顶点
index = baseIndex - 6 个角顶点
index = baseIndex + 1 ... baseIndex + 6 - 三角形顺序:中心点、第 i 个角点、第 i + 1 个角点(循环)
代码实现如下:
private void AddHex(Vector3 center, Vector3[] corners, List<Vector3> vertices, List<int> triangles) { int baseIndex = vertices.Count; // 1. 中心顶点 vertices.Add(center); // 2. 6 个角点 for (int i = 0; i < 6; i++) { vertices.Add(center + corners[i]); } // 3. 6 个三角形 for (int i = 0; i < 6; i++) { triangles.Add(baseIndex); triangles.Add(baseIndex + 1 + i); triangles.Add(baseIndex + 1 + (i + 1) % 6); } }这个方案的优点是代码简单、容易理解,缺点是每个六边形独立生成,相邻六边形没有共享顶点。在地形生成原型阶段完全够用;如果要进生产环境,可以改成共享顶点网格,后面第 9 节会聊优化方向。
4.2 生成完整的六边形平面网格
完整代码整合如下:
using System.Collections.Generic; using UnityEngine; public class HexPlaneGenerator : MonoBehaviour { [Header("网格参数")] public int gridRadius = 5; public float planeRadius = 12f; [Header("Mesh Filter / Renderer")] public Material material; [ContextMenu("Generate Grid")] public void Generate() { List<Vector3> vertices = new List<Vector3>(); List<int> triangles = new List<int>(); Vector3[] corners = HexMetrics.Corners(); for (int q = -gridRadius; q <= gridRadius; q++) { for (int r = -gridRadius; r <= gridRadius; r++) { if (Mathf.Abs(q + r) > gridRadius) continue; Vector3 center = HexMetrics.AxialToWorld(q, r); // 裁剪到圆盘内,方便后续做球面映射 if (center.magnitude > planeRadius) continue; AddHex(center, corners, vertices, triangles); } } Mesh mesh = new Mesh(); mesh.vertices = vertices.ToArray(); mesh.triangles = triangles.ToArray(); mesh.RecalculateNormals(); mesh.RecalculateBounds(); GetComponent<MeshFilter>().sharedMesh = mesh; } private void AddHex(Vector3 center, Vector3[] corners, List<Vector3> vertices, List<int> triangles) { int baseIndex = vertices.Count; vertices.Add(center); for (int i = 0; i < 6; i++) { vertices.Add(center + corners[i]); } for (int i = 0; i < 6; i++) { triangles.Add(baseIndex); triangles.Add(baseIndex + 1 + i); triangles.Add(baseIndex + 1 + (i + 1) % 6); } } }把脚本挂到一个空物体上,在 Inspector 中调整gridRadius和planeRadius,点击右键菜单Generate Grid,就可以看到一块六边形蜂窝平面。planeRadius的作用是裁剪超出圆盘范围的外围格子,目的是为后续球面映射做准备。
这里有一点需要解释:为什么裁剪成圆盘?
因为我们后面要把平面网格映射成球面,最自然的做法是把平面当成“圆盘投影”,圆盘中心变成球的一个极点,圆盘边缘变成球的赤道。如果不裁剪成圆盘,那么正方形的尖角区域会投影出奇怪的重叠,视觉上不可控。
4.3 Mesh 显示与材质
临时查看效果时,直接用 Unity 默认材质就能看到六边形面片,但是默认材质不带双面渲染,如果三角形绕序反了会显示黑色。这里建议直接使用下面的顶点色 Shader,然后挂一个材质,材质 Shader 选择Custom/VertexColorUnlit:
Shader "Custom/VertexColorUnlit" { Properties { } SubShader { Tags { "RenderType"="Opaque" } Cull Off LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float4 color : COLOR; }; struct v2f { float4 pos : SV_POSITION; float4 color : COLOR; }; v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.color = v.color; return o; } fixed4 frag (v2f i) : SV_Target { return i.color; } ENDCG } } }把 Shader 保存为Assets/Shaders/VertexColorUnlit.shader后,在 Project 面板创建 Material,把 Shader 改成Custom/VertexColorUnlit,即可给网格上色。Cull Off的作用是双面渲染,避免三角形绕序问题导致的黑色面片。
4.4 关于 UV
上面的网格没有生成 UV。对于纯地形高度着色,UV 不是必须的。如果后续要在六边形表面叠加纹理、图标或贴花,可以给每个六边形单独做平铺 UV。最简单的方案是让 UV 落在[0, 1]的六边形区域:
uvs.Add(new Vector2(0.5f, 0.5f)); // 中心 for (int i = 0; i < 6; i++) { uvs.Add(new Vector2( 0.5f + corners[i].x, 0.5f + corners[i].z )); }这样每一块六边形都使用同一张纹理,适合做瓦片类材质。
5. 从六边形平面到球面行星
六边形平面网格已经生成,接下来是本文的核心:把平面映射成球面。这一步会直接决定星球是否“圆”,以及地形是否自然。
5.1 圆盘映射半球
我们采用“圆盘投影”方式,把平面圆盘映射到半球。原理如下:
- 平面圆盘中心
(0, 0)映射为北极点(正 Y); - 平面圆盘边缘映射为赤道;
- 中间点按高度过渡。
假设当前平面顶点是p = (x, 0, z),圆盘半径是planeRadius,则半球高度:
h = sqrt(max(0, planeRadius^2 - x^2 - z^2))于是方向向量为:
dir = normalize(x, h, z)再用dir * planetRadius得到球面坐标。这个映射本质上是把圆盘“顶”成一个半球。中心点h = planeRadius,方向垂直向上;圆盘边缘h = 0,方向水平向外。
如果要把圆盘映射到南半球,只需要把p.x、p.z取反,并让h取负号:
dir = normalize(-x, -h, -z)这样一北一南两个半球可以拼成一个完整的程序化星球。
5.2 加入噪声地形
默认球面高度是固定planetRadius,看起来就是一颗光滑的六边形球。要生成山脉和海洋,就要在半径方向叠加噪声。
Unity 内置Mathf.PerlinNoise是二维噪声,但球面方向是三维的,直接对dir.x、dir.z采样会出现带状条纹。一种简单有效的替代方案是叠加多个不同方向的二维噪声,模拟三维噪声:
private float SampleNoise(Vector3 dir, int seed, float noiseScale) { float n1 = Mathf.PerlinNoise( dir.x * noiseScale + seed * 0.137f, dir.z * noiseScale + seed * 0.271f ); float n2 = Mathf.PerlinNoise( dir.y * noiseScale + seed * 0.631f, dir.z * noiseScale + seed * 0.821f ); float n3 = Mathf.PerlinNoise( dir.x * noiseScale + seed * 0.283f, dir.y * noiseScale + seed * 0.513f ); return (n1 * 0.5f + n2 * 0.3f + n3 * 0.2f); }三个方向叠加之后,噪声在球面的分布会更均匀,不会出现明显的经线纹理。
5.3 完整行星生成器代码
把上面的思路整合成一个PlanetGenerator脚本:
using System.Collections.Generic; using UnityEngine; public class PlanetGenerator : MonoBehaviour { [Header("六边形平面参数")] public int gridRadius = 6; public float planeRadius = 12f; [Header("行星参数")] public float planetRadius = 5f; public float heightAmplitude = 0.35f; public float noiseScale = 1.2f; public int seed = 2025; [Header("半球开关")] public bool generateNorth = true; public bool generateSouth = true; [Header("材质")] public Material planetMaterial; void Start() { Generate(); } [ContextMenu("Generate Planet")] public void Generate() { // 清理旧的子物体 for (int i = transform.childCount - 1; i >= 0; i--) { if (Application.isPlaying) { Destroy(transform.GetChild(i).gameObject); } else { DestroyImmediate(transform.GetChild(i).gameObject); } } List<Vector3> planeVerts = new List<Vector3>(); List<int> planeTris = new List<int>(); Vector3[] corners = HexMetrics.Corners(); for (int q = -gridRadius; q <= gridRadius; q++) { for (int r = -gridRadius; r <= gridRadius; r++) { if (Mathf.Abs(q + r) > gridRadius) continue; Vector3 center = HexMetrics.AxialToWorld(q, r); if (center.magnitude > planeRadius) continue; AddHex(center, corners, planeVerts, planeTris); } } if (generateNorth) { BuildHemisphereMesh("NorthernHemisphere", planeVerts, planeTris, true); } if (generateSouth) { BuildHemisphereMesh("SouthernHemisphere", planeVerts, planeTris, false); } } private void AddHex(Vector3 center, Vector3[] corners, List<Vector3> vertices, List<int> triangles) { int baseIndex = vertices.Count; vertices.Add(center); for (int i = 0; i < 6; i++) { vertices.Add(center + corners[i]); } for (int i = 0; i < 6; i++) { triangles.Add(baseIndex); triangles.Add(baseIndex + 1 + i); triangles.Add(baseIndex + 1 + (i + 1) % 6); } } private void BuildHemisphereMesh(string meshName, List<Vector3> srcVerts, List<int> srcTris, bool north) { System.Random rand = new System.Random(seed); List<Vector3> vertices = new List<Vector3>(srcVerts.Count); List<Color> colors = new List<Color>(srcVerts.Count); for (int i = 0; i < srcVerts.Count; i++) { Vector3 p = srcVerts[i]; // 计算半球高度 float d2 = p.x * p.x + p.z * p.z; d2 = Mathf.Clamp(d2, 0f, planeRadius * planeRadius); float h = Mathf.Sqrt(planeRadius * planeRadius - d2); // 南半球做对称翻转 if (!north) { p.x = -p.x; p.z = -p.z; h = -h; } Vector3 dir = new Vector3(p.x, h, p.z).normalized; // 噪声高度 float noise = SampleNoise(dir, new System.Random(seed), noiseScale); float heightFactor = 1f + noise * heightAmplitude; Vector3 finalPos = dir * (planetRadius * heightFactor); vertices.Add(finalPos); colors.Add(ColorByHeight(noise, dir)); } // 南半球的三角形绕序需要反转 List<int> triangles = new List<int>(srcTris); if (!north) { for (int i = 0; i < triangles.Count; i += 3) { int temp = triangles[i + 1]; triangles[i + 1] = triangles[i + 2]; triangles[i + 2] = temp; } } Mesh mesh = new Mesh(); mesh.name = meshName; mesh.vertices = vertices.ToArray(); mesh.triangles = triangles.ToArray(); mesh.colors = colors.ToArray(); mesh.RecalculateNormals(); mesh.RecalculateBounds(); GameObject go = new GameObject(meshName); go.transform.SetParent(transform, false); go.transform.localRotation = Quaternion.identity; MeshFilter mf = go.AddComponent<MeshFilter>(); mf.sharedMesh = mesh; MeshRenderer mr = go.AddComponent<MeshRenderer>(); mr.sharedMaterial = planetMaterial; } private float SampleNoise(Vector3 dir, System.Random rand, float scale) { // 用随机种子让每个星球噪声起点不同 float sx = (float)rand.NextDouble() * 100f; float sy = (float)rand.NextDouble() * 100f; float sz = (float)rand.NextDouble() * 100f; float n1 = Mathf.PerlinNoise( dir.x * scale + sx, dir.z * scale + sy ); float n2 = Mathf.PerlinNoise( dir.y * scale + sy, dir.z * scale + sz ); float n3 = Mathf.PerlinNoise( dir.x * scale + sz, dir.y * scale + sx ); return (n1 * 0.5f + n2 * 0.3f + n3 * 0.2f); } private Color ColorByHeight(float height, Vector3 dir) { float latitude = Mathf.Abs(dir.y); // 极地冰雪 if (latitude > 0.88f && height > 0.35f) { return new Color(0.95f, 0.97f, 1f); } // 按高度分层 if (height < 0.32f) return new Color(0.2f, 0.4f, 0.8f); // 海洋 if (height < 0.40f) return new Color(0.76f, 0.70f, 0.5f); // 沙滩 if (height < 0.60f) return new Color(0.2f, 0.65f, 0.3f); // 草地 if (height < 0.80f) return new Color(0.45f, 0.35f, 0.25f);// 岩石 return new Color(0.95f, 0.95f, 0.98f); // 雪地 } }把脚本挂到空物体,手动指定planetMaterial为第 4 节创建的双面顶点色材质,然后点击 Inspector 里的Generate Planet,场景中就会生成一颗由六边形瓦片组成的低多边形星球。调整seed可以看到完全不同的地形分布。
5.4 运行效果与参数调整
常见参数含义如下:
| 参数 | 作用 |
|---|---|
gridRadius | 六边形网格半径,越大瓦片越多 |
planeRadius | 平面裁剪圆盘半径,影响球面覆盖范围 |
planetRadius | 基础球体半径 |
heightAmplitude | 地形起伏幅度,越大山脉越夸张 |
noiseScale | 噪声频率,越小地形越平缓 |
seed | 随机种子,相同种子得到相同星球 |
在运行时调节seed或heightAmplitude并重新点击生成按钮,可以看到行星形状实时变化。注意,如果两半球的seed不一致,南北半球的噪声边界会完全不同,所以最终代码里南北半球应当共用同一个System.Random或者复用同一个种子参数。
6. Blender 端:六边形资源的制作与导出
既然题目中包含 Blender,就需要聊一下程序化生成与美术资产的配合方式。虽然 Unity 可以纯代码生成六边形网格,但在实际项目中,六边形 Tile 往往是美术在 Blender 里制作好的模型,上面带有倒角、纹理、植被装饰等细节。
6.1 在 Blender 中用 Python 创建六边形 Tile
打开 Blender,切换到 Scripting 工作区,在 Text Editor 中粘贴以下脚本。这段脚本会清空当前场景,然后生成一个 5x5 的六边形平面网格:
import bpy import math # 清空场景对象(注意备份) bpy.ops.object.select_all(action='SELECT') bpy.ops.object.delete(use_global=False) def create_hex_tile(radius=0.9): verts = [] for i in range(6): angle = math.radians(60 * i) verts.append((radius * math.cos(angle), 0, radius * math.sin(angle))) # 中心点 verts.append((0, 0, 0)) center_index = len(verts) - 1 faces = [] for i in range(6): nxt = (i + 1) % 6 faces.append((i, nxt, center_index)) mesh = bpy.data.meshes.new("HexTile") mesh.from_pydata(verts, [], faces) mesh.update() obj = bpy.data.objects.new("HexTile", mesh) bpy.context.collection.objects.link(obj) return obj # 批量排列六边形 for row in range(5): for col in range(5): tile = create_hex_tile(radius=0.9) tile.location = (col * 1.56, 0, row * 1.35)运行脚本后,你会看到 25 个六边形整齐排列在 XZ 平面上。
这里解释一下间距:
- 水平方向:
sqrt(3) * radius = 1.732 * 0.9 ≈ 1.56 - 垂直方向:
1.5 * radius = 1.35
如果六边形 Tile 带有更复杂的几何体,比如倒角、凸起、纹理贴图,可以在此基础上继续建模,然后把单个 Tile 保存为独立资产,或者直接导出整块网格。
6.2 导出 FBX 并导入 Unity
在 Blender 中选中需要导出的对象,执行File -> Export -> FBX (.fbx)。在导出面板中,建议做以下设置:
- 勾选
Apply Transform - 选择
+Y Up作为 Up 轴,避免导入 Unity 后模型翻转 Scale保留默认 1.0
然后在 Unity 中执行Assets -> Import New Asset,选择该 FBX 文件。若模型方向不对,可以在 Unity 的 FBX 导入设置中调整Conversion里的Up Axis,或直接在 Blender 里绕 X 轴旋转 -90° 后再导一次。
6.3 两种实现方案的取舍
| 方案 | 优点 | 缺点 |
|---|---|---|
| Unity 代码生成 Mesh | 参数化强,体积小,适合运行时动态生成 | 细节装饰少,需要程序化贴图或 Shader 支持 |
| Blender 制作 Tile | 美术可控,倒角/细节丰富 | 需要批量摆放和烘焙,运行时不能随意改字形 |
| 混合方案 | 先用 Blender 做 Tile,再在 Unity 中按六边形坐标实例化 | 需要统一锚点和轴心,策划数据与美术资源必须对齐 |
建议项目早期先用 Unity 代码生成 Mesh,快速验证玩法;当美术风格确定后,再用 Blender 制作高细节 Tile,替换掉程序化面片。
7. 关卡设计与游戏逻辑
生成一颗六边形星球不只是为了看效果,它最终要服务于关卡设计。你可以把六边形格子当成策略游戏的棋盘、生存游戏的采集区块,或者太空探索的区块地图。
7.1 地形类型与格子数据
在游戏逻辑中,我们不能直接操作 Mesh 顶点,而是要为每个六边形建立一套业务数据。定义一个HexCell类:
public enum TerrainType { Water = 0, Sand = 1, Grass = 2, Rock = 3, Snow = 4 } public class HexCell { public int Q; public int R; public TerrainType Terrain; public float Height; public bool Walkable => Terrain != TerrainType.Water; public HexCell(int q, int r) { Q = q; R = r; } }有了Q和R,我们就能把业务数据和生成器数据关联起来。注意,行星生成时也可以让HexCell同步记录worldPosition和sphereNormal,方便后续放置单位、特效或建筑。
生成格子容器:
private Dictionary<(int, int), HexCell> cells = new Dictionary<(int, int), HexCell>(); public void GenerateCells(int radius) { cells.Clear(); for (int q = -radius; q <= radius; q++) { for (int r = -radius; r <= radius; r++) { if (Mathf.Abs(q + r) > radius) continue; cells[(q, r)] = new HexCell(q, r); } } }7.2 邻居查找与移动规则
六边形寻路的第一步是找到邻居。基于轴向坐标,6 个邻居方向已经在HexMetrics.NeighborDirections中定义好了。
public static List<HexCell> GetNeighbors(HexCell cell, Dictionary<(int, int), HexCell> grid) { List<HexCell> neighbors = new List<HexCell>(); for (int i = 0; i < 6; i++) { int nq = cell.Q + HexMetrics.NeighborDirections[i, 0]; int nr = cell.R + HexMetrics.NeighborDirections[i, 1]; if (grid.TryGetValue((nq, nr), out HexCell neighbor)) { neighbors.Add(neighbor); } } return neighbors; }移动规则通常在计算移动代价时体现:
public static float GetMoveCost(HexCell from, HexCell to) { if (!to.Walkable) return float.MaxValue; float baseCost = 1f; // 地形代价 switch (to.Terrain) { case TerrainType.Sand: baseCost = 1.2f; break; case TerrainType.Rock: baseCost = 2.5f; break; case TerrainType.Snow: baseCost = 1.8f; break; case TerrainType.Grass: baseCost = 1.0f; break; } // 高度差代价 float heightDiff = Mathf.Abs(to.Height - from.Height); baseCost += heightDiff * 0.5f; return baseCost; }7.3 六边形 A* 寻路思路
A* 算法在方形网格和六边形网格上的核心逻辑一致,只是邻居计算和启发函数不同。
六边形启发函数(轴向坐标距离)如下:
public static int HexDistance(int aq, int ar, int bq, int br) { int dq = aq - bq; int dr = ar - br; return (Mathf.Abs(dq) + Mathf.Abs(dr) + Mathf.Abs(dq + dr)) / 2; }然后套用标准 A*:
public static List<HexCell> AStar(HexCell start, HexCell goal, Dictionary<(int, int), HexCell> grid) { List<HexCell> open = new List<HexCell>(); Dictionary<(int, int), (int, int)> cameFrom = new Dictionary<(int, int), (int, int)>(); Dictionary<(int, int), float> gScore = new Dictionary<(int, int), float>(); Dictionary<(int, int), float> fScore = new Dictionary<(int, int), float>(); open.Add(start); gScore[(start.Q, start.R)] = 0f; fScore[(start.Q, start.R)] = HexDistance(start.Q, start.R, goal.Q, goal.R); while (open.Count > 0) { open.Sort((a, b) => fScore.GetValueOrDefault((a.Q, a.R), float.MaxValue) .CompareTo(fScore.GetValueOrDefault((b.Q, b.R), float.MaxValue))); HexCell current = open[0]; open.RemoveAt(0); if (current.Q == goal.Q && current.R == goal.R) { return ReconstructPath(cameFrom, current); } foreach (HexCell neighbor in GetNeighbors(current, grid)) { float tentativeG = gScore[(current.Q, current.R)] + GetMoveCost(current, neighbor); if (tentativeG < gScore.GetValueOrDefault((neighbor.Q, neighbor.R), float.MaxValue)) { cameFrom[(neighbor.Q, neighbor.R)] = (current.Q, current.R); gScore[(neighbor.Q, neighbor.R)] = tentativeG; fScore[(neighbor.Q, neighbor.R)] = tentativeG + HexDistance( neighbor.Q, neighbor.R, goal.Q, goal.R ); if (!open.Contains(neighbor)) { open.Add(neighbor); } } } } return null; }这段代码是核心算法的教学版本,生产环境中可以用优先队列优化。GetValueOrDefault依赖 C# 的Dictionary扩展方法,Unity 2021+ 支持良好。
路径重建函数:
private static List<HexCell> ReconstructPath( Dictionary<(int, int), (int, int)> cameFrom, HexCell current ) { List<HexCell> path = new List<HexCell>(); while (cameFrom.ContainsKey((current.Q, current.R))) { path.Add(current); var previous = cameFrom[(current.Q, current.R)]; current = new HexCell(previous.Item1, previous.Item2); } path.Reverse(); return path; }注意ReconstructPath需要能访问格子数据,实际项目中建议把HexCell对象引用直接记录在cameFrom字典中,避免反复构造新对象。
8. 常见问题与排查思路
在实际运行这套生成器的过程中,高频问题集中在 Mesh 显示、球面映射和 Blender 导出三个环节。下面是排查表:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| Mesh 显示为黑色或半透明 | 三角形绕序错误,背面被剔除 | 在 Shader 中开启Cull Off,或检查三角形索引绕序;南半球需要交换第二、第三个索引 |
| 球面两极出现明显挤压 | 圆盘映射到半球时,极区顶点收缩 | 接受原型阶段的效果;生产环境改用二十面体细分或六面体展开 |
| 南北半球接缝有裂缝 | 两个半球 Mesh 在赤道没有共享顶点 | 把两个半球合并成同一个 Mesh,并共享边界顶点;原型阶段可增大planetRadius掩盖裂缝 |
| 地表出现条形纹理 | 直接对dir.x和dir.z使用二维 Perlin 噪声 | 叠加三个方向的 Perlin,或改为真正的三维噪声 |
| 行星表面六边形大小不均匀 | 球面投影本身会导致极点区域收缩 | 尝试在极区使用五边形,或者限制活动区域在中低纬度 |
| Blender 导出的 Tile 在 Unity 中方向不对 | Blender 的 Z-up 和 Unity 的 Y-up 不一致 | 导出时选择+Y Up,或导入后调整模型 Rotation |
| 只有半颗星球显示 | 两个半球子物体没生成 | 检查generateNorth和generateSouth开关,以及材质是否双面渲染 |
| 生成的六边形中间有洞 | 顶点重复或三角形索引越界 | 检查AddHex的baseIndex是否有误,确认triangles长度是 3 的倍数 |
下面详细讲两个最常见的坑。
8.1 三角形绕序问题
Unity 默认对三角形做背面剔除,如果顶点顺序朝向相反,网格会显示为黑色。解决方式有两种:
- 在 Shader 中设置
Cull Off,强制双面渲染; - 生成南半球时交换
triangles[i + 1]和triangles[i + 2],让法线朝向球外。
从工程角度看,建议先用Cull Off快速验证,后面要优化渲染性能时再把绕序修正干净。
8.2 噪声纹理与接缝问题
如果噪声只用Mathf.PerlinNoise(dir.x, dir.z),球面在 Y 方向上的变化完全缺失,地形会出现纵向拉伸的“条状感”。本文的SampleNoise方法叠加了三组噪声,覆盖了 X、Y、Z 三个方向的组合,能有效缓解这个问题。
南北半球接缝依然存在,因为南北 Mesh 的顶点没有共享。原型阶段可以让两个半球在赤道位置留一部分重叠,或者把planetRadius稍微调大一点,用重叠掩盖微小裂缝。
9. 最佳实践与工程建议
当原型跑通后,如果想要把这个六边形行星生成器引擎真正落地到项目中,下面这些建议能帮你少走不少弯路。
9.1 用数据驱动代替硬编码
行星参数(网格半径、种子、噪声强度、颜色梯度)应该放到ScriptableObject中,而不是散落在组件字段里。这样你可以设计多个星球预设:巨型气态行星、小型岩石行星、冰封卫星等,通过切换配置文件快速生成不同关卡。
9.2 业务逻辑与 Mesh 分离
Mesh 只是为了可视化,真正的关卡数据应该存在于Dictionary<(int, int), HexCell>中。单位移动、战斗、资源采集、建筑放置等逻辑都不要直接依赖Vector3,而是通过Q/R坐标计算。这样当网格分辨率变化时,玩法逻辑不用重写。
9.3 性能优化
- 减少 Mesh 顶点数:原型阶段每个六边形生成 7 个顶点,但相邻六边形没有共享顶点,造成大量冗余。生产版本应构建共享顶点的网格,把顶点数从
N * 7降到接近N * 3。 - 合并 Mesh:南北半球各一个 Mesh 已经比每个六边形一个 Mesh 省很多 Draw Call,但还是可以把两半合并成一个
Mesh。 - 使用对象池:如果玩家在星球表面不断放置建筑和特效,不要频繁实例化和销毁对象,使用对象池管理。
- LOD 层级:距离摄像机较远的六边形可以使用更粗糙的 Mesh,降低 GPU 压力。
- 多线程生成:
Mesh不能在子线程直接创建,但可以在子线程计算顶点数据和三角形索引,拿到主线程后再构建Mesh。
9.4 随机种子与可复现性
所有随机逻辑必须围绕seed展开。不要使用UnityEngine.Random的全局状态,推荐使用System.Random(seed)生成独立随机流。这样同一个seed在任何机器上都会生成完全相同的星球,方便测试、回放和玩家分享星球 ID。
9.5 极区与五边形问题
严格的几何学告诉我们:用纯六边形无缝覆盖球面是不可能的,任何球面只要由正多边形组成,至少要引入 12 个五边形。本文通过南北半球拼接和圆盘映射,在视觉上掩盖了极区问题。如果项目需要严格无缝的棋盘,可以研究“细分的二十面体(Geodesic Grid)”:把二十面体每个三角面细分,再把三角形顶点连接成六边形,最后自然出现 12 个五边形对应二十面体的 12 个顶点。这套方案是生产级六边形星球的核心算法。
9.6 安全与备份
生成器脚本中如果有DestroyImmediate、批量删除或大规模修改场景的操作,一定要在编辑器操作前备份场景,至少做到可撤销。生产环境中的生成任务建议放到独立流程中运行,并输出生成日志,方便排查。
10. 总结与学习路线
通过这篇文章,你已经亲手实现了一条完整的六边形行星生成链路:从轴向坐标的数学推导,到 Unity 中的六边形 Mesh 生成,再到球面映射和噪声地形,最后还能把格子数据接到关卡设计和 A* 寻路中。同时你学会了如何在 Blender 中使用 Python 批量创建六边形 Tile,并按规范导入 Unity。
如果继续深挖,建议按这个顺序进阶:
- 掌握立方体坐标与轴向坐标的完整换算,重点理解
x + y + z = 0约束; - 研究二十面体细分,生成真正无缝的六边形球面;
- 引入真正的 3D 噪声库(如 FastNoiseLite),替代叠加二维噪声的方案;
- 实现地块边框 Mesh,让六边形之间不再只是紧贴,而是有明确的边界高亮;
- 为生成的星球接入 ECS 或 Burst 编译器,提升大规模格子的生成性能。
这套引擎既可以作为独立技术 Demo 继续打磨,也可以直接作为策略游戏、模拟经营或太空探索项目的地图基础。动手做一颗属于你自己的六边形星球,远比停留在纸面上更有成就感。如果过程中遇到新的问题,欢迎带着报错信息回来继续讨论。