尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

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

Unity六边形行星生成器教程:从网格到球面地形与寻路 分享一套从零实现“六边形行星生成器引擎”的完整教程覆盖 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 版本都完全兼容。工具建议版本用途Unity2021 LTS 或 2022 LTS任意 3D 模板运行 C# 网格生成与球面渲染Blender3.x 或 4.x制作六边形 Tile 模型、导出 FBXC#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.prefabBlender 导出后放入如果你使用的是更高版本的 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 个顶点坐标本地坐标Y0 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|是否大于 Nfor (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 baseIndex6 个角顶点index baseIndex 1 ... baseIndex 6三角形顺序中心点、第 i 个角点、第 i 1 个角点循环代码实现如下private void AddHex(Vector3 center, Vector3[] corners, ListVector3 vertices, Listint 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() { ListVector3 vertices new ListVector3(); Listint triangles new Listint(); 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(); GetComponentMeshFilter().sharedMesh mesh; } private void AddHex(Vector3 center, Vector3[] corners, ListVector3 vertices, Listint 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/VertexColorUnlitShader Custom/VertexColorUnlit { Properties { } SubShader { Tags { RenderTypeOpaque } 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); } } ListVector3 planeVerts new ListVector3(); Listint planeTris new Listint(); 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, ListVector3 vertices, Listint 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, ListVector3 srcVerts, Listint srcTris, bool north) { System.Random rand new System.Random(seed); ListVector3 vertices new ListVector3(srcVerts.Count); ListColor colors new ListColor(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)); } // 南半球的三角形绕序需要反转 Listint triangles new Listint(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.AddComponentMeshFilter(); mf.sharedMesh mesh; MeshRenderer mr go.AddComponentMeshRenderer(); 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(actionSELECT) bpy.ops.object.delete(use_globalFalse) def create_hex_tile(radius0.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(radius0.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 ListHexCell GetNeighbors(HexCell cell, Dictionary(int, int), HexCell grid) { ListHexCell neighbors new ListHexCell(); 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 ListHexCell AStar(HexCell start, HexCell goal, Dictionary(int, int), HexCell grid) { ListHexCell open new ListHexCell(); 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 ListHexCell ReconstructPath( Dictionary(int, int), (int, int) cameFrom, HexCell current ) { ListHexCell path new ListHexCell(); 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 继续打磨也可以直接作为策略游戏、模拟经营或太空探索项目的地图基础。动手做一颗属于你自己的六边形星球远比停留在纸面上更有成就感。如果过程中遇到新的问题欢迎带着报错信息回来继续讨论。
返回列表