ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Unity和Blender的六边形行星生成器:网格、地形与关卡设计全解析

基于Unity和Blender的六边形行星生成器:网格、地形与关卡设计全解析

做太空题材或策略游戏时,程序化生成行星几乎是必答题。这篇文章要拆解的,是基于 Unity 和 Blender 构建一套六边形行星生成器引擎的完整技术路线:用 C# 在 Unity 里生成六边形球面网格,用噪声算法做出山脉、海洋和生态分布,再用 Blender 配合制作面板资产、烘焙贴图和材质预览。整个过程不仅覆盖网格生成,还会落到关卡设计:六边形网格上的寻路、地块类型、批量生成多个行星,以及运行时性能怎么观察、怎么优化。

文章会先讲清楚“为什么是六边形”,再给六边形球面网格的算法基础,然后分别拆解 Unity 侧脚本与 Blender 侧资产流程。后面补上噪声地形、关卡设计落地的具体写法、性能观察和排查清单。适合正在做星球探索、太空策略、RTS 或回合制品类,又不想靠手工摆地形,想用程序化管线批量产出星球场景的开发者。即使你只是对六边形网格算法感兴趣,这篇也值得收藏后慢慢看。

项目本身不是某个仓库名,而是一套完整的程序化内容生成(PCG)技术方案。你不需要第三方收费插件,用 Unity 的 Mesh API、C# 基础和 Blender 的几何节点就能把主体跑起来。这也就意味着,自己动手改造的余量非常大,从地形风格到玩法规则全部可控。

1. 核心概念与技术选型

1.1 什么是六边形行星生成器

六边形行星生成器,顾名思义,是把一颗星球从“球体几何”到“地形高度”再到“地块属性”都交给程序生成的引擎工具。它不是简单地在球上贴一张高度图,而是先把球面拆成紧凑的六边形网格,然后对每个六边形地块做高度偏移、生态类型、资源分布和颜色控制。最终得到的不是一张贴图,而是一套可以被游戏逻辑查询和修改的数据结构。

这和传统的 Quad Sphere 方案有本质区别。四边形球面方案在极点区域会有严重的 UV 拉伸,而且网格拓扑结构不够均匀;六边形网格有更好的拓扑表示,地块之间共享边而不是共享角,非常适合策略游戏里的区域操作、移动消耗计算和势力划分。

1.2 Unity 与 Blender 的分工

这套架构里,Unity 和 Blender 不是二选一的关系,而是各管一段:

阶段工具职责
运行时网格生成Unity生成二十面体、细分、转对偶网格、采样噪声、输出 Mesh
地形与生态逻辑Unity根据噪声和温度/湿度信息设置地块类型、资源权重
面板资产制作Blender制作六边形面板、岩壁、建筑底座的低模资产,预览配色
贴图与光照烘焙Blender烘焙 AO、法线贴图,导出 FBX/Prefab 到 Unity
批量生成与配置Unity使用 ScriptableObject 管理参数,批量生成多个行星

简单理解:Blender 负责“把单块六边形地板做得好看”,Unity 负责“把几千上万个六边形地板拼成一颗星球,并且能响应游戏玩法”。

1.3 为什么是六边形网格而不是四边形网格

六边形网格在关卡设计里的优势非常明显:

  • 每个格子有 6 个邻居,移动路径比四边形更自然,转弯角度更平滑。
  • 相邻地块共享整条边,适合做边界判定、区域接壤、战争迷雾扩散。
  • 六边形中心到每个邻居中心的距离一致,不会出现四边形里“斜角距离更远”的问题。
  • 在球面上,六边形网格比矩形网格更接近等距分布,极点畸变更小。

代价也很直接:六边形网格的顶点和三角形关系更复杂,UV 展开困难,而且球面上无法做到全部是六边形,一定会混入五边形。这一点后面会详细讲。

2. 六边形球面网格的算法基础

2.1 从二十面体开始:为什么用二十面体

要在球面上生成尽量均匀的六边形网格,最常见的做法是先建一个二十面体(Icosphere),然后不断细分,最后做对偶网格转换。

