てくメモ

trivial な notes

【Godot C#】Cysharp の次世代 Rx ライブラリ「R3」を Godot で使ってみる


【追記】 この記事はR3のプレビューリリース時に書かれたものであり、正式リリース (2024/2/16) においては内容が符合しない部分があります。



Cysharp からプレビューリリースされた次世代 Rx ライブラリ、「R3」を Godot(ver. 4.2.1)で試す。

R3はなんと、プレビューリリース時点で明示的に Godot (4.x) をサポートしている。というわけでこの記事では、R3 on Godot C# なコードを書いて動かしてみる。

また、白眉な特徴であるトラッキング機能も確認する。



導入

Nuget で導入するだけでもコア機能の利用は可。

ただし、フレームベース処理といったプラットフォームサポートを受けるには作業が必要になる。

(上記リンク先導入手順 3. の前に、書いてないけれど、一旦ビルドするといいかもしれない。(自分はそのままだとエラーが出た))

プロジェクトページにも導入方法は書いてあるが、一応以下に記述(追記:ver. 0.1.7以降変わったため折りたたみ)

古くなったため折りたたみ

  1. Nuget から R3 を導入
  2. ダウンロードするなり clone するなりして、R3 プロジェクトの src/R3.Godot にある addons ディレクトリを、導入するプロジェクトに置く
  3. 「プロジェクト」メニュー ⇨ 「プロジェクト設定...」 ⇨ 「自動読み込み」タブにおいて、R3.Godot 内の FrameProviderDispatcher.cs を設定

プロジェクト ⇨ プロジェクト設定 ⇨ 自動読み込み

最初の例

まずは R3 のプロジェクトページに示されている例を見る。(コメントは付したもの)

public partial class Node2D : Godot.Node2D
{
    IDisposable subscription;

    public override void _Ready()
    {
        subscription = Observable.EveryUpdate()
            .ThrottleLastFrame(10) // 10 フレームごとに
            .Subscribe(x =>
            {
                // フレームカウントを GD.Print
                GD.Print($"Observable.EveryUpdate: {GodotFrameProvider.Process.GetFrameCount()}");
            });
    }

    protected override void Dispose(bool disposing)
    {
        // 購読を解除
        subscription?.Dispose();
    }
}

Godot において、10フレームごとにプリントするというフレームベースの処理が実現されている。

EveryUpdateのようなジェネレーターメソッドによる Observable 生成、ThrottleLastFrameのようなオペレーターメソッドによる操作、Subscribeによる購読、購読は最後に破棄、と、外見は当たり前だが Rx。

一方で、再設計などによるパフォーマンス向上やAPIの洗練などが行われており、Rx ライブラリとして選ぶだけでそれらの恩恵を受けることができる。

良い。

マウスを追いかけるアイコン ~ キャンセルトークンによるライフタイム管理

それでは、実践として動くものを書く。

マウスを追いかけるアイコンを Rx で表現してみる。

https://i.imgur.com/EiQcmEZ.png

