news 2026/10/17 10:10:29

Unity六边形行星生成器教程:从网格到球面地形与寻路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity六边形行星生成器教程:从网格到球面地形与寻路

分享一套从零实现“六边形行星生成器引擎”的完整教程,覆盖 Unity 侧 C# 网格生成、球面映射、噪声地形,以及 Blender 侧六边形资源制作与导出。文章按“数学基础 → 代码实测 → 美术协作 → 关卡设计 → 排错 → 工程建议”的顺序展开,新手可以从零复现,有经验的开发者也可以直接拿走核心算法和排查清单。

1. 背景与核心概念

做策略游戏、模拟经营或者太空题材项目时,很多开发者会遇到同一个问题:如何在球面上放置规则格子。如果用常见的方形网格做星球,最直观的后果是两极区域格子严重变形,贴图拉伸,单位寻路也不自然。而使用六边形网格,可以让整个星球表面看起来更均匀,也更接近“蜂巢”“行星”“棋盘”这类视觉语言。

六边形网格并不是一个新概念,桌游和策略游戏里已经用了很多年。它的核心优势有两个:

  1. 邻居关系均匀:每个六边形只有 6 个相邻格子,所有邻居到中心的距离基本一致,路径计算和范围判定都比方形网格简单。
  2. 视觉友好:六边形拼合起来没有方形网格那种明显的“十字缝”,在低多边形风格里非常好看。

所谓“行星生成器引擎”,本质上是把程序化生成(Procedural Generation)和六边形网格结合到一起:先用数学方法生成一套完整的六边形网格坐标,再把平面网格映射到球面上,最后用噪声函数决定地形高度,从而得到一颗可以反复随机生成的“程序化星球”。

本文的路线如下:

  • 在 Unity 中用 C# 从零生成六边形网格 Mesh;
  • 把平面六边形网格映射为球面,并用噪声生成山脉、海洋、雪地;
  • 在 Blender 中制作六边形美术 Tile 并导出供项目使用;
  • 把生成的星球接入关卡设计逻辑,包括地形类型、邻居查找和 A* 寻路;
  • 最后整理高频排错方案和工程化建议。

如果你是第一次接触程序化生成,这篇文章可以帮你打通“数学坐标 → C# 三角网格 → 球面投影 → 游戏数据”这条完整链路。如果你已经能写出基础的 Mesh 生成逻辑,重点可以放在第 5、7、8、9 节。

2. 环境准备与版本说明

先说明一下,本文不会绑定某个具体 Unity 小版本,因为 Mesh 生成和 Shader 代码属于基础 API,近几个 LTS 版本都完全兼容。

工具建议版本用途
Unity2021 LTS 或 2022 LTS,任意 3D 模板运行 C# 网格生成与球面渲染
Blender3.x 或 4.x制作六边形 Tile 模型、导出 FBX
C#Unity 内置,支持 C# 8 语法即可核心代码
ShaderUnity 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 默认对三角形做背面剔除,如果顶点顺序朝向相反,网格会显示为黑色。解决方式有两种:

  1. 在 Shader 中设置Cull Off,强制双面渲染;
  2. 生成南半球时交换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。

如果继续深挖,建议按这个顺序进阶:

  1. 掌握立方体坐标与轴向坐标的完整换算,重点理解x + y + z = 0约束;
  2. 研究二十面体细分,生成真正无缝的六边形球面;
  3. 引入真正的 3D 噪声库(如 FastNoiseLite),替代叠加二维噪声的方案;
  4. 实现地块边框 Mesh,让六边形之间不再只是紧贴,而是有明确的边界高亮;
  5. 为生成的星球接入 ECS 或 Burst 编译器,提升大规模格子的生成性能。

这套引擎既可以作为独立技术 Demo 继续打磨,也可以直接作为策略游戏、模拟经营或太空探索项目的地图基础。动手做一颗属于你自己的六边形星球,远比停留在纸面上更有成就感。如果过程中遇到新的问题,欢迎带着报错信息回来继续讨论。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 10:07:57

RoboCup 3D仿真平台Ubuntu安装实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 10:06:45

Linux MMC core层深度解析:SD卡识别与调试实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 10:06:07

OpenVINO GPU推理依赖OpenCL:跨平台安装配置与验证指南

1. 为什么装OpenVINO时要单独折腾OpenCL先把这个事儿说透&#xff1a;OpenVINO的GPU推理路径依赖OpenCL&#xff0c;不是可选项&#xff0c;是硬前提。很多人在装OpenVINO的时候&#xff0c;以为装完Python的openvino包就万事大吉&#xff0c;结果一跑device_name "GPU&q…

作者头像 李华
网站建设 2026/10/17 10:05:14

一文搞懂ARM通信协议:串并行、同步异步、单工双工

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 10:03:53

聚类算法实战指南:无监督学习原理、选型与工程落地

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华