ささみのメモ帳

ゲーム会社で働く、無能プログラマーが自分のためのメモ帳として利用しています。

UEのスクラッチ的入力による削り表現のメモ

削り表現とは

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

具体的には、RenderTarget(テクスチャ)に入力で削り/透過を意味する個所にMask(色)を書き込み、
その書き込んだRTと削れる範囲のMaskTextureをブレンドして削り状態を作るものです。

フロー

今回の流れは大きく分けて以下の3段階で実装します。

  1. 検証結果を表示するWidgetを用意
  2. 入力で削りマスクを生成
  3. 削り状態を元に表示を加工

実践/実装

環境構築

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を書いてその影響に応じてメッセージが表示されれば完了です。
これによって指定割合塗り絵ができたらストーリーが進むトリガー発火とかできますねぇ~。

参考資料

以下は今回のテスト環境のリポジトリです。

github.com

余談

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

UEのネットワーク同期、GASのRPC利用(基本)についてのメモ

こちらの記事の内容も含めた少し続きとなる内容になっています。 shiromisasami.hatenablog.com

内容は私が個人で調べたものです。 間違った個所は必ずあると思いますので、そのうえで参考程度にご確認ください

UEにおけるマルチプレイ/ネットワーク構造


UEのネットワークモデルはクライアント-サーバーモデルを採用している。
ネットワーク内の 1 台のコンピューターがメイン処理を行うサーバーマシンとなり
マルチプレイヤー場合ほかのコンピューターすべてがクライアントとしてそのサーバーに接続する。
サーバーに接続しているクライアントはゲームステート情報等をサーバーから共有してもらい、
必要があれば、サーバーからの命令で処理をクライアント側で行います。

※シングルやローカルマルチの場合は単独マシンにてどちらの処理も行われるため、どちらのこの区別に あまり意味がありません。ただ、それぞれでの処理を確認はできます。

内容
サーバー ゲームの基本的な処理を担っている
クライアント ユーザーが触れたり、認識できる領域処理を担っている

参考公式ページ

Unreal Engine でのネットワーキングの概要 | Unreal Engine 5.5 ドキュメンテーション | Epic Developer Community

所有権について

全てActorは生成したサーバー/クライアントが所有権を持っている。
基本的にはサーバーになると思うが、Pown/Characterといったクライアント(PlayerController)
にとつながっているActorはクライアント側が所有権を持っている。

参考公式ページ Unreal Engine でのアクタとアクタの所有接続 | Unreal Engine 5.5 ドキュメンテーション | Epic Developer Community

ネットワーク同期(Replication)とは

オブジェクトの状態をサーバーを介することで複数のクライアントで共有すること。
基本的にはマルチプレイによる座標、姿勢等の描画的情報共有で利用することが多い

shiromisasami.hatenablog.com

通常のアクタのネットワーク同期設定
AActor::NetUpdateFrequency

UEの標準の機能。該当のアクター(AActor)のネットワーク同期頻度。

// 1/100 秒毎同期
NetUpdateFrequency = 100.f;

dev.epicgames.com

UActorComponent::SetIsReplicated

UEの標準の機能。該当のUActorComponentがネットワーク上で同期(レプリケート)を可能にするのかの設定。

ReplicatedUsing(マクロ)

該当の変数が更新されたタイミングで発火する関数の紐づけ定義。

UPROPERTY(ReplicatedUsing = OnRep_AttributeValue)
FGameplayAttributeData Value; 

UFUNCTION()
void OnRep_AttributeValue(const FGameplayAttributeData& OldValue);
AActor::HasAuthority

実行がサーバーか判定する関数。

GASのアクタのネットワーク同期
UAbilitySystemComponent::SetReplicationMode

レプリケーションモード (EGameplayEffectReplicationMode) を指定することで、アビリティシステムコンポーネント内のアクティブなゲームプレイエフェクト(Gameplay Effects)がどのようにネットワーク上で同期されるかを制御する。

設定名 内容
Minimal ゲームプレイエフェクトの最小限の情報のみが同期される
Mixed 特定のゲームプレイエフェクトのみが詳細に同期され、そのほかは最小限の情報のみが同期される
Full すべてのゲームプレイエフェクトが詳細に情報が同期される
AbilitySystemComponent->SetReplicationMode(EGameplayEffectReplicationMode::Minimal);
UGameplayAbility::NetSecurityPolicy(protected メンバ)

ネットワークセキュリティポリシーを設定するためのプロパティ。
アビリティが実行マシンの設定。

設定名 内容
ClientOrServer クライアントまたはサーバーのいずれかで実行される
ServerOnly サーバーでのみ実行される
ClientOnly クライアントでのみ実行される
ReplicationProxyEnabled
UAbilitySystemComponent::IsOwnerActorAuthoritative

実行がサーバーか判定する関数。
GAS のネットワークモデルに最適化された関数で、GAS関係やGAS利用環境ではこちらが推奨。

UAbilitySystemComponent::GetReplicatedDataOfGenericReplicatedEvent

指定イベントのデータを取得するために機能。

{
    EAbilityGenericReplicatedEvent::Type EventType = EAbilityGenericReplicatedEvent::Activate;
    FGameplayAbilitySpecHandle AbilityHandle = ...;
    FPredictionKey PredictionKey = ...;

    FAbilityReplicatedData ReplicatedData = AbilitySystemComponent->GetReplicatedDataOfGenericReplicatedEvent(EventType, AbilityHandle, PredictionKey);
}
UAbilitySystemComponent::ConsumeAllReplicatedData

サーバーでキャッシュ(同期用データ)されたレプリケーションデータを削除する。
指定された AbilityHandle と AbilityOriginalPredictionKey に関連する。
すべてのキャッシュされた能力クライアントデータを削除する。

{
    FGameplayAbilitySpecHandle AbilityHandle = ...;
    FPredictionKey PredictionKey = ...;

    AbilitySystemComponent->ConsumeAllReplicatedData(AbilityHandle, PredictionKey);
}
UAbilitySystemComponent::ConsumeClientReplicatedTargetData

クライアントからターゲットデータを削除する。
指定された AbilityHandle と AbilityOriginalPredictionKey に関連する
該当のターゲットデータを削除する。

{
    FGameplayAbilitySpecHandle AbilityHandle = ...;
    FPredictionKey PredictionKey = ...;

    AbilitySystemComponent->ConsumeClientReplicatedTargetData(AbilityHandle, PredictionKey);
}
UAbilitySystemComponent::ConsumeGenericReplicatedEvent

クライアントが送信したイベントの指定解除する。
イベントが再度トリガーされないようにする。

{
    EAbilityGenericReplicatedEvent::Type EventType = EAbilityGenericReplicatedEvent::Activated;
    FGameplayAbilitySpecHandle AbilityHandle = ...;
    FPredictionKey PredictionKey = ...;

    AbilitySystemComponent->ConsumeGenericReplicatedEvent(EventType, AbilityHandle, PredictionKey);
}
UAbilitySystemComponent::InvokeReplicatedEvent

指定された登録イベントをローカルで呼び出すための機能。
イベントが正常に呼び出された場合は true、それ以外の場合は false

{
    EAbilityGenericReplicatedEvent::Type EventType = EAbilityGenericReplicatedEvent::AbilityActivated;
    FGameplayAbilitySpecHandle AbilityHandle = ...;
    FPredictionKey OriginalPredictionKey = ...;
    FPredictionKey CurrentPredictionKey = ...;

    bool bSuccess = AbilitySystemComponent->InvokeReplicatedEvent(EventType , AbilityHandle,     OriginalPredictionKey, CurrentPredictionKey);
    if (bSuccess)
    {
        // イベントが正常に呼び出された場合の処理
    }
    else
    {
        // イベントの呼び出しに失敗した場合の処理
    }
}
UAbilitySystemComponent::InvokeReplicatedEventWithPayload

InvokeReplicatedEventの追加で関連データ(ペイロード)も渡せる機能。
FVector_NetQuantize100 のデータ型で渡せる。

{
    EAbilityGenericReplicatedEvent::Type EventType = EAbilityGenericReplicatedEvent::AbilityActivated;
    FGameplayAbilitySpecHandle AbilityHandle = ...;
    FPredictionKey OriginalPredictionKey = ...;
    FPredictionKey CurrentPredictionKey = ...;
    FVector_NetQuantize100 VectorPayload = FVector_NetQuantize100(1.0f, 2.0f, 3.0f);

    bool bSuccess = AbilitySystemComponent->InvokeReplicatedEventWithPayload(EventType, AbilityHandle, OriginalPredictionKey, CurrentPredictionKey, VectorPayload);
    if (bSuccess)
    {
        // イベントが正常に呼び出された場合の処理
    }
    else
    {
        // イベントの呼び出しに失敗した場合の処理
    }
}
UAbilitySystemComponent::ConfirmAbilityTargetData

アビリティの実行に必要なデータを設定するための機能

{
    FGameplayAbilitySpecHandle AbilityHandle = ...;
    FPredictionKey PredictionKey = ...;
    FGameplayAbilityTargetDataHandle TargetData = ...;
    FGameplayTag ApplicationTag = ...;

    AbilitySystemComponent->ConfirmAbilityTargetData(AbilityHandle, PredictionKey, TargetData, ApplicationTag);
}
UAbilitySystemComponent::CancelAbilityTargetData

ターゲットデータをサーバーに送信をキャンセルするための機能

{
    FGameplayAbilitySpecHandle AbilityHandle = ...;
    FPredictionKey PredictionKey = ...;

    AbilitySystemComponent->CancelAbilityTargetData(AbilityHandle, PredictionKey);
}
UAbilitySystemComponent::CallAllReplicatedDelegatesIfSet

指定された AbilityHandle と AbilityOriginalPredictionKey に関連する
すべてのレプリケートされたデリゲートを呼び出すための機能。

{
    FGameplayAbilitySpecHandle AbilityHandle = ...;
    FPredictionKey AbilityOriginalPredictionKey = ...;

    AbilitySystemComponent->CallAllReplicatedDelegatesIfSet(AbilityHandle, AbilityOriginalPredictionKey);
}
UAbilitySystemComponent::CallReplicatedTargetDataDelegatesIfSet

指定された AbilityHandle と AbilityOriginalPredictionKey に関連するターゲットデータの
確認/キャンセルイベントが設定されている場合に、それらのデリゲートを呼びだす機能
デリゲートが正常に呼び出された場合は true、それ以外の場合は false

{
    FGameplayAbilitySpecHandle AbilityHandle = ...;
    FPredictionKey AbilityOriginalPredictionKey = ...;

    bool bDelegatesCalled = AbilitySystemComponent->CallReplicatedTargetDataDelegatesIfSet(AbilityHandle, PredictionKey);

    if (bDelegatesCalled)
    {
        // デリゲートが呼び出された場合の処理
    }
    else
    {
        // デリゲートが呼び出されなかった場合の処理
    }
}
UAbilitySystemComponent::CallReplicatedEventDelegateIfSet

指定されたイベントが既に送信されている場合に、そのイベントに登録されているデリゲートを呼び出す。
イベントが既に送信されている場合に true を返し、そうでない場合は false

{
    EAbilityGenericReplicatedEvent::Type EventType = EAbilityGenericReplicatedEvent::AbilityActivated;
    FGameplayAbilitySpecHandle AbilityHandle = ...;
    FPredictionKey AbilityOriginalPredictionKey = ...;

    bool bDelegatesCalled = AbilitySystemComponent->CallReplicatedEventDelegateIfSet(EventType, AbilityHandle, AbilityOriginalPredictionKey);

    if (bDelegatesCalled)
    {
        // デリゲートが呼び出された場合の処理
    }
    else
    {
        // デリゲートが呼び出されなかった場合の処理
    }
}
UAbilitySystemComponent::CallOrAddReplicatedDelegate

