UE5 自定义资源类型完整实现指南
做 UE 插件或者工具链开发迟早会碰到一个需求项目里有一种特殊的数据既想在 Content Browser 里统一管理又想有专属的缩略图和编辑器界面——就像引擎内置的 Texture、DataTable 那样。官方文档对这块讲得比较零散很多关键细节要翻源码才能找到。这篇文章把整个流程从头到尾梳理一遍帮你少走弯路。1 目录基本概念定义资源类实现导入 Factory实现缩略图渲染器实现 AssetTypeActions实现自定义编辑器注册到插件模块文件清单常见问题总结2 一、基本概念2.1 什么是 UE 资源UE 里的资源就是一个存在.uasset文件里的 UObject 子类实例。自定义资源类型让你可以在 Content Browser 里管理自己的数据用 Details 面板编辑属性甚至做一个专属的编辑器界面。2.2 实现一个自定义资源需要哪些东西组件作用UObject子类资源本体存数据UFactory负责从文件创建资源拖入时触发UThumbnailRendererContent Browser 里的缩略图FAssetTypeActions_Base右键菜单、双击行为、资源颜色FAssetEditorToolkit自定义编辑器主窗口FEditorViewportClient编辑器里的视口绘制和交互2.3 整体结构Content Browser │ ├── 显示缩略图 ←── UThumbnailRenderer::Draw() ├── 右键菜单 ←── IAssetTypeActions::GetActions() └── 双击打开 ←── IAssetTypeActions::OpenAssetEditor() │ ▼ FAssetEditorToolkit编辑器窗口 ├── SEditorViewport视口 │ └── FEditorViewportClient绘制 输入 └── IDetailsView属性面板2.4 数据流拖入文件 ──► UFactory::FactoryCreateFile() ──► UMyAsset资源对象 │ ┌───────────┴───────────┐ ▼ ▼ UThumbnailRenderer FAssetEditorToolkit Content Browser 缩略图 双击打开的编辑器3 二、定义资源类3.1 为什么要继承 UObjectUE 的资源系统依赖反射只有UCLASS标记的类才能被序列化、在编辑器里显示、被蓝图引用。UPROPERTY控制哪些字段出现在 Details 面板UFUNCTION控制哪些函数可以在蓝图里调用。3.2 头文件MyPlugin/Source/MyPlugin/Classes/MyAsset.h#pragma once #include CoreMinimal.h #include UObject/NoExportTypes.h #include MyAsset.generated.h UCLASS(BlueprintType, meta (DisplayThumbnail true)) class MYPLUGIN_API UMyAsset : public UObject { GENERATED_BODY() public: UMyAsset(const FObjectInitializer ObjectInitializer FObjectInitializer::Get()); // 底层纹理 UPROPERTY(BlueprintReadOnly, VisibleAnywhere, Category MyAsset) TObjectPtrUTexture BaseTexture; // UV 起始坐标0-1 范围 UPROPERTY(BlueprintReadOnly, EditAnywhere, Category MyAsset) FVector2D StartUV; // UV 区域大小0-1 范围 UPROPERTY(BlueprintReadOnly, EditAnywhere, Category MyAsset) FVector2D SizeUV; // 返回实际像素尺寸 UFUNCTION(BlueprintCallable, Category MyAsset) FVector2D GetDimensions() const; };3.3 实现MyPlugin/Source/MyPlugin/Private/MyAsset.cpp#include MyAsset.h UMyAsset::UMyAsset(const FObjectInitializer ObjectInitializer) : Super(ObjectInitializer) , StartUV(0.f, 0.f) , SizeUV(1.f, 1.f) { } FVector2D UMyAsset::GetDimensions() const { if (!BaseTexture) { return FVector2D::ZeroVector; } return FVector2D( BaseTexture-GetSurfaceWidth() * SizeUV.X, BaseTexture-GetSurfaceHeight() * SizeUV.Y ); }3.4 几个宏的说明BlueprintType允许在蓝图里把该类作为变量类型使用meta (DisplayThumbnail true)让 Content Browser 调用缩略图渲染器VisibleAnywhereDetails 面板里显示但不可编辑只读展示用EditAnywhereDetails 面板里可以直接编辑4 三、实现导入 Factory4.1 Factory 是做什么的把文件拖进 Content Browser 时UE 会扫描所有已注册的 Factory根据文件扩展名找到合适的然后调用FactoryCreateFile()来创建资源对象。所以 Factory 本质上就是一个从文件构建 UObject的转换器。4.2 头文件MyPlugin/Source/MyPluginEditor/Classes/MyAssetImportFactory.h#pragma once #include CoreMinimal.h #include Factories/Factory.h #include MyAssetImportFactory.generated.h UCLASS(HideCategories Object) class UMyAssetImportFactory : public UFactory { GENERATED_BODY() public: UMyAssetImportFactory(const FObjectInitializer ObjectInitializer FObjectInitializer::Get()); virtual UObject* FactoryCreateFile( UClass* InClass, UObject* InParent, FName Name, EObjectFlags Flags, const FString Filename, const TCHAR* Parms, FFeedbackContext* Warn, bool bOutOperationCanceled ) override; virtual bool DoesSupportClass(UClass* Class) override; };4.3 实现MyPlugin/Source/MyPluginEditor/Private/MyAssetImportFactory.cpp#include MyAssetImportFactory.h #include MyAsset.h UMyAssetImportFactory::UMyAssetImportFactory(const FObjectInitializer ObjectInitializer) : Super(ObjectInitializer) { // 注册支持的文件扩展名格式扩展名;描述文字 Formats.Add(TEXT(mydata;My Custom Data)); SupportedClass UMyAsset::StaticClass(); bCreateNew false; // 不支持右键菜单新建 bEditorImport true; // 支持拖入导入 } bool UMyAssetImportFactory::DoesSupportClass(UClass* Class) { return Class SupportedClass; } UObject* UMyAssetImportFactory::FactoryCreateFile( UClass* InClass, UObject* InParent, FName Name, EObjectFlags Flags, const FString Filename, const TCHAR* Parms, FFeedbackContext* Warn, bool bOutOperationCanceled ) { UMyAsset* NewAsset NewObjectUMyAsset(InParent, InClass, Name, Flags | RF_Transactional); // 读取并解析文件把数据写入 NewAsset // 例如读 JSON // FString FileContent; // FFileHelper::LoadFileToString(FileContent, *Filename); // ... 解析后赋值到 NewAsset-StartUV 等字段 if (NewAsset) { NewAsset-MarkPackageDirty(); } return NewAsset; }注意RF_Transactional这个 flag 要加上不然 CtrlZ 撤销会出问题。5 四、实现缩略图渲染器5.1 原理Content Browser 显示资源时如果这个类有注册 ThumbnailRenderer就会调用Draw()在一个固定尺寸的 Canvas 上绘制。你拿到的就是一块画布和目标矩形区域画什么完全自由。5.2 头文件MyPlugin/Source/MyPluginEditor/Classes/MyAssetThumbnailRenderer.h#pragma once #include CoreMinimal.h #include ThumbnailRendering/ThumbnailRenderer.h #include MyAssetThumbnailRenderer.generated.h UCLASS() class UMyAssetThumbnailRenderer : public UThumbnailRenderer { GENERATED_BODY() public: virtual void Draw( UObject* Object, int32 X, int32 Y, uint32 Width, uint32 Height, FRenderTarget* Viewport, FCanvas* Canvas, bool bAdditionalViewFamily ) override; virtual bool CanVisualizeAsset(UObject* Object) override; };5.3 实现MyPlugin/Source/MyPluginEditor/Private/MyAssetThumbnailRenderer.cpp#include MyAssetThumbnailRenderer.h #include MyAsset.h #include CanvasItem.h #include CanvasTypes.h #include Engine/Texture2D.h #include ThumbnailRendering/ThumbnailManager.h bool UMyAssetThumbnailRenderer::CanVisualizeAsset(UObject* Object) { return CastUMyAsset(Object) ! nullptr; } void UMyAssetThumbnailRenderer::Draw( UObject* Object, int32 X, int32 Y, uint32 Width, uint32 Height, FRenderTarget* Viewport, FCanvas* Canvas, bool bAdditionalViewFamily ) { UMyAsset* MyAsset CastUMyAsset(Object); if (!MyAsset || !MyAsset-BaseTexture) { return; } FTextureResource* Resource MyAsset-BaseTexture-GetResource(); if (!Resource) { return; } // 有透明通道的话先画棋盘格背景 UTexture2D* Tex2D CastUTexture2D(MyAsset-BaseTexture); if (Tex2D Tex2D-HasAlphaChannel()) { UTexture2D* Checker UThumbnailManager::Get().CheckerboardTexture; if (Checker Checker-GetResource()) { Canvas-DrawTile( X, Y, Width, Height, 0.f, 0.f, 8.f, 8.f, FLinearColor::White, Checker-GetResource() ); } } // 计算保持比例的绘制区域 float TexW MyAsset-BaseTexture-GetSurfaceWidth() * MyAsset-SizeUV.X; float TexH MyAsset-BaseTexture-GetSurfaceHeight() * MyAsset-SizeUV.Y; float DrawW Width, DrawH Height; if (TexW / TexH (float)Width / Height) DrawH DrawW * TexH / TexW; else DrawW DrawH * TexW / TexH; float OffsetX X (Width - DrawW) * 0.5f; float OffsetY Y (Height - DrawH) * 0.5f; FVector2D UV0(MyAsset-StartUV); FVector2D UV1(MyAsset-StartUV MyAsset-SizeUV); FCanvasTileItem Item(FVector2D(OffsetX, OffsetY), Resource, FVector2D(DrawW, DrawH), UV0, UV1); Item.BlendMode (Tex2D Tex2D-HasAlphaChannel()) ? SE_BLEND_Translucent : SE_BLEND_Opaque; Item.Draw(Canvas); }6 五、实现 AssetTypeActions6.1 它控制什么FAssetTypeActions_Base决定资源在 Content Browser 里显示什么颜色、叫什么名字、双击时打开什么编辑器、右键菜单里有哪些自定义选项。6.2 头文件MyPlugin/Source/MyPluginEditor/Private/MyAssetTypeActions.h#pragma once #include AssetTypeActions_Base.h class FMyAssetTypeActions : public FAssetTypeActions_Base { public: virtual FText GetName() const override; virtual FColor GetTypeColor() const override; virtual UClass* GetSupportedClass() const override; virtual uint32 GetCategories() override; virtual void OpenAssetEditor( const TArrayUObject* InObjects, TSharedPtrIToolkitHost EditWithinLevelEditor ) override; };6.3 实现MyPlugin/Source/MyPluginEditor/Private/MyAssetTypeActions.cpp#include MyAssetTypeActions.h #include MyAsset.h #include MyAssetEditor.h #define LOCTEXT_NAMESPACE MyAssetTypeActions FText FMyAssetTypeActions::GetName() const { return LOCTEXT(Name, My Asset); } FColor FMyAssetTypeActions::GetTypeColor() const { return FColor(255, 128, 0); } UClass* FMyAssetTypeActions::GetSupportedClass() const { return UMyAsset::StaticClass(); } uint32 FMyAssetTypeActions::GetCategories() { return EAssetTypeCategories::Misc; } void FMyAssetTypeActions::OpenAssetEditor( const TArrayUObject* InObjects, TSharedPtrIToolkitHost EditWithinLevelEditor) { const EToolkitMode::Type Mode EditWithinLevelEditor.IsValid() ? EToolkitMode::WorldCentric : EToolkitMode::Standalone; for (UObject* Obj : InObjects) { if (UMyAsset* Asset CastUMyAsset(Obj)) { TSharedRefFMyAssetEditor Editor MakeShareable(new FMyAssetEditor()); Editor-InitMyAssetEditor(Mode, EditWithinLevelEditor, Asset); } } } #undef LOCTEXT_NAMESPACE7 六、实现自定义编辑器这部分是整个流程里代码最多的拆成三个文件来讲。7.1 视口客户端负责绘制和输入视口客户端是编辑器里那块画面的实际控制者。绘制逻辑写在DrawCanvas()里鼠标键盘输入在InputKey()里处理。MyAssetEditorViewportClient.h#pragma once #include EditorViewportClient.h class FMyAssetEditor; class FMyAssetEditorViewportClient : public FEditorViewportClient { public: explicit FMyAssetEditorViewportClient( const TSharedRefFMyAssetEditor InEditor, const TWeakPtrSEditorViewport InViewportWidget TWeakPtrSEditorViewport() ); virtual void DrawCanvas(FViewport InViewport, FSceneView View, FCanvas Canvas) override; virtual FLinearColor GetBackgroundColor() const override; virtual bool InputKey(const FInputKeyEventArgs EventArgs) override; virtual bool InputGesture( FViewport* InViewport, const FInputDeviceId DeviceId, EGestureEvent GestureType, const FVector2D GestureDelta, bool bIsDirectionInvertedFromDevice, const uint64 Timestamp ) override; void SetZoomLevel(float InZoom); private: TWeakPtrFMyAssetEditor EditorPtr; float ZoomLevel 1.0f; static constexpr float MinZoom 0.1f; static constexpr float MaxZoom 16.0f; static constexpr float ZoomStep 1.2f; };MyAssetEditorViewportClient.cpp#include MyAssetEditorViewportClient.h #include MyAssetEditor.h #include MyAsset.h #include CanvasItem.h #include CanvasTypes.h FMyAssetEditorViewportClient::FMyAssetEditorViewportClient( const TSharedRefFMyAssetEditor InEditor, const TWeakPtrSEditorViewport InViewportWidget) : FEditorViewportClient(nullptr, nullptr, InViewportWidget) , EditorPtr(InEditor) { SetRealtime(true); SetViewportType(LVT_OrthoXY); } FLinearColor FMyAssetEditorViewportClient::GetBackgroundColor() const { return FLinearColor(0.1f, 0.1f, 0.1f); } void FMyAssetEditorViewportClient::DrawCanvas( FViewport InViewport, FSceneView View, FCanvas Canvas) { UMyAsset* Asset EditorPtr.Pin()-GetAssetBeingEdited(); if (!Asset || !Asset-BaseTexture) { return; } FTextureResource* Resource Asset-BaseTexture-GetResource(); if (!Resource) { return; } float TexW Asset-BaseTexture-GetSurfaceWidth() * Asset-SizeUV.X; float TexH Asset-BaseTexture-GetSurfaceHeight() * Asset-SizeUV.Y; float DrawW TexW * ZoomLevel; float DrawH TexH * ZoomLevel; FIntPoint ViewSize InViewport.GetSizeXY(); float OffsetX (ViewSize.X - DrawW) * 0.5f; float OffsetY (ViewSize.Y - DrawH) * 0.5f; FVector2D UV0(Asset-StartUV); FVector2D UV1(Asset-StartUV Asset-SizeUV); FCanvasTileItem Item(FVector2D(OffsetX, OffsetY), Resource, FVector2D(DrawW, DrawH), UV0, UV1); Item.Draw(Canvas); } bool FMyAssetEditorViewportClient::InputKey(const FInputKeyEventArgs EventArgs) { if (EventArgs.Event IE_Pressed) { if (EventArgs.Key EKeys::MouseScrollUp) { SetZoomLevel(ZoomLevel * ZoomStep); return true; } if (EventArgs.Key EKeys::MouseScrollDown) { SetZoomLevel(ZoomLevel / ZoomStep); return true; } } return FEditorViewportClient::InputKey(EventArgs); } bool FMyAssetEditorViewportClient::InputGesture( FViewport* InViewport, const FInputDeviceId DeviceId, EGestureEvent GestureType, const FVector2D GestureDelta, bool bIsDirectionInvertedFromDevice, const uint64 Timestamp) { if (GestureType EGestureEvent::Scroll) { float Delta bIsDirectionInvertedFromDevice ? -GestureDelta.Y : GestureDelta.Y; SetZoomLevel(ZoomLevel * (1.0f Delta * 0.01f)); return true; } return FEditorViewportClient::InputGesture( InViewport, DeviceId, GestureType, GestureDelta, bIsDirectionInvertedFromDevice, Timestamp); } void FMyAssetEditorViewportClient::SetZoomLevel(float InZoom) { ZoomLevel FMath::Clamp(InZoom, MinZoom, MaxZoom); Invalidate(); }7.2 编辑器主框架MyAssetEditor.h#pragma once #include Toolkits/AssetEditorToolkit.h class UMyAsset; class FMyAssetEditor : public FAssetEditorToolkit { public: void InitMyAssetEditor( EToolkitMode::Type Mode, TSharedPtrIToolkitHost InitToolkitHost, UMyAsset* InAsset ); UMyAsset* GetAssetBeingEdited() const { return AssetBeingEdited; } // FAssetEditorToolkit 接口 virtual void RegisterTabSpawners(const TSharedRefFTabManager TabManager) override; virtual void UnregisterTabSpawners(const TSharedRefFTabManager TabManager) override; virtual FName GetToolkitFName() const override { return FName(MyAssetEditor); } virtual FText GetBaseToolkitName() const override; virtual FString GetWorldCentricTabPrefix() const override { return TEXT(MyAsset); } virtual FLinearColor GetWorldCentricTabColorScale() const override { return FLinearColor::White; } private: TObjectPtrUMyAsset AssetBeingEdited; TSharedPtrSEditorViewport ViewportWidget; TSharedPtrIDetailsView DetailsView; static const FName ViewportTabId; static const FName DetailsTabId; };MyAssetEditor.cpp#include MyAssetEditor.h #include MyAsset.h #include MyAssetEditorViewportClient.h #include Modules/ModuleManager.h #include PropertyEditorModule.h #include Widgets/Docking/SDockTab.h #define LOCTEXT_NAMESPACE MyAssetEditor const FName FMyAssetEditor::ViewportTabId(MyAssetEditor_Viewport); const FName FMyAssetEditor::DetailsTabId(MyAssetEditor_Details); // 在 .cpp 里定义视口 Widget不需要单独一个文件 class SMyAssetEditorViewport : public SEditorViewport { public: SLATE_BEGIN_ARGS(SMyAssetEditorViewport) {} SLATE_ARGUMENT(TSharedPtrFMyAssetEditor, Editor) SLATE_END_ARGS() void Construct(const FArguments InArgs) { EditorPtr InArgs._Editor; SEditorViewport::Construct(SEditorViewport::FArguments()); } virtual TSharedRefFEditorViewportClient MakeEditorViewportClient() override { return MakeShareable(new FMyAssetEditorViewportClient( EditorPtr.Pin().ToSharedRef(), SharedThis(this))); } private: TWeakPtrFMyAssetEditor EditorPtr; }; void FMyAssetEditor::InitMyAssetEditor( EToolkitMode::Type Mode, TSharedPtrIToolkitHost InitToolkitHost, UMyAsset* InAsset) { AssetBeingEdited InAsset; ViewportWidget SNew(SMyAssetEditorViewport).Editor(SharedThis(this)); FPropertyEditorModule PropModule FModuleManager::LoadModuleCheckedFPropertyEditorModule(PropertyEditor); FDetailsViewArgs Args; Args.bAllowSearch true; Args.NameAreaSettings FDetailsViewArgs::HideNameArea; DetailsView PropModule.CreateDetailView(Args); DetailsView-SetObject(InAsset); const TSharedRefFTabManager::FLayout Layout FTabManager::NewLayout(MyAssetEditor_v1) -AddArea(FTabManager::NewPrimaryArea() -SetOrientation(Orient_Horizontal) -Split(FTabManager::NewStack() -SetSizeCoefficient(0.7f) -AddTab(ViewportTabId, ETabState::OpenedTab)) -Split(FTabManager::NewStack() -SetSizeCoefficient(0.3f) -AddTab(DetailsTabId, ETabState::OpenedTab))); InitAssetEditor(Mode, InitToolkitHost, GetToolkitFName(), Layout, true, true, InAsset); } FText FMyAssetEditor::GetBaseToolkitName() const { return LOCTEXT(AppLabel, My Asset Editor); } void FMyAssetEditor::RegisterTabSpawners(const TSharedRefFTabManager InTabManager) { FAssetEditorToolkit::RegisterTabSpawners(InTabManager); InTabManager-RegisterTabSpawner(ViewportTabId, FOnSpawnTab::CreateLambda( [this](const FSpawnTabArgs) { return SNew(SDockTab) .Label(LOCTEXT(ViewportTab, Viewport)) [ViewportWidget.ToSharedRef()]; })); InTabManager-RegisterTabSpawner(DetailsTabId, FOnSpawnTab::CreateLambda( [this](const FSpawnTabArgs) { return SNew(SDockTab) .Label(LOCTEXT(DetailsTab, Details)) [DetailsView.ToSharedRef()]; })); } void FMyAssetEditor::UnregisterTabSpawners(const TSharedRefFTabManager InTabManager) { FAssetEditorToolkit::UnregisterTabSpawners(InTabManager); InTabManager-UnregisterTabSpawner(ViewportTabId); InTabManager-UnregisterTabSpawner(DetailsTabId); } #undef LOCTEXT_NAMESPACE8 七、注册到插件模块所有东西都在StartupModule()里注册在ShutdownModule()里反注册顺序不能反。MyPlugin/Source/MyPluginEditor/Private/MyPluginEditorModule.cpp#include Modules/ModuleManager.h #include AssetToolsModule.h #include ThumbnailRendering/ThumbnailManager.h #include MyAsset.h #include MyAssetTypeActions.h #include MyAssetThumbnailRenderer.h class FMyPluginEditorModule : public IModuleInterface { public: virtual void StartupModule() override { IAssetTools AssetTools FModuleManager::LoadModuleCheckedFAssetToolsModule(AssetTools).Get(); AssetTypeAction MakeShareable(new FMyAssetTypeActions()); AssetTools.RegisterAssetTypeActions(AssetTypeAction.ToSharedRef()); UThumbnailManager::Get().RegisterCustomRenderer( UMyAsset::StaticClass(), UMyAssetThumbnailRenderer::StaticClass()); } virtual void ShutdownModule() override { if (UObjectInitialized()) { UThumbnailManager::Get().UnregisterCustomRenderer(UMyAsset::StaticClass()); } if (FModuleManager::Get().IsModuleLoaded(AssetTools)) { FAssetToolsModule AssetToolsModule FModuleManager::GetModuleCheckedFAssetToolsModule(AssetTools); AssetToolsModule.Get().UnregisterAssetTypeActions(AssetTypeAction.ToSharedRef()); } } private: TSharedPtrIAssetTypeActions AssetTypeAction; }; IMPLEMENT_MODULE(FMyPluginEditorModule, MyPluginEditor);Build.cs 里要加上这些依赖PublicDependencyModuleNames.AddRange(new string[] { Core, CoreUObject, Engine, UnrealEd, AssetTools, PropertyEditor, EditorFramework, ToolMenus });9 八、文件清单MyPlugin/ ├── Source/ │ ├── MyPlugin/ # 运行时模块 │ │ ├── Classes/ │ │ │ └── MyAsset.h │ │ ├── Private/ │ │ │ └── MyAsset.cpp │ │ └── MyPlugin.Build.cs │ │ │ └── MyPluginEditor/ # 编辑器模块 │ ├── Classes/ │ │ ├── MyAssetImportFactory.h │ │ └── MyAssetThumbnailRenderer.h │ ├── Private/ │ │ ├── MyAssetImportFactory.cpp │ │ ├── MyAssetThumbnailRenderer.cpp │ │ ├── MyAssetTypeActions.h │ │ ├── MyAssetTypeActions.cpp │ │ ├── MyAssetEditor.h │ │ ├── MyAssetEditor.cpp # 含 SMyAssetEditorViewport │ │ ├── MyAssetEditorViewportClient.h │ │ ├── MyAssetEditorViewportClient.cpp │ │ └── MyPluginEditorModule.cpp │ └── MyPluginEditor.Build.cs └── MyPlugin.uplugin.uplugin里记得把两个模块都声明进去编辑器模块的Type设为EditorLoadingPhase用PostEngineInit或Default均可。10 九、常见问题编译报错找不到头文件先检查Build.cs的PublicIncludePathModuleNames或PrivateIncludePathModuleNames是否缺了相关模块。UnrealEd、PropertyEditor、EditorFramework是最常漏的几个。双击资源没有反应GetSupportedClass()返回的类要和实际资源类型完全对应用StaticClass()不要手写类名字符串。另外检查OpenAssetEditor()里InitMyAssetEditor有没有被正常调用。缩略图不显示两个地方要同时满足资源类声明里要有meta (DisplayThumbnail true)并且StartupModule()里RegisterCustomRenderer要成功执行。Content Browser 里可以右键资源选Refresh Thumbnail强制刷新看效果。编辑器打开后视口空白先在DrawCanvas()里加个日志确认有没有走到这里。常见原因是GetAssetBeingEdited()返回空或者BaseTexture-GetResource()还没准备好纹理异步加载中。Factory 注册了但拖文件没反应确认Formats.Add()里的扩展名格式正确小写不带点并且bEditorImport true没有漏掉。11 十、总结回顾一下整个实现路径其实就是六个东西按顺序搭起来步骤类作用1UObject子类资源的数据载体用UPROPERTY暴露属性2UFactory把外部文件转成 UObject支持拖入导入3UThumbnailRenderer在 Content Browser 里画缩略图4FAssetTypeActions_Base控制右键菜单和双击行为5FAssetEditorToolkitFEditorViewportClient双击后打开的编辑器界面6模块StartupModule()把上面所有组件注册到引擎几个容易忽略的细节最后再强调一下运行时模块和编辑器模块分开。UMyAsset本身放运行时模块Factory / ThumbnailRenderer / Editor 全放编辑器模块否则打包会出问题。RF_Transactional必须加。创建资源对象时带上这个 flagCtrlZ 撤销才能正常工作。缩略图的两个条件缺一不可meta (DisplayThumbnail true)和RegisterCustomRenderer()必须同时存在。注册顺序有要求。ShutdownModule()里注销的顺序要和StartupModule()里注册的顺序相反特别是UThumbnailManager要在UObjectInitialized()检查之后再调用。视口 Widget 可以写在 .cpp 里。SMyAssetEditorViewport不需要单独的头文件直接在MyAssetEditor.cpp里定义就行省一个文件。自定义资源这套机制在 UE 里已经相当成熟引擎内部的 Texture Editor、Static Mesh Editor 本质上都是同一套框架。把这个流程跑通一遍之后再做更复杂的编辑器扩展思路就会清晰很多。