前阵子给项目组写资产管理工具,需要在编辑器里遍历几百个StaticMesh,把资源缩略图统一导出成PNG,同时还要把每张图的Base64和宽高信息返回给Web接口。最后整理出这套用UE动态获取资源缩略图、下载到本地或返回图片信息的完整链路。
刚开始想得很简单:UE不是自带ThumbnailTools嘛,直接调用不就行了。真正上手才发现,光是选API就能绕晕——不同接口拿到的缩略图来源不一样,有从缓存里读的、有现场生成的、有依赖Slate渲染的,转成最终能落盘的PNG还牵扯到BGRA通道顺序和纹理格式。这篇文章不搞虚的,直接把能跑通的完整方案写出来,适合正在做编辑器工具、资产管理、批量导出、Web接口对接的UE开发者参考。
其中会涉及ThumbnailTools、FObjectThumbnail、AssetThumbnail/FAssetThumbnailPool、FImageUtils以及Base64输出这几条核心链路。新手照着抄也能用,老手可以直接跳到踩坑那节,有几个坑不写下来可惜了。
1. 先分清“编辑器缩略图”和“运行时预览图”:别选错API
1.1 编辑器缩略图与运行时预览图的本质区别:使用场景决定API
我先说结论:UE编辑器里看到的内容浏览器缩略图,和游戏运行时真正渲染出来的“预览图”,是两套完全不同的东西。
编辑器里的缩略图,本质上是引擎在资产保存或导入时,调用资源对应ThumbnailRenderer生成的一张位图,存放在工程Saved/Thumbnails目录下的缩略图缓存里。ThumbnailTools这一族API基本上是围绕着这份缓存和FObjectThumbnail对象做文章。它快、轻量,适合批处理,但它依赖UnrealEd模块,打包出来之后就没有了。
运行时预览图则是用SceneCapture2D这类方式,把静态网格体、骨骼网格体或者关卡场景重新渲染一遍。它需要真正的渲染线程资源,开销大得多,但胜在打包后也能用,而且可以调节光照、相机角度、背景。
这就引出了第一个决策点:你这个功能是在编辑器里跑,还是在打包后的应用里跑?如果你是想做DCC工具、批量导出、给Web服务返回缩略图,基本都是编辑器侧,ThumbnailTools路线就够了,下面的内容可以继续看。如果是在游戏运行时动态展示某个资产的预览,那你需要的是运行时Capture方案,和本文的ThumbnailTools就不是一路了,别混着用。
1.2 编辑器侧获取缩略图的四个入口对比
实际编辑器工具开发中,经常被用到的“获取缩略图”入口大概有下面四种。放个表格,一眼能看明白各自的定位。
| 入口 | 所在模块 | 同步/异步 | 输出形态 | 典型场景 |
|---|---|---|---|---|
| ThumbnailTools::GenerateThumbnailForObjectToSaveToFile | UnrealEd | 同步 | FObjectThumbnail* | 批量生成/落盘/二次压缩 |
| ThumbnailTools::GetThumbnailForObject | UnrealEd | 同步(可能返回空) | FObjectThumbnail* | 拿内容浏览器已经生成的缓存缩略图 |
| AssetThumbnail + AssetThumbnailPool | AssetThumbnail | 异步 | ViewportTexture/RenderTarget | Slate UI中展示资产缩略图 |
| SceneCapture2D 等运行时渲染 | Engine | 实时 | TextureRenderTarget2D | 打包后运行时动态预览 |
简单解释一下:
ThumbnailTools::GenerateThumbnailForObjectToSaveToFile是同步的,你给它一个UObject,它立刻调该资产的ThumbnailRenderer生成一张新缩略图,返回FObjectThumbnail指针。这个数据结构本身就是为了“保存成图片”而设计的,内部直接带未压缩像素数据,最适合做导出。
ThumbnailTools::GetThumbnailForObject其实是另一个路线:它优先去拿已经缓存的缩略图对象。如果资产没有生成过缩略图,或者缓存被清掉了,返回nullptr,需要自己兜底。
AssetThumbnail是Slate层面用的封装,带缩略图池,可以让大量缩略图异步生成、异步更新,非常适合内容浏览器那种滚动列表。但它输出的是用于Slate绘制和展示的纹理,你想拿到像素做导出,还得绕一圈渲染目标。这个我放到第3节详细说。
SceneCapture2D那就是另一套运行时方案了,不是本文主角,但它确实在很多“运行时资源预览”需求里是唯一可行的路子。
这句建议重要:如果你只是想要“把缩略图存成文件”或“把图片信息返回出去”,优先用第一条同步生成路线,代码量和心智复杂度都最低。AssetThumbnail虽然听起来高级,但很多情况下是自我感动。
2. 正统路线:用ThumbnailTools生成FObjectThumbnail并导出PNG
2.1 同步生成缩略图的核心函数
先说头文件,别在include上浪费时间:
#include "ThumbnailRendering/ThumbnailTools.h" #include "ThumbnailRendering/ThumbnailManager.h" #include "ImageUtils.h" #include "Misc/FileHelper.h"核心接口就一个:
FObjectThumbnail* Thumbnail = ThumbnailTools::GenerateThumbnailForObjectToSaveToFile( AssetObject, 128, 128 );第一个参数是你要生成缩略图的资产UObject;第二个和第三个参数是目标宽度和高度。但注意,这个函数生成的实际尺寸不一定等于你传进去的尺寸,它是一个“最小宽高”概念,最终尺寸受资产自带缩略图尺寸的影响。所以后面读取尺寸时一定要用Thumbnail->GetImageWidth()和Thumbnail->GetImageHeight(),不要用自己传进去的参数。
AssetObject需要是一个已经加载到内存的UObject。如果你手里只有FAssetData,记得先调用GetAsset()完成加载。
FObjectThumbnail这个对象的生命周期由ThumbnailTools内部管理,不需要你手动delete。你只需要在拿到它之后立即处理数据。
2.2 解包像素数据的格式陷阱
生成完之后,取像素数据用这个接口:
const TArray<uint8>& RawData = Thumbnail->GetUncompressedImageData();这里有一半的人会踩坑:GetUncompressedImageData返回的是BGRA8格式,不是RGB,也不是RGBA。也就是说数组顺序是 Blue、Green、Red、Alpha,而不是常见的 R、G、B、A。你直接把这段数据当RGBA传给OpenCV、传给Python PIL、或者直接塞给Web端的RawImage,图片会整体偏蓝偏紫,观感像开了假的色调映射。
如果就是要往Web返回PNG,正确的做法是让FImageUtils去处理PNG编码,PNG格式本身带颜色空间标注,引擎编码时会正确处理,所以下面要讲的CompressImageArray是安全的。只有当你打算拿RawData自己转成RGB数组,或者丢给别的库做像素处理时,才需要手动交换第0和第2个字节。
2.3 压缩成PNG并写文件
拿到RawData之后,压缩成PNG并保存其实就两行核心代码:
TArray<uint8> PngBytes; FImageUtils::CompressImageArray(Width, Height, RawData, PngBytes); FFileHelper::SaveArrayToFile(PngBytes, *OutFilePath);FFileHelper::SaveArrayToFile会把PngBytes整个写到磁盘。实测下来,几百个静态网格体导出成256x256 PNG,就算在大工程里,速度也完全能接受。
在UE5.1及以上版本,FImageUtils还提供了更直接的SaveImageAutoFormat接口,配合FImage对象使用。思路是先用RawData初始化一个FImage,然后指定路径让引擎根据扩展名自动编码:
FImage Image; Image.Init(Width, Height, ERawImageFormat::BGRA8, EGammaSpace::sRGB); FMemory::Memcpy(Image.GetData(), RawData.GetData(), RawData.Num()); FImageUtils::SaveImageAutoFormat(*OutFilePath, Image);两种写法都能跑,CompressImageArray胜在兼容性,老工程切过去不用改头文件;SaveImageAutoFormat更符合UE5风格,但要求UE5.1以上。我自己的习惯是维护一个统一封装,内部走CompressImageArray,这样UE4和UE5都能编译。
这里单独说一个很多人没注意的点:CompressImageArray之后拿到的PngBytes并不是你RawData的简单字节复制,它是正经经过zlib压缩的PNG文件流。所以你后面无论走FFileHelper还是走Base64,都是基于这段PNG字节流,而不是原始像素流。图片在Web端展示、在图片查看器里打开,都不需要你额外处理颜色转换。
3. 界面路线:AssetThumbnail在Slate中的正确打开方式
3.1 FAssetThumbnail与FAssetThumbnailPool的分工
如果目标只是为了导出成文件,我强烈建议跳过这一节,用第2节的方案。但如果你要做的是资产管理器里那种网格视图、列表缩略图,那就绕不开AssetThumbnail这套Slate组件。
FAssetThumbnail和FAssetThumbnailPool是配对使用的。FAssetThumbnailPool相当于一个缩略图异步生成和缓存池,负责调度“哪些缩略图该后台生成了”“生成完怎么通知界面刷新”。FAssetThumbnail则代表一个具体资产的缩略图对象,内部持有一个对池的引用,负责提供这个缩略图对应的Viewport和绘制用纹理。
要理解它的设计动机,就得知道内容浏览器的缩略图是“按需生成”的:几十万个资产不可能在打开编辑器时全部生成完,只有当你滚动到某个资产附近时,池才会触发该资产的异步生成任务。生成完后,通过委托通知关联的Slate控件重新绘制。
3.2 等回调后再取ViewportTexture
创建FAssetThumbnail本身不难:
TSharedPtr<FAssetThumbnailPool> ThumbnailPool = MakeShared<FAssetThumbnailPool>(32); TSharedPtr<FAssetThumbnail> AssetThumbnail = MakeShared<FAssetThumbnail>( AssetObject, 128, 128, ThumbnailPool );难点在于什么时机可以拿到可用的纹理。如果你创建完立刻去取图,很大概率拿到一张灰色占位图或者nullptr。因为缩略图还在异步生成队列里,根本没有到位。
正确的姿势是监听缩略图池的通知,等某个缩略图生成完成后,再通过FAssetThumbnail暴露的Viewport接口拿纹理:
ThumbnailPool->OnGetThumbnail().AddLambda([AssetThumbnail](const FAssetThumbnail& Thumbnail) { // 确认回调的就是自己关联的资产 if (&Thumbnail != AssetThumbnail.Get()) { return; } UTexture* ViewportTexture = AssetThumbnail->GetViewportRenderTargetTexture(); if (ViewportTexture == nullptr) { return; } // 这里拿到的是可用于Slate显示/读取的纹理资源 });不同UE版本里FAssetThumbnail获取纹理的接口名略有差异,有的叫GetViewportTexture,有的叫GetViewportRenderTargetTexture,编译报错时切换一下即可,本质都是拿那张已经烘好的纹理对象。
拿到的UTexture想要导出为图片文件,还需要再走一步:创建一个TextureRenderTarget2D,把纹理拷贝进去,然后通过RenderTarget->ReadPixels()把像素读出来。这又多了一层渲染目标拷贝,所以对于纯粹的“导出图片”需求,绕这一圈纯属自找麻烦。
3.3 什么时候该用它
我自己对AssetThumbnail路线的定位就一句话:只做展示,不做导出。
如果你的需求是“资产管理器里能看到缩略图”,或者“某个UI面板上要预览一批资产”,那就用AssetThumbnail,它替你解决了异步生成、缓存复用、UI刷新这些痛点。如果你是想把图存下来、发出去,应该回到ThumbnailTools那条路上。两条路都是UE官方提供的,但不是互相替代的关系,而是分工不同。
4. 两种输出形态:落盘保存与Base64回传图片信息
4.1 批量保存到指定目录的完整函数
现在把链路串起来,给你一个可以直接复制到编辑器模块里的完整函数。它的作用是:输入一个FAssetData列表,批量把每个资产的缩略图生成好,保存为PNG到指定目录,同时返回成功和失败的文件路径列表。
bool ExportThumbnailsToLocal( const TArray<FAssetData>& InAssets, const FString& OutDir, TArray<TPair<FString, FString>>& OutResult) { if (InAssets.Num() == 0) { return false; } IFileManager::Get().MakeDirectory(*OutDir, true); for (const FAssetData& AssetData : InAssets) { UObject* Asset = AssetData.GetAsset(); if (!Asset) { continue; } FObjectThumbnail* Thumbnail = ThumbnailTools::GenerateThumbnailForObjectToSaveToFile( Asset, 256, 256 ); if (!Thumbnail) { continue; } const int32 Width = Thumbnail->GetImageWidth(); const int32 Height = Thumbnail->GetImageHeight(); const TArray<uint8>& RawData = Thumbnail->GetUncompressedImageData(); TArray<uint8> PngBytes; FImageUtils::CompressImageArray(Width, Height, RawData, PngBytes); FString SafeAssetName = AssetData.AssetName.ToString(); SafeAssetName = FPaths::MakeValidFileName(SafeAssetName); FString OutFilePath = FPaths::Combine(OutDir, SafeAssetName + TEXT(".png")); if (FFileHelper::SaveArrayToFile(PngBytes, *OutFilePath)) { OutResult.Emplace(AssetData.GetObjectPathString(), OutFilePath); } } return true; }几个细节说一下:
- 这里调用GetAsset()会同步加载资产。如果资产本来就常驻内存,开销很小;如果是冷资产,第一次加载会有点卡,在批处理大量资产时建议用第6节的分帧思路。
- FPaths::MakeValidFileName用来过滤掉文件名里的非法字符,否则Windows上可能会出现保存失败。
- 缩略图生成失败的情况是真实存在的。资产没有注册ThumbnailRenderer时,Thumbnail可能返回nullptr。所以一定要做空判断,不要把生成失败直接当异常。
- 这个函数是同步的,几百个资产跑下来编辑器还是会卡。批量场景最好用一个滑动窗口,每帧处理几个,后面第6节会展开。
4.2 返回图片信息给HTTP/脚本调用:Base64 + 宽高 + 路径
“返回图片信息”在我理解里,有两种形态:一种是返回图片文件的本地路径,另一种是直接把图片数据编码成Base64,配合宽高、格式等信息打包成JSON文本,交给上一级工具。比如UE侧起一个HTTP服务,收到外部请求后返回这些JSON;或者C++工具函数直接输出给Python脚本消费。
Base64编码这段代码很简单:
#include "Misc/Base64.h" TArray<uint8> PngBytes; FImageUtils::CompressImageArray(Width, Height, RawData, PngBytes); FString Base64String; FBase64::Encode(PngBytes, Base64String);注意编码之后体积会比原PNG膨胀约三分之一。256x256的缩略图PNG一般就几KB到几十KB,Base64之后也不会太大,完全够用。但如果一次性返回几十张图,建议还是把图落盘,返回本地路径更稳妥。
完整的返回信息可以拼成JSON:
FString JsonString = FString::Printf( TEXT("{\"objectPath\":\"%s\",\"width\":%d,\"height\":%d,\"base64\":\"%s\"}"), *AssetPath, Width, Height, *Base64String );这里没有用UE的FJsonObjectSerializer,是为了让你一眼看懂字段结构。真要上生产,建议用FJsonObject装配好再序列化,避免特殊字符出问题——尤其是资产路径里如果带反斜杠或者引号,上面的Printf直接拼会有转义隐患。
4.3 扩展:Python侧怎么解码
如果你正好需要跟Python侧对接,解码这段Base64很简单:
import base64 from PIL import Image from io import BytesIO image_data = base64.b64decode(base64_string) img = Image.open(BytesIO(image_data)) print(img.size, img.mode)PNG和JPEG在这种场景下都能直接交PIL处理,不需要关心BGRA还是RGBA,因为PNG解码后PIL会按颜色空间自动解释。这也是为什么我强烈建议“要么存PNG文件,要么给PNG的Base64”,不要直接把RawData传给外部。RawData的通道顺序、每像素字节数、有无压缩这些信息太容易被忽略,一旦出错,排查成本极高。
5. 实测踩坑记录:空指针、偏色、卡线程与路径问题
5.1 GetThumbnailForObject为什么会返回nullptr
我刚上手时用的是ThumbnailTools::GetThumbnailForObject,想着“这不就是拿现有缩略图吗”,结果打了一堆空指针。
后来查了源码才知道,这个函数是去内容浏览器的缩略图缓存里捞已有的FObjectThumbnail对象。如果这个资产从来没被内容浏览器展示过,或者缓存被清理了,它就返回nullptr。它不是“没有就现场生成”的兜底函数,而是一个“只读缓存”的查询入口。
如果你的使用场景是“资产已经显示在内容浏览器里,现在想额外拿一份缩略图去做点事”,可以用它。但如果你想要“不管缓存里有没有,我都要一张缩略图”,就必须用第2节的GenerateThumbnailForObjectToSaveToFile。
这里其实反映了一个通用认知:带Generate字样的接口,通常意味着现场生成;带Get字样的接口,多半是查缓存。我后来选型时都会先按这个规律过一遍,能少踩很多坑。
5.2 通道顺序错了,网页上全是蓝紫色
这个坑前面已经提到,但值得单独说一次。背景是:我最初没有走CompressImageArray,而是把RawData直接通过TArray 发给了Python侧,想让Python直接做成Raw图片。结果Web端预览全是蓝紫色,像开了一个奇怪的色调映射。
问题就出在GetUncompressedImageData返回的是BGRA8。如果你希望直接拿到RGB数组,或者要把数据交给不识别BGRA的库,记得做一次通道交换:
void ConvertBGRAtoRGBA(TArray<uint8>& InRawData) { for (int32 i = 0; i + 3 < InRawData.Num(); i += 4) { Swap(InRawData[i], InRawData[i + 2]); } }交换完第0和第2个字节,就变成RGBA。注意别连Alpha一起动,Alpha在第3个字节,保持不变。
5.3 批处理时编辑器卡死
第二个实际问题是批处理几百个资产时,编辑器UI基本卡死,鼠标拖动都不顺畅。原因很简单:GenerateThumbnailForObjectToSaveToFile是同步函数,而且它在调用ThumbnailRenderer时,某些资产的缩略图渲染很重——尤其是高面数StaticMesh或材质复杂的SkeletalMesh,一个就可能耗掉几十毫秒甚至几百毫秒。
我的做法是分帧处理。用一个滑动窗口,每帧只处理几个资产,把整个批量过程拆开。这样虽然总耗时变长了,但编辑器和引擎的Tick还能继续跑,不会让用户以为程序崩了。
具体实现可以在编辑器模块里注册一个FTSTicker委托,每次Tick从队列里取N个资产处理:
FTSTicker::GetCoreTicker().AddTicker( FTickerDelegate::CreateLambda([this](float DeltaTime) { int32 ProcessedThisFrame = 0; while (PendingAssets.Num() > 0 && ProcessedThisFrame < MaxPerFrame) { FAssetData AssetData = PendingAssets.Pop(); // 执行生成与保存 ++ProcessedThisFrame; } return true; }) );MaxPerFrame建议设在5~10,具体看缩略图生成耗时。这个方案不是零成本,但至少不会让编辑器看起来像死了一样。
5.4 中文路径、未加载资产、延迟缩略图
还有几个零散但很常见的坑,放在一起说一下。
第一,中文路径。FFileHelper::SaveArrayToFile在Windows上保存中文路径本身没问题,但如果你的最终文件要被Web服务或第三方工具读取,还是尽量保证目录和文件名剔除非ASCII字符。否则传出去容易在后续环节产生一串乱码路径。用FPaths::MakeValidFileName还不够,我一般会额外做一次Unicode转拼音或者直接用资产GUID作为文件名。
第二,未加载资产。前文说过GetAsset()会触发同步加载。要在批处理场景里尽量避免大资产的同步加载,可以考虑先只在缩略图生成时才加载,不要提前把一堆资产引用保留在内存里。用一个FAssetData列表持有资产路径,而不是持有UObject强引用,这样释放起来干净。
第三,延迟缩略图。某些资产的缩略图是在编辑器后台线程异步生成的,GenerateThumbnailForObjectToSaveToFile虽然名字里带Generate,但它并不是对所有资产类型都会立刻返回有效结果。遇到个别资产返回了空FObjectThumbnail,不要直接报错,做个重试机制效果会更好。
6. 性能优化与缓存策略:批量导出时不拖垮编辑器
6.1 用资产注册表拿列表,避免全量遍历Object
在编辑器工具里拿资产列表,我见过不少人直接去遍历UObject,或者用FindObject到处找。工程小的时候没事,工程一大人就麻了。规范做法是从资产注册表(AssetRegistry)拿元数据列表。
以UE5.x为例:
#include "AssetRegistry/AssetRegistryModule.h" FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked<FAssetRegistryModule>("AssetRegistry"); TArray<FAssetData> AssetDataList; AssetRegistryModule.Get().GetAssetsByClass( FTopLevelAssetPath(TEXT("/Script/Engine"), TEXT("StaticMesh")), AssetDataList, true );如果是UE4.x,把第一个参数换成TEXT("StaticMesh")的FName即可。资产注册表拿到的只是FAssetData轻量描述,包含路径、类名、GUID,不会把资产本体加载进内存。这对批量处理是个巨大优势。
这里加上bSearchSubClasses参数为true,是因为很多引擎类有子类,比如蓝图生成的资产可能继承自StaticMesh,搜索子类能覆盖更多情况。
6.2 分帧处理和异步压缩
前面讲了分帧生成缩略图。另外一个优化点是:压缩PNG的工作也可以丢到异步任务里做。ThumbnailTools生成缩略图依赖编辑器GUI上下文,不能随便丢线程;但FImageUtils::CompressImageArray和FFileHelper::SaveArrayToFile本身是可以离线程的。
所以一个更好的pipeline是:主线程生成FObjectThumbnail,把RawData拷贝出来,塞到异步任务队列里;后台线程负责压缩和写盘。这样主线程只干一件事:生成缩略图,而且生成完就把数据交给后台,不占UI时间。
用UE的AsyncTask就可以:
#include "Async/Async.h" AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [RawData = MoveTemp(RawData), Width, Height, OutFilePath]() { TArray<uint8> PngBytes; FImageUtils::CompressImageArray(Width, Height, RawData, PngBytes); FFileHelper::SaveArrayToFile(PngBytes, *OutFilePath); });但要注意,RawData是从FObjectThumbnail里拿的const引用,拷贝一份再MoveTemp是必要的,不能直接把引用叼到lambda里然后让主线程把Thumbnail释放掉,否则就是悬垂引用。
6.3 直接复用Saved/Thumbnails缓存
最后一个关于缓存的想法。
UE的内容浏览器本身有一整套缩略图缓存,位置在工程的Saved/Thumbnails目录。你在编辑器里浏览过哪些资产,这些资产的缩略图就有可能存在那里。如果你只是想把某些“已经在内容浏览器里生成过缩略图”的资产对应图片复制出来,不需要每次都现场Generate。
不过UE的缩略图缓存文件名是经过GUID或路径哈希处理的,不是按资产名命名,直接复制文件出来没法对应。要靠代码去读FThumbnailCache又比较麻烦,所以我一般只在两个场景用到它:一是做增量导出,先比对目标目录里有没有同名PNG,有就跳过;二是设置一个较大尺寸的AssetThumbnailPool,让内容浏览器和我的工具共用一套缩略图池,减少重复生成。
对于大多数“动态获取资源缩略图、下载到本地或返回图片信息”的需求,更务实的策略是:自己维护一份导出缓存。比如导过一次之后,在工程里放一个Cache目录,下次直接查Cache,命中就不再走ThumbnailTools。这样比每次现场生成快得多,也能避免大量的重复IO和渲染开销。
我实测过,一个500资产规模的工程,第一次全量导出大约需要几十秒,第二次因为有缓存,几乎秒开。这对工具的使用体验影响很大,尤其是需要反复调试输出格式或者跟Web联调的时候,不用每次等全量重跑。
我做这套东西最大的体会是:不要被“动态获取缩略图”这句话误导,它在UE里的实现路径远比听起来琐碎。先从使用场景倒推,确定是编辑器还是运行时,再选定是走ThumbnailTools还是AssetThumbnail,最后才轮到编码和优化。把这四步理顺,剩下的事情其实就是调API加处理边缘情况。回头再看那些卡住我一整天的问题——通道顺序、异步回调时机、空指针——每一个都有明确的规律可循,只是文档里不会把这些坑提前告诉你。
如果再做一次,我会直接跳过AssetThumbnail,从第一节就用ThumbnailTools + FImageUtils这条组合拳,然后立刻把导出缓存加上。这套方案基本能覆盖编辑器工具里绝大部分缩略图导出需求。剩下的那部分,等真正遇到再回来补SceneCapture那套。