😽

[UE5]指定したモデルを超高解像度透過pngで出力する実装

に公開

結論::解像度はPCの性能とご相談を!!

(zenn上だと透過pngにならないかも)

クレジット

以下は今回画像に使用しているモデル関係のクレジットです。
©MyMe.VR様
さばさばショップ様

やりたいこと

指定したアセット、今回の場合はvrmモデルの透過立ち絵pngがほしい!!
しかし通常のスクリーンショットでやると
①拡大すると粗が目立つ
②透過作業がめんどくさい
この2つを自作することで解決します。

前提

UE5.6.1
C++必須
ある程度のPC(後述しますが当然解像度を高くするほどPC性能は必要です)
参考までに手元のパソコンの性能です。
ここまでの高性能でなくても全然動きます。

AMD Ryzen 7 7800X3D 8-Core Processor
RAM 32.0 GB
NVIDIA GeForce RTX 5070 Ti 16GB

①RenderTargetの作成

RenderTarget(描画ターゲット)を2つ作ります。
ClearColorのAは0.0、SizeX,Yは出力したい解像度を指定してください。
いったん1920*1080くらいでいいと思います。
やってみてクラッシュなどが発生したら下げる、いけそうなら上げる。

これを2枚作ってください。中身は同じで問題ありません。

②C++本体(一部省略)

(身内向けに作ったので命名適当です)
.h


#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Engine/TextureRenderTarget2D.h"
#include "Components/SceneCaptureComponent2D.h"
#include "VrmPngRecorder.generated.h"

UCLASS()
class プロジェクト名_API AVrmPngRecorder : public AActor
{
	GENERATED_BODY()

public:
	AVrmPngRecorder();

	/** 録画開始->BPから呼ぶ想定 */
	UFUNCTION(BlueprintCallable, Category = "VRM|Recorder")
	void StartRecord(AActor* TargetActor, float DurationSeconds, const FString& OutputDir, int32 FPS);

	/** 録画停止 */
	UFUNCTION(BlueprintCallable, Category = "VRM|Recorder")
	void StopRecord();

protected:
	virtual void BeginPlay() override;

private:
	void TickCapture();

	/** 出力先フォルダを確実に作る */
	static void EnsureDir(const FString& Dir);

	/** ShowOnlyにターゲットをセット */
	void SetupShowOnly(AActor* TargetActor);

	/** SceneCapture設定 */
	void SetupCaptures();

private:
	UPROPERTY(VisibleAnywhere, Category = "VRM|Recorder")
	USceneComponent* Root = nullptr;

	UPROPERTY(VisibleAnywhere, Category = "VRM|Recorder")
	USceneCaptureComponent2D* CaptureColor = nullptr;

	UPROPERTY(VisibleAnywhere, Category = "VRM|Recorder")
	USceneCaptureComponent2D* CaptureMask = nullptr;

public:
	/** これをRT_Colorに指定 */
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "VRM|Recorder")
	UTextureRenderTarget2D* RT_Color = nullptr;

	/** これをRT_Maskに指定 */
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "VRM|Recorder")
	UTextureRenderTarget2D* RT_Mask = nullptr;

	/** 出力解像度(RTもこのサイズにリサイズする) */
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "VRM|Recorder")
	int32 Width = 1024;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "VRM|Recorder")
	int32 Height = 1024;

	/** true推奨->RTが線形(HDR)の場合に、png用にsRGB変換する */
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "VRM|Recorder")
	bool bGammaCorrectColor = true;

	/** ここを設定すると、このActorのTransformを追従させる(カメラ追従) */
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "VRM|Recorder")
	AActor* FollowCameraActor = nullptr;

private:
	UPROPERTY()
	AActor* OnlyActor = nullptr;

	FTimerHandle TimerHandle;

	bool bRecording = false;
	float EndTime = 0.0f;

	int32 FrameIndex = 0;
	int32 TargetFPS = 30;

	FString OutputDirectory;
};

#include "VrmPngRecorder.h"