イベントが既に送信されている場合、そのイベントに関連するデリゲートを呼び出す。
イベントがまだ送信されていない場合、そのデリゲートをマルチキャストコールバックに追加し、
イベントが送信されたときに呼び出されるようにする。
イベントが既に発生している場合は true を返し、そうでない場合は false

{
    EAbilityGenericReplicatedEvent::Type EventType = EAbilityGenericReplicatedEvent::AbilityActivated;
    FGameplayAbilitySpecHandle AbilityHandle = ...;
    FPredictionKey PredictionKey = ...;

    FSimpleMulticastDelegate::FDelegate MyDelegate = FSimpleMulticastDelegate::FDelegate::CreateUObject(this, &UMyAbilitySystemComponent::MyDelegateFunction);

    bool bEventAlreadyTriggered = AbilitySystemComponent->CallOrAddReplicatedDelegate(EventType , AbilityHandle, PredictionKey, MyDelegate);

    if (bEventAlreadyTriggered)
    {
        // イベントが既に発生済み
    }
}

void UMyAbilitySystemComponent::MyDelegateFunction()
{
}

RPCとは

RPC(Remote Procedure Call)通信は、異なるコンピュータシステム間、または同一システム内の異なるプログラム間で関数(処理)を遠隔で呼び出すためのプロトコル(通信技術)。
言語非依存であり、異なるプログラミング言語で書かれたアプリケーション間での通信が可能。
RPCは通信の詳細が抽象化されており、開発者がネットワークプロトコルやデータのシリアライゼーションの詳細を意識せずに関数呼び出しが可能。

RPC通信設定
NetMulticast/Server/Client(マクロ)

所有権をもとに、該当の関数の実行先を設定

設定名 内容
Server クライアントが実行/呼び出しをした場合に、サーバー側で処理してクライアントに同期
Client サーバーが特定のクライアントで処理を実行
NetMulticast サーバーが呼び出すと、全クライアントで処理を実行
Reliable/Unreliable(マクロ)

データ送信の時の速度とパケットロス時の対応設定

設定名 内容 用途
Reliable 送信が確実に成功する。再送信あり
死亡処理、アイテム取得、スコア送信
Unreliable 送信が軽量で速いが、届かない場合もある
移動データ、エフェクト、カメラ向き
UFUNCTION(Client, Reliable)
void ClientFunction();

void AMyActor::ClientFunction_Implementation() {
    // クライアントのみで実行
}
GASでのRPC

アビリティのロジックをネットワーク上の異なるクライアント間(サーバー側)で同期させることができ、
マルチ通信の場合より効果を発揮する。
サーバー利用した処理分散も可能なため、シングルプレイヤーでもサーバー側に一部
処理を委ねることで効果が出せる場合もある。

FGameplayEffectSpecForRPC

FGameplayEffectSpecの軽量版であり、ネットワーク通信時に使用される。
サーバーからクライアントへGameplayEffectの適用情報を伝達する際、
帯域幅を節約するために必要最小限の情報のみを含むこの構造体が使用される。

UAbilitySystemComponent::TryActivateAbility

アビリティの発動関数。
第二引数の bool bAllowRemoteActivation
を有効にすることでクライアント側からのアビリティ発火要求(RPC)

 AbilitySystemComponent->TryActivateAbility(AbilityHandle, true);
UAbilitySystemComponent::TryActivateAbilityByClass

特定のアビリティクラスを基にした、アビリティの発動関数。
第二引数の bool bAllowRemoteActivation
を有効にすることでクライアント側からのアビリティ発火要求(RPC)

 AbilitySystemComponent->TryActivateAbilityByClass(AbilityClass, true);
UAbilitySystemComponent::TryActivateAbilitiesByTag

特定のアビリティタグを基にした、アビリティの発動関数。
第二引数の bool bAllowRemoteActivation
を有効にすることでクライアント側からのアビリティ発火要求(RPC)

 AbilitySystemComponent->TryActivateAbilitiesByTag(AbilityTag, true);
UAbilitySystemComponent::CallServerEndAbility

クライアントからサーバーに対して「アビリティの終了」を通知するための RPC関数

{
   FGameplayAbilitySpecHandle AbilityHandle = ...;

   FPredictionKey PredictionKey = ...;
   FGameplayAbilityActivationInfo ActivationInfo = ...;
   AbilitySystemComponent->CallServerEndAbility(AbilityHandle, ActivationInfo, PredictionKey);
}
UAbilitySystemComponent::ServerSetReplicatedEvent

クライアントが発生させたアビリティ関連のイベントをサーバーに通知する機能
依頼されたサーバー側ではHandleReplicatedEventと同様に実行する。

{
   if(!IsOwnerActorAuthoritative())
   {
       EAbilityGenericReplicatedEvent::Type EventType = EAbilityGenericReplicatedEvent::InputPressed;
       FGameplayAbilitySpecHandle AbilityHandle = ...;
       FPredictionKey OriginalPredictionKey = ...;
       FPredictionKey CurrentPredictionKey = ...;

       AbilitySystemComponent->ServerSetReplicatedEvent(EventType, AbilityHandle, PredictionKey);
   }
}
UAbilitySystemComponent::ServerSetReplicatedEventWithPayload

ServerSetReplicatedEventの追加で関連データ(ペイロード)も渡せる機能。
FVector_NetQuantize100 のデータ型で渡せる。

{
   if(!IsOwnerActorAuthoritative())
   {
       EAbilityGenericReplicatedEvent::Type EventType = EAbilityGenericReplicatedEvent::InputPressed;
       FGameplayAbilitySpecHandle AbilityHandle = ...;
       FPredictionKey OriginalPredictionKey = ...;
       FPredictionKey CurrentPredictionKey = ...;
       FVector_NetQuantize100 VectorPayload = FVector_NetQuantize100(1.0f, 2.0f, 3.0f);

       ServerSetReplicatedEventWithPayload(EventType , AbilityHandle, OriginalPredictionKey, CurrentPredictionKey, VectorPayload);
   }
}
UAbilitySystemComponent::ClientSetReplicatedEvent

サーバー側が発生させたアビリティ関連のイベントをクライアントに通知する機能

