很多UE4开发者第一次做星球,第一反应是拉一个球体模型,或者用蓝图生成一个经纬球(UV Sphere),再往上叠地形噪声。我也这么干过,结果两极顶点挤成一团、三角形大小不一,想做六边形蜂窝风格更是无从下手。这篇文章要分享的是一种更扎实的做法:用PMC(Procedural Mesh Component)在运行期程序化生成戈德堡多面体(Goldberg Polyhedron),也就是俗称的六边形星球。它适合要做策略类六边形格子、低多边形星球、科幻蜂窝行星的项目,也适合想搞明白“球面网格到底怎么程序化生成”的客户端开发。
标题里的“戈德堡多面体”可能听着生僻,但它就是足球、高尔夫球凹坑和C60富勒烯背后的几何结构。它由六边形为主、恰好12个五边形组成,能够无扭曲地闭合在球面上。这篇文章会从数学原理一路讲到C++实现,以及我把整个流程跑通过程中踩的坑。读完你不仅能生成一颗干净的六边形星球,还能顺手搞定碰撞、UV、法线和性能优化的问题。
1. 为什么“纯六边形”永远铺不满一个球体
先聊一个经常被忽略的数学事实:六边形确实可以铺满平面,但铺不满球面。不信你拿纸剪一堆正六边形去糊一个气球,糊到一半就会发现总有缝隙,强制挤压就会产生褶皱。原因是欧拉公式。
1.1 欧拉公式和“12个五边形”的必然性
对任何闭合多面体,顶点数V、边数E、面数F满足: V - E + F = 2
假设一个闭合曲面完全由六边形铺成,每个面有6条边,每条边被两个面共享,所以6F = 2E,即E = 3F。每个顶点处恰好有3个面交汇(六边形网格的常见情形),每个面有6个顶点,所以6F = 3V,即V = 2F。代入欧拉公式: 2F - 3F + F = 0,根本不等于2。
结论是:纯六边形的闭合曲面在拓扑上不可能存在。要让公式成立,必须有少量其他多边形参与。经典的解法就是加入12个五边形。这个结论是硬性的:无论你怎么细分、尺寸如何变化,闭合曲面上的六边形球必须由“若干六边形 + 恰好12个五边形”构成。这就是戈德堡多面体的定义,也是足球为什么长那样的原因——足球上你看得见的每一个黑色五边形,背后都是这个拓扑在起作用。
1.2 游戏开发者为什么应该关心这件事
如果你只是做一个视觉上“有一点点六边形花纹”的球,确实可以靠贴图解决。但如果你要做的是《文明》那样的六边形策略格子,或者需要在星球表面放置六边形蜂窝地块,“地图”本身的网格结构就必须是戈德堡多面体,否则地块与地块之间会出现缝隙、重叠、邻居关系错乱。更实际的一个教训是:很多人的做法是拿平面六边形网格直接投影到球面上,结果靠近极点的地方六边形严重变形,看起来像被压缩过。而戈德堡多面体由正二十面体细分而来,天然把变形压力均摊给了所有面,每个六边形的大小和形状都相当接近,规则程度远超经纬球方案。
我第一次在项目里发现这个问题,是给一个星球做“格子占领”玩法。用经纬球的四边形格子做,两极地区方块根本没法布置。后来换成戈德堡多面体作为格子底层,占领区域才稳定下来。所以这篇文章会以戈德堡多面体作为唯一的几何基础。
2. 从二十面体出发:黄金比例与20个三角面
戈德堡多面体不用一块一块去拼六边形,它有标准的生产路线:先细分正二十面体(Icosahedron),再取对偶(Dual),就自然得到戈德堡多面体。二十面体是一个有12个顶点、20个全等正三角面的凸多面体,它的顶点坐标恰好和黄金比例有关系。
2.1 二十面体的12个顶点怎么算
设黄金比例φ = (1 + √5) / 2 ≈ 1.618034。二十面体的12个顶点由3组互相垂直的黄金矩形组成:
- (0, ±1, ±φ)
- (±1, ±φ, 0)
- (±φ, 0, ±1)
这12个点并不在单位球上,它们的模长都是√(1 + φ²),实际半径大约1.902。所以拿到坐标后需要normalize并乘以目标半径R,再作为球面顶点。这个操作很简单,但很多教程没讲清楚,导致有人直接用这些坐标,生成的多面体比预期大了快一倍。
如果按可复现的代码来组织,我建议把顶点和三角索引直接写死,而不是运行时用三角函数现算。我验证过的一组标准数据如下:
const float Phi = (1.0f + FMath::Sqrt(5.0f)) * 0.5f; TArray<FVector> IcoVerts = { FVector(0, -1, Phi), FVector(0, 1, Phi), FVector(0, -1, -Phi), FVector(0, 1, -Phi), FVector(-1, -Phi, 0), FVector(1, -Phi, 0), FVector(-1, Phi, 0), FVector(1, Phi, 0), FVector(-Phi, 0, -1), FVector(Phi, 0, -1), FVector(-Phi, 0, 1), FVector(Phi, 0, 1), }; TArray<FPolygon> IcoFaces = { {0, 11, 5}, {0, 5, 1}, {0, 1, 7}, {0, 7, 10}, {0, 10, 11}, {1, 5, 9}, {5, 11, 4}, {11, 10, 2}, {10, 7, 6}, {7, 1, 8}, {3, 9, 4}, {3, 4, 2}, {3, 2, 6}, {3, 6, 8}, {3, 8, 9}, {4, 9, 5}, {2, 4, 11}, {6, 7, 8}, {8, 1, 9}, {2, 11, 10} };2.2 为什么选二十面体而不是八面体/经纬球
因为二十面体是所有柏拉图立体里三角面最多、最接近球形的,细分后的三角形形状最均匀。用八面体也能做类似的事,但是八面体只有8个面,细分后靠近原来8个顶点的区域依然会有可见的聚集感。二十面体有20个面,每个面都足够小,细分之后的顶点分布已经非常接近理想球面。
选择它还有一个和戈德堡多面体直接相关的原因:二十面体的每个原顶点刚好有5条边相遇,所以在取对偶时,原来的12个顶点天然变成12个五边形;细分后新增的每个顶点都有6条边相遇,取对偶后变成六边形。于是“12个五边形 + 若干六边形”这个戈德堡结构不需要额外修正,自动成立。
3. PMC到底在做什么:顶点、索引、法线的三角关系不再神秘
在进入算法之前,必须把PMC的数据组织方式说明白。很多开发者第一次用Procedural Mesh Component时只知道往数组里塞数据,但遇到“面不显示”“面是黑的”“碰撞没有”的问题就卡住了。其实PMC的模型非常直接,理解了它,整个星球生成就是填数组的事。
3.1 CreateMeshSection一次调用干了什么
PMC的核心就是一个“网格数据集”容器,你不必创建Actor或者静态网格资源,运行期调用CreateMeshSection就能把CPU上的顶点数据交给渲染管线。最少需要提供两组数组:
- Vertices:顶点坐标列表,每个FVector表示一个顶点在世界空间下的相对位置。
- Triangles:三角形索引列表,每3个int32描述一个三角形。比如 {0,1,2} 表示第0号、第1号、第2号顶点构成一个三角形面。
除了这两个,还可以传法线(Normals)、UV0、顶点色(VertexColors)和切线(Tangents)。不传的话UE会帮你自动计算一部分,但生成的网格光照表现往往不理想,建议显式给法线。
ProcMesh->CreateMeshSection( 0, // Section Index Vertices, // TArray<FVector> Triangles, // TArray<int32> Normals, // TArray<FVector> UVs, // TArray<FVector2D> VertexColors, // TArray<FColor> Tangents, // TArray<FProcMeshTangent> false // bCreateCollision,先关掉,后面单独处理 );3.2 顶点环绕顺序:一个折腾了我两小时的细节
三角形索引的排列顺序决定了面法线的朝向。UE4使用左手坐标系,默认情况下,从正面看顶点按顺时针顺序排列,面就是可见的;反过来则是背向镜头、被剔除。我第一次生成二十面体时把索引顺序写反了,结果从球外面看是透明的,钻进球里才看到面。这个错误很典型。
经验法则:生成三角形时,先确定一个期望的外法线方向,然后用“顶点顺序从外部观察为顺时针”来核对。如果整个球面都反了,直接把所有三角形的第二个和第三个索引交换就行:
for (int32 i = 0; i + 2 < Triangles.Num(); i += 3) { Swap(Triangles[i + 1], Triangles[i + 2]); }3.3 为什么不用StaticMesh硬做
有些人会问:我直接在DCC软件里建好六边形星球模型,UE里加载StaticMesh不也行吗?可以,但那就失去了这篇博客的核心价值——程序化生成的意义在于“参数化”和“运行时动态控制”。细分等级可以随时改,星球半径可以随时调,碰撞体可以低精度,渲染可以高精度,这些用StaticMesh都得准备一堆模型资源。而PMC几十行代码就把这些问题解决了。
PMC的代价是:它不参与Nanite管线,不能自动烘焙LOD,频繁更新网格会有重建成本。这个在后面性能部分会详细说。
4. 核心算法拆解:细分二十面体,然后取对偶拿到六边形
现在到了整个项目最核心的算法部分。整个过程分三步:先把二十面体的每个大三角面细分成n²个小三角形,把小三角形顶点投影到球面,然后取对偶,让每个原顶点变成一个五边形/六边形面。
4.1 把一个三角面分成 n² 个小三角面
假设细分等级为Frequency,记作n。每个原始三角面会被分成n²个小三角形。比如n=1就是原始二十面体;n=2一个面分成4个小三角,整个球有80个小三角;n=3一个面分成9个小三角,整个球有180个小三角。
用重心坐标生成小三角形顶点是最稳的写法。对一个大三角形三个顶点A、B、C,遍历整数对(i, j),满足i ≥ 0、j ≥ 0、i + j ≤ n,则顶点坐标:
float Alpha = 1.0f - (float)(i + j) / n; float Beta = (float)i / n; float Gamma = (float)j / n; FVector V = Alpha * A + Beta * B + Gamma * C;遍历结束后会得到(n+1)(n+2)/2个顶点。然后按网格顺序生成小三角形索引:
for (int32 i = 0; i < n; ++i) { for (int32 j = 0; j + i < n; ++j) { int32 p0 = GetVertIndex(i, j); int32 p1 = GetVertIndex(i + 1, j); int32 p2 = GetVertIndex(i, j + 1); Triangles.Add(p0); Triangles.Add(p1); Triangles.Add(p2); if (i + j < n - 1) { int32 p3 = GetVertIndex(i + 1, j + 1); Triangles.Add(p1); Triangles.Add(p3); Triangles.Add(p2); } } }这里GetVertIndex需要你用一个二维索引到一维数组的映射表,或者用TMap<pair<int32,int32>, int32>来记录。
4.2 投影到球面:顶点全部归一化再乘半径
细分得到的顶点还是权重插值,不在球面上。要让网格贴合球面,每个顶点生成后都要“归一化”:FVector::Normalize,再乘目标半径R。这一步做完,三角网格已经是一个光滑的测地球体了。
有一个小技巧:细分时先做线性插值,再统一归一化,比每一步都归一化要准。因为每一步归一化会累积误差,最后有些顶点会略深略浅。先线性生成所有顶点,最后一次性归一化,网格整体更均匀。
4.3 取对偶:五边形和六边形怎么冒出来
先别急着输出三角形。现在你手里是一个完整的三角形网格,要得到戈德堡多面体,需要取这个三角网格的对偶图。
对偶的操作非常直观:把每个三角形面的重心作为一个新顶点;对每个原顶点,把围绕它的所有三角形的重心按角度连接成一个封闭多边形。原二十面体的12个顶点周围有5个三角形,所以形成五边形;细分新增的顶点周围有6个三角形,所以形成六边形。
代码层面最难的是“按角度排序”。实现思路:
- 先构建一个TMap,记录每个原顶点参与了哪些三角形。
- 对每个原顶点V,收集所有相关三角形重心CenterArray。
- 以V的归一化方向作为参考法线N,取一个参考向量Ref作为角度0,然后对每个重心C计算向量Dir = C - V,用atan2计算夹角。
- 按夹角从小到大排序,再把重心点依次连接成多边形,输出为三角扇。
这一步生成的每个“面”的顶点都是相邻三角形的重心,它们在几何上并不保证共面,尤其在球面上会有明显的起伏。但是别担心,视觉上这种起伏只要法线处理得当,几乎看不出来。真正的戈德堡多面体本来也是曲面化的,不是严格平面。
4.4 面数公式和不同细分等级的复杂度
把过程跑完以后,可以用公式快速估算规模:
- 细分三角网格顶点数:V = 10n² + 2
- 细分三角网格面数:F = 20n²
- 对偶后网格顶点数:F' = 20n²
- 对偶后网格面数:V' = 10n² + 2
在V'个面里,五边形永远是12个,六边形数量 = 10n² - 10。
| 细分等级 n | 三角网格三角形数 | 对偶后面数 | 其中六边形数 |
|---|---|---|---|
| 1 | 20 | 12 | 0 |
| 2 | 80 | 42 | 30 |
| 3 | 180 | 92 | 80 |
| 4 | 320 | 162 | 150 |
| 6 | 720 | 362 | 350 |
| 8 | 1280 | 642 | 630 |
对做游戏来说,n=4到n=8都是很合理的选择。n=4的网格有162个面,做策略游戏格子完全够用;n=8有642个面,适合地形更细腻的展示或视觉表现。
5. 实战代码:一个最小可复现的生成流程
我把整个生成流程整理成一个精简但完整的C++函数骨架。这里面包含了二十面体顶点初始化、细分、投影、查重合并顶点、取对偶和最终CreateMeshSection的调用。你可以直接抄到UE4的Actor里跑。
5.1 统一顶点查重:避免裂缝和黑点
细分过程中,相邻两个大三角面会在公共边上生成顶点。如果不做合并,同一个空间位置会有多个重复顶点,最终网格会出现裂缝、黑点或光照破面。最脏但有效的做法是:在生成每个细分顶点后,用TMap记录“坐标 → 索引”。为了避免浮点误差,我会把坐标量化到整数再查重:
uint64 MakeVertexKey(const FVector& V, float Scale = 10000.0f) { int32 X = FMath::RoundToInt(V.X * Scale); int32 Y = FMath::RoundToInt(V.Y * Scale); int32 Z = FMath::RoundToInt(V.Z * Scale); return (uint64)(uint32)X << 42 | (uint64)(uint32)Y << 21 | (uint64)(uint32)Z; }这个方案比我最早用的FVector作为Key要稳得多。FVector的哈希基于浮点,两个几乎相等的坐标可能算出不同哈希,合并失败率很高。用整数量化之后,合并效率很高,生成过程也不慢。
5.2 完整生成流程代码
void UMyPlanetGenerator::GenerateGoldbergSphere(int32 Frequency, float Radius) { TArray<FVector> BaseVerts = GetIcosahedronVerts(); TArray<FPolygon> BaseFaces = GetIcosahedronFaces(); // 1. 细分三角网格 TArray<FVector> TriVerts; TArray<int32> TriIndices; TMap<uint64, int32> VertMap; for (const FPolygon& Face : BaseFaces) { const FVector A = BaseVerts[Face.A]; const FVector B = BaseVerts[Face.B]; const FVector C = BaseVerts[Face.C]; for (int32 i = 0; i <= Frequency; ++i) { for (int32 j = 0; j + i <= Frequency; ++j) { float Alpha = 1.0f - (float)(i + j) / Frequency; float Beta = (float)i / Frequency; float Gamma = (float)j / Frequency; FVector V = Alpha * A + Beta * B + Gamma * C; V.Normalize(); V *= Radius; uint64 Key = MakeVertexKey(V); int32* ExistingIdx = VertMap.Find(Key); int32 NewIdx; if (ExistingIdx) { NewIdx = *ExistingIdx; } else { NewIdx = TriVerts.Add(V); VertMap.Add(Key, NewIdx); } // 记录二维坐标(i, j)对应的顶点索引,供三角化用 VertIndexMap.Add(FIntPoint(i, j), NewIdx); } } // 生成这个小面的三角形索引(省略GetVertIndex实现) for (int32 i = 0; i < Frequency; ++i) { for (int32 j = 0; j + i < Frequency; ++j) { int32 p0 = GetVertIndex(i, j); int32 p1 = GetVertIndex(i + 1, j); int32 p2 = GetVertIndex(i, j + 1); TriIndices.Add(p0); TriIndices.Add(p1); TriIndices.Add(p2); if (i + j < Frequency - 1) { int32 p3 = GetVertIndex(i + 1, j + 1); TriIndices.Add(p1); TriIndices.Add(p3); TriIndices.Add(p2); } } } } // 2. 计算对偶(关键部分,见5.3) TArray<FVector> DualVerts; TArray<int32> DualIndices; BuildDualMesh(TriVerts, TriIndices, DualVerts, DualIndices, Radius); // 3. 计算法线、UV,创建PMC TArray<FVector> Normals; TArray<FVector2D> UVs; ComputeSmoothNormals(DualVerts, DualIndices, Normals); ComputeSphereUVs(DualVerts, Radius, UVs); ProcMesh->CreateMeshSection(0, DualVerts, DualIndices, Normals, UVs, TArray<FColor>(), TArray<FProcMeshTangent>(), false); }如果你第一次接触这段代码,建议先把n=2跑通,然后再调大。n=2只有42个面,任何渲染/碰撞问题都容易定位。
5.3 BuildDualMesh的实现思路
对偶转换是最容易写乱的部分,我把逻辑拆成三步,每一步单独验证:
- 第一步:遍历TriIndices,每3个索引生成一个重心,把重心投影到半径为Radius的球面,作为对偶网格的顶点。
- 第二步:遍历TriIndices,对每个三角形,把它的三个顶点索引分别记录下来,建立“原顶点索引 → 三角形ID数组”的映射。
- 第三步:对每个原顶点,取它关联的所有三角形重心,按以顶点法线为基准的角度排序,连接成多边形,通过三角扇输出到DualIndices。
角度排序时,参考向量要选得和该顶点处的切平面平行。我会这样算:
FVector N = TriVerts[VertexIndex]; // 球面上已是单位方向 N.Normalize(); FVector Ref = FVector::CrossProduct(N, FVector::UpVector); if (Ref.IsNearlyZero()) Ref = FVector::CrossProduct(N, FVector::RightVector); Ref.Normalize(); float Angle = FMath::Atan2( FVector::DotProduct(FVector::CrossProduct(N, Dir), Ref), FVector::DotProduct(Dir, Ref) );这个坑我印象非常深:第一次用FVector::UpVector做统一参考向量,结果靠近球体顶部和底部的顶点排序全乱,生成的面自相交,看起来像被揉过的纸团。后来改成每个顶点单独算参考向量,问题立刻消失。
6. 法线、UV和顶点色:让六边形星球真正“显示”出来
网格几何正确只是第一步。你不给法线,球面会呈现奇怪的明暗;不给UV,材质贴图没法贴。这些细节决定了最终效果。
6.1 三种法线策略怎么选
- 平面法线(Flat):每个对偶面(五边形/六边形)算一个面法线,面内所有顶点共用。适合低多边形风格,蜂窝边界锐利,像手工折纸。
- 平滑球面法线(Smooth):直接把顶点位置归一化作为法线。适合光滑星球,蜂窝边界不突出,光照连续。
- 形变后重算法线:如果后续要用噪声扰动顶点做地形起伏,必须在形变后根据相邻三角形叉积重新计算法线,否则地形受光完全错误。
对六边形星球来说,我常用的方案是“面法线”。把每个六边形当成一个独立切面,法线方向等于面内所有顶点的平均位置方向。渲染出来每个六边形都是一个独立的蜂窝平面,非常符合星球表面被切割成六边形地块的感觉。
6.2 球面UV不完美但最实用
给球面网格展UV是最头疼的事之一。最实用的是球面经纬映射,每个顶点的方向向量换算成经纬度:
float U = 0.5f + FMath::Atan2(V.X, V.Z) / (2.0f * PI); float V = 0.5f + FMath::Asin(V.Y / Radius) / PI;这个方案的缺陷是:南北极附近会有明显的拉伸,接缝处U会从1跳到0。但对于蜂窝风格,配合Voronoi噪波材质,这点变形几乎感知不到。如果你无法接受接缝,还有三个方案:立方体展开(Cube Mapping)、每个六边形独立展开UV到自身重心平面、或者直接用三平面映射(Triplanar)让材质不依赖UV。三者实现成本从低到高,多数项目用球面UV就足够了。
6.3 顶点色区分五边形和六边形
一个很实用的技巧是:生成对偶网格时,把“这个面是五边形还是六边形”的信息写入VertexColor。比如五边形用红色(或者任何你想强调的颜色),六边形用绿色。这样材质里直接乘一个顶点色,就能直观看出12个五边形分布在哪里。这个信息在调试时极其有价值——很多人会怀疑程序生成错了,其实是足球上的五边形本来就存在。
你还可以把地区高度、温度、噪声值写入顶点色,材质里用顶点色控制草地/沙漠/冰雪分布。这样就不需要额外的大尺寸贴图,星球生成的参数化程度大幅提升。
7. 碰撞查询与物理模拟:“看不到”的坑才最致命
PMC生成的是纯渲染网格,它默认不参与任何碰撞。很多人的星球生成出来,角色站上去直接掉下去,子弹穿过去,问题就出在这里。
7.1 怎么给程序化网格加碰撞体
常规做法是给PMC关联一个BodySetup。最省事的路径是调用:
ProcMesh->bUseComplexAsSimpleCollision = false; ProcMesh->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics); ProcMesh->SetCollisionObjectType(ECC_WorldStatic); ProcMesh->SetCollisionResponseToAllChannels(ECR_Block); ProcMesh->UpdateCollision();但这只解决“有碰撞体”的问题,碰撞体形状可能还是一个简化凸包,不精确。要精确碰撞,需要手动把网格顶点交给Complex碰撞:
ProcMesh->SetCollisionConvexMeshes(ConvexShapes); ProcMesh->UpdateCollision();或者更简单:创建一个独立的StaticMesh碰撞体(用简化网格生成),用它做查询和物理阻挡,渲染仍旧用PMC。这是目前最推荐的方案,“碰撞用低模、显示用高模”,成本低、精度够。
7.2 Query和Simulate是两套通道
这个坑在程序化网格上尤其明显:碰撞查询(Query)和物理模拟(Simulate)在UE物理系统里是两套不同的通道。射线检测、Overlap查询走Query通道;重力、物理约束、碰撞响应走Simulate通道。你可以在SetCollisionEnabled里把它们分别开关。
对程序化星球,我见过两种典型翻车:一是只开了Query没开Simulate,角色是站住了,但物体从星球上掉不上去;二是把Simulate开了,但每帧动态更新网格顶点,导致物理系统反复重建碰撞体,帧率直接腰斩。动态网格最忌讳的就是频繁改顶点还开着物理模拟,一次UpdateMesh就是一次碰撞体重建,开销极大。如果确实要做动态地形,建议物理碰撞用低频采样的独立低模,不让PMC参与Simulate。
8. 细分等级实测数据、LOD策略和扩展方向
最后给一份我实测过的数据参考,以及从“能生成”到“能上线”之间需要做的优化工作。
8.1 不同Frequency的生成时间和网格规模
我的测试环境是i7-8700K、UE 4.27,生成耗时不含碰撞体创建:
| Frequency n | 对偶后面数 | 对偶后顶点数 | CPU生成耗时 |
|---|---|---|---|
| 2 | 42 | 80 | <1ms |
| 4 | 162 | 320 | 1~2ms |
| 8 | 642 | 1280 | 5~8ms |
| 16 | 2562 | 5120 | 20~30ms |
这个量级对PMC来说非常友好。哪怕Frequency=16也只是一次性生成时的偶发卡顿,运行时动态调整也不至于无法接受。真正的性能大头不在网格本身,而在碰撞体创建和渲染顶点处理上。
8.2 LOD不要指望引擎自动给你
PMC没有内建LOD系统。我的做法是在Actor里预生成三套网格:低模(n=2)用于远处和碰撞、中模(n=6)用于中距离、高模(n=12)用于近距离。根据相机距离动态切换CreateMeshSection内容。切换的时候要平滑,不要瞬切,先做一个小淡入或者直接低模+法线贴图撑住场面。
远距离还有一个更极端的做法:直接换一个Impostor(广告牌)或者静态网格球体,只在需要交互时切换回PMC网格。这样可以省下大量的顶点和DrawCall。
8.3 从纯几何到可玩星球
生成出戈德堡多面体之后,玩法扩展基本就看想象力了。一个自然的扩展是:把每个六边形/五边形的重心作为地块的中心点,给每个地块分配一个ID,预计算邻居关系。这样你得到的就是一颗可以支持“点灯”“占格子”“路径规划”的六边形策略星球。邻居查找不需要任何空间索引,因为对偶网格的邻居关系在生成时就可以直接记录下来。
地形起伏方面,可以在顶点坐标上叠加Simplex噪声或Perlin噪声后重新投影,投影方向沿顶点法线方向即可。注意每次位移后重新计算法线,否则光照就穿帮了。纹理方面,用UE4材质编辑器里的Voronoi节点或不规则蜂窝贴图,叠加顶点色遮罩,基本能做出《北境之地》风格的低多边形星球地表。
如果你只是想快速得到“看起来是六边形”的星球,其实不搞对偶也行:直接用第4节的细分三角网格,顶点法线用Smooth,再叠一个Voronoi蜂窝纹理,视觉上几乎骗得过所有人。但如果你要做六边形格子玩法、要精确的邻居关系、要地块生成逻辑,对偶后的戈德堡多面体是无法绕开的基础设施。这两种方案的取舍,取决于你的项目到底需要“六边形风格”,还是需要“真正的六边形地图”。