1. 从改个模型到造个引擎GeometryCore 到底在解决什么如果你在 UE5 里做过角色换装、地形雕刻、程序化建筑生成或者只是单纯想用代码把一块石头捏成另一块形状那你大概率绕不开一个东西——运行时几何处理。传统做法是把模型丢进 DCC 软件里改好再导回来但一旦需求变成玩家每换一件装备盔甲上的花纹要实时重新贴合这条路就走不通了。GeometryCore 就是在这个背景下被拎出来的它不是一个具体的插件而是一套围绕FDynamicMesh3和Geometry Script构建的几何处理引擎层负责在引擎运行时对 Mesh 做增删改查、布尔运算、简化、重网格化、UV 重算等操作。我第一次接触这套东西是因为一个室内设计类的项目客户要求拖拽一面墙房间里的家具自动避让地板和天花板跟着重新生成。听起来像是参数化建模的活儿但问题在于整个场景是运行时加载的没有预烘焙的模型可用。当时试过用 StaticMesh 拼结果发现一旦墙体尺寸变化接缝处的 UV 拉伸得没法看。后来转到 FDynamicMesh3 上才真正把实时改几何这件事跑通。这篇文章适合谁看如果你已经会用 UE5 的蓝图或 C 做基础开发但对 Geometry Script 还停留在听说过的阶段或者你正在做程序化生成、CAD 类工具、角色自定义系统那接下来的内容应该能帮你少走不少弯路。我会从 FDynamicMesh3 的数据结构讲起一路说到 Geometry Script 的实战用法、性能陷阱以及那些官方文档里不会写的坑。提示GeometryCore 相关模块在 UE5 中属于 Experimental 到 Beta 之间摇摆的状态不同小版本 API 有变动本文基于 UE5.3/5.4 的常见实践展开升级引擎时务必先跑一遍回归。2. FDynamicMesh3 的数据结构为什么它比 StaticMesh 更适合改2.1 顶点-边-三角形三件套的存储逻辑FDynamicMesh3 是 GeometryCore 模块里的核心类它和 UStaticMesh 最大的区别在于StaticMesh 是给渲染器看的FDynamicMesh3 是给算法看的。StaticMesh 的顶点数据被打包成 GPU 友好的格式顶点重复、索引分离你很难直接回答这个顶点周围有几个三角形这种问题。而 FDynamicMesh3 维护的是一套完整的半边结构Half-Edge变体每个顶点、每条边、每个三角形都有明确的邻接关系。具体来说它内部维护了这么几组数据顶点数组每个顶点存位置、法线、颜色、UV 等属性通过 VertexID 索引。三角形数组每个三角形存三个 VertexID通过 TriangleID 索引。边数组每条边记录两个端点和相邻三角形这是实现邻接查询的关键。属性覆盖层Attribute OverlayUV、法线、颜色可以按顶点、按三角形、按边分别存储支持多套 UV 通道。这种结构的代价是内存占用比 StaticMesh 高不少一个十万面的模型FDynamicMesh3 可能要吃几十 MB。但换来的是你可以 O(1) 查到任意顶点的邻接三角形这对做平滑、简化、布尔运算来说是刚需。2.2 和 StaticMesh 之间的转换代价实际项目里你很少会从零构建一个 FDynamicMesh3更多是从 StaticMesh 转过来改完再转回去。这个转换过程有两个方向// StaticMesh - DynamicMesh FDynamicMesh3 DynamicMesh; UE::Geometry::FDynamicMeshConversionOptions Options; Options.bDisableAttributes false; GeometryScript::CopyMeshToDynamicMesh(StaticMesh, DynamicMesh, Options); // DynamicMesh - StaticMesh UStaticMesh* NewStaticMesh GeometryScript::CopyDynamicMeshToStaticMesh(DynamicMesh, ...);这里有个容易被忽略的点转换不是无损的。StaticMesh 里的顶点是分裂的一个位置可能对应多个顶点因为法线或 UV 不同转成 DynamicMesh 时如果开启属性合并会把相同位置的顶点焊起来这时候法线和 UV 就需要重新计算。我踩过一次坑一个硬边模型转过去再转回来硬边全变软了因为焊接时把法线平均了。解决办法是在转换选项里关掉顶点焊接或者转换后手动按角度重新计算法线。注意如果你的模型有自定义的顶点色或 Lightmap UV转换前一定要确认这些属性通道被正确保留否则转回来就是一片白。2.3 属性覆盖层的实际用法FDynamicMesh3 的属性系统是它最灵活也最容易让人迷糊的地方。简单说同一个 UV 数据可以存在不同的层上属性类型存储位置适用场景顶点属性每个顶点一份顶点色、平滑法线三角形属性每个三角形一份面法线、面材质 ID边属性每条边一份硬边标记、UV 接缝覆盖层独立于拓扑多套 UV、多套法线做 UV 展开的时候你通常需要在边上标记接缝然后按接缝把顶点分裂开再重新计算 UV。这个过程在 Geometry Script 里有现成的节点但理解底层覆盖层的机制能帮你在出问题时快速定位。3. Geometry Script 的实战边界哪些活儿它能干哪些别硬上3.1 布尔运算好用但别滥用Geometry Script 提供的布尔运算Union、Subtract、Intersect是我用得最多的功能之一。做程序化建筑时用布尔在墙上开窗洞、在楼板上开楼梯口比手动建模快得多。但布尔运算有个致命问题它会产生大量退化三角形和细碎边。我做过一个测试一个 5000 面的墙体和 200 面的窗户做 Subtract结果生成了 8000 多个三角形其中将近 30% 是面积小于 0.001 平方厘米的碎片。这些碎片在渲染时看不出问题但一旦你要做后续的平滑或简化它们就是灾难。处理办法是在布尔之后接一个Merge 和 Simplify步骤// 布尔之后清理 GeometryScript::ApplyMeshBoolean(DynamicMesh, ...); GeometryScript::ApplyMeshMerge(DynamicMesh, ...); // 焊接重合顶点 GeometryScript::ApplyMeshSimplify(DynamicMesh, TargetTriangleCount, ...); // 简化 GeometryScript::ApplyMeshRecalculateNormals(DynamicMesh, ...); // 重算法线Simplify 的目标面数建议设为原始面数的 60% 到 80%太低会丢失细节太高则清理不干净。实测下来这个组合能把布尔产生的碎片减少 90% 以上。3.2 重网格化什么时候需要什么时候是浪费重网格化Remesh是把模型重新划分成均匀三角形网格的过程。它在两种场景下特别有用一是扫描得到的模型拓扑很乱需要整理二是要做有限元分析或物理模拟需要均匀网格。但重网格化的计算成本很高一个五万面的模型做一次 Remesh 可能要几百毫秒。如果你的场景是运行时交互比如玩家拖拽改变形状那每帧都 Remesh 是不现实的。我的做法是交互过程中用低精度网格松手后再做一次高质量 Remesh。这样既保证了操作流畅又保证了最终质量。3.3 网格简化的参数选择Simplify 是优化性能的利器但参数选不好会毁掉模型。Geometry Script 的简化节点通常提供两种模式按目标面数和按误差阈值。按面数简单直接但可能把关键特征也简化掉按误差阈值更智能但需要你理解模型的尺度。我的经验是对于角色模型目标面数不要低于原始面数的 30%否则手指、面部这些细节会糊成一团。对于建筑模型可以压到 10% 甚至更低因为建筑大多是平面简化损失小。另外简化前一定要锁定边界边否则模型的开口处会变形。4. 性能陷阱与优化那些让帧率掉到个位数的操作4.1 每帧重建 Mesh 的代价新手最容易犯的错误是在 Tick 里直接操作 FDynamicMesh3。我见过一个项目每帧对角色装备做一次布尔运算结果帧率从 120 掉到 15。FDynamicMesh3 的构建和转换本身就很重更别说布尔和简化了。正确的做法是把几何操作放到异步任务里或者至少做脏标记只在参数真正变化时才重建。UE5 的 Geometry Script 支持在后台线程执行部分操作但要注意 FDynamicMesh3 不是线程安全的跨线程传递时需要拷贝。// 错误示范每帧都做 void AMyActor::Tick(float DeltaTime) { Super::Tick(DeltaTime); RebuildMesh(); // 里面是布尔简化帧率杀手 } // 正确做法脏标记 异步 void AMyActor::Tick(float DeltaTime) { Super::Tick(DeltaTime); if (bMeshDirty !bIsRebuilding) { bIsRebuilding true; AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [this]() { RebuildMesh(); AsyncTask(ENamedThreads::GameThread, [this]() { bIsRebuilding false; bMeshDirty false; }); }); } }4.2 内存碎片与 GC 压力FDynamicMesh3 是值类型频繁创建和销毁会产生内存碎片。如果你的项目需要不断生成临时网格比如预览效果建议用对象池复用 FDynamicMesh3 实例而不是每次 new 一个。另外从 FDynamicMesh3 转出来的 UStaticMesh 是 UObject会参与 GC。如果每帧都生成新的 UStaticMeshGC 压力会非常大。我的做法是复用同一个 UStaticMesh只更新它的 MeshDescription这样能显著降低 GC 频率。4.3 LOD 与碰撞的同步问题用 Geometry Script 改了网格之后LOD 和碰撞体不会自动更新。你需要手动重建// 重建碰撞 NewStaticMesh-CreateBodySetup(); NewStaticMesh-GetBodySetup()-InvalidatePhysicsData(); NewStaticMesh-GetBodySetup()-CreatePhysicsMeshes(); // 重建 LOD NewStaticMesh-SetNumSourceModels(NumLODs); // 对每个 LOD 重新设置 MeshDescription这里有个坑如果碰撞体没更新玩家会撞在旧形状上看起来像是穿模。我调试了整整一个下午才发现是碰撞没重建。5. 从 Geometry Script 到 GeometryCore什么时候该下沉到 C5.1 蓝图的性能天花板Geometry Script 的蓝图节点用起来很爽拖拖拽拽就能做布尔和简化。但蓝图有个硬伤每次节点执行都有虚拟机开销。一个包含几十个节点的几何处理流程在蓝图里跑可能比 C 慢三到五倍。如果你的几何操作是低频的比如编辑器工具、加载时生成一次蓝图完全够用。但如果是运行时高频操作建议把核心逻辑下沉到 C。我的做法是用蓝图做原型验证跑通之后把热点函数改写成 C 的 UFunction再暴露给蓝图调用。这样既保留了蓝图的灵活性又拿到了 C 的性能。5.2 直接操作 FDynamicMesh3 的时机Geometry Script 封装了很多常用操作但有些高级需求它覆盖不到比如自定义的网格平滑算法、特定的拓扑修复逻辑。这时候就需要直接操作 FDynamicMesh3 的底层 API。举个例子我要做一个沿着曲线挤出管道的功能Geometry Script 没有现成节点。我的实现是// 沿曲线挤出 FDynamicMesh3 Mesh; TArrayFVector CurvePoints SampleCurve(Curve, NumSegments); for (int32 i 0; i CurvePoints.Num() - 1; i) { // 在每个采样点生成一个圆环截面 TArrayint32 RingVerts GenerateRing(Mesh, CurvePoints[i], Radius, NumSides); if (i 0) { // 连接相邻两个圆环生成侧面 ConnectRings(Mesh, PrevRingVerts, RingVerts); } PrevRingVerts RingVerts; } // 封口 CapRing(Mesh, PrevRingVerts);这种底层操作需要对半边结构有清晰的理解但一旦写通性能和灵活性都是蓝图比不了的。5.3 自定义网格属性的读写FDynamicMesh3 允许你添加自定义属性层这在做特殊效果时很有用。比如我要给每个三角形存一个磨损度用来控制材质混合// 添加三角形属性层 FDynamicMeshAttributeSet* Attribs Mesh.Attributes(); int32 WearLayer Attribs-AddTriangleAttributefloat(Wear, 1.0f); // 写入 for (int32 TID : Mesh.TriangleIndices()) { float Wear ComputeWear(TID); Attribs-GetTriangleAttributefloat(WearLayer)-SetValue(TID, Wear); }这种自定义属性在渲染时可以通过材质节点读取实现基于几何数据的动态效果。6. 踩坑实录三个让我加班到凌晨的 GeometryCore 问题6.1 法线翻转布尔运算后的隐形杀手布尔运算之后模型的法线方向可能不一致。有些三角形朝外有些朝内渲染时看起来就是一片黑或者闪烁。这个问题在编辑器里不明显因为编辑器有双面渲染但打包后就是灾难。排查方法是用 Geometry Script 的Check Mesh Validity节点它会报告法线不一致的三角形数量。修复方法是统一重算法线GeometryScript::ApplyMeshRecalculateNormals(DynamicMesh, true, true);两个 bool 参数分别控制是否按角度分割和是否保留硬边。如果你的模型有硬边第二个参数要设 true否则硬边会被平滑掉。6.2 UV 接缝处的裂缝做 UV 展开时如果接缝处理不当模型表面会出现细小的裂缝。这是因为接缝处的顶点被分裂了但位置没有完全对齐。解决办法是在分裂顶点后用Weld Edges把位置相同的顶点焊起来但保留 UV 接缝。这个操作在 Geometry Script 里叫Merge Mesh但要注意它的参数焊接阈值设太大会把不该焊的顶点焊在一起设太小裂缝又修不好。我的经验值是模型包围盒对角线的 0.001 倍。6.3 大规模网格的内存爆炸有一次我处理一个扫描得到的模型原始面数 200 万。直接转成 FDynamicMesh3 后内存占用飙升到 1.5 GB编辑器直接卡死。后来发现是属性覆盖层的问题默认情况下FDynamicMesh3 会为每个顶点、每条边、每个三角形都分配属性存储即使你不需要。解决办法是在转换时关闭不需要的属性通道FDynamicMeshConversionOptions Options; Options.bDisableAttributes true; // 先关掉所有属性 // 转换后再按需添加这样内存占用能降到原来的三分之一。如果还需要 UV可以转换后单独添加 UV 覆盖层而不是一开始就全开。7. 把 GeometryCore 用出花几个值得尝试的扩展方向7.1 程序化地形与洞穴生成用 FDynamicMesh3 做地形比用 Heightmap 灵活得多因为你可以做真正的三维洞穴和悬崖。我的做法是用 Marching Cubes 算法从体素数据生成初始网格然后用 Geometry Script 做平滑和简化。这套流程在 UE5 里跑通后生成一个 500 米见方的洞穴系统只需要几秒钟。7.2 角色装备的实时适配角色换装时装备需要贴合身体。传统做法是预烘焙多套模型但用 GeometryCore 可以实时做网格变形把装备网格投影到身体网格上然后根据身体形状调整装备顶点。这个方案在 UE5 里已经有人实现核心是用Mesh Projection节点做最近点查询然后做拉普拉斯变形。7.3 与 PCG 框架的结合UE5 的 PCGProcedural Content Generation框架负责撒点GeometryCore 负责把这些点变成实际的几何体。比如用 PCG 在场景里撒石头每块石头的形状都用 Geometry Script 随机变形这样既保证了分布的自然性又保证了每块石头的独特性。我在实际项目里发现GeometryCore 最大的价值不是某个具体功能而是它把几何变成了可以在运行时随意操作的数据。以前做程序化生成要么预烘焙大量模型要么用简单的缩放旋转凑合。现在可以真正地生成几何这打开了很多以前不敢想的设计空间。当然代价是性能开销和复杂度上升所以我的建议是先想清楚这个需求是不是真的需要运行时改几何如果加载时生成一次就够那就别放到运行时。这个判断能帮你省掉很多优化工作。