1. 项目概述:为什么要在运行时生成网格?
在虚幻引擎5(UE5)的项目开发中,我们经常会遇到一个需求:需要根据玩家的操作、游戏逻辑或者外部数据,在游戏运行过程中动态地创建出全新的三维模型。比如,一个建筑游戏里玩家拖拽生成的墙体,一个地形编辑工具实时刷新的地表,或者一个数据可视化应用里根据实时数据流生成的图表模型。这些模型不是从3D软件里导入的预制资产,而是由程序在“运行时”即时计算并构建出来的。
传统的做法是,我们可能会在3D建模软件(如Blender、3ds Max)中预先做好所有可能用到的模型,然后导入UE5作为静态网格体(StaticMesh)资产。这种方式在模型种类固定、数量有限时是可行的。但一旦需求变得动态和不可预测,预制作所有模型就变得不可能,或者会导致资源包体积爆炸式增长。
这时,我们就需要掌握“运行时动态生成多边形网格”这项核心技术。它允许你的游戏或应用像捏橡皮泥一样,在内存中实时“捏”出任意形状的3D物体,并赋予它真实的物理碰撞和视觉材质,使其能够无缝地融入到游戏世界中,与其他物体互动,被玩家看到和触摸到。
本次分享的核心,就是使用UE5提供的MeshDescription这套底层数据接口,手把手教你从零开始构建一个带碰撞和材质的StaticMesh。相比于更上层的ProceduralMeshComponent,MeshDescription是构建UE渲染和物理系统所认可的“一等公民”网格资产的标准方式,性能更优,与引擎管线集成度更深,是进行高质量、高性能运行时几何体生成的推荐路径。
2. 核心思路与工具选型解析
在深入代码之前,我们必须理清在UE5中创建一个可用的静态网格体需要哪些要素,以及为什么选择MeshDescription作为实现工具。
2.1 一个完整StaticMesh的构成要素
一个能在场景中正常显示和交互的StaticMesh,远不止是一堆顶点位置数据。它至少包含以下几层信息:
- 几何数据(Geometry):这是网格的骨架,包括顶点(Vertex)的位置、多边形(Polygon,通常是三角形或四边形)如何由这些顶点连接构成、以及多边形的朝向(法线)。
- 渲染数据(Rendering):这决定了网格看起来是什么样子。主要包括UV坐标(用于贴图映射)、顶点颜色、以及最重要的——材质(Material)。一个网格体可以有一个或多个材质槽位,不同的多边形可以应用不同的材质。
- 碰撞数据(Collision):这决定了网格在物理世界中的“实体”。没有碰撞,玩家和子弹就会穿模而过。碰撞体通常比渲染网格更简单,可以是包围盒、胶囊体,或者由凸包(Convex Hull)构成的复杂形状。
2.2 为什么是MeshDescription?
UE5提供了多种动态生成网格的途径,最常见的有:
- ProceduralMeshComponent: 属于组件(Component)级别,使用简单,API直观。但它生成的网格体是“临时”的,无法直接保存为资产,其渲染路径也可能不是最优的,不适合需要大量生成或需要与蓝图深度交互的复杂场景。
- DynamicMesh(通过GeometryScript插件): 功能强大,包含丰富的网格编辑操作。但它更侧重于网格处理算法,要将其转换为引擎原生静态网格体仍需一步转换。
- MeshDescription: 这是UE渲染模块(Render Hardware Interface, RHI)和静态网格体资产内部使用的底层数据结构。直接使用
MeshDescription构建网格,意味着你是在用引擎“原生语言”与它对话。
选择MeshDescription的核心优势在于:
- 性能与集成: 生成的网格数据可以直接用于创建
UStaticMesh资产,享受引擎所有的优化,如自动LOD生成、距离场烘焙、Nanite支持(需满足条件)等。 - 功能完整: 可以完整地定义顶点属性(位置、法线、UV、颜色)、多边形分组(为不同材质分配多边形)、并为其生成碰撞体。
- 资产化: 生成的
UStaticMesh可以像普通导入的资产一样,被保存、引用、在编辑器中查看,甚至动态加载。
简单来说,ProceduralMeshComponent像是用临时脚手架搭了个棚子,而用MeshDescription则是按照标准图纸和建材,盖了一座可以入住的房子。我们本次的目标就是盖一座功能齐全的“房子”。
2.3 所需模块与前置知识
在开始编码前,请确保你的UE5 C++项目配置正确:
- 在项目的
.Build.cs文件中,添加必要的模块依赖。核心模块是"RenderCore","MeshDescription","StaticMeshDescription"和"MeshBuilder"。通常,依赖"CoreUObject","Engine","RHI"也是必须的。PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "RHI", "RenderCore", "MeshDescription", "StaticMeshDescription", "MeshBuilder" }); - 你需要对C++有基本了解,并熟悉UE5的编程范式(如UObject、FVector等)。
- 对计算机图形学的基础概念(顶点、三角形、法线、UV)有理解会事半功倍。
3. 从零构建MeshDescription:定义几何与渲染
接下来,我们进入实战环节。我们将创建一个函数,用于生成一个简单的四边形(两个三角形)平面,并为其设置UV和材质。
3.1 创建MeshDescription对象并添加顶点
首先,我们需要一个FMeshDescription对象来存储所有的网格数据。
#include "MeshDescription.h" #include "StaticMeshAttributes.h" #include "MeshDescriptionBuilder.h" void UMyMeshGenerator::BuildQuadMesh(UStaticMesh* OutStaticMesh) { if (!OutStaticMesh) return; // 1. 创建MeshDescription FMeshDescription MeshDescription; // 2. 获取并设置属性集(Attribute Set)。这是定义顶点、多边形等能拥有哪些属性(位置、法线、UV等)的蓝图。 FStaticMeshAttributes Attributes(MeshDescription); Attributes.Register(); // 现在,MeshDescription已经准备好了顶点位置、顶点实例(Vertex Instance,用于存储法线、UV等)、多边形等属性通道。 }FMeshDescription的结构需要理解两个核心概念:
- 顶点(Vertex): 一个几何空间中的点,只有位置(Position)属性。
- 顶点实例(Vertex Instance): 当同一个顶点被不同的多边形共享时,它在每个多边形中可能有不同的法线、UV或顶点颜色。
Vertex Instance就是顶点在特定多边形中的“化身”,存储了这些渲染属性。
为了更方便地构建网格,UE提供了FMeshDescriptionBuilder工具类。
void UMyMeshGenerator::BuildQuadMesh(UStaticMesh* OutStaticMesh) { // ... 前述代码 ... // 3. 使用Builder来简化构建过程 FMeshDescriptionBuilder MeshBuilder; MeshBuilder.SetMeshDescription(&MeshDescription); MeshBuilder.EnablePolyGroups(); // 启用多边形组,用于分配材质 // 4. 添加4个顶点 (一个正方形平面,在XZ平面上,中心在原点) FVertexID VertexIDs[4]; VertexIDs[0] = MeshBuilder.AppendVertex(FVector(-50.0f, -50.0f, 0.0f)); // 左下 VertexIDs[1] = MeshBuilder.AppendVertex(FVector( 50.0f, -50.0f, 0.0f)); // 右下 VertexIDs[2] = MeshBuilder.AppendVertex(FVector( 50.0f, 50.0f, 0.0f)); // 右上 VertexIDs[3] = MeshBuilder.AppendVertex(FVector(-50.0f, 50.0f, 0.0f)); // 左上 }3.2 创建多边形并设置顶点属性
有了顶点,我们需要将它们连接成多边形(这里用三角形)。同时,为每个顶点实例设置法线和UV。
void UMyMeshGenerator::BuildQuadMesh(UStaticMesh* OutStaticMesh) { // ... 前述代码 ... // 5. 为每个顶点创建顶点实例(Vertex Instance),并设置其属性(法线、UV)。 // 因为我们的平面朝上(Z轴正方向),所以所有顶点的法线都是 (0,0,1)。 FVector Normal = FVector(0, 0, 1); // 假设UV从(0,0)到(1,1)铺满整个平面 TArray<FVertexInstanceID> InstanceIDs; InstanceIDs.SetNum(4); InstanceIDs[0] = MeshBuilder.AppendInstance(VertexIDs[0]); MeshBuilder.SetInstanceNormal(InstanceIDs[0], Normal); MeshBuilder.SetInstanceUV(InstanceIDs[0], FVector2D(0.0f, 0.0f)); // UV0 InstanceIDs[1] = MeshBuilder.AppendInstance(VertexIDs[1]); MeshBuilder.SetInstanceNormal(InstanceIDs[1], Normal); MeshBuilder.SetInstanceUV(InstanceIDs[1], FVector2D(1.0f, 0.0f)); InstanceIDs[2] = MeshBuilder.AppendInstance(VertexIDs[2]); MeshBuilder.SetInstanceNormal(InstanceIDs[2], Normal); MeshBuilder.SetInstanceUV(InstanceIDs[2], FVector2D(1.0f, 1.0f)); InstanceIDs[3] = MeshBuilder.AppendInstance(VertexIDs[3]); MeshBuilder.SetInstanceNormal(InstanceIDs[3], Normal); MeshBuilder.SetInstanceUV(InstanceIDs[3], FVector2D(0.0f, 1.0f)); // 6. 创建多边形组(Polygon Group),通常一个组对应一个材质槽位。 FPolygonGroupID PolygonGroupID = MeshBuilder.AppendPolygonGroup(); // 7. 用两个三角形(多边形)组成一个四边形 // 三角形1:顶点0, 1, 2 (左下,右下,右上) TArray<FVertexInstanceID> Triangle1; Triangle1.Add(InstanceIDs[0]); Triangle1.Add(InstanceIDs[1]); Triangle1.Add(InstanceIDs[2]); MeshBuilder.AppendTriangle(Triangle1, PolygonGroupID); // 三角形2:顶点0, 2, 3 (左下,右上,左上) TArray<FVertexInstanceID> Triangle2; Triangle2.Add(InstanceIDs[0]); Triangle2.Add(InstanceIDs[2]); Triangle2.Add(InstanceIDs[3]); MeshBuilder.AppendTriangle(Triangle2, PolygonGroupID); }关键点解析:
AppendTriangle函数会自动处理多边形的创建和顶点实例的关联。你也可以使用AppendPolygon来创建四边形或更多边的多边形,但最终引擎渲染时都会三角化。PolygonGroupID至关重要,它建立了多边形与材质槽位的链接。后面我们将材质赋值给这个组。
实操心得:法线与UV的常见坑法线方向错误会导致光照异常(背面变亮或全黑)。确保法线方向与多边形顶点顺序满足右手定则(在UE的坐标系下,默认逆时针顶点顺序的面为正面)。UV设置错误会导致贴图拉伸、错乱。对于简单平面,从(0,0)到(1,1)是标准做法。对于复杂模型,需要你计算或生成合理的UV坐标,这可能涉及UV展开算法,是另一个深水区。
4. 将MeshDescription转换为StaticMesh资产
现在我们有了一个完整的FMeshDescription,但它还只是内存中的数据。下一步是将其转换为引擎可管理和渲染的UStaticMesh资产。
4.1 构建渲染数据与LOD
UStaticMesh的核心是它的渲染数据(FStaticMeshRenderData),而MeshDescription需要被处理成这些数据。
void UMyMeshGenerator::BuildQuadMesh(UStaticMesh* OutStaticMesh) { // ... 前述构建MeshDescription的代码 ... // 8. 为StaticMesh创建新的模型(Source Model)并填入MeshDescription if (OutStaticMesh->GetNumSourceModels() == 0) { OutStaticMesh->AddSourceModel(); } FStaticMeshSourceModel& SourceModel = OutStaticMesh->GetSourceModel(0); // 将我们构建的MeshDescription赋予给SourceModel FMeshDescription* OriginalMeshDescription = OutStaticMesh->GetMeshDescription(0); if (OriginalMeshDescription) { *OriginalMeshDescription = MeshDescription; // 拷贝数据 } else { OutStaticMesh->CreateMeshDescription(0, MeshDescription); } // 9. 设置LOD组和生成设置(这一步影响网格重建的质量和性能) SourceModel.BuildSettings.bRecomputeNormals = false; // 我们已手动设置法线,无需重算 SourceModel.BuildSettings.bRecomputeTangents = true; // 通常需要重新计算切线以支持法线贴图 SourceModel.BuildSettings.bUseMikkTSpace = true; // 推荐使用MikkTSpace空间计算切线,标准更高 SourceModel.BuildSettings.bRemoveDegenerates = true; // 移除退化三角形(面积接近零的三角形) SourceModel.BuildSettings.bGenerateLightmapUVs = false; // 运行时生成通常不需要光照贴图UV,除非你后续烘焙光照 // 10. 指定材质槽位。槽位索引对应之前的PolygonGroupID。 // 首先清空原有材质 OutStaticMesh->GetStaticMaterials().Empty(); // 创建一个新的材质槽位,并赋予一个默认材质(或你指定的材质) FStaticMaterial StaticMaterial; StaticMaterial.MaterialInterface = LoadObject<UMaterialInterface>(nullptr, TEXT("/Game/Path/To/Your_Material.Your_Material")); // 动态加载或使用默认材质 if (!StaticMaterial.MaterialInterface) { // 如果加载失败,使用引擎默认材质 StaticMaterial.MaterialInterface = UMaterial::GetDefaultMaterial(MD_Surface); } StaticMaterial.MaterialSlotName = FName("GeneratedMaterialSlot"); StaticMaterial.UVChannelData = FMeshUVChannelInfo(); // UV通道信息 OutStaticMesh->GetStaticMaterials().Add(StaticMaterial); // 11. 关键一步:构建StaticMesh的渲染数据 OutStaticMesh->Build(false); // 参数bAsync设为false,表示同步构建。对于运行时生成,同步即可。 // 12. 通知资产系统此资源已变更,需要更新 OutStaticMesh->PostEditChange(); OutStaticMesh->MarkPackageDirty(); }代码详解:
SourceModel存储了网格的原始描述(即我们的MeshDescription)和如何从它构建渲染数据的设置。BuildSettings是控制网格处理流程的关键。bRecomputeTangents通常要设为true,因为正确的切线对于使用法线贴图的材质至关重要。如果你手动计算并设置了切线,可以关闭它。OutStaticMesh->Build(false);是核心调用。它触发引擎内部管线,根据MeshDescription和BuildSettings生成顶点缓冲区、索引缓冲区等GPU所需的渲染数据,以及可选的碰撞数据(下一步会添加)。这是一个相对耗时的操作,对于复杂网格需注意性能。PostEditChange()和MarkPackageDirty()主要用于在编辑器环境下通知UI更新和标记保存。在纯运行时逻辑中,如果网格不保存为资产,可能不需要,但调用它们通常无害。
注意事项:材质加载代码中我们尝试从路径加载一个材质。在打包后的游戏中,直接使用路径字符串加载可能失败。更健壮的做法是:
- 将可能用到的材质作为
UPROPERTY()暴露在蓝图或C++类中,在编辑器中指定引用。- 使用
ConstructorHelpers::FObjectFinder在构造函数中查找(仅适用于原生C++类且材质在编译时已知)。- 使用异步加载(
StreamableManager)或同步加载(LoadObject)时,确保路径正确且资产已打包。- 对于完全动态的材质,也可以考虑使用
UMaterialInstanceDynamic在运行时创建和修改材质实例。
5. 为动态生成的StaticMesh添加碰撞体
没有碰撞的网格只是一个“幽灵”,物理系统会忽略它。为运行时生成的StaticMesh添加碰撞体有几种方式,我们将介绍最常用的两种:简单形状碰撞和自动凸包碰撞。
5.1 方法一:添加简单盒体碰撞
对于像我们生成的四边形平面这种形状规则的网格,添加一个匹配的盒体碰撞是最简单高效的。
#include "PhysicsEngine/BodySetup.h" void UMyMeshGenerator::AddBoxCollisionToStaticMesh(UStaticMesh* StaticMesh) { if (!StaticMesh || !StaticMesh->GetBodySetup()) { return; } // 获取或创建BodySetup(物理数据设置) UBodySetup* BodySetup = StaticMesh->GetBodySetup(); if (!BodySetup) { BodySetup = NewObject<UBodySetup>(StaticMesh); StaticMesh->SetBodySetup(BodySetup); } // 创建一个FKBoxElem(盒体碰撞元素) FKBoxElem BoxElem; BoxElem.Center = FVector(0.0f, 0.0f, 0.0f); // 中心与网格原点对齐 BoxElem.X = 100.0f; // 长度 (X轴方向,对应我们平面的宽度) BoxElem.Y = 100.0f; // 宽度 (Y轴方向,对应我们平面的深度) BoxElem.Z = 10.0f; // 高度 (Z轴方向,给一个较小的厚度,因为平面本身没有厚度) // 将碰撞元素添加到BodySetup中 BodySetup->AggGeom.BoxElems.Add(BoxElem); // 必须重新创建物理状态,碰撞才会生效 BodySetup->CreatePhysicsMeshes(); BodySetup->InvalidatePhysicsData(); // 标记物理数据已变更 BodySetup->CreatePhysicsMeshes(); // 重新创建 // 同样,需要通知StaticMesh更新 StaticMesh->PostEditChange(); }参数选择逻辑:
BoxElem.X/Y/Z定义了盒体在局部空间中的半轴尺寸(注意:是半轴,即从中心到边的距离)。我们的平面顶点在X和Y方向从-50到50,所以总长度是100,因此半轴长度X和Y设为50.0f。上面代码中写了100,这是一个常见错误点,正确的应该是50。BoxElem.Z给了平面一个很小的厚度(比如5或10),这是因为一个绝对扁平的盒体在物理引擎中可能行为不稳定。给予微小厚度能确保碰撞检测更可靠。BodySetup->CreatePhysicsMeshes()调用是必须的,它根据AggGeom中的几何元素(盒体、球体、胶囊体、凸包等)生成物理引擎(如Chaos)内部使用的碰撞网格。
5.2 方法二:基于渲染网格生成自动凸包碰撞
对于形状不规则的动态网格,手动设置简单碰撞体太麻烦。我们可以让引擎根据渲染网格自动生成一个(或多个)凸包(Convex Hull)来近似其形状。这正是静态网格体编辑器中“自动凸包碰撞”功能在运行时的实现。
#include "PhysicsEngine/BodySetup.h" #include "PhysicsEngine/ConvexElem.h" void UMyMeshGenerator::AddAutoConvexCollisionToStaticMesh(UStaticMesh* StaticMesh) { if (!StaticMesh) { return; } UBodySetup* BodySetup = StaticMesh->GetBodySetup(); if (!BodySetup) { BodySetup = NewObject<UBodySetup>(StaticMesh); StaticMesh->SetBodySetup(BodySetup); } // 1. 清除旧的凸包碰撞(如果有) BodySetup->AggGeom.ConvexElems.Empty(); // 2. 从StaticMesh的渲染数据中获取顶点位置数据 // 注意:这里获取的是简化后的LOD0的顶点数据,用于生成碰撞。 FPositionVertexBuffer* PositionVertexBuffer = nullptr; if (StaticMesh->GetRenderData() && StaticMesh->GetRenderData()->LODResources.Num() > 0) { PositionVertexBuffer = &StaticMesh->GetRenderData()->LODResources[0].VertexBuffers.PositionVertexBuffer; } if (!PositionVertexBuffer || PositionVertexBuffer->GetNumVertices() == 0) { UE_LOG(LogTemp, Warning, TEXT("Failed to get vertex data for convex collision generation.")); return; } // 3. 创建一个凸包元素并填充顶点 FKConvexElem& NewConvexElem = BodySetup->AggGeom.ConvexElems[BodySetup->AggGeom.ConvexElems.AddDefaulted()]; TArray<FVector> ConvexVerts; ConvexVerts.Reserve(PositionVertexBuffer->GetNumVertices()); for (uint32 i = 0; i < PositionVertexBuffer->GetNumVertices(); ++i) { ConvexVerts.Add(PositionVertexBuffer->VertexPosition(i)); } // 4. 使用顶点数据初始化凸包 // bComputeConvexHull: true表示从点云计算凸包;false则表示直接使用这些点(要求点本身构成凸包)。 // bCreateSkin: 是否创建用于膨胀的“皮肤”,通常保持默认。 if (!NewConvexElem.InitConvexElem(ConvexVerts, true)) { UE_LOG(LogTemp, Warning, TEXT("Failed to initialize convex hull from mesh vertices.")); BodySetup->AggGeom.ConvexElems.Empty(); return; } // 5. (可选)设置凸包分解参数,控制生成碰撞的复杂度和精度 // 这类似于编辑器中的“外壳数量”、“最大外壳顶点数”等选项。 // 对于简单网格,一个凸包可能就够了。对于复杂中空物体,可能需要分解成多个凸包。 // BodySetup->bGenerateMirroredCollision = false; // BodySetup->bDoubleSidedGeometry = false; // BodySetup->CollisionTraceFlag = CTF_UseDefault; // 碰撞精度 // 6. 重新创建物理网格 BodySetup->InvalidatePhysicsData(); BodySetup->CreatePhysicsMeshes(); StaticMesh->PostEditChange(); }关键点与避坑指南:
- 顶点数据来源:我们是从
LODResources[0](即最高细节级别的渲染数据)中获取顶点。这确保了碰撞体基于视觉上看到的网格生成。你也可以从MeshDescription直接获取顶点,但需注意坐标空间是否一致。 - 凸包计算:
InitConvexElem函数内部会调用第三方库(如V-HACD的简化集成)计算点云的凸包。计算开销与顶点数量成正比。对于非常复杂的网格,在运行时计算可能成为性能瓶颈。 - 多凸包与分解:一个复杂的非凸物体(如一个中空的杯子)无法用一个凸包完美表示。需要用多个凸包来拼接。上面的代码只生成了一个凸包。要实现自动分解,需要更复杂的逻辑,可能涉及调用
FKAggregateGeom的其他方法或使用Chaos物理模块提供的工具。在运行时,为了性能,通常要么接受单凸包近似的误差,要么对生成的网格复杂度进行严格限制。 - 性能考量:
CreatePhysicsMeshes()是一个同步的、可能耗时的操作,尤其是在生成复杂凸包时。切忌在每帧或高频调用的函数中执行此操作。应该在网格生成完成、确定不再修改后,一次性调用。
6. 完整流程封装与蓝图调用
我们将上述步骤整合到一个易于使用的函数或类方法中,并暴露给蓝图,方便设计师和策划调用。
6.1 创建蓝图可调用的函数
首先,在一个继承自UObject或AActor的C++类中(例如URuntimeMeshGenerator),创建我们的核心函数。
// RuntimeMeshGenerator.h #pragma once #include "CoreMinimal.h" #include "UObject/NoExportTypes.h" #include "RuntimeMeshGenerator.generated.h" UCLASS(BlueprintType) class YOURPROJECT_API URuntimeMeshGenerator : public UObject { GENERATED_BODY() public: // 生成一个简单的平面网格并赋予材质和碰撞 UFUNCTION(BlueprintCallable, Category = "Runtime Mesh") static UStaticMesh* CreateSimplePlaneMesh(UObject* Outer, FVector2D Size, UMaterialInterface* Material, bool bGenerateCollision); };// RuntimeMeshGenerator.cpp #include "RuntimeMeshGenerator.h" #include "Engine/StaticMesh.h" #include "Materials/MaterialInterface.h" // ... 包含其他必要的头文件 ... UStaticMesh* URuntimeMeshGenerator::CreateSimplePlaneMesh(UObject* Outer, FVector2D Size, UMaterialInterface* Material, bool bGenerateCollision) { if (!Outer || Size.X <= 0 || Size.Y <= 0) { return nullptr; } // 1. 创建一个新的StaticMesh对象 FName MeshName = MakeUniqueObjectName(Outer, UStaticMesh::StaticClass(), FName("RuntimeGeneratedMesh")); UStaticMesh* NewStaticMesh = NewObject<UStaticMesh>(Outer, MeshName); // 2. 构建MeshDescription (平面) FMeshDescription MeshDescription; FStaticMeshAttributes Attributes(MeshDescription); Attributes.Register(); FMeshDescriptionBuilder MeshBuilder; MeshBuilder.SetMeshDescription(&MeshDescription); MeshBuilder.EnablePolyGroups(); float HalfX = Size.X / 2.0f; float HalfY = Size.Y / 2.0f; FVertexID VIDs[4]; VIDs[0] = MeshBuilder.AppendVertex(FVector(-HalfX, -HalfY, 0)); VIDs[1] = MeshBuilder.AppendVertex(FVector( HalfX, -HalfY, 0)); VIDs[2] = MeshBuilder.AppendVertex(FVector( HalfX, HalfY, 0)); VIDs[3] = MeshBuilder.AppendVertex(FVector(-HalfX, HalfY, 0)); FVector Normal = FVector(0, 0, 1); TArray<FVertexInstanceID> InstanceIDs; InstanceIDs.SetNum(4); InstanceIDs[0] = MeshBuilder.AppendInstance(VIDs[0]); MeshBuilder.SetInstanceNormal(InstanceIDs[0], Normal); MeshBuilder.SetInstanceUV(InstanceIDs[0], FVector2D(0, 0)); InstanceIDs[1] = MeshBuilder.AppendInstance(VIDs[1]); MeshBuilder.SetInstanceNormal(InstanceIDs[1], Normal); MeshBuilder.SetInstanceUV(InstanceIDs[1], FVector2D(1, 0)); InstanceIDs[2] = MeshBuilder.AppendInstance(VIDs[2]); MeshBuilder.SetInstanceNormal(InstanceIDs[2], Normal); MeshBuilder.SetInstanceUV(InstanceIDs[2], FVector2D(1, 1)); InstanceIDs[3] = MeshBuilder.AppendInstance(VIDs[3]); MeshBuilder.SetInstanceNormal(InstanceIDs[3], Normal); MeshBuilder.SetInstanceUV(InstanceIDs[3], FVector2D(0, 1)); FPolygonGroupID PGID = MeshBuilder.AppendPolygonGroup(); TArray<FVertexInstanceID> Tri; Tri = { InstanceIDs[0], InstanceIDs[1], InstanceIDs[2] }; MeshBuilder.AppendTriangle(Tri, PGID); Tri = { InstanceIDs[0], InstanceIDs[2], InstanceIDs[3] }; MeshBuilder.AppendTriangle(Tri, PGID); // 3. 将MeshDescription赋给StaticMesh并构建 NewStaticMesh->CreateMeshDescription(0, MeshDescription); FStaticMeshSourceModel& SourceModel = NewStaticMesh->GetSourceModel(0); SourceModel.BuildSettings.bRecomputeNormals = false; SourceModel.BuildSettings.bRecomputeTangents = true; SourceModel.BuildSettings.bUseMikkTSpace = true; SourceModel.BuildSettings.bRemoveDegenerates = true; NewStaticMesh->GetStaticMaterials().Empty(); FStaticMaterial StaticMat; StaticMat.MaterialInterface = Material ? Material : UMaterial::GetDefaultMaterial(MD_Surface); StaticMat.MaterialSlotName = FName("MainMaterial"); NewStaticMesh->GetStaticMaterials().Add(StaticMat); NewStaticMesh->Build(false); // 4. 根据参数生成碰撞 if (bGenerateCollision) { UBodySetup* BodySetup = NewStaticMesh->GetBodySetup(); if (!BodySetup) { BodySetup = NewObject<UBodySetup>(NewStaticMesh); NewStaticMesh->SetBodySetup(BodySetup); } BodySetup->AggGeom.BoxElems.Empty(); FKBoxElem BoxElem; BoxElem.Center = FVector(0, 0, 0); BoxElem.X = HalfX; // 注意这里是半轴长度 BoxElem.Y = HalfY; BoxElem.Z = 5.0f; // 给予一个小的厚度 BodySetup->AggGeom.BoxElems.Add(BoxElem); BodySetup->InvalidatePhysicsData(); BodySetup->CreatePhysicsMeshes(); } NewStaticMesh->PostEditChange(); return NewStaticMesh; }6.2 在蓝图中使用
编译C++代码后,你可以在蓝图中直接调用这个静态函数。
- 在蓝图事件图表中,右键搜索
Create Simple Plane Mesh。 - 提供
Outer(通常用Get Transient Package或某个持久化对象),指定平面尺寸和材质。 - 将返回的
Static Mesh对象,通过Spawn Actor From Class生成一个Static Mesh Actor,或者通过Set Static Mesh节点设置给场景中已有的Static Mesh Component。
这样,你就拥有了一个在运行时完全通过代码生成、带有碰撞和材质、可以立即投入使用的静态网格体。
7. 常见问题、性能优化与高级技巧
在实际项目中应用此技术时,你会遇到各种问题。以下是我踩过的一些坑和总结的经验。
7.1 常见问题排查速查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 网格不显示/全黑 | 1. 法线方向错误。 2. 材质为空或加载失败。 3. 顶点顺序(缠绕顺序)不正确,导致背面剔除。 | 1. 检查法线计算,确保朝向多边形正面(通常为逆时针顶点顺序的法线方向)。 2. 检查返回的 StaticMesh的材质槽位是否有效。在函数内打印或使用IsValid检查材质引用。3. 尝试在材质中禁用背面剔除(Two Sided),或检查 AppendTriangle的顶点顺序。 |
| 碰撞无效,物体穿模 | 1. 碰撞体未成功生成或添加。 2. BodySetup的CollisionTraceFlag设置不当。3. Actor或Component的碰撞预设(Collision Preset)未设置。 | 1. 在编辑器中查看生成的StaticMesh资产,看碰撞预览是否显示(快捷键Alt+C)。2. 确保 CreatePhysicsMeshes()被调用且没有错误日志。3. 检查使用该网格的Actor/Component,其碰撞预设是否包含对相应通道(如WorldStatic, Pawn)的阻挡(Block)响应。 |
| UV错误,贴图拉伸 | 1. 为顶点实例设置的UV坐标错误。 2. UV超出了[0,1]范围且材质寻址模式为Clamp。 | 1. 仔细核对每个顶点实例的UV值。对于复杂网格,需要正确的UV展开算法。 2. 在材质中检查纹理采样节点的寻址模式,或确保UV在合理范围内。 |
| 生成复杂网格时崩溃或卡顿 | 1. 顶点/多边形数量过多,单帧生成超时。 2. Build()或CreatePhysicsMeshes()在主线程耗时过长。3. 内存泄漏(反复创建 UStaticMesh未释放)。 | 1. 限制单次生成的网格复杂度。对于复杂模型,考虑分帧生成或使用异步任务。 2. 将耗时的网格构建和物理创建操作放到异步任务(AsyncTask)或工作线程中,但需注意UE对象系统的线程限制。 3. 使用 NewObject时指定正确的Outer,对于临时网格,可将其Outer设为GetTransientPackage(),引擎会在合适时机垃圾回收。对于需要持久化的,要管理好引用。 |
| 生成的网格在编辑器里能看到,打包后看不到 | 1. 材质引用路径在打包后失效。 2. 依赖的模块未正确打包。 | 1. 避免在运行时使用硬编码的绝对路径加载材质。使用UPROPERTY引用或通过资产管理器加载。2. 确保项目 .Build.cs中添加了"MeshDescription","StaticMeshDescription","MeshBuilder"等模块依赖。 |
7.2 性能优化要点
- 批量化与复用:避免每帧都创建新网格。如果可能,预生成一些基础网格块,在运行时通过实例化(Instanced Static Mesh Component)或动态修改顶点缓冲区(更高级)来复用。
- 简化碰撞:物理碰撞的计算成本很高。对于视觉复杂但碰撞要求不高的物体,务必使用简化的碰撞体(如盒体、胶囊体、少量凸包),而不是复杂的三角网格碰撞(
CTF_UseComplexAsSimple)。 - 异步生成:对于需要实时生成但非即时需要的网格(如预加载下一区域的动态地形),使用
AsyncTask或自定义的异步线程进行MeshDescription的构建和Build()调用,完成后在主线程将结果赋予StaticMesh Component。注意,UStaticMesh::Build()和UBodySetup::CreatePhysicsMeshes()内部可能涉及渲染线程和物理线程的交互,完全移出游戏线程需要谨慎处理。 - LOD考虑:运行时生成的网格通常只有LOD0。对于可能出现在远景中的大型动态网格,考虑实现简单的LOD生成算法(如顶点合并),或者使用
UStaticMesh的BuildSettings中的自动LOD生成选项(但这在运行时可能较慢)。
7.3 高级技巧延伸
- 动态更新网格:
MeshDescription和UStaticMesh构建完成后,并非不可变。你可以获取MeshDescription进行修改(如顶点动画),然后再次调用Build()。但这比从头创建更复杂,需要处理顶点缓冲区索引的同步等问题,且频繁重建开销大。 - 与GeometryScript结合:对于极其复杂的动态建模操作(如布尔运算、细分、平滑),可以先用
DynamicMesh(通过GeometryScript插件)进行计算,然后再将DynamicMesh转换为MeshDescription。插件提供了转换函数。 - Nanite支持:UE5的Nanite虚拟几何体系统对网格有特定要求(如需要支持网格距离场)。要让运行时生成的网格支持Nanite,需要在
BuildSettings中启用相关选项,并确保网格的三角形数量足够多且分布合理以体现其优势。这属于更进阶的主题。
通过以上步骤,你应该已经掌握了在UE5运行时使用MeshDescription动态构建带碰撞和材质的StaticMesh的核心流程。这项技术打开了程序化内容生成的大门,从简单的几何体到复杂的地形、建筑,都可以在你的游戏世界中实时涌现。关键在于理解数据流动的每个环节——从定义顶点到构建渲染数据,再到装配物理碰撞——并做好性能和内存管理。