UE5 通过Dynamic Mesh制作实时子弹击中玻璃破碎效果

📅 2026/8/10 12:05:19
UE5 通过Dynamic Mesh制作实时子弹击中玻璃破碎效果
首先了解Dynamic Mesh的原理Dynamic Mesh 是什么DynamicMesh在 UE 里主要指UE::Geometry::FDynamicMesh3这一套东西。它本质上是一个CPU 侧的运行时网格数据结构。也就是说它不是 GPU 上已经画出来的东西也不是一个提前导入好的 Static Mesh 资产。它是一份可以在运行时被 C 修改的几何数据有哪些点 哪些点组成三角形 每个三角形用什么材质 每个顶点/三角形的法线是什么 UV 是什么 颜色是什么这些数据存在 CPU 内存里。你可以随时增删顶点、增删三角形、重新生成法线、重新生成 UV然后再交给组件显示。可以把它理解成FDynamicMesh3 网格数据本体 UDynamicMeshComponent 把这份网格显示到世界里的组件Static Mesh 和 Dynamic Mesh 的区别StaticMesh更像是“烤好的模型资产”。例如你从 Maya/Blender 导入一块玻璃SM_GlassPane.uasset它的顶点、三角形、UV、LOD、碰撞通常提前生成好。运行时可以移动它、换材质但不适合频繁改它的拓扑。DynamicMesh是“运行时现场捏出来的模型”。比如你想在游戏运行时鼠标切割模型 布尔运算 生成洞 实时破碎 运行时建模工具 程序化地形 动态切片这些就更适合DynamicMesh。区别可以这样看Static Mesh 提前做好的模型 运行时主要用来显示 修改几何结构不方便 Dynamic Mesh 运行时生成的模型数据 可以随时改点、线、面 适合程序化几何和破碎Dynamic Mesh 的基本组成一个网格最基础的是Vertex顶点 Triangle三角形 Edge边 Attributes属性比如一块矩形玻璃v3 -------- v2 | / | | / | | / | | / | v0 -------- v1它有 4 个顶点v0 (-100, -50, 0) v1 ( 100, -50, 0) v2 ( 100, 50, 0) v3 (-100, 50, 0)但 GPU 通常画三角形所以矩形会拆成两个三角形t0 v0, v1, v2 t1 v0, v2, v3在FDynamicMesh3里大概就是UE::Geometry::FDynamicMesh3 Mesh; int V0 Mesh.AppendVertex(FVector3d(-100, -50, 0)); int V1 Mesh.AppendVertex(FVector3d( 100, -50, 0)); int V2 Mesh.AppendVertex(FVector3d( 100, 50, 0)); int V3 Mesh.AppendVertex(FVector3d(-100, 50, 0)); Mesh.AppendTriangle(V0, V1, V2); Mesh.AppendTriangle(V0, V2, V3);到这一步Mesh只是数据还没有出现在场景里。要显示它需要UDynamicMeshComponent* Comp; Comp-SetMesh(MoveTemp(Mesh));这样UDynamicMeshComponent才会把它送到渲染线程让你在场景里看到。为什么叫 Dynamic因为它可以动态改。比如我想让玻璃裂开我可以不是换模型而是运行时重新生成碎片 mesh。假设原来是一整块------------------- | | | | | | -------------------被子弹打中后我生成很多 polygon------------------ |\ | /|\ /| | \ | / | \ / | | \ | / | \ / | |--- * ---|---\ /---| | / | \ | / \ | | / | \ | / \ | |/ | \| / \ | ------------------每一个碎片 polygon 再单独变成一个FDynamicMesh3Shard 0 - DynamicMeshComponent 0 Shard 1 - DynamicMeshComponent 1 Shard 2 - DynamicMeshComponent 2 ...然后每个 Component 都可以SetSimulatePhysics(true); SetCollisionEnabled(QueryAndPhysics); SetPhysicsLinearVelocity(...);这样每块碎片就变成独立物理物体。DynamicMesh 不是 Actor这个点很重要。FDynamicMesh3不是 Actor也不是 Component。它只是数据结构。它自己不会显示不会碰撞不会 Tick不会物理模拟。关系是AActor └── UDynamicMeshComponent └── FDynamicMesh3AActor是场景对象。UDynamicMeshComponent是挂在 Actor 上的组件负责显示、材质、碰撞、物理。FDynamicMesh3是组件内部拿来显示的几何数据。所以你的玻璃 Actor 里会有IntactGlass CreateDefaultSubobjectUDynamicMeshComponent(TEXT(IntactGlass));这代表完整玻璃的显示组件。破碎时会UDynamicMeshComponent* Comp NewObjectUDynamicMeshComponent(this); Comp-RegisterComponent(); Comp-SetMesh(MoveTemp(Mesh));这代表运行时生成一个碎片组件。DynamicMesh 怎么从 polygon 变成立体碎片你这个插件里不是只画一张薄片而是要有厚度。比如一个 2D 碎片轮廓p0 ----- p1 \ | \ | p2 ---p3如果只是 2D它没有厚度只是一张面。要变成玻璃碎片需要前后两个面正面 z Thickness / 2 背面 z -Thickness / 2比如每个 2D 点会生成两个 3D 顶点p0_front (p0.x, p0.y, t/2) p0_back (p0.x, p0.y, -t/2) p1_front (p1.x, p1.y, t/2) p1_back (p1.x, p1.y, -t/2)然后要生成三类三角形1. 正面三角形 2. 背面三角形 3. 侧边三角形例如正面可以从中心扇形三角化front: center, p0, p1 center, p1, p2 center, p2, p3背面也来一套但顺序反过来保证法线朝外。侧边就是连接前后点p0_front ---- p1_front | | p0_back ---- p1_back拆成两个三角形p0_front, p1_front, p1_back p0_front, p1_back, p0_back这样这个碎片就有厚度了。为什么三角形顺序重要三角形不是只记录“三个点”还记录顺序。Mesh.AppendTriangle(A, B, C);和Mesh.AppendTriangle(A, C, B);是不一样的。顺序决定法线方向。一般用右手法则逆时针正面朝向你 顺时针背面朝向你如果顺序错了会出现看不见 背面剔除 法线反了 光照奇怪 碰撞奇怪所以玻璃碎片生成时正面和背面的三角形顺序要相反。DynamicMesh 的 Attribute 是什么只存顶点和三角形模型可以显示但会很粗糙。还需要属性Normal 法线 UV 贴图坐标 Material ID 材质槽 Vertex Color 顶点色法线决定光照。UV 决定材质怎么贴。Material ID 决定某个三角形用哪个材质槽。比如玻璃碎片可以有正面/背面玻璃材质 侧边破碎边缘材质这时候就可以给不同三角形设置不同 Material ID。如果不设置 UV材质可能还能显示颜色但贴图、法线贴图、裂纹图就不一定正确。DynamicMesh 和碰撞FDynamicMesh3本身只是渲染网格不天然等于物理碰撞。要让碎片能掉到地上需要给UDynamicMeshComponent设置碰撞。常见方式有Comp-SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics); Comp-SetSimulatePhysics(true);但还不够。物理需要知道碰撞形状。你这个插件里用了类似Comp-SetSimpleCollisionShapes(RuntimeGlass::BuildConvexCollision(...), true);意思是根据碎片轮廓生成一个简单凸包碰撞。为什么不用复杂三角网格碰撞因为小碎片很多如果每片都用复杂三角形碰撞性能很差而且动态物理也不一定支持得好。所以通常做法是显示网格细一点可以真实 物理碰撞简化凸包这也是游戏里常见的做法。DynamicMesh 在玻璃破碎里的完整流程你的玻璃插件大概是这个逻辑1. 生成完整玻璃矩形 2. 子弹命中拿到 HitPoint 3. 把 HitPoint 转到玻璃本地坐标 4. 从命中点向外发射很多射线 5. 生成一圈一圈的碎片 polygon 6. 每个 polygon 裁剪到玻璃矩形边界内 7. 每个 polygon 变成带厚度的 DynamicMesh 8. 每个 DynamicMesh 放进一个 UDynamicMeshComponent 9. 给每个碎片设置材质和碰撞 10. 开启物理 11. 给速度 12. 碎片掉落核心图是完整玻璃 local 2D 平面 ----------------------- | / | \ | | / | \ | | / | \ | |----/-------*-------\--| | / | \ | | / | | ----------------------- * HitPoint 每个小区域都是一个 2D polygon 每个 polygon - 立体 DynamicMesh 碎片为什么用 DynamicMesh 做玻璃破碎因为它适合运行时生成不同形状。玻璃每次命中点不同打左上角碎片分布不同 打中间碎片分布不同 打边缘碎片分布不同如果用 Static Mesh就需要提前准备很多破碎模型。用 DynamicMesh 可以根据命中点实时生成碎片 每次破碎形状不同 可以调 RayCount / RingCount / Jitter 可以控制碎片数量和大小这就是它适合你的插件的原因。但是 DynamicMesh 不等于好看的破碎这个也必须说清楚。DynamicMesh只解决我能不能运行时生成碎片几何它不自动解决碎片怎么飞 裂纹怎么扩散 玻璃材质怎么表现 碰撞是否稳定 过程是否有观赏性这些是另外的系统破碎图案生成算法 动画阶段 物理参数 材质裂纹 碰撞形状 相机距离 帧率所以你现在一直不满意的“没有掉落过程”不是 DynamicMesh 本身的问题而是碎片生成后物理运动和视觉接管没有设计好目录结构YourProject/ Plugins/ RuntimeGlassDestruction/ RuntimeGlassDestruction.uplugin Source/ RuntimeGlassDestruction/ RuntimeGlassDestruction.Build.cs Public/ RuntimeGlassPaneActor.h Private/ RuntimeGlassDestructionModule.cpp RuntimeGlassPaneActor.cpp下面是完整代码。RuntimeGlassDestruction.uplugin{ FileVersion: 3, Version: 1, VersionName: 1.0, FriendlyName: Runtime Glass Destruction, Description: Runtime bullet-hit spiderweb glass fracture using Dynamic Mesh shards., Category: Runtime, EnabledByDefault: false, CanContainContent: false, Modules: [ { Name: RuntimeGlassDestruction, Type: Runtime, LoadingPhase: Default } ] }Source/RuntimeGlassDestruction/RuntimeGlassDestruction.Build.csusing UnrealBuildTool; public class RuntimeGlassDestruction : ModuleRules { public RuntimeGlassDestruction(ReadOnlyTargetRules Target) : base(Target) { PCHUsage PCHUsageMode.UseExplicitOrSharedPCHs; PublicDependencyModuleNames.AddRange(new[] { Core, CoreUObject, Engine, GeometryCore, GeometryFramework, PhysicsCore }); } }Private/RuntimeGlassDestructionModule.cpp#include Modules/ModuleManager.h class FRuntimeGlassDestructionModule : public IModuleInterface { }; IMPLEMENT_MODULE(FRuntimeGlassDestructionModule, RuntimeGlassDestruction)Public/RuntimeGlassPaneActor.h#pragma once #include CoreMinimal.h #include Engine/HitResult.h #include GameFramework/Actor.h #include RuntimeGlassPaneActor.generated.h class UDynamicMeshComponent; class USceneComponent; class UMaterialInterface; USTRUCT(BlueprintType) struct FRuntimeGlassShard2D { GENERATED_BODY() UPROPERTY(VisibleAnywhere, BlueprintReadOnly) TArrayFVector2D Points; }; UCLASS(Blueprintable) class RUNTIMEGLASSDESTRUCTION_API ARuntimeGlassPaneActor : public AActor { GENERATED_BODY() public: ARuntimeGlassPaneActor(); UPROPERTY(VisibleAnywhere, BlueprintReadOnly) TObjectPtrUSceneComponent SceneRoot; UPROPERTY(VisibleAnywhere, BlueprintReadOnly) TObjectPtrUDynamicMeshComponent IntactGlass; UPROPERTY(EditAnywhere, BlueprintReadWrite, CategoryGlass|Shape) float Width 300.0f; UPROPERTY(EditAnywhere, BlueprintReadWrite, CategoryGlass|Shape) float Height 200.0f; UPROPERTY(EditAnywhere, BlueprintReadWrite, CategoryGlass|Shape) float Thickness 2.0f; UPROPERTY(EditAnywhere, BlueprintReadWrite, CategoryGlass|Fracture) int32 RayCount 18; UPROPERTY(EditAnywhere, BlueprintReadWrite, CategoryGlass|Fracture) int32 RingCount 5; UPROPERTY(EditAnywhere, BlueprintReadWrite, CategoryGlass|Fracture) float AngleJitter 0.12f; UPROPERTY(EditAnywhere, BlueprintReadWrite, CategoryGlass|Fracture) float RadiusJitter 0.12f; UPROPERTY(EditAnywhere, BlueprintReadWrite, CategoryGlass|Fracture) float MinShardArea 10.0f; UPROPERTY(EditAnywhere, BlueprintReadWrite, CategoryGlass|Physics) float ShotImpulse 1200.0f; UPROPERTY(EditAnywhere, BlueprintReadWrite, CategoryGlass|Physics) float RadialImpulse 350.0f; UPROPERTY(EditAnywhere, BlueprintReadWrite, CategoryGlass|Physics) float ShardMassKg 0.08f; UPROPERTY(EditAnywhere, BlueprintReadWrite, CategoryGlass|Rendering) TObjectPtrUMaterialInterface GlassMaterial; virtual void OnConstruction(const FTransform Transform) override; virtual void BeginPlay() override; UFUNCTION(BlueprintCallable, CategoryGlass) void RebuildGlass(); UFUNCTION(BlueprintCallable, CategoryGlass) void BreakAtWorldPoint(FVector WorldHitPoint, FVector ShotDirection, int32 Seed 12345); UFUNCTION(BlueprintCallable, CategoryGlass) void BreakFromHitResult(const FHitResult Hit, FVector ShotDirection, int32 Seed 12345); UFUNCTION(BlueprintCallable, CategoryGlass) void ResetGlass(); private: UPROPERTY(Transient) bool bBroken false; UPROPERTY(Transient) TArrayTObjectPtrUDynamicMeshComponent Shards; void GenerateShards(FVector2D LocalHitPoint, int32 Seed, TArrayFRuntimeGlassShard2D OutShards) const; void SpawnShard(const FRuntimeGlassShard2D Shard, FVector WorldHitPoint, FVector ShotDirection); void ClearShards(); float GetOuterRadius() const; static bool ClipToPane(const TArrayFVector2D InPoly, float W, float H, TArrayFVector2D OutPoly); static double SignedArea(const TArrayFVector2D Poly); static FVector2D Centroid(const TArrayFVector2D Poly); };Private/RuntimeGlassPaneActor.cpp#include RuntimeGlassPaneActor.h #include Algo/Reverse.h #include Components/DynamicMeshComponent.h #include Components/SceneComponent.h #include DynamicMesh/DynamicMesh3.h #include PhysicsEngine/AggregateGeom.h namespace RuntimeGlass { enum class EClipSide : uint8 { Left, Right, Bottom, Top }; static bool Inside(FVector2D P, EClipSide Side, FVector2D Min, FVector2D Max) { switch (Side) { case EClipSide::Left: return P.X Min.X; case EClipSide::Right: return P.X Max.X; case EClipSide::Bottom: return P.Y Min.Y; case EClipSide::Top: return P.Y Max.Y; } return true; } static FVector2D Intersect(FVector2D A, FVector2D B, EClipSide Side, FVector2D Min, FVector2D Max) { const FVector2D D B - A; if (Side EClipSide::Left || Side EClipSide::Right) { const double X Side EClipSide::Left ? Min.X : Max.X; const double T FMath::IsNearlyZero(D.X) ? 0.0 : (X - A.X) / D.X; return A D * T; } const double Y Side EClipSide::Bottom ? Min.Y : Max.Y; const double T FMath::IsNearlyZero(D.Y) ? 0.0 : (Y - A.Y) / D.Y; return A D * T; } static void ClipSide(const TArrayFVector2D Input, EClipSide Side, FVector2D Min, FVector2D Max, TArrayFVector2D Output) { Output.Reset(); if (Input.Num() 0) return; FVector2D Prev Input.Last(); bool bPrevInside Inside(Prev, Side, Min, Max); for (FVector2D Curr : Input) { const bool bCurrInside Inside(Curr, Side, Min, Max); if (bCurrInside ! bPrevInside) { Output.Add(Intersect(Prev, Curr, Side, Min, Max)); } if (bCurrInside) { Output.Add(Curr); } Prev Curr; bPrevInside bCurrInside; } } static void RemoveDuplicates(TArrayFVector2D Poly) { constexpr double TolSq 0.01; for (int32 i Poly.Num() - 1; i 0; --i) { if ((Poly[i] - Poly[i - 1]).SizeSquared() TolSq) { Poly.RemoveAt(i); } } if (Poly.Num() 1 (Poly[0] - Poly.Last()).SizeSquared() TolSq) { Poly.RemoveAt(Poly.Num() - 1); } } static void BuildMesh(const TArrayFVector2D Poly, float Thickness, UE::Geometry::FDynamicMesh3 Mesh) { Mesh.Clear(); Mesh.EnableTriangleGroups(); const int32 N Poly.Num(); if (N 3) return; const double HalfT FMath::Max(Thickness, 0.1f) * 0.5; TArrayint32 Front; TArrayint32 Back; Front.Reserve(N); Back.Reserve(N); for (FVector2D P : Poly) { Front.Add(Mesh.AppendVertex(FVector3d(P.X, P.Y, HalfT))); Back.Add(Mesh.AppendVertex(FVector3d(P.X, P.Y, -HalfT))); } for (int32 i 1; i N - 1; i) { Mesh.AppendTriangle(Front[0], Front[i], Front[i 1], 0); Mesh.AppendTriangle(Back[0], Back[i 1], Back[i], 1); } for (int32 i 0; i N; i) { const int32 j (i 1) % N; Mesh.AppendTriangle(Front[i], Back[i], Back[j], 2); Mesh.AppendTriangle(Front[i], Back[j], Front[j], 2); } } static FKAggregateGeom BuildConvexCollision(const TArrayFVector2D Poly, float Thickness) { FKAggregateGeom Agg; FKConvexElem Convex Agg.ConvexElems.AddDefaulted_GetRef(); const float HalfT FMath::Max(Thickness, 0.1f) * 0.5f; for (FVector2D P : Poly) { Convex.VertexData.Add(FVector(P.X, P.Y, HalfT)); Convex.VertexData.Add(FVector(P.X, P.Y, -HalfT)); } Convex.UpdateElemBox(); return Agg; } } ARuntimeGlassPaneActor::ARuntimeGlassPaneActor() { PrimaryActorTick.bCanEverTick false; SceneRoot CreateDefaultSubobjectUSceneComponent(TEXT(SceneRoot)); SetRootComponent(SceneRoot); IntactGlass CreateDefaultSubobjectUDynamicMeshComponent(TEXT(IntactGlass)); IntactGlass-SetupAttachment(SceneRoot); IntactGlass-SetMobility(EComponentMobility::Movable); IntactGlass-SetCollisionProfileName(TEXT(BlockAll)); IntactGlass-bEnableComplexCollision false; } void ARuntimeGlassPaneActor::OnConstruction(const FTransform Transform) { Super::OnConstruction(Transform); if (!bBroken) RebuildGlass(); } void ARuntimeGlassPaneActor::BeginPlay() { Super::BeginPlay(); if (!bBroken) RebuildGlass(); } void ARuntimeGlassPaneActor::RebuildGlass() { TArrayFVector2D Rect; Rect.Add(FVector2D(-Width * 0.5f, -Height * 0.5f)); Rect.Add(FVector2D( Width * 0.5f, -Height * 0.5f)); Rect.Add(FVector2D( Width * 0.5f, Height * 0.5f)); Rect.Add(FVector2D(-Width * 0.5f, Height * 0.5f)); UE::Geometry::FDynamicMesh3 Mesh; RuntimeGlass::BuildMesh(Rect, Thickness, Mesh); IntactGlass-SetMesh(MoveTemp(Mesh)); IntactGlass-SetSimpleCollisionShapes(RuntimeGlass::BuildConvexCollision(Rect, Thickness), true); IntactGlass-SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics); IntactGlass-SetVisibility(true, true); IntactGlass-SetSimulatePhysics(false); if (GlassMaterial) { IntactGlass-SetMaterial(0, GlassMaterial); } } void ARuntimeGlassPaneActor::BreakFromHitResult(const FHitResult Hit, FVector ShotDirection, int32 Seed) { BreakAtWorldPoint(Hit.ImpactPoint, ShotDirection, Seed); } void ARuntimeGlassPaneActor::BreakAtWorldPoint(FVector WorldHitPoint, FVector ShotDirection, int32 Seed) { if (bBroken) return; bBroken true; IntactGlass-SetVisibility(false, true); IntactGlass-SetCollisionEnabled(ECollisionEnabled::NoCollision); const FVector LocalHit3 GetActorTransform().InverseTransformPosition(WorldHitPoint); FVector2D LocalHit(LocalHit3.X, LocalHit3.Y); LocalHit.X FMath::Clamp(LocalHit.X, -Width * 0.5f 0.1f, Width * 0.5f - 0.1f); LocalHit.Y FMath::Clamp(LocalHit.Y, -Height * 0.5f 0.1f, Height * 0.5f - 0.1f); TArrayFRuntimeGlassShard2D GeneratedShards; GenerateShards(LocalHit, Seed, GeneratedShards); FVector SafeShotDir ShotDirection.GetSafeNormal(); if (SafeShotDir.IsNearlyZero()) { SafeShotDir GetActorForwardVector(); } for (const FRuntimeGlassShard2D Shard : GeneratedShards) { SpawnShard(Shard, WorldHitPoint, SafeShotDir); } } void ARuntimeGlassPaneActor::ResetGlass() { ClearShards(); bBroken false; RebuildGlass(); } void ARuntimeGlassPaneActor::GenerateShards(FVector2D LocalHitPoint, int32 Seed, TArrayFRuntimeGlassShard2D OutShards) const { OutShards.Reset(); const int32 Rays FMath::Clamp(RayCount, 3, 96); const int32 Rings FMath::Clamp(RingCount, 1, 32); const float OuterRadius GetOuterRadius(); const float Step UE_TWO_PI / Rays; const float SafeAngleJitter FMath::Min(AngleJitter, Step * 0.3f); const float SafeRadiusJitter FMath::Clamp(RadiusJitter, 0.0f, 0.75f); FRandomStream Rand(Seed); TArrayfloat Angles; for (int32 i 0; i Rays; i) { Angles.Add(i * Step Rand.FRandRange(-SafeAngleJitter, SafeAngleJitter)); } TArrayTArrayFVector2D Points; Points.SetNum(Rays); for (int32 i 0; i Rays; i) { Points[i].SetNum(Rings 1); Points[i][0] LocalHitPoint; float PrevRadius 0.0f; for (int32 r 1; r Rings; r) { const float T float(r) / float(Rings); float Radius OuterRadius * FMath::Pow(T, 1.45f); if (r Rings) { Radius * Rand.FRandRange(1.0f - SafeRadiusJitter, 1.0f SafeRadiusJitter); } else { Radius OuterRadius; } Radius FMath::Max(Radius, PrevRadius 2.0f); PrevRadius Radius; const float Angle Angles[i] Rand.FRandRange(-SafeAngleJitter, SafeAngleJitter) * T; Points[i][r] LocalHitPoint FVector2D(FMath::Cos(Angle), FMath::Sin(Angle)) * Radius; } } auto AddShard [this, OutShards](const TArrayFVector2D Source) { TArrayFVector2D Clipped; if (!ClipToPane(Source, Width, Height, Clipped)) return; const double Area SignedArea(Clipped); if (FMath::Abs(Area) MinShardArea) return; if (Area 0.0) { Algo::Reverse(Clipped); } FRuntimeGlassShard2D NewShard OutShards.AddDefaulted_GetRef(); NewShard.Points MoveTemp(Clipped); }; for (int32 i 0; i Rays; i) { const int32 Next (i 1) % Rays; TArrayFVector2D Center; Center.Add(LocalHitPoint); Center.Add(Points[i][1]); Center.Add(Points[Next][1]); AddShard(Center); for (int32 r 1; r Rings; r) { TArrayFVector2D Cell; Cell.Add(Points[i][r]); Cell.Add(Points[i][r 1]); Cell.Add(Points[Next][r 1]); Cell.Add(Points[Next][r]); AddShard(Cell); } } } void ARuntimeGlassPaneActor::SpawnShard(const FRuntimeGlassShard2D Shard, FVector WorldHitPoint, FVector ShotDirection) { if (Shard.Points.Num() 3) return; UDynamicMeshComponent* Comp NewObjectUDynamicMeshComponent(this); Comp-SetMobility(EComponentMobility::Movable); Comp-AttachToComponent(SceneRoot, FAttachmentTransformRules::SnapToTargetNotIncludingScale); AddInstanceComponent(Comp); Comp-RegisterComponent(); UE::Geometry::FDynamicMesh3 Mesh; RuntimeGlass::BuildMesh(Shard.Points, Thickness, Mesh); Comp-SetMesh(MoveTemp(Mesh)); Comp-SetSimpleCollisionShapes(RuntimeGlass::BuildConvexCollision(Shard.Points, Thickness), true); Comp-SetCollisionProfileName(TEXT(PhysicsActor)); Comp-SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics); Comp-bEnableComplexCollision false; if (GlassMaterial) { Comp-SetMaterial(0, GlassMaterial); } Comp-SetMassOverrideInKg(NAME_None, FMath::Max(0.001f, ShardMassKg), true); Comp-SetSimulatePhysics(true); const FVector2D C2 Centroid(Shard.Points); const FVector WorldCenter GetActorTransform().TransformPosition(FVector(C2.X, C2.Y, 0.0)); const FVector RadialDir (WorldCenter - WorldHitPoint).GetSafeNormal(); const FVector Impulse ShotDirection.GetSafeNormal() * ShotImpulse RadialDir * RadialImpulse; Comp-WakeAllRigidBodies(); Comp-AddImpulseAtLocation(Impulse, FMath::Lerp(WorldHitPoint, WorldCenter, 0.5f)); Shards.Add(Comp); } void ARuntimeGlassPaneActor::ClearShards() { for (UDynamicMeshComponent* Comp : Shards) { if (Comp) { Comp-DestroyComponent(); } } Shards.Reset(); } float ARuntimeGlassPaneActor::GetOuterRadius() const { return FVector2D(Width, Height).Size() * 1.45f; } bool ARuntimeGlassPaneActor::ClipToPane(const TArrayFVector2D InPoly, float W, float H, TArrayFVector2D OutPoly) { if (InPoly.Num() 3) return false; const FVector2D Min(-W * 0.5f, -H * 0.5f); const FVector2D Max( W * 0.5f, H * 0.5f); TArrayFVector2D A InPoly; TArrayFVector2D B; RuntimeGlass::ClipSide(A, RuntimeGlass::EClipSide::Left, Min, Max, B); RuntimeGlass::ClipSide(B, RuntimeGlass::EClipSide::Right, Min, Max, A); RuntimeGlass::ClipSide(A, RuntimeGlass::EClipSide::Bottom, Min, Max, B); RuntimeGlass::ClipSide(B, RuntimeGlass::EClipSide::Top, Min, Max, OutPoly); RuntimeGlass::RemoveDuplicates(OutPoly); return OutPoly.Num() 3; } double ARuntimeGlassPaneActor::SignedArea(const TArrayFVector2D Poly) { double A 0.0; for (int32 i 0; i Poly.Num(); i) { const FVector2D P Poly[i]; const FVector2D Q Poly[(i 1) % Poly.Num()]; A P.X * Q.Y - Q.X * P.Y; } return A * 0.5; } FVector2D ARuntimeGlassPaneActor::Centroid(const TArrayFVector2D Poly) { const double A SignedArea(Poly); if (FMath::IsNearlyZero(A)) { FVector2D Sum FVector2D::ZeroVector; for (FVector2D P : Poly) Sum P; return Sum / double(Poly.Num()); } FVector2D C FVector2D::ZeroVector; for (int32 i 0; i Poly.Num(); i) { const FVector2D P Poly[i]; const FVector2D Q Poly[(i 1) % Poly.Num()]; const double Cross P.X * Q.Y - Q.X * P.Y; C (P Q) * Cross; } return C / (6.0 * A); }用法把ARuntimeGlassPaneActor拖进关卡子弹 LineTrace 命中后调用if (ARuntimeGlassPaneActor* Glass CastARuntimeGlassPaneActor(Hit.GetActor())) { Glass-BreakFromHitResult(Hit, BulletDirection, RandomSeed); }1. 核心思路玻璃破碎不是播放预制动画而是在运行时命中点 - 生成 2D 碎片轮廓 - 每个碎片生成 DynamicMesh - 每个碎片注册成 DynamicMeshComponent - 给每片碎片设置材质、碰撞、物理 - 播放裂缝 / 飞出 / 掉落过程也就是说一整块玻璃 DynamicMeshComponent 破碎后变成多个碎片 DynamicMeshComponent每个碎片都是独立对象可以单独移动、旋转、碰撞、物理掉落。2. DynamicMesh 是什么FDynamicMesh3是运行时可编辑的网格数据。它主要保存顶点 Vertex 三角形 Triangle 三角形组 Group UV / Normal / MaterialID 等属性FDynamicMesh3本身只是数据不会显示。真正显示的是UDynamicMeshComponent关系是Actor - DynamicMeshComponent - FDynamicMesh33. 完整玻璃如何生成完整玻璃先在本地 XY 平面生成一个矩形(-W/2, -H/2) ( W/2, -H/2) ( W/2, H/2) (-W/2, H/2)然后通过BuildMesh生成有厚度的 3D 网格正面 z Thickness / 2 背面 z -Thickness / 2 侧面连接正背面所以玻璃并不是一张无限薄的面而是有厚度的立体碎片。4. BuildMesh 做了什么BuildMesh(Poly, Thickness, Mesh)把一个 2D 多边形变成 3D 有厚度 mesh。它做三件事1. 给每个 2D 点生成 Front 顶点 2. 给每个 2D 点生成 Back 顶点 3. 用三角形生成正面、背面、侧边例如Front.Add(Mesh.AppendVertex(FVector3d(P.X, P.Y, HalfT))); Back.Add(Mesh.AppendVertex(FVector3d(P.X, P.Y, -HalfT)));正面用扇形三角化Front[0], Front[1], Front[2] Front[0], Front[2], Front[3] ...背面顺序反过来保证法线方向正确Back[0], Back[2], Back[1]侧边每条边生成两个三角形Mesh.AppendTriangle(Front[i], Back[i], Back[j], 2); Mesh.AppendTriangle(Front[i], Back[j], Front[j], 2);意思是Fi ---- Fj | | Bi ---- Bj用两个三角形拼成一个侧面矩形。后面的数字0 正面 group 1 背面 group 2 侧边 group5. 命中点如何处理蓝图传入HitResult ShotDirection代码把世界命中点转到玻璃本地空间LocalHit ActorTransform.InverseTransformPosition(WorldHitPoint)然后只取 XYFVector2D LocalHit(LocalHit3.X, LocalHit3.Y);因为玻璃是在本地 XY 平面上破碎。命中点会被 Clamp 到玻璃内部X: -Width/2 0.1 到 Width/2 - 0.1 Y: -Height/2 0.1 到 Height/2 - 0.10.1是为了避免命中点刚好在边界上产生退化碎片。6. 如何生成 2D 碎片从命中点向外发射Rays条射线\ | / \ | / -------- * -------- / | \ / | \每条射线上生成Rings圈点。参数含义Rays裂缝方向数量 Rings从中心到外圈分几层 AngleJitter角度随机扰动 RadiusJitter半径随机扰动 Seed随机种子保证可复现每条射线的角度Angle i * Step RandomJitter每个点位置Point LocalHitPoint Direction * RadiusRadius OuterRadius * Pow(T, 1.45)让中心附近碎片更密 外圈碎片更大7. 如何拼出碎片 polygon相邻射线 相邻圈组成碎片。中心区域LocalHitPoint Points[i][1] Points[next][1]形成三角碎片。外圈区域Points[i][r] Points[i][r1] Points[next][r1] Points[next][r]形成四边形碎片。这些碎片还只是FRuntimeGlassShard2D也就是 2D 点数组不是 3D mesh。8. ClipToPane 做什么外圈点可能超出玻璃矩形所以要裁剪。ClipToPane依次裁左边界 右边界 下边界 上边界它不是简单删除外面的点而是删除玻璃外面的部分 在边界交叉处补新交点 生成新的有效 polygon裁剪完后RemoveDuplicates SignedArea去掉重复点过滤面积太小的碎片并统一顶点顺序。9. Shard 是什么Shard指一片玻璃碎片。但分两个阶段FRuntimeGlassShard2D 只是 2D 碎片轮廓 只有 Points UDynamicMeshComponent 真正显示出来的 3D 碎片 有 mesh / 材质 / 碰撞 / 物理前半段代码生成的是 2D Shard。后面SpawnShard把 2D Shard 变成真正的 3D 碎片。10. SpawnShard 做什么每个碎片都会1. 计算碎片中心 Centroid 2. 把点转换成相对自身中心的坐标 3. 创建一个 UDynamicMeshComponent 4. 把组件放到碎片中心位置 5. 根据点生成 DynamicMesh 6. 设置材质 7. 设置碰撞 8. 设置质量、重力、阻尼 9. 记录动画参数关键点CenteredPoints.Add(Point - C2); Comp-SetRelativeLocation(FVector(C2.X, C2.Y, 0));这样Component 原点 碎片自身中心 Mesh 顶点 相对碎片中心物理模拟才会围绕碎片自身中心而不是整块玻璃中心。