tanaka's Programming Memo

プログラミングについてのメモ。

Unity6ではじめてのAwaitable

本記事は、Qiita Advent Calendar 2024. Unity Advent Calendar 2024のシリーズ2の12日目の記事です。

です。

最近まで、諸々の事情でUnity2021を使っていて、がっつりタスクを使う場面もなかったので、コルーチンにはIEnumeratorを使い続けていました。ようやくUnity6に更新したので、新たに導入されたAwaitableを使ってみようということで、リアル「はじめてのAwaitable」です。

目次

基本的なこと

これまでずっとIEnumeratorでやってたので、基本的なことを調べるところからはじめました。

そもそも非同期処理とは

流石に非同期処理は知ってましたが、念の為。

非ではない同期処理から。同期処理とは、ある処理が完了するまで、次に処理を進めない処理方法です。たとえば次のようなコードがあったとします。

Debug.Log($"こんにちは!");
Debug.Log($"同期処理で、");
Debug.Log($"表示します。");

Unityでこれを実行すると、コンソールに次のような感じで表示されます。

こんにちは!
同期処理で、
表示します。

当たり前のようですが、このように順序よく表示されるのは、UnityのC#が基本的に同期的に実行されるからです。

非同期処理は、同期しない、つまり、ある処理の完了を待たずに、すぐに次の処理をはじめる処理方法のことです。先のコードを非同期に実行すると、次のように表示されるかも知れません。

こ同表ん期示に処しち理てはでい!
、ま

す。

3つのDebug.Logが次々に開始して、それぞれが並行して処理を進めていきます。実行順は、その時々の状況に応じて変わるため、実行するたびに結果は変わります。

同期処理は、手順通りに処理を進めるプログラムの実行に向いています。しかし、時間がかかる大量のデータを処理するプログラムや、サーバーからの返信を待つような処理では、システムの応答が止まってしまいます。複数の処理をスレッドなどに分割して、非同期に実行することで、システムの停止を回避できます。非同期処理は、複数の処理や重い処理を実行したい場合に活躍します。

Unityでは、IEnumeratorを使ったコルーチンや、AsyncOperationを返すLoadSceneAsyncやUnloadSceneAsyncなどの非同期処理の機能を提供してきました。

Awaitableとは

日本語にすると、「待つことができる」。Unity6(Unity2023.1)で追加されたAwaitableクラスは、Unityのライフサイクルを、awaitで待機できるようにするクラスです。裏スレッドに実行を切り替える機能もあります。

Awaitableは、awaitと一緒に使うことを想定したクラスです。

awaitとは

awaitは、非同期操作を受け取る演算子です。かなりややこしい動きをします。

  • awaitが出てくるまでは、普通のメソッドと同じように、同期して処理を進める
  • awaitが非同期操作を受け取ったら、その場で処理を中断して、呼び出し元に制御を返す
  • 非同期操作が完了したら、awaitで中断したところから処理を再開する

処理を途中で中断して、呼び出し元に制御を戻すことで、システムのフリーズを防ぎます。非同期操作が完了したら、中断した場所から再開されます。これにより、UIや他のゲームオブジェクトの動作を停止させることなく、いつ終わるかわからないような処理の完了を待ってから、続きの処理を実行できます。手順通りに進めたい処理で、途中で時間がかかったり、操作待ちがあるような場合でも、手軽に実装できます。

awaitのうしろに、非同期操作を返すラムダ式や、非同期メソッドを書くことができます。その場合は、awaitで中断するより先に、ラムダ式や非同期メソッドを呼び出して、同期的に処理が進みます。どこかでawaitに非同期操作が渡されて、待機状態になったら、awaitをさかのぼって、呼び出し元まで制御が戻ります。そこからは、通常のawaitと同様の流れで実行されます。戻り値を受け取るで、実際の進行を例示します。

asyncとは

asyncは、awaitを使いたいメソッドに付けて、非同期メソッドとして定義するものです。

awaitを含まないメソッドの宣言に、asyncを付ける意味はありません。awaitを含まないメソッドにasyncをつけると、以下のような警告が出ます。

awaitを持たないasyncメソッドは警告が出る

asyncとawaitは、同一スレッド上の制御が滞らないようにする機能です。これら自体には、別スレッドで処理を実行するような機能はありません。

TaskとAwaitable

TaskとAwaitableは、どちらもawaitに渡せる非同期操作を返す機能を持ちます。

Taskは、別のスレッドで処理を実行する機能を提供します。メインスレッドを止めずに、時間がかかる計算を実行するのに適しています。名前が示す通り、何らかの処理を実行することを目的としています。.NETで提供されるクラスなので、Unityのライフサイクルにあわせて実行することはできません。

Awaitableは、Unityから提供されるクラスです。Awaitableという名前が示す通り、awaitで待機することを主な目的としています。Unityの特定のライフサイクルや、指定の秒数が経過するのをawaitで待機する機能を提供します。これにより、IEnumeratorと同じようなUnityのライフサイクル内で実行できるコルーチンを、async/awaitを使って実装できるようになりました。また、実行スレッドをメインとバックグラウンドで切り替える機能も提供されています。Taskと同様に、重い処理をバックグラウンドスレッドで実行したいときにも使えます。

Awaitableの使い方

基本的なことがわかったので、Awaitableを使ってみます。Awaitableが提供する機能は、次のとおりです。

  • IsCompleted
    • Awaitableが完了していたら、trueを返す
  • Cancel
    • Awaitableが待機中なら、System.OperationCanceledExceptionをスローして、処理をキャンセルする
  • BackgroundThreadAsync
    • awaitで処理を中断して、次のフレームまで待機する。次のフレームになったら、バックグラウンドスレッドに切り替えて、処理を再開する。すでにバックグラウンドスレッドだったら、何もしない
  • EndOfFrameAsync
    • 現在のフレームの更新処理が完了するまで待機してから、処理を再開する
  • FixedUpdateAsync
    • 次の物理更新まで待機してから、処理を再開する
  • FromAsyncOperation
    • LoadSceneAsyncなどで返されるAsyncOperationのインスタンスを、Awaitableに変換する
  • MainThreadAsync
    • awaitで処理を中断して、次のフレームまで待機する。次のフレームになったら、メインスレッドに切り替えて、処理を再開する。すでにメインスレッドだったら、何もしない
  • NextFrameAsync
    • 次のフレームまで待機してから、処理を再開する
  • WaitForSecondsAsync
    • 指定の秒数待機してから、処理を再開する

Awaitable - Unity スクリプトリファレンスより

Unityのライフサイクルを考慮したコルーチン

