ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

虚幻引擎程序化网格生成:从ProceduralMeshComponent到动态金字塔构建

2026/8/3 19:59:29 拓冰建站 浏览量
虚幻引擎程序化网格生成:从ProceduralMeshComponent到动态金字塔构建 1. 项目概述为什么要在UE里“手搓”一个金字塔在虚幻引擎UE4/UE5的项目开发里我们经常需要各种模型。美术同学用3DMax、Maya、Blender等工具制作精美的静态网格体然后导入引擎使用这是标准流程。但有时候标准流程会遇到瓶颈比如你需要一个参数化、可动态变化的模型或者你的项目逻辑需要根据运行时数据实时生成几何体又或者你只是想深入理解计算机图形学中网格构建的基本原理。这时候程序化生成网格体就成了一个绕不开的技术点。“手搓”一个带碰撞的金字塔模型听起来像是一个图形学入门练习但它背后串联起的是UE中程序化内容生成PCG的基石技术——ProceduralMeshComponent程序化网格体组件。这个组件允许我们在运行时通过代码动态地定义顶点、三角形和UV从而“无中生有”地创建出任何你能用数学描述的3D形状。从简单的立方体、球体到复杂的地形、建筑甚至是动态变化的流体表面其底层逻辑都与此相通。选择金字塔作为目标是因为它的几何结构足够简单四个三角形侧面加一个正方形底面但又涵盖了3D建模的核心要素顶点位置计算、三角形索引排序、法线生成、UV映射以及至关重要的碰撞生成。通过这个实战你不仅能得到一个会出现在场景里的金字塔更能彻底掌握在UE中动态构建和渲染一个带物理交互的3D物体的完整流程。这对于开发动态地形系统、建筑生成器、特效几何体如破碎效果乃至一些特定的游戏机制如可变形地形都至关重要。2. 核心思路与组件选型解析2.1 为什么是ProceduralMeshComponent在UE中动态生成网格主要有几种方式ProceduralMeshComponent、RuntimeMeshComponent来自RuntimeMeshPlugin插件以及UE5新引入的DynamicMeshActor和Geometry Scripting。对于本次“手搓”金字塔的任务ProceduralMeshComponent是最直接、最经典也最能揭示原理的选择。ProceduralMeshComponent以下简称PMC是UE内置的一个组件位于“Procedural Mesh”插件中。它的核心思想是“数据驱动”你提供一组顶点数据位置再提供一组索引数据告诉引擎如何用这些顶点连接成三角形它就能帮你渲染出来。它不关心这些数据是来自一个.obj文件还是你刚刚用sin和cos算出来的这给了我们最大的自由度。它的优势在于零依赖作为官方插件启用后即可使用无需引入第三方库。原理透明使用它需要你亲自处理顶点、三角形、UV、法线等所有底层数据是学习网格构建原理的绝佳教材。功能完整支持多段Sections、碰撞生成、UV通道、切线等标准网格体属性。当然它也有缺点比如每次更新整个网格数据开销较大不适合每帧剧烈变化的复杂网格。但对于我们生成一个静态或低频更新的金字塔来说这完全不是问题。2.2 金字塔的几何与数据拆解在动手写代码前我们必须先在脑子里把金字塔“拆解”成PMC能理解的数据。一个四棱锥金字塔由5个面构成4个三角形侧面和1个正方形底面。这需要多少个顶点一个常见的误区是认为5个面就需要5 * 3 15个顶点。实际上由于多个三角形会共享顶点我们可以通过顶点复用大大减少数据量。一个标准的四棱锥我们可以用5个顶点来定义顶点0 (V0)金字塔的顶端Apex。顶点1 (V1)底面正方形的顶点A例如前右角。顶点2 (V2)底面正方形的顶点B例如后右角。顶点3 (V3)底面正方形的顶点C例如后左角。顶点4 (V4)底面正方形的顶点D例如前左角。这样底面正方形由两个三角形组成(V1, V2, V3) 和 (V1, V3, V4)。四个侧面三角形分别是(V0, V1, V2), (V0, V2, V3), (V0, V3, V4), (V0, V4, V1)。总共6个三角形但只用了5个顶点。接下来我们需要为这5个顶点计算位置Vertices根据金字塔的高度、底面边长确定每个顶点在局部空间中的坐标。三角形索引Triangles一个整数数组每三个数字一组指明用哪三个顶点按在Vertices数组中的索引构成一个三角形。注意UE默认使用顺时针缠绕顺序从摄像机看向三角形正面时顶点顺序为顺时针来定义三角形正面。法线Normals每个顶点对应的法线向量用于光照计算。对于金字塔这种平坦的面同一个面上的顶点法线是相同的。UV坐标UV0每个顶点在纹理贴图上的二维坐标用于贴图映射。这是让金字塔“有皮肤”的关键。顶点颜色Vertex Colors可选可以为每个顶点赋予颜色。切线Tangents可选主要用于法线贴图计算。对于简单漫反射贴图可以留空或生成默认值。最后我们还需要为这个网格生成碰撞体让玩家和物体不能穿过去。3. 实战构建从零到一的C实现我们将使用C来实现因为这样能接触到最核心的API理解也更深刻。当然蓝图也可以通过调用C暴露的函数节点来实现类似功能。3.1 创建Actor与组件首先在UE编辑器中创建一个新的C类继承自Actor命名为BPyramidProcedural或其他你喜欢的名字。在其头文件中我们需要包含必要的头文件并声明组件和函数。// BPyramidProcedural.h #pragma once #include CoreMinimal.h #include GameFramework/Actor.h #include ProceduralMeshComponent.h // 必须包含PMC头文件 #include BPyramidProcedural.generated.h UCLASS() class YOURPROJECT_API ABPyramidProcedural : public AActor { GENERATED_BODY() public: ABPyramidProcedural(); protected: virtual void BeginPlay() override; virtual void OnConstruction(const FTransform Transform) override; // 在编辑器中和蓝图构建时调用 public: // 可编辑参数方便在编辑器中和蓝图中调整 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Pyramid) float BaseSize; // 底面边长 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Pyramid) float Height; // 金字塔高度 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Pyramid) UMaterialInterface* Material; // 应用的材质 // 用于生成碰撞的参数简单碰撞 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Pyramid|Collision) bool bGenerateCollision; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Pyramid|Collision, meta (EditCondition bGenerateCollision)) TEnumAsByteECollisionTraceFlag CollisionTraceFlag; // 碰撞复杂度 private: // 核心的ProceduralMeshComponent UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category Pyramid, meta (AllowPrivateAccess true)) class UProceduralMeshComponent* ProceduralMesh; // 生成网格数据的函数 void GeneratePyramidMesh(); void CreatePyramidSection( TArrayFVector InVertices, TArrayint32 InTriangles, TArrayFVector InNormals, TArrayFVector2D InUVs, TArrayFLinearColor InVertexColors, TArrayFProcMeshTangent InTangents ); };在构造函数中我们创建ProceduralMeshComponent并设置为根组件同时初始化默认参数。// BPyramidProcedural.cpp #include BPyramidProcedural.h #include Kismet/KismetMathLibrary.h ABPyramidProcedural::ABPyramidProcedural() { PrimaryActorTick.bCanEverTick false; // 创建并设置ProceduralMeshComponent为根组件 ProceduralMesh CreateDefaultSubobjectUProceduralMeshComponent(TEXT(ProceduralMesh)); RootComponent ProceduralMesh; // 初始化默认参数 BaseSize 200.0f; Height 300.0f; bGenerateCollision true; CollisionTraceFlag CTF_UseDefault; // 使用简单碰撞 // 确保组件在编辑器构建脚本中更新 ProceduralMesh-SetFlags(RF_Transactional); }3.2 核心算法生成金字塔网格数据GeneratePyramidMesh函数是核心。我们将在OnConstruction中调用它这样在编辑器中修改参数时金字塔就能实时更新。void ABPyramidProcedural::OnConstruction(const FTransform Transform) { Super::OnConstruction(Transform); GeneratePyramidMesh(); } void ABPyramidProcedural::GeneratePyramidMesh() { if (!ProceduralMesh || BaseSize 0 || Height 0) { return; } // 清空之前可能存在的网格段Section 0 ProceduralMesh-ClearMeshSection(0); TArrayFVector Vertices; TArrayint32 Triangles; TArrayFVector Normals; TArrayFVector2D UVs; TArrayFLinearColor VertexColors; TArrayFProcMeshTangent Tangents; // 调用函数填充上述数组 CreatePyramidSection(Vertices, Triangles, Normals, UVs, VertexColors, Tangents); // 将数据提交给ProceduralMeshComponent生成网格和碰撞 ProceduralMesh-CreateMeshSection_LinearColor( 0, // Section Index Vertices, Triangles, Normals, UVs, VertexColors, Tangents, bGenerateCollision // 是否创建碰撞 ); // 设置碰撞复杂度 if (bGenerateCollision) { ProceduralMesh-ContainsPhysicsTriMeshData(bGenerateCollision); // 注意CreateMeshSection_LinearColor的bCreateCollision参数为true时 // 碰撞数据已生成。这里我们可能需要根据CollisionTraceFlag设置碰撞类型。 // 更精细的控制可能需要调用ProceduralMeshComponent的其他函数或事后修改BodyInstance。 } // 应用材质 if (Material) { ProceduralMesh-SetMaterial(0, Material); } }现在让我们实现最关键的CreatePyramidSection函数计算所有几何数据。void ABPyramidProcedural::CreatePyramidSection( TArrayFVector InVertices, TArrayint32 InTriangles, TArrayFVector InNormals, TArrayFVector2D InUVs, TArrayFLinearColor InVertexColors, TArrayFProcMeshTangent InTangents) { // 1. 定义顶点位置 (局部空间以Actor中心为原点) // 假设底面中心在原点金字塔沿正Z轴向上 float HalfBase BaseSize * 0.5f; // 顶点顺序: 0:顶端, 1:前右, 2:后右, 3:后左, 4:前左 InVertices.Empty(); InVertices.Add(FVector(0.0f, 0.0f, Height)); // V0: 顶端 InVertices.Add(FVector(HalfBase, HalfBase, 0.0f)); // V1: 前右 InVertices.Add(FVector(HalfBase, -HalfBase, 0.0f)); // V2: 后右 InVertices.Add(FVector(-HalfBase, -HalfBase, 0.0f));// V3: 后左 InVertices.Add(FVector(-HalfBase, HalfBase, 0.0f)); // V4: 前左 // 2. 定义三角形索引 (顺时针顺序) InTriangles.Empty(); // 底面三角形1 (V1, V2, V3) InTriangles.Append({1, 2, 3}); // 底面三角形2 (V1, V3, V4) InTriangles.Append({1, 3, 4}); // 侧面三角形 (V0, V1, V2), (V0, V2, V3), (V0, V3, V4), (V0, V4, V1) InTriangles.Append({0, 1, 2}); InTriangles.Append({0, 2, 3}); InTriangles.Append({0, 3, 4}); InTriangles.Append({0, 4, 1}); // 3. 计算法线 InNormals.Empty(); InNormals.Init(FVector::ZeroVector, InVertices.Num()); // 初始化为零向量 // 计算每个三角形的面法线并累加到其三个顶点上 for (int32 i 0; i InTriangles.Num(); i 3) { int32 i0 InTriangles[i]; int32 i1 InTriangles[i 1]; int32 i2 InTriangles[i 2]; FVector v0 InVertices[i0]; FVector v1 InVertices[i1]; FVector v2 InVertices[i2]; // 计算三角形两条边的向量 FVector Edge1 v1 - v0; FVector Edge2 v2 - v0; // 叉积得到面法线 (注意顺序影响方向这里保证是朝外的) FVector FaceNormal FVector::CrossProduct(Edge1, Edge2).GetSafeNormal(); // 累加到顶点法线 InNormals[i0] FaceNormal; InNormals[i1] FaceNormal; InNormals[i2] FaceNormal; } // 归一化所有顶点法线 for (FVector Normal : InNormals) { Normal.Normalize(); } // 4. 生成简单的UV坐标 (这里使用简单的投影实际项目可能需要更复杂的UV展开) InUVs.Empty(); for (const FVector Vertex : InVertices) { // 将X和Y坐标归一化到[0,1]范围作为UV。这是一个非常基础的映射对于复杂形状需要专门的UV展开算法。 float U (Vertex.X HalfBase) / BaseSize; float V (Vertex.Y HalfBase) / BaseSize; // 对于顶端顶点(V0)其X和Y为0所以UV为(0.5, 0.5)。你也可以为侧面和底面设计不同的UV策略。 InUVs.Add(FVector2D(U, V)); } // 5. 顶点颜色 (全部设为白色) InVertexColors.Empty(); InVertexColors.Init(FLinearColor::White, InVertices.Num()); // 6. 切线 (对于没有法线贴图的简单材质可以生成默认值或留空) InTangents.Empty(); // 这里我们为每个顶点生成一个默认的切线。更准确的做法是根据UV方向计算。 for (int32 i 0; i InVertices.Num(); i) { // 使用一个简单的算法假设切线方向为(1,0,0)副法线由法线和叉积得出 FVector Normal InNormals[i]; FVector TangentX FVector(1, 0, 0); // 如果法线几乎平行于切线则调整切线方向 if (FMath::Abs(FVector::DotProduct(TangentX, Normal)) 0.99f) { TangentX FVector(0, 1, 0); } // 通过叉积得到副法线再与法线叉积得到正交化的切线 FVector TangentY FVector::CrossProduct(Normal, TangentX).GetSafeNormal(); TangentX FVector::CrossProduct(TangentY, Normal).GetSafeNormal(); InTangents.Add(FProcMeshTangent(TangentX, false)); // false表示副切线方向由引擎计算 } }注意UV映射的学问上面的UV生成方法基于X,Y坐标的线性映射是最简单的对于金字塔这种形状贴图会在侧面产生严重的拉伸。在实际项目中对于程序化生成的复杂网格你可能需要实现更智能的UV展开算法或者使用“三平面投影”等技术来减少拉伸。对于这个教学示例我们暂时使用简单映射以便将重点放在网格生成流程上。编译项目将BPyramidProcedural拖入场景。你应该能看到一个白色的线框金字塔。在细节面板中修改BaseSize和Height参数金字塔会实时更新。为其指定一个带贴图的材质就能看到效果。4. 碰撞生成详解与性能考量4.1 碰撞是如何生成的在调用CreateMeshSection_LinearColor时我们将bCreateCollision参数设为bGenerateCollision默认为true。这会触发PMC内部为这个网格段生成碰撞数据。PMC生成的碰撞是“复杂碰撞”Complex Collision即使用渲染网格的三角形数据作为碰撞体CTF_UseComplexAsSimple。这对于动态生成的、形状特异的物体是合适的因为UE无法自动为其生成一个简单的包围盒Box、胶囊体Capsule或凸包Convex。引擎会为这些三角形数据构建一个用于物理引擎如PhysX查询的加速结构例如BVH树。重要区别简单碰撞Simple Collision用基本的几何体球、盒、胶囊、凸包近似表示物体计算速度快内存占用小。适用于大多数刚体。复杂碰撞Complex Collision使用渲染网格的实际三角形精度高但计算和内存开销大。通常用于静态网格体StaticMesh或需要高精度碰撞检测的情况如射线检测命中特定三角形。对于我们的程序化金字塔由于形状固定且简单使用复杂碰撞是可以接受的。但如果金字塔很大或者场景中有很多这样的物体性能可能会成为问题。4.2 优化碰撞性能如果性能成为瓶颈我们可以考虑以下优化策略使用简单碰撞近似虽然PMC主要生成复杂碰撞但我们可以在生成网格后为其附加一个简单的碰撞体组件。例如可以计算金字塔的轴对齐包围盒AABB然后添加一个BoxComponent作为碰撞体并禁用PMC自身的碰撞生成bGenerateCollision false。这样物理开销会小很多。// 在GeneratePyramidMesh函数中如果选择使用简单碰撞 if (bUseSimpleCollision) { // 1. 先创建网格但不生成碰撞 ProceduralMesh-CreateMeshSection_LinearColor(0, Vertices, Triangles, Normals, UVs, VertexColors, Tangents, false); // 2. 计算包围盒 FBox BoundingBox(Vertices); FVector BoxExtent BoundingBox.GetExtent(); // 3. 创建并配置BoxComponent UBoxComponent* BoxCollision NewObjectUBoxComponent(this); BoxCollision-SetupAttachment(RootComponent); BoxCollision-SetBoxExtent(BoxExtent); BoxCollision-SetCollisionProfileName(TEXT(BlockAll)); // 设置碰撞预设 BoxCollision-RegisterComponent(); }生成凸包碰撞对于比包围盒更精确但比复杂碰撞更高效的方案可以考虑使用第三方库如UE的GeometryProcessing模块为顶点集计算一个凸包然后用多个凸包ConvexElem来组成碰撞体。这需要更复杂的代码通常通过修改UBodySetup来实现。控制碰撞精度ProceduralMeshComponent本身对碰撞生成的控制有限。对于静态网格体我们可以在导入时设置碰撞精度和简化参数。对于PMC一个间接的方法是先简化生成的三角形网格减少面数再用简化后的网格生成碰撞。这需要额外的网格处理逻辑。实操心得碰撞的取舍在项目初期为了快速验证功能直接使用复杂碰撞是最快的。但在性能优化阶段一定要在场景中放入预期数量的对象进行性能剖析Profiling。如果PhysX的耗时过高首要考虑的就是用简单几何体替代复杂碰撞。记住一个原则视觉精度不等于碰撞精度玩家通常感知不到微小的碰撞差异。5. 常见问题、调试技巧与扩展思路5.1 网格显示异常问题排查当你第一次运行代码金字塔可能没有显示或者显示为全黑、闪烁、贴图错乱。别慌按以下步骤排查检查插件确保在“编辑-插件”中已经启用了“Procedural Mesh Component”插件并重新编译项目。检查材质如果网格是纯黑色很可能材质没有正确应用或者材质本身是黑色的。先尝试应用一个简单的DefaultLit材质看看。检查法线如果模型看起来只有内部可见外面是黑的或者光照异常很可能是法线计算错误导致三角形背面朝外。确保你的三角形索引顺序是顺时针从摄像机看向三角形正面时。在上面的代码中我们计算面法线时使用的叉积顺序CrossProduct(Edge1, Edge2)是基于(v0-v1, v0-v2)的顶点顺序这需要与InTriangles中的顶点顺序一致。你可以使用编辑器的“显示-可视化-表面法线”来辅助调试。检查UV如果贴图拉伸严重或错位就是UV映射问题。尝试一个棋盘格纹理材质可以非常直观地看出UV的分布和拉伸情况。检查顶点数量在CreatePyramidSection函数末尾打印InVertices.Num()和InTriangles.Num()确保数量符合预期5个顶点18个索引对应6个三角形。5.2 蓝图调用与参数动态调整为了让设计师也能使用我们可以将生成函数暴露给蓝图。在头文件的函数声明前加上UFUNCTION(BlueprintCallable, CategoryPyramid)即可。// 在ABPyramidProcedural类声明中 UFUNCTION(BlueprintCallable, Category Pyramid) void RegenerateMesh(float NewBaseSize, float NewHeight);在蓝图中就可以在运行时动态调用这个函数改变金字塔的大小实现动态缩放的效果。5.3 从金字塔出发的扩展思路掌握了基础的金字塔生成你已经拥有了打开程序化建模大门的钥匙。以下是一些可以尝试的扩展方向参数化建筑生成将金字塔看作一个参数底面边数N。当N3时生成三棱锥N5时生成五棱锥当N很大且高度很小就变成了棱柱。你可以创建一个函数输入底面半径、边数、高度生成一个正棱锥或棱柱。动态变形在Tick函数中根据时间或外部输入如玩家距离动态修改Vertices数组中某个顶点的Z坐标高度然后调用UpdateMeshSection函数注意不是CreateMeshSection来更新局部网格数据实现金字塔顶点上下波动的动画效果。多段Multi-Section网格金字塔的侧面和底面可以使用不同的材质。ProceduralMeshComponent支持多个段Section每个段有独立的材质。你可以将侧面三角形索引0-11定义为第0段底面三角形索引12-17定义为第1段然后分别指定不同的材质。与Landscape结合用程序化网格生成一些特定的岩石、废墟模型然后通过代码将其放置到地形Landscape上可以用于快速搭建程序化环境。向UE5的Geometry Scripting过渡UE5引入了更强大的Geometry Scripting插件和DynamicMesh体系它提供了更丰富、更稳定的网格操作功能。在熟悉了PMC的底层原理后学习使用UDynamicMesh和UGeometryScriptLibrary来进行布尔运算、网格简化、UV展开等高级操作是自然的进阶路径。手搓一个金字塔远不止得到一个模型。它是一次对虚幻引擎渲染管线、网格数据结构和程序化思维的深度触摸。当你看到那个由几行代码生成的几何体在场景中稳稳立住并与物理世界发生碰撞时你对引擎的理解就从“使用者”向“创造者”迈进了一大步。