An asset preview window refers to the ability to preview the representation of assets in the world after opening the asset editor. This preview is widely used in UE’s StaticMesh and Animation. An example is as follows:

In this post, we will learn about how to add a preview window to our own custom assets.

1. Preparation

According to the method we described before, we add a custom class and add asset Action, factory and editor to it:

UCLASS()
class ADVANCEDALS_API UPreviewObject : public UObject
{
	GENERATED_BODY()
public:

	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	float ValueFloat;

	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	int32 ValueInt;
};

Its asset Action is:

class ADVANCEDALSEDITOR_API FAssetTypeActions_PreviewObject : public FAssetTypeActions_Base
{
public:
	FAssetTypeActions_PreviewObject(EAssetTypeCategories::Type Type);
	/** Begin FAssetTypeActions_Base Interface */
    virtual FText GetName() const override;
	virtual UClass* GetSupportedClass() const override;
	virtual uint32 GetCategories() override;
	virtual FColor GetTypeColor() const override;
	virtual void OpenAssetEditor(const TArray<UObject*>& InObjects, TSharedPtr<IToolkitHost> EditWithinLevelEditor) override;

private:
	EAssetTypeCategories::Type TypeCategories;
};

//.cpp
void FAssetTypeActions_PreviewObject::OpenAssetEditor(const TArray<UObject*>& InObjects,
	TSharedPtr<IToolkitHost> EditWithinLevelEditor)
{
	const EToolkitMode::Type ToolKitModeType = EditWithinLevelEditor ? EToolkitMode::WorldCentric : EToolkitMode::Standalone;

	for (auto ObjectIterator = InObjects.CreateConstIterator(); ObjectIterator; ++ObjectIterator)
	{
		if (UPreviewObject* OurAsset = Cast<UPreviewObject>(*ObjectIterator))
		{
			const TSharedRef<FPreviewObjectEditorToolkit> RecoilAssetEditorToolKit = MakeShareable(new FPreviewObjectEditorToolkit());
			RecoilAssetEditorToolKit->InitializeAssetEditor(ToolKitModeType, EditWithinLevelEditor, OurAsset);
		}
	}
}

Its editor class:

class ADVANCEDALSEDITOR_API FPreviewObjectEditorToolkit : public FAssetEditorToolkit
{
public:

	/** Begin FAssetEditorToolkit Interface */

	FORCEINLINE virtual FName GetToolkitFName() const override {return FName("PreviewObjectToolkit");}
	virtual FText GetBaseToolkitName() const override {return NSLOCTEXT("EditorExtension", "Preview Base Toolkit Name", "浏览窗口物体编辑器");}
	virtual FString GetWorldCentricTabPrefix() const override {return NSLOCTEXT("EditorExtension", "Preview Object Tab Prefix", "浏览窗口").ToString();}
	virtual FLinearColor GetWorldCentricTabColorScale() const override {return FLinearColor::Yellow;}
	
        virtual void RegisterTabSpawners(const TSharedRef<FTabManager>& TabManager) override;
	virtual void UnregisterTabSpawners(const TSharedRef<FTabManager>& TabManager) override;
	
	/** End FAssetEditorToolkit Interface */

	void InitializeAssetEditor(const EToolkitMode::Type Mode, const TSharedPtr<IToolkitHost>& InitToolkitHost, UObject* InAssets);

private:

	TObjectPtr<UPreviewObject> EditingPreviewObject = nullptr;
	TSharedPtr<IDetailsView> DetailsViewWidget;

	inline static FName PreviewObjectEditorLayoutName{"PreviewObjectEditorLayout"};
	inline static FName PreviewObjectEditorDetailTabName{"PreviewObjectEditorDetailTab"};
	inline static FName PreviewObjectEditorPreviewSceneTabName{"PreviewObjectEditorPreviewSceneTab"};

	TSharedRef<SDockTab> SpawnDetailTab(const FSpawnTabArgs& SpawnTabArgs);
};

//.cpp
void FPreviewObjectEditorToolkit::RegisterTabSpawners(const TSharedRef<FTabManager>& InTabManager)
{
	FAssetEditorToolkit::RegisterTabSpawners(InTabManager);
	InTabManager->RegisterTabSpawner(PreviewObjectEditorDetailTabName, FOnSpawnTab::CreateRaw(this, &FPreviewObjectEditorToolkit::SpawnDetailTab));
	// 注册我们的浏览界面
}