#include "TimerManager.h"
#include "Engine/World.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "HAL/PlatformFileManager.h"
#include "Async/Async.h"

#include "IImageWrapper.h"
#include "IImageWrapperModule.h"
#include "Modules/ModuleManager.h"

void AVrmPngRecorder::EnsureDir(const FString& Dir)
{
	IFileManager::Get().MakeDirectory(*Dir, true);
}

AVrmPngRecorder::AVrmPngRecorder()
{
	PrimaryActorTick.bCanEverTick = false;

	Root = CreateDefaultSubobject<USceneComponent>(TEXT("Root"));
	RootComponent = Root;

	CaptureColor = CreateDefaultSubobject<USceneCaptureComponent2D>(TEXT("CaptureColor"));
	CaptureColor->SetupAttachment(RootComponent);

	CaptureMask = CreateDefaultSubobject<USceneCaptureComponent2D>(TEXT("CaptureMask"));
	CaptureMask->SetupAttachment(RootComponent);

	// Timerで手動キャプチャ
	CaptureColor->bCaptureEveryFrame = false;
	CaptureColor->bCaptureOnMovement = false;
	CaptureMask->bCaptureEveryFrame = false;
	CaptureMask->bCaptureOnMovement = false;

	// ShowOnlyでターゲットだけ描く
	CaptureColor->PrimitiveRenderMode = ESceneCapturePrimitiveRenderMode::PRM_UseShowOnlyList;
	CaptureMask->PrimitiveRenderMode = ESceneCapturePrimitiveRenderMode::PRM_UseShowOnlyList;

	// TAA等の状態保持->境界のギザギザ軽減に効く
	CaptureColor->bAlwaysPersistRenderingState = true;
	CaptureMask->bAlwaysPersistRenderingState = true;
}

void AVrmPngRecorder::BeginPlay()
{
	Super::BeginPlay();
}

void AVrmPngRecorder::SetupShowOnly(AActor* TargetActor)
{
	CaptureColor->ShowOnlyActors.Empty();
	CaptureMask->ShowOnlyActors.Empty();

	// Actor単位でOK(子コンポーネントも基本入る)
	CaptureColor->ShowOnlyActors.Add(TargetActor);
	CaptureMask->ShowOnlyActors.Add(TargetActor);
}

void AVrmPngRecorder::SetupCaptures()
{
	// RT割当
	CaptureColor->TextureTarget = RT_Color;
	CaptureMask->TextureTarget = RT_Mask;

	// エディタ表示に近い最終見た目を狙う
	CaptureColor->CaptureSource = ESceneCaptureSource::SCS_FinalColorLDR;

	// AにInvOpacityが入る
	// → 後で A = 1 - InvOpacity にして不透明度に戻す
	CaptureMask->CaptureSource = ESceneCaptureSource::SCS_SceneColorHDR;

	// 背景透過用:RTは(0,0,0,0)推奨
	if (RT_Color)
	{
		RT_Color->ClearColor = FLinearColor(0, 0, 0, 0);
	}
	if (RT_Mask)
	{
		RT_Mask->ClearColor = FLinearColor(0, 0, 0, 0);
	}
}

void AVrmPngRecorder::StartRecord(AActor* TargetActor, float DurationSeconds, const FString& OutputDir, int32 FPS)
{
	if (!TargetActor || !RT_Color || !RT_Mask)
	{
		UE_LOG(LogTemp, Warning, TEXT("StartRecord failed: TargetActor or RTs are null."));
		return;
	}

	OnlyActor = TargetActor;
	TargetFPS = FMath::Max(1, FPS);

	// 出力先:相対ならSaved/配下に落とす
	OutputDirectory = OutputDir;
	if (OutputDirectory.IsEmpty())
	{
		OutputDirectory = FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("VrmFrames"));
	}
	else if (FPaths::IsRelative(OutputDirectory))
	{
		OutputDirectory = FPaths::Combine(FPaths::ProjectSavedDir(), OutputDirectory);
	}
	EnsureDir(OutputDirectory);

	// RTサイズを揃える
	RT_Color->ResizeTarget(Width, Height);
	RT_Mask->ResizeTarget(Width, Height);

	SetupCaptures();
	SetupShowOnly(TargetActor);

	FrameIndex = 0;
	EndTime = GetWorld()->GetTimeSeconds() + FMath::Max(0.01f, DurationSeconds);

	bRecording = true;

	const float Interval = 1.0f / (float)TargetFPS;
	GetWorld()->GetTimerManager().SetTimer(TimerHandle, this, &AVrmPngRecorder::TickCapture, Interval, true, 0.0f);
}