(動画は apng であり、表示・再生されない場合があるかもしれない)
(パーティクル素材:Kenney's particle pack)

public partial class MainScene : Node2D
{
    private readonly CancellationTokenSource cts = new();
    private IDisposable disposables;

    private Viewport viewport;
    private Node2D icon;
    private Label labelMousePosition;

    private ReadOnlyReactiveProperty<Vector2> mousePosition;
    private ReadOnlyReactiveProperty<Vector2> iconPosition;

    public override void _Ready()
    {
        // GetNode など省略(簡潔のため)
        
        // IDisposable をまとめるビルダー
        var d = Disposable.CreateBuilder();
        cts.AddTo(ref d);

        // マウス位置(10Fごと更新)
        mousePosition = Observable.EveryUpdate(cts.Token)
            .ThrottleLastFrame(10)
            .Select(_ => viewport.GetMousePosition())
            .ToReadOnlyReactiveProperty();

        // アイコン位置
        iconPosition = Observable.EveryUpdate(cts.Token)
            .Select(_ =>
            {
                var current = iconPosition.CurrentValue;
                const float q = 1 / 12f;
                var v = q * (mousePosition.CurrentValue - current);
                return current + v;
            })
            .ToReadOnlyReactiveProperty();

        // UI更新
        mousePosition.Subscribe(p => labelMousePosition.Text = $"{p}");
        iconPosition.Subscribe(p => icon.Position = p);

        // DisposableBuilder をビルドして単一の IDisposable を取得
        disposables = d.Build();
    }

    protected override void Dispose(bool disposing)
    {
        cts.Cancel();
        disposables?.Dispose();

        base.Dispose(disposing);
    }
}

時間が関わる処理を、名前をつけて宣言的に書けるのは Rx の美徳。EveryUpdateが生成する Observable をソースとして、ReadOnlyReactivePropertyでマウスとアイコンの位置を定義した。

そして、Observable.EveryUpdateにキャンセルトークンを渡した。

R3 では、ジェネレーターメソッドやTakeUntilメソッドなどにキャンセルトークンを渡すオーバーロードがある。ノード破棄時にキャンセルして紐つけた Observable を完了させられるほか、単純に任意タイミングのキャンセルとして用いることもできる。Task による非同期処理はキャンセルトークンを用いるので、並びとして統一的1

Subscribeの返り値をAddToしていないが、上流からキャンセルすれば Observable が完了し、購読は終了する。subscription、上から切るか(キャンセルトークン)、下から切るか(AddTo)。個人的には、上からの管理は明瞭に思える。

また、Disposable.CreateBuilder()を使った。これは複数のIDisposableAddToを通して投げ込み、最後にBuildすることで単一のIDisposableを取得できる。なお、R3 にはIDisposable構築手段が複数用意されているので、効率や記述の楽さなどの志向に合わせて選べるようになっている。

アイコンがマウスに追いついているときの処理を追加する ~ Observable の合成

Observable ストリームは、合成などの操作を行うことができる。

ここでは、アイコンがマウスに追いついたときの処理を追加してみる。具体的には、パーティクルを派手にする。

https://i.imgur.com/ekxc4rt.png

public partial class MainScene : Node2D
{
    // 前掲部分省略

    private GpuParticles2D iconParticles;

    private Observable<bool> areCloseMouseAndIcon;

    public override void _Ready()
    {
        // 前掲部分、GetNode など省略

        // アイコンとマウスが概ね同じ位置か
        areCloseMouseAndIcon = mousePosition.CombineLatest(iconPosition, static (p1, p2) =>
            {
                var v = p1 - p2;
                return MathF.Abs(v.X) < 4f && MathF.Abs(v.Y) < 4f;
            })
            .DistinctUntilChanged();

        // パーティクル
        areCloseMouseAndIcon.Subscribe(
            onNext: areClose =>
            {
                var pm = iconParticles.ProcessMaterial;
                if (areClose)
                {
                    // アイコンがマウスに追いついていたらパーティクルを派手に
                    // (冗長なので具体的な操作省略)
                }
                else
                {
                    // 通常に
                    // (冗長なので具体的な操作省略)
                }
            },
            onComplete: _ =>
            {
                // 完了時にパーティクルを止める
                iconParticles.AmountRatio = 0f;
            });
    }
}

マウス位置とアイコン位置の最新の値をCombineLatestによって合成することで、アイコンがマウスに追いついているか定義した。それを購読することでパーティクルのパラメータを変更している。

また、購読完了時(OnCompleted)の処理も書いてみた。エラー時(OnErrorResume)の処理も書ける。

ところで、R3ではエラー時に購読が解除されない。これは本家 ReactiveExtensions や UniRx などと異なる挙動で、継続する必要がある場合に購読し直しを頑張る必要がなくなっている。一方で、例外を見て何かをしたい場合はOnErrorResumeOnCompletedResult型で情報を取れる)で容易に書けるため、戦略を選択しやすくなっている。

なお、起きたエラーはUnhandledExceptionHandlerを通る。R3.Godot の標準の挙動はGD.PrintErr。この挙動を変更したい場合、例えばGD.PushErrorにしたいなら、次のようにすればよい。

ObservableSystem.RegisterUnhandledExceptionHandler(ex => GD.PushError(ex));

クリアを設定してゲームにする ~ シグナルの処理

せっかくなのでクリアを設定してゲームにしてみる。

ここでは Godot との連携という意味で、シグナルを使う。ゲームクリアをシグナルで通知し、それを R3 で処理してみる。クリアのゴールは目標エリアに指定回数タッチ、とする。

https://i.imgur.com/DLr2ZUz.png

(追記:試行錯誤中のコードを消し忘れていたのを修正しました)

public partial class MainScene : Node2D
{
    // 前掲部分省略

    private ColorRect targetRect;
    private Label labelCount;
    private Label labelTime;
    private ColorRect clearOverlay;
    private Label labelScoreTime;

    private readonly ReactiveProperty<int> touchCount = new(0);
    private ReadOnlyReactiveProperty<double> elapsedSeconds;

    [Signal]
    public delegate void GameClearEventHandler();
    