{
   if(IsOwnerActorAuthoritative())
   {
       EAbilityGenericReplicatedEvent::Type EventType = EAbilityGenericReplicatedEvent::InputPressed;
       FGameplayAbilitySpecHandle AbilityHandle = ...;
       FPredictionKey PredictionKey = ...;

       AbilitySystemComponent->ClientSetReplicatedEvent(EventType, AbilityHandle, PredictionKey);
}
UAbilitySystemComponent::ServerSetReplicatedTargetData

クライアントが発生させたアビリティのターゲットデータをサーバーに送信する。

{
   if(!IsOwnerActorAuthoritative())
   {
       FGameplayAbilitySpecHandle AbilityHandle = ...;
       FPredictionKey OriginalPredictionKey = ...; 
       FGameplayAbilityTargetDataHandle TargetDataHandle = ...; 
       FGameplayTag ApplicationTag = ...; 
       FPredictionKey CurrentPredictionKey = ...;

       AbilitySystemComponent->ServerSetReplicatedTargetData(AbilityHandle, OriginalPredictionKey, TargetDataHandle, ApplicationTag, CurrentPredictionKey);
}
UAbilitySystemComponent::ServerSetReplicatedTargetDataCancelled

クライアントからサーバーに ターゲットデータの送信がキャンセルされたことを通知する。

{
    if(!IsOwnerActorAuthoritative())
    {
       FGameplayAbilitySpecHandle AbilityHandle = ...;
       FPredictionKey OriginalPredictionKey = ...;
       FPredictionKey CurrentPredictionKey = ...;

       AbilitySystemComponent->ServerSetReplicatedTargetDataCancelled(AbilityHandle, OriginalPredictionKey, CurrentPredictionKey);
    }
}
UAbilitySystemComponent::ServerSetInputPressed

クライアントからサーバーに入力が押されたことを通知するためのRPC。

{
    if (!IsOwnerActorAuthoritative())
    {
        FGameplayAbilitySpecHandle AbilityHandle = ...;
        AbilitySystemComponent->ServerSetInputPressed(AbilityHandle);
    }
}
UAbilitySystemComponent::ServerSetInputReleased

クライアントからサーバーに入力終了したことを通知するためのRPC。

{
    if (!IsOwnerActorAuthoritative())
    {
        FGameplayAbilitySpecHandle AbilityHandle = ...;
        AbilitySystemComponent->ServerSetInputReleased(AbilityHandle);
    }
}
UAbilitySystemComponent::CallServerTryActivateAbility

クライアントからサーバーへアビリティのアクティベーションをリクエストするために使用。
クライアントが特定のアビリティをアクティブにしたいときにサーバーに通知する

{
   if(!IsOwnerActorAuthoritative())
   {
       EAbilityGenericReplicatedEvent::Type EventType = EAbilityGenericReplicatedEvent::InputPressed;
       FGameplayAbilitySpecHandle AbilityHandle = ...;
       FPredictionKey PredictionKey = ...;

       AbilitySystemComponent->CallServerTryActivateAbility(EventType , AbilityHandle, PredictionKey);
   }
}
UAbilitySystemComponent::CallServerSetReplicatedTargetData

クライアントからサーバーへターゲットデータを送信するために使用。
アビリティのターゲット情報をサーバーに伝達し、サーバー側でその情報に基づいた処理を行う。

{
   FGameplayAbilitySpecHandle AbilityHandle = ...;
   FPredictionKey AbilityOriginalPredictionKey = ...;
   FGameplayAbilityTargetDataHandle ReplicatedTargetDataHandle = ...;
   FGameplayTag ApplicationTag = ...;
   FPredictionKey CurrentPredictionKey = ...;

   AbilitySystemComponent->CallServerSetReplicatedTargetData(AbilityHandle, AbilityOriginalPredictionKey, ReplicatedTargetDataHandle, ApplicationTag, CurrentPredictionKey);
}
TArray<FServerAbilityRPCBatch, TInlineAllocator<1> > LocalServerAbilityRPCBatchData;

クライアントからサーバーへのアビリティ関連のリモートプロシージャコール(RPC)をバッチ処理
するためのデータ型。

FServerAbilityRPCBatch

サーバー側でAbilityのRPC呼び出しをバッチ処理(まとめ実行処理)するための構造体。
複数のAbilityの実行や入力をまとめて処理する。

{
    FServerAbilityRPCBatcher Batcher(AbilitySystemComponent);
    
    AbilitySystemComponent->TryActivateAbility(AbilityHandle1, true);
    AbilitySystemComponent->TryActivateAbility(AbilityHandle2, true);
    AbilitySystemComponent->TryActivateAbility(AbilityHandle3, true);
}   ← スコープ終了時に送信
FScopedServerAbilityRPCBatcher

スコープ内でアビリティRPC実行をバッチ管理するためのヘルパー構造体。
使用することで、スコープ内で自動的にバッチ処理(まとめ実行処理)が開始され
スコープ終了時にサーバーにまとめて送信される。

{
    FScopedServerAbilityRPCBatcher Batcher(AbilitySystemComponent);
    
    AbilitySystemComponent->TryActivateAbility(AbilityHandle1, true);
    AbilitySystemComponent->TryActivateAbility(AbilityHandle2, true);
    AbilitySystemComponent->TryActivateAbility(AbilityHandle3, true);
}   ← スコープ終了時に送信
UAbilityStstemComponent::ShouldDoServerAbilityRPCBatch

サーバー側でAbilityのRPCバッチ処理を行うかどうかを判定する関数。
現在の状況や設定に基づいて、バッチ処理を行うべきかを判断する。

UAbilitySystemComponent::ServerAbilityRPCBatch/ ServerAbilityRPCBatch_Internal

クライアントからサーバーへAbilityのバッチRPCリクエストを送信するための関数。
クライアント側で複数のAbility実行や入力をまとめてサーバーに送信する。

UAbilitySystemComponent::BeginServerAbilityRPCBatch

現在のRPCバッチ処理を開始し、RPCコールを蓄積する関数。

UAbilitySystemComponent::EndServerAbilityRPCBatch

現在のRPCバッチ処理を終了し、蓄積されたRPCコールをまとめて送信する関数。
バッチ処理の終了時に呼び出す。

C#とC++についてのメモ

※以下記載する内容は調べたものであり、間違った解釈や勘違いも十分にあるため
必ず一度自分でも調べなおしてほしい。

ゲーム開発で使うプログラム言語

ゲーム開発における様々なツールやゲーム内処理(ゲーム内ロジック、通信等)は、
様々な言語の利用によって実装されている。
ツール開発だとPythonやShell Script、通信やDBだとGoやJavaScriptGPU言語(シェーダー)だと HLSLやGLSL...
とまあ本当にいろいろある。

その中でもゲーム開発をするとなると、C#C++という二つの言語を耳にすると思う。
これらは主に、クライアント/フロントエンド(プレイヤが直接触れるまたは近い部分の実装)でよく利用される言語。

ゲーム開発での活用ゲームエンジン、ライブラリ事例

C++

・UnrealEngine(ゲームロジック周り)
・RE ENGINE(エンジン部分)
MT Framework

C#

・Unity
・RE ENGINE(ゲームロジック部分)
・FF16開発エンジン

以下参考資料

en.wikipedia.org

C++とは…

※ここに記載するのは純粋なC++についてである
汎用プログラミング言語の一つで、C言語から派生した手続き型/データ抽象/オブジェクト指向等といった
設計思想を取り入れた言語である。機械語に変換(コンパイル)する際にアセンブリ言語以外の低水準言語を必要としない
高速な処理のできる言語設計思想で作られている。
ISO/IECが共同で標準規格を3年程度の間隔で発表している。

C#とは…

汎用プログラミング言語の一つで、Microsoftが開発したC/C++に似たの書き方ができる言語。
手続き型/オブジェクト指向/コンポーネント指向等といった設計思想を取り入れた言語
Windowsの「.NET Framework」上で動作することを前提として開発された言語である。
機械語に変換(コンパイル)する際にはCIL(共通中間言語)を介する。
.NET Framework/.NET Coreの更新によって規格も更新されるため発表は不定期で行われる。
近年1年間隔では発表されている。

ここまでを踏まえて

C++はCの派生、C#C/C++の書き方に似ている。名前が似ている、オブジェクト指向が入っている
といったところからこれら二つの違いをあまり意識しない人もいるのではないかと思うが、
上記あるようにこれらは作ったひと/場所。更新をしている場所が、生み出された経緯が違く中身も結構違うのである。

機械語までの流れ

そもそも機械語とは...

機械語は 数字 で表現される命令列のコードである。
人間には理解が難しいが機械的には解釈が楽でありCPUへの行動命令が羅列されているものである。

プログラム言語を機械語

・プログラムをビルド/コンパイルしたことがある人
・PCのアプリ(ソフト)をファイルから起動したことがある人

上記経験者はおそらく.exe/.appといった拡張子のついたファイルを見たことがあるだろう。
これが実行ファイル、バイナリ、アプリ/ソフトとよばれるものである。
上記はPCの実行ファイルだが、もちろんゲーム機にも同様の実行ファイルが用意されている。
これらの中身は機械語であり、コンパイルという変換工程をへて人間が書いたC++/C#等のコードを
機械語のコード(命令)に翻訳したもの。

C++の場合のフロー

1. プリプロセス/プリプロセッサ

#include、#define などの記述箇所を該当のコードに展開/置き換えして一つのコードに変換する。
プリプロセッサ定義でコードの仕分けをした場合は、この時に該当コードのみを取り入れる。

#include:ヘッダーファイル内容の展開命令定義
#define:マクロの定義 で該当の値や式を記号な名前に置き換える定義

2. コンパイル

C++のコードをアセンブリコードに変換する。
変換の際にコードの解析(パース)、その解析をもとにしたコード最適化、型チェックを行う。
autoやsize_tの型確定をコード解析の結果で行う。
よくビルドの際にコンパイルエラーというものが出ると思うが、あのエラーは
このコンパイルを行うコンパイラがコード解析を行って構文的問題を発見してエラー出力してくれている。 constexprといった、事前計算定義系はこのタイミングで確定する。

3. アセンブル

アセンブリコードを機械語(バイナリ)に変換する。

4. リンク(静的リンク/Static Linking)

既に機械語に翻訳されている静的ライブラリをつなげる。 .lib(Windows)や .a(Linux)のような静的ライブラリをこのタイミングで含める。

C#の場合のフロー

1. C#コンパイル(CIL変換)

C# のコードを解析(パース)とCIL/MSIL(中間言語) コードに変換 CILの動的ライブラリの形式のアセンブリを作成
※このアセンブリとはC++コンパイル時のアセンブリ言語ではなく
動的ライブラリ的にしたもののことを意味する。
CIL は CPU ではなく .NET ランタイム(CLR)で解釈しやすいコード。

2. JIT/AOT コンパイル(CIL → 一部機械語

JITは ( Just-In-Time)実行時に実行に必要なコードを機械語にする。
AOTは(Ahead-Of-Time)実行前(ビルド時)に実行に必要なコードを機械語にする
機械語にされた部分はコード最適化される。

3. 実行(動的リンク)

JIt/AOTで機械語にされなかった箇所を実行中に随時機械語に変換する。
動的ライブラリを利用していた場合はこの時に繋げる。

機械語までのフロー比較で見える特徴

C++は事前にすべてのコードを機械語にし、一部のコードもコンパイル時に確定するので、
実行中は出来上がった機械語を使います。
C#のコードは事前にCILにして完全に機械語にはせずに、実行時中/実行中(ランタイム)に機械語に変換されます。
これが良くC++C#より高速であるという理由の一つです。
機械語に変換する工程や可変的コードが減って、処理負荷が減るということです。 ※ただ、近年はAOT等の利用することでC#も十分早くはなってきている。

C#はCILになって利用されます。CILはプラットフォーム依存がないことを思想に作られたもので
JIT/AOTや実行時にそのプラットフォームに合うようにコードが最適化や変換されます。
逆にC++コンパイル時に確定させるため、プラットフォーム単位にコードを変えて、
プラットフォーム単位に実行ファイルが必要になります。
そのため、複数プラットフォームに対応するのが非常に面倒です。

このように、C++C#は根本的に構造が違くそれぞれにメリットがある。

機能の違い

型の扱い

C++

auto(型推論)がある。
型を引数やラムダのキャプチャで渡すときに参照渡しにするか、コピー渡しにするか選択ができる。
※ラムダで参照渡しでキャプチャしたものが破棄された場合エラーになるので注意 また、moveによるアドレスをそのままに渡す方法もある。

C#

var(型推論)がある。
全ての型は値型と、参照型に区別される。

ポインタ(メモリ)管理

C++

ポインタを使うとき明示的に生成/破棄(メモリ解放)をするか、C++11より追加されたスマートポインタという 所有権の明記によるポインタの自動破棄管理機能を使う、2つの方法があります。

明示的ポインタ破棄は、記述を忘れるとそのメモリが解放されずにメモリリークにつながる。 生成せずに破棄を明記または、一度削除済みのポインタを破棄といった場合はエラーやクラッシュ、メモリ状態の不安定を招く原因となる。
これは、派生元となったC言語の名残でもあります。
生成をスマートポインタの記述にすることポインタに所有権の概念を与えて、その所有権をもとに自動的に該当の破棄可能なポインタ破棄する。 スマートポインタには種類があるため、使い分ける必要がある。
また、スマートポインタの登場はC++11からだが、C++17、20、23でも機能追加/更新があるので
今後さらに便利になっていくと思われる。
malloc/free等によるメモリ管理もあります。

スタックメモリ

ローカル変数や関数の引数等の一時的な処理で利用
コピー渡しの値

ヒープメモリ

長期的や任意的なメモリ保持が必要な、明示的な生成(new)やスマートポインタ、
mallocで確保されたオブジェクトや値。

C#(CLR)

GC(ガベレージコレクション)というポインタの破棄管理をユーザーに意識させない、
機能が用意されています。
GCは参照がなくなったものをGCの更新毎にチェックして破棄します。
面倒なメモリ管理を意識しなくてよい分、メモリの解放がGC依存になり場合によっては、
メモリの解放が想定より遅れていたりする場合などはあります。ただ、楽ではあります。

スタックメモリ

ローカル変数や関数等の一時的な処理で利用
値型で利用
※参照型の値をローカルで置いたとき、そのアドレスをスタックして実体はヒープ

ヒープメモリ

参照型で利用
※ラムダ(クロージャオブジェクト)もおそらくそう?

継承

C++

クラスの多重継承をサポート。
そのため該当のクラスに様々なクラスの機能を継承することが可能。
ただ、インターフェースという形は存在しないため、クラスをそういう扱いに疑似的にすることになります。
また、クラスだけでなく構造体(struct)も継承が可能です。
C++の構造体とクラスは非常に機能が似ています。

C#

クラスの継承は一つのみで、代わりにインターフェースの多重継承が可能。
インターフェースはクラスと違い、変数やコンストラクタ、デストラクタが定義できないというような
制限があり、仮想そのインターフェースの機能である仮想関数やプロパティの定義をすることができる。

まとめ

違いはそれぞれで利用できるライブラリや環境、構文の記述的違いを踏まえると
バージョン単位でそれぞれ変化したりするので、違いを言い出したらほぼ無限にある。
ただ、上記にある内容は比較的基本となる違いであるため知っておくと、これら言語を
利用する上で役に立てることは場合もある。 また、こういった言語タイでの比較や直腸をしることは今後新しい言語が出た時その言語が どういう使い方をすれば、現状よりパフォーマンスを出せそうかわかる場合もある。

個人的にRustあたりは素晴らしと思っている。
シェーダー言語も汎用プラットフォームなslangあたりの需要がより上がる可能性も高いと思う。

UE環境で独自Octree空間分割についてのメモ

Octree(8分木)とは

プログラム、ゲーム開発の中では様々な〇分木と呼ばれる分割探索手法がある。
分木とは木構造(ツリー構造)のことを意味しており、
木の枝分かれを例えた分岐、分割方法である。

該当的要素

根(ルート): 最上位に位置する原点となるノード。
枝分かれ: 各ノードから複数の子ノードに繋がる。
葉(リーフ): 子を持たない終端ノード。

よく使われるものだと、辞書配列、連想配列、マップと呼ばれる
C++: std::map、C#:System.Collections.Generic.Dictionary、Python:dict 等の
の機能には2分木が利用されている。

Octree(オクトリー)は8つに分割する手法で、3Dのゲーム開発でオブジェクトの衝突検出、
レイキャスティング等で利用される手法。
4分木(2D)でも同様にできるため多くの記事では4分木でわかりやすく解説されることが多い。

この手法は基本的にオブジェクトが数百~数千あたりの量の時によりパフォーマンスを発揮するため
少量のオブジェクトを対象にしたり、狭い空間で利用する場合にはメリットは少ない

実装/検証環境

IDE : Rider
言語C++ 20
ゲームエンジン:UE 5.5
UEテンプレート:ThirdPerson

分割とノードのモートンコード

8分木では上記画像の様に「Z」を描くように順に分割した空間に番号(ID)を振る。
このようにIDを振る、順列させることを「モートン序列/順序(Morton Order)」
呼び、これによって生成された番号を「モートンコード(Morton Code)」と呼ぶ


分割をさらにもう一層深くすると、上記のようになる。
この時の親と子10進数番号をそれぞれの番号を3ビットの2進数表現にする。
例) 親:1 子:5 => 親:001 子:101

この時の3ビットにしたのは 8分割を表現(0~7)するのに必要なビット数であるため。
そのため4分木の場合は2ビットになる。


親のモートンコードと子のモートンコードをつなげて、それを10進数表現に変換すると子の番号になる
これは分割の層が深くなっても変わらない。
また、逆にいえば子のモートンコードから3ビット減らすと親のモートンコードになる。

座標をモートンコードに変換

前提内容

座標(XYZ)は基本的にどの方向の値も0以上である必要がある。
これは負の値のビット表現が原因で変換計算/処理で都合が悪いためだ。 基本的に負の表現ができる(signed)の値型は最上ビットが符号に利用される。(0:正 1:負)
そして、以下のビットは正と負と正で異なることはない、 これが原因でこの後行うモートンコードへの変換しても負、正で同じになってしまう。
int(32)の例)
5:0000 0000 0000 0101  -5:1000 0000 0000 0101

最小ノード単位の整数部の取得

モートンコード生成にはそのOctreeの最小ノードの単位長で割った値の整数部を利用する。
これは最小ノード単位のどのノードにその座標が所属しているかを求めるものだ。

単位長(ベクトル)は「ルートノードのサイズ 」 /  2の「最小ノードまでの階層(分割回数)」乗 で求まる。
例)
サイズ(X: 1000, Y: 800, Z: 700) 分割回数: 4
(X: 1000, Y: 800, Z: 700) / pow(2, 4) = (X: 62.5, Y: 50.0, Z: 43.75)