void AVrmPngRecorder::StopRecord()
{
	if (!bRecording) return;

	bRecording = false;
	GetWorld()->GetTimerManager().ClearTimer(TimerHandle);
}

void AVrmPngRecorder::TickCapture()
{
	if (!bRecording) return;

	const float Now = GetWorld()->GetTimeSeconds();
	if (Now >= EndTime)
	{
		StopRecord();
		return;
	}

	// 撮影用カメラアクタに追従
	if (FollowCameraActor)
	{
		SetActorTransform(FollowCameraActor->GetActorTransform());
	}

	// キャプチャ
	CaptureColor->CaptureScene();
	CaptureMask->CaptureScene();

	// Readback
	FTextureRenderTargetResource* ResColor = RT_Color->GameThread_GetRenderTargetResource();
	FTextureRenderTargetResource* ResMask = RT_Mask->GameThread_GetRenderTargetResource();
	if (!ResColor || !ResMask) return;

	TArray<FColor> ColorPixels;
	TArray<FColor> MaskPixels;
	ColorPixels.SetNumUninitialized(Width * Height);
	MaskPixels.SetNumUninitialized(Width * Height);

	// 超重要:RT_Colorが線形(HDR/16f)なら、png用にsRGBへ
	FReadSurfaceDataFlags ColorFlags(RCM_UNorm);
	ColorFlags.SetLinearToGamma(bGammaCorrectColor);

	// Maskはガンマ不要(Alphaだけ使う)
	FReadSurfaceDataFlags MaskFlags(RCM_UNorm);
	MaskFlags.SetLinearToGamma(false);

	ResColor->ReadPixels(ColorPixels, ColorFlags);
	ResMask->ReadPixels(MaskPixels, MaskFlags);

	// 合成:RGB=Color、A=1-InvOpacity
	TArray<uint8> BGRA;
	BGRA.SetNumUninitialized(Width * Height * 4);

	for (int32 i = 0; i < Width * Height; ++i)
	{
		const FColor& C = ColorPixels[i];
		const FColor& M = MaskPixels[i];

		const uint8 Opacity = 255 - M.A; // InvOpacity->Opacity

		const int32 o = i * 4;
		BGRA[o + 0] = C.B;
		BGRA[o + 1] = C.G;
		BGRA[o + 2] = C.R;
		BGRA[o + 3] = Opacity;
	}

	const FString FileName = FString::Printf(TEXT("frame_%06d.png"), FrameIndex++);
	const FString FullPath = FPaths::Combine(OutputDirectory, FileName);

	// png圧縮は別スレッドへ
	TArray<uint8> BGRA_Copy = MoveTemp(BGRA);

	AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask,
		[BGRA_Copy = MoveTemp(BGRA_Copy), FullPath, W = Width, H = Height]()
		{
			IImageWrapperModule& ImageWrapperModule =
				FModuleManager::LoadModuleChecked<IImageWrapperModule>(FName("ImageWrapper"));
			TSharedPtr<IImageWrapper> Png = ImageWrapperModule.CreateImageWrapper(EImageFormat::PNG);

			if (Png.IsValid() && Png->SetRaw(BGRA_Copy.GetData(), BGRA_Copy.Num(), W, H, ERGBFormat::BGRA, 8))
			{
				const TArray64<uint8>& Compressed = Png->GetCompressed(3);
				FFileHelper::SaveArrayToFile(Compressed, *FullPath);
			}
		});
}