    public override void _Ready()
    {
        // 前掲部分、GetNode など省略

        // 矩形タッチ処理
        touchCount.AddTo(ref d);
        iconPosition
            .Where(p => targetRect.GetRect().HasPoint(p)) // 目標矩形の範囲内にアイコンがいたら
            .Subscribe(_ =>
            {
                touchCount.Value += 1; // タッチ回数++

                if (touchCount.Value < 5) // 5回繰り返す
                {
                    // 目標矩形再設定
                    var windowSize = viewport.GetWindow().Size;
                    var r = Random.Shared;
                    var x = r.Next(0, windowSize.X - (int)targetRect.Size.X);
                    var y = r.Next(0, windowSize.Y - (int)targetRect.Size.Y);
                    targetRect.Position = new(x, y);
                }
                else
                {
                    // ゲームクリアのシグナルを発火
                    EmitSignal(SignalName.GameClear);
                }
            });

        // 経過タイム(0.1秒単位)
        TimeProvider tp = GodotTimeProvider.Process;
        var startingTime = tp.GetTimestamp();
        elapsedSeconds = Observable.EveryUpdate(cts.Token)
            .ThrottleLast(TimeSpan.FromSeconds(0.1), tp)
            .Select(_ => tp.GetElapsedTime(startingTime).TotalSeconds)
            .ToReadOnlyReactiveProperty();

        // UI更新
        touchCount.Subscribe(v => labelCount.Text = $"{v} pts.");
        elapsedSeconds.Subscribe(v => labelTime.Text = $"{v:0.0}秒");

        // ゲームクリア
        Observable.FromEvent(
                h => new GameClearEventHandler(h),
                h => GameClear += h,
                h => GameClear -= h)
            .Take(1)
            .Subscribe(_ =>
            {
                labelScoreTime.Text = $"{elapsedSeconds.CurrentValue:0.0}秒";
                clearOverlay.Show(); // クリア画面を表示
                cts.Cancel(); // キャンセル発火
            })
            .AddTo(ref d);
    }
}

シグナルの扱いには、Observable.FromEventを利用した。シグナルはデリゲートを定義するが、conversion を渡すオーバーロードを利用することで簡潔に扱うことができた。

今回の例では一度だけ待つので、本来は async/await が適するかもしれない。ただ、イベントは Rx を自然かつ便利に活用しうる例として挙げられるもののひとつ。シグナルはイベントを利用する仕組みであるため、Godot と Rx との親和ポイントとして数えてもいいかもしれない。

なおイベントということは、UIイベントも Rx の範疇。この点、R3.Unity ではOnClickAsObservableといった便利拡張メソッド群が用意されている。R3.Godot では現状まだ。(【追記】追加されました (ver.0.1.7~)。(参考:【Godot C#】R3.Godot のUI用便利拡張メソッド - てくメモ))

また、クリア時にキャンセルを発火してみた。貼った動画画像のとおり、購読に任せていたUI更新はクリア時に停止。ノードの寿命で区切る意味のほか、こうした任意タイミングキャンセルもよさそうに感じた。


というわけで以上のように、宣言的な記述といった Rx の表現を重ねることで、単純ながら動くものをつくることができた。

R3 on Godot、楽しい。


なお、参考でノード構成とコード全容を折りたたんでおく。

ノード構成画像とコード折りたたみ(長い)

ノード構成

using System;
using System.Threading;
using Godot;
using R3;

namespace Rx3Playground;

public partial class MainScene : Node2D
{
    private readonly CancellationTokenSource cts = new();
    private IDisposable disposables;

    private Viewport viewport;
    private Node2D icon;
    private GpuParticles2D iconParticles;
    private ColorRect targetRect;
    private Label labelMousePosition;
    private Label labelCount;
    private Label labelTime;
    private ColorRect clearOverlay;
    private Label labelScoreTime;

    private ReadOnlyReactiveProperty<Vector2> mousePosition;
    private ReadOnlyReactiveProperty<Vector2> iconPosition;
    private Observable<bool> areCloseMouseAndIcon;
    private readonly ReactiveProperty<int> touchCount = new(0);
    private ReadOnlyReactiveProperty<double> elapsedSeconds;

    [Signal]
    public delegate void GameClearEventHandler();

    public override void _Ready()
    {
        viewport = GetViewport();
        icon = GetNode<Node2D>("Icon");
        iconParticles = GetNode<GpuParticles2D>("Icon/IconParticles");
        targetRect = GetNode<ColorRect>("Rect");
        labelMousePosition = GetNode<Label>("Hud/LabelMousePosition");
        labelCount = GetNode<Label>("Hud/LabelCount");
        labelTime = GetNode<Label>("Hud/LabelTime");
        clearOverlay = GetNode<ColorRect>("ClearOverlay");
        labelScoreTime = GetNode<Label>("ClearOverlay/VBoxContainer/LabelScoreTime");

        // UnhandledExceptionHandler を GD.PushError に
        ObservableSystem.RegisterUnhandledExceptionHandler(ex => GD.PushError(ex));

        // 購読トラッカー有効
        SubscriptionTracker.EnableTracking = true;
        //SubscriptionTracker.EnableStackTrace = true;

        // IDisposable をまとめるビルダー
        var d = Disposable.CreateBuilder();
        cts.AddTo(ref d);

        // マウス位置(10Fごと更新)
        mousePosition = Observable.EveryUpdate(cts.Token)
            .ThrottleLastFrame(10)
            .Select(_ => viewport.GetMousePosition())
            .ToReadOnlyReactiveProperty();

        // アイコン位置
        iconPosition = Observable.EveryUpdate(cts.Token)
            .Select(_ =>
            {
                var current = iconPosition.CurrentValue;
                const float q = 1 / 12f;
                var v = q * (mousePosition.CurrentValue - current);
                return current + v;
            })
            .ToReadOnlyReactiveProperty();

        // アイコンとマウスが概ね同じ位置か
        areCloseMouseAndIcon = mousePosition.CombineLatest(iconPosition, static (p1, p2) =>
            {
                var v = p1 - p2;
                return MathF.Abs(v.X) < 4f && MathF.Abs(v.Y) < 4f;
            })
            .DistinctUntilChanged();

        // 矩形タッチ処理
        touchCount.AddTo(ref d);
        iconPosition
            .Where(p => targetRect.GetRect().HasPoint(p)) // 目標矩形の範囲内にアイコンがいたら
            .Subscribe(_ =>
            {
                touchCount.Value += 1; // タッチ回数++

                if (touchCount.Value < 5) // 5回繰り返す
                {
                    // 目標矩形再設定
                    var windowSize = viewport.GetWindow().Size;
                    var r = Random.Shared;
                    var x = r.Next(0, windowSize.X - (int)targetRect.Size.X);
                    var y = r.Next(0, windowSize.Y - (int)targetRect.Size.Y);
                    targetRect.Position = new(x, y);
                }
                else
                {
                    // ゲームクリアのシグナルを発火
                    EmitSignal(SignalName.GameClear);
                }
            });

        // 経過タイム(0.1秒単位)
        TimeProvider tp = GodotTimeProvider.Process;
        var startingTime = tp.GetTimestamp();
        elapsedSeconds = Observable.EveryUpdate(cts.Token)
            .ThrottleLast(TimeSpan.FromSeconds(0.1), tp)
            .Select(_ => tp.GetElapsedTime(startingTime).TotalSeconds)
            .ToReadOnlyReactiveProperty();

        // UI更新
        mousePosition.Subscribe(p => labelMousePosition.Text = $"{p}");
        iconPosition.Subscribe(p => icon.Position = p);
        touchCount.Subscribe(v => labelCount.Text = $"{v} pts.");
        elapsedSeconds.Subscribe(v => labelTime.Text = $"{v:0.0}秒");

        // ゲームクリア
        Observable.FromEvent(
                h => new GameClearEventHandler(h),
                h => GameClear += h,
                h => GameClear -= h)
            .Take(1)
            .Subscribe(_ =>
            {
                labelScoreTime.Text = $"{elapsedSeconds.CurrentValue:0.0}秒";
                clearOverlay.Show(); // クリア画面を表示
                cts.Cancel(); // キャンセル発火
            })
            .AddTo(ref d);

        // パーティクル
        areCloseMouseAndIcon.Subscribe(
            onNext: areClose =>
            {
                var pm = iconParticles.ProcessMaterial;
                if (areClose)
                {
                    // アイコンがマウスに追いついていたらパーティクルを派手に
                    iconParticles.AmountRatio = 1f;
                    iconParticles.SpeedScale = 1.5f;
                    pm.Set("initial_velocity_min", 180f);
                    pm.Set("initial_velocity_max", 180f);
                    pm.Set("radial_accel_min", 75f);
                    pm.Set("radial_accel_max", 100f);
                    pm.Set("tangential_accel_min", 25f);
                    pm.Set("tangential_accel_max", 100f);
                }
                else
                {
                    // 通常に
                    iconParticles.AmountRatio = 1 / 6f;
                    iconParticles.SpeedScale = 1f;
                    pm.Set("initial_velocity_min", 0f);
                    pm.Set("initial_velocity_max", 0f);
                    pm.Set("radial_accel_min", 0f);
                    pm.Set("radial_accel_max", 0f);
                    pm.Set("tangential_accel_min", 0f);
                    pm.Set("tangential_accel_max", 0f);
                }
            },
            onComplete: _ =>
            {
                // 完了時にパーティクルを止める
                iconParticles.AmountRatio = 0f;
            });

        // DisposableBuilder をビルドして IDisposable を取得
        disposables = d.Build();
    }

    public override void _Input(InputEvent @event)
    {
        // for debug
        if (@event.IsActionReleased("ui_cancel"))
        {
            GD.Print("cancel and dispose.");
            cts.Cancel();
            disposables.Dispose();
        }
        else if (@event.IsActionReleased("ui_focus_next"))
        {
            var count = 0;
            SubscriptionTracker.ForEachActiveTask(state =>
            {
                ++count;
                if (SubscriptionTracker.EnableStackTrace) GD.Print(state);
            });
            GD.Print($"current subscription count: {count}");
        }
        else if (@event.IsActionReleased("ui_accept"))
        {
            GD.Print("GC.Collect()");
            GC.Collect();
        }
    }

    protected override void Dispose(bool disposing)
    {
        cts.Cancel();
        disposables?.Dispose();

        base.Dispose(disposing);
    }
}

購読のトラッキング

R3 には、購読のトラッキングが可能という特徴がある。

R3プロジェクトページの例を、トラッカー部分だけ抜き出すと以下。

SubscriptionTracker.EnableTracking = true; // default is false
SubscriptionTracker.EnableStackTrace = true;

// subscriptions

SubscriptionTracker.ForEachActiveTask(x =>
{
    Console.WriteLine(x);
});

これで生きている購読の情報(ID、FormattedType(購読の型的な情報)、時間、スタックトレース)がすべて列挙される。

ユーザーが認識しやすい購読だけでなく、内部の購読も含めスタックトレース込みですべてが出力されるため、実際の活用は少し考える必要がある。この点、R3.Unity ではエディタ用のUIが用意されている。

R3.Godot ではエディタUIはまだ用意されておらず、自前でさっと用意するための知見もなかったので、今回は試すために以下のデバッグ用キー入力を用いた。


【追記】エディタ用UIは追加されました (ver.0.1.7~)


public override void _Input(InputEvent @event)
{
    // for debug
    if (@event.IsActionReleased("ui_cancel"))
    {
        GD.Print("cancel and dispose.");
        cts.Cancel();
        disposables.Dispose();
    }
    else if (@event.IsActionReleased("ui_focus_next"))
    {
        var count = 0;
        SubscriptionTracker.ForEachActiveTask(state =>
        {
            ++count;
            if (SubscriptionTracker.EnableStackTrace) GD.Print(state);
        });
        GD.Print($"current subscription count: {count}");
    }
    else if (@event.IsActionReleased("ui_accept"))
    {
        GD.Print("GC.Collect()");
        GC.Collect();
    }
}

ラッキング件数を確認し、キャンセルとDisposeによって漏れがなく破棄ができるどうかをチェックする、というかたちで利用する。今回の例は、多くの購読の寿命を個別AddToでなく上流でのキャンセルに委ねたが、機能しているか確認できる。

件数確認 ⇨ 確認のため一度強制GC ⇨ 件数確認 ⇨ キャンセル + Dispose ⇨ 件数確認 ⇨ 強制GC ⇨ 件数確認 とした出力が以下。

current subscription count: 16
GC.Collect()
current subscription count: 16
cancel and dispose.
current subscription count: 8
GC.Collect()
current subscription count: 0

キャンセルとDisposeをし、GCを経たあとに購読の数がゼロになったことを確認できた。


強力な特徴なので是非使いたい。Godot にもエディタUIがほしい。

おわりに

R3 というライブラリは次世代 Rx ライブラリなのだけれど、次世代性をフィーチャーせずとも、単純に Rx on Godot の実現手段として素晴らしいと感じた。


Rx は現在では「使うべきところで使う」といったポジションに落ち着いていると思う。

ただ、使うと高い表現力があると感じるし、それがモダンに再設計されたライブラリで実現されるならなおさら嬉しい。

プレビューリリースの段階ということもあり、注目したい。


もしここまで読まれてまだ試していない Godot C#er の人がいましたら、是非触ってみてください。



  1. 実際には、Task との並びというよりかは Unity の destroyCancellationToken が念頭に置かれているよう