上个月做项目资产盘点,我花了一个下午把场景里所有Mesh列出来,静态网格体、骨骼网格体、程序化网格体混在一起,量一大就完全看不下去了。后来干脆把这块写成了一个收集工具,挂在编辑器菜单里,跑一下就输出完整清单。今天要说的就是我标题里写的“ue源码:Mesh收集”这件事。拆开看其实就两层:一层是UE的源码级实现,另一层是把Mesh作为收集对象,从场景里、Content目录里、甚至运行时内存里把Mesh资产捞出来,按你想要的维度整理好。它听起来不复杂,但真正落地时会踩到很多组件过滤、依赖收集、加载优化方面的坑。
这篇文章适合谁?如果你是TA、引擎程序,或者在做项目瘦身、性能优化、素材规范管理,那“Mesh收集”几乎是绕不开的基础能力。蓝图也能做,但很多数据和接口在蓝图层拿不全,所以我主要讲C++实现,蓝图项目也可以作为参考思路来用。全文不会贴整套工程,只把关键代码、选型思路和踩坑记录摊开讲,你拿回去改改就能用。
1. Mesh收集到底在收集什么:需求拆解与方案边界
1.1 先从真实需求出发
“收集Mesh”听起来像是一个功能,但它背后对应的是完全不同的需求,需求不同,写法和接口选型也不同。
最常见的需求是资产盘点与项目瘦身。我接手过一个做了两年的项目,Content目录里Mesh资产上千个,没人说得清哪些还在用、哪些已经没人用了。这时候收集的目标是Content目录本身,需要把全部Mesh类资产列出来,查引用关系,找出“孤立资产”。
还有一个高频场景是场景性能分析。这时候要收集的不是Content目录,而是某个Level里实际摆出来的Mesh。每个Mesh被多少个Actor引用、有没有Nanite、LOD数量够不够、三角形面数是多少,这些数据直接决定优化优先级。
再比如运行时预加载。做MMO或者开放世界时,不能等到玩家走到某个NPC旁边才开始加载Mesh,那样必然卡顿。你需要在进入区域之前,从配置表或者AssetRegistry里把一批Mesh软引用收集起来,异步加载到内存里,等待召唤。
还有一种偏门需求是变体替换。比如换肤、换季节、换建筑外观,你要把同类型Mesh都收集出来,统一替换材质或者Mesh资源本身。收集的粒度很关键,收集范围太大会误伤,太小又漏。
你发现没有,同样是“收集Mesh”,四个场景的目标对象完全不同。不做需求拆解就直接写遍历循环,大概率会做成一个“什么都捞但什么都用不好”的工具。
1.2 收集的三个层级:编辑器、运行时、打包期
我习惯把 Mesh 收集分为三个层级,对应不同的调用时机和数据来源,你可以直接当作方案表来参考:
| 层级 | 收集目标 | 常用接口 | 典型工具/场景 |
|---|---|---|---|
| 编辑器收集 | Content目录、当前关卡 | AssetRegistry、TActorIterator | 资产盘点、批量替换、性能分析 |
| 运行时收集 | 已加载/需加载的Mesh | TSoftObjectPtr、FStreamableManager | 预加载、对象池、变体系统 |
| 打包引用收集 | Cook后的包内资产 | DependencyGraph、链接器 | 打包瘦身、热更新资源统计 |
编辑器收集跑一次就出结果,慢一点无所谓,重点是数据全。运行时收集讲究稳定性,你不能让收集逻辑本身卡掉主线程。打包引用收集最隐蔽,因为Cook后的资产引用关系和编辑器里看到的未必一致,很多资源明明被引用了却没打进包,或者恰好相反,打进去一堆没用的。
在设计工具时,我建议一开始就明确“你写的是哪个层级的收集器”,然后在函数名、文件组织、甚至日志前缀上都区分开。别把编辑器和运行时逻辑混在一个类里,否则后面会因为World类型、资源加载方式不同踩一堆雷。
1.3 为什么从源码层入手而不是纯蓝图
有朋友问过,UE提供了那么多蓝图节点,Get All Actors Of Class、Get Components By Class,为什么还要动C++?因为蓝图节点是封装好的黑盒,它解决“能不能取出Mesh”的问题,但解决不了“收集过程是否高效、数据是否完整、逻辑是否可控”的问题。
举个实际例子。蓝图里遍历所有Actor,本质上是调用了C++的TActorIterator,但每次取组件都会经过蓝图虚拟机,成千上万个组件遍历下来,那个开销是会真实反映到编辑器卡顿上的。还有一点,蓝图拿不到AssetRegistry里很多底层数据,比如资产依赖关系、是否在磁盘上、包的GUID和文件大小。这些信息C++里一条接口就能拿到,蓝图层要么拿不到,要么先加载整个资产才能知道,成本完全不同。
从源码层入手,并不意味着拒绝蓝图,我更推荐“C++提供能力,蓝图做胶水”的姿势。核心收集逻辑写成C++函数或编辑器工具,需要交互的部分再暴露成蓝图节点。这样既有源码层的性能和完整度,又能让策划或TA直接在编辑器里调用。
2. 关卡内Mesh收集上手指南:先学会遍历Actor与组件
2.1 拿World,遍历Actor
关卡内收集Mesh的思路很朴素:拿到当前World,遍历所有Actor,再遍历Actor身上的Components,过滤出包含Mesh的组件。
编辑器环境下,World的获取方式和运行时不一样。写编辑器工具时,我常用这段:
UWorld* World = nullptr; // 优先取当前编辑器正在编辑的世界 if (GEditor) { World = GEditor->GetEditorWorldContext().World(); } // 如果是在PIE或者运行时,直接用GWorld或GetWorld() if (!World) { World = GWorld; } if (!World) { UE_LOG(LogTemp, Error, TEXT("Failed to get a valid UWorld")); return; }拿到World之后,遍历Actor有两种写法。一种是官方的TActorIterator模板:
for (TActorIterator<AActor> It(World); It; ++It) { AActor* Actor = *It; // 处理Actor }另一种是更保险的Actors数组:
for (AActor* Actor : World->PersistentLevel->Actors) { if (!IsValid(Actor)) { continue; } // 处理Actor }TActorIterator会递归遍历所有Level,包括子关卡和动态生成的Actor,范围更全,但性能开销稍高。PersistentLevel->Actors只覆盖当前持久关卡,适合只想分析当前Level的场景。我自己做通用工具时更倾向TActorIterator,因为流送关卡的Actor很容易被漏掉,而漏掉资产恰恰是盘点工具的大忌。
2.2 从Actor身上取组件,过滤出Mesh相关组件
Actor本身不是Mesh,Mesh在Component上。这一步是收集逻辑的核心,也是最容易写错的地方。
常见的Mesh组件有三类:
- UStaticMeshComponent,最基础,StaticMesh属性能拿到UStaticMesh*。
- USkeletalMeshComponent,骨骼网格体,SkeletalMesh属性拿到USkeletalMesh*。
- UProceduralMeshComponent,程序化生成的网格体,没有UStaticMesh属性,要单独处理Section。
遍历组件时,不要用GetComponentByClass逐个查,那样会写很多重复代码。我一般直接用GetComponents拿到组件数组,然后按类型分支处理:
TInlineComponentArray<UPrimitiveComponent*> Components; Actor->GetComponents(Components, true); for (UPrimitiveComponent* PrimitiveComponent : Components) { if (UStaticMeshComponent* StaticMeshComp = Cast<UStaticMeshComponent>(PrimitiveComponent)) { if (UStaticMesh* Mesh = StaticMeshComp->GetStaticMesh()) { // 收集 Mesh } continue; } if (USkeletalMeshComponent* SkeletalMeshComp = Cast<USkeletalMeshComponent>(PrimitiveComponent)) { if (USkeletalMesh* Mesh = SkeletalMeshComp->GetSkeletalMeshAsset()) { // 收集 Mesh } continue; } if (UProceduralMeshComponent* ProceduralComp = Cast<UProceduralMeshComponent>(PrimitiveComponent)) { // 程序化网格体没有统一Asset,需要单独逻辑去取Section数据 } }这里有两个细节值得注意。
第一,Cast顺序不能乱。USkeletalMeshComponent和UStaticMeshComponent都继承自UPrimitiveComponent,但两者互不继承,所以顺序影响不大。真正要注意的是,如果场景里有InstancedStaticMeshComponent(ISM)或HierarchicalInstancedStaticMeshComponent(HISM),它们继承自UStaticMeshComponent,所以Cast 能接住。这意味着你统计引用数时,一棵植被里几千个HISM实例可能都指向同一个UStaticMesh,计数逻辑要想清楚。
第二,GetComponents第二个参数是bool bIncludeFromChildActors。很多工具漏Mesh,都是漏在这个地方。有些Mesh挂在子Actor或附加组件上,不递归取就全丢了。打开递归之后,嵌套结构也能全部收到。
2.3 收集结果的结构体设计与去重
收集只是手段,最后的产出必须是一份干净的数据。我习惯定义独立的收集结构体,避免后面维护时字段满天飞:
USTRUCT() struct FMeshCollectItem { GENERATED_BODY() // 资产路径,作为唯一标识 UPROPERTY() FSoftObjectPath MeshPath; // 被多少个组件引用 UPROPERTY() int32 ComponentCount = 0; // 被多少个Actor引用 UPROPERTY() int32 ActorCount = 0; // 实际引用的Actor集合,便于后续抽查 UPROPERTY() TArray<TWeakObjectPtr<AActor>> ReferencedActors; // Mesh本身的属性,后续统计用 UPROPERTY() int32 LODCount = 0; UPROPERTY() int32 MaterialCount = 0; UPROPERTY() bool bNaniteEnabled = false; };收集过程中用FSoftObjectPath作为Key,天然去重。用TSet还是TMap?如果只看“有哪些Mesh”,用TSet 就够。如果还要统计引用关系,那就得用TMap<FSoftObjectPath, FMeshCollectItem>。
我踩过的坑是,直接用UStaticMesh*当Key收集,结果在编辑器里一切正常,但同一份资源在打包后的运行时里可能被卸载再重新加载,指针会变。指针做临时传递没问题,做长期存储就是给自己埋雷。FSoftObjectPath本质上是一条资产路径,不持有资源生命周期,做长期存储靠谱得多。
2.4 大型档案关卡:别硬遍历,试试反向收集
全场景遍历Actor听着简单,但当你面对一个几千个Actor的大关卡时,遍历本身就会花掉不少时间。而且很多时候你关心的不是“场景里有哪些Mesh”,而是“某个特定Mesh被场景里谁用了”。
这时候就轮到反向收集出场。AssetRegistry提供了引用查询接口,可以直接查“哪些资产引用了这个Mesh”。
FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked<FAssetRegistryModule>("AssetRegistry"); TArray<FName> Referencers; AssetRegistryModule.Get().GetReferencers( MeshPackageName, Referencers, EAssetRegistryDependencyType::Hard );拿到Referencers之后,再结合场景Actor列表,就能定位到具体引用者。这个方法本质上是在用资产索引代替场景遍历,数据量越大优势越明显。我做过一次项目级Mesh清理,几千个Mesh挨个查谁在用,用正向遍历跑了差不多二十分钟,换成反向收集之后五分钟不到跑完全量。
但反向收集也有局限。它只能查“资产层面的引用关系”,场景里临时动态加载的Actor、运行时生成的组件、程序化创建的Mesh,AssetRegistry里都没有记录。所以实际项目里,正向遍历和反向查询经常是拼在一起用的:先用反向查询筛掉80%的Mesh,剩下的再对场景做定向遍历。
2.5 编辑器模式下容易被忽略的三件事
编辑器收集和运行时收集,表面上都是遍历Actor,但世界环境差异会导致完全不同的结果。
第一,PIE环境和编辑器环境拿World的路径不一样。GEditor->GetEditorWorldContext().World()拿的是编辑器世界,PIE里你可能需要OuterWorld或者WorldContext的World,拿错了就会得到空集合或者“No World”报错。
第二,编辑器里有些Actor是编辑器专用Actor,比如Gizmo、编辑器辅助线、预览物体,它们身上也可能挂着Mesh组件。如果盘点场景资产,这些要排除掉;如果做渲染分析,这些反而可能是要保留的。过滤条件要不要开,取决于你的业务目标。
第三,关闭编辑器再重新打开后,AssetRegistry的缓存不是实时刷新的。新拖进Content目录的Mesh如果不先让编辑器扫描目录,GetAssets可能查不到。我自己有个习惯,版本更新后第一遍收集工具前面加一个“Rescan AssetRegistry”的按钮,其实底层就是调用AssetRegistryModule.Get().ScanPathsSynchronous,避免缓存脏数据误导结果。
3. 资产库级Mesh批量收集:AssetRegistry才是真正的性能出口
3.1 先理解AssetRegistry是什么
做Content目录级的Mesh收集,最容易想到的是遍历AssetRegistry,但很多人不知道AssetRegistry到底做了什么。
UE启动时会扫描磁盘上的.uasset文件,把每个资产的元数据(Class、Path、Package Name、依赖关系等)写进一张索引表,这就是AssetRegistry。它不加载资产本体,所以查询速度极快——收集几千个Mesh资产往往只需要几百毫秒。
如果你不用AssetRegistry,而是直接遍历Content目录然后LoadObject加载每个资产去判断类型,那个慢是几何级别的,而且编辑器内存会直接爆掉。我见过一个团队用纯加载方式统计Mesh,跑到一半引擎直接OOM,进程都崩了。
所以在资产库级收集里,AssetRegistry不是“选项”,而是“标准答案”。
3.2 基础收集模板,按类和路径过滤
最简单的全量收集代码长这样:
FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked<FAssetRegistryModule>("AssetRegistry"); FARFilter Filter; Filter.ClassNames.Add(UStaticMesh::StaticClass()->GetFName()); Filter.ClassNames.Add(USkeletalMesh::StaticClass()->GetFName()); Filter.PackagePaths.Add(FName(TEXT("/Game"))); Filter.bRecursivePaths = true; TArray<FAssetData> AssetDataList; AssetRegistryModule.Get().GetAssets(Filter, AssetDataList); for (const FAssetData& AssetData : AssetDataList) { // 处理单个Mesh资产 FSoftObjectPath MeshPath = AssetData.GetSoftObjectPath(); // ... }几个参数展开讲一下。
Filter.ClassNames是“资产类名”过滤,不是“目录名”。虽然UStaticMesh都是StaticMesh类,但有些游戏会写蓝图子类或特殊工厂类,看起来是Mesh但类名不一样。如果发现收集结果比预期少,多半是这块没配好。
Filter.PackagePaths是包路径,根目录是/Game。如果你的项目有插件目录、工程外部资源目录,需要按需添加路径。
bRecursivePaths决定是否递归子目录。不递归时,/Game/A和/Game/A/B是两个独立路径,要分别加;递归时一个/Game就能覆盖全部子目录。我上面的写法是递归,大部分场景够用。
还有一种情况是只收集目录,不区分具体Asset,这时可以用GetAssetsByPath这个快捷接口:
AssetRegistryModule.Get().GetAssetsByPath( FName(TEXT("/Game/Characters")), AssetDataList, /*bRecursive=*/true );好用是好用,但它不会按类过滤,会把你指定目录里的材质、贴图、蓝图全捞出来。如果只要Mesh,建议还是用FARFilter。
3.3 从AssetData到资源本体,按需加载
FAssetData只是索引,不等于资产本体。你想要的信息如果AssetData里没有,就得靠GetAsset去加载。
区分清楚很重要:AssetData里有什么?路径、类名、包名、GUID、TagValue。AssetData里没有什么?Mesh实际的顶点数、三角形数、LOD数量、材质实际引用的贴图数量,这些都在资产本体里。
CPU/GPU内存层面想统计“这个Mesh多大”,必须先加载资产本体:
for (const FAssetData& AssetData : AssetDataList) { UObject* LoadedAsset = AssetData.GetAsset(); if (UStaticMesh* StaticMesh = Cast<UStaticMesh>(LoadedAsset)) { // 拿到LOD数量 int32 LODCount = StaticMesh->GetNumLODs(); // 编辑器环境下可以进一步读取Nanite设置 bool bNaniteEnabled = false; #if WITH_EDITOR bNaniteEnabled = StaticMesh->NaniteSettings.bEnabled; #endif // 统计顶点、三角形需要遍历RenderData int32 TriangleCount = 0; if (StaticMesh->GetRenderData()) { for (int32 LODIndex = 0; LODIndex < StaticMesh->GetRenderData()->LODResources.Num(); ++LODIndex) { TriangleCount += StaticMesh->GetRenderData()->LODResources[LODIndex].GetNumTriangles(); } } } }这里要特别提醒一点:全量加载所有Mesh资产在资产规模大的项目里依然会卡,甚至会剧烈消耗内存。经验值是,几千个普通网格体资产全部GetAsset,编辑器内存会涨好几个GB,碰到几个高面数Nanite网格体更夸张。
怎么办?两种思路。
第一种,分批加载,每50~100个资产为一组,处理完一组Release掉。编辑器工具里可以配合FKismetEditorUtilities或Automation消息来做进度反馈。
第二种,只加载必要字段。如果只关心路径、数量、类名,那就停留在AssetData层面,别碰GetAsset。把“加载”和“收集”拆成两个阶段,先做低成本的全量收集,再做按需深度分析。
3.4 依赖收集:把Mesh背后的材质和贴图一起摸清
很多业务需要的不只是Mesh清单,而是“这个Mesh用到了哪些材质,这些材质又引用了哪些贴图”。Mesh本身只是引用链的入口,依赖收集做得好,才能支撑真正的内存和包体分析。
AssetRegistry提供了依赖查询接口:
TArray<FName> Dependencies; AssetRegistryModule.Get().GetDependencies( AssetData.PackageName, Dependencies, EAssetRegistryDependencyType::Hard );Hard依赖是指Mesh必须加载才能正常渲染的依赖,比如材质、贴图、物理资源。Soft依赖不会阻止主资产加载,但对包体分析依然有意义。
依赖收集有个麻烦点:依赖关系是分层的。你查Mesh的依赖,得到的是材质和物理资产;查材质的依赖,才得到贴图。要拿到完整链路,就得写递归,而且要做好环形依赖防护。我在实际项目里用的是“TSet visited + 栈递归”的组合,访问过的包不再重复展开,避免材质互相引用造成死循环。
如果只是做包体分析,还可以用更省事的办法:Editor的ReferenceViewer就是干这个的,不过它只能看单个资产;代码里可以用IReferenceViewer接口或Asset Dependencies模块批量执行,控制台甚至可以输出DependencyGraph的Dot文件。但这里我不推荐过度依赖现成工具,因为ReferenceViewer的数据侧重交互演示,不好直接汇入脚本流程,真要自动化分析还是要自己把依赖层拉出来。
3.5 把收集结果导出成结构化档案
收集完之后,光在日志里打一行是没法用的。我每次做工具,最后一步都会把结果导出为结构化文件,最常用的是CSV和JSON。
CSV简单直观,双开对比查看方便:
| MeshPath | ComponentCount | LODCount | Nanite | TriangleCount |
|---|---|---|---|---|
| /Game/Assets/SM_Rock.SM_Rock | 3 | 4 | false | 12850 |
| /Game/Characters/Ch_Boss.Ch_Boss | 1 | 5 | true | 2400000 |
CSV的网络显示可能会乱码,所以表格后面再用Markdown表格展示更容易读。JSON适合二次处理,配合脚本做版本对比时效率更高。
我自己的管理习惯是,每次大版本迭代跑一次收集,输出JSON到项目根目录的AssetReport文件夹,然后利用Source Control做版本对比。对比内容很简单:新增了哪些Mesh、删除了哪些Mesh、哪些Mesh的三角形数和LOD数发生了变化。一个月下来,项目的资产演进趋势一目了然。
4. 运行时收集与动态加载:别把你的Mesh全塞进内存里
4.1 运行时收集的典型玩法
编辑器里收集Mesh是为了分析和治理,运行时收集Mesh则完全是为了体验。
最常见的玩法是预加载。玩家跑图快到转角时,提前把转角后的NPC角色、道具、装饰物的Mesh加载进内存,避免走到一半突然卡顿。这个场景下,收集的是“即将可能用到的Mesh”。
另一个玩法是对象池。射击游戏里怪被击杀后,模型销毁,但Mesh数据可以留在池子里复用。收集池化Mesh时要特别小心,不能把正在渲染的Mesh卸载了,否则你会看到一堆“幽灵模型”。
再一个是换肤变体。运行时根据玩家的选择,动态替换某个角色的Mesh或材质。实现上通常是一张配置表,把变体选项映射到不同的SoftObjectPath,然后由运行时收集模块统一处理加载与切换。
4.2 硬引用与软引用:这是内存管理的分水岭
很多人在运行时收集上吃亏,不是因为不会写加载函数,而是从数据结构上就错了。
如果你在UCLASS里直接写:
UPROPERTY(EditAnywhere) UStaticMesh* DefaultMesh;这就是硬引用。Cooking时引擎会把DefaultMesh打进制包,运行时会随类加载而一起进内存。硬引用的问题是“不可控”,你无法决定它何时卸载,只能等整个类被GC。
正确的姿势是软引用:
UPROPERTY(EditAnywhere) TSoftObjectPtr<UStaticMesh> DefaultMesh;TSoftObjectPtr本质上存的是资产路径字符串,不会在类加载时把Mesh拖进内存。只有你调用LoadSynchronous或RequestAsyncLoad时,Mesh才真正进内存。
判断一个项目是否值得做运行时收集,第一眼就看代码里硬引用Mesh、碰材质、碰贴图的地方多不多。如果一张配置表里全是硬引用,后面做任何内存优化都像是在漏水的船上舀水。
4.3 异步加载与优先级调度
收集到SoftObjectPath列表之后,加载时仍然要谨慎。最直接的LoadSynchronous会卡主线程:
UStaticMesh* Mesh = DefaultMesh.LoadSynchronous(); if (!Mesh) { return; }少量Mesh用这个可以,一下加载几十个上百个,主线程必定卡顿。正确做法是交给FStreamableManager做异步加载:
FSoftObjectPath MeshPath(TEXT("/Game/Characters/Ch_Boss.Ch_Boss")); FStreamableManager& StreamableManager = UAssetManager::GetStreamableManager(); TSharedPtr<FStreamableHandle> Handle = StreamableManager.RequestAsyncLoad( MeshPath, FStreamableDelegate::CreateLambda([this, MeshPath]() { // 加载完成后的回调,在这里通过路径获取Mesh UStaticMesh* LoadedMesh = Cast<UStaticMesh>(MeshPath.TryLoad()); if (LoadedMesh) { // 派发给实际使用逻辑 } }) );这里有个小坑。FStreamableDelegate没有参数,进入Lambda后只能依靠捕获的路径去重新获取资产。捕获路径没问题,但要注意路径必须用FSoftObjectPath而不是裸字符串,否则拼接错误会Load到空气。
优先级调度是运行时收集的另一个关键点。把所有Mesh排好顺序,让要紧的Mesh先加载,不紧的延后加载。我常用的做法是给每个收集项加一个Score:
- 玩家正前方视野内的可见Mesh,Score最高。
- 距离玩家较近但还没进入视野的Mesh,Score其次。
- 预留给未来使用、纯池化目的的Mesh,Score最低。
然后用一个排序数组按Score从高到低分批RequestAsyncLoad。每批控制在5~10个资产。实测这样既不会把加载带宽打满,也不会让关键模型迟迟不出现。
4.4 流送与卸载:收集不是一次性事件
在开放世界或大世界里,Mesh收集是一个持续的过程。玩家从一个区域进入另一个区域,旧的Mesh要卸载,新的Mesh要加载,这时你要的不是“收集”,而是“流送调度”。
UE自带的World Partition、Level Streaming已经处理了大部分Actor和Map的流送,但Mesh资产本身不会跟着Level流送自动卸载干净。如果每个区域都通过不同的Level引用了不同Mesh,Level卸载时Mesh引用计数降到零,GC才会回收。这种隐式GC如果配合上TSoftObjectPtr的访问,经常会出现“Level切走之后Mesh还赖在内存里不走”的情况。
手动管理的话,FStreamableHandle的Release接口就是你的卸载开关:
Handle->ReleaseHandle();注意,Release是减少引用计数,不是强制销毁。如果还有其他地方引用了同一个Mesh,它不会真正释放。这个机制是引用计数制的天然特性,理解这一点你就能推理各种内存现象了:明明Release了,Mesh还在内存里,十有八九是别处还有引用。
5. Mesh收集常见问题与避坑实录:从报错到数据丢失
5.1 高频问题速查表
代码写完不是结束,排查才是大头。我把实际操作中遇到的坑整理成一张速查表:
| 问题现象 | 可能原因 | 解决建议 |
|---|---|---|
| 收集到的Mesh数量比Content Browser少 | Filter.ClassNames类型不对或路径没递归 | 检查FARFilter的ClassNames和PackagePaths |
| 编辑器工具一运行就崩溃 | World为空或拿到了PIE World | 用GEditor->GetEditorWorldContext().World(),先判空 |
| 场景里Mesh漏了一堆 | GetComponents没开递归 | 调用Actor->GetComponents(Components, true) |
| 运行时异步加载回调里Mesh为空 | FSoftObjectPath被错误拼接或资产未Cook | 打印Path确认,用TryLoad校验 |
| 收集结果重复 | 蓝图引用、子Level重复遍历 | 统一用FSoftObjectPath做Set去重 |
| 打包后Mesh不在包里 | 软引用没被引擎识别为依赖 | 用SoftObjectPtr方式声明或在项目Settings添加额外资产目录 |
| Level切走但Mesh不卸载 | 还有其他硬引用或Handle未Release | 搜索引用方,确认Handle已Release |
5.2 数据重复是把你逼疯的一棵树
场景里同一个Mesh被100个StaticMeshComponent引用,AssetRegistry收集时又因为蓝图类、重定向器之类的东西把同一个资产出现两次,这些小事情加在一起,最后结果能比实际资产多出两三倍。
从实操角度,我有两个强制习惯。
第一,无论走哪条路径收集,最终结果统一汇总到TSet 。组件层可能重复、Actor层可能重复、AssetRegistry可能重复,但只要最终Key是FSoftObjectPath,去重就是幂等的。
第二,图上重复和计数重复要分开看。同一只Mesh被100个植被实例引用,资产列表里它只出现一次,但引用计数是100或者更多。如果直接使用去重后的TSet长度做统计,会低估场景负载;如果直接用组件访问计数做统计,又会高估资产多样性。说清楚“你要的是资产列表还是引用矩阵”,后面所有统计口径才立得住。
5.3 Cook后Mesh不在包里
运行时收集最痛的问题之一,是编辑器里一切正常,但打包后Mesh加载不到。
根因通常是Cook流程没有把Mesh纳入依赖图。TSoftObjectPtr的好处是不会强制Cooking依赖,坏处也一样:如果引擎没在任何地方识别到这个软引用,打包时它就被当作“没人要的资产”跳过了。
解决方案有三个方向:
- 把Mesh路径添加到Project Settings里的Additional Asset Directory,强制Cook。
- 将软引用显式放在某个资产列表中,比如DataAsset里的TArray<TSoftObjectPtr >,让Cook系统能扫到。
- 在Cooking阶段使用AssetManager的ModifyCook或PrecacheCook逻辑,把动态资产标记进依赖图。
我自己的项目多采用第二种方案。维护一张“运行时Mesh包”DataAsset,所有需要动态加载的Mesh都登记在这张表里,既是开发期配置,也是Cook期依赖清单,还天然承担了收集模块的“候选清单”职责,一举三得。
5.4 蓝图和GeneratedClass制造的坑
你可能会收集到很多“蓝图资源”而不是直接装备的Mesh。这是因为有些Actor是蓝图类,资产路径指向的是Blueprint资产,要真正拿到Mesh,需要从蓝图生成的Class里找组件,再取Mesh。
AssetData里会出现GeneratedClass的Tag,它的值才是蓝图编译后的类。如果用AssetData.GetAsset()直接得到的是蓝图资产,Cast成不了静态网格体。
处理这类资产时,我用两种方式规避。
第一种,过滤阶段就排除掉不需要的蓝图类,只保留UStaticMesh、USkeletalMesh等具体板块。可是如果关卡里主要资产都是蓝图生成的,全排除会漏掉很多数据。
第二种,收集组件时直接从Actor实例下手,而不是从资产类下手。在编辑器里运行收集工具时,已经放置的蓝图Actor能被正常遍历到,它们身上的组件也都能通过上面的GetComponents方式拿到Mesh。AssetRegistry层的蓝图收集则留给依赖分析场景,两种目标分开,互不干扰。
5.5 收集工具自身的性能优化心得
最后聊一个工具开发本身的优化问题。收集看起来是一次性操作,但大项目里跑一次的时间会直接决定团队成员愿不愿意用。
我总结三条原则。
第一,能用索引就不用遍历。AssetRegistry就是典型索引,场景卡不卡,看你会不会先查一遍索引再做有限遍历。
第二,能分批就不全量。编辑器工具加载Mesh统计数据时,分批加载可以有效避免OOM和卡死。即使用户抱怨慢一点,也比崩溃好。
第三,结果落地成文件。第一次收集可能需要一分钟,但生成的结果文件是秒开级别的。后续再做同样分析时,让工具优先读上次的CSV/JSON,只在必要时重新收集,体验会好很多。
写在最后
我做这类收集工具最大的体会是,Mesh收集的难点从来不是“写代码”,而是“定义收集目标”。你到底是盘点Content目录的资产存量,还是查某一个Level里的实际负载,或者是在运行时管理动态加载的Mesh,三者收集的对象、时间点和接口都不一样。一开始搞混,后面注定要返工。
工具层面,我最后封了一个EditorSubsystem类,把收集逻辑挂在编辑器菜单下,每次运行输出一份CSV和一份JSON,配合脚本做版本对比,效果非常直观。唯一有遗憾的地方是Nanite普及之后,从UStaticMesh拿三角形数量不像以前那么直接,有些资源只能通过RenderData或者Nanite资源的虚拟化数据去间接估算,数据精度没有以前高。如果你也要在这个方向继续做下去,建议先从小场景跑通原理,确认收集口径没问题,再逐步放大到全量资产。
大概就这些。如果你手头正好也在做类似的资产统计、性能分析或者运行时预加载,欢迎拿着上面的思路对照自己项目源码改一版。