Levelに配置して撮影する

今作ったC++クラスをそのままLevelに配置。
RecorderのRTを前作ったやつに設定。
WidgetやHeightは先ほどのRTと同じ値にするのを推奨。
異なる場合ほぼこちらを優先します。が乱れる原因なので同じにした方がいいと思います。
GammaはTrue推奨。
FollowCameraActorは撮影用のCameraActor(適当でいいです)を指定。

適当に入力用のPlayerを作成してさっきのクラスをGet->関数を呼んでください。

TargetはActorであればなんでも入ります。
これが透過pngになります。
Dirは保存先です。画像の書き方だとSavedの配下に作られます。
DurationSecondsは秒数。FPSは1秒当たりのフレーム数です。
ここで指定した秒数指定したFPSで連続で撮影します。
つまりコマ送りになります。
1で1なら1枚だけです。3で30なら90枚です(90枚だと重すぎる)

解説

①SceneCaptureを2つ用意する

こいつが必要です。
bCaptureEveryFrame = false;
これがないと勝手に撮っちゃうので絶対に必要です。

②StartRecordで撮影設定

まずOutputDirが相対パスならSaved/配下に入れる作りにしてます。
安全処理です。
RTのサイズをWidth/Heightに強制的に揃えます。

RT_Color->ResizeTarget(Width, Height);
RT_Mask ->ResizeTarget(Width, Height);

RTアセットの元サイズよりWidth/Heightが優先です。
取得に変えてもいいと思います。

③見た目と透明度を別々のCaptureSourceで撮る

ptureColor->CaptureSource = ESceneCaptureSource::SCS_FinalColorLDR;
CaptureMask ->CaptureSource = ESceneCaptureSource::SCS_SceneColorHDR; // A=InvOpacity

ここが透過pngの要です。
Color(FinalColorLDR)は最終的に画面に出る色に近い状態です。
Mask(SceneColorHDR)はAlphaチャンネルにInvOpacityを持つものです。
透明なら大きく不透明なら小さいです。

④ShowOnlyで指定したActorだけを描く

CaptureColor->PrimitiveRenderMode = PRM_UseShowOnlyList;
CaptureMask ->PrimitiveRenderMode = PRM_UseShowOnlyList;

CaptureColor->ShowOnlyActors.Add(TargetActor);
CaptureMask ->ShowOnlyActors.Add(TargetActor);

背景などを移さないためにShowOnlyリストを使います(神機能)
指定したものだけ描画する神機能です。この機能を作った人は偉大すぎます。
ありがとうございます。

⑤TimerでXfps一定間隔でTickCaptureする部分

const float Interval = 1.0f / (float)TargetFPS;
GetWorld()->GetTimerManager().SetTimer(TimerHandle, this, &AVrmPngRecorder::TickCapture, Interval, true);

⑥2枚(RGBとA)を撮る

CaptureColor->CaptureScene();
CaptureMask ->CaptureScene();

ResColor->ReadPixels(ColorPixels, Flags);
ResMask ->ReadPixels(MaskPixels,  Flags);

⑦透過の合成

const uint8 A = 255 - M.A;

M.AはInvOpacity(前述の通り)なので透過を復元して詰めなおします。

BGRA[o+0] = C.B;
BGRA[o+1] = C.G;
BGRA[o+2] = C.R;
BGRA[o+3] = A;

⑧pngとして圧縮保存

Png->SetRaw(BGRA_Copy.GetData(), BGRA_Copy.Num(), W, H, ERGBFormat::BGRA, 8);
const TArray64<uint8>& Compressed = Png->GetCompressed(3);
FFileHelper::SaveArrayToFile(Compressed, *FullPath);

おしまい

以上が超解像度スクショのやり方です。
ただ前述の通り、どこまで高解像度で連続出力できるかはお手元のPC次第です。

Discussion