void FPreviewObjectEditorToolkit::UnregisterTabSpawners(const TSharedRef<FTabManager>& InTabManager)
{
	FAssetEditorToolkit::UnregisterTabSpawners(InTabManager);

	InTabManager->UnregisterTabSpawner(PreviewObjectEditorDetailTabName);
	// 注销我们的浏览界面
}

void FPreviewObjectEditorToolkit::InitializeAssetEditor(const EToolkitMode::Type Mode,
	const TSharedPtr<IToolkitHost>& InitToolkitHost, UObject* InAssets)
{
	const TSharedRef<FTabManager::FLayout> StandaloneLayout = FTabManager::NewLayout(PreviewObjectEditorLayoutName)->AddArea
	(
		FTabManager::NewPrimaryArea()->SetOrientation(EOrientation::Orient_Horizontal)
		->Split
		(
			FTabManager::NewStack()->AddTab(PreviewObjectEditorPreviewSceneTabName, ETabState::OpenedTab)
		)
		->Split
		(
			FTabManager::NewStack()->AddTab(PreviewObjectEditorDetailTabName, ETabState::OpenedTab)
		)
	);

	EditingPreviewObject = Cast<UPreviewObject>(InAssets);
	InitAssetEditor(Mode, InitToolkitHost, FName("PreviewObjectEditor"), StandaloneLayout, true, true, InAssets);
	RegenerateMenusAndToolbars();
}

TSharedRef<SDockTab> FPreviewObjectEditorToolkit::SpawnDetailTab(const FSpawnTabArgs& SpawnTabArgs)
{
	FPropertyEditorModule& PropertyEditorModule = FModuleManager::LoadModuleChecked<FPropertyEditorModule>("PropertyEditor");
	const FDetailsViewArgs DetailsViewArgs;

	DetailsViewWidget = PropertyEditorModule.CreateDetailView(DetailsViewArgs);
	DetailsViewWidget->SetObject(GetEditingObject());

	return SNew(SDockTab)
	[
		DetailsViewWidget.ToSharedRef()
	];
}

Its factory class:

UCLASS()
class ADVANCEDALSEDITOR_API UPreviewObjectFactory : public UFactory
{
	GENERATED_BODY()

public:

	UPreviewObjectFactory();

	virtual bool CanCreateNew() const override;
	virtual bool ShouldShowInNewMenu() const override;
	virtual UObject* FactoryCreateNew(UClass* InClass, UObject* InParent, FName InName, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override;
};

//.cpp
UPreviewObjectFactory::UPreviewObjectFactory()
{
	SupportedClass = UPreviewObject::StaticClass();
	bCreateNew = true;
}

bool UPreviewObjectFactory::CanCreateNew() const
{
	return true;
}

bool UPreviewObjectFactory::ShouldShowInNewMenu() const
{
	return true;
}

UObject* UPreviewObjectFactory::FactoryCreateNew(UClass* InClass, UObject* InParent, FName InName, EObjectFlags Flags,
	UObject* Context, FFeedbackContext* Warn)
{
	UPreviewObject* PreviewObject = NewObject<UPreviewObject>(InParent, InClass, InName, Flags);
	check(PreviewObject != nullptr);
	return PreviewObject;
}

Register it in the module:

void FAdvancedAlsEditorModule::RegisterAssetsAction() const
{
	IAssetTools& AssetToolModule = FModuleManager::LoadModuleChecked<FAssetToolsModule>("AssetTools").Get();
	const auto Category = AssetToolModule.RegisterAdvancedAssetCategory(FName(TEXT("自定义资产")), NSLOCTEXT("Editor", "Our Defined Asset", "我们的自定义资产"));
	const auto PreviewObjectCategory = AssetToolModule.RegisterAdvancedAssetCategory(FName(TEXT("自定义资产")), NSLOCTEXT("EditorExtension", "Preview Object Asset Name", "浏览窗口资产"));
	const TSharedPtr<FAssetTypeActions_OurAssets> AssetsTypeAction = MakeShareable(new FAssetTypeActions_OurAssets(Category));
	const TSharedPtr<FAssetTypeActions_PreviewObject> PreviewObjectTypeAction = MakeShareable(new FAssetTypeActions_PreviewObject(PreviewObjectCategory));
	AssetToolModule.RegisterAssetTypeActions(AssetsTypeAction.ToSharedRef());
	AssetToolModule.RegisterAssetTypeActions(PreviewObjectTypeAction.ToSharedRef());
}

2. Add preview window class

The preview window can inherit from SEditorViewport, so let’s write the simplest one:

Inherit from a SViewport:

class ADVANCEDALSEDITOR_API SPreviewObjectViewPort : public SEditorViewport
{
public:
	SLATE_BEGIN_ARGS(SPreviewObjectViewPort)
	{
	}