対象の座標を単位長で割る
※当たり前だが、ルートノードのサイズに収まる座標値であることが前提である。
例)
座標(X: 300, Y: 400, Z: 50) 単位長 (X: 62.5, Y: 50.0, Z: 43.75)
(X: 300, Y: 400, Z: 50) / (X: 62.5, Y: 50.0, Z: 43.75) = (X: 4.8, Y: 8.0, Z: 1.14)

単位長で割ったXYZの整数部のみを利用する。
この整数部のみとは、四捨五入をせず小数点以下は切り捨てのことを意味する。

例) (X: 4.8, Y: 8.0, Z: 1.14) => (X: 4, Y: 8, Z: 1)

整数部からモートンコード(ノードID)を生成

整数部の各XYZをそれぞれ2進数変換する表現にする。

32ビット 例)
(X: 4, Y: 8, Z: 1) =>
X: 00000000 00000000 00000000 00000100
Y: 00000000 00000000 00000000 00001000
Z: 00000000 00000000 00000000 00000001

2進数(ビット)表現にした値の下位ビットから順に各ビットの間に2ビットの間隔(0の加算)をあける。
これは次元の数で決まり、4分木(2D)の場合では1ビットの間隔をあける。
例)
X: 00000000 00000000 00000000 00000100
Y: 00000000 00000000 00000000 00001000
Z: 00000000 00000000 00000000 00000001

X: 00000000 00000000 00000000 01000000
Y: 00000000 00000000 00000010 00000000
Z: 00000000 00000000 00000000 00000001

この2ビットの間隔をあける方法は、コードでは以下の様にすることでできる。

static constexpr uint32 MortonBitSpaceOrganize(const uint32 rawBit)
{
    auto result = rawBit;
    result = (result | (result << 16)) & 0x00FF00FF;
    result = (result | (result << 8)) & 0x0300F00F;
    result = (result | (result << 4)) & 0x030c30c3;
    result = (result | (result << 2)) & 0x09249249;
    return result;
}   

間隔をあけたZの値のビット全体を左に2つシフト、Yの値のビット全体を1ビットシフトする。
例)
X: 00000000 00000000 00000000 01000000
Y: 00000000 00000000 00000010 00000000
Z: 00000000 00000000 00000000 00000001

X: 00000000 00000000 00000000 01000000
Y: 00000000 00000000 00000100 00000000
Z: 00000000 00000000 00000000 00000100

XYZをORすることで、モートンコードとなる。 3ビット単位でZYXの並びになっている。
例)
X: 00000000 00000000 00000000 01000000
Y: 00000000 00000000 00000100 00000000
Z: 00000000 00000000 00000000 00000100

MortonCode:
 00000000 00000000 00000100 01000100

00000000 00000000 00000100 01000100   =>  1092(座標が所属するノードID)

実践検証動画

赤いボックスが現在キャラクタの中心座標が所属するノード

こんな簡単に分割された空間の所属位置がわかるなんて。。。感動!!
と、これを初めて知った当時は衝撃を受けた。

二点の座標から所属ノードを検索

二点それぞれをのモートンコードを生成する。
例)
座標A (X: 300, Y: 400, Z: 50) 座標B (X: 400, Y: 500, Z: 100)

座標A : 00000000 00000000 00000100 01000100
座標B : 00000000 00000000 00000100 01111000

2つのモートンコードをXOR(不一致、排他的論理和)して一つのビット値にする。
※XORとは値が一致しないビットを1とする演算処理のこと。
例)
座標A : 00000000 00000000 00000100 01000100
座標B : 00000000 00000000 00000100 01111000

XORビット:00000000 00000000 00000000 00111100

XORしたビットの上位からチェックして、下位に向かって0ではないビットになるまでマスク(1)をつくる。
※0以降は0となる。
例)
00000000 00000000 00000000 00111100

11111111 11111111 11111111 11000000

マスクと座標AまたはBどちらかのモートンコードをANDすることで、
その二点の座標が所属するモートンコードが生成される。
例)
座標A: 00000000 00000000 00000100 01000100
マスク: 11111111 11111111 11111111 11000000

00000000 00000000 0000100 01000000

ANDビットのうち、マスクで0の領域は右シフト。
ANDビット: 00000000 00000000 0000100 01000000
マスク: 11111111 11111111 11111111 11000000

↓ (6ビット右シフト)

00000000 00000000 00000000 00010001

生成されたモートンコードを読んでみる

上記の「分割とノードのモートンコード」で記述したが、8分木のノードのモートンコードは3ビット(0~7)で表現する。
そのためモートンコードは3ビット単位で各層のモートンコード表現となっている。
下位ビットから上位ビットにむかって、「3 * 分割回数」ビット領域が最小層のモートンコードを示している。
例)
4回分割 = 「3 * 4 」ビット = 12ビット
00000000 00000000 0000100 01000000

3ビットずつ右シフトすると層が上がっていく。

上記でマスクの「0」領域分右シフト(6ビット右シフト)したので、最小層から2つ上の層のモートンコードであることがわかる。

実践検証動画

青いボックスが現在の青い二点が所属するノード

これで簡単にスケールを考慮した所属ノードや、二つのオブジェクト位置が所属するノードが分かるようになった!?
ってことで、これまた当時衝撃を受けました。
これで大幅に応用できるようになり様々な場所で利用されるようになったということですね~。

Octreeをつかって遊ぶ

Octreeの領域に接触するオブジェクトをモートンコードをKeyにしてハッシュ。
そのオブジェクトがアクティブになってほしい領域を決める二点をモートンコードにして
それをもとにハッシュしたオブジェクトのアクティブを操作する。

実践検証動画

二点が所属するノード を基準に周辺のStaticMeshの表示を操作している。

参考資料

該当わかりやすい解説サイト

marupeke296.com

edom18.hateblo.jp

CEDiL

cedil.cesa.or.jp

今回の検証リポジトリ

github.com

あとがき

もともと突発的にAdCに書くつもりのなんとなくやってみた内容でしたが、 Qiita垢つくるの渋ってたらAdC枠埋まってましたw 来年は小ネタでも他の人がやらないような内容を11月ごろに考えておいて、枠も早めに確保しようかと。。w まあ、気分でめんどくなってやらない可能性も高いですが。

UEのGASのAbilityについてのメモ

Ability(GameplayAbility)とは

UnrealEngineのGASの機能の一つで、技・行動等の内容定義
効果の生存時間、効果内容の適用タイミング、タグの付与、攻撃等のイベント発生の内容を定義をする
GameplayAbility(UGameplayAbility)クラスをベースとして作る
公式リファレンス

設定項目は以下の要素がある(C++での定義例あり)
※ver UE 5.3

項目名 定義内容
Tags アビリティを実行する上での依存するTag
Input アビリティの実行タイミング
Advanced アビリティの実行方法
Costs アビリティ実行による消費
Triggers アビリティ実行を紐づけるトリガー
Cooldown 次のアビリティ実行可能までの待機

Tags

Ability Tags

アビリティ自体に付与されるタグ

Cancel Abilities With Tag

該当のタグを持つすべてのアクティブなアビリティを中断

Block Abilities With Tag

このアビリティがアクティブな間、該当のタグを持つすべてのアビリティを実行させない

Activation Owned Tags

アビリティがアクティブな間、アビリティ所有者に適用されるタグ

Activation Required Tags

該当のタグを持つアビリティがアクティブな場合、このアビリティを実行する

Activation Blocked Tags

該当のタグを持つアビリティがアクティブな場合、このアビリティを実行しない

Source Required Tags

発動者が該当のタグを持つ場合、このアビリティを実行する

Source Blocked Tags

発動者が該当のタグを持つ場合、このアビリティを実行しない

Target Required Tags

適用者が該当のタグを持つ場合、このアビリティを実行する

Target Blocked Tags

適用者が該当のタグを持つ場合、このアビリティを実行しない

AbilityTags.AddTag([Tag]);
CancelAbilitiesWithTag.AddTag([Tag]);
BlockAbilitiesWithTag.AddTag([Tag]);
ActivationOwnedTags.AddTag([Tag]);
ActivationRequiredTags.AddTag([Tag]);
ActivationBlockedTags.AddTag([Tag]);
SourceRequiredTags.AddTag([Tag]);
SourceBlockedTags.AddTag([Tag]);
TargetRequiredTags.AddTag([Tag]);
TargetBlockedTags.AddTag([Tag]);

Input

Replicate Input Directly

直接サーバー側に入力と同時にアビリティ実行処理を行う。
通常入力を受けて、ローカルで処理を行った後にサーバーへの処理に移行するが、
これにより並行して処理することができ、通信下での遅延が減る。ただ、処理コストは増える

bReplicateInputDirectly = true;

Advanced

Replicate Policy

アビリティの実行処理同期設定
アビリティ実行をサーバー側(他プレイヤー)でも行う

項目名 内容
Do Not Replicate サーバーに同期しない。
コード上では、EGameplayAbilityReplicationPolicy::ReplicateNo
Replicate サーバーに同期する。
コード上では、EGameplayAbilityReplicationPolicy::ReplicateYes

Instancing Policy

アビリティのインスタンス(生成、破棄)設定

項目名 内容
Non Instanced アビリティの種類単位で同一のアビリティデータを共有。
メモリ効率が良いが、アビリティの状態を個別で管理できない。
Instanced Per Actor 各アクター(キャラクターなど)ごとに一度だけインスタンス化される。
各アクター単位でアビリティの状態管理ができる。
Instanced Per Execution アビリティ実行単位でインスタンス化される。
メモリ効率が悪いが、各実行単位でアビリティの状態管理ができ、同じアビリティを同時に複数回実行することが可能。

