ARTICLE DETAIL

资讯详情

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

UE源码实现Mesh收集器:从Scene资产统计到性能优化实战

UE源码实现Mesh收集器:从Scene资产统计到性能优化实战 ue源码Mesh收集这个标题我第一次拿到手第一反应不是怎么写个遍历循环而是——这兄弟是不是也遇到过场景资产烂成一锅粥、想盘点却无从下手的时候做UE项目做到中后期几乎每个人都会碰上一个问题场景里的Mesh到底有哪些谁在用有多少三角形LOD跑哪去了是不是一堆没用的高模压着内存一个个手动数肯定不现实常规编辑器自带的Statistics面板又过于宏观帮不了你定位到具体某一张地图里的具体资产。这个时候自己从源码层面写一个Mesh收集器把场景里和资产库里的网格数据一次性捞出来按你需要的方式统计和导出就成了一个投入产出比极高的工具。这篇文章我就以我实际开发的一个Mesh收集插件为例把从思路拆解、源码设计、核心API使用到踩坑记录的全过程写清楚。内容偏向引擎工具开发方向也就是常说的Editor Tool Development适合技术美术、工具开发者、客户端性能优化同学参考。如果你只是想快速出个清单不打算深入源码文里的思路和坑也一样有价值。1. 整体设计与思路拆解1.1 这个工具到底要解决什么问题先说场景。我遇到的实际情况是一个开放世界项目地图数量30每个关卡里静态网格、动态骨骼网格、程序化生成的Mesh实例混在一起。性能组每次做QA都要问同一个问题某个陶罐、某棵树、某个建筑组到底在高配机器上占多少个Draw Call、多少三角形、多少显存项目里的美术说我用的这个Mesh是Nanite的性能组说Nanite个鬼你关掉之后帧率掉了一半。两边吵来吵去的原因就一个没有一份可信的、可追溯的、按关卡拆分的Mesh明细数据。引擎里能看到数据但数据不在一个地方也没有办法导出来给数据组做基线分析。我当时的想法很简单——写一个工具它做四件事扫描整个项目资产库列出所有StaticMesh和SkeletalMesh资产及其基础信息遍历当前关卡所有Actor和Component收集实际使用的Mesh实例信息把资产信息和实例信息关联起来统计三角形总数、LOD信息、Nanite开关、材质槽、碰撞体数量导出成CSV给策划、美术、性能组一起看。1.2 为什么不用现成的Editor脚本和插件非要从源码层做市面上确实有现成的资产统计工具比如各种付费插件、免费的Editor Utility Widget。但我用过一轮之后发现几个绕不过去的坎。第一资产信息不完整。很多工具能列出资产路径但拿不到渲染数据内部的三角形数、IndexBuffer大小、每个LOD的顶点数。因为这些数据在引擎源码里不是公开API直接返回的你得从FStaticMeshRenderData里取。第二不能定制输出格式。性能组要的是能进数据看板的CSV美术要的是带参考图的Excel策划要的是按玩法区域分类的清单。现成工具往往是固定列你没法自己改。第三场景实例和资产的关系对不上。很多工具要么只扫资产要么只扫场景没人帮你做这个Mesh在哪些关卡被哪些Actor用的关联分析。而这个问题恰恰是优化项目最有价值的维度——你知道某个Mesh被200个Actor实例化和你只知道某Mesh存在完全是两回事。所以我的结论是从源码层做不是为了炫技而是因为自定义工具的需求只有源码层能彻底满足。UE提供了足够多的Editor API问题不是做不出来而是大多数人不知道从哪几个类下手。下面我把关键设计和代码思路全部展开。1.3 方案选型为什么用C插件而不是Blueprint/PythonUE做编辑器工具至少有四条路Editor Utility Blueprint、Editor Utility WidgetUMG、Python脚本、C编辑器模块。我最终选了C理由也很实在数据量级不同。一个中型项目的StaticMesh资产可能有一万多张SkeletalMesh也有两三千张遍历场景Actor也是上万级别的。Python在UPython里跑这种密集遍历速度慢到可以先去喝杯咖啡再回来。C配合AssetRegistry的异步扫描几秒钟就能过完一遍。源码API访问权限不同。很多关键数据类如FStaticMeshLODResources、FStaticMeshRenderData在Python侧拿不到类型信息或者访问路径异常曲折。C里直接#include引擎头文件就可以用了。部署和协作成本不同。一个C编辑器模块打成一个插件团队内所有人拉到项目里直接编谁也不用额外配环境。Blueprint工具每次修改还要操作编辑器容易出操作误差。注意如果你只是临时起意收集一次数据用Editor Utility Blueprint快速遍历一遍也完全可以。但如果你想做的是一份稳定、可复用、可扩展的资产数据管线C插件是一次投入、长期受益的选择。2. 核心源码设计与实现要点2.1 数据模型设计先想清楚收集什么再写代码写工具之前我最怕的就是回调函数满天飞、数据算得乱七八糟。所以第一步永远是设计数据结构。我的Mesh收集器输出分两层资产层Asset Level一个Mesh资产自己的永久属性。字段说明获取方式AssetPath资产路径FAssetData::GetObjectPathString()MeshName资产名FAssetData.AssetNameMeshTypeStaticMesh / SkeletalMeshClassNameVertexCountLOD0顶点数LODResources.VertexBuffersTriangleCountLOD0三角形数IndexBuffer数量 / 3LODCountLOD层数LODResources.Num()IsNanite是否开启NaniteMesh-NaniteSettings.bEnabledCollisionPrimitives碰撞体数量Mesh-GetBodySetup()-AggGeom.GetElementCount()MaterialSlotCount材质槽数量Mesh-GetMaterialIndex().Num() ?实际是Materials.Num()实例层Instance Level场景里每个Actor/Component的使用情况。字段说明ActorNameActor名ActorLabel关卡中显示名ComponentName组件名ComponentTypeStaticMeshComponent / SkeletalMeshComponent / HISM / ISMStaticMeshAsset引用的Mesh资产路径IsVisible可见性MobilityStatic / Stationary / DynamicWorldName所属关卡名Transform位置信息可选两层通过MeshAssetPath关联。这样导出的时候可以拆成两个Sheet也可以合在一个大表里。2.2 核心代码骨架从插件模块到收集器插件的标准结构先拉出来MyMeshCollector/ ├── MyMeshCollector.uplugin ├── Source/ │ ├── MyMeshCollector/ │ │ ├── MyMeshCollectorModule.cpp │ │ ├── MyMeshCollectorModule.h │ │ ├── MeshCollectorSubsystem.h/cpp │ │ ├── MeshCollectorData.h/cpp │ │ └── MeshCollectorCommands.h/cpp编辑器模块在StartupModule()里注册EditorSubsystem和UI命令。这里有个关键点把收集逻辑放进EditorSubsystem而不是纯普通类。好处是生命周期由编辑器托管不用担心模块卸载后指针悬空而且可以在任意编辑器UI里GEditor-GetEditorSubsystemUMeshCollectorSubsystem()直接拿到单例。我在MeshCollectorData.h里定义了资产结构体USTRUCT() struct FMyMeshAssetInfo { GENERATED_BODY() UPROPERTY() FSoftObjectPath MeshPath; UPROPERTY() FString MeshName; UPROPERTY() FString MeshType; UPROPERTY() int32 VertexCountLOD0 0; UPROPERTY() int32 TriangleCountLOD0 0; UPROPERTY() int32 TriangleCountTotal 0; UPROPERTY() int32 LODCount 0; UPROPERTY() bool bIsNanite false; UPROPERTY() int32 CollisionShapes 0; UPROPERTY() int32 MaterialSlotCount 0; UPROPERTY() int64 DiskSize 0; };实例层结构体USTRUCT() struct FMyMeshInstanceInfo { GENERATED_BODY() UPROPERTY() FString WorldName; UPROPERTY() FString ActorName; UPROPERTY() FString ActorLabel; UPROPERTY() FString ComponentName; UPROPERTY() FString ComponentType; UPROPERTY() FSoftObjectPath MeshPath; UPROPERTY() bool bIsVisible true; UPROPERTY() uint8 MobilityType 0; };2.3 资产扫描AssetRegistry的地毯式遍历资产扫描我用的核心API是IAssetRegistry。不要直接用FAssetRegistryModule的旧版风格了UE5里面直接通过模块获取单例IAssetRegistry AssetRegistry FModuleManager::LoadModuleCheckedFAssetRegistryModule(TEXT(AssetRegistry)).Get();然后构建过滤器同时捞StaticMesh和SkeletalMeshFARFilter Filter; Filter.ClassNames.Add(UStaticMesh::StaticClass()-GetFName()); Filter.ClassNames.Add(USkeletalMesh::StaticClass()-GetFName()); Filter.bRecursiveClasses true; Filter.PackagePaths.Add(FName(/Game)); // 也可以扫 /Plugins 里的内容 Filter.bIncludeOnlyOnDiskAssets false; TArrayFAssetData MeshAssetList; AssetRegistry.GetAssets(Filter, MeshAssetList);这里有个很重要的设计决策我先拿到FAssetData这时资产还没加载到内存然后按需加载。因为上万个Mesh如果全部LoadObject到内存编辑器内存直接飙到几个G扫描完你还得手动TryUnload搞不好还会把本来项目该热载的资源给挤掉。我的做法是分批处理constexpr int32 BatchSize 64; for (int32 i 0; i MeshAssetList.Num(); i BatchSize) { int32 End FMath::Min(i BatchSize, MeshAssetList.Num()); for (int32 j i; j End; j) { UObject* LoadedAsset MeshAssetList[j].FastGetAsset(); // 这里对LoadedAsset进行数据提取 } // 每批处理后让出主线程刷新进度后面讲如何不卡编辑器 }FastGetAsset()是UE5.x里FAssetData提供的便捷接口内部会根据bIsAssetLoaded判断是否加载。如果资产已经加载直接返回现有对象没有加载才真正Load。比我们手动LoadObject更安全。2.4 场景内实例收集不止是GetAllActors场景遍历是老生常谈但如果你直接用TActorIteratorAActor在蓝图和C实体较多的大关里速度勉强能忍真正让我意外的是很多Mesh放在子关卡和Streaming里出生状态不同直接遍历当前World拿不到未加载的Level资产。我的解决方案分三步先遍历当前World所有Level对象包括已加载的子关卡UWorld* World GEditor-GetEditorWorldContext().World(); for (ULevel* Level : World-GetLevels()) { // 每个Level的Actors }在每个Level内用TActorIteratorAActor遍历所有Actor过滤掉编辑器标为隐藏的。在Actor内用GetComponentsT分别拿UStaticMeshComponent、USkeletalMeshComponent、UHierarchicalInstancedStaticMeshComponent、UInstancedStaticMeshComponent。为什么分开拿很多人只用GetComponentsUPrimitiveComponent()一把梭结果拿到一堆UBrushComponentBSP、ULandscapeComponent还得自己筛浪费又容易错。分开拿的好处是可以直接确定组件类型后面数据归类时不用再做类型判断。for (UActorComponent* Comp : Actor-GetComponentsByClass(UStaticMeshComponent::StaticClass())) { UStaticMeshComponent* SMC CastUStaticMeshComponent(Comp); if (SMC SMC-GetStaticMesh()) { // 收集实例信息 } }注意HierarchicalInstancedStaticMeshComponent是UStaticMeshComponent的子类如果你先拿了UStaticMeshComponentHISM会被一并拿到。但HISM你还需要多记录一个实例数量InstanceCount如果单独遍历它你要CastUHierarchicalInstancedStaticMeshComponent()再拿GetInstanceCount()。所以这里我的逻辑是优先级排序先判断UHierarchicalInstancedStaticMeshComponent其次UInstancedStaticMeshComponent再USkeletalMeshComponent最后UStaticMeshComponent这样才能做到每个Mesh实例既不重复统计又不遗漏HISM/ISM的实例数量。2.5 渲染数据提取三角形数和顶点数的正确姿势很多初学者问过我一个问题UStaticMesh::GetNumVertices()和三角形数到底是什么关系引擎里的数据是这样的UStaticMesh本身不直接存渲染顶点它持有的是FStaticMeshRenderData*在GetRenderData()里。每个LOD层对应一份FStaticMeshLODResources里面有VertexBuffers.PositionVertexBuffer.GetNumVertices()——顶点数IndexBuffer.GetNumIndices()——索引数三角形数 GetNumIndices() / 3但注意这个索引数不是最终渲染三角形数。如果Nanite开启三角形数统计口径就变了。Nanite网格在运行时走的是cluster化渲染编辑器里IndexBuffer可能只有低精度的fallback数据用于射线检测等完整Nanite数据在FNaniteResources里。我的工具策略是如果Nanite开启记录三角形数为Mesh-GetNaniteResources().GetNumTriangleCount()如果有并标记bIsNanite true同时在输出列里加一个Nanite资源三角形数。如果Nanite关闭用IndexBuffer.GetNumIndices() / 3并把全部LOD三角形加起来。void ExtractMeshRenderData(UStaticMesh* Mesh, FMyMeshAssetInfo OutInfo) { if (!Mesh) return; OutInfo.bIsNanite Mesh-NaniteSettings.bEnabled; OutInfo.LODCount Mesh-GetNumLODs(); // 注意必须让RenderData处于可用状态 FStaticMeshRenderData* RenderData Mesh-GetRenderData(); if (!RenderData) return; OutInfo.TriangleCountTotal 0; for (int32 LodIndex 0; LodIndex RenderData-LODResources.Num(); LodIndex) { const FStaticMeshLODResources LodResource RenderData-LODResources[LodIndex]; int32 NumIndices LodResource.IndexBuffer.GetNumIndices(); int32 TriangleInThisLod NumIndices / 3; if (LodIndex 0) { OutInfo.VertexCountLOD0 LodResource.VertexBuffers.PositionVertexBuffer.GetNumVertices(); OutInfo.TriangleCountLOD0 TriangleInThisLod; } OutInfo.TriangleCountTotal TriangleInThisLod; } }这里有个编码细节我要特别提醒GetNumIndices()返回的是uint32如果你直接用它当int32做除法某些超大Mesh索引数超过2^31可能会溢出。虽然现实中很少见但上万面的角色鞋子、极端雕刻资产索引数也不是完全没可能逼近亿级Nanite高精模型。稳妥起见强转int64再算int64 TriangleInThisLod (int64)LodResource.IndexBuffer.GetNumIndices() / 3;2.6 骨骼网格的边界情况SkeletalMesh和StaticMesh最大的不同是它还有骨骼、Morph Target、Clothing等额外数据这些不是核心收集项但有一个是必拿的当前LOD层级数和每个LOD的三角形数。SkeletalMesh的渲染数据获取方式和StaticMesh差别很大。USkeletalMesh* SKMesh CastUSkeletalMesh(LoadedAsset); if (SKMesh) { OutInfo.LODCount SKMesh-GetLODNum(); for (int32 LodIndex 0; LodIndex OutInfo.LODCount; LodIndex) { const FSkeletalMeshLODModel LodModel SKMesh-GetLODImportedData(ModelIndex); // 注意LODModel的三角形数通常在导入阶段由ImportData持有 // 如果你已经在RenderingResourceSkinWeightProfiles等里找可能拿不到完整的 } }这里有个实际经验骨骼网格的渲染资源在编辑器里的获取路径不像StaticMesh那么直觉。USkeletalMesh::GetResourceForRendering()拿到的FSkeletalMeshRenderData里每层LOD有RenderSections纯粹的三角形数要从每个Section的NumTriangles上累加FSkeletalMeshRenderData* SKRenderData SKMesh-GetResourceForRendering(); if (SKRenderData) { for (int32 LodIndex 0; LodIndex SKRenderData-LODRenderData.Num(); LodIndex) { const FSkeletalMeshLODRenderData LodData SKRenderData-LODRenderData[LodIndex]; int64 LodTriangles 0; for (const FSkeletalMeshRenderSection Section : LodData.RenderSections) { LodTriangles Section.NumTriangles; } // 记录 } }这不是唯一办法但实测稳定。导入数据里不一定保存最终的渲染section信息尤其开了骨骼网格合并优化后所以直接读渲染资源更靠谱。2.7 材质槽和碰撞体这两个字段可以不加但我强烈建议加。因为我们收集Mesh的目的百分之八十最后都会归结到两个问题材质对不对、碰撞有没有冗余。材质槽数直接取Mesh-GetMaterials().Num()StaticMesh和SkeletalMesh都有GetMaterials()。碰撞体数量从UBodySetup取if (const UBodySetup* BS Mesh-GetBodySetup()) { OutInfo.CollisionShapes BS-AggGeom.GetElementCount(); }这里最容易踩的坑某个Mesh在内容浏览器里看没有碰撞体但实际场景里Actor上挂了自定义碰撞组件BoxComponent/CapsuleComponent。这个工具统计的是资产级碰撞体数量不是运行时物理效果。真正要审查两倍盒体碰撞之类的问题你还要在实例层去看Component的CollisionResponse设置。我在工具里额外加了一个功能对每个Component检查是否有额外挂载的实体碰撞组件并单独标记。3. 实操过程把收集器做成一个可用的编辑器工具3.1 从命令行能跑的最小Demo正式做插件UI前我建议先做一个能跑的命令行版本。这样排查逻辑错误容易内存问题也方便观测。我的做法是注册一个Console Commandstatic FAutoConsoleCommand MeshCollectorCmd( TEXT(MyMeshCollector.Run), TEXT(Collects all mesh data in the current world and exports to CSV.), FConsoleCommandWithArgsDelegate::CreateStatic(RunMeshCollectionCommand) ); void RunMeshCollectionCommand(const TArrayFString Args) { UMeshCollectorSubsystem* Collector GEditor-GetEditorSubsystemUMeshCollectorSubsystem(); if (Collector) { FString OutputPath Args.Num() 0 ? Args[0] : TEXT(D:/MeshReport.csv); Collector-CollectAllMeshInfo(OutputPath); } }控制台执行MyMeshCollector.Run D:/Report/MeshReport_20240628.csv这个方式的最大好处是能脱离UI做批量测试尤其适合CI环境挂进自动化流程。我的CI服务器上就挂了一条每晚自动打开指定Level跑一次收集输出CSV然后给数据组。3.2 Editor Utility Button五分钟接入菜单对于编辑器内的美术和TA来说菜单按钮比控制台亲切得多。我在AssetToolsModule里注册了一个自定义菜单挂在关卡编辑器Toolbar上。这块代码很常规void UMeshCollectorSubsystem::Initialize(FSubsystemCollectionBase Collection) { Super::Initialize(Collection); // 把命令注册到UI FToolMenus::RegisterStartupCallback( FToolMenus::RegisterStartupCallback::FSimpleMulticastDelegate::FDelegate::CreateRaw( this, UMeshCollectorSubsystem::RegisterMenus)); }菜单布局不用做复杂一个按钮Collect Mesh Info加上下拉选项导出当前关卡和导出全项目资产就够。美术用起来全程无感知点一下弹窗选路径生成CSV。3.3 CSV导出别用简单逗号分隔就完事了CSV看似简单但实际坑不少。MeshName里可能有逗号美术命名自由奔放材质路径中也可能有引号。我吃过亏之后学乖了不要自己拼逗号字符串用一个FString的边界处理函数FString SanitizeCSVField(const FString InString) { FString Result InString; if (Result.Contains(TEXT(,)) || Result.Contains(TEXT(\)) || Result.Contains(TEXT(\n))) { Result.ReplaceInline(TEXT(\), TEXT(\\)); Result TEXT(\) Result TEXT(\); } return Result; }写完文件头每次收集合并一行。实测Excel和Numbers都能正常打开Pyhton pandas读也不会裂。void WriteCSVHeader(FString InOutContent) { InOutContent TEXT(AssetPath,MeshName,MeshType,LOD0_Vertices,LOD0_Triangles,TotalTriangles,LODCount,Nanite,CollisionShapes,MaterialSlots,DiskSize\n); } void WriteCSVLine(FString InOutContent, const FMyMeshAssetInfo Info) { InOutContent FString::Printf( TEXT(%s,%s,%s,%d,%lld,%lld,%d,%s,%d,%d,%lld\n), *SanitizeCSVField(Info.MeshPath.ToString()), *SanitizeCSVField(Info.MeshName), *Info.MeshType, Info.VertexCountLOD0, (long long)Info.TriangleCountLOD0, (long long)Info.TriangleCountTotal, Info.LODCount, Info.bIsNanite ? TEXT(True) : TEXT(False), Info.CollisionShapes, Info.MaterialSlotCount, (long long)Info.DiskSize ); }3.4 进度反馈与编辑器防卡大项目资产扫描动辄几万条全在主线程跑的话编辑器会卡死几秒钟到十几秒。用户第一反应是崩溃了直接强杀进程。UE编辑器的IsBusy反馈又很弱所以我默认用批量处理加SlowTaskFScopedSlowTask Progress(MeshAssetList.Num(), FText::FromString(TEXT(Collecting Mesh Assets...))); Progress.MakeDialog(true); for (int32 i 0; i MeshAssetList.Num(); i) { if (Progress.ShouldCancel()) { // 用户点了Cancel清理当前批次的临时引用 break; } Progress.EnterProgressFrame(1.0f); // 每128个资产让一下主线程 if (i % 128 0) { FPlatformProcess::Sleep(0.01f); } // 处理资产 }还有一个细节如果资产未加载FastGetAsset()会对Package做同步加载。如果你在收集后不主动释放会导致下次编辑器GC时出现Partial GC的延迟卡顿。我的做法是在扫描完成之后单独对本次新加载的包做引用清理——把加载出来的UStaticMesh指针从收集器的临时数组里清空然后触发一次GEngine-ForceGarbageCollection(true)。注意不要对项目里本来就该加载的资产随便Unload比如当前Level正在用的Mesh否则编辑器会闪一下白。临时方案是TArrayFSoftObjectPath LoadedThisPass; // 收集时记录路径 LoadedThisPass.Add(Info.MeshPath); // 收集完统一处理 for (const FSoftObjectPath Path : LoadedThisPass) { UObject* Obj Path.ResolveObject(); if (Obj !Obj-IsRooted()) { // 如果这个资源不是被其他对象强引用我们可以拆掉CDO引用标记 // 但最稳的处理还是交给GC松开我们自己的UPROPERTY数组引用即可 } }核心是你在收集时如果用了TArrayUStaticMesh*数组持有Mesh引用一定要在循环结束后Empty()这个引用数组。否则GC永远不会回收它们等于内存泄漏。这是我在实际中踩过最狠的一坑后面专门讲。4. 常见问题与排查技巧实录4.1 场景Actor遍历时遇到断言崩溃第一次跑真实项目我用TActorIteratorAActor在主世界遍历结果跑了大概200个Actor编辑器直接弹断言然后崩溃。查了Callstack崩溃点在GetActorLabel()里对AActor::GetActorLabel的保证断言过不去。原因TActorIterator默认会遍历到PIEPlay In Editor留下的临时Actor或者尚未初始化完成的Blueprint实例。GetActorLabel对这些Actor可能拿不到合理值。解决方法是二选一在遍历时过滤掉Actor-HasAnyFlags(RF_Transient)和Actor-GetClass()-HasAnyClassFlags(CLASS_Transient)。只用World-PersistentLevel和SubLevels中的AWorldSettings可见Actor。我最终确定了一套过滤条件if (Actor-IsPendingKill() || Actor-HasAnyFlags(RF_BeginDestroyed | RF_FinishDestroyed)) { continue; } if (Actor-IsEditorOnly()) { continue; // 编辑专用的Actor不参与统计 } if (Actor-GetWorld() ! World) { continue; // 其他World的实例不混进来 }这套过滤写完之后崩溃问题再也没有出现过。4.2 三角形数与其他工具对不上这个问题我在内部测试时被挑战了很多次——我用工具统计某个Mesh是8000三角形美术在建模软件里看到是7992Diff为8。排查后才知道UE对导入Mesh做三角剖分和顶点去重三角形数不一定等于你在Maya/Blender里看的数值。尤其是曲面平滑组处理、网格清理和Optimize操作会让三角面合并。这不算工具Bug反而是一个特征——你统计的目的是为了运行时优化判断应该以引擎三角剖分为准而不是DCC软件里的数值。另一个对不上的原因是NDLNaniteMesh。你看到的IndexBuffer三角形数可能只有渲染trace用fallback而真正Nanite cluster数是另一个值。类似情况和性能组沟通时要明确统计口径。4.3 大场景扫描卡死的问题一开始扫描全项目资产2万条界面无反馈鼠标转圈看起来就像死机。后来加了SlowTask和每128个Sleep之后虽然不会卡死但仍然要等20多秒。我继续优化了改成FAssetRegistry的异步扫描。GetAssets本身比较快慢的是加载和提取数据。我把加载和提取拆成多个Batch放在FTimerManager或AsyncTask里做。注意不能在后台线程访问UStaticMesh的渲染数据因为GetRenderData()不是线程安全的这部分必须在GameThread。所以我的方案是资产定位用后台线程加载和数值提取Batch化切回GameThread。加了缓存机制。第一次收集会把结果序列化成.bin缓存文件用FArchive二进制序列化结构体。第二次跑如果资产路径和修改时间都没变直接读缓存秒出。这对全员每天跑日常性能检查用处极大。4.4 引用内存泄漏问题我最开始写的版本跑完一次工具后右下角Used Physical Memory比之前高了将近800MB。排查了很久最后定位到两点TArrayUStaticMesh* LoadedMeshes在整个工具生命周期一直持有引用导致GC无法回收。使用FAssetData::FastGetAsset()加载后资产在Package里会被标记为加载状态但没有人Unload。解决方案上面已经提到了收集完立刻LoadedMeshes.Empty()并且将持有资产的成员变量设计为TArrayTWeakObjectPtrUObject而不是强引用数组。如果你需要在后续处理中引用数据就把提取后的数值复制到纯USTRUCT数据里丢掉UObject引用。这是一个工具开发中非常重要但也极容易被忽略的细节。4.5 无法收集子关卡/StreamingLevel资产大世界项目里Mesh常常分布在StreamingLevel里而这些子关卡并不一定在编辑器中处于Loaded状态。World-GetLevels()只返回当前已加载的Level。对未加载的子关卡AssetRegistry里也能拿到它们的资产路径但拿不到这个Mesh在哪个具体关卡使用的实例信息。我的变通方案是用ULevelStreaming遍历当前World的所有StreamingLevel定义对未加载的关卡读取它的Package中的Actor列表for (ULevelStreaming* Streaming : World-GetStreamingLevels()) { const FString LevelPackageName Streaming-GetWorldAssetPackageName(); UPackage* LevelPackage LoadPackage(nullptr, *LevelPackageName, LOAD_None); UWorld* SubWorld UWorld::FindWorldInPackage(LevelPackage); if (SubWorld) { // 虽然SubWorld没被设为CurrentWorld但它的Actor列表是完整的 // 可以遍历SubWorld-PersistentLevel-Actors } }注意LoadPackage加载子关卡会引入不必要的激活所以我不建议默认开启。我这个功能做成了勾选项——Include Unloaded Streaming Levels?默认关闭。只在需要全局盘点时才开。5. 从收集到决策如何把数据变成优化动作5.1 一份能吵架用的报告工具做出来还只是开始真正有价值的是拿这份数据推动了什么决策。以我们项目为例第一次全量收集后我在性能组会议上亮了几组数据项目里有374个Mesh资产是0 LOD只有LOD0而且其中128个三角形数超过5万性能组一看就知道这些必须补LOD22个StaticMesh开了Nanite但LOD0三角形数只有1万不到Nanite的收益极其有限反而占用渲染路径开销某张大地图里有超过12万个HISM实例由同一棵树组成但用的是传统InstancedStaticMesh而不是Nanite或AutoLOD这直接引导我们把那棵树的方案换成Nanite Foliage有16个Mesh资产在网络项目里根本没人用占磁盘超过300MB可以晋升废弃资产流程。这些数据如果靠人肉review可能得几个月。工具一跑每个人的反应都是哦原来在这。5.2 自动生成危险资产Top20榜单我在工具最后加了个函数对所有收集到的Mesh按TriangleCountLOD0 * 某权重 CollisionShapes * 某权重 LODCount缺失惩罚排序输出Top20危险资产。权重默认值Score TriangleCountLOD0 * 1.0 CollisionShapes * 0.2 (LODCount 1 ? 100 : 0)这里LODCount 1的加分是一个经验性惩罚因为无LOD的资产在移动端/低配机上会被强制用满细节渲染对帧率伤害大。你们可以根据项目情况调。这个Top20列表我甚至在Slack机器人通知里放了一条链接每天新提交的Mesh如果进入候选Top20就提醒对应的美术负责人。等于把收集工具从手动查变成了自动监控。5.3 数据核对与协作规范有了数据还要让它进到日常协作流程里不然就是死数据。我的经验是导出的CSV文件名上带上日期和关卡名方便回溯数据进Perforce/SVN前用脚本检查CSV行数如果比上次少了200行大概率是某种异常中断脚本会报警给美术的Mesh资产命名建议中把三角形数/LOD层级写进Smoke Test的检查项——提交时自动用这个收集器验证Mesh规格不符合就拦截。6. 扩展方向从收集到Mesh加工6.1 Mesh合并 / HLOD生成的输入收集器拿到的数据其实可以直接指导FMeshMergeUtilities的自动合并。你可以把三角形数超标的资产挑出来按位置聚类生成HLOD代理Mesh。这块我在一个子项目里试过思路是收集器导出一份候选合并资产清单用UHLODBuilder或FMeshDescription按merge distance配置合并自动替换场景里原始Actor的HLODLayer绑定。这个扩展有一个大前提你的工具必须能稳定拿到资产的FBoxSphereBounds和FTransform。收集器里我当时顺手记录了每个Component的Bounds中心点和半径就是为了这些场景。6.2 自动化LOD生成之前统计出大量无LOD的资产人工一个个去设置LOD太痛苦。我就写了一个批处理遍历Top200危险资产调用IMeshReductionManager或MeshUtilities.QuadricSimplify按75%/50%/25%的比例自动生成LOD1/LOD2/LOD3。做法是IMeshUtilities MeshUtilities FModuleManager::Get().LoadModuleCheckedIMeshUtilities(MeshUtilities); MeshUtilities.GenerateLODInBuildData( InMesh, /*LODIndex*/ 1, /*SourceLODIndex*/ 0, /*LODSettings*/ InMetric, /*bUpdateMeshData*/ true );注意这个程序化生成LOD的行为会直接覆盖Mesh的Build Data生产环境里请务必在分支或副本上做不要直接在正式美术资源上生成否则美术回来会把你们打死。6.3 编辑器Popup预览最后我加了每个危险资产的In-Editor预览面板。收集器输出结果后双击某一行MeshName会在旁边打开一个AssetEditorSubsystem的OpenEditorForAsset美术能立刻看到它长什么样、LOD切换阈值在哪。这个功能虽然简单但对美术理解为什么我的资产被性能组点名非常有帮助。7. 个人经验总结做这个Mesh收集器前后花了我大概一周时间包含了踩坑、测试、对接数据组。总结下来最值钱的不是代码本身而是想清楚了几件事第一工具开发的难点从来不在于API调用而在于数据口径和边界情况。你必须明确告诉使用者三角形数以引擎剖分为准、Nanite资产统计的是fallback数据、碰撞体数量是资产级BodySetup不包含运行时额外挂载的组件。没有这些注解任何一份Excel都可能在跨部门沟通时被质疑。第二收集器这类工具的终极形态是从一次性脚本变成持续监控的基础设施。与其等优化Review时才跑一次不如挂在CI、挂在提交检查里让不规范资产在进入项目前就被发现问题。第三性能收集数据要带着上下文看。单纯看三角形数没有意义要看它在哪个地图、以什么方式Static还是HISM被使用、是否Nanite、有没有碰撞体成本。做数据驱动的优化数据分析师那套维度拆解思路放在引擎工具开发里一样管用。如果你也要做类似工具我的建议是最小可行版本先跑通——先能导出CSV再加UI再加缓存再多线程。技术实现不复杂但要做到别人愿意用、性能组能签到、美术不骂人细节里全是功夫。
返回列表