Awaitableのうち、Unityのライフサイクルや、秒数を待つコルーチンとしての使い方です。

using UnityEngine;

public class CoroutineSample : MonoBehaviour
{
    private void Start()
    {
        DoCoroutine();
        Debug.Log("Start終わり");
   }

    /// <summary>
    /// コルーチン的な使い方
    /// </summary>
    async void DoCoroutine()
    {
        Debug.Log("処理開始");
        await Awaitable.NextFrameAsync();
        Debug.Log("1フレーム経過");

        await Awaitable.EndOfFrameAsync();
        Debug.Log("フレーム処理が完了。カメラの移動など、他のオブジェクトの更新後の処理を書くとよい");

        await Awaitable.FixedUpdateAsync();
        Debug.Log("物理更新のタイミング。物理更新関連の処理を書くとよい");

        await Awaitable.WaitForSecondsAsync(1f);
        Debug.Log("1秒経過");
    }
}

ざっくりと処理の流れです。

  • 5行目:シーンが開始したら、Startが呼ばれる
  • 7行目:非同期メソッドDoCoroutineを呼び出す。従来のコルーチンのようなStartCoroutine()は不要
  • 14-16行目:「処理開始」とログ表示。ここまでは、普通のメソッドと同じ動作
  • 17行目:awaitのオペランドのAwaitable.NextFrameAsync()を呼び出して、次のフレームまで待機する非同期操作を受け取ったら、呼び出し元に制御を戻す
  • 8行目:awaitによって制御が戻るので、「Start終わり」と表示して、Startメソッドを終える
  • 次のフレームになるまで、処理なし
  • 18行目:次のフレームになったら、18行目から処理を再開。「1フレーム経過」と表示
  • 20行目:awaitが出てきたので、メソッドから抜ける。呼び出し元のStartは終わっているので、他のオブジェクトの処理などへ
  • フレームの更新処理が終わるまで、処理なし
  • 21行目:フレームの更新処理が一通り終わったら、21行目から処理を再開。「フレーム処理が完了・・・」と表示
  • 23行目:awaitが出てきたので、メソッドから抜ける。20行目と同様
  • 物理更新がはじまるまで、処理なし
  • 24行目:物理更新がはじまったら、24行目から処理を再開。「物理更新のタイミング・・・」と表示
  • 26行目:20, 23行目と同様
  • 1秒経過するまで、処理なし
  • 27行目:1秒経過したら、27行目から処理を再開。「1秒経過」と表示
  • 28行目:非同期メソッド終わり

コンソールは、以下のように表示されます。

コルーチン的使い方のサンプルの表示例

処理は、メインスレッド上で実行されます。「Start終わり」の表示が出力されるタイミングが腑に落ちれば、async/awaitが大体理解できたものと思います。フレーム更新や物理更新するまで、awaitで待機する、文字通り「Awaitable」な使い方をしています。

スレッドの切り替え

Awaitableには、BackgroundThreadAsyncと、MainThreadAsyncという、スレッドを切り替える機能も提供されています。これらを使うと、Taskと似たような使い方ができます。以下、サンプルコードです。

using UnityEngine;

public class ThreadChangeSample : MonoBehaviour
{
    private void Start()
    {
        BackThread();
    }

    async void BackThread()
    {
        Debug.Log($"処理開始");
        await Awaitable.BackgroundThreadAsync();

        Debug.Log($"バックグラウンド切り替え。重い処理開始");
        HeavyTask();
        Debug.Log($"重い処理完了");

        // transform.position = Vector3.zero; // エラー

        await Awaitable.MainThreadAsync();
        Debug.Log($"メインスレッドに帰還");

        transform.position = Vector3.zero; // 実行可能
    }

    void HeavyTask()
    {
        double x = 1;
        for (int i = 0; i < 100000000; i++)
        {
            x += Mathf.Sqrt(i);
        }
    }
}

HeavyTaskに書いた重い処理を、バックグラウンドスレッドで実行するサンプルです。このスクリプトを、任意のゲームオブジェクトにアタッチして実行すると、コンソールに経過が表示されます。

バックグラウンドスレッドで実行

バックグラウンドスレッドに切り替えてから、HeavyTaskを呼び出しているので、その前に表示しているログが2つ表示されます。そして、HeavyTaskの終了が終わったら、残りの「重い処理完了」と「メインスレッドに帰還」が表示されます。システムが停止していないので、Debug.Logを実行したタイミングで、ログが表示されています。

13行目のawait Awaitable.BackgroundThreadAsync();コメントアウトして実行すると、処理が終了するまで、ログが表示されなくなります。

デフォルトのまま実行

HeavyTaskが、メインスレッドで実行されるので、すべての処理が終わるまでコンソールへの表示ができないからです。

最後にメインスレッドに戻していますが、メインスレッドに戻してから実行する処理がなければ、これは不要です。バックグラウンドスレッドで実行されるのは、asyncメソッド内のみです。

バックグラウンドスレッドは使えない機能が多い

便利そうなバックグラウンドスレッドなのですが、Unityの多くの機能が使えません。19行目のコメントアウトを外すと、以下のような例外が出力されます。

バックグラウンドスレッドではいろいろとエラーになる

同じAwaitableクラスのAwaitable.NextFrameAsync()やAwaitable.WaitForSecondsAsync()といった、Unityライフサイクルに関する機能も使えません。そのような機能を使いたい場合は、Awaitable.MainThreadAsync()で、メインスレッドに戻してください。

スレッドの切り替えには1フレームかかる

スレッドの切り替えは、一度awaitで呼び出しもとに処理を返してから、次のフレームで処理を再開させるときに実行されます。瞬時に切り替わらないことを前提に、利用してください。

戻り値を受け取る

Awaitableは、Taskと同様に戻り値を受け取ることができます。戻り値は、非同期メソッドの結果として返します。awaitで待機する必要があるので、非同期メソッドで利用することになります。

ユーザーがYかNのどちらかを押すまで待機して、押されたキーをログに表示する例です。

using UnityEngine;

public class ReturnValue : MonoBehaviour
{
    void Start()
    {
        YorNAsync();
        Debug.Log("Start終了");
    }

    async void YorNAsync()
    {
        Debug.Log("YかNを押してください。");
        string res = await InputYorNAsync();
        Debug.Log($"{res}が押されました。");
    }