Server Respects Remote Ability Cancellation

クライアントのアビリティキャンセルをサーバー側も同期してキャンセルを行う

Retrigger Instanced Ability

アクティブなアビリティを再度発動可能(再使用速度の向上)にする

Net Executeion Policy

アビリティの実行がクライアント、サーバーでどのように処理されるかの設定
クライアント寄りだと、データの一貫性が保ちにくくなるが遅延が少ない。
サーバー寄りだと、データの一貫性が保ちやすいが遅延が多くなる。

項目名 内容
Local Predicated アビリティはクライアントで予測的に実行する。
その後サーバーによる確認が行われて、一貫性を保つ。
Local Only アビリティはクライアントでのみ実行され、サーバー(他プレイヤー)には影響を与えない。
Server Initiated アビリティはサーバーによって実行する。
サーバーがアビリティのトリガーを管理しつつ、クライアント側でのレスポンスも許可する。
※実行はクライアントでも行われる可能性がある。
Server Only アビリティはサーバーでのみ実行され、その後クライアントに結果を同期する。

Net Security Policy

アビリティのクライアント、サーバーでの実行、同期制御内容の設定

項目名 内容
Client Or Server アビリティ実行やキャンセルがクライアントとサーバーのどちらからでも可能。
不正行為やエラーに対して脆弱になる可能性がある。
Server Only Execution サーバー側でのみアビリティ実行可能。
結果をクライアントに同期、データの整合性を保つ。
Server Only Termination アビリティ実行はサーバーから開始し、その後の処理がクライアントで行われる。
サーバーのオーソリティとクライアントのレスポンス性をバランス良く行う。
Server Only サーバー側でのみアビリティ実行可能。
結果はクライアントに同期されず、サーバー側でのみ持つ。データの整合性。

ReplicationPolicy = EGameplayAbilityReplicationPolicy::ReplicateYes;
InstancingPolicy = EGameplayAbilityInstancingPolicy::InstancedPerActor;
bServerRespectsRemoteAbilityCancellation = true;
bRetriggerInstancedAbility = true;
NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::LocalPredicted;
NetSecurityPolicy = EGameplayAbilityNetSecurityPolicy::ClientOrServer;

Costs

Cost Gameplay Effect Class

アビリティの消費(コスト)のGmeplayEffect

CostGameplayEffectClass = [TSubclassOf<UGameplayEffect>];

Triggers

Ability Triggers

Trigger Tag
トリガータグ。このタグをもとにほかのアビリティを実行。

Trigger Source
トリガー、実行の条件。

項目名 内容
Gameplay Event 指定されたタグがEventTagのGameplayEvent(FGameplayEventData)の実行でトリガー
Owned Tag Added 指定されたタグが対象に新たに追加された瞬間にトリガー
Owned Tag Present 指定されたタグが対象に存在している場合、トリガー、実行可能

※Source Required TagsとOwned Tag Presentは似ているが、
Sourceはアビリティを所持しているActor本体がそのタグを持っているか(武器、防具等) Owned Tag Presentはアビリティを所持しているActorのOwnerがそのタグを所持しているか(武器、防具の使用者等) というタグ所持の確認する対象の違いがある。

FAbilityTriggerData TriggerData;
TriggerData.TriggerTag = [Tag];
TriggerData.TriggerSource = EGameplayAbilityTriggerSource::GameplayEvent;
AbilityTriggers.Add(TriggerData);

Cooldown

Cooldown Gameplay Effect Class

アビリティの再使用までの待機(クールタイム)のGmeplayEffect

CooldownGameplayEffectClass = [TSubclassOf<UGameplayEffect>];

UEのGameplayAbilitySystem(基本)についてのメモ

GameplayAbilitySystem(GAS)とは

UnrealEngineの公式機能のひとつで、ゲーム上でのやり取りを楽に実装することができる機能

機能、要素について

利用する上でGAS固有の以下のものがある
※すべて使わなくても問題なし

GameplayAttribute(属性)

アクターのステータスを管理するために使用されます。
UAttributeSetクラスを用いて定義

例: スタミナ、攻撃力、防御力など。

GameplayAbility(アビリティ)

アクターが行う、アクションやスキル等の内容を定義 UGameplayAbilityクラスを用いて定義

例: 攻撃、スプリント、スペルキャスティングなど。

GameplayEffect(エフェクト)

アクターの種類、属性、ステータス等に対する変化や効果を定義 UGameplayEffectクラスを用いて定義

例: ダメージ、バフ、デバフ、継続ダメージなど。

GameplayTag(タグ)

アクターに付与する状態、属性等の種類の識別IDをタグで定義 FGameplayTag構造体を用いて定義

例: 「状態.毒」、「状態.スタン」、「効果.防御アップ」など。

GameplayCue(キュー)

エフェクト適用によるタグ変化に対するビジュアルや音声のフィードバックをトリガーするために定義 GameplayCueNotify_Static、GameplayCueNotify_Actorを用いて定義

例:ヒットエフェクト、バフのオーラエフェクト、ダメージ音など。

GameplayEvent(イベント)

特定のイベントが発生したことを通知し、アビリティや効果をトリガーするために使用 FGameplayEventDataを用いて定義

例: 特定条件のアビリティ発動する、特の地点でのイベントを発生など。

AbilitySystemComponent(コンポーネント)

Abilityの管理、Effectの適用、Eventの発生、Attuributeの管理を内容を定義します UAbilitySystemComponentを用いて定義。

フロー

※GameplayTask(タスク)という似た名前の機能もあるが、これはGASとは別の機能で非同期タスクの機能

実践/実装

環境構築

ver 5.4.1
First Person テンプレート
C++(20)
※既存のコードのフォーマットに則り、コードを記述しているため パスカルケースでの変数、関数等にする
※追加するパラメータのカテゴリはすべてGASに統一

プロジェクトの作成

プラグインの追加

Gameplay Abilities プラグインの追加はEditor再起動により反映されます。

モジュールの追加

ビルドファイルの依存モジュール名配列に”GameplayTasks”と”GameplayAbilities”を追加

AbilitySystemComponentの作成/追加

AbilitySystemComponent(UAbilitySystemComponent)を継承した独自のAbilitySystemComponentを作成

First Person テンプレートのデフォルトで生成されるキャラクターのファイルを開く。
※このプロジェクトの場合 GAS_TestCharacter

キャラクターに、作成したAbilitySystemComponentのメンバを定義。

protected:
    UPROPERTY(EditAnywhere, Category = GAS)
    UPlayerAbilitySystemComponent* AbilitySystemComponent;

コンストラクタにて、コンポーネント生成を記述

AbilitySystemComponent = CreateDefaultSubobject<UPlayerAbilitySystemComponent>(TEXT("PlayerAbilitySystemComponent"));

BeginPlayにて、初期化を記述

void AGAS_TestCharacter::BeginPlay()
{
    Super::BeginPlay();

    AbilitySystemComponent->InitAbilityActorInfo(this, this);
}

コンポーネントが追加されたことを確認。

AttributeSetの作成/追加

AttributeSet(UAttributeSet)を継承した独自のAttributeSetを作成

Playerの武器の最大弾数、残弾数のAttribute(属性)を追加、定義

public:
    ATTRIBUTE(UPlayerAttributeSet, MaxBalletCount)
    ATTRIBUTE(UPlayerAttributeSet, BalletCount)

public:
    UPROPERTY(BlueprintReadOnly, Category = "Player Attributes")
    FGameplayAttributeData MaxBalletCount;

    UPROPERTY(BlueprintReadOnly, Category = "Player Attributes")
    FGameplayAttributeData BalletCount;

※Attribute定義には以下の複数のマクロを利用する必要がありますが、めんどくさいのでマクロで全部まとめています。

#define ATTRIBUTE(ClassName, PropertyName) \
   GAMEPLAYATTRIBUTE_PROPERTY_GETTER(ClassName, PropertyName)\
   GAMEPLAYATTRIBUTE_VALUE_GETTER(PropertyName)\
   GAMEPLAYATTRIBUTE_VALUE_SETTER(PropertyName)\
   GAMEPLAYATTRIBUTE_VALUE_INITTER(PropertyName)

AttributeSetのコンストラクタにて、属性値の初期化

UPlayerAttributeSet::UPlayerAttributeSet()
{
    MaxBulletCount = 5;
    BulletCount = MaxBulletCount;
}

AbilitySystemComponent同様にCharacterに独自に作成したAttributeSetの 定義と生成の記述

protected:
    UPROPERTY(EditAnywhere, Category = GAS)
    UPlayerAttributeSet* AttributeSet;
AttributeSet = CreateDefaultSubobject<UPlayerAttributeSet>(TEXT("PlayerAttributeSet"));

追加されたことを確認。

Abilityの作成/追加

Abilityの基本的な作成/設定は以下にまとめている shiromisasami.hatenablog.com

GameplayAbilityt(UGameplayAbility)を継承した独自のAbilityを作成

CharacterにAbilitySystemComponentに追加するAbilityの配列追加

protected:
    UPROPERTY(EditAnywhere, Category = GAS)
    TArray<TSubclassOf<UGameplayAbility>> Abilities;

CharacterのBeginPlayにてAbilitySystemComponentにAbilityの登録を記述

//型はTSubclassOf<UGameplayAbility>
for (auto ability : Abilities)
{
    AbilitySystemComponent->GiveAbility(FGameplayAbilitySpec(ability, 1, 0));
}

CharacterにAbilitySystemComponentのGetter追加

public:
    FORCEINLINE UPlayerAbilitySystemComponent* GetAbilitySystemComponent() const { return AbilitySystemComponent; }

UTP_WeaponComponentのメンバに発動Abilityクラスを定義

private:
    UPROPERTY(EditAnywhere, Category = GAS)
    TSubclassOf<UGameplayAbility> ProjectileAbilityClass;

既存の弾発射処理(UTP_WeaponComponent::Fire)にアビリティ発動処理を記述

//型はUPlayerAbilitySystemComponent
if (auto* asc = Character->GetAbilitySystemComponent())
{
    //アビリティを発動する
    asc->TryActivateAbilityByClass(FireAbility);
}

以下がUTP_WeaponComponent::Fireに記載されている、弾,音,アニメーションの生成/設定をProjectileAbilityのActivateAbilityに移動記述する

// Try and fire a projectile
if (ProjectileClass != nullptr)
{
    UWorld* const World = GetWorld();
    if (World != nullptr)
    {
        APlayerController* PlayerController = Cast<APlayerController>(Character->GetController());
        const FRotator SpawnRotation = PlayerController->PlayerCameraManager->GetCameraRotation();
        // MuzzleOffset is in camera space, so transform it to world space before offsetting from the character location to find the final muzzle position
        const FVector SpawnLocation = GetOwner()->GetActorLocation() + SpawnRotation.RotateVector(MuzzleOffset);

        //Set Spawn Collision Handling Override
        FActorSpawnParameters ActorSpawnParams;
        ActorSpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButDontSpawnIfColliding;

        // Spawn the projectile at the muzzle
        World->SpawnActor<AGAS_TestProjectile>(ProjectileClass, SpawnLocation, SpawnRotation, ActorSpawnParams);
    }
}

// Try and play the sound if specified
if (FireSound != nullptr)
{
    UGameplayStatics::PlaySoundAtLocation(this, FireSound, Character->GetActorLocation());
}