二十面体有 20 个三角形面、30 条边、12 个顶点。它的顶点均匀分布在球面上,是所有多面体里最适合做球形细分的基础形状。

初始顶点可以用黄金比例计算:

float phi = (1f + Mathf.Sqrt(5f)) * 0.5f; Vector3[] vertices = new Vector3[12] { new Vector3(-1, phi, 0), new Vector3( 1, phi, 0), new Vector3(-1, -phi, 0), new Vector3( 1, -phi, 0), new Vector3( 0, -1, phi), new Vector3( 0, 1, phi), new Vector3( 0, -1, -phi), new Vector3( 0, 1, -phi), new Vector3( phi, 0, -1), new Vector3( phi, 0, 1), new Vector3(-phi, 0, -1), new Vector3(-phi, 0, 1) };

这些顶点需要归一化到单位球面上,然后按二十面体的固定索引表组成 20 个三角形。这里的索引表是一组公开的标准几何数据,在 Unity 里可以直接存入int[] triangles。

2.2 细分:把三角形变成更细的网格

细分的核心逻辑是:对每一条边插入中点,然后把一个三角形拆成四个小三角形。关键点是边的中点不能重复创建,因为相邻三角形共享同一条边。

Dictionary<long, int> midpointCache = new Dictionary<long, int>(); int GetMidpointIndex(int a, int b) { long key = a < b ? (a << 16) + b : (b << 16) + a; if (midpointCache.TryGetValue(key, out int index)) return index; Vector3 mid = (vertices[a] + vertices[b]) * 0.5f; mid.Normalize(); index = vertices.Count; vertices.Add(mid); midpointCache[key] = index; return index; }

一次细分后,三角形数量从 20 变为 80。做 3 次细分,三角形数量约为 1280,六边形地块数量在几百到一千左右,已经可以看清星球轮廓;做 5 次细分,三角形数量约 20480,地块数接近一万一,适合正式关卡。细分次数每增加一次,三角形数翻四倍,所以性能瓶颈会非常快地出现。

2.3 对偶网格:三角形转六边形

三角形网格本身不是六边形。要把三角形网格变成六边形网格,需要做“对偶”操作:对原始网格中的每一个顶点,收集它周围的所有三角形,把这些三角形的质心按顺序连接成一个多边形。

这个操作在任意球面上都会产生两种面:

  • 原始顶点连接 6 个三角形时,得到六边形。
  • 原始顶点连接 5 个三角形时,得到五边形。

这里必须强调一个数学事实:一个封闭的球面网格,不可能全部由六边形构成。根据欧拉公式,球面上至少要有 12 个五边形顶点。这 12 个五边形在二十面体的 12 个原始顶点位置附近。它们不是 bug,而是球面几何的必然结果。处理得好,它们可以变成地表建筑的锚点,或者资源富集区,让关卡更有记忆点。

对偶转换的示意代码:

Dictionary<int, List<int>> vertexTriangles = new Dictionary<int, List<int>>(); for (int i = 0; i < triangles.Count; i += 3) { int a = triangles[i]; int b = triangles[i + 1]; int c = triangles[i + 2]; AddToDict(vertexTriangles, a, i / 3); AddToDict(vertexTriangles, b, i / 3); AddToDict(vertexTriangles, c, i / 3); } List<Vector3> dualVertices = new List<Vector3>(); List<int> dualTriangles = new List<int>(); foreach (KeyValuePair<int, List<int>> pair in vertexTriangles) { List<int> incident = pair.Value; Vector3 center = Vector3.zero; foreach (int triIndex in incident) { Vector3 v0 = vertices[triangles[triIndex * 3]]; Vector3 v1 = vertices[triangles[triIndex * 3 + 1]]; Vector3 v2 = vertices[triangles[triIndex * 3 + 2]]; center += (v0 + v1 + v2) / 3f; } center /= incident.Count; center.Normalize(); dualVertices.Add(center); }

这里最需要小心的坑是:入射三角形的质心顺序不保证是绕顶点一圈排列的,直接连多边形会出现面片交叉。正确做法是先按质心相对顶点的方位角排序,再生成三角形扇。这一步处理不好,生成的行星表面会产生大量扭曲三角形,肉眼看起来就像噪声糊在球面上。

2.4 噪声采样的坐标系选择

六边形球面网格生成后,要给每个地块计算海拔。常见的错误是在纬度/经度坐标系里采样二维噪声,再映射到球面,这样极区会有严重的拉伸和重复。正确做法是直接用三维噪声采样:把六边形顶点的归一化方向向量当作采样坐标。

三维 Perlin/Simplex 噪声天然没有接缝,采样点在球面均匀分布时,生成的噪声场连续且方向无关。如果需要山脊效果,可以用abs()扭曲噪声值;需要海洋和大陆,可以用频率更低的多频叠加。

3. 环境准备与前置条件

一套完整的开发环境大致如下,具体版本建议以你本机的 Unity 和 Blender 版本为准,不强制踩新版本:

工具用途建议版本
Unity运行时行星生成、C# 逻辑2021 LTS 或更高版本
Blender六边形资产建模、烘焙贴图3.6 LTS 或更高版本
C# IDE脚本编写与调试Visual Studio / Rider
版本控制管理代码和场景文件Git + Git LFS

配置页面建议开启内置渲染管线的 URP,方便后处理效果;如果只做纯数据生成,不依赖渲染效果,内置渲染管线也完全够用。

启动工程前检查以下几点:

  • Unity 已安装对应模块,能创建 3D 项目。
  • Blender 能打开几何节点工作区。
  • 磁盘预留足够空间,Unity 烘焙光照贴图和 Blender 导出纹理都需要临时空间。
  • Git LFS 提前配置,避免 FBX 和纹理文件损坏。

4. 在 Unity 中实现六边形行星生成器核心

4.1 项目结构与脚本规划

建议把生成器代码按职责拆成四个脚本:

脚本职责
IcosahedronBuilder.cs生成二十面体顶点和三角形
SphereSubdivider.cs细分三角形,维护边中点缓存
DualMeshConverter.cs对偶网格转换,输出多边形面
PlanetMeshGenerator.cs高层入口,串联全部流程,赋值 MeshFilter/MeshCollider

再加一个数据配置类PlanetConfig,用 ScriptableObject 保存细分次数、噪声参数、颜色梯度、高度缩放。这样后续生成多个行星只需要创建多个配置文件,不需要改代码。

4.2 构建二十面体与细分管线

在PlanetMeshGenerator的入口处,把整条流程按顺序调用:

public Mesh BuildPlanetMesh(PlanetConfig config) { IcosahedronBuilder builder = new IcosahedronBuilder(); builder.Build(); SphereSubdivider subdivider = new SphereSubdivider(builder.Vertices, builder.Triangles); for (int i = 0; i < config.SubdivisionLevels; i++) { subdivider.SubdivideOnce(); } DualMeshConverter dual = new DualMeshConverter( subdivider.Vertices, subdivider.Triangles ); dual.Convert(); ApplyNoiseElevation(dual.Vertices, config); Mesh mesh = BuildMeshFrom(dual); return mesh; }

命名和接口可以按你的工程风格调整,但这个流程本身是稳定的:二十面体 → 细分 → 对偶 → 噪声位移 → 生成 Mesh。

4.3 地形高度与多频噪声

对每个对偶顶点,需要把它的方向向量作为三轴噪声采样坐标。多频叠加的做法如下:

float SampleElevation(Vector3 point, PlanetConfig config) { float value = 0f; float amplitude = 1f; float frequency = 1f; for (int octave = 0; octave < config.Octaves; octave++) { float noise = Mathf.PerlinNoise( point.x * frequency + config.SeedOffset, point.y * frequency + config.SeedOffset ); // 这里是 2D 噪声简化示例,生产环境建议用 3D Simplex value += noise * amplitude; amplitude *= config.Persistence; frequency *= config.Lacunarity; } return value; }

注意:上面示例用 2D Perlin 只是为了展示叠加思路。真正的球面采样强烈建议用 3D Simplex 或 OpenSimplex,避免极点接缝。Unity 内置的Mathf.PerlinNoise是 2D API,直接用它做球面地形会有明显缺陷。更好的选择是引入 LibNoise.Unity 或自己写一个 3D Simplex 实现。

采样后,把高度作用到顶点位置:

Vector3 dir = dualVertices[i].normalized; dualVertices[i] = dir * (baseRadius + elevation * heightScale);

生成 Mesh 时,法线可以直接使用dir方向,因为高度只改变半径,不改变方位。

4.4 顶点色与地块材质

六边形行星不能只有高度。按高度和噪声分配顶点色,可以让海洋、沙滩、平原、山脉、雪峰一目了然。

Color EvaluateColor(float elevation, float moisture) { if (elevation < config.SeaLevel) return config.WaterGradient.Evaluate(elevation / config.SeaLevel); if (elevation < config.BeachLevel) return Color.Lerp(config.SandColor, config.GrassColor, (elevation - config.SeaLevel) / (config.BeachLevel - config.SeaLevel)); return config.MountainGradient.Evaluate((elevation - config.BeachLevel) / (config.MaxElevation - config.BeachLevel)); }

这只是一个示例规则。你可以在 Actual 开发里加入温度带、湿度噪声、轴倾斜导致的气候带,把行星生成器从“地形工具”升级为“生态工具”。

5. Blender 侧资产准备与流程配合

5.1 使用几何节点生成六边形面板

Blender 的几何节点非常适合快速制作六边形面板的基础资产。思路是新建一个Grid节点,再通过Dual Mesh节点得到六边形排列,然后挤出厚度;或者直接使用Cylinder节点并设置顶点数为 6,得到单个六边形柱子底座。

如果你想做更复杂的星球表面建筑基地,可以用几何节点构建一个六边形瓦片的集合:面板中心、边缘凸起、四角铆钉。这样每次修改都不会破坏基础拓扑。

5.2 导出 FBX 和贴图的注意点

  • 导出前把 Blender 单位设置成米,和 Unity 的默认单位一致。
  • 贴图命名不能用中文,避免 Unity 导入时出现路径告警。
  • 如果面板资产带倒角,烘焙 AO 后在 Unity 里看起来体积感会强很多。
  • 法线贴图需要选择“Tangent Space”导出,直接导出 Object Space 法线会在 Unity 里出现方向错乱。
  • 在 Blender 里先清掉未使用的材质球,再导出 FBX,可以减小工程体积。

5.3 与 Unity 脚本配合的资产管线

Blender 做好的单个六边形面板导入 Unity 后,不要直接把它当成地面格子逐块摆。行星运行时生成的网格数量非常大,用 Prefab 逐块实例化会带来巨大的 Draw Call 压力。

正确做法是:把 Blender 面板作为“视觉母版”,在 Unity 里用 Mesh 合并或 GPU Instancing 把相同的面板按地块位置批量实例化;或者只把 Blender 面板的高模和贴图作为烘焙参考,运行时使用 Unity 生成的低模六边形网格,配合顶点色和 Triplanar 贴图采样。

简单说:Blender 负责“长什么样”,Unity 负责“怎么快速铺满星球”。

6. 关卡设计:六边形行星上的玩法落地

6.1 六边形网格坐标模型

在地块逻辑层面,不要直接操作 Mesh 顶点。更好的做法是先建立一块数据网格,把每个地块的坐标、邻居、高度、生态类型、归属势力放在一个类里。比如使用 Cube Coordinates 或 Offset Coordinates,六边形寻路会非常顺手。

public struct HexCoord { public int X; public int Y; public int Z; public static List<HexCoord> Neighbors(HexCoord h) { return new List<HexCoord>() { new HexCoord { X = h.X + 1, Y = h.Y - 1, Z = h.Z }, new HexCoord { X = h.X + 1, Y = h.Y, Z = h.Z - 1 }, new HexCoord { X = h.X, Y = h.Y + 1, Z = h.Z - 1 }, new HexCoord { X = h.X - 1, Y = h.Y + 1, Z = h.Z }, new HexCoord { X = h.X - 1, Y = h.Y, Z = h.Z + 1 }, new HexCoord { X = h.X, Y = h.Y - 1, Z = h.Z + 1 } }; } }

这只是一个平面六边形的邻居模型。球形六边形网格的邻居关系不需要重新发明,把三角形网格对偶后得到的多边形面的“共享边关系”导出成一张邻接表即可。

6.2 地块类型与规则生成

地块类型可以按高度和湿度进行规则划分:

高度区间湿度条件地块类型
低于海平面任意海洋
海平面到沙滩线任意沙滩
沙滩线到中海拔高湿度森林
沙滩线到中海拔低湿度草原
高海拔任意山地
极高海拔任意雪峰

这张规则表可以写到PlanetConfig里,让策划在不改代码的前提下调权重。行星生成器从“纯技术工具”变成“可配置的关卡生产管线”,关键就在于把规则暴露到配置层。

6.3 批量生成多个行星与参数配置

用 ScriptableObject 管理配置后,批量生成多颗行星只需要遍历配置列表:

public List<PlanetData> GeneratePlanets(List<PlanetConfig> configs) { List<PlanetData> results = new List<PlanetData>(); foreach (PlanetConfig config in configs) { PlanetData data = new PlanetData(); data.Mesh = BuildPlanetMesh(config); data.BiomeMap = GenerateBiomeMap(data.Vertices, config); data.HexNodes = BuildHexNodesFromMesh(data.Mesh, config); results.Add(data); } return results; }

这里的关键点是:Mesh、BiomeMap、HexNodes 必须解耦。Mesh 只是渲染数据,BiomeMap 决定地块属性,HexNodes 才是玩法逻辑直接查询的对象。如果你让玩法逻辑直接依赖 Mesh 三角形索引,后面改分辨率或换网格算法时会非常痛苦。

6.4 五边形地块的特殊处理

前面提到球面必然有 12 个五边形。这些五边形的邻居只有 5 个,而不是 6 个。寻路时如果直接按六边形规则遍历邻居,五边形地块的其中一个访问方向会越界或缺失。

实际关卡设计里,可以把五边形做成“特殊节点”:

  • 资源高产区。
  • 副本入口。
  • 大型建筑的地基。
  • 行星传送门。

这样不仅规避了寻路问题,还能让玩家在星球上找到结构性地标。这是六边形行星生成器相较于四边形球的一个隐藏优点:天然拥有视觉奇观点。

7. 性能优化与资源占用观察

7.1 观察性能的几个维度

运行六边形行星生成器时,需要重点观察以下数据:

维度观察方式关注点
三角形数量Unity Profiler / Mesh 信息面板生成时间与渲染压力
内存占用Profiler Memory Module顶点数组、索引数组、邻接表占用
主线程耗时CPU Usage集中式循环是否卡顿
Draw CallFrame Debugger实例化是否合并
Mesh Collider 重建Profiler物理碰撞计算成本

注意:没有固定的“显存占用”数值可以套用,因为行星网格的渲染开销取决于三角形数量、材质复杂度和后处理特效。在低端设备上,建议控制细分次数,先用 3 次细分跑通逻辑,再逐步提高。

7.2 降低顶点数量的手段

  • 细分次数不要无脑调高。5 次细分已经是很多策略游戏的上限。
  • 对远离玩家的半球做简化,或者使用基于与相机距离的 LOD 切换。
  • 把相邻的同生态地块合并成一个 Mesh 子网格,减少渲染批次。
  • 六边形地板不需要每块都带大量顶点,平地区域可以展平,减少高度采样精度。

7.3 减少主线程压力

生成大量网格时,CPU 会停顿。减少卡顿的手段,按推荐顺序排列:

  • 把生成流程放到协程里,分帧执行。
  • 使用 Unity Job System,把噪声采样和顶点计算放到工作线程。
  • 用 Burst Compiler 编译高频循环。
  • 对 Mesh 的MarkDynamic()做必要设置,避免不必要的 CPU 与 GPU 间数据拷贝。

7.4 内存与资源释放

每次重新生成行星时,必须手动释放旧 Mesh 和旧邻接表:

if (existingMesh != null) { Destroy(existingMesh); existingMesh = null; }

这句话写不好,反复生成多颗行星后在 Profiler 里会看到网格内存居高不下。旧 Mesh 不会因为MeshFilter.mesh被替换就自动释放,需要手动销毁。

8. 常见问题与排查方法

问题现象可能原因排查方式解决方案
行星表面出现大量扭曲三角形对偶网格顶点未按环绕排序查看对偶网格的面索引顺序按质心方位角排序后生成三角扇
部分地块看起来是三角形而不是六边形细分次数不足,五边形占比过高统计地块边数分布增加细分次数,但保留对 12 个五边形的特殊处理
地形在极点区域出现拉伸或接缝使用了 2D 噪声采样球面坐标检查噪声采样输入向量改用 3D Simplex/OpenSimplex 噪声
生成后的网格背面朝外三角形环绕序反向在 Scene 视图检查法线方向反转三角形索引顺序或重新计算法线
重复生成多个行星后内存暴涨旧 Mesh 未销毁Profiler Memory 查看 Mesh 数量生成前销毁旧 Mesh 和邻接表
Mesh Collider 碰撞极不准确Collider 直接使用高密度 MeshPhysics Debug 视图为碰撞单独生成低密度 Mesh
Blender 导出的六边形资产在 Unity 中颜色发黑法线贴图空间不一致检查导入设置导出 Tangent Space 法线,并确认 Unity 导入参数
整体生成时间过长细分次数过高或主线程集中计算Profiler CPU Usage调低细分,改用 Job System
游戏逻辑找不到某个六边形地块的邻居邻接表未同步对偶网格拓扑检查五边形地块的邻居数量手动补邻居或把五边形设为特殊地块

9. 最佳实践与合规建议

9.1 工程组织建议

  • 把噪声采样、网格生成、地块逻辑分成三个独立程序集,方便单测。
  • 用 ScriptableObject 存放所有生成参数,禁止在代码里写死星球半径、噪声频率、颜色梯度。
  • 保持一套最小可运行配置,用于每次改动后回归测试。
  • 把输出行星的数据序列化为 JSON,便于调试和版本对比。
{ "planetName": "TestPlanet", "subdivisionLevel": 3, "baseRadius": 100, "seaLevel": 0.35, "octaves": 5, "seedOffset": 12345 }

这个 JSON 可以导出为配置文件,也可以用同一结构在 Unity 里生成ScriptableObject。策划和程序只要约定好字段名,就可以无痛协作。

9.2 测试与回归

第一次跑通后,先把细分次数设为 1、关闭噪声,确认球体成型;再把噪声打开,观察地形是否连续;最后加入地块类型和寻路,验证玩法数据。这个顺序能快速定位问题出在哪一层。

9.3 版权与合规提醒

  • 用 Blender 制作六边形资产时,如果参考了外部模型或纹理,确认授权范围能否用于商业项目。
  • AI 辅助生成地形或贴图素材时,检查生成模型的使用条款,避免商用版权纠纷。
  • 如果项目涉及真实星球数据或公开地图数据,确认数据来源的许可协议。
  • 游戏上线前,对资源、文本、音效做完整合规核查,程序化生成工具也要纳入版本归档。

10. 总结与后续扩展

这套六边形行星生成器引擎最值得尝试的点,是它把程序化几何、噪声地形、关卡设计和美术资产管线完整串联起来。你最先应该验证的,不是画面好看,而是“细分之后对偶网格有没有正确变成六边形”,以及“12 个五边形是否被妥善处理”。最容易踩坑的也恰好是这两个点:对偶网格的顶点排序,和五边形地块的寻路边界。

后续可以继续扩展的方向非常多:加上 Lod 调度,让行星可以从太空无缝切入地表;引入资源分布噪声,让采矿玩法有据可依;加上轴倾斜和气候带,把地形生成升级成生态生成。如果再把 Job System 和 Burst 编译器接进去,完全可以在中等配置的笔记本上批量生成几十颗可探索星球。

方案不复杂,先跑通一个小型六边形球,再做噪声、再做地块逻辑。建议收藏备用,等你把第一个会转的六边形星球跑出来,后面的扩张空间会非常开阔。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进