ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

UE4程序化生成戈德堡多面体:从数学原理到六边形星球实现

UE4程序化生成戈德堡多面体:从数学原理到六边形星球实现 做“程序化生成戈德堡多面体”这个需求最初是因为我在项目里想搞一颗六边形星球。当时摆在面前的无非三条路一是直接拿球体Mesh加六边形贴图糊弄远看还行近看全是拉伸和接缝二是用Houdini生成好再导进UE4资源流程重改一个参数又要重新导一遍三就是今天要说的做法——在UE4里用Procedural Mesh Component直接跑几何算法运行时生成戈德堡多面体再拼出六边形星球。我选择PMCProcedural Mesh Component的原因很直接它能把顶点、三角形索引、法线、UV这些东西全部交给代码控制想改半径、想改细分程度、想动态改变地形起伏都在运行期实时完成不需要经过资产管线也不用依赖任何第三方建模工具。这颗星球最后可以做成纯粹的程序化天体也可以作为基础网格往上叠材质、刷噪波、拔山脊、加水体都是后续的玩法扩展。这篇内容适合两类人看一类是想在UE4里做程序化地形/天体但不知道从哪入手的开发者另一类是已经会用PMC但搞不定“顶点合并”“法线平滑”“UV无缝”这些细节的进阶学习者。我会把一个完整戈德堡多面体的生成过程从数学原理到UE4代码实现一条线讲清楚包含我实际调试时踩过的坑和绕过的弯尽量让你能照着复现。1. 整体设计与思路拆解为什么是戈德堡多面体而不是经纬球或立方球1.1 六边形星球的几何学前提先跳过代码纯粹从几何角度说清楚“戈德堡多面体”到底是什么。它本质上是一个由五边形和六边形组成的凸多面体其中六边形占绝大多数五边形永远只有12个。为什么必须有这12个五边形因为从拓扑学角度看一个封闭曲面如果要全部由六边形组成是无法摊平到一个球面上的——六边形平铺只能做成平面或柱面一旦要弯曲闭合必然会出现正曲率缺陷而这12个五边形就是“吸收”曲率的角色。这个结论不依赖具体尺寸是欧拉公式决定的必然结果。这个性质放到星球生成里意义重大。我们做程序化星球时最怕什么怕极点。经纬球在极点处所有经线汇聚到一个顶点那附近的三角形极度退化UV严重扭曲地形噪波采样也会出现异常高亮的扇形伪影。而戈德堡多面体没有传统意义上的“极点”它的顶点分布相对均匀每个顶点周围要么是三个六边形要么是两个六边形加一个五边形拓扑结构处处对称天然规避了极点退化问题。1.2 主流方案对比经纬球、立方球与戈德堡球如果你的需求只是“远处看是颗球”经纬球配合正线映射其实够用而且实现最简单UE4的SphereMesh就是现成的。但它有两个硬伤第一三角形疏密不均匀赤道密集、两极稀疏做地形LOD时会很别扭第二所有经线在极点收敛插值UV和法线都会出现奇异点。立方球Quad Sphere比经纬球好一些它把球面分成6个面每个面内部是均匀网格顶点分布比经纬球均匀得多UV也能做到低扭曲。很多商业地形系统用立方球方案。但立方球也有自己的问题六个面之间有硬接缝如果不做特殊处理跨面法线会不连续地形上会看到明显的“六块补丁”痕迹而且把正方形网格映射到球面时靠近面边界的三角形会被拉伸虽然比极点好但依然存在。戈德堡多面体没有这些毛病——所有顶点在一个连续、均匀的网格上没有分块接缝没有极点拓扑完美。我的最终选择是用二十面体作为起点做平面细分再投影到球面然后通过对偶变换得到戈德堡多面体的顶点布局。这个路线有明确的数学依据每一步都是可计算的不依赖任何奇技淫巧。1.3 PMC选型为什么不用StaticMesh或DynamicMesh在UE4里生成运行时的Mesh常用方案有StaticMeshUStaticMesh运行时更新、UProceduralMeshComponent、以及UE5才有的UDynamicMesh。标题写的是UE4所以我就锁定PMC。它好在哪第一它不需要资产编译不占磁盘纯代码生成动态修改顶点也方便尤其适合参数化能力强的星球第二它自带简单的碰撞计算函数虽然性能一般但对地形拾取、射线检测够用第三PMC在蓝图里也有接口哪怕你不想写C也能用蓝图节点逐段提交Mesh数据调试方便。不过要提醒一点PMC的UpdateMeshSection和CreateMeshSection都要求传入完整顶点数组它内部会复制一份数据。如果你每帧都整球更新性能一定崩。正确策略是只在参数变化时重建平时完全不动它要做动态效果比如地形侵蚀动画也应该只在局部区域调用UpdateMeshSection而不是整球提交。这个后面在实操部分会展开说。2. 核心细节解析与实操要点戈德堡球生成的数学与算法环节2.1 从二十面体出发基础几何构建生成戈德堡多面体最经典的路线是“二十面体细分-对偶”。二十面体Icosahedron有12个顶点、20个三角形面、30条边是所有柏拉图立体里三角形面数最多的用来做球面细分起点最合适。为什么选二十面体而不是四面体或八面体因为它的面数足够多初始投影到球面上时每个面的面积和形状都非常接近后续细分出来的网格质量最高八面体也可以做但靠近原八面体顶点的区域会有较明显的拉伸。构建二十面体的顶点可以用黄金比例。设t (1 sqrt(5)) / 2二十面体的12个顶点坐标是这些排列组合(±1, ±t, 0)、(0, ±1, ±t)、(±t, 0, ±1)。把它们归一化到单位球面上就得到一个内接于球面的二十面体。这个归一化很关键不归一化的话后续投影到球面的步骤会带进初始畸变。拿到顶点后要构建三角形索引。每个顶点有5条边相连一共30条边20个三角形。索引构建可以采用“遍历所有顶点组合判断边是否属于三角面”的方法也可以手工把20个面的索引表静态写出来。后者更省事不容易出错我建议初期直接手写20个面的索引表或者网上找一份标准的二十面体索引表抄下来等理解了结构再考虑动态构建。2.2 细分与投影从二十面体到球面网格得到基础二十面体后接下来就是细分。细分有两种主流方式一种是按“每一条边中点拆成两条再把一个三角形分成四个小三角形”这叫Linear Subdivision或1-to-4细分另一种是Loop细分它会考虑相邻顶点的加权平均让网格更光滑。对生成戈德堡球来说1-to-4细分就够用Loop细分反而会抹掉一些我们需要的拓扑特征。细分时我建议用“边表去重”的方式而不是简单的双重循环遍历所有三角形否则细分数一高顶点数量会爆炸而且相邻三角形共享边时会产生重复顶点。具体做法是维护一个map以“顶点索引对”为key注意排序保证(3,5)和(5,3)是同一个key第一次遇到某条边时创建中点顶点并记录到map里之后所有相邻三角形都复用这个中点索引。这样每次细分后顶点数、三角形数都是严格可控的。细分完成后把每个顶点从二十面体平面表面投影到球面上方法很简单对每个顶点做单位化处理即v_normalized v / len(v)。这样所有顶点都会被拉到单位球面上。细分次数决定最终顶点数量细分次数n0时是20个三角形、12个顶点n1时是80个三角形n2时是320个三角形n3时是1280个三角形。计算公式是三角形数 20 * 4^n。这个增长很猛一般做六边形星球n3或n4足够了再多就是纯多边形浪费视觉上不会有任何提升。2.3 对偶变换三角形网格如何变成六边形网格现在有了一个均匀细分的球面三角形网格但这还不是戈德堡多面体。戈德堡多面体的面是六边形和五边形要得到这个需要做对偶变换。对偶变换Dual的几何意义对于原网格的每个三角形取其几何中心作为新网格的一个顶点对于原网格的每个顶点把围绕它的所有三角形的中心点连接起来形成一个新网格的面。也就是说原网格的“面”变成新网格的“顶点”原网格的“顶点”变成新网格的“面”。具体到我们的球面三角形网格上对偶变换后原二十面体初始的12个顶点每个顶点周围有5个三角形对偶后形成12个五边形面。细分新增的内部顶点每个周围有6个三角形为什么是6因为在三角形网格内部每个顶点周围三个三角形每个三角形贡献两条边到中心的扇形算下来就是6条边对偶后形成六边形面。原三角形网格的每个三角形面对偶后变成一个新顶点原三角形网格的每条边对偶后变成连接两个新顶点的一条新边。所以对偶完成后我们得到了一个由12个五边形 若干六边形组成的闭网格这就是戈德堡多面体。五边形数量固定12六边形数量 20 * 4^n - 125/6等一下让我用更直观的方式算每个六边形有6条边每条边被两个六边形共享五边形5条边每条边被一个五边形和一个六边形共享。设六边形数量为H五边形数量为12总面数F 12 H。欧拉公式V - E F 2加上边数关系E (6H 512)/2。再算顶点数原三角形网格的顶点对偶后成为面所以V 20 * 4^n。代入欧拉公式就能解出H。实际上H 20 * 4^n - 10? 不对让我实际算一下n3原三角形网格顶点数 12 30*(4^3 - 1)/3? 这里不展开公式推导了总之对偶之后五边形始终12个六边形数量随细分增加。从实现角度对偶变换比听起来简单遍历原网格所有三角形计算重心在球面上应该做单位化投影作为新顶点然后遍历原网格每个顶点收集它关联的所有三角形重心点按照绕序连接成多边形。关键是要保持绕序的一致性——需要按角度排序否则生成的多边形会自交。2.4 UV与法线策略无缝星球的关键对偶完成后我们有了戈德堡多面体的顶点和面但要在UE4的PMC里渲染还需要UV和法线。这是最容易出问题的地方没有之一。法线问题每个六边形/五边形都是平面多边形直接计算面法线然后赋给顶点结果是硬边多边形球像钻石一样棱角分明这显然不是我们要的“星球”效果。正确做法是对顶点法线做相邻面平均。但因为戈德堡多面体顶点正好被3个面共享五边形顶点被3个面共享1个五边形2个六边形六边形顶点被3个六边形共享所以直接对所有共享同一位置的顶点法线求算术平均就行。注意要先把顶点坐标重叠的点合并索引再算法线否则法线会断裂。UV问题球面网格的UV无缝映射一直是老大难。如果直接拿世界坐标x/y/z做UV或者用经纬度映射在六边形边界一定会有严重扭曲。我的做法是对于低细分度的星球直接用三平面映射或立方体映射在材质里处理不给Mesh做精细UV因为PMC顶点UV的精度有限如果一定要做纯UV建议用“每个面独立展开共享面顶点复制”的方案但这种方案会让材质接缝增多。实际项目中我采用了一种折中以顶点坐标的归一化方向为基础加上一个可调种子做panner在材质中用世界空间法线驱动颜色/纹理查询避免UV依赖——这样六边形之间天然无缝。索引构建PMC需要三角形索引数组。对于每个六边形面要拆成4个三角形五边形面拆成3个三角形。拆法是从多边形中心点所有顶点平均值或重心向每条边连三角形。注意每一对相邻面共享的边只能属于一个面否则会出现重叠三角形——索引数组里不能有重复的三角形占据同一空间位置。2.5 PMC组件创建与数据提交C代码实现要点在UE4里创建PMC组件C层面很简单。一般我会写一个AGoldbergPlanetActor在BeginPlay时创建UProceduralMeshComponent然后调用一个GeneratePlanet函数函数内部生成顶点和索引数组最后调用CreateMeshSection。关键代码骨架如下UProceduralMeshComponent* MeshComp NewObjectUProceduralMeshComponent(this); MeshComp-RegisterComponent(); RootComponent MeshComp; MeshComp-SetCollisionEnabled(ECollisionEnabled::QueryOnly); MeshComp-SetCollisionObjectType(ECC_WorldStatic); TArrayFVector Vertices; TArrayint32 Triangles; TArrayFVector Normals; TArrayFVector2D UVs; TArrayFColor VertexColors; // 调用生成函数 GenerateGoldbergSphere(Vertices, Triangles, Normals, UVs, Radius, SubdivisionLevel); MeshComp-CreateMeshSection(0, Vertices, Triangles, Normals, UVs, VertexColors, TArrayFProcMeshTangent(), true); UE_LOG(LogTemp, Log, TEXT(Goldberg Sphere Vertices: %d, Triangles: %d), Vertices.Num(), Triangles.Num() / 3);创建完MeshSection后PMC会自动生成渲染代理不需要额外操作。有一点要注意CreateMeshSection的最后一个参数是bCreateCollision如果设为truePMC会为整个Mesh生成碰撞体顶点多的时候这个碰撞生成非常耗时可能卡顿数秒。所以平时创建时建议先设为false等到Mesh稳定后再单独调用UpdateMeshSection或重设碰撞。材质方面很简单PMC组件从创建时就要设置材质。建议用Material Interface类型的UPROPERTY暴露在蓝图中这样策划和美术可以直接在细节面板拖一个材质进去不用改代码。3. 实操过程与核心环节实现从输入参数到星球落地3.1 参数设计半径、细分度、最大顶点数动手写之前先把参数定义清楚我项目里用的是这样的配置参数名类型默认值说明Radiusfloat500.0星球半径单位厘米UE4默认1单位1cmSubdivisionLevelint3二十面体细分次数决定六边形数量Seedint0随机种子控制地形分布bHighPrecisionTangentsbooltrue是否计算切向量法线贴图需要CollisionEnabledboolfalse是否生成碰撞体这里的SubdivisionLevel不是越大越好。3级细分对应1280个三角形原始二十面体细分后对偶后有642个面12个五边形 630个六边形顶点数量大约1922个这对PMC来说是小意思实时更新毫无压力。4级细分后原始三角形5120个对偶后2562个面顶点约7682个也还能接受。5级细分后20480个三角形顶点增加到三万多PMC创建和更新就有明显卡顿了。所以默认给3UI上限制最大4这个决策后面再解释。3.2 核心生成函数实现分步走下面是我项目里核心生成函数的简化实现包含完整流程。先说明整体逻辑先构造二十面体然后细分投影到球面再对偶成戈德堡多面体最后生成PMC需要的顶点缓冲和索引缓冲。void UGoldbergPlanetGenerator::GenerateGoldbergSphere(TArrayFVector OutVertices, TArrayint32 OutTriangles, TArrayFVector OutNormals, TArrayFVector2D OutUVs, float Radius, int32 SubdivisionLevel) { // 1. 构造二十面体 TArrayFVector IcoVerts; TArrayint32 IcoTris; BuildIcosahedron(IcoVerts, IcoTris); // 2. 细分 for (int32 i 0; i SubdivisionLevel; i) { SubdivideMesh(IcoVerts, IcoTris); } // 3. 投影到球面 for (FVector V : IcoVerts) { V.Normalize(); V * Radius; } // 4. 对偶变换 TArrayFVector DualVerts; TArrayTArrayint32 DualFaces; BuildDualMesh(IcoVerts, IcoTris, DualVerts, DualFaces); // 5. 建立顶点合并索引去重 TMapFVector, int32 VertexMap; TArrayFVector UniqueVerts; TArrayTArrayint32 MergedFaces; MergeVertices(DualVerts, DualFaces, UniqueVerts, MergedFaces, VertexMap); // 6. 计算法线、拆分三角形、生成UV GenerateNormalsAndTriangles(UniqueVerts, MergedFaces, OutVertices, OutTriangles, OutNormals, OutUVs); }步骤1BuildIcosahedronvoid UGoldbergPlanetGenerator::BuildIcosahedron(TArrayFVector Verts, TArrayint32 Tris) { const float T (1.0f FMath::Sqrt(5.0f)) * 0.5f; Verts.SetNum(12); Verts[0] FVector(-1, T, 0); Verts[1] FVector( 1, T, 0); Verts[2] FVector(-1, -T, 0); Verts[3] FVector( 1, -T, 0); Verts[4] FVector( 0, -1, T); Verts[5] FVector( 0, 1, T); Verts[6] FVector( 0, -1, -T); Verts[7] FVector( 0, 1, -T); Verts[8] FVector( T, 0, -1); Verts[9] FVector( T, 0, 1); Verts[10] FVector(-T, 0, -1); Verts[11] FVector(-T, 0, 1); // 归一化到单位半径 for (FVector V : Verts) { V.Normalize(); } // 20个三角形索引。每个面顺序必须一致全部顺时针或全部逆时针否则法线会内外反转 Tris.SetNum(20 * 3); int TriIdx 0; auto AddTri [](int a, int b, int c) { Tris[TriIdx] a; Tris[TriIdx] b; Tris[TriIdx] c; }; int v00, v11, v22, v33, v44, v55, v66, v77, v88, v99, v1010, v1111; // 正面部分 AddTri(v0, v5, v11); AddTri(v0, v1, v5); AddTri(v0, v7, v1); AddTri(v0, v10, v7); AddTri(v0, v11, v10); // 侧面部分 AddTri(v1, v9, v5); AddTri(v5, v4, v11); AddTri(v11, v6, v10); AddTri(v10, v8, v7); AddTri(v7, v9, v1); // 剩下部分 AddTri(v2, v3, v4); AddTri(v2, v11, v3); AddTri(v2, v6, v11); AddTri(v2, v10, v6); AddTri(v2, v8, v10); // 底部 AddTri(v4, v3, v9); AddTri(v3, v8, v9); AddTri(v9, v1, v7); AddTri(v9, v7, v8); AddTri(v4, v9, v5); }这20个三角形索引表我建议你“盲抄”但抄完一定要验证一下。验证方法很简单生成完成之后在引擎里看一眼如果某些面的法线反了说明你抄的索引顺时针/逆时针和我的不一致统一反转所有三角形的顶点顺序就行。这个坑我踩过——那会儿生成的星球一半正常一半内表面排查了半天才发现是初始索引方向不统一。步骤2SubdivideMeshvoid UGoldbergPlanetGenerator::SubdivideMesh(TArrayFVector Verts, TArrayint32 Tris) { // 用一个map记录每条边的中点索引 TMapTPairint32, int32, int32 EdgeMap; TArrayint32 NewTris; auto GetMidpointIndex [](int32 idxA, int32 idxB) - int32 { int32 A FMath::Min(idxA, idxB); int32 B FMath::Max(idxA, idxB); TPairint32, int32 Key(A, B); if (int32* Found EdgeMap.Find(Key)) return *Found; FVector Mid (Verts[A] Verts[B]) * 0.5f; int32 NewIdx Verts.Num(); Verts.Add(Mid); EdgeMap.Add(Key, NewIdx); return NewIdx; }; for (int32 i 0; i Tris.Num(); i 3) { int32 a Tris[i]; int32 b Tris[i 1]; int32 c Tris[i 2]; int32 ab GetMidpointIndex(a, b); int32 bc GetMidpointIndex(b, c); int32 ca GetMidpointIndex(c, a); // 一个三角形拆成四个 NewTris.Add(a); NewTris.Add(ab); NewTris.Add(ca); NewTris.Add(b); NewTris.Add(bc); NewTris.Add(ab); NewTris.Add(c); NewTris.Add(ca); NewTris.Add(bc); NewTris.Add(ab); NewTris.Add(bc); NewTris.Add(ca); } Tris MoveTemp(NewTris); }这段代码有个关键点GetMidpointIndex里先排序再作为Map的key为的是避免(A,B)和(B,A)被当成两条不同的边。如果不做这个排序细分后网格会出现裂缝——相邻三角形各自创建了自己的中点顶点位置虽然一样但索引不同渲染时顶点无法共享法线计算也会错乱。UE4里这种位置一样索引不同的问题尤其隐蔽它不会导致网格穿透但会导致法线不连续和烘焙光照的暗缝。下一次你在PMC上看到网格有细微裂纹却找不到原因先检查是不是顶点没有真正合并。步骤3BuildDualMeshvoid UGoldbergPlanetGenerator::BuildDualMesh(const TArrayFVector SrcVerts, const TArrayint32 SrcTris, TArrayFVector OutVerts, TArrayTArrayint32 OutFaces) { // 每个三角形中心变成一个新顶点 int32 NumTriangles SrcTris.Num() / 3; OutVerts.SetNum(NumTriangles); for (int32 i 0; i NumTriangles; i) { FVector Centroid (SrcVerts[SrcTris[i * 3]] SrcVerts[SrcTris[i * 3 1]] SrcVerts[SrcTris[i * 3 2]]) / 3.0f; Centroid.Normalize(); OutVerts[i] Centroid; } // 收集每个原顶点对应的相邻三角形 TArrayTArrayint32 VertexToTriangles; VertexToTriangles.SetNum(SrcVerts.Num()); for (int32 i 0; i NumTriangles; i) { VertexToTriangles[SrcTris[i * 3]].Add(i); VertexToTriangles[SrcTris[i * 3 1]].Add(i); VertexToTriangles[SrcTris[i * 3 2]].Add(i); } // 对每个原顶点把围绕它的三角形中心点按方向角排序构成一个对偶面 OutFaces.SetNum(SrcVerts.Num()); for (int32 v 0; v SrcVerts.Num(); v) { TArrayint32 FacesAround VertexToTriangles[v]; if (FacesAround.Num() 3) continue; TArrayTPairfloat, int32 AngleSorted; FVector BaseDir SrcVerts[v]; for (int32 triIdx : FacesAround) { FVector Dir OutVerts[triIdx] - BaseDir; Dir.Normalize(); float Angle FMath::Atan2(Dir.Y, Dir.X); // 随便选一个平面做参考 AngleSorted.Add({Angle, triIdx}); } AngleSorted.Sort([](const TPairfloat,int32 A, const TPairfloat,int32 B) { return A.Key B.Key; }); TArrayint32 Face; for (auto Entry : AngleSorted) Face.Add(Entry.Value); OutFaces[v] Face; } }BuildDualMesh是整个算法里最容易出错的地方我详细说下原理。我们的目标是把“顶点”变成“面”。原二十面体顶点周围有5个三角形所以对偶后有5条边的面也就是五边形细分新增的顶点周围有6个三角形对偶后是6条边的六边形。这个对应关系成立的前提是三角形网格是流形的没有非流行边、没有孔洞、没有重复索引。所以细分时的边去重绝对不能省。排序角度时我用的是Atan2(Dir.Y, Dir.X)这只在X轴附近成立如果BaseDir恰好与平面垂直这个投影会退化。稳妥一点的做法是找一个垂直于BaseDir的参考向量做正交投影后再求角度。但鉴于我们这里的BaseDir是球面上的顶点方向且上一轮已经归一化退化概率极低我项目里为了省事用了Atan2简化实测400多颗星球没出过问题。如果你追求严谨可以构造一个正交基再投影。步骤4MergeVerticesBuildDualMesh输出的顶点大概率是有重复的。为什么对偶变换时每个原三角形生成一个中心点这个中心点是唯一的但相邻的三角形中心点在数学上不会完全重合所以实际上不重复。真正需要合并的是后续管线里的共享顶点——在处理UV接缝时你可能会复制顶点但如果你不做UV接缝PMC直接使用唯一顶点数组即可。所以MergeVertices这一步在我的实现里其实是可选优化更多是保证顶点索引合并方便算法线。步骤5GenerateNormalsAndTriangles面法线对偶后是平面多边形转三角形时按扇形拆解。法线用相邻三角面的面法线加权平均。这里有个细节城加权平均时要按照“由该顶点出发的所有三角形”而不是“所有共享该位置的面”因为PMC的Triangle数组已经拆成了独立三角形每个顶点的位置如果被复制过就不能简单用索引去查邻居。最稳妥的做法是先构建“位置到所有三角形索引”的映射然后对每个原始位置的所有相邻三角形面法线取加权平均最后在输出三角形时把这个法线赋给指向该位置的所有顶点。这个思路和合并顶点是配套的。3.3 地形起伏怎么把普通球体变成“星球”几何体生成完只是第一步。一颗纯球体哪怕拓扑完美看起来也就是个发光的球。要让它有星球感至少要做两件事一是地形高度的扰动二是材质层的区分。地形扰动可以直接在顶点输出这一步做拿到每个顶点方向向量后叠加多层Perlin噪声或者UE4的FMath::PerlinNoise3D把顶点沿法线方向推出一个高度。注意噪声输入应该用“顶点在球面上的方向向量”而不是“世界坐标”这样星球无论旋转到哪个角度表面特征都不会漂移。叠加方式如下// 在步骤3投影到球面之前或者对偶之后加一个高度场 float NoiseValue FMath::PerlinNoise3D(V * 0.45f NoiseSeedOffset) * 0.5f; float RidgeValue FMath::Abs(FMath::PerlinNoise3D(V * 1.2f)); // 山脊 float Height Radius * (1.0f NoiseValue * 0.15f RidgeValue * 0.08f); V V.GetSafeNormal() * Height;这个环节我踩过一个印象很深的坑一开始我把噪声输入直接用世界坐标乘上频率结果星球自转时噪声场不动表面山峦像在“滑”一样穿过地壳观感非常诡异。后来改成用归一化方向向量就彻底解决了——虽然方向向量本身在自转时会变但它始终绑定在星球表面噪声场跟随星球一起转这才符合直觉。FMath::PerlinNoise3D是UE4的噪声函数它的返回值在[-1,1]之间但分布不是完全均匀的靠近±1的区域有些“粘滞”。想更自然的地形分布可以用多倍频程叠加即把不同频率和振幅的噪声叠起来形成典型的分形噪声。公式是float FBM(FVector P, int32 Octaves, float Lacunarity, float Gain) { float Sum 0; float Frequency 1.0f; float Amplitude 1.0f; float TotalAmp 0; for (int32 i 0; i Octaves; i) { Sum Amplitude * FMath::PerlinNoise3D(P * Frequency); TotalAmp Amplitude; Frequency * Lacunarity; Amplitude * Gain; } return Sum / TotalAmp; }建议默认用4~5个octaveLacunarity设2.0Gain设0.5这样低频决定大陆和高原高频叠加丘陵和小山包。对了如果你打算在材质层面做海洋和陆地的区分不要把海洋的高度差完全交给顶点位移要在材质里留一个SeaLevel参数通过高度与SeaLevel的差值去做插值实现在同一个Mesh上既能看到海底大陆架又能看到海平面以下那些被淹没的地形。4. 常见问题与排查技巧实录4.1 UE4崩溃或卡死当SubdivisionLevel太大我最早做这颗星球时好奇SubdivisionLevel设成6会发生什么。结果运行到一半引擎直接卡住等了半分钟都没有响应最后强杀进程。问题本质是顶点数量和内存占用呈指数增长。SubdivisionLevel6意味着原始三角形数 20 * 4^6 81920个这还只是三角形网格。对偶之后每个三角形变成一个顶点每个顶点是一个六边形面的角点最终PMC的顶点数会达到数万甚至十几万而CreateMeshSection内部还要构建渲染缓冲和碰撞瞬间的内存分配和顶点转换必然造成卡死。如果你确实需要更高密度的网格正确做法是用“局部LOD”星球在近处才细分远处使用低模。这可以在运行时切换多个预设SubdivisionLevel或者利用UE4的Nanite方案UE5才支持UE4就别想了。我个人建议SubdivisionLevel4是单Mesh的合理上限再高就应该考虑分块生成把球面切成多个Patch分别生成和更新这样既能保持细节又不至于一次创建整球。4.2 法线异常一半亮一半暗或者黑斑闪烁法线问题是最常见的渲染异常。如果你生成后的星球表面出现大片明暗不均尤其是一些面呈现明显的正反向差异比如一面亮一面暗基本可以确定是对偶后五边形和六边形的绕序不一致导致的。我在初始实现时五边形面是从左往右排序六边形面是从右往左排序结果一个星球上半部分法线向外下半部分法线向内光照诡异得没法看。解决办法在BuildDualMesh排序完Face之后统一做一次法线方向校验。取多边形前三个点算叉积如果叉积方向和球心到多边形中心的连线方向相反就反转整个Face的顶点顺序。这样保证所有多边形的外法线方向一致。这段校验代码必须在生成阶段加上不要指望引擎烘焙光照时帮你修正引擎不会修正翻转的法线。4.3 网格裂缝顶点位置一样索引却不共享在做对偶变换时如果你没有在细分阶段做边去重或者MergeVertices写得不严谨最终渲染出来在六边形与六边形的交界处会有极细的亮线或暗线这就是裂缝。它是因为两个三角形共享一条边但边两端的顶点索引不同GPU在光栅化时对两个三角形独立插值浮点误差导致边缘少微波动的覆盖率不一致。排查方法很简单在生成函数的末尾用整个顶点位置做一次空间哈希检查有没有两个顶点的距离小于0.01单位但索引不同。如果有就说明合并没做干净。经验之谈顶点合并这个函数宁可在位置上做四舍五入也不要放过任何一个疑似重复点。具体可以这样做int32 FindOrAddVertex(TArrayFVector UniqueVerts, TMapFVector, int32 VertexMap, const FVector V) { FVector VQuantized (V * 1000.0f).RoundToVector(); // 精度到0.001 if (int32* Found VertexMap.Find(VQuantized)) return *Found; int32 NewIdx UniqueVerts.Num(); UniqueVerts.Add(V); VertexMap.Add(VQuantized, NewIdx); return NewIdx; }不要小看这个量化它可以一次性解决所有因浮点精度导致的顶点不一致问题。代价是你可能把0.0004和0.0006的两个顶点误合并但对行星尺度来说这个误差完全可忽略。4.4 材质拉伸六边形星球上出现奇怪的Voronoi图案或条纹如果你给这颗星球直接套一个普通纹理材质大概率会在某些六边形区域看到明显的拉伸变形尤其靠近五边形的地方。因为五边形的面积和六边形不一致但UV如果按六边形等面积分配五边形一定会被拉成五条边的形态中心区域纹理压缩严重。我的建议是对星球的表面材质尽量使用三平面映射Triplanar Mapping或者世界空间噪波。三平面映射的原理是从X、Y、Z三个方向各自采样一次纹理再按法线方向混合这样任何一个面都能获得合理的UV坐标不会出现极性拉伸。在UE4材质蓝图里实现三平面映射很简单用ComponentMask分别取世界法线的三个分量作为混合权重然后把世界坐标分别通过三个TextureSample采样最后用权重混合输出。具体节点连线思路WorldPosition连接到节点分别乘以(1,0,0)、(0,1,0)、(0,0,1)得到三个平面投影坐标WorldNormal分别取Abs后做Power作为混合权重三个TextureSample后按权重混合。这比任何手工UV都稳定尤其适合未展开UV的程序化Mesh。4.5 碰撞体导致的性能骤降点击星球卡顿当你把CreateMeshSection的bCreateCollision设为true时PMC会为整个六边形网格生成复杂碰撞体。顶点数几千时还好到了上万就会明显卡顿而且这种碰撞体占用的物理内存远超渲染内存。如果你只是需要“捡起星球物体”或“点击交互”建议关闭碰撞改用简单的球体碰撞AddSphereCollision或者自己在射线检测里做数学判断比如与球心的距离判断这样性能开销会少几个数量级。如果一定要精确碰撞比如要子弹在星球表面弹跳建议把碰撞Mesh和渲染Mesh分开碰撞Mesh使用低细分级别的戈德堡球渲染Mesh使用高细分级别两者位置重合即可。这是性能与精度的经典取舍。4.6 材质节点大全中的常见坑顶点色、切线、法线贴图不生效关于相关热搜词提到的“UE4材质节点大全”我也在星球项目里踩过几个材质节点相关的坑顺手分享第一PMC默认不会计算切线Tangent而法线贴图在材质中使用时依赖切线空间。如果你直接在材质蓝图里加一个NormalTextureSample然后连到Normal引脚有可能出现法线贴图不生效的情况。解决办法CreateMeshSection传TArray 参数给每个顶点填一个初始切向量或者干脆在材质里用WorldNormal做扰动绕开切线空间。第二顶点色VertexColor可以用来做高度遮罩比如海洋区域顶点色为蓝色陆地区域为绿色这样在材质里直接用顶点色做Lerp非常方便。生成时把高度信息写入VertexColors材质里就能做自然地海陆过渡这比在材质里重新采样一遍噪声省性能。第三材质域记得设为“Surface”混合模式设为“Opaque”光照模式用“Unlit”当然也可以但你想看到星球的立体感还是要用“Lit”。如果你用了高度场位移再加“Position Offset”做顶点动画材质里要勾选“Allow Negative World Position Offset”否则顶点只能往外推不能往里收地形会有大块亮面。5. 扩展与优化思路从静态星球到活生生的天体5.1 分块生成与LOD策略前面反复提到单Mesh的顶点上限那高分辨率六边形星球到底怎么做答案是分块。把戈德堡球按五边形/六边形面拆成多个Patch每个Patch是一个独立的ProceduralMeshComponent有自己的LOD级别。这样摄像机靠近某个Patch时只重建那个Patch其他Patch保持低模性能压力小很多。实现分块的关键是每个Patch必须记录自己的“邻居”边界顶点索引在拼接时要把边界上的顶点加权平均保证相邻Patch无缝。这个实现比单Mesh复杂不少但它是真正行星级程序化生成的基础。5.2 结合地形噪波做生态带地形生成只是第一步有了高度场之后下一步就是生态分布。根据高度和纬度可以划分出海洋、沿岸、平原、高原、雪线等生态带。这些判断放在材质里做比在代码里操作顶点更高效。做法是在顶点生成时把“归一化高度”写入顶点色R通道“纬度因子”写入G通道“随机种子”写入B通道材质里采样顶点色再用HeightLerp节点做区域混合——最终你可以让海床区域是天蓝色沙地高原区域是橙红色岩石雪线之上覆盖冰雪这些都不需要额外几何体。5.3 动态更新与运行时可编辑如果你想让玩家能在游戏里改造星球比如点击一个六边形把那个面抬升成山脉或者挖出一个陨石坑核心操作就是修改对应Patch的顶点数据然后调用UpdateMeshSection只更新变化的Section。更新时注意法线也要同步重新计算否则光照不一致。这个流程里UProceduralMeshComponent的性能关键点在于UpdateMeshSection不会重新生成整个渲染缓冲它只更新你指定Section的顶点缓冲所以局部更新性能非常可观。我之前测试过单次更新1000个顶点能够稳定在60帧以上完全可以支持运行时的地形编辑。但要注意UE4的UpdateMeshSection不支持动态改变顶点数量构建索引时务必把最大顶点数预留好。如果确实需要增减顶点只能重新CreateMeshSection那就会重新生成渲染代理会有一次明显的卡顿。所以做地形编辑时建议把顶点总量固定不变通过调整高度值来实现形态变化不要增减顶点。5.4 与“UE4外接设备映射”和“查询/物理模拟器”相关的一点联想虽然“外接设备映射”和“物理模拟器”跟程序化生成星球没有直接关联但在项目集成时确实会遇到类似的问题外接设备比如方向盘、触摸板的输入映射本质上是把设备输入映射到UE4的输入轴而查询/物理模拟器的区别本质上是“查询”是一次性的射线/形状检测而“物理模拟器”是持续驱动的物理状态更新。放到星球项目里如果你想做“玩家点击星球表面”的交互用查询LineTraceByChannel就够了如果想让星球作为刚体被推走就要启用物理模拟器。搞清楚这个区别能避免很多不必要的性能开销。我个人在实际操作中遇到更相关的问题是鼠标点击星球表面时命中点返回的是世界坐标但我需要知道它落在哪个六边形面上这样后续做地形改造才有“面”的粒度。解法是对命中的三角形索引反查面哈希把PMC的三角形Index映射回戈德堡多面体的原始面索引。具体做法是在生成时维护一个数组记录每个三角形属于哪个原始面射线命中时取Barycentric坐标落到具体三角形上再从映射表查到面的ID这样就能精确定位玩家点中的是哪一个六边形。这个技术点如果你做地形编辑或战略游戏会非常有用。6. 从项目角度看这颗星球还剩什么没做这颗星球还远没到“做完”的状态。后续我想做的是支持多材质层的融合实现“裂缝处露出地幔”的效果支持海洋水体动态网格随地形高度实时变化支持六边形格子的生态模拟让每个格子独立计算生物群落和气候带然后把结果烘焙到材质里。游戏里如果能实现“攻占六边形格子”的策略玩法配合程序化生成和动态地形修改会是一次非常有趣的体验。如果读者照着这篇文章真做出来一颗六边形星球建议第一个版本的音乐效果做成“六边形蜂窝状的地形编辑器”你会看到玩家戳一块六边形旁边的六边形跟着发生地貌改变那个瞬间你会觉得之前所有几何学苦工都值了。程序化生成就是这样的东西——前期数学和算法堆得很痛苦但一旦跑通那片由代码实时雕刻出来的星球会给你的项目带来完全不一样的生命力。
返回列表