// Try and play a firing animation if specified
if (FireAnimation != nullptr)
{
    // Get the animation object for the arms mesh
    UAnimInstance* AnimInstance = Character->GetMesh1P()->GetAnimInstance();
    if (AnimInstance != nullptr)
    {
        AnimInstance->Montage_Play(FireAnimation, 1.f);
    }
}

Abilityに移行するにあたり必要なメンバ変数の定義

protected:
    UPROPERTY(EditDefaultsOnly, Category = GAS)
    TSubclassOf<class AGAS_TestProjectile> ProjectileClass;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = GAS)
    USoundBase* FireSound;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = GAS)
    UAnimMontage* FireAnimation;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = GAS)
    FVector MuzzleOffset;

コンストラクタでMuzzleOffsetの初期値設定

UProjectileAbility::UProjectileAbility()
{
    MuzzleOffset = FVector(100.0f, 0.0f, 10.0f);
}

AbilityにActivateAbilityをオーバーライド定義

public:
    virtual void ActivateAbility(
        const FGameplayAbilitySpecHandle Handle,
        const FGameplayAbilityActorInfo* ActorInfo,
        const FGameplayAbilityActivationInfo ActivationInfo, 
        const FGameplayEventData* TriggerEventData) override;

弾,音,アニメーションの生成の生成/設定をAbilityに合わせて記述,

void UProjectileAbility::ActivateAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, const FGameplayEventData* TriggerEventData)
{
    Super::ActivateAbility(Handle, ActorInfo, ActivationInfo, TriggerEventData);

    if (ProjectileClass)
    {
        UWorld* const World = GetWorld();
        if (World)
        {
            APlayerController* PlayerController = ActorInfo->PlayerController.Get();
            const FRotator SpawnRotation = PlayerController->PlayerCameraManager->GetCameraRotation();
            const FVector SpawnLocation = ActorInfo->AvatarActor->GetActorLocation() + SpawnRotation.RotateVector(MuzzleOffset);

            FActorSpawnParameters ActorSpawnParams;
            ActorSpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButDontSpawnIfColliding;
            World->SpawnActor<AGAS_TestProjectile>(ProjectileClass, SpawnLocation, SpawnRotation, ActorSpawnParams);
        }
    }

    if (FireSound)
    {
        UGameplayStatics::PlaySoundAtLocation(this, FireSound, ActorInfo->AvatarActor->GetActorLocation());
    }

    AGAS_TestCharacter* Character = Cast<AGAS_TestCharacter>(ActorInfo->AvatarActor);
    if (FireAnimation && Character)
    {
        UAnimInstance* AnimInstance = Character->GetMesh1P()->GetAnimInstance();
        if (AnimInstance)
        {
            AnimInstance->Montage_Play(FireAnimation, 1.f);
        }
    }

  EndAbility(Handle, ActorInfo, ActivationInfo, false, false);
}

AbilityのBPを作り、弾,音,アニメーションの設定

CharacterのAbilitiesに作ったAbilityのBPを設定

武器(BP_PickUp_Rifle)のWeaponComponentのProjectileAbilityClassを設定

ゲーム実行を行い、ロジックをAbility移行前と同じ挙動になっているか確認

Tagの追加

shiromisasami.hatenablog.com
上記の手法より、以下のTagを作成

Tag名 利用内容
Player.FireWait 弾の発射待機
Projectile.Hit 弾の接触
GameplayCue.Player.Reload 弾のリロード
Effectの作成/追加

Effectの作成/設定はいろいろあるので、以下にまとめている shiromisasami.hatenablog.com

GameplayEffect(UGameplayEffect)を継承した独自のEffectを2つ作成

特に複雑なロジックを組むことがない限りは基本Effectは既存機能で十分なためBPのを推奨

一つは残弾数の属性値を変更するエフェクト(GE_Fire)を作成

もう一つはタグ付与するエフェクト(GE_FireWait)を作成

C++でも以下の様に設定できる
FireEffect

#include "PlayerAttributeSet.h"

UFireEffect::UFireEffect()
{
    DurationPolicy = EGameplayEffectDurationType::Instant;
    // BulletCountを減らすModifierを設定
    FGameplayModifierInfo ModifierInfo;
    ModifierInfo.Attribute = UPlayerAttributeSet::GetBulletCountAttribute();
    ModifierInfo.ModifierOp = EGameplayModOp::Additive;
    ModifierInfo.ModifierMagnitude = FScalableFloat(-1.0f); // BulletCountを1減らす
    Modifiers.Add(ModifierInfo);
}

FireWaitEffect

#include "NativeTags.h"
#include "GameplayEffectComponents/TargetTagsGameplayEffectComponent.h"

UFireWaitEffect::UFireWaitEffect()
{
    DurationPolicy = EGameplayEffectDurationType::HasDuration;
    DurationMagnitude = FGameplayEffectModifierMagnitude(FScalableFloat(5.0f));

    UTargetTagsGameplayEffectComponent* TargetTagsComponent = CreateDefaultSubobject<UTargetTagsGameplayEffectComponent>(TEXT("TargetTagsComponent"));
    FInheritedTagContainer TagContainer;
    TagContainer.AddTag(FNativeTags::Get().FireWait);
    TargetTagsComponent->SetAndApplyTargetTagChanges(TagContainer);
    GEComponents.Add(TargetTagsComponent);

    FGameplayEffectCue ReloadCue = FGameplayEffectCue(FNativeTags::Get().Reload, 0, 0);
    GameplayCues.Add(ReloadCue);
}
Effectの適用

Abilityに適用する効果の定義メンバを追加

protected:
    UPROPERTY(EditDefaultsOnly, Category = GAS)
    TSubclassOf<UGameplayEffect> FireEffect;

    UPROPERTY(EditDefaultsOnly, Category = GAS)
    TSubclassOf<UGameplayEffect> FireWaitEffect;

BPにて、作成したEffectを2つを設定

Ability処理に追加でEffect適用の記述とブロックタグの設定

コンストラクタにて、ブロックタグを設定

ActivationBlockedTags.AddTag(FNativeTags::Get().FireWait);

以前の弾発射処理の後に以下のEffect適用記述を追加

if (UPlayerAbilitySystemComponent* ASC = Character->GetAbilitySystemComponent())
{
    // エフェクトスペックを作成
    FGameplayEffectContextHandle EffectContext = MakeEffectContext(Handle, ActorInfo);
    FGameplayEffectSpecHandle FireHandle = ASC->MakeOutgoingSpec(FireEffect, GetAbilityLevel(), EffectContext);

    if (FireHandle.IsValid())
    {
        // BullectCountの減少効果を適用
        ASC->ApplyGameplayEffectSpecToSelf(*FireHandle.Data.Get());
    }

    if (ASC->GetNumericAttribute(UPlayerAttributeSet::GetBulletCountAttribute()) < 1)
    {
        FGameplayEffectSpecHandle FireWaitHandle = ASC->MakeOutgoingSpec(FireWaitEffect, GetAbilityLevel(), EffectContext);

        if (FireWaitHandle.IsValid())
        {
            // 発射待機と、リロード処理キューのタグ付与効果を適用
            ASC->ApplyGameplayEffectSpecToSelf(*FireWaitHandle.Data.Get());
        }
    }
}

※上記内容はあくまで基本を示したものです。
アビリティの既存機能でコストとクールダウン用のエフェクト枠は備わっているため、本来はそこに当て設定するのが良い

Cueの作成/追加

Cueには大きく分けて
「GameplayCueNotify_Static」と「GameplayCueNotify_Actor」の二種類がある。

種類 イベント GameplayEffectのタイプ 内容
GameplayCueNotify_Static Execute InstantまたはPeriodic インスタンスなしでGameplayCueNotifies動作し、ヒット インパクトなどの 1 回限りのエフェクト
GameplayCueNotify_Actor AddまたはRemove DurationまたはInfinite GameplayCueNotifiesのときに新しいインスタンスを生成。付与タグの削除や手動で削除を呼び出すことによって削除されるサウンドやパーティクル効果をループさせるのに適している。同時に許可される数を管理するオプションも付属しており、同じ効果を複数回適用してもサウンドやパーティクルが 1 回だけ開始される


今回はReloadキュータグが付与された時に一括で全弾のリロードをするCueなので
「GameplayCueNotify_Static」 でCueを作成する。
BPのCueは管理されており、Effect適用で該当タグを付与した際にそのタグに紐づけられたキューが実行される。
C++クラスのままだと認識されないので注意 ReloadCue.h

UCLASS()
class GAS_TEST_API UReloadCue : public UGameplayCueNotify_Static
{
    GENERATED_BODY()
public:
    UReloadCue();

protected:
    virtual void HandleGameplayCue(AActor* MyTarget, EGameplayCueEvent::Type EventType, const FGameplayCueParameters& Parameters) override;
};

ReloadCue.cpp

UReloadCue::UReloadCue()
{
    GameplayCueTag = FNativeTags::Get().Reload;
}

void UReloadCue::HandleGameplayCue(AActor* MyTarget, EGameplayCueEvent::Type EventType, const FGameplayCueParameters& Parameters)
{
    Super::HandleGameplayCue(MyTarget, EventType, Parameters);

    if (EventType == EGameplayCueEvent::OnActive)
    {
        if (AGAS_TestCharacter* Character = Cast<AGAS_TestCharacter>(MyTarget))
        {
            UPlayerAbilitySystemComponent* ASC = Character->GetAbilitySystemComponent();
            int maxBulletCount = ASC->GetNumericAttribute(UPlayerAttributeSet::GetMaxBulletCountAttribute());
            ASC->ApplyModToAttribute(UPlayerAttributeSet::GetBulletCountAttribute(), EGameplayModOp::Override, maxBulletCount);
        }
    }
}


※GameplayCueTag の設定はかみ合いが悪い感じがしたので、BPでの設定のほうが良いかもしれない

ゲーム実行を行い、リロード待機と弾数の切り替えが想定通りの挙動になっているか確認
「showdebug abilitysystem」コマンドをつかうことで、現在のAttributeやTagが可視化できる

弾丸接触Abilityの作成/追加

「Abilityの作成/追加」で行った手順で「HitAbility」を作成
コンストラクタにてAbilityTriggersに紐づけるTagを設定したTriggerDataを追加   Abilityの処理内容は弾丸発射者と弾丸接触者のログ出力

#include "HitAbility.h"
#include "AbilitySystemComponent.h"
#include "NativeTags.h"

UHitAbility::UHitAbility()
{
    FAbilityTriggerData TriggerData;
    TriggerData.TriggerTag = FNativeTags::Get().Hit;
    AbilityTriggers.Add(TriggerData);
}

void UHitAbility::ActivateAbility(
    const FGameplayAbilitySpecHandle Handle, 
    const FGameplayAbilityActorInfo* ActorInfo, 
    const FGameplayAbilityActivationInfo ActivationInfo, 
    const FGameplayEventData* TriggerEventData)
{
    Super::ActivateAbility(Handle, ActorInfo, ActivationInfo, TriggerEventData);

    const AActor* Source = TriggerEventData->Instigator.Get();
    const AActor* Target = TriggerEventData->Target.Get();

    FString Message = FString::Printf(TEXT("Hit!!  Source:%s  Target:%s"), *Source->GetName(), *Target->GetName());
    GEngine->AddOnScreenDebugMessage(-1, 3.f, FColor::Red, Message);
    

    EndAbility(Handle, ActorInfo, ActivationInfo, false, false);
}
弾丸接触Abilityの発動設定

ProjectileAbilityのProjectile生成部分にて、そのProjectileのOwnerとInstigatorをAbility発動者に設定する。
※以降で利用するのはInstigatorのみ

