削り表現とは
画像が削れて下の画像が見えたり、部分的に別の映像が透過して見えたりなど
様々な切り抜き的表現のこと。
他の環境でも同様な考え方で実装利用はできるものでになっています。

具体的には、RenderTarget(テクスチャ)に入力で削り/透過を意味する個所にMask(色)を書き込み、
その書き込んだRTと削れる範囲のMaskTextureをブレンドして削り状態を作るものです。
フロー
今回の流れは大きく分けて以下の3段階で実装します。
- 検証結果を表示するWidgetを用意
- 入力で削りマスクを生成
- 削り状態を元に表示を加工
実践/実装
環境構築
ver 5.7
C++(20)
VisualStudio 2022/2026
UMG Widget ベース
表示するWigetを用意
今回の検証ではUI(widget)上で操作、表示を行います。 そのために以下内容でのWidgetを初期定義してします。
以下は内部で自動生成して、設定するためします。
RootCanvas:WidgetTreeのRootCanvas
OutputImage:Canvasに乗せる画像
class PEELOFFTEST_API UScratchRevealWidget : public UUserWidget { GENERATED_BODY() public: UScratchRevealWidget(const FObjectInitializer& ObjectInitializer); protected: virtual bool Initialize() override; virtual void NativeConstruct() override; private: void BuildWidgetTree(); void ApplyOutputImage(); public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Scratch|Visuals") TObjectPtr<UTexture> CoverTexture; private: UPROPERTY(Transient) TObjectPtr<UCanvasPanel> RootCanvas; UPROPERTY(Transient) TObjectPtr<UImage> OutputImage; }
Initializeより、BuildWidgetTree利用にて今回の表示検証で利用するWidget要素の
RootCanvasとOutputImageの生成、初期化をします。
NativeConstructより、ApplyOutputImage利用にて画像を真っ白なものに設定します。
ApplyOutputImageの利用や中身は次のステップ以降で変更していきます。
#include "UI/ScratchRevealWidget.h" #include "Blueprint/WidgetTree.h" #include "Components/CanvasPanel.h" #include "Components/CanvasPanelSlot.h" #include "Components/Image.h" #include "Engine/Texture.h" #include "Styling/SlateBrush.h" UScratchRevealWidget::UScratchRevealWidget(const FObjectInitializer& ObjectInitializer) : Super(ObjectInitializer) { SetVisibility(ESlateVisibility::Visible); SetIsEnabled(true); SetIsFocusable(false); } bool UScratchRevealWidget::Initialize() { const bool bWidgetInitialized = Super::Initialize(); if (bWidgetInitialized) { BuildWidgetTree(); } return bWidgetInitialized; } void UScratchRevealWidget::NativeConstruct() { Super::NativeConstruct(); ApplyOutputImage(); } void UScratchRevealWidget::BuildWidgetTree() { if (!WidgetTree) { return; } if (!RootCanvas) { RootCanvas = WidgetTree->ConstructWidget<UCanvasPanel>(UCanvasPanel::StaticClass(), TEXT("TextureDisplayRoot")); } if (WidgetTree->RootWidget != RootCanvas) { WidgetTree->RootWidget = RootCanvas; } if (!OutputImage) { OutputImage = WidgetTree->ConstructWidget<UImage>(UImage::StaticClass(), TEXT("TextureDisplayImage")); OutputImage->SetVisibility(ESlateVisibility::HitTestInvisible); } const bool bNeedsCanvasRebuild = RootCanvas->GetChildrenCount() != 1 || RootCanvas->GetChildAt(0) != OutputImage; if (bNeedsCanvasRebuild) { RootCanvas->ClearChildren(); if (UCanvasPanelSlot* CanvasSlot = RootCanvas->AddChildToCanvas(OutputImage)) { CanvasSlot->SetAnchors(FAnchors(0.0f, 0.0f, 1.0f, 1.0f)); CanvasSlot->SetOffsets(FMargin(0.0f)); } } } void UScratchRevealWidget::ApplyOutputImage() { if (!OutputImage || !CoverTexture) { return; } FSlateBrush Brush = OutputImage->GetBrush(); Brush.ImageSize = FVector2D(128, 128); Brush.SetResourceObject(CoverTexture); OutputImage->SetBrush(Brush); OutputImage->SetColorAndOpacity(FLinearColor::White); }
WidgetをGamModeにて生成します。
そのために以下GameMode内に定義をします。
UCLASS() class APeelOffTestGameMode : public AGameModeBase { GENERATED_BODY() public: APeelOffTestGameMode(); protected: virtual void BeginPlay() override; public: UPROPERTY(EditAnywhere, Category = "UI|Scratch Reveal") TSubclassOf<UScratchRevealWidget> ScratchRevealWidgetClass; UPROPERTY(EditAnywhere, Category = "UI|Scratch Reveal", meta = (ClampMin = "0")) int32 ScratchRevealWidgetZOrder = 10; protected: UPROPERTY() TObjectPtr<UScratchRevealWidget> ScratchRevealWidget; };
BeginPlayにて開始時に、Widgetが生成されて表示されるように設定します。
APeelOffTestGameMode::APeelOffTestGameMode() { DefaultPawnClass = APeelOffTestCharacter::StaticClass(); PlayerControllerClass = APeelOffTestPlayerController::StaticClass(); } void APeelOffTestGameMode::BeginPlay() { Super::BeginPlay(); APlayerController* PlayerController = GetWorld() ? GetWorld()->GetFirstPlayerController() : nullptr; if (!PlayerController || !PlayerController->IsLocalPlayerController()) { return; } TSubclassOf<UScratchRevealWidget> WidgetClassToSpawn = ScratchRevealWidgetClass; if (!WidgetClassToSpawn) { WidgetClassToSpawn = UScratchRevealWidget::StaticClass(); } ScratchRevealWidget = CreateWidget<UScratchRevealWidget>(PlayerController, WidgetClassToSpawn); if (!ScratchRevealWidget) { return; } ScratchRevealWidget->AddToPlayerScreen(ScratchRevealWidgetZOrder); ScratchRevealWidget->SetVisibility(ESlateVisibility::Visible); }
作成したWidgetを継承したBPを作成し、
そのBPのDetailの「CoverTexture」に任意の好きな画像を設定します。

作成したWidgetBPをGameModeのDetailの「ScratchRevealWidgetClass」に設定します。

この設定後、実行をして確認をしてみると画面に設定したTextureで表示がされると思います。

入力で削り箇所のマスクを生成
Widgetクラスに以下関数と変数を追加します。
BrushSize: マスクを書くブラシのサイズ
BrushStrength: 書くマスクの濃さ
BrushMaskTexture: ブラシの形状テクスチャ
BrushTextureSize : ブラシの形状テクスチャサイズ(解像度)
AccumulatedMaskTexture : マスクを書きこむテクスチャ (RenderTarget)
public: UFUNCTION(BlueprintCallable, Category = "Scratch") void ResetScratch(); protected: virtual void SynchronizeProperties() override; virtual FReply NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) override; virtual FReply NativeOnMouseButtonUp(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) override; virtual FReply NativeOnMouseMove(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) override; private: void InitializeScratchResources(); bool ScratchAtPointer(const FGeometry& Geometry, const FPointerEvent& PointerEvent); void ApplyScratch(const FVector2D& ScratchPosition, const FVector2D& WidgetSize); void ApplyScratchMask(UCanvas* Canvas, const FVector2D& TargetPosition, const FVector2D& BrushRadiusOnTarget); public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Scratch", meta = (ClampMin = "1.0", UIMin = "1.0")) float BrushSize = 96.0f; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Scratch", meta = (ClampMin = "0.01", ClampMax = "1.0", UIMin = "0.01", UIMax = "1.0")) float BrushStrength = 0.12f; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Scratch") TObjectPtr<UTexture> BrushMaskTexture; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Scratch", meta = (ClampMin = "1.0", UIMin = "1.0")) float BrushTextureSize = 1024.f; private: UPROPERTY(Transient) TObjectPtr<UTextureRenderTarget2D> AccumulatedMaskTexture;
マスクを書きこみ、WidgetのImageに反映するため以下関数を用意します。
Begin/EndDrawCanvasToRenderTargetを利用して、
Canvasに書き込んだ内容をRenderTargetに書き込みます。
※今回の書きこむ値(BrushColor)はMask的0~1判断で問題ないためRGBAのうち「R」要素だけを利用します。
ScratchAtPointerはWidget上での入力を画面上での入力位置に変換するもので 引数である「FGeometry」「FPointerEvent」はそれぞれ、Widget上の表示情報と入力情報です。
void UMaskTextureDisplayWidget::InitializeScratchResources() { AccumulatedMaskTexture = UKismetRenderingLibrary::CreateRenderTarget2D(this, BrushTextureSize, BrushTextureSize, RTF_RGBA8, FLinearColor::Black, false, false); } void UMaskTextureDisplayWidget::ResetScratch() { if (!AccumulatedMaskTexture) { InitializeScratchResources(); } else { UKismetRenderingLibrary::ClearRenderTarget2D(this, AccumulatedMaskTexture, FLinearColor::Black); } ApplyOutputImage(); } void UMaskTextureDisplayWidget::ApplyOutputImage() { if (!OutputImage || !AccumulatedMaskTexture) { return; } FSlateBrush Brush = OutputImage->GetBrush(); Brush.SetResourceObject(AccumulatedMaskTexture); OutputImage->SetBrush(Brush); OutputImage->SetColorAndOpacity(FLinearColor::White); } void UMaskTextureDisplayWidget::ApplyScratchMask(UCanvas* Canvas, const FVector2D& TargetPosition, const FVector2D& BrushRadiusOnTarget) { if (!Canvas || !Canvas->Canvas) { return; } const float ClampedBrushStrength = FMath::Clamp(BrushStrength, 0.01f, 1.0f); const FLinearColor BrushColor(ClampedBrushStrength, 0.0f, 0.0f); UTexture* BrushTexture = BrushMaskTexture ? BrushMaskTexture.Get() : Canvas->DefaultTexture; Canvas->K2_DrawTexture( BrushTexture, TargetPosition - BrushRadiusOnTarget, BrushRadiusOnTarget * 2.0f, FVector2D::ZeroVector, FVector2D::UnitVector, BrushColor, BLEND_Additive, 0.0f, FVector2D(0.5f, 0.5f)); } void UMaskTextureDisplayWidget::ApplyScratch(const FVector2D& ScratchPosition, const FVector2D& WidgetSize) { if (!AccumulatedMaskTexture) { return; } const FVector2D ClampedLocalPosition( FMath::Clamp(ScratchPosition.X, 0.0f, WidgetSize.X), FMath::Clamp(ScratchPosition.Y, 0.0f, WidgetSize.Y)); const FVector2D NormalizedPosition( WidgetSize.X > KINDA_SMALL_NUMBER ? ClampedLocalPosition.X / WidgetSize.X : 0.0f, WidgetSize.Y > KINDA_SMALL_NUMBER ? ClampedLocalPosition.Y / WidgetSize.Y : 0.0f); UCanvas* Canvas = nullptr; FVector2D CanvasSize = FVector2D::ZeroVector; FDrawToRenderTargetContext Context; UKismetRenderingLibrary::BeginDrawCanvasToRenderTarget(this, AccumulatedMaskTexture, Canvas, CanvasSize, Context); if (Canvas) { const float BrushSizePixels = FMath::Max(1.0f, BrushSize); const FVector2D BrushRadiusOnTarget( 0.5f * BrushSizePixels * (CanvasSize.X / FMath::Max(1.0f, WidgetSize.X)), 0.5f * BrushSizePixels * (CanvasSize.Y / FMath::Max(1.0f, WidgetSize.Y))); const FVector2D TargetPosition(NormalizedPosition.X * CanvasSize.X, NormalizedPosition.Y * CanvasSize.Y); ApplyScratchMask(Canvas, TargetPosition, BrushRadiusOnTarget); } UKismetRenderingLibrary::EndDrawCanvasToRenderTarget(this, Context); ApplyOutputImage(); } bool UMaskTextureDisplayWidget::ScratchAtPointer(const FGeometry& Geometry, const FPointerEvent& PointerEvent) { if (!AccumulatedMaskTexture) { return false; } const FVector2D WidgetSize = Geometry.GetLocalSize(); if (WidgetSize.X <= KINDA_SMALL_NUMBER || WidgetSize.Y <= KINDA_SMALL_NUMBER) { return false; } const FVector2D ScratchPosition = Geometry.AbsoluteToLocal(PointerEvent.GetScreenSpacePosition()); ApplyScratch(ScratchPosition, WidgetSize); return true; }
Widgetのイベント関数に以前の関数との入れ替えと今回作成した関数を入れます。
SynchronizeProperties:Widgetパラメータでパラメータが更新された時(今回だとBrushの更新とか)
NativeOnMouseButtonDown:いずれかのマウスボタン押下された時
NativeOnMouseButtonUp:いずれかのマウスボタンを離した時
NativeOnMouseButtonMove:マウスの移動がされた時
void UMaskTextureDisplayWidget::NativeConstruct() { Super::NativeConstruct(); ResetScratch(); } void UMaskTextureDisplayWidget::SynchronizeProperties() { Super::SynchronizeProperties(); ApplyOutputImage(); } FReply UMaskTextureDisplayWidget::NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) { if (InMouseEvent.GetEffectingButton() == EKeys::LeftMouseButton && ScratchAtPointer(InGeometry, InMouseEvent)) { return FReply::Handled().CaptureMouse(TakeWidget()); } return Super::NativeOnMouseButtonDown(InGeometry, InMouseEvent); } FReply UMaskTextureDisplayWidget::NativeOnMouseButtonUp(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) { if (HasMouseCapture() && InMouseEvent.GetEffectingButton() == EKeys::LeftMouseButton) { ScratchAtPointer(InGeometry, InMouseEvent); return FReply::Handled().ReleaseMouseCapture(); } return Super::NativeOnMouseButtonUp(InGeometry, InMouseEvent); } FReply UMaskTextureDisplayWidget::NativeOnMouseMove(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent) { if (HasMouseCapture() && InMouseEvent.IsMouseButtonDown(EKeys::LeftMouseButton) && ScratchAtPointer(InGeometry, InMouseEvent)) { return FReply::Handled(); } return Super::NativeOnMouseMove(InGeometry, InMouseEvent); }
GameModeのWidget表示設定部分にて、PlayerControllerにUI側に入力を移行する処理も追加しておきます。
void APeelOffTestGameMode::BeginPlay() { /// ...既存実装そのまま PlayerController->bShowMouseCursor = true; PlayerController->bEnableClickEvents = true; PlayerController->bEnableTouchEvents = true; FInputModeGameAndUI InputMode; InputMode.SetHideCursorDuringCapture(false); InputMode.SetLockMouseToViewportBehavior(EMouseLockMode::DoNotLock); PlayerController->SetInputMode(InputMode); }
BPWidgetに以下パラメータがついかされているので、
defaultが気に入らなければパラメータは変更して、Textureだけ最低限好きなものを設定してください。

設定、実装にもんだいがなければ
最初画面が真っ暗で表示されて、その中でマウス操作で左クリックを
しながら動かすと、指定したTextureで赤く書き込みができることが確認できます。

マスクを利用した表現
このステップではマテリアルノードの利用がメインの内容です。
そのため、コード側はマテリアルへパラメータを渡す設定とマテリアルでの表示切替のみになります。
private: void UpdateCompositeOutput(); public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Scratch|Visuals") TObjectPtr<UTexture> OutsideTexture; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Scratch|Visuals") TObjectPtr<UTexture> RangeMaskTexture; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Scratch|Visuals") FLinearColor CoverTint = FLinearColor(0.55f, 0.55f, 0.55f, 1.0f); UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Scratch|Materials") TObjectPtr<UMaterialInterface> CompositeMaterial; private: UPROPERTY(Transient) TObjectPtr<UMaterialInstanceDynamic> CompositeMaterialInstance;
ScratchRevealWidgetPrivateはWidget側とマテリアル側をつなぐ際の共通のパラメータ名定義です。
namespace ScratchRevealWidgetPrivate { const FName CoverTextureParam(TEXT("CoverTexture")); const FName OutsideTextureParam(TEXT("OutsideTexture")); const FName AccumulatedMaskParam(TEXT("AccumulatedMask")); const FName RangeMaskParam(TEXT("RangeMask")); const FName CoverTintParam(TEXT("CoverTint")); } void UScratchRevealWidget::SynchronizeProperties() { Super::SynchronizeProperties(); UpdateCompositeOutput(); } void UScratchRevealWidget::ResetScratch() { if (!AccumulatedMaskTexture || !CompositeMaterialInstance) { InitializeScratchResources(); } else { UKismetRenderingLibrary::ClearRenderTarget2D(this, AccumulatedMaskTexture, FLinearColor::Transparent); } UpdateCompositeOutput(); } void UScratchRevealWidget::InitializeScratchResources() { if (!AccumulatedMaskTexture) { AccumulatedMaskTexture = UKismetRenderingLibrary::CreateRenderTarget2D(this, BrushTextureSize, BrushTextureSize, RTF_RGBA8, FLinearColor::Transparent, false, false); } if (!CompositeMaterialInstance && CompositeMaterial) { CompositeMaterialInstance = UMaterialInstanceDynamic::Create(CompositeMaterial, this); } } void UScratchRevealWidget::ApplyOutputImage() { if (!OutputImage || !CompositeMaterialInstance) { return; } OutputImage->SetBrushFromMaterial(CompositeMaterialInstance); FSlateBrush Brush = OutputImage->GetBrush(); OutputImage->SetBrush(Brush); OutputImage->SetColorAndOpacity(FLinearColor::White); } void UScratchRevealWidget::UpdateCompositeOutput() { if (!AccumulatedMaskTexture || !CompositeMaterialInstance) { return; } CompositeMaterialInstance->SetTextureParameterValue(ScratchRevealWidgetPrivate::CoverTextureParam, CoverTexture); CompositeMaterialInstance->SetTextureParameterValue(ScratchRevealWidgetPrivate::OutsideTextureParam, OutsideTexture); CompositeMaterialInstance->SetTextureParameterValue(ScratchRevealWidgetPrivate::AccumulatedMaskParam, AccumulatedMaskTexture); CompositeMaterialInstance->SetTextureParameterValue(ScratchRevealWidgetPrivate::RangeMaskParam, RangeMaskTexture); CompositeMaterialInstance->SetVectorParameterValue(ScratchRevealWidgetPrivate::CoverTintParam, CoverTint); ApplyOutputImage(); } void UScratchRevealWidget::ApplyScratch(const FVector2D& ScratchPosition, const FVector2D& WidgetSize) { /// ...既存実装そのまま UpdateCompositeOutput(); }
マテリアルを新しく用意します。利用先はWidgetなので「UserInterface」でBlendModeは「TranslucentGreyTransmittance」にします。
※TranslucentGreyTransmittanceは内部では「BLEND_Translucent」で従来で利用されてきたTranslucentです。

マテリアルノードは以下です。
やっていることとしては、RangeMaskから内外マスクを作り、そこに任意のテクスチャの適用と
削った個所だった場合透明するようにしています。




※Substrate使っていますが、以下従来のつなぎ方でも問題はないです。

blueprintue.comblueprintue.com
BPWidgetに以下の様に、任意Textureの設定と作成したマテリアルを適用します。

正しく設定、実装できていれば以下のような挙動になります。

おまけ
削れる、塗りつぶせる範囲が定まっているということであれば、 これらを比較することで現状どのくらい塗りつぶせているのかも判断できるということで
現在の削れ割合をチェックと通知をする仕組みを加えようと思います。
削れている割合を求める
比較用のテクスチャを渡すことでして
テクスチャの比較/チェックするロジッククラスを用意します。
設定定義のFScratchRevealCheckConfig構造体と結果のFScratchRevealCheckResult構造体を定義しておきます。
個人的にTextureの比較になるため、どのチャンネル(RGBA要素)で比較するのかの設定も欲しく感じたので、EScratchRevealProgressCheckChannel も用意していますが、他を利用するわけではないのでロジック側にハードコーディングで固定定義でもいいです。
今回はRに削り箇所として書き込みしたのでR比較にはなります。
比較方法は対象となる二つのTextureを同じ解像度(セル数)にして、同じ位置のセルの値を比較していく方法になります。
UV値は0~1なので、分割数が固定であればテクスチャサイズがどうあれ、決まったUV値でピクセル抽出になるため、抽出場所のUVをキャッシュします。 そして、今回のRangeMaskTextureは動的に変化しないので、初期化時にそのピクセルを抽出してCachedRangeMaskPixelsにキャッシュし、以降比較で利用します。

FScratchRevealCheckConfig要素内容は以下です。
RangeMaskTexture:比較する削れる範囲MaskTexture
GridResolution:判定を行う上での解像度(判定格子数)の行/列数
CompletionThreshold:達成目標割合(0~1)
RevealedPixelThreshold: 1マスあたりの削り終えたと判断するマスクの濃さ(0~1)
ProgressCheckChannel:比較チャンネル
FScratchRevealCheckResult要素内容は以下です。
RevealProgress: 進捗割合(0~1)
bCompleted: 達成目標割合の到達の有無
bValid:処理の正常成功の有無
class UTexture; class UTextureRenderTarget2D; UENUM(BlueprintType) enum class EScratchRevealProgressCheckChannel : uint8 { Red UMETA(DisplayName = "R"), Green UMETA(DisplayName = "G"), Blue UMETA(DisplayName = "B"), Alpha UMETA(DisplayName = "A"), RGB UMETA(DisplayName = "RGB"), RGBA UMETA(DisplayName = "RGBA") }; USTRUCT(BlueprintType) struct FScratchRevealCheckConfig { GENERATED_BODY() UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Scratch|Parameters") TObjectPtr<UTexture2D> RangeMaskTexture; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Scratch|Parameters", meta = (ClampMin = "8", UIMin = "8")) int32 GridResolution = 64; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Scratch|Parameters", meta = (ClampMin = "0.0", ClampMax = "1.0")) float CompletionThreshold = 0.65f; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Scratch|Parameters", meta = (ClampMin = "0.0", ClampMax = "1.0")) float RevealedPixelThreshold = 0.9f; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Scratch|Parameters") EScratchRevealProgressCheckChannel ProgressCheckChannel = EScratchRevealProgressCheckChannel::Red; }; USTRUCT(BlueprintType) struct FScratchRevealCheckResult { GENERATED_BODY() UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Scratch") float RevealProgress = 0.0f; UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Scratch") bool bCompleted = false; UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Scratch") bool bValid = false; }; UCLASS(BlueprintType) class PEELOFFTEST_API UScratchRevealProgressChecker : public UObject { GENERATED_BODY() public: void Initialize(const FScratchRevealCheckConfig& InConfig); FScratchRevealCheckResult CheckCoverage(UTextureRenderTarget2D* InAccumulatedMaskTexture) const; private: void RebuildRangeMaskPixels(); void RebuildMaskUVs(); bool ReadRangeMaskPixels(UTexture* InTexture, TArray<FColor>& OutPixels, int32& OutWidth, int32& OutHeight) const; bool TryReadUncompressedRangeMaskPixels(UTexture2D* InTexture, TArray<FColor>& OutPixels, int32 Width, int32 Height) const; static bool IsCompressedPixelFormat(EPixelFormat PixelFormat); static bool ReadBGRA8Pixels(const void* RawPixelData, int32 PixelCount, TArray<FColor>& OutPixels); static bool ReadRGBA8Pixels(const void* RawPixelData, int32 PixelCount, TArray<FColor>& OutPixels); static bool ReadFloatRGBAPixels(const void* RawPixelData, int32 PixelCount, TArray<FColor>& OutPixels); bool ReadTexturePixels(UTexture* InTexture, int32 Width, int32 Height, TArray<FColor>& OutPixels) const; bool DrawTextureToRenderTarget(UTexture* InTexture, int32 Width, int32 Height, UTextureRenderTarget2D*& OutRenderTarget) const; static bool ReadRenderTargetPixels(UTextureRenderTarget2D* InRenderTarget, TArray<FColor>& OutPixels); FScratchRevealCheckResult BuildCoverageResult(const TArray<FColor>& AccumulatedMaskPixels, int32 AccumulatedWidth, int32 AccumulatedHeight) const; static FVector2D GetSampleUV(int32 X, int32 Y, int32 GridResolution); static float GetRangeMaskValue(const FColor& Pixel, EScratchRevealProgressCheckChannel InProgressCheckChannel); static float GetAccumulatedMaskValue(const FColor& Pixel, EScratchRevealProgressCheckChannel InProgressCheckChannel); static FColor SamplePixel(const TArray<FColor>& Pixels, int32 Width, int32 Height, const FVector2D& UV); static int32 GetPixelIndex(int32 X, int32 Y, int32 Width); private: FScratchRevealCheckConfig Config; TArray<FColor> CachedRangeMaskPixels; TArray<FVector2D> CachedMaskUVs; };
テクスチャのフォーマットの種類には大きく分けて、圧縮と非圧縮の2種類があります。
テクスチャフォーマットの「BC〇〇/DXT〇〇」などのものはGPUに効率的に渡すための圧縮形式です。
これは基本的にGPU側に渡ったときデコードされて利用されるもので、
今回のようなCPU側で扱うことを想定したデコードAPIや構造はUEにデフォルトではないです。
※デバッグ的Editor内利用用機能とかはありますが、通常のゲームロジックで利用を考慮するような場合には最初っからCPU向けのフォーマットにする感じです。
TryReadUncompressedRangeMaskPixelsにて、圧縮で色抽出ができない/抽出が面倒なテクスチャ
非圧縮で楽に抽出できるものに分岐させています。
抽出が難しいものはRT書き込んでそのRTから色抽出をするという方法をとっています。
※今回は自分がどんなテクスチャにも対応できるようにしたくてやった実装ではあるので、
一般的には不要な実装に思います。
void UScratchRevealProgressChecker::Initialize(const FScratchRevealCheckConfig& InConfig) { Config = InConfig; RebuildMaskUVs(); RebuildRangeMaskPixels(); } void UScratchRevealProgressChecker::RebuildRangeMaskPixels() { CachedRangeMaskPixels.Reset(); if (!Config.RangeMaskTexture) { return; } TArray<FColor> RangeMaskPixels; int32 RangeMaskWidth = 0; int32 RangeMaskHeight = 0; if (!ReadRangeMaskPixels(Config.RangeMaskTexture, RangeMaskPixels, RangeMaskWidth, RangeMaskHeight)) { return; } const int32 PixelCount = RangeMaskWidth * RangeMaskHeight; if (RangeMaskPixels.Num() != PixelCount) { return; } CachedRangeMaskPixels.SetNumUninitialized(PixelCount); for (int32 PixelIndex = 0; PixelIndex < PixelCount; ++PixelIndex) { CachedRangeMaskPixels[PixelIndex] = RangeMaskPixels[PixelIndex]; } } void UScratchRevealProgressChecker::RebuildMaskUVs() { CachedMaskUVs.Reset(); const int32 SampleResolution = Config.GridResolution; CachedMaskUVs.SetNumUninitialized(SampleResolution * SampleResolution); for (int32 Y = 0; Y < SampleResolution; ++Y) { for (int32 X = 0; X < SampleResolution; ++X) { const int32 PixelIndex = GetPixelIndex(X, Y, SampleResolution); CachedMaskUVs[PixelIndex] = GetSampleUV(X, Y, SampleResolution); } } } bool UScratchRevealProgressChecker::ReadRangeMaskPixels( UTexture* InTexture, TArray<FColor>& OutPixels, int32& OutWidth, int32& OutHeight) const { OutWidth = 0; OutHeight = 0; if (!InTexture) { return false; } OutWidth = InTexture->GetSurfaceWidth(); OutHeight = InTexture->GetSurfaceHeight(); if (UTexture2D* Texture2D = Cast<UTexture2D>(InTexture)) { if (TryReadUncompressedRangeMaskPixels(Texture2D, OutPixels, OutWidth, OutHeight)) { return true; } } return ReadTexturePixels(InTexture, OutWidth, OutHeight, OutPixels); } bool UScratchRevealProgressChecker::TryReadUncompressedRangeMaskPixels( UTexture2D* InTexture, TArray<FColor>& OutPixels, const int32 Width, const int32 Height) const { if (!InTexture || !InTexture->GetPlatformData() || InTexture->GetPlatformData()->Mips.Num() == 0) { return false; } const EPixelFormat PixelFormat = InTexture->GetPlatformData()->PixelFormat; if (IsCompressedPixelFormat(PixelFormat)) { return false; } FTexture2DMipMap& Mip = InTexture->GetPlatformData()->Mips[0]; const void* RawPixelData = Mip.BulkData.LockReadOnly(); if (!RawPixelData) { return false; } const int32 PixelCount = Width * Height; bool bReadSucceeded = false; switch (PixelFormat) { case PF_B8G8R8A8: bReadSucceeded = ReadBGRA8Pixels(RawPixelData, PixelCount, OutPixels); break; case PF_R8G8B8A8: bReadSucceeded = ReadRGBA8Pixels(RawPixelData, PixelCount, OutPixels); break; case PF_FloatRGBA: bReadSucceeded = ReadFloatRGBAPixels(RawPixelData, PixelCount, OutPixels); break; default: break; } Mip.BulkData.Unlock(); return bReadSucceeded; } bool UScratchRevealProgressChecker::IsCompressedPixelFormat(const EPixelFormat PixelFormat) { return GPixelFormats[PixelFormat].BlockSizeX > 1 || GPixelFormats[PixelFormat].BlockSizeY > 1; } bool UScratchRevealProgressChecker::ReadBGRA8Pixels(const void* RawPixelData, const int32 PixelCount, TArray<FColor>& OutPixels) { OutPixels.SetNumUninitialized(PixelCount); FMemory::Memcpy(OutPixels.GetData(), RawPixelData, PixelCount * sizeof(FColor)); return true; } bool UScratchRevealProgressChecker::ReadRGBA8Pixels(const void* RawPixelData, const int32 PixelCount, TArray<FColor>& OutPixels) { OutPixels.SetNumUninitialized(PixelCount); const uint8* PixelBytes = static_cast<const uint8*>(RawPixelData); for (int32 PixelIndex = 0; PixelIndex < PixelCount; ++PixelIndex) { const int32 ByteIndex = PixelIndex * 4; OutPixels[PixelIndex] = FColor( PixelBytes[ByteIndex + 0], PixelBytes[ByteIndex + 1], PixelBytes[ByteIndex + 2], PixelBytes[ByteIndex + 3]); } return true; } bool UScratchRevealProgressChecker::ReadFloatRGBAPixels(const void* RawPixelData, const int32 PixelCount, TArray<FColor>& OutPixels) { OutPixels.SetNumUninitialized(PixelCount); const FFloat16Color* PixelData = static_cast<const FFloat16Color*>(RawPixelData); for (int32 PixelIndex = 0; PixelIndex < PixelCount; ++PixelIndex) { OutPixels[PixelIndex] = FLinearColor(PixelData[PixelIndex]).ToFColor(false); } return true; } bool UScratchRevealProgressChecker::ReadTexturePixels( UTexture* InTexture, const int32 Width, const int32 Height, TArray<FColor>& OutPixels) const { if (!InTexture || Width <= 0 || Height <= 0) { return false; } UTextureRenderTarget2D* RenderTarget = nullptr; return DrawTextureToRenderTarget(InTexture, Width, Height, RenderTarget) && ReadRenderTargetPixels(RenderTarget, OutPixels) && OutPixels.Num() == Width * Height; } bool UScratchRevealProgressChecker::DrawTextureToRenderTarget( UTexture* InTexture, const int32 Width, const int32 Height, UTextureRenderTarget2D*& OutRenderTarget) const { UObject* WorldContextObject = const_cast<UScratchRevealProgressChecker*>(this); OutRenderTarget = UKismetRenderingLibrary::CreateRenderTarget2D( WorldContextObject, Width, Height, RTF_RGBA8, FLinearColor::Transparent, false, false); if (!OutRenderTarget) { return false; } UCanvas* Canvas = nullptr; FVector2D CanvasSize = FVector2D::ZeroVector; FDrawToRenderTargetContext Context; UKismetRenderingLibrary::BeginDrawCanvasToRenderTarget(WorldContextObject, OutRenderTarget, Canvas, CanvasSize, Context); if (!Canvas) { UKismetRenderingLibrary::EndDrawCanvasToRenderTarget(WorldContextObject, Context); return false; } Canvas->K2_DrawTexture( InTexture, FVector2D::ZeroVector, FVector2D(static_cast<float>(Width), static_cast<float>(Height)), FVector2D::ZeroVector, FVector2D::UnitVector, FLinearColor::White, BLEND_Opaque, 0.0f, FVector2D::ZeroVector); UKismetRenderingLibrary::EndDrawCanvasToRenderTarget(WorldContextObject, Context); return true; } bool UScratchRevealProgressChecker::ReadRenderTargetPixels( UTextureRenderTarget2D* InRenderTarget, TArray<FColor>& OutPixels) { if (!InRenderTarget) { return false; } FTextureRenderTargetResource* RenderTargetResource = InRenderTarget->GameThread_GetRenderTargetResource(); if (!RenderTargetResource) { return false; } OutPixels.Reset(); RenderTargetResource->ReadPixels(OutPixels); return OutPixels.Num() == InRenderTarget->SizeX * InRenderTarget->SizeY; } FVector2D UScratchRevealProgressChecker::GetSampleUV(const int32 X, const int32 Y, const int32 GridResolution) { return FVector2D( (static_cast<float>(X) + 0.5f) / static_cast<float>(GridResolution), (static_cast<float>(Y) + 0.5f) / static_cast<float>(GridResolution)); } int32 UScratchRevealProgressChecker::GetPixelIndex(const int32 X, const int32 Y, const int32 Width) { return (Y * Width) + X; }
キャッシュしているRangeMaskのPixelと削り箇所として書き込んだRTのピクセルを共通UVで
抽出して、指定のチャンネルで比較して結果をリザルトで返します。
FScratchRevealCheckResult UScratchRevealProgressChecker::CheckCoverage(UTextureRenderTarget2D* InAccumulatedMaskTexture) const { if (!InAccumulatedMaskTexture) { return FScratchRevealCheckResult(); } if (CachedRangeMaskPixels.Num() == 0) { return FScratchRevealCheckResult(); } TArray<FColor> AccumulatedMaskPixels; if (!ReadRenderTargetPixels(InAccumulatedMaskTexture, AccumulatedMaskPixels)) { return FScratchRevealCheckResult(); } return BuildCoverageResult(AccumulatedMaskPixels, InAccumulatedMaskTexture->SizeX, InAccumulatedMaskTexture->SizeY); } FScratchRevealCheckResult UScratchRevealProgressChecker::BuildCoverageResult( const TArray<FColor>& AccumulatedMaskPixels, const int32 AccumulatedWidth, const int32 AccumulatedHeight) const { FScratchRevealCheckResult Result; float InitialBlackWeight = 0.0f; float RemainingBlackWeight = 0.0f; bool bEveryMaskedPixelRevealed = true; const int32 SampleResolution = Config.GridResolution; if (!Config.RangeMaskTexture) { return Result; } const int32 RangeMaskWidth = Config.RangeMaskTexture->GetSurfaceWidth(); const int32 RangeMaskHeight = Config.RangeMaskTexture->GetSurfaceHeight(); if (CachedRangeMaskPixels.Num() != RangeMaskWidth * RangeMaskHeight) { return Result; } if (CachedMaskUVs.Num() != SampleResolution * SampleResolution) { return Result; } for (const FVector2D& UV : CachedMaskUVs) { const float RangeMaskValue = GetMaskValue(SamplePixel(CachedRangeMaskPixels, RangeMaskWidth, RangeMaskHeight, UV), Config.ProgressCheckChannel); const float ClampedRangeMaskValue = FMath::Clamp(RangeMaskValue, 0.0f, 1.0f); const float AccumulatedMaskValue = GetMaskValue(SamplePixel(AccumulatedMaskPixels, AccumulatedWidth, AccumulatedHeight, UV), Config.ProgressCheckChannel); const float InvertedRangeValue = 1.0f - ClampedRangeMaskValue; const float ClampedAccumulatedMaskValue = FMath::Clamp(AccumulatedMaskValue, 0.0f, 1.0f); const float QuantizedAccumulatedMaskValue = ClampedAccumulatedMaskValue >= Config.RevealedPixelThreshold ? 1.0f : ClampedAccumulatedMaskValue; InitialBlackWeight += ClampedRangeMaskValue; RemainingBlackWeight += 1.0f - FMath::Max(InvertedRangeValue, QuantizedAccumulatedMaskValue); if (ClampedRangeMaskValue > KINDA_SMALL_NUMBER) { if (ClampedAccumulatedMaskValue < 1.0f - KINDA_SMALL_NUMBER) { bEveryMaskedPixelRevealed = false; } } } const float RemainingBlackRatio = InitialBlackWeight > KINDA_SMALL_NUMBER ? RemainingBlackWeight / InitialBlackWeight : 0.0f; Result.RevealProgress = bEveryMaskedPixelRevealed ? 1.0f : FMath::Min(1.0f - FMath::Clamp(RemainingBlackRatio, 0.0f, 1.0f), 1.0f - UE_SMALL_NUMBER); Result.bCompleted = InitialBlackWeight <= KINDA_SMALL_NUMBER || Result.RevealProgress >= Config.CompletionThreshold; Result.bValid = true; return Result; } float UScratchRevealProgressChecker::GetMaskValue(const FColor& Pixel, const EScratchRevealProgressCheckChannel InProgressCheckChannel) { switch (InProgressCheckChannel) { case EScratchRevealProgressCheckChannel::Red: return static_cast<float>(Pixel.R) / 255.0f; case EScratchRevealProgressCheckChannel::Green: return static_cast<float>(Pixel.G) / 255.0f; case EScratchRevealProgressCheckChannel::Blue: return static_cast<float>(Pixel.B) / 255.0f; case EScratchRevealProgressCheckChannel::Alpha: return static_cast<float>(Pixel.A) / 255.0f; case EScratchRevealProgressCheckChannel::RGB: return ( static_cast<float>(Pixel.R) + static_cast<float>(Pixel.G) + static_cast<float>(Pixel.B)) / (255.0f * 3.0f); case EScratchRevealProgressCheckChannel::RGBA: default: return ( static_cast<float>(Pixel.R) + static_cast<float>(Pixel.G) + static_cast<float>(Pixel.B) + static_cast<float>(Pixel.A)) / (255.0f * 4.0f); } } FColor UScratchRevealProgressChecker::SamplePixel(const TArray<FColor>& Pixels, const int32 Width, const int32 Height, const FVector2D& UV) { if (Pixels.Num() == 0 || Width <= 0 || Height <= 0) { return FColor::Black; } const int32 X = FMath::Clamp(FMath::FloorToInt(UV.X * static_cast<float>(Width)), 0, Width - 1); const int32 Y = FMath::Clamp(FMath::FloorToInt(UV.Y * static_cast<float>(Height)), 0, Height - 1); return Pixels[GetPixelIndex(X, Y, Width)]; }
達成割合に応じて通知をする
作ったChackerを使ってWidgetに削った時の変化毎に外部通知する機能を追加します。
過去の進捗状態と比較して通知を出すため進捗キャッシュ用のメンバ変数も用意しておきます。
Detailにて編集可能なCheckerに渡す用のFScratchRevealCheckConfigも用意してます。
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FScratchRevealProgressChangedSignature, float, Progress); DECLARE_DYNAMIC_MULTICAST_DELEGATE(FScratchRevealCompletedSignature); UCLASS(BlueprintType) class PEELOFFTEST_API UScratchRevealWidget : public UUserWidget { public: UPROPERTY(BlueprintAssignable, Category = "Scratch") FScratchRevealProgressChangedSignature OnRevealProgressChanged; UPROPERTY(BlueprintAssignable, Category = "Scratch") FScratchRevealCompletedSignature OnRevealCompleted; private: void BroadcastProgress(); public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Scratch|Parameters") FScratchRevealCheckConfig ProgressCheckConfig; private: UPROPERTY(Transient) TObjectPtr<UScratchRevealProgressChecker> ProgressChecker; float LastBroadcastProgress = -1.0f; bool bCompletionBroadcast = false; };
初期化時にチェッカーを生成して設定を渡して初期化します。
更新毎に削りMask書き込みTextureの最新を渡してその結果より通知を行います。
通知は2種類で現在の進捗通知と指定達成率を超えたとき達成通知です。
bool UScratchRevealWidget::Initialize() { const bool bWidgetInitialized = Super::Initialize(); if (bWidgetInitialized) { ProgressCheckConfig.RangeMaskTexture = RangeMaskTexture; ProgressChecker = NewObject<UScratchRevealProgressChecker>(this); ProgressChecker->Initialize(ProgressCheckConfig); BuildWidgetTree(); } return bWidgetInitialized; } void UScratchRevealWidget::ResetScratch() { //既存の実装... LastBroadcastProgress = -1.0f; bCompletionBroadcast = false; } void UScratchRevealWidget::BroadcastProgress(const FScratchRevealCheckResult& CheckResult) { if (!CheckResult.bValid) { return; } const float ClampedProgress = FMath::Clamp(CheckResult.RevealProgress, 0.0f, 1.0f); if (FMath::IsNearlyEqual(LastBroadcastProgress, ClampedProgress)) { return; } const bool bCompletedNow = CheckResult.bCompleted; const bool bCompletedJustNow = bCompletedNow && !bCompletionBroadcast; LastBroadcastProgress = ClampedProgress; if (bCompletedNow) { bCompletionBroadcast = true; } OnRevealProgressChanged.Broadcast(ClampedProgress); if (bCompletedJustNow) { OnRevealCompleted.Broadcast(); } } void UScratchRevealWidget::ApplyScratch(const FVector2D& ScratchPosition, const FVector2D& WidgetSize) { //既存の実装... if (ProgressChecker) { const FScratchRevealCheckResult CheckResult = ProgressChecker->CheckCoverage(AccumulatedMaskTexture); BroadcastProgress(CheckResult); } }
Widgetを作っているGameModeにて、更新毎の現在の進捗通知と指定達成率を超えたとき達成通知
に紐づける二種類の関数を用意しておきます。
class APeelOffTestGameMode : public AGameModeBase { protected: UFUNCTION() void HandleScratchRevealProgressChanged(float Progress); UFUNCTION() void HandleScratchRevealCompleted(); };
中身はただのメッセージ表示です。
Widget生成をするBeginPlayに追加でDelegateとの紐づけをします。
void APeelOffTestGameMode::BeginPlay() { //既存の実装... ScratchRevealScratchWidget->OnRevealProgressChanged.AddDynamic(this, &APeelOffTestGameMode::HandleScratchRevealProgressChanged); ScratchRevealScratchWidget->OnRevealCompleted.AddDynamic(this, &APeelOffTestGameMode::HandleScratchRevealCompleted); } void APeelOffTestGameMode::HandleScratchRevealProgressChanged(const float Progress) { const float ProgressPercent = FMath::Clamp(Progress, 0.0f, 1.0f) * 100.0f; UE_LOG(LogTemp, Log, TEXT("Scratch progress: %.2f%%"), ProgressPercent); if (GEngine) { GEngine->AddOnScreenDebugMessage( reinterpret_cast<uint64>(this), 1.5f, FColor::Green, FString::Printf(TEXT("Scratch progress: %.2f%%"), ProgressPercent)); } } void APeelOffTestGameMode::HandleScratchRevealCompleted() { UE_LOG(LogTemp, Log, TEXT("Scratch completed")); if (GEngine) { GEngine->AddOnScreenDebugMessage( reinterpret_cast<uint64>(this) + 1ULL, 2.0f, FColor::Yellow, TEXT("Scratch completed")); } }
WidgetにてMaskを書いてその影響に応じてメッセージが表示されれば完了です。
これによって指定割合塗り絵ができたらストーリーが進むトリガー発火とかできますねぇ~。

参考資料
以下は今回のテスト環境のリポジトリです。
余談
久々に書いてて、AIあっても記事書くのはまだ時間かかる
まだ書き終えていない下書きが。。。
リポジトリと過去の記事のサンプルと自分の性格をコンテキストで渡したら
簡単に記事吐き出してくれる時代になってほしいが、いつ来てくれるのやら。




































