    async Awaitable<string> InputYorNAsync()
    {
        Debug.Log($"キー入力待機");
        while (true)
        {
            if (Input.GetKeyDown(KeyCode.Y))
            {
                return "Y";
            }
            else if (Input.GetKeyDown(KeyCode.N))
            {
                return "N";
            }

            await Awaitable.NextFrameAsync();
        }
    }
}
  • 5-7行目:開始したら、YorNAsyncメソッドを呼び出す
  • 11-13行目:「YかNを押してください。」と表示する
  • 14行目:awaitのうしろがメソッドなので、InputYorNAsyncメソッドを呼び出す
  • 18-20行目:「キー入力待機」と表示する
  • キー入力がなければ、32行目まで進む
  • 32行目:awaitで、次のフレームまで待機するので、呼び出し元に制御を返す
  • 14行目:awaitに非同期操作が渡されるので、さらに呼び出し元に制御を戻す
  • 8行目:「Start終了」と表示して、Startメソッドを終える
  • 次のフレームになるまで、処理なし
  • 32行目:次のフレームになったら、32行目から処理再開
  • 21-33行目:while文で、YかNが押されるまで、フレーム更新ごとにループ
  • Yが押されたら
  • 23-25行目:Yを戻り値として返す
  • 14行目:非同期操作が終了して、戻り値としてYが返るので、resに代入
  • 15行目:「Yが押されました。」と表示
  • 16行目:YorNAsyncを終了

以上です。Nが押された時は、23-25行目と同様に27-29行目が実行されて、「Nが押されました。」と表示されます。

「キー入力待機」が、「Start終了」よりも先に表示されています。これにより、awaitに非同期操作が渡されるまでは、通常の同期処理と同様に処理が進むことが確認できます。

待機をキャンセルする

先の例では、YかNを押すまで処理を停止できませんでした。InputYorNAsyncに、処理を抜ける機能を追加することもできますが、Awaitableのインスタンスを使えば、外部から中断させることもできます。

using System;
using UnityEngine;

public class ReturnValue : MonoBehaviour
{
    Awaitable<string> inputYorNAsync;

    void Start()
    {
        YorNAsync();
        Debug.Log("Start終了");
    }

    private void OnDestroy()
    {
        CancelInvoke();
    }

    async void YorNAsync()
    {
        Debug.Log("YかNを押してください。");
        
        inputYorNAsync = InputYorNAsync();
        Invoke(nameof(AbortAwait), 3);

        try
        {
            string res = await inputYorNAsync;
            CancelInvoke();
            Debug.Log($"{res}が押されました。");
        }
        catch (OperationCanceledException e)
        {
            Debug.Log(e);
        }
    }

    void AbortAwait()
    {
        inputYorNAsync?.Cancel();
    }

    async Awaitable<string> InputYorNAsync()
    {
        Debug.Log($"キー入力待機");
        while (true)
        {
            if (Input.GetKeyDown(KeyCode.Y))
            {
                return "Y";
            }
            else if (Input.GetKeyDown(KeyCode.N))
            {
                return "N";
            }

            await Awaitable.NextFrameAsync();
        }
    }
}
  • 6行目:Awaitableインスタンスを保存しておくinputYorNAsyncを定義
  • 23行目:InputYorNAsyncメソッドから返されるAwaitableの非同期操作インスタンスを直にawaitせずに、inputYorNAsyncに代入しておく
  • 43-57行目:非同期メソッドでも、awaitが出てくるまでは通常のメソッドと同様に処理されるので、57行目のawaitまでそのまま処理が進む
  • 57行目:awaitで、次のフレームの待機をはじめたら、呼び出し元に制御を返す
  • 24行目:3秒後に、待機をキャンセルするメソッド呼び出しを設定
  • 28行目:InputYorNAsyncメソッドがreturnするまで、待機を開始して、呼び出しもとのStartメソッドに制御を返す
  • 11行目:「Start終了」と表示して、Startメソッドを終える
  • YかNが押されたら
    • 先の例と同様に、戻り値で文字を返す
    • 29行目:時間切れ処理が呼ばれないように、CancelInvokeメソッドを呼び出して、Invokeをキャンセル
    • 30行目:押された文字を表示する
  • 3秒経過したら
    • 38行目:Invokeで設定した、AbortAwaitメソッドが呼ばれる
    • 40行目:inputYorNAsyncに保存していたAwaitableのインスタンスのCancelメソッドを呼び出して、処理をキャンセルする
    • 32行目:非同期処理をキャンセルすると、OperationCanceledExecptionが発生するので、catchして、エラーになることを防止
    • 34行目:例外の内容を表示。予定の動作なら、表示はしなくてよい

OperationCanceledExeptionをログに表示

Awaitableのインスタンスをキャッシュしておいて、Cancelメソッドを呼べば、待機をキャンセルできます。待機しているawaitでOperationCanceledExceptionが発生するので、try-catchで囲んでおくとよいでしょう。

キャッシュしたAwaitableは再利用できない

Taskのインスタンスは、キャッシュしておいて再利用できます。一方、Awaitableのインスタンスは、パフォーマンス上の理由から、再利用できません。次のようなコードは、エラーになります。

using UnityEngine;

public class UseCache : MonoBehaviour
{
    async void Start()
    {
        Awaitable cache = Awaitable.NextFrameAsync();

        Debug.Log($"実行前");
        await cache;
        Debug.Log($"1フレーム経過");
        await cache;  // ここでエラー
        Debug.Log($"2フレーム経過");
    }
}

2回目の利用時にエラー

シーンの切り替えシーケンス

シーンの読み込みや解放をするSceneManager.LoadSceneAsyncやUnloadSceneAsyncが返すAsyncOperationも、awaitに渡せます。

    async void ChangeScene()
    {
        // シーン読み込み
        await FadeOut();
        await SceneManager.LoadSceneAsync("SceneA", LoadSceneMode.Additive);
        await FadeIn();

        // シーン解放
        await FadeOut();
        await SceneManager.UnloadSceneAsync("SceneA");
        await FadeIn();
    }

AsyncOperationをキャンセル可能にする

AwaitableのFromAsyncOperationに、AsyncOperationのインスタンスとCancellationTokenを渡すと、キャンセル可能なawaitができます。Webアクセスのキャンセルなどに使えそうです。

タスクのキャンセル - .NET | Microsoft Learn

以下、意味はありませんが、シーンの読み込みのキャンセルを試みるサンプルです。

using System;
using System.Threading;
using UnityEngine;
using UnityEngine.SceneManagement;

public class LoadSceneCancel : MonoBehaviour
{
    private void Start()
    {
        var token_source = new CancellationTokenSource();
        CancelableLoadScene(token_source.Token);
        token_source.Cancel();
    }

    async void CancelableLoadScene(CancellationToken token)
    {
        var asyncOperation = SceneManager.LoadSceneAsync("SceneA", LoadSceneMode.Additive);
        var awaitable = Awaitable.FromAsyncOperation(asyncOperation, token);
        try
        {
            await awaitable;
        }
        catch (OperationCanceledException e)
        {
            Debug.LogException(e);
        }
    }
}
  • 10-11行目:CancellationTokenSourceのインスタンスを生成して、Tokenをメソッドに渡して呼び出し
  • 17-18行目:シーンの非同期読み込みを実行して、AsyncOperationと、引数で受け取ったトークンから、awaitableを用意
  • 21行目:シーンの読み込みが完了するのと待機

シーンが読み込まれるより先に、12行目が実行されれば、シーンの読み込み待機がキャンセルされます。処理がキャンセルされたら、OperationCanceledExceptionが発生するので、19-26行目はtry-catchで囲んでいます。

Awaitableのパフォーマンス

Awaitableは、従来のIEnumeratorを使ったコルーチンよりも、パフォーマンスは向上するとのことです。Unity6以降を使うのであれば、IEnumeratorから乗り換えた方がよさそうです。

UpdateやFixedUpdateの代わりにAwaitableのループを使うことは推奨されていません。以下、Unity公式マニュアルから引用します。

Although Unity’s Awaitable class is optimized for performance, you should avoid running hundreds of thousands of concurrent coroutines. Similar to coroutine-based iterators, a behavior with a loop similar to the following example attached to all your game objects is very likely to cause performance problems:

Await support - Unity マニュアルより

Awaitableを使うと、小さいループで処理が書けるため、パフォーマンスがよさそうに見えます。しかし、処理を再開するために状態を保存したり、待機処理から再開できるようにコードを調整したりと、内部的には結構な処理をしています。通常のゲームオブジェクトの更新処理は、UpdateやFixedUpdateに実装しましょう。

Awaitableは、多少のパフォーマンスを犠牲にしても、ややこしい手順をシンプルに書きたい場合や、シナリオの再生処理などの限定された場面で利用します。パフォーマンスを向上させたい場合は、DOTSなどの利用を検討してください。

まとめ

ようやく、asyncとawaitに足を踏み入れました。非同期処理のややこしさが、awaitに集約されているように感じました。疑問に感じたところを自分自身であれこれ試して、ようやく腑に落ちました。

Awaitableクラスは、Unityのライフサイクルにあわせて、async/awaitを利用する機能を提供してくれます。これにより、従来のIEnumeratorを使ったコルーチンは、より効率が良いasync/awaitを使ったものに書き換えられます。

awaitのオペランドとして、処理を再開するための非同期操作を渡すことで、呼び出し元に制御を戻します。これにより、メインスレッドの処理を進められるので、システムが停止しなくなります。awaitした非同期操作が完了したら、awaitの続きの処理を呼び出して、処理を再開します。これにより、メインスレッドだけでも、非同期的な処理が実現できます。

また、スレッドの切り替え機能も持っています。awaitした非同期操作から再開する時に、スレッドを切り替えます。切り替えたスレッドは、非同期メソッドのチェーン内で有効です。バックグラウンドスレッドでは、Unityの主要な機能が使えません。PureなC#や、スレッドセーフな機能だけで処理をします。Unityの機能を使う場合は、メインスレッドに戻します。次のフレームの更新タイミングで、awaitから戻るタイミングでスレッドを切り替えるため、スレッドを切り替えるごとに1フレーム進むことに注意が必要です。

AsyncOperationも、awaitできます。待機をキャンセルするときは、Cancelメソッドを使います。

自分の利用用途としては、IEnumeratorの置き換えがほとんどです。シナリオの制御や、シーンの切り替え、エンドロールやUIのシーケンス処理といったところです。今のところ、特に不足点はなさそうなので、これからビシバシ使っていきたいと考えています。

本記事は、Qiita Advent Calendar 2024. Unity Advent Calendar 2024のシリーズ2の12日目の記事でした。

です。

参考・関連URL

【Godot4.3】Macで2Dゲームの動きがガタついたときにやったこと

本記事は、Qiita Advent Calender 2024Godot Engine Advent Calender 2024シリーズ1の4日目の記事です。

qiita.com

2Dゲームを、手持ちのMac Book Pro(以降、MBP)で動かしたところ、動きがガタつく症状が発生しました。その原因と、調査のまとめです。

目次

原因は互換性レンダラー

あまりに簡単なオチだったので先に書きます。原因は互換性レンダラーでした。Forward+やモバイルを使っていたら滑らかに動きます。また、互換性レンダラーでも、WindowsWebブラウザーでは、滑らかに動きました。

動きがガタつくのは、互換性レンダラーで、WindowsMac上で実行した場合です。普通は、PC向けならForward+を使うので、問題は起きません。うちの古いインテルMBPだと、MoltenVKが不安定で、互換性レンダラーしか動きません。このような特殊な場合に起きる問題でした。

以下、調査内容です。

ことのおこり

Godot Meetup Tokyo Vol.3に、展示枠で応募したのがはじまりでした。ゲームジャムに、未完成で出したものを完成させて、この機会に遊んでいただこうと考えました。せっかく展示するなら、Webブラウザーではなく、ネイティブのフルスクリーンで動くようにしようと、手持ちのMBP上で動くようにビルドしてみました。

滑らかに動くことを期待していたのですが、動きがガタつきます。ブロック崩しクリッカーをまぜたSTORM OF BALLSで、特にガタつきが目立ちました。

am1tanaka.itch.io

こんなシンプルなゲームが滑らかに動かないのはおかしい、というところから、今後に備えて原因を調査しはじめました。

フレームレートを確認する

画面がガタつく原因として考えられるのは、画面の更新タイミングが安定しないことか、処理が間に合っていないことです。まずは、フレームレートを確認しました。

1秒ごとにログに出力

Godotの標準の機能で、フレームレートを確認する方法があります。

  • プロジェクトメニューから、プロジェクト設定を開く
  • 高度な設定を有効にすると、デバッグ欄が表示されるので、設定からFPSを表示するにチェック

FPSをログに表示

この設定をして実行すると、1秒ごとに、出力にFPSが表示されます。

フレームレートを出力

デバッガーのモニターで確認する

もう少ししっかり確認したい場合は、下部パネルのデバッガーのモニターを利用します。モニターは自動的に記録されるので、実行したあとに確認できます。

  • ゲームを実行する
  • 画面下部のデバッガーをクリックして、デバッガーパネルを表示する

デバッガーのモニターでFPSを確認

フレームレート(FPS)の値の欄で、直近のフレームレートが分かります。チェックを入れれば、推移が確認できます。

MBPで、フルスクリーンで実行した結果が以下です。

MBPでのフレームレートの推移

フレームレートが安定していれば、グラフは一直線になるはずです。ガタガタしているということは、フレームレートが安定していないということです。

スクリプトを書いて自力で表示する

フレームレートが安定しない原因は、Godotエディターかも知れません。以下のような簡単なスクリプトを作成して、ビルドしたアプリ単体でフレームレートを確認しました。

// 1:
class_name FrameRateLabel
extends Label

var _last_time : int
var _counting: int


func _ready() -> void:
    _last_time = Time.get_ticks_msec()


func _process(_delta: float) -> void:
    _counting += 1
    if (Time.get_ticks_msec() - _last_time) < 1000:
        return
    
    _last_time = Time.get_ticks_msec()
    text = "%d fps" % [_counting]
    _counting = 0

このスクリプトをLabelにアタッチすれば、フレームレートが表示されます。これでも、フレームレートは不安定でした。

ラベルに表示

フレームレートが安定しない現象のまとめ

フルスクリーンなら、モニターのリフレッシュレートに同期するので、フレームレートが安定すると考えていました。しかし、Mac Bookは可変リフレッシュレートがデフォルトの動作で、内臓モニターだとリフレッシュレートを指定できないようでした。

MacBook Pro や Apple Pro Display XDR でリフレッシュレートを変更する - Apple サポート (日本)

120fpsで安定して動くときがあって、その時はスムーズに動きます。しかし、再現性がありません。AppleシリコンのMacだと、フルスクリーンにするとゲームモードで動いてくれるらしいのですが、インテルMacは対応していないようです。

Mac でゲームモードを使う - Apple サポート (日本)

これが原因だと思ったのですが、フレームレートが固定のWinでもガタつくことが分かりました。ほかの原因を探します。

スクリプトの実行速度を確認する

タイトル画面が特に不安定で、70fpsになったりします。重い処理がないか、プロファイラーで調べることにしました。プロファイラーは、下部パネルのデバッガーから選べます。

下部パネルからデバッガー>プロファイラーを選択

プロファイラーは、モニターと違って、手動で開始をクリックする必要があります。また、実行するたびに下部パネルが出力に変わるのは面倒です。以下のような設定で動かしました。

  1. プロジェクトメニューから、プロジェクト設定を開く
  2. 表示>ウィンドウ欄のモードを、Windowedに設定
  3. エディターメニューから、エディター設定を開く
  4. 一般タブの、実行>Bottom Panelを選択
  5. Action on Play欄を、Open Debuggerに変更

実行時に、デバッガーパネルを開く設定

  1. デバッガーパネルを選択して、プロファイラーを選択

これで、ウィンドウで起動して、下部パネルが自動的にデバッガーになります。実行して、プロファイラーの開始ボタンを押します。

プロファイラーを開始

ボールを200個以上表示させてみました。

ボール関連の処理にかかっている時間

223個のボールを処理して、3msもかかっていません。Physics 2Dなども、負担はなく、処理時間は余裕がありそうです。

処理ごとの実行時間は、時間の表示設定を、包括から自己に変更すると確認できます。

各処理ごとの処理時間

当たり判定のShapeCastが一番重くて、0.86msかかっています。それでも、1msもかかっていないので、気にしなくてよいでしょう。

設定で試したこと

ここまでの調査で、次のようなことが分かりました。

  • MBPでは、フレームレートが安定しない
  • 処理速度には余裕があるので、スクリプトの高速化などは不要

最大フレームレートを設定する

フレームレートを一定にできれば、滑らかに動かせそうです。MBPが可変リフレッシュレートでも、最大フレームレートをGodotで設定すればいけるのではないかと考えました。設定は、プロジェクト設定の実行欄にあります。

最大FPSを60、デルタ時間のスムージングをオフ

これでも安定しないため、デルタ時間のスムージングをオフにしてみました。この設定で、やや安定したように感じたのですが、気のせいでした。その時は、運よく120fpsで安定動作していたのでしょう。デフォルトに戻しても、120fpsで安定していれば滑らかに動くので、解決策にならないことが分かりました。

処理をprocessに移動

MBPでの解決は難しそうなのでひとまず保留して、固定フレームレートのWindowsで滑らかに動くことを確認しようと考えました。しかし、期待に反して、フレームレートが固定されるWindowsのフルスクリーンでも処理がガタつきました。

こうなると、フレーム更新と物理更新の干渉が疑われます。そこで、物理更新に実装していた移動処理を、_processに移してみました。move_and_collideで実装していた移動処理は、次のようにグローバル座標移動に変更しました。

   #move_and_collide(step * Vector2.DOWN)
    global_position += step * Vector2.DOWN

フレームレートが一定で、移動量が同じなら、滑らかに動くはずです。ところが、これでもガタつきが解消しません。こうなると、Godotの画面更新が不安定だとしか考えられません。

もはや、エンジンのコードに手を入れるしかなさそうです。ここで一度諦めたのですが、こんな簡単な処理ができないはずがありません。ここで、互換性レンダラーが疑わしいことに思い至りました。レンダラーをForward+にしたところ、無事、滑らかに動きました。モバイルも滑らかです。ここまでにやった対策を、すべてデフォルトに戻しても、滑らかなままです。ということで、原因が互換性レンダラーだったという結論になりました。

今回の調査で気になったところ

今回の調査で気になったことを、おまけで書きます。

モニターのインポートプロセス欄はProcessの間違い

デバッガーのモニターに表示されるインポートプロセスで混乱しました。

謎のインポートプロセス

インポートプロセスって、アセットをインポートする作業ですよね?なぜ、実行中に発生するのか。しかも、フレーム更新と同程度の16msもかかっています。ググったりあれこれ調べてみたのですが、よくわからず。ふと、英語表記にしてみたところ、Processとなっていました。それは16msかかりますね。翻訳のミスでした。

すでに修正が出ているかも知れませんが、まだなら報告方法を調べて報告します。とりあえず、Weblateで修正して保存しました(2024/12/4)

プロファイラーで待ち時間が知りたい

Godotのプロファイラーですが、各項目にかかっている時間はわかるのですが、「待ち時間」が分かりません。

待ち時間がない

Godotのプロファイラーでは、個別の項目や、ある処理グループにかかった時間はわかるのですが、それらがフレームの更新時間内にどのような順序で、総合してどれぐらい時間を使っていて、余っている時間がどれぐらいかが分かりません。見落としがありそうな気がしますが、見つけることができませんでした。

UnityのProfilerでは、以下のようにターゲットの時間に対するCPUとGPUの使用率を見ることができます。

UnityのProfiler

また、スレッドごとにかかった時間を、並べて見せてくれます。処理の余力など、把握しやすくなっています。

GODOT DOCSのThe Profiler — Godot Engine (stable) documentation in Englishを見ると、主要なデータとして、Frame time, Physics frame, Idle time, Physics timeという項目が示されています。名前的に、Idle timeがフレーム更新までの待ち時間だろうと思ったのですが、プロファイラーに見当たりません。はて?と思ってマニュアルを読んでみると、Idle timeは、_processを含めた物理更新以外にかかった時間の合計だと書かれていました。どうやら、Process Timeのことを、以前はIdle timeと書いていたようです。本来のIdle timeが欲しい・・・。

まとめ

Webブラウザーだと、互換性レンダラーでもPCよりもガタつきが気になりません。問題になるのは、WinやMac上で、互換性レンダラーを使った場合のようです。PC向けなら、通常はForward+を使うので、想定されていないのかも知れません。

うちの古いMBPのように、Vulkanが安定して動かないビデオカードを持ったPCでは、互換性レンダラーにしないとGodotが落ちて使えません。Godot4.4で、Metalの対応がはじまったようですが、残念ながらインテルMacは当面は対象外とのことでした。

Dev snapshot: Godot 4.4 dev 1 – Godot Engine

別件ですが、Forward+やモバイルは、互換性に比べて、入力への反応が鈍いような気がしました。フレームバッファの扱いが違っているからかも知れませんし、気のせいかもしれません。

今回の件は、Windowsがなければ解明できませんでした。複数の環境を持っているのは大切ですね。また、Mac向けには、通常のForward+版だけではなく、互換性レンダラー版のビルドを用意しないと、落ちる可能性があるのは気を付けたいところです。

本記事は、Qiita Advent Calender 2024Godot Engine Advent Calender 2024シリーズ1の4日目の記事でした。

qiita.com

参考URL

技術書典17に出展してきました!!制作スケジュール、持ち物リスト、頒布数など

11/3(日)に、池袋のサンシャインシティで開催された技術書典17に、オフライン出展してきました。

展示ブース

準備から、展示、成果の報告です。

目次

進行

制作の進行です。

6/23 プロジェクト作成

6/23に、ReVIEWのプロジェクトを作成して、制作を始めました。会場で、本の内容に興味を持ってくださったお客さんにもオススメできる入門本を模索していたのですが、構想が間に合わず。新しいバージョンが出たり、ゲームジャムに参加して知見も溜まっていたので、Godotの研究ノートの続編でいくことにしました。

今回も、TechBoosterさんのReVIEW-Templateをひな形にしました。この辺りについては、以下の記事にまとめています。

この手順で、新しいプロジェクトを作成して、前回の書籍のコンフィグなどを移植すれば、プロジェクトのできあがりです。

8/5-10/4 執筆

プロジェクトを作ってからしばらくは、気づいたことをメモする程度でした。8/5に、書きたい内容を並べて、目次を書き始めました。

8月から9月上旬までは、思いついたり、調べたことを、散発的に書いていました。本格的に書き始めたのは、9月中旬からです。ここからは、ほぼ毎日何らかの記事を書いていきました。それから3週間ほどで、草稿を書き終えました。

10/5-10/13 草稿完成と仕上げ

草稿ができたら、推敲と画像の作成です。スマホにPDFを入れて、電車の移動中などに推敲をします。自宅に戻ったら、画像のスクショとサイズ調整を進めました。

以下のようなことをやりました。

  • スクリーンショットの大きさ調整
    • PDFを確認して、スクショの文字が、本文より少し小さくなる程度に調整。スケール指定は使わず、スクショ画像の左右の余白の幅で調整
  • リンクのURLの設定
    • URLの表示とリンクの設定は、@<href>{URL}で良い。ラベルにURLを書くと、自動的に折り返してくれない
  • 改ページ位置の調整
    • 図やリストの見出しが、別ページに分かれているような場所があれば、//pagebreakで改ページ位置を調整。//pagebreakタグは、textlintでエラーになってしまうので、最後に調整。エラーにしない方法を調査したい
  • epubのエラー対策
    • デフォルトのままepubを出力すると、/OEBPS/style.cssでエラーが出る。articles/style.cssを開いて、エラー個所(images/html_header.jpgを指定している行)を消して対処

10/14 入稿

印刷は、初参加の時から、技術書典のバックアップ印刷所の一つである日光企画さんにお願いています。出来上がった本は、イベント当日に、直接会場に届けてくれるので、荷物が減らせて助かります。また、前から印刷や後から印刷などのキャンペーンに対応しています。

本文は、前述したTechBoosterさんのReVIEW-Templateプロジェクトで作成したPDFデータを、そのまま入稿しています。

表紙は、RGB形式のPSDで出力したものを、本文とまとめてZIP圧縮して入稿しました。日光企画さんのオンデマンド印刷は、RGB入稿に対応しています。テンプレートは、日光企画さんのWebサイトからダウンロードできます。CMYK形式なので、RGBに変換してから、UnityやGIMPでデータを作成して、PSDにエクスポートしました。

今回は、念願の早割りに間に合ったことで、ページ数が増えても、紙の本の値段は前回と同じにできました。

10/14-11/2 おまけ作業

早めに入稿できたので、本書に関連する作品ということで、ゲームジャムに中途半端なまま投稿してしまった作品の仕上げをしていました。一本は、Kenney Jam 2024 - itch.ioに投稿したMinecart Rails by たなかゆう-TANAKA Yu-です。

Minecart Rails

am1tanaka.itch.io

レールが接続されていなくてもゴールできてしまったり、ステージ数が2つしかなかったりと、もろもろ不完全な状態でした。気づいたバグはすべて修正して、ステージを10個に増やしました。4.2.2で作成していたので、4.3にバージョンアップしました。

インスタンスの受け渡し方や、警告システムの研究は、本作品が土台になっています。改行で変な文字が出力されたことから、ビットマップフォントの使い方を調べるきっかけにもなりました。

STORM OF BALLS

am1tanaka.itch.io

こちらは、Brackeys Game Jam 2024.2 - itch.ioに投稿した作品です。締め切り時間を間違えていたことに、締め切りの30分前に気づいて、未調整なまま慌てて投稿しました。操作説明が不十分で伝わりにくかったり、バリケードがパワーゲージに表示されているのに使えなかったり、パワーアップやミス時のバランスが悪かったりと、散々な状況でした。バグを解消して、未実装だったバリケードを入れて、ボールの動きの改善、ゲームバランスの調整と、かなり手を入れました。

ShapeCastやアニメーションの章は、本作品の成果です。

グラチェン!!

godotplayer.com

この作品は、「Godotでゆるっと!ゲーム制作祭」に投稿したものです。ゲームシステムや、警告システムの利用方法を模索している時期でした。

ゲームシステムの開発に時間を使ってしまって、ゲーム内容がおざなりになってしまいました。他の2作品と違い、ゲームの核となるアイディアが弱いのが悩みです。中途半端に手を加えてもあまりよくならなさそうなので、改良は見送りました。

10/27-11/2 展示準備

おまけ作業と並行して、展示準備を進めました。最低限のものは揃っているので、新規に用意したのは、新刊の情報や、前回の展示中に欲しいと感じた次のものです。

  • 配布用のカード
  • 値段表ポスター
  • A5本用スマート本棚
  • 見本誌のビニールカバー

配布カードと値段表ポスター

表紙もそうですが、画像はUnityで作成することが多いです。Godotの本なので、Godotで作りたいところでしたが、締め切りが迫っていたので、使い慣れているUnityで済ませました。350dpiで必要なピクセル数を計算して、Gameビューの解像度に設定して、Unity RecorderでPNGファイルを出力します。それをLibre Office Writerに貼り付けて、PDFでエクスポートしました。

印刷は、ファミマのネットプリントを利用しました。光沢紙より普通紙の方が発色が好みなので、普通紙を選んでいます。印刷したものを家に持ち帰って、カッターで切って準備完了です。

スマート本棚

見本誌を飾るためのダンボール製の棚を、印刷をお願いした同人工房さんで購入しました。

B5本用とA5本用があって、A5用を購入しました。値段は1,210円(税込み)でした。

スマート本棚A5

パズル本はB5ですが、少しはみ出すだけなので、このサイズで正解でした。

見本誌のビニールカバー

見本誌には、前述の写真のように見本誌カードや、新刊シールを貼りたかったので、ビニールカバーを用意しました。100円ショップのセリアに売っている「テープ付きクリアファイル」を購入して、以下のサイトを参考に自作しました。

透明ブックカバーの作り方!同人誌の見本などにおすすめ。コミケ前でお急ぎの方に!|お絵かき図鑑

1セットに数枚入っているので、A5本用にA4クリアファイル1セット、B5本用にB4クリアファイル1セットで済みました。

見本カードは、自分で印刷したものと、前回の技術書典の手ぶらセットでもらっていたカードを自分でパウチしたものを、はがせる両面テープで貼り付けました。

持ち物リスト

当日の持ち物リストです。

  • 既刊本
  • 見本誌用のビニールカバー
  • テーブルクロス。技術書典16の手ぶらセットでもらったやつ
  • 値段表パネル。ファミマプリントでカラー印刷したものを、B4のカンパネに貼りました
  • イーゼル。100円ショップで購入。値段表パネルを立てかけるのに利用
  • スマート本棚
  • スマホスタンド。サークルカードを立てるのに利用
  • 配布カード
  • 見本カード
  • 見本カードを貼り付けるための剥がせる両面テープ
  • カロリーメイトウィダー的なやつ
  • 飲み物
  • ゴミ袋
  • 文房具(カッター、ハサミ、カッティングシート、テープ、ボールペン、サインペン)
  • モバイルバッテリーとケーブル

これらを、キャリーバッグと、リュックに詰めて、荷造り完了です。

搬入荷物

11/3 イベント当日

設営

いい天候になりました。

池袋駅からサンシャインを望む

開場予定時刻の10分前の9時20分に到着すると、すでに入場できるようになっていました。ブースには、無事に新刊が到着していました。

新刊到着!!

事前に作っていった新刊用のビニールカバーがピッタリで、早めに入場できたこともあって、余裕を持って設営を終えられました。PCでの実演がないと、準備が楽です。

展示ブース

展示

オフライン展示のメリットは、なんといっても、立ち寄ってくださった方と交流できることです。次のようなことをお話しました。

  • 無料で使えるGodotに興味がある
  • Unityから移行するとどんな感じか
  • ゲームの設計談義
  • 手軽なはじめ方(ゆるっと本と公式ドキュメントをご紹介)
  • 前作のご感想

デジゲー博が同日開催だった影響からか、ゲーム開発勢のお客さんが少ない印象でした。春に比べると、のんびりできる時間が多く、次回作に向けた構想などを練りながら過ごしました。

以上、展示までの進行でした。

成果

会場での配布や販売数です。

  • 配布カードの配布数:5枚
  • 新刊のGodot研究ノート2
    • 紙+電子版:6冊
    • 電子版  :1冊
  • 前回のGodot研究ノート
    • 紙+電子版:2冊
  • 2回前のUnity用のパズル本
    • 紙+電子版:3冊
    • 電子版  :3冊

オンラインでも、ほぼ同数の本をご購入いただけました。オフラインとオンラインあわせて、紙本の目標販売数である10冊を超えられました。

会場の紙の受注生産メニューは、会場で売り切れるまでは無効にしておく

深く考えずに、受注生産を設定していたところ、そちらでご購入した方がいらっしゃいました。会場では、購入画面の値段だけ見て本をお渡ししていたので、帰宅して、売り上げ管理を見るまで気づきませんでした。

IDがあるので、送付はキャンセルできるかも知れませんが、あちこちにお手間をとらせてしまいます。印刷の予備もありますし、たった一冊のためにご迷惑をかけるのも何なので、そのままお送りすることになりそうです。

本が複数あると、メニューがずらっと並んでしまうので間違えるのはわかります。次回は、会場で紙の本が売り切れるまでは、受注生産のメニューはオフにしておこうと思いました。

まとめと次回に向けて

日程がデジゲー博と被っていたことが影響しているかはわかりませんが、春に比べると、ゲーム開発勢のお客さんが少なかった印象です。それでも、目標の販売数を達成できました!既刊も、ぼちぼちご購入いただいており、嬉しい限りです。ご購入くださったみなさま、ありがとうございます!!今回の新刊は、30冊印刷しました。余り過ぎず、なくなりもせず、丁度よい数でした。

前回も今回も、既刊本の在庫をすべて持って行ったのですが、数部あれば十分だと展示中に気づきました。本の種類が増えたらどうしようかと考えていましたが、既刊本の数を減らせばよいだけでした。

配布カードは、全ての本を載せたサークル紹介のチラシ1種類にした方が良いかもしれないと感じました。3種類もあると、どれを持っていったら良いか迷われますし、一枚一枚取るのが面倒そうで、ご負担をおかけしてしまいました。

以前から、なんらかの技術を試せる手引きのような書籍を出したい、と考えているのですが、構想がまとまらず現在に至っています。お客さんから、Godotの実務的な使い方や考え方を手っ取り早く知りたい、というお話をちょくちょく伺いました。そういう切り口なら、既存の入門本と違う切り口で書けるかもしれません。また、シェーダーを調査する予定があるので、その入門を書くのもよいかもしれません。次回こそ、はじめてのなんとか系の本をラインナップに加えたいものです。

紙と電子本のセットは、2024/11/17(日)までご購入いただけます。電子本は、オンラインでいつでもご購入いただけます。ご興味があれば、よろしくお願いいたします!!

techbookfest.org

参考・関連URL

古いプリンターをWin11で使う

WindowsXP時代から使っていた複合プリンターを、Win11で動かす方法です。

メーカーからのデバイスドライバーはとっくにリリースされていないのですが、古いデバイスドライバーをWin11で動かすことができます。簡単な手順です。

  1. プリンターをUSBでPCに接続します
  2. プリンターの設定を確認すると、標準ドライバーでは動作しないようなことが表示されます
  3. Windows Updateを実行して、詳細オプションからオプションの更新プログラムを開きます
  4. ドライバー更新プログラムに表示されるプリンター用のドライバーを選択してインストールします

この時点で動けばよいのですが、バイナリーの整合性の問題が発生しました。その場合はその機能を切ります。

  1. Windowsの設定から、プライバシーとセキュリティを開きます
  2. Windowsセキュリティを開きます
  3. バイスセキュリティを開きます
  4. コア分離のメモリ整合性をオフにします
  5. Windowsを再起動します

以上で動作するようになりました。この機能を切ることによるリスクは、現時点では確認されていないとのことです。

Microsoft. メモリ整合性やコア分離はなぜ必要か?

ただ、せっかくのセキュリティ機能が無効になるのは気になりますし、さすがにもう20年も使っている機械なので、買い替えた方がいいかもしれませんね。

【Godot】エディターの文字などを小さくする

Godotを使っていて、Full HD(1920x1080px)のモニターだとどうにも画面が狭いと感じてました。エディター設定で簡単に変更できたので紹介します。

  • エディターメニューからエディター設定を開きます

エディター設定を開く

  • インターフェースのエディター設定から、表示スケール欄で、好きなスケールを設定します

表示スケールを設定

  • ウィンドウ右下の保存して再起動ボタンをクリックします

これで、Godotエディターが再起動して、表示が変わります。150%にしているのは、Mac Book Proでの設定です。自動だと200%になっていて、文字がデカかったのです。

設定前(200%)

設定後(150%)

WinでFull HDモニターの場合、75%程度がよいのではないかと思います。

【Godot】読み出し専用プロパティは作れないっぽい

Unityではgetterのみを用意することで読み出し専用のプロパティを作ることができました。Godotでも同様のことができると思っていて次のようなコードを作成していました。

@export var _data := 0
var data: int:
    get:
        return _data

意図としては、_dataをインスペクターに表示して設定できるようにしつつ、アクセス先はgetterを定義したdataにして、コードからの書き換えはできないようにするというものです。

ところがこれを実行すると、インスペクターにData欄が2つ表示されてしまいました。また、setterを定義していないはずのdataに値を代入してもエラーになりません。

Godotのドキュメントでプロパティのところを読んでも、getterのみの例がなく、そのような記載例もありません。どうもsetterを無効にすることができないようです。配列の数を確認するsize()などのプロパティでよさそうなものがメソッドだったのはそういう理由だったと腑に落ちました。

ということでまあまあ作っていた読み出し専用のつもりのプロパティをすべてメソッドに置き換えたのでした。なお、C#のように参照を全て検索、とか、名前を一括変換、とかができないので、全部手作業・・・。

おまけ

本格的に開発するにはGDExtensionとかで基礎を作った方がよいだろうかとも考えたのですが、こちら→こわくない!! たのしい!! GDExtension - Speaker Deckのスライドを拝見すると、プラットフォームごとにビルドが必要とか面倒そうなことが書いてありました。

なかなかベストな使い方が見つからないです。

【Godot4.x】インスペクターにユーザークラスの変数を表示する

Godotでは変数に@exportを付けるとインスペクターに表示して編集できます。Unityの[SerializeField]と同等のものです。表示できるのは組み込み型やNodeなどの一部のクラスなので、ユーザークラスはそのままでは出力できません。この制約もUnityと同様です。

Unityではユーザークラスに[Serializable]属性をつけることでインスペクターに表示できます。GodotではユーザークラスをResourceから継承します。

例えばインスペクターで編集したいデータのクラスを以下のように定義します。

class_name LoadSceneData
extends Resource

## シーンの読み込みデータ

## シーン
@export var scene: PackedScene

## 読み込み済みのシーンをリロードするときにチェック。チェックがなければ既存のシーンをそのまま使う。
@export var is_reload_when_exists: bool

これを利用するためのノードの子クラスを以下のように定義します。

class_name FuncLoadScenes
extends Node

## シーン読み込み機能

@export var scenes: Array[LoadSceneData]

このFuncLoadScenesクラスを任意のノードにアタッチすると、インスペクターに項目が表示されてリソースの新規作成や読み込みができるようになります。

リソースとして新規作成や読み込みができる

新規で要素を作成すればデータクラスの要素を編集できます。

要素を作成すれば属性を設定できる

Resourceのよいところは、設定したデータを保存できることです。

設定したデータは保存できる

データを他の場面で再利用するのに便利です。

配列をまとめて保存

保存はResourceごとに行うので上記の例では配列全体の保存ができません。配列として保存したい場合は、配列を定義したResourceを継承したクラスをさらに用意します。

class_name LoadScenes
extends Resource

@export var scenes: Array[LoadSceneData]

先のNodeのクラスの定義をLoadScenesに変更します。

class_name FuncLoadScenes
extends Node

## シーン読み込み機能

@export var scenes: LoadScenes

これで配列がResourceに含まれるようになるので配列をまるごと保存できるようになります。

配列をまとめて保存

まとめ

Godotでユーザークラスをインスペクターで編集するには、ユーザークラスをResourceから継承します。Resourceを継承したクラスにResourceを継承したクラスを持たせることができるので、扱いたい範囲に応じてクラスを用意します。Resourceクラスはデータの保存や読み込みができるのでUnityのプリセットのように利用することができます。

Resourceを継承したクラスで表示したい変数にも@exportが必要です。@exportをつけないとその変数はインスペクターには表示されなくなります。