FActorSpawnParameters ActorSpawnParams;
if (AGAS_TestCharacter* Pawn = Cast<AGAS_TestCharacter>(ActorInfo->AvatarActor))
{
    //弾丸の発射者を設定
    ActorSpawnParams.Owner = Pawn;
    ActorSpawnParams.Instigator = Pawn;
}
ActorSpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButDontSpawnIfColliding;
AGAS_TestProjectile* Projectile = World->SpawnActor<AGAS_TestProjectile>(ProjectileClass, SpawnLocation, SpawnRotation, ActorSpawnParams);
}

生成するProjectile(GAS_TestProjectile)のOnHitにGameplayEventの生成と実行を追加
内容は、アビリティ発動者をInstigator、接触者をTarget、TriggerタグをProjectile.Hit

if (AGAS_TestCharacter* SourceCharacter = Cast<AGAS_TestCharacter>(GetInstigator()))
{
    UPlayerAbilitySystemComponent* SourceASC = SourceCharacter->GetAbilitySystemComponent();
    FGameplayEventData Payload;
    Payload.EventMagnitude = 1.0f;
    Payload.Instigator = GetInstigator();
    Payload.Target = OtherActor;
    Payload.OptionalObject = this;
    Payload.ContextHandle = SourceASC->MakeEffectContext();
    SourceASC->HandleGameplayEvent(FNativeTags::Get().Hit, &Payload);
}

BP_FirstPersonCharacterのAbilitesにHitAbilityを追加

動作確認
ゲーム実行を行い、弾丸がなにかに接触するごとにログが出現することを確認

おまけ

管理するCueのディレクトリ設定

デフォルトで管理するCue(BP)の参照は「/Game/」になっており、Contentフォルダ下にあれば 管理対象となる。
ただ、デフォルトだと参照範囲広すぎてフォルダがふえて階層が広く深くなった場合に、読み込みが重くなる。
また、気づきずらいがゲーム実行するとWarningが出ている。

以下の様に方法でファイルパス指定をすることで、管理(読み込む)Cueの範囲を指定が可能

iniファイルによる設定

DefaultGame.ini

[/Script/GameplayAbilities.AbilitySystemGlobals]
+GameplayCueNotifyPaths=/Game/FirstPerson/Blueprints/GAS/

コードによる設定

#include "AbilitySystemGlobals.h"

// AbilitySystemGlobalsのインスタンスを取得
UAbilitySystemGlobals& AbilitySystemGlobals = UAbilitySystemGlobals::Get();

// GameplayCueNotifyPathsの設定を追加
AbilitySystemGlobals.AddGameplayCueNotifyPath(TEXT("/Game/FirstPerson/Blueprints/GAS/"));
コードによるiniファイルへの項目設定
#include "Misc/ConfigCacheIni.h"

// iniファイルパスの定義
FString ConfigFilePath = FPaths::ProjectConfigDir() / "DefaultGame.ini";

// セクション名、キー、値の定義
FString Section = TEXT("/Script/GameplayAbilities.AbilitySystemGlobals");
FString Key = TEXT("+GameplayCueNotifyPaths");
FString Value = TEXT("/Game/FirstPerson/Blueprints/GAS/");

// ini ファイルに設定を追加
GConfig->SetString(*Section, *Key, *Value, ConfigFilePath);

// 変更を保存
GConfig->Flush(false, ConfigFilePath);

活用タイトル

参考資料

github.com

以下は今回作成したサンプルプロジェクトです
github.com

UEのGASのEffectについてのメモ

Effect(GameplayEffect)とは

UnrealEngineのGASの機能の一つで、ターゲット者に適用する効果内容の定義 効果の生存時間、効果内容の適用タイミング、タグの付与、攻撃等のイベント発生の内容を定義をする
GameplayEffect(UGameplayEffect)クラスをベースとして作る
公式リファレンス

設定項目は以下の要素がある(C++での定義例あり)
※ver UE 5.3

項目名 定義内容
Duration 効果持続期間
Period 処理実行頻度
Gameplay Effect 効果中の処理内容
Gameplay Cue 連携するCue
Stacking 既に効果を保持しているターゲットに対する振る舞い

Duration


DurationPolicy(期間の種類)を設定

Instant(即時)

即時適用されて、即時消滅する

DurationPolicy = EGameplayEffectDurationType::Instant;
Infinite (無限)

一定の間隔で適用されて、意図的に削除しない限り消滅しない

DurationPolicy = EGameplayEffectDurationType::Infinite;

static ConstructorHelpers::FObjectFinder<UCurveTable> Curve(TEXT([CurveTablePath]));
if (Curve.Succeeded())
{
    //一括の場合
    Period.SetScalingValue(2.0f, FName([RowName]), Curve.Object);

    //単体の場合
    Period.Value = 2.0f;
    Period.Curve.CurveTable = Curve.Object;
    Period.Curve.RowName = FName([RowName]);
}

bExecutePeriodicEffectOnApplication = false;
PeriodicInhibitionPolicy = EGameplayEffectPeriodInhibitionRemovedPolicy::ResetPeriod;

Period:適用間隔(秒) ※CurveTableはパターン化のための基本値に掛ける係数を管理 Y : 係数(倍率)
Execute Periodic Effect on Application:初回即時適用の有無
Periodic Inhibition Policy:周期的な適用ができなかったときの処理

項目 内容
Never Reset リセットはせず、そのまま続行
Reset Priod 間隔周期をリセット
Execute and Reset Priod 即時実行して、間隔周期をリセット
Has Duration (期間あり)

一定の間隔で適用されて、一定時間後に消滅

Magnitude Calculation Type:期間の種類

  • Scalable Float
    任意の時間効果をつける

Scalable Float Magnitude:時間(秒)
※CurveTableはパターン化のための持続時間の基本値に掛ける係数を管理 Y : 係数(倍率)

static ConstructorHelpers::FObjectFinder<UCurveTable> Curve(TEXT([CurveTablePath]));
if (Curve.Succeeded())
{
    FScalableFloat ScalableDuration(10.f);
    ScalableDuration.Curve.CurveTable = Curve.Object;
    ScalableDuration.Curve.RowName = FName([RowName]);
    DurationMagnitude = FGameplayEffectModifierMagnitude(ScalableDuration);
}
  • Attribute Based
    任意の属性値をもとに効果時間の計算

Coefficient:ベース属性値に掛ける係数
Pre Multiply Additive Value:乗算前にベースに加算される値
Post Multiply Additive Value:乗算前にベースに加算される値
Backing Attribute

Attribute to Capture:対象とする属性の選択
※GAMEPLAYATTRIBUTE_PROPERTY_GETTER(ClassName, PropertyName)を使用して追加した属性が表示される

Attribute Source:属性所持者

項目 属性を持つ対象
Source 発動者
Target 受動者

Snapshot:ベース属性値を計算前の値で固定か可変か

Attribute Curve

Curve Table:属性値をX軸とした基本値(Y)を取得するCurveTable
※X: 属性値 Y : 計算で利用するベース値
RowName:CurveTableのRowName

Attribute Calulation Type:計算方法の種類

Attribute Magnitude:属性値の総計
Attribute Base Value:加算となるものを含まない純粋な属性値
Attribute Bonus Magnitude:加算部分のみの属性値

Source Tag Filter:効果を発生する対象が持つ必要があるTag
Target Tag Filter:効果を受ける対象が持つ必要があるTag

static ConstructorHelpers::FObjectFinder<UCurveTable> Curve(TEXT([CurveTablePath]));
if (Curve.Succeeded())
{
    FAttributeBasedFloat ABF;

    ABF.Coefficient.Value = 10.0f;
    ABF.Coefficient.Curve.CurveTable = Curve.Object;
    ABF.Coefficient.Curve.RowName = FName([RowName]);

    ABF.PreMultiplyAdditiveValue.Value = 10.0f;
    ABF.PreMultiplyAdditiveValue.Curve.CurveTable = Curve.Object;
    ABF.PreMultiplyAdditiveValue.Curve.RowName = FName([RowName]);

    ABF.PostMultiplyAdditiveValue.Value = 10.0f;
    ABF.PostMultiplyAdditiveValue.Curve.CurveTable = Curve.Object;
    ABF.PostMultiplyAdditiveValue.Curve.RowName = FName([RowName]);

    ABF.BackingAttribute.AttributeToCapture = UAbilitySystemComponent::GetOutgoingDurationCapture().AttributeToCapture;
    ABF.BackingAttribute.AttributeSource = EGameplayEffectAttributeCaptureSource::Source;
    ABF.BackingAttribute.bSnapshot = true;

    ABF.AttributeCurve.CurveTable = Curve.Object;
    ABF.AttributeCurve.RowName = FName([RowName]);

    ABF.AttributeCalculationType = EAttributeBasedFloatCalculationType::AttributeBaseValue;
    ABF.SourceTagFilter = FGameplayTagContainer([Tag]);
    ABF.TargetTagFilter = FGameplayTagContainer([Tag]);

    DurationMagnitude = FGameplayEffectModifierMagnitude(ABF);
}
  • Custom Calulation Class
    独自の計算方法で効果時間設定
    Calculation Class :独自処理定義クラス
    Coefficient:ベースとなる効果時間に掛ける係数
    Pre Multiply Additive Value:乗算前にベースに加算される値
    Post Multiply Additive Value:乗算前にベースに加算される値

Final Lookup Curve

Curve Table:計算した値をX軸とした、パターン値(Y)のCurveTable
Row Name:CurveTableのRowName

static ConstructorHelpers::FObjectFinder<UCurveTable> Curve(TEXT([CurveTablePath]));
if (Curve.Succeeded())
{
    FCustomCalculationBasedFloat CCBF;
    //型はUGameplayModMagnitudeCalculation
    CCBF.CalculationClassMagnitude = UCustomCalculation::StaticClass();
    CCBF.Coefficient = 10.0f;
    CCBF.PostMultiplyAdditiveValue = 5.0f;
    CCBF.PreMultiplyAdditiveValue = 10.0f;

    CCBF.FinalLookupCurve.CurveTable = Curve.Object;
    CCBF.FinalLookupCurve.RowName = FName([RowName]);
    DurationMagnitude = FGameplayEffectModifierMagnitude(CCBF);
}
  • Set by Caller
    該当のData(Tag)が呼ばれるまで効果持続
    Data Name :Dataの名前
    Data Tag:DataとなるTag
FSetByCallerFloat SBCF;

SBCF.DataName = FName("FireWait");
SBCF.DataTag = FGameplayTagContainer([Tag]);

DurationMagnitude = FGameplayEffectModifierMagnitude(SBCF);
Period(周期)

適用間隔(秒)
※DurationPolicyが「Instant」では関係なし
※CurveTableはパターン化のための基本値に掛ける係数を管理 Y : 係数(倍率)

Execute Periodic Effect on Application:初回即時適用の有無
Periodic Inhibition Policy:周期的な適用ができなかったときの処理

項目 内容
Never Reset リセットはせず、そのまま続行
Reset Priod 間隔周期をリセット
Execute and Reset Priod 即時実行して、間隔周期をリセット

static ConstructorHelpers::FObjectFinder<UCurveTable> Curve(TEXT([CurveTablePath]));
if (Curve.Succeeded())
{
    Period.Value = 3.0f;
    Period.Curve.CurveTable = Curve.Object;
    Period.Curve.RowName = FName([RowName]);
    bExecutePeriodicEffectOnApplication = true;
    PeriodicInhibitionPolicy = EGameplayEffectPeriodInhibitionRemovedPolicy::ResetPeriod;
}

Gameplay Effect

Components(要件)

効果の適用要件(Component)追加

既存の要件定義は以下のものがある

要件の種類 内容
Apply Additional Effects 他のエフェクトを追加で適用
Block Abilities with Tags 特定のタグを持つアビリティをブロック
Chance To Apply This Effect エフェクトを適用する確率を設定
Custom Can Apply This Effect カスタムロジックでエフェクトの適用
Grant Gameplay Abilities ゲームプレイアビリティを付与
Grant Tags to Target Actor ターゲットアクターにタグを付与
Immunity to Other Effects 他のエフェクトに対する免疫を設定
Remove Other Effects 他のエフェクトを削除
Require Tags to Apply/Continue This Effect このエフェクトの適用・継続に必要なタグを設定
Tags This Effect Has (Asset Tags) このエフェクトが持つタグを設定
Remove Other Effects 他のエフェクトを削除
Require Tags to Apply/Continue This Effect このエフェクトの適用・継続に必要なタグを設定
UI Data (Text Only) テキストのみのUIデータを設定

UGameplayEffectComponentを継承することで、独自のComponentを作ることも可能
※以下の「CustomEffectRequirement」は独自で作ったもの

UCustomEffectRequirement* component = CreateDefaultSubobject<UCustomEffectRequirement>("CustomEffectRequirement");
GEComponents.Add(component);
Modifiers(修飾子)

Attribute(属性)への変更要求

Attribute:変更を加える属性
Modifier Op: 属性値の変更を加え方
Magnitude Calculation Class :量計算方法の種類

  • Scalable Float
    任意の変更量

Scalable Float Magnitude:時間(秒)
※CurveTableはパターン化のための持続時間の基本値に掛ける係数を管理 Y : 係数(倍率)

static ConstructorHelpers::FObjectFinder<UCurveTable> Curve(TEXT([CurveTablePath]));
if (Curve.Succeeded())
{
    FScalableFloat ScalableDuration(10.f);
    ScalableDuration.Curve.CurveTable = Curve.Object;
    ScalableDuration.Curve.RowName = FName([RowName]);

    FGameplayModifierInfo Info;
    Info.ModifierMagnitude = FGameplayEffectModifierMagnitude(ScalableDuration);
    Modifiers.Add(Info);
}
  • Attribute Based
    任意の属性値をもとに変更値の計算

Coefficient:ベースとなる属性値に掛ける係数
Pre Multiply Additive Value:乗算前にベースに加算される値
Post Multiply Additive Value:乗算前にベースに加算される値
Backing Attribute

Attribute to Capture:対象とする属性の選択
※GAMEPLAYATTRIBUTE_PROPERTY_GETTER(ClassName, PropertyName)を使用して追加した属性が表示される

Attribute Source:属性所持者

項目 ベース属性値を持つ対象
Source 発動者
Target 受動者

Snapshot:ベース属性値を計算前の値で固定か可変か

Attribute Curve

Curve Table:属性値をX軸とした基本値(Y)を取得するCurveTable
※X: 属性値 Y : 計算に利用するベース値
RowName:CurveTableのRowName

Attribute Calulation Type:計算方法の種類

Attribute Magnitude:ベース属性値の総計
Attribute Base Value:加算となるものを含まない純粋なベース属性値
Attribute Bonus Magnitude:加算部分のみのベース属性値

Source Tag Filter:効果を発生する対象が持つ必要があるTag
Target Tag Filter:効果を受ける対象が持つ必要があるTag

static ConstructorHelpers::FObjectFinder<UCurveTable> Curve(TEXT([CurveTablePath]));
if (Curve.Succeeded())
{
    FAttributeBasedFloat ABF;

    ABF.Coefficient.Value = 10.0f;
    ABF.Coefficient.Curve.CurveTable = Curve.Object;
    ABF.Coefficient.Curve.RowName = FName([RowName]);

    ABF.PreMultiplyAdditiveValue.Value = 10.0f;
    ABF.PreMultiplyAdditiveValue.Curve.CurveTable = Curve.Object;
    ABF.PreMultiplyAdditiveValue.Curve.RowName = FName([RowName]);

    ABF.PostMultiplyAdditiveValue.Value = 10.0f;
    ABF.PostMultiplyAdditiveValue.Curve.CurveTable = Curve.Object;
    ABF.PostMultiplyAdditiveValue.Curve.RowName = FName([RowName]);

    ABF.BackingAttribute.AttributeToCapture = UAbilitySystemComponent::GetOutgoingDurationCapture().AttributeToCapture;
    ABF.BackingAttribute.AttributeSource = EGameplayEffectAttributeCaptureSource::Source;
    ABF.BackingAttribute.bSnapshot = true;

    ABF.AttributeCurve.CurveTable = Curve.Object;
    ABF.AttributeCurve.RowName = FName([RowName]);

    ABF.AttributeCalculationType = EAttributeBasedFloatCalculationType::AttributeBaseValue;
    ABF.SourceTagFilter = FGameplayTagContainer([Tag]);
    ABF.TargetTagFilter = FGameplayTagContainer([Tag]);

    FGameplayModifierInfo Info;
    Info.ModifierMagnitude = FGameplayEffectModifierMagnitude(ABF);
    Modifiers.Add(Info);
}
  • Custom Calulation Class
    独自の計算方法で属性値設定
    Calculation Class :独自処理定義クラス
    Coefficient:ベース属性値に掛ける係数
    Pre Multiply Additive Value:乗算前にベースに加算される値
    Post Multiply Additive Value:乗算前にベースに加算される値

Final Lookup Curve

Curve Table:計算した属性値をX軸とした、パターン値(Y)のCurveTable
Row Name:CurveTableのRowName

static ConstructorHelpers::FObjectFinder<UCurveTable> Curve(TEXT([CurveTablePath]));
if (Curve.Succeeded())
{
    FCustomCalculationBasedFloat CCBF;
    //型はUGameplayModMagnitudeCalculation
    CCBF.CalculationClassMagnitude = UCustomCalculation::StaticClass();
    CCBF.Coefficient = 10.0f;
    CCBF.PostMultiplyAdditiveValue = 5.0f;
    CCBF.PreMultiplyAdditiveValue = 10.0f;

    CCBF.FinalLookupCurve.CurveTable = Curve.Object;
    CCBF.FinalLookupCurve.RowName = FName([RowName]);

    FGameplayModifierInfo Info;
    Info.ModifierMagnitude = FGameplayEffectModifierMagnitude(CCBF);
    Modifiers.Add(Info);
}
  • Set by Caller
    該当のData(Tag)が呼ばれるまで属性値を変更
    Data Name :Dataの名前
    Data Tag:DataとなるTag
 FSetByCallerFloat SBCF;

    SBCF.DataName = FName("FireWait");
    SBCF.DataTag = FGameplayTagContainer([Tag]);

    FGameplayModifierInfo Info;
    Info.ModifierMagnitude = FGameplayEffectModifierMagnitude(SBCF);
    Modifiers.Add(Info);

Source Tags :属性変更効果発生者のタグ要件
Must Have Tags :必ず所持しているべきタグ
Must Not Have Tags:必ず所持していてはならないタグ Query Must Match:条件を満たす必要があるタグ

Target Tags :属性変更効果適用者のタグ要件
Must Have Tags :必ず所持しているべきタグ
Must Not Have Tags:必ず所持していてはならないタグ
Query Must Match:条件を満たす必要があるタグ

 FGameplayModifierInfo Info;
Info.Attribute = [FGameplayAttribut];
Info.ModifierOp = EGameplayModOp::Override;

static ConstructorHelpers::FObjectFinder<UCurveTable> Curve(TEXT([CurvePath]));
if (Curve.Succeeded())
{
    FScalableFloat ScalableDuration(10.f);
    ScalableDuration.Curve.CurveTable = Curve.Object;
    ScalableDuration.Curve.RowName = FName([RowName]);
    Info.ModifierMagnitude = FGameplayEffectModifierMagnitude(ScalableDuration);
}

Info.SourceTags.RequireTags.AddTag([必要Tag]);
Info.SourceTags.IgnoreTags.AddTag([不必要Tag]);
Info.SourceTags.TagQuery = FGameplayTagQuery();

Info.TargetTags.RequireTags.AddTag([必要Tag]);
Info.TargetTags.IgnoreTags.AddTag([不必要Tag]);
Info.TargetTags.TagQuery = FGameplayTagQuery();

Modifiers.Add(Info);
Executions(実行)

独自ロジックによる効果適用
Calculation Class :実行ロジック定義クラス
Effect Class:実行するGameplayEffectクラス
Required Source Tags:発動者が所持が必要なタグ

FGameplayEffectExecutionDefinition Definition;

Definition.CalculationClass = [UGameplayModMagnitudeCalculation];

FConditionalGameplayEffect Effect;
Effect.EffectClass = [UGameplayEffect];
Effect.RequiredSourceTags.AddTag([発動者が所持が必要なタグ]);

Definition.ConditionalGameplayEffects.Add(Effect);
Executions.Add(Definition);

Gameplay Cues

Rwquire Modifier Success to Trigger Cues

修飾子(Modifier)が成功したときのみ実行の有無

Suppress Stacking Cues

溜まった(Stacking)効果の実行の有無

Gameplay Cues

Magnitude Attribute :キュー実行をするために参照する属性
Min Level:キュー実行をするための最小レベル
Min Level:キュー実行をするための最大レベル
Gameplay Cue Tags:Cue実行用Tag
※キューはタグに紐づいているため、このタグにより実行されるキューが決まる
※タグは「GameplayCue.[キュータグ]」の階層形式

bRequireModifierSuccessToTriggerCues = true;
bSuppressStackingCues = false;

FGameplayEffectCue Cue;
Cue.MagnitudeAttribute = [FGameplayAttribut]
Cue.MinLevel = 1;
Cue.MaxLevel = 10;
Cue.GameplayCueTags.AddTag(GameplayCue.[実行タグ]);

GameplayCues.Add(Cue);

Stacking

Stacking Type

効果の集計対象

対象の種類 内容
Aggregate by Source 発動者ごとに集計
Aggregate by Target 適用者ごとに集計
Stack Limit Count

スタックの最大数を指定。0は無制限

Stack Duration Refresh Policy

スタック毎にスタック中の効果持続期間のリフレッシュ

対象の種類 内容
Refresh on Successful Application スタック中の持続時間がリフレッシュ
Never Refresh スタックの持続時間はリフレッシュされない
Stack Period Reset Policy

スタック毎にスタック中の効果適用周期のリセット

対象の種類 内容
Reset on Successful Application スタック中の適用周期をリセット
Never Reset スタック中の適用処周期をリセットしない
Stack Expiration Policy

スタック効果の破棄

対象の種類 内容
Clear Entire Stack 持続期間終了時、すべてのスタックを破棄
Clear Single Stack 持続期間終了時、一つのスタックを破棄
Overflow

スタックが上限に達した場合の設定
Overflow Effects :適用される追加の効果
Deny Overflow Application:その効果適用拒否の有無
Clear Stack on Overflow:全てのスタックをクリアの有無

StackingType = EGameplayEffectStackingType::AggregateBySource;
StackLimitCount = 10;
StackDurationRefreshPolicy = EGameplayEffectStackingDurationPolicy::RefreshOnSuccessfulApplication;
StackPeriodResetPolicy = EGameplayEffectStackingPeriodPolicy::ResetOnSuccessfulApplication;
StackExpirationPolicy = EGameplayEffectStackingExpirationPolicy::ClearEntireStack;

OverflowEffects.Add([UGameplayEffect]);
bDenyOverflowApplication = true;
bClearStackOnOverflow = false;