	SLATE_END_ARGS()

	/** Constructs this widget with InArgs */
	void Construct(const FArguments& InArgs);

	/** Begin SViewport Interface */

	// 这个函数实现聚焦的时候的行为,也就是按F该干啥,不会有人用手柄开发游戏吧
	virtual void OnFocusViewportToSelection() override;
	// 添加一个ViewportClient类,我们要处理的核心部分之一
	virtual TSharedRef<FEditorViewportClient> MakeEditorViewportClient() override;
	// 创建Toolbar
	virtual TSharedPtr<SWidget> MakeViewportToolbar() override;
	
	/** End SViewport Interface */
};

which is actually implemented as:

BEGIN_SLATE_FUNCTION_BUILD_OPTIMIZATION

void SPreviewObjectViewPort::Construct(const FArguments& InArgs)
{
	SEditorViewport::Construct(SEditorViewport::FArguments());
}

void SPreviewObjectViewPort::OnFocusViewportToSelection()
{
	SEditorViewport::OnFocusViewportToSelection();
}

TSharedRef<FEditorViewportClient> SPreviewObjectViewPort::MakeEditorViewportClient()
{
	TSharedRef<FEditorViewportClient> EditorViewportClient = MakeShareable(new FEditorViewportClient(nullptr));
	return EditorViewportClient;
}

TSharedPtr<SWidget> SPreviewObjectViewPort::MakeViewportToolbar()
{
	return SEditorViewport::MakeViewportToolbar();
}

END_SLATE_FUNCTION_BUILD_OPTIMIZATION

We used a class called FEditorViewport. Their relationship is that SEditorViewPort is a widget class used to display FEditorViewport, and FEditorViewport is responsible for managing windows.

We complete the implementation of the editor class so that the preview window can be displayed:

void FPreviewObjectEditorToolkit::RegisterTabSpawners(const TSharedRef<FTabManager>& InTabManager)
{
	FAssetEditorToolkit::RegisterTabSpawners(InTabManager);
	
	InTabManager->RegisterTabSpawner(PreviewObjectEditorPreviewSceneTabName, FOnSpawnTab::CreateLambda([&](const FSpawnTabArgs& SpawnTabArgs)
	{
		return SNew(SDockTab)
		[
			SNew(SPreviewObjectViewPort)
		];
	}));
	InTabManager->RegisterTabSpawner(PreviewObjectEditorDetailTabName, FOnSpawnTab::CreateRaw(this, &FPreviewObjectEditorToolkit::SpawnDetailTab));
}

void FPreviewObjectEditorToolkit::UnregisterTabSpawners(const TSharedRef<FTabManager>& InTabManager)
{
	FAssetEditorToolkit::UnregisterTabSpawners(InTabManager);

	InTabManager->UnregisterTabSpawner(PreviewObjectEditorPreviewSceneTabName);
	InTabManager->UnregisterTabSpawner(PreviewObjectEditorDetailTabName);
}

Restart the editor, create an asset, and you can see our browsing scene. At this time, the browsing scene is consistent with the PIE world:

3. Why PIE World?

UWorld* FEditorViewportClient::GetWorld() const
{
	UWorld* OutWorldPtr = NULL;
	// If we have a valid scene get its world
	if( PreviewScene )
	{
		OutWorldPtr = PreviewScene->GetWorld();
	}
	if ( OutWorldPtr == NULL )
	{
		OutWorldPtr = GWorld;
	}
	return OutWorldPtr;
}

We can see that if PreviewScene is empty, then go OutWorldPtr = GWorld, and the PIE world will be returned at this time.

The second parameter of the FEditorViewportClient constructor is to pass a FPreviewScene, which reminds us that if we want a different preview world, then we need to create a FPreviewScene.

Note that two modules are needed here, “AdvancedPreviewScene” and “InputCore”. A little trick is that if there is a link error, such as mine, if you see a FKey, you can directly google FKey UnrealEngine, and there is in the API document:

In addition to FPreviewScene, there is also its subclass FAdvancedPreviewScene, which is commonly used in UE. FAdnvancedPreviewScene can be used to create objects with sky. Let’s test it and change the class declaration to:

class ADVANCEDALSEDITOR_API SPreviewObjectViewPort : public SEditorViewport
{
private:

	TSharedPtr<FAdvancedPreviewScene> PreviewScene;
	
public:
	SLATE_BEGIN_ARGS(SPreviewObjectViewPort)
	{
	}

	SLATE_END_ARGS()

	/** Constructs this widget with InArgs */
	void Construct(const FArguments& InArgs);

	/** Begin SViewport Interface */

	// 这个函数实现聚焦的时候的行为,也就是按F该干啥,不会有人用手柄开发游戏吧
	virtual void OnFocusViewportToSelection() override;
	// 添加一个ViewportClient类,我们要处理的核心部分之一
	virtual TSharedRef<FEditorViewportClient> MakeEditorViewportClient() override;
	// 创建Toolbar
	virtual TSharedPtr<SWidget> MakeViewportToolbar() override;
	
	/** End SViewport Interface */
};

which is actually implemented as:

BEGIN_SLATE_FUNCTION_BUILD_OPTIMIZATION

void SPreviewObjectViewPort::Construct(const FArguments& InArgs)
{
	SEditorViewport::Construct(SEditorViewport::FArguments());
}

void SPreviewObjectViewPort::OnFocusViewportToSelection()
{
	SEditorViewport::OnFocusViewportToSelection();
}

TSharedRef<FEditorViewportClient> SPreviewObjectViewPort::MakeEditorViewportClient()
{
	FAdvancedPreviewScene::ConstructionValues ConstructionValues;
	ConstructionValues.LightBrightness = 4.0f;
	PreviewScene = MakeShareable(new FAdvancedPreviewScene(ConstructionValues));
	TSharedRef<FEditorViewportClient> EditorViewportClient = MakeShareable(new FEditorViewportClient(nullptr, PreviewScene.Get()));
	return EditorViewportClient;
}

TSharedPtr<SWidget> SPreviewObjectViewPort::MakeViewportToolbar()
{
	return SEditorViewport::MakeViewportToolbar();
}

END_SLATE_FUNCTION_BUILD_OPTIMIZATION

Compile and run:

4. Add an object to the priview world

In fact, if you continue to write, it is best to inherit the following things and implement one yourself: FEditorViewportClient

class ADVANCEDALSEDITOR_API FPreviewObjectEditorViewportClient : public FEditorViewportClient
{
public:
    FPreviewObjectEditorViewportClient(FEditorModeTools* InModeTools, FPreviewScene* InPreviewScene = nullptr, const TWeakPtr<SEditorViewport>& InEditorViewportWidget = nullptr);
};

Now there is only one constructor, we can directly call the parent class constructor, and then go to our SEditorViewport and replace it with it.

To do this, we add a StaticMesh to the PreviewObject:

UCLASS()
class ADVANCEDALS_API UPreviewObject : public UObject
{
	GENERATED_BODY()
public:

	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	float ValueFloat;

	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	int32 ValueInt;

	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	UStaticMesh* StaticMesh;
};

Then, we need to pass this to the Slate widget, adding these in the Slate class:

SLATE_BEGIN_ARGS(SPreviewObjectViewPort)
	{
		
	}

SLATE_ARGUMENT(UPreviewObject*, ManagedPreviewObject)

SLATE_END_ARGS()

TObjectPtr<UPreviewObject> ManagedPreviewObject;

..

//.cpp
void SPreviewObjectViewPort::Construct(const FArguments& InArgs)
{
    ManagedPreviewObject = InArgs._ManagedPreviewObject;
    SEditorViewport::Construct(SEditorViewport::FArguments());
}

Then pass it in at construction:

InTabManager->RegisterTabSpawner(PreviewObjectEditorPreviewSceneTabName, FOnSpawnTab::CreateLambda([&](const FSpawnTabArgs& SpawnTabArgs)
	{
		return SNew(SDockTab)
		[
			SNew(SPreviewObjectViewPort).ManagedPreviewObject(EditingPreviewObject)
		];
	}));

Then, we write something in FEditorViewportClient that can display our Mesh:

class ADVANCEDALSEDITOR_API FPreviewObjectEditorViewportClient : public FEditorViewportClient
{
private:

	TObjectPtr<UPreviewObject> EditingPreviewObject;
	TOptional<AStaticMeshActor*> MeshActor;
	
public:
    FPreviewObjectEditorViewportClient(FEditorModeTools* InModeTools, FPreviewScene* InPreviewScene = nullptr, const TWeakPtr<SEditorViewport>& InEditorViewportWidget = nullptr);

	void SetEditingObject(UPreviewObject* PreviewObject);
	void OnAssetChanged();

	virtual void Tick(float DeltaSeconds) override;
};

TOptional is an interesting syntactic sugar that can be used to test whether a variable has been initialized. This class is implemented as:

FPreviewObjectEditorViewportClient::FPreviewObjectEditorViewportClient(FEditorModeTools* InModeTools,
                                                                       FPreviewScene* InPreviewScene, const TWeakPtr<SEditorViewport>& InEditorViewportWidget) : FEditorViewportClient(InModeTools, InPreviewScene, InEditorViewportWidget)
{
	SetRealtime(true);
}

void FPreviewObjectEditorViewportClient::SetEditingObject(UPreviewObject* PreviewObject)
{
	EditingPreviewObject = PreviewObject;
}

void FPreviewObjectEditorViewportClient::OnAssetChanged()
{
	if (EditingPreviewObject && EditingPreviewObject->StaticMesh)
	{
		if (!MeshActor.IsSet())
		{
			MeshActor = PreviewScene->GetWorld()->SpawnActor<AStaticMeshActor>(FVector::Zero(), FRotator::ZeroRotator);
			MeshActor.GetValue()->GetStaticMeshComponent()->SetStaticMesh(EditingPreviewObject->StaticMesh);
		}
		else
		{
			MeshActor.GetValue()->GetStaticMeshComponent()->SetStaticMesh(EditingPreviewObject->StaticMesh);
		}
		return;
	}
	if (MeshActor.IsSet())
	{
		MeshActor.GetValue()->Destroy();
	}
	MeshActor.Reset();
}

void FPreviewObjectEditorViewportClient::Tick(float DeltaSeconds)
{
	FEditorViewportClient::Tick(DeltaSeconds);
	PreviewScene->GetWorld()->Tick(LEVELTICK_All, DeltaSeconds);
}

at last. Let’s test it out:

When you open it for the first time, there is nothing in it. After selecting StaticMesh, close it and open it again, it will be there. Why?

Because we only call OnAssetsChanged once when it is opened. But we don’t want that, we want OnAssetsChanged to be called as soon as we change the property.

For this, we continue to next phase:

5. Real-time modification

Add the following content in UPreviewObject:

DECLARE_DELEGATE(FOnAssetPropertyChanged);

UCLASS()
class ADVANCEDALS_API UPreviewObject : public UObject
{
	GENERATED_BODY()
public:

	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	float ValueFloat;

	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	int32 ValueInt;

	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	UStaticMesh* StaticMesh;

#if WITH_EDITORONLY_DATA
	FOnAssetPropertyChanged OnAssetPropertyChanged;
#endif

#if WITH_EDITOR
	virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override;
#endif
};

and:

#if WITH_EDITOR
void UPreviewObject::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
{
	if (OnAssetPropertyChanged.IsBound())
	{
		OnAssetPropertyChanged.Execute();
	}
}
#endif

Now, experienced friends must already know it, we only need to let the OnAssetPropertyChanged delegate bind the OnAssetChanged function and there will be no problem. In fact, this is indeed the case. You only need to pay attention to macro isolation here:

void FPreviewObjectEditorViewportClient::SetEditingObject(UPreviewObject* PreviewObject)
{
	EditingPreviewObject = PreviewObject;
	EditingPreviewObject->OnAssetPropertyChanged.BindRaw(this, &FPreviewObjectEditorViewportClient::OnAssetChanged);
}

It works! If you think this floor is annoying, you can call the SetFloorVisibility(false) method of FAdvancedPreviewScene to turn it off.

By JiahaoLi

Hypergryph - Game Programmer 2023 - Now Shandong University - Bachelor 2019-2023

Leave a Reply

Your email address will not be published. Required fields are marked *