UE4 C++でSlateアニメーション

本記事はHAL名古屋アドベントカレンダー8日目です。
今回初めてアドカレに参加しますゲーム学科2年プログラマーのSuuta(すーた)です。よろしくお願いします。


# 概要
この記事はUnrealEngine4(以下UE4)でUIを作る際に用いるUMGのC++版であるSlateで、UIをアニメーションさせようという内容です。需要は少ないかもしれないですが、UE4のロード画面でこれから躓くであろう人達にむけての備忘録的な感じになればいいなと思っています。なので、UE4に触れたことが無い人には少し理解しづらい内容かもしれません。ご了承ください。

# Slate(スレート)って何？
UE4のUI作成ツールには**UMG**(Unreal Motion Graphics)というものがあります。ドラック&ドロップで視覚的にUIを配置でき、タイムラインにキーフレームを打つことでアニメーションさせることもできます。それに対して**Slate**はC++で記述するUIフレームワークで、どちらかというとエンジンコード寄りな部分です。UE4のエディターのUIもSlateで構成されており、Slateをいじることでエディター拡張も可能です。

### UMGの編集画面(エディター)
![UMG.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273916/6915f28e-679e-c5b7-fbb0-7a506066dd0c.jpeg)

### Slate (C++)
https://docs.unrealengine.com/4.27/ja/ProgrammingAndScripting/Slate/Overview/

![Slate.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273916/8ff07f0b-b7fb-926f-7a62-167459088825.jpeg)

両者は別モノというわけではなく、SlateをエディターからGUIで操作できるようにしたラップしたツールがUMGです。なのでどちらを使っても同じ表現が可能です。ならUMG使った方がいいんじゃないの？って思いますよね。はい、そのとおりです。**特別な理由がない限りSlateを使う意味は全くありません**。


# Slateを使わないといけない場面
Slateが必要になる場合のひとつは**シーン遷移中のロード画面でアニメーションを使用する**です。

シーン遷移のロード中にアニメーションさせたいのに画面が固まってしまうという経験をしたことがありますか？もしくは、UE4のOpenLevelで重たいレベルを開く時にUMGがアニメーションしない！って困ったことが一度はあると思います。シングルスレッドで実行されている限り、一度読み込みを初めてしまうと読み込みが終わるまでロード画面のUIは更新できず、画面が固まった様に見えるわけです。これが**同期ロード**と呼ばれるものです。これに対して市販のゲームで見られる、ロード画面のUIを更新しつつその裏でシーンの読み込みを行うものを**非同期ロード**といいます。

:::note warn
厳密にいえばエディター拡張も非同期ロード画面も、Slateを記述しない実装方法もあるのですが、この記事の範囲外になるので、その実装に関して今回は扱いません。
:::

### Unityの場合
Unityのことは全く知らないんですが、この非同期ロード関数で行けると思います。(たぶん… Asyncって書いてあるし…

https://docs.unity3d.com/ja/current/ScriptReference/SceneManagement.SceneManager.LoadSceneAsync.html

### UE4の場合
UE4にはこれを解決する方法が標準では用意（エディターにAPIとして公開）されていないので、自前で対処しないといけません。そこでSlateが必要になります。Slateは別スレッドで動作しているので、メインスレッドでシーンの読み込みを行い、SlateでUIの描画・更新を行うことが出来ます。今回はSlateのアニメーションがメインなので詳細な実装は以下の記事を参考にしてください。

https://suuta-blog.hatenablog.com/entry/2019/12/08/154405

# アニメーションさせてみる
↑の記事ではSlateで描画させるところまでを行っていますが、エンジンで用意されているデフォルトのローディングウィジェット（UIパーツ）を使用しており、すでに回転アニメーションが実装されています。もちろん自作のUIウィジェットを使用することもできますが、UMGでアニメーションをつけただけでは、上でも述べたようにロード中は固まってしまってアニメーションしません。アニメーションさせないならUMG上でアニメーションを作れば良いですが、自作ウィジェットでアニメーションをつけたい場合は、C++でアニメーションコードを書く必要があります… あくまで、**シーン遷移中にUIをアニメーションさせる場合**です。プレイヤーのHUDなど、シーン遷移以外のアニメーションはUMGのアニメーションで対応できます。

### 参考UI
![FINAL FANTASY VII REMAKE_20200504163149.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273916/b1b8fa38-521f-d327-cbe0-550837d775e5.jpeg)

今回自作するローディングウィジェットはFFVIIリメイクのローディングアイコンを参考に作っていきたいと思います。UIの構成は、"Loading"というTextと◾️型のImageを8個並べてロードアイコンを表現しています。ロードアイコンによくあるクルクル回ってるやつです。

今回アニメーションさせるパラメータはImageのRenderOpacity(透明度)、Rotation(回転)、Scale(拡縮)です。各Imageのパラメータを指定のキーフレーム間で変化させることで回転を表現しています。キーフレーム間の値はキーフレーム間の終始点の線形補完で求めることができます。Image自体の回転は45度固定(◆)です。


[ソースコード - Github Gist](https://gist.github.com/Suuta/d7deb625992b6f4ad60132552b6777c7)


# 実装していく

:::note warn
実装に関して正しい情報をしっかり得られない箇所もあり、憶測も含みます。
:::

Slateは基本的にSCompoundWidgetを継承します。初期化はSWidget::Construct、アニメーションの更新はSWidget::Tickをオーバーライドして実装していきます。

```c++
LOADINGSCREEN_API
class SLoadingScreen : public SCompoundWidget
{
public:
    SLATE_BEGIN_ARGS(SLoadingScreen) {}
    SLATE_END_ARGS()
    
    // 初期化
    void Construct(const FArguments& InArgs)
    {
        // ...
    }

    // 更新処理
    void Tick(const FGeometry& AllottedGeometry, const double InCurrentTime, const float InDeltaTime) override
    {
        // ...
    }
}
```
### CanvasとHorizontalBox
![Inkedffvii_LI.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273916/3a3008af-edbe-6543-7c23-94cda7d6d5c5.jpeg)




見た目の部分の実装はSWidget::ChildSlotメンバに行います。ここからは少し見かけないコードですが、ただの演算子オーバーロードです。基本的には、子要素を持つことができるレイアウト用ウィジェット（SCanvas、HorizontalBox、SOverlay等...）に、+ ○○::Slot関数を呼んでスロットに要素を追加することで、UIを作っていきます。また、**operator.()**でスロットのパラメータを指定し、**operator[]**でスロット内に追加する要素を指定します。今回は、自由なレイアウトが可能なCanvasをルートに設定し、HorizontalBoxでローディングアニメーション要素を水平レイアウトし、右下に配置しています。

```c++
ChildSlot
[
    // キャンバスパネル
    SNew(SCanvas)
    + SCanvas::Slot() // スロット追加
    .Position(FVector2D(0.f, 0.f))
    .Size(FVector2D(1920.f, 1080.f))
    [
        // 背景画像
        SNew(SImage)
        .Image(m_BackgroundBrush.Get())
    ]
    + SCanvas::Slot() // スロット追加
    .Position(FVector2D(1670.f, 930.f))
    .Size(FVector2D(200.f, 100.f))
    [
        // 水平レイアウトボックス
        SNew(SHorizontalBox)
        + SHorizontalBox::Slot()
        .HAlign(HAlign_Fill)
        .VAlign(VAlign_Fill)
        [
            // ...
        ]
    ]
];
```

### HorizontalBox
![Inkedffvii_2_LI.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273916/8054b287-373c-243c-969b-36177d9eb943.jpeg)

基本的なことは上記のコードと同じです。異なる点はアニメーションで更新する要素Color（色）Transform（拡縮行列）を　ColorAndOpacity_LambdaやRenderTransform_Lambdaで結び付けているところです。後述するTick関数（更新関数）で要素を更新することで、結び付けたパラメータが変化します。

```C++
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.HAlign(HAlign_Fill)
.VAlign(VAlign_Center)
.AutoWidth()
[
    // テキスト(LOADING: 24px)
    SNew(STextBlock)
    .Font(FCoreStyle::GetDefaultFontStyle("Light", 24))
    .Text(FText::FromString(TEXT("Loading")))
    .ColorAndOpacity(FSlateColor(FLinearColor(0.0f, 0.2f, 0.4f, 1.0f)))
]
+ SHorizontalBox::Slot()
.HAlign(HAlign_Fill)
.VAlign(VAlign_Fill)
.AutoWidth()
[
    // スペーサー（空白）
    SNew(SSpacer)
    .Size(FVector2D(25.0f, 1.0f);
    .RenderTransformPivot(FVector2D(0.5f, 0.5f))
]
+ SHorizontalBox::Slot()
.HAlign(HAlign_Fill)
.VAlign(VAlign_Fill)
.FillWidth(1.0f)
[
    // キャンバスパネル
    SNew(SCanvas)
    + SCanvas::Slot()
    .Position(FVector2D(20.0f, 30.f))
    .Size(FVector2D(10.0f, 10.0f))
    [
        SNew(SImage)
        .Image(m_IconElementBrush.Get())
        .RenderTransformPivot(FVector2D(0.5f, 0.5f))
        .ColorAndOpacity_Lambda([this]()
        {
            return m_Elements[0].Color;
        })
        .RenderTransform_Lambda([this]()
        {
            return m_Elements[0].Transform;
        })
    ]
    + SCanvas::Slot()
    .Position(FVector2D(30.0f, 40.f))
    .Size(FVector2D(10.0f, 10.0f))
    [
        SNew(SImage)
        .Image(m_IconElementBrush.Get())
        .RenderTransformPivot(FVector2D(0.5f, 0.5f))
        .ColorAndOpacity_Lambda([this]()
        {
            return m_Elements[1].Color;
        })
        .RenderTransform_Lambda([this]()
        {
            return m_Elements[1].Transform;
        })
   ]

    // ...
    // m_Elements[7])まで同様に更新
```

### アニメーション更新

次は更新関数です。SWidget::Tickのオーバーライド関数でおそらく（60FPS）固定で呼ばれていると思います。実装に関してはかなり力技で無理やり感があるのであまり参考にしないでください（笑）

更新タイミングは引数 InDeltaTimeから前フレームからの現在までの経過時間が得られので、それを利用して0.2秒おきにパラメータを変化させています。変化は **FMath::Lerp＜float＞(初期値, 最終値, 割合)**を使ってパラメータを線形補間させています。拡大率を0.5 ~ 1.0に変化させたい場合は FMath::Lerp＜float＞(0.5f, 1.0f, alpha)という感じです。alphaは指定区間の進行度を渡しています。つまり、0.5f(50%)を渡せば、0.75f(補間値)が返ってきます。0.2秒間で変化させている場合、（現在時間 - 区間開始時間）/ 0.2　でalphaが求められます。

:::note info
今回は力技で実装していますが、UE4には**SWidget::RegisterActiveTimer**という関数もあり、指定した一定間隔で指定した関数を自動で実行してくれる機能もあるので使ってみると良いかもしれません。
:::

```C++
void Tick(const FGeometry& AllottedGeometry, const double InCurrentTime, const float InDeltaTime) override
{
    // 時間の更新
    m_CurrentTime += InDeltaTime;
    if (m_CurrentTime >= m_TotalAnimTime)
    {
        m_CurrentTime = 0.0f;
        m_SectionEndTime = 0.2f;
        m_SectionHeadTime = 0.0f;
    }

    // タイムラインキーフレーム間隔（0.2秒）の更新
    if (m_CurrentTime >= m_SectionEndTime)
    {
        m_SectionHeadTime = m_CurrentTime;
        m_SectionEndTime = m_CurrentTime + 0.2f;
    }

    // 区間内の進捗度
    float alpha = (m_CurrentTime - m_SectionHeadTime) / 0.2f;

    //==================== 1 ====================
    {
        if (m_SectionHeadTime >= 0.2f && m_SectionHeadTime <= 0.4f)
        {
            m_Elements[0].Color.A = FMath::Lerp<float>(1.0f, 0.75f, alpha);
        }
        else if (m_SectionHeadTime >= 0.8f && m_SectionHeadTime <= 1.0f)
        {
            m_Elements[0].Color.A = FMath::Lerp<float>(0.75f, 0.5f, alpha);
        }
        else if (m_SectionHeadTime >= 1.4f && m_SectionHeadTime <= 1.6f)
        {
            m_Elements[0].Color.A = FMath::Lerp<float>(0.5f, 1.0f, alpha);
        }


        if (m_SectionHeadTime >= 0.8f && m_SectionHeadTime <= 1.0f)
        {
            FMatrix2x2 scale(FMath::Lerp<float>(1.0f, 0.5f, alpha));
            FMatrix2x2 rot(FQuat2D(FMath::DegreesToRadians(45.0f)));

            m_Elements[0].Transform = FTransform2D(scale.Concatenate(rot));
        }
        else if (m_SectionHeadTime >= 1.4f && m_SectionHeadTime <= 1.6f)
        {
            FMatrix2x2 scale(FMath::Lerp<float>(0.5f, 1.0f, alpha));
            FMatrix2x2 rot(FQuat2D(FMath::DegreesToRadians(45.0f)));

            m_Elements[0].Transform = FTransform2D(scale.Concatenate(rot));
        }
    }
    
    //==================== 2 ====================
    // ...

    // 8個(m_Elements[7])まで同様に更新
}
```


# まとめ

Slateをエディター拡張以外で解説しているサイトは少ないと思うので、実装の良し悪しは別として「少なくとも実装できる参考例が１つある」と捉えて頂けると書いた甲斐があります。ここまで読んで頂きありがとうございます。😊

次回は11日の@hv_demoonさんによる**【Excel関数】VLOOKUPの神になる」**です。