如果你也想用 Unity 和 Blender 做一个程序化星球生成器,目标又希望它看起来像《文明》那样,在六边形格子上管理城市、军队和资源,你会发现,真正挡住你的往往不是某个高深的图形学算法,而是如何把一堆互不相关的小功能拼成一个真正可用的世界引擎。
我最早做原型时也经历过类似的阶段:星球生成脚本跑了一遍,十秒钟后屏幕上出现一颗带噪点颜色的球体,很好看。接下来问题立刻来了:这个球体上的某个区域到底属于哪一格?两格之间是真正意义上的相邻关系,还是仅仅在贴图上挨着?想改某处地形,鼠标点下去,如何精确知道改的是哪一块?如果场景里已经根据旧参数生成了几千个物体,新参数一来,怎么重新生成?
所以我的第一个判断很明确:这种项目不要从“先把一个球面六边形网格建出来”开始,而应该从“先做出一个能看、能选、能改地形的最小闭环”开始,再逐步把这个闭环扩成一套可配置、可复现、可维护的生成系统。程序化星球生成器的难点不是生成瞬间,而是生成之后的互动与迭代。
1. 先定位清楚:你要的是“六边形棋盘星球”,还是“真实球面六边形网格”
1.1 “全六边形球面”本身就是数学陷阱
很多人浏览过大量 unity map、blender 星球建模的内容后,会产生一种想法:世界应该是一个球,球面上均匀铺满六边形,就像把《文明》的地图包在篮球上。
这个想法的第一个阻碍不是技术,而是几何约束。一个只由正六边形构成的封闭球面是不存在的。正六边形平铺只能填满平面或环面,球面如果想要用正六边形铺满,最后一定会出现五边形来收口。最典型的例子就是足球,它上面有 20 个正六边形和 12 个正五边形。
如果你要做“真实球面六边形引擎”,就必须引入五边形格子,并且为它处理邻接关系、移动寻路、地块渲染、地形生成等整套逻辑。这不是简单写一个网格生成器就能带过的,它是玩法系统的一部分。
1.2 三种常见方案的实际差异
在动手写代码前,我建议先选定一个长期方向。方向不同,后面的数据结构和渲染方案完全不同。
| 方案 | 玩法格子系统 | 视觉效果 | 实现成本 | 适合场景 |
|---|---|---|---|---|
| 平面六边形地图 | axial 坐标,成熟稳定 | 外观不做成球体,或做成一块块大陆面板 | 最低 | 快速验证玩法,策略回合制、经营建造 |
| 圆柱卷轴地图 | 仍然平面格子,但左右边界相连可以环绕 | 远端看起来像围绕一周的星球带 | 中等 | 想强调“围绕星球扩张”的战略游戏 |
| 真实球面细分网格 | 二十面体/球面细分混合五边形六边形 | 真正可旋转的星球,南北极可见 | 较高 | 太空文明、真实星球探索、全局世界 |
如果你的最终目标是做出一个类似《文明》的策略世界,那么第一版完全可以用平面六边形网格跑通所有规则。你想模拟“同一个文明在不同大陆上发展”的感觉,可以用多块分开的大陆地图。
只有当核心玩法真的需要“站在太空中看到一个完整星球、绕飞、点击任意经纬度地块”时,再切换到真实球面方案,才是合理的顺序。
1.3 “生成星球”不等于“生成一张贴图”
看到很多“程序化星球生成”的演示视频,大部分只是用 noise texture 生成了一张星球表面的颜色贴图。它满足视觉,不满足交互。
真正能称为“六边形世界引擎”的系统,必须让渲染层和逻辑层分离。你可以先有一个球体作为可视化壳,但你要管理的是一组离散格子数据。鼠标点击球面某个位置,系统要能把屏幕坐标转成一条射线,再换算到格子数据,最后才能判断当前点到了哪一块地块上。
反过来,如果只做一个好看的球体,不管噪声参数怎么调,都无法跟玩法产生关系,那它还不是“引擎”。
2. 程序化星球地形不是“画地形”,而是在制作数值表格
2.1 从二维噪声到球面噪声,是一道真正的分界线
Unity 里最容易接触到的噪声是Mathf.PerlinNoise,它是二维输入、一维输出。做平面地图时,直接把格子坐标映射进去很方便。
但做球面时,同一个格子坐标如果来自经度、纬度展开,最终会在南北极产生明显的拉伸和接缝。所以实际项目里更推荐用三维噪声:把格子的中心方向向量或者世界坐标作为输入,采样结果作为高度。它绕开了 UV 展开问题,也更符合“星球表面”的直觉。
有 2D 噪声经验并不是无用功,因为在项目初期你仍然可以用二维噪声理解频率、振幅、倍频、阈值这些核心概念。只是正式做星球时,要尽早迁移到三维噪声或基于网格顶点的伪随机方案。
// 这是一种通用采样思路,不能直接复制到项目里跑 // 需要先实现三维噪声函数,或者引入成熟的噪声库 Vector3 samplePoint = cellDirection * heightFrequency; float height = Noise3D(samplePoint); float elevation = Mathf.Clamp01((height + 1f) * 0.5f);这里的要点是:不要一上来用一个平面 tile 的 x/y 坐标去采样一个二维噪声,然后把结果硬贴到球面。短时间看着能用,纬度一高马上出问题。
2.2 用多因子判定地块类型,而不只是“高度值”
一个星球如果只靠高度区分海洋和陆地,会显得非常单薄。即使只是做早期原型,也建议至少让“海拔”“温度”“湿度”三个数值共同参与地块类型判定。
比如:
海水 = 海拔低于海平面阈值 海岸 = 海拔略高于海平面,且紧邻海洋 沙漠 = 温度高,湿度低,海拔中等 草原 = 温度适中,湿度适中,海拔中等 森林 = 湿度高,温度适中 雪原 = 温度很低 山脉 = 海拔很高这套判断可以放在一个独立服务类中,让生成的地块数据只保存最终枚举值,而不把整段判断逻辑塞进渲染代码。
从工程经验看,最值得投入的不是去调一个“看起来合理”的高度算法,而是先把数据层建好。以后每次调整都是改规则、看结果、再调参数。
2.3 渲染要分级和分层,不要把代码逻辑写死在 Shader 里
程序化生成能力越强,调试难度就越大。建议为每个显示模式准备一个开关:
- 根据海拔显示颜色
- 根据温度显示颜色
- 根据植被和地形类型显示颜色
- 根据势力范围显示颜色
这样你会发现,bug 很容易定位。如果某一块地形看起来完全错误,可以先切到海拔模式,判断是不是高度数据错了;再切到类型模式,判断是不是规则判断错了。
在 starter 阶段,用 MonoBehaviour 逐格修改 MeshRenderer 的材质颜色是可以的。一旦格子数量上到几千甚至几万,就要换成MaterialPropertyBlock或实例化绘制,避免生成大量材质实例。
3. Blender 在流程里到底负责哪一段
3.1 容易用反:拿 Blender 生成“整个星球”
Blender 是很强的建模工具。很多新手项目的问题是:先花一个月做一个看起来很漂亮的低多边形星球,又做了一批六边形地面贴图,然后导入 Unity,试图在上面跑玩法。
结果通常很尴尬:模型导入后单位不统一,贴图方向不对,地面并不能和逻辑格子一一对应。因为 Unity 里生成世界逻辑的脚本还停留在“放模型”阶段。
如果一个系统需要在运行时反复生成、销毁、改地形,那么你真正需要的不是“一个星球模型”,而是一组可以被程序化组合的资产模块。Blender 更适合做这些模块:独立的六边形地块、不同地形上的山脉岩石、建筑、单位、植被点等。
3.2 推荐资产管线
我建议采用一条稳定的 Blender 到 Unity 管线:
- 在 Blender 里先决定“标准六边形地块”的尺寸。比如中心到顶点半径是 1 米、2 米还是 5 米。所有后续地块装饰都围绕这个尺寸设计。
- 把物体原点放在地块中心,避免导出后偏移。
- 建模时统一采用公制单位。
- 导出为 FBX,导入 Unity 后检查导入比例。
- 在 Unity 中不要随意嵌套缩放父节点,尽量让 Prefab 的根节点缩放为 1。
Blender 默认是 Z-up,Unity 默认是 Y-up,但多数引擎导入器会自动转换轴。实际更常见的坑是:导出前原点没对齐,导致地块在运行时无法对齐到六边形格子的中心点。
3.3 如果不擅长建模,前期可以完全绕开它
如果你现在精力有限,连 Blender 建模都还没入门,前期完全可以不做模型。用 Unity 自带的 Cubes、Spheres、Cylinders 临时拼出建筑和单位,用不同颜色表示不同地块类型。先把玩法跑通,再做资产替换。
这不会影响“程序化星球生成器”的核心逻辑。真正影响项目的是数据结构和生成策略,而不是视觉素材。
4. 最小可行版本:动手跑通一个能看、能选、能改地形的六边形世界
4.1 先不要急着做一个真球面网格
这里是一句很具体的经验:第一版不要做“真实球面六边形网格”,建议先用轴向六边形网格做一个 80×50 的地图并跑通交互。然后再考虑把它映射成一个球。
因为在平面网格上,调试邻接关系、寻路、地块刷新成本都很低。真球面细分会让“屏幕上的坐标到底属于哪个格子”计算量大增,还会引入五边形特殊逻辑。如果玩法还没有确定,这会让项目卡住。
所以最小版本验收应该包含:
| 能力 | 怎么验证 |
|---|---|
| 生成一张六边形地图 | 能看见海洋、陆地、山地等不同类型格子的颜色区分 |
| 鼠标点击选中格子 | 被点击的格子高亮,并在 UI 上显示地块属性 |
| 修改地块类型 | 点击后把当前格子改成另一种地形,并立刻刷新显示 |
| 稳定邻接关系 | 打印相邻格子 ID,手动检查 A 的邻居中包含 B 时 B 是否也包含 A |
4.2 基础数据结构:别让所有逻辑都挂在 GameObject 上
许多新手会把每个格子物化成十几个 GameObject,然后把“地块类型”“海拔”都挂在 MonoBehaviour 字段上。短期可行,长期很痛。
更好的做法是维护一个独立的HexCell数据结构:
[System.Serializable] public class HexCell { public int id; public Vector3Int axialCoord; public Vector3 worldCenter; public float elevation; public float temperature; public float moisture; public TerrainType terrainType; public List<int> neighborIds = new List<int>(); }生成阶段只负责填充数据。渲染层读取数据后决定显示什么颜色、放什么模型、朝哪个方向。以后要做存档、网络同步、AI 寻路,都直接读这个数据表,比从场景里遍历对象高效得多。
4.3 一个能改变地形的命令链
有了数据结构之后,最小的互动闭环可以是:
public void ChangeTerrain(HexCell cell, TerrainType newType) { cell.terrainType = newType; rendererSystem.RefreshCell(cell.id); // 这里可以继续触发邻近格子的影响,例如把陆地变成海洋后要更新海岸 refreshNeighbors = true; }这个例子很小,但它奠定了后续一切扩展的基础。以后当你加入“地块产出”“区域控制”时,会发现操作的数据始终是格子,而不是“删除一个 GameObject、再新建一个 GameObject”。
4.4 摄像机和交互可以先做简单版
网上关于 unity 摄像机跟随的内容很多,容易让人误以为必须一开始就写一套复杂的 RTS 相机系统。
其实第一版只需要两种模式:
- 平面地图模式:斜俯视视角,可以用鼠标中键拖动,用滚轮缩放。
- 球面预览模式:相机始终看向某个球心,通过鼠标右键旋转相机。
建议先用简单的 orbit camera 脚本把交互跑通。等你确定玩法需要的是大量框选、行军、缩放边界,再扩展成策略游戏相机也不迟。
5. 躲不掉的坑和排查顺序:单位、接缝、邻接、加载
5.1 一条可复用的排查链路
遇到生成结果不对,不要直接改参数,先按固定顺序排查:
- 看现象:是形状错、颜色错、位置错,还是点击没反应?
- 看输入:噪声采样的坐标对不对?格子中心点有没有正确计算?
- 看环境:Unity 版本、shader、FBX 导入设置、坐标轴是否一致?
- 看参数:频率、缩放、阈值是否在合理范围?
- 看边界:你要做的东西,在当前方案里是不是本身就不支持?
这套顺序适合 Unity、Blender 协同开发中的大多数问题。
5.2 坑:Blender 和 Unity 的单位不一致
Blender 里 1 个单位可以当成 1 米,Unity 中 1 unit 也默认约等于 1 米。问题通常出在建模时:
- 你在 Blender 里做了一个半径 1 的球体,却忘了应用缩放。
- 你为了看清细节,把物体放大了 100 倍。
- 你导出的是 1 米尺寸,但 Unity 导入时模型缩放被改成了 0.01 或 100。
遇到模型大小不符合预期时,先检查两处:Blender 的Apply All Transforms是否做过了;Unity Import Settings 里的 Scale Factor 是否一致。不要盲目在场景里缩放 GameObject,否则后续做格子对齐时会越来越乱。
5.3 坑:二维噪声直接做球面
用Mathf.PerlinNoise(latitude * freq, longitude * freq)生成球面高度是标准反例。经度是周期性的,纬度的线性采样会在地图两端产生一条越来越窄的搜索区域。靠近极点处会出现明显的扭曲和拉伸。
如果你只是临时看一眼效果,可以用;但如果已经准备作为玩法地图数据,请尽快换成球面方向采样或真正的三维噪声实现。
5.4 坑:格子看起来相邻,但数据不相邻
平面六边形地图看似简单,但轴向坐标很容易算错边界。常见现象是:
- 地图最左边一格和最右边一格显示时挨着,但邻接关系里没有对方。
- 换行时偶数行和奇数行的 x 偏移漏加,导致某一对格子互相认为不相邻。
建议把“格子的邻居枚举”集中到一个地方计算,不要在每个业务脚本里自己加一点。生成完地图后可以加一个断言:遍历每个格子,检查neighborIds是否双向连通,不一致直接抛日志。
5.5 坑:一次性实例化太多地块
很多初学者会写一个 for 循环,在地图上实例化几千个六边形 mesh。如果是 30×20 可能还好,一旦是 200×100 的星球,场景树、CPU 和显存都会崩。
工程化做法是分区块异步加载:只渲染视口附近的地块,远处格子只保留数据。如果只是调试显示,也可以用Graphics.DrawMeshInstanced、GPU Instancing,而不是真正生成几千个 GameObject。
如果你在使用 Addressables 管理地块模型和建筑资产,要特别关注“资源释放”。常规做法是:每个生成区块持有一个AssetHandle列表,区块卸载时统一 Release。不要一边实例化资源一边到处Resources.Load,最后连对象都找不到谁加载的。
6. 从“生成一次”到“可复用引擎”:把临时脚本改造成可持续系统
6.1 用配置对象代替硬编码参数
程序化生成最忌讳的是,每次调参都要改代码、重新编译。更好的做法是把所有关键参数放到一个 ScriptableObject 配置里:
[CreateAssetMenu(menuName = "WorldGen/Config")] public class WorldGenConfig : ScriptableObject { public int seed; public int mapWidth; public int mapHeight; public float hexRadius; public float waterLevel; public float heightFrequency; public float heightAmplitude; public float temperatureFrequency; }这样你可以直接创建多个配置资产,例如“温和大陆”“群岛世界”“冰封星球”,在编辑器里快速切换比较。
6.2 相同种子 + 相同配置 = 相同世界
程序化生成器一旦可以复现,后续所有问题都更好排查。因为同样的 seed 永远生成同样结果,你就可以把一个错误的格子坐标提交给同事复现,而不是让对方重新调一遍参数。
种子可以是一开始随机生成,但生成后需要保存。每次正式生成前把 seed 写入日志,回放时只需重新输入。
这也有利于单元测试:给定一组固定输入,断言生成结果中海洋块数量、山脉数量,以及邻接关系是否合法。
6.3 模块分工要清晰
到了这一步,代码不建议再全部放在一个叫WorldGenerator的巨型 MonoBehaviour 里。
至少拆成两个层:
- 数据生成层:WorldGenerator 负责填数据,不直接实例化任何 Prefab。
- 表现层:WorldVisualizer 监听生成完成事件,再根据数据生成显示对象。
以后如果要做存档,存档的应当是 WorldData 数据,而不是保存场景里的所有 GameObject。如果以后要做网络同步,同步的也应当是数据变更,而不是把每个格子的 Transform 发出去。
6.4 让调试可视化成为开发工具的一部分
每个格子的海拔、温度、湿度、类型在运行中不容易看出来。建议保留一个“调试模式”开关,按一下 R 键就在所有格子上方显示一个 Debug Label,或者用不同颜色覆盖显示不同类型。
这个能力越早加越好。否则当格子数量增大,你会很难知道问题出在噪声采样、邻接计算、还是渲染坐标偏转。
结尾:别急着成为星球引擎,先跑通那个最小闭环
做 Unity 和 Blender 程序化星球生成,本质上是把一张手工设计的静态地图,变成一套随时可以重新生成、重新调整、反复复用的数据系统。
如果你决心走这条路,明天就把第一步定小一点:不需要直接做一个完整的《文明》世界,也不需要一上来就实现真正的球面六边形细分网格。先做一张只有几十个格子的六边形地图,让它能根据噪声生成地块类型,能点击选中,能手动改一格地形并刷新显示。整个闭环只有几百行代码,但它足以帮你打破“生成器=模型”的幻觉。
再往后,所有看似高级的星球功能,都是从这个最小闭环上长出来的。资源管理、建筑系统、AI 寻路、势力范围、球面显示,都是给同一套格子数据增加新的规则和视图。
在做程序化引擎这件事上,最后的差距通常不在美术有多漂亮,而在你能不能把生成过程变得可复现、可调节、可维护。这才是 Unity 和