てくメモ

trivial な notes

【Godot Shader】2D Doodle エフェクトの作成

横着しようと AIに Unity 用シェーダーの Godot ポートをお願いしてみたところ、上手くいかなかった。

なので、手を動かして学習する。ここでは、Doodle エフェクト1を、Sprite2D にかけてみる。(Godot 4.x)


最初に成果物。以下の画像のような感じ。(外部サイト投稿の apng なので表示・再生されないことがあるかもしれない)

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

書いたものは以下。

// I referred to the following article.
// https://www.alanzucconi.com/2019/04/16/sprite-doodle-shader-effect/

shader_type canvas_item;

uniform float doodle_factor = 0;
uniform float doodle_scale : hint_range(1, 20, 0.05) = 5;
uniform float doodle_snap : hint_range(0, 1, 0.05) = 0.25;
uniform float noise_radius : hint_range(0, 0.1, 0.0005) = 0.0050;

vec2 random2(vec2 p) {
    return fract(sin(vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)))) * 43758.5453);
}

float snap(float x, float snap) {
    return snap * round(x / snap);
}

void vertex() {
    vec2 time = vec2(snap(TIME, doodle_snap), doodle_factor);
    vec2 noise = random2(VERTEX + time) * doodle_scale;
    VERTEX += noise;
}

void fragment() {
    vec2 noise = random2(UV) * noise_radius;
    COLOR = texture(TEXTURE, UV + noise);
}

学習

ドキュメントは以下。日本語は翻訳が疎らなので英語にリンクする。

GLSL

ドキュメントによれば、シェーダー言語は GLSL ES 3.0。

Unity が HLSL なので、ポートしようとすると独自機能以外にここで引っかかる。
以下のような記事がある。

また、以下のような GLSL に慣れ親しんでいる人向けのガイドがあった。

CanvasItem shaders

CanvasItem shaders(2D シェーダー)は、シェーダー先頭でshader_type canvas_item;を宣言する。

ドキュメントは以下。

uniform 変数

シェーダーパラメータとしてuniform変数を宣言できる。

uniform float doodle_factor = 0;
uniform float doodle_scale : hint_range(1, 20, 0.05) = 5;
uniform float doodle_snap : hint_range(0, 1, 0.05) = 0.25;
uniform float noise_radius : hint_range(0, 0.1, 0.0005) = 0.0050;

初期値を代入できるほか、エディタ用のヒントを付与することができる。

エディタからUI編集できる

乱数

vec2 random2(vec2 p) {
    return fract(sin(vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)))) * 43758.5453);
}

例えばこの短いメソッドでも HLSL との間には違いがある。(fracfractfloat2vec2

頂点シェーダー

float snap(float x, float snap) {
    return snap * round(x / snap);
}

void vertex() {
    vec2 time = vec2(snap(TIME, doodle_snap), doodle_factor);
    vec2 noise = random2(VERTEX + time) * doodle_scale;
    VERTEX += noise;
}

(引数で渡される型ではなく、)各種のビルトインについて操作を行う。ここでは、VERTEXにノイズを足した。

時間経過を利用した表現には、ビルトイン変数であるTIMEが利用できる。この効果では適度に間引いた間隔としたいので、一定間隔にスナップした値を使用した。

フラグメントシェーダー

void fragment() {
    vec2 noise = random2(UV) * noise_radius;
    COLOR = texture(TEXTURE, UV + noise);
}

頂点シェーダーと同様に、各種ビルトインを用いる。ここでは、TEXTUREUVCOLORを利用している。

なお、スクリーンを取得するSCREEN_TEXTUREが Godot 4.x で廃止されており、Web上(例えば Godot Shaders など)にある例がコピペで動かない原因のひとつになっている。(エディタ上でどうしたらよいかの専用エラーメッセージが出てはくれる)


というわけで、冒頭のような 2D Doodle エフェクトシェーダーを作成することができた。

最初戸惑ったが(HLSLとの差異、ver. 3.x ⇨ 4.x による変更でWeb上のものを参考にするのにひと手間が必要になる場合がある、など)、手を動かすことで理解は進んだ。シェーダーはシェーダーそのものの沼が深いので、手前の部分はできれば戸惑わないようにしたい。



  1. 同様の効果を表す違う名前がいくつかあるみたいなので、馴染みのある名前に読み替えてください。

【Godot C#】標準ライブラリと被っていない Mathf メンバー

Godot C#Mathfクラスは、GDScript の数学定数・数学関数に対応するメンバーを提供している。

そのなかには、C# における標準ライブラリ提供のメンバーとオーバーラップしているため、処理がそのまま委譲されているものもある。

独自に提供されている機能を手軽に確認できるよう1、ソースをざっと確認してリストした。

  • Godot 4.2.x
  • .NET 8


一覧

name note
AngleDifference
BezierDerivative
BezierInterpolate
CubicInterpolate
CubicInterpolateAngle
CubicInterpolateAngleInTime
CubicInterpolateInTime
DbToLinear
DecimalCount
DegToRad
Ease
Epsilon Field
InverseLerp
IsEqualApprox
IsZeroApprox
Lerp
LerpAngle
LinearToDb
MoveToward
NearestPo2
PingPong
PosMod
RadToDeg
Remap
RotateToward
SmoothStep
Snapped
Sqrt2 Field
StepDecimals
Wrap

一覧(メンバーすべて)

name delegated as is note
Abs
Acos
Acosh
AngleDifference
Asin
Asinh
Atan
Atan2
Atanh
BezierDerivative
BezierInterpolate
Ceil MathF.Ceiling
CeilToInt (int)MathF.Ceiling
Clamp
Cos
Cosh
CubicInterpolate
CubicInterpolateAngle
CubicInterpolateAngleInTime
CubicInterpolateInTime
DbToLinear
DecimalCount
DegToRad
E Field. not delegated as is, but Equals
Ease
Epsilon Field
Exp
Floor
FloorToInt (int)MathF.Floor
Inf Field. float.PositiveInfinity
InverseLerp
IsEqualApprox
IsFinite
IsInf float.IsInfinity
IsNaN
IsZeroApprox
Lerp
LerpAngle
LinearToDb
Log
Max
Min
MoveToward
NaN Field
NearestPo2
Pi Field. not delegated as is, but Equals
PingPong
PosMod
Pow
RadToDeg
Remap
RotateToward
Round
RoundToInt (int)MathF.Round
Sign
Sin
SinCos
Sinh
SmoothStep
Snapped
Sqrt
Sqrt2 Field
StepDecimals
Tan
Tanh
Tau Field. not delegated as is, but Equals
Wrap

補足

Lerp

float.Lerpについて補足。一応、結果が異なりうる。

// Mathf.Lerp; same for Unity
from + ((to - from) * weight);
// float.Lerp
(from * (1.0f - weight)) + (to * weight);

NearestPo2

標準ライブラリに存在するBitOperations.RoundUpToPowerOf2を紹介する。同様の値を取得するが、可能な場合には高速な専用命令を使ってくれる。

var answer = BitOperations.RoundUpToPowerOf2(42);
// 64

可能でない場合でもNearestPo2と同様のアルゴリズムにフォールバックするので、ほぼ使い得(のはず)。



  1. なおパフォーマンス面では、コンパイラがインライン化するため使い分けたりする必要はない(はず)

【Godot C#】Godot C# における Rx、または ProcessAsObservable を自前で用意する方法


【追記】

UniRx の Cysharp から次世代 Rx ライブラリがリリースされ(2024/1~)、明示的に Godot がサポートも含まれています。オレオレ Rx をする理由がなくなりました。



Godot における Rx (Reactive Extensions) について、GDScript には GodotRx がある。

一方、C# においては次のプロジェクトがあるが、最終コミットは記事現在で3年前。メンテナンスされていないようだ。

Godot 4.x で使いたい場合には、自前でのキャッチアップが必要になる。(なお、フォークを覗くと 4.x 対応させていそうなものがあるのも触れておく(宣伝しているわけではなさそうなので触れる程度に))


改めて考えてみると、Rx を使いたいモチベーションは次の2点。

  • IObservableで通知 (push) 型の処理を書きたい
  • UI を楽に取り扱いたい

UIに応じたAPIが必要な後者はともかく、前者については ReactiveExtensionsReactiveProperty を導入すれば実現自体は可能。

問題は、そのままだと Godot に寄り添っていない点。例えば、処理ループをIObservable化するProcessAsObservabe的なものはほしい。

というわけで、Godot 理解の推進も兼ねてそれを用意してみる。

ProcessAsObservableの用意

  • 参考:前掲の C# 版 GodotRx 、UniRx
  • ReactiveExtensions 導入前提

処理ループをIObservable化するには、Subject<T>を用意してIObservable<T>として公開し、処理ループのなかでOnNext(T)を発行すればよい。以下のようなクラスを用意する。

internal partial class ObservableProcessTrigger : Node
{
    public const string SearchTerm = "__ObservableProcessTrigger__";

    private Subject<double> process;
    private Subject<double> physicsProcess;
    private Subject<InputEvent> input;

    public IObservable<double> ProcessAsObservable() => process ??= new Subject<double>();
    public IObservable<double> PhysicsProcessAsObservable() => physicsProcess ??= new Subject<double>();
    public IObservable<InputEvent> InputAsObservable() => input ??= new Subject<InputEvent>();

    public override void _Process(double delta) => process?.OnNext(delta);
    public override void _PhysicsProcess(double delta) => physicsProcess?.OnNext(delta);
    public override void _Input(InputEvent @event) => input?.OnNext(@event);

    protected override void Dispose(bool disposing)
    {
        static void disposeCore<T>(Subject<T> subject)
        {
            if(subject is not null)
            {
                subject.OnCompleted();
                subject.Dispose();
            }
        }

        if(disposing)
        {
            disposeCore(process);
            disposeCore(physicsProcess);
            disposeCore(input);
        }
        base.Dispose(disposing);
    }
}

IObservableは用意できたが、このままではこのノードのものでしかない。

そこで、これをProcessAsObservableしたい対象ノードに潜り込ませる。

internal static class Observable
{
    public static IObservable<double> ProcessAsObservable(this Node node)
        => GetOrAdd(node).ProcessAsObservable();
    public static IObservable<double> PhysicsProcessAsObservable(this Node node)
        => GetOrAdd(node).PhysicsProcessAsObservable();
    public static IObservable<InputEvent> InputAsObservable(this Node node)
        => GetOrAdd(node).InputAsObservable();

    private static ObservableProcessTrigger GetOrAdd(Node node)
    {
        var trigger = node.GetNodeOrNull<ObservableProcessTrigger>(ObservableProcessTrigger.SearchTerm);
        if(trigger is null)
        {
            trigger = new ObservableProcessTrigger() { Name = ObservableProcessTrigger.SearchTerm };
            node.AddChild(trigger);
        }

        return trigger;
    }
}

これでNode.ProcessAsObservable()が書けるようになった。

試用

単純にジャンプをするような例で試してみる。

  • ジャンプには接地が必要
  • ジャンプにはクールダウンあり
  • クールダウン状況をUI表示する
  • ReactiveExtensions, ReactiveProperty 利用

参考:ノードツリー

public partial class Player : CharacterBody2D
{
    CompositeDisposable disposables = new();

    private bool isOnGround = false;
    private Vector2 motion = Vector2.Zero;
    private ReactivePropertySlim<int> Cooldown { get; set; } = new();
    private ReadOnlyReactivePropertySlim<bool> NeedsCooldown { get; set; }

    public override void _Ready()
    {
        const float jumpPower = -1000f;
        const float g = 50f;
        const int jumpCooldownFrame = 90;

        UpDirection = Vector2.Up;

        NeedsCooldown = Cooldown
            .Select(v => 0 < v)
            .ToReadOnlyReactivePropertySlim().AddTo(disposables);

        // ProcessAsObservable でクールダウン
        this.ProcessAsObservable()
            .Where(_ => NeedsCooldown.Value)
            .Subscribe(_ => --Cooldown.Value).AddTo(disposables);

        // InputAsObservable でジャンプ設定
        this.InputAsObservable()
            .Where(ev => ev.IsActionPressed("ui_accept"))
            .Where(_ => isOnGround && NeedsCooldown.Value is false)
            .Subscribe(_ =>
            {
                motion.Y = jumpPower;
                Cooldown.Value = jumpCooldownFrame;

            }).AddTo(disposables);

        // PhysicsProcessAsObservable で物理
        this.PhysicsProcessAsObservable()
            .Subscribe(_ =>
            {
                isOnGround = IsOnFloor();
                if (isOnGround is false)
                {
                    motion.Y += g;
                }

                Velocity = motion;
                MoveAndSlide();

            }).AddTo(disposables);

        // クールダウンのプログレスバー反映
        var progress = GetParent().GetNode<ProgressBar>("ProgressBar");
        progress.MaxValue = jumpCooldownFrame;
        progress.Value = jumpCooldownFrame;
        Cooldown
            .Subscribe(v => progress.Value = jumpCooldownFrame - v).AddTo(disposables);
    }

    protected override void Dispose(bool disposing)
    {
        if (disposing) disposables.Dispose();
        base.Dispose(disposing);
    }
}

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

普通に書いてもいい例だけど、宣言的な記述にできるポイント。
また、Model ⇨ View はやはり ReactiveProperty があると嬉しい。


というわけで、ProcessAsObservable的なものを実現するための方法を確認できた。

とはいえ、寄り添ったライブラリが使いたい、というのが正直なところ、ではある。

【Godot C#】async/await な流れで落ち物ゲームをつくる

本記事は、Godot Engine Advent Calendar 2023 参加記事となっています。

空きのある日があったので、枯れ木も山の賑わいとばかりに参加させていただきました。不勉強な部分があるかと思いますが、よろしくお願いいたします。


以下の画像(外部サイト投稿の apng につき、表示・再生されない場合があるかもしれません)のような雰囲気の落ち物ゲームを、async/await なフローの処理でつくってみました。そのアプローチについての記事です。

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

(ゲーム利用の画像素材:Blue Archive (Global version) の digital merch より)

イカゲームライクの落ち物です。


本記事では、async/await のアプローチに焦点を当て、全体は示しません。Godot でのスイカゲームライク落ち物については、プロジェクト全体を公開のうえで解説されている記事がありますので、紹介します。

また、昨年のアドベントカレンダーにおいて、GDScript による同様のアプローチを検証されている記事がありますので、紹介します。



メインノード

async/await のフローを使えると何が嬉しいのか。それは、状態遷移を自然に書けることです。

例えば、メインノードのスクリプトは以下のようにしました。

参考:ノードツリー

public partial class Main : Node2D
{
    public override void _Ready() => _ = LoopAsync();

    private async Task LoopAsync()
    {
        var startScene = GetNode<StartScene>("StartScene");
        var mainGame = GetNode<MainGame>("MainGame");
        var resultScene = GetNode<ResultScene>("ResultScene");

        // シーン(1):Click to Start。入力を待機
        await startScene.WaitAsync();
        startScene.QueueFree();

        for(bool continuesToPlay = true; continuesToPlay;)
        {
            resultScene.Hide();

            // シーン(2):メインゲーム。ゲームオーバーを待機し、スコア結果を取得
            int score = await mainGame.RunAsync();

            // シーン(3):リザルト。スコア結果を表示し、再度プレイするかの入力を待機
            continuesToPlay = await resultScene.ConfirmAsync(score);
        }

        // ゲーム終了
        GetTree().Quit();
    }
}

このゲームは、① 起動したら入力を待機する ⇨ ②メインの落ち物 ⇨ ③リザルトを表示しゲームを再度プレイするか確認する ⇨ 続けるなら再度②・続けないなら終了、という流れで構成されています。

async/await を利用することで、その流れのとおりに処理を書くことができます。また、シーン状態フラグ等を自前で管理する必要がありません。嬉しい。

というわけで、こうした await を実現するために各シーンのスクリプトを書いていきます。

Click to Start シーン(単純な入力待機)

まずは Click to Start なスタートシーン。決定アクション (ui_accept) かマウスクリックを待機することにします。

Click to Start

こうした単純な待機であれば、ToSignalawaitすればよいでしょう。

public partial class StartScene : CanvasLayer
{
    [Signal] // Signal 属性 + EventHandler サフィックス命名でソースジェネレーターが諸々を自動生成してくれる
    public delegate void ClickEventHandler();

    // ToSignal で await。SignalName.Click は自動生成されている
    public async Task WaitAsync() => await ToSignal(this, SignalName.Click);

    public override void _Input(InputEvent @event)
    {
        if (@event.IsActionPressed("ui_accept")
            || @event.IsActionPressed("mouse_click_left")) // (※ このアクションは追加したもの)
        {
            EmitSignal(SignalName.Click); // EmitSignal で発火
        }
    }
} 

メインゲーム(ゲームオーバーを待機しながら落ち物)

次にメインゲームの落ち物を考えます。

今回は、デッドライン到達でのゲームオーバーでのみ、シーンが終了するとします。流れは次のようになります。

[Signal]
public delegate void ReachDeadEventHandler();

public async Task<int> RunAsync()
{
    // 開始処理
    await OnStarting();

    // ゲームオーバー(キャンセル)まで落下動作をループ
    using CancellationTokenSource cts = new();
    _ = ReleaseLoopAsync(cts.Token);

    // ゲームオーバーを待機
    await ToSignal(this, SignalName.ReachDead);

    // 終了処理
    OnClosing();
    cts.Cancel();

    // スコアを返す
    return Score.Value;
}

落下動作はゲームオーバーまで繰り返します。ゲームオーバーを待機するとすれば、落下動作の繰り返しはゲームオーバー時にキャンセルしなければなりません。


キャンセルの実現方法ですが、今回は Unity における UniTaskAsyncEventHandler の仕組みを参考とすることにしました。

Godot における UniTask ライクライブラリの GDTask では、AsyncEventHandler は(おそらく)ポートされていないようでした。というわけで、自前で用意します。以下に折りたたみます。

AsyncEventHandler 折りたたみ

internal static class AsyncEventHandler
{
    public static AsyncEventHandler<Action> Create(Action<Action> addHandler, Action<Action> removeHandler, bool callOnce, CancellationToken cancellationToken)
        => new(addHandler, removeHandler, callOnce, cancellationToken);

    public static AsyncEventHandler<TEventHandler> Create<TEventHandler>(Action<TEventHandler> addHandler, Action<TEventHandler> removeHandler, bool callOnce, CancellationToken cancellationToken)
        where TEventHandler : Delegate
        => new(addHandler, removeHandler, callOnce, cancellationToken);

    public static ValueTask OnInvokeAsync(Action<Action> addHandler, Action<Action> removeHandler, CancellationToken cancellationToken)
        => new AsyncEventHandler<Action>(addHandler, removeHandler, true, cancellationToken).OnInvokeAsync();

    public static ValueTask OnInvokeAsync<TEventHandler>(Action<TEventHandler> addHandler, Action<TEventHandler> removeHandler, CancellationToken cancellationToken)
        where TEventHandler : Delegate
        => new AsyncEventHandler<TEventHandler>(addHandler, removeHandler, true, cancellationToken).OnInvokeAsync();
}

internal sealed class AsyncEventHandler<TEventHandler> : IValueTaskSource, IDisposable
    where TEventHandler : Delegate
{
    private static readonly Action<object?> cancellationCallback = CancellationCallback;

    private TEventHandler? handler;
    private Action<TEventHandler>? removeHandler;

    private readonly CancellationToken cancellationToken;
    private readonly CancellationTokenRegistration registration;
    private bool isDisposed = false;
    private readonly bool callOnce;

    private ManualResetValueTaskSourceCore<int> core;

    public AsyncEventHandler(Action<TEventHandler> addHandler, Action<TEventHandler> removeHandler, bool callOnce, CancellationToken cancellationToken)
    {
        this.cancellationToken = cancellationToken;
        if (cancellationToken.IsCancellationRequested)
        {
            isDisposed = true;
            return;
        }

        this.removeHandler = removeHandler;
        this.callOnce = callOnce;

        Action action = Invoke;
        handler = typeof(TEventHandler) == typeof(Action)
            ? (TEventHandler)(object)action
            : action.Method.CreateDelegate<TEventHandler>(action.Target); // reflection. some other way is preferable.
        addHandler(handler);

        if (cancellationToken.CanBeCanceled)
        {
            registration = cancellationToken.Register(cancellationCallback, this);
        }
    }

    public ValueTask OnInvokeAsync()
    {
        core.Reset();
        if (isDisposed)
        {
            TrySetException();
        }
        return new ValueTask(this, core.Version);
    }

    private bool TrySetException()
    {
        // sloppy TrySetCanceled
        if (core.GetStatus(core.Version) == ValueTaskSourceStatus.Pending)
        {
            core.SetException(new OperationCanceledException(cancellationToken));
            return true;

        }
        return false;
    }

    private void Invoke()
    {
        // sloppy TrySetResult
        if (core.GetStatus(core.Version) == ValueTaskSourceStatus.Pending)
        {
            core.SetResult(0);
        }
    }

    private static void CancellationCallback(object? state)
    {
        var self = (AsyncEventHandler<TEventHandler>)state!;
        self.Dispose();
    }

    public void Dispose()
    {
        if (isDisposed) return;

        isDisposed = true;
        registration.Dispose();
        if (removeHandler is not null)
        {
            removeHandler.Invoke(handler!);
            removeHandler = null;
            handler = null;
        }
        TrySetException();
    }

    void IValueTaskSource.GetResult(short token)
    {
        try
        {
            core.GetResult(token);
        }
        finally
        {
            if (callOnce)
            {
                Dispose();
            }
        }
    }

    ValueTaskSourceStatus IValueTaskSource.GetStatus(short token) => core.GetStatus(token);
    void IValueTaskSource.OnCompleted(Action<object?> continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) => core.OnCompleted(continuation, state, token, flags);
}

参考とした UniTask はUnityEventを扱うのですが Godot には無い(当たり前)ので、イベントハンドラのアタッチ・デタッチを渡すかたちにしています。また、色々と姑息的な部分は見逃してください。


さて、これを利用することで、落下動作の繰り返しは以下のように書けます。
(コメントの「モモフレンズ」は、スイカゲームにおける果物だと思ってください1

[Signal]
public delegate void ReleaseEventHandler();

private async Task ReleaseLoopAsync(CancellationToken cancellationToken)
{
    var releaseEventHandler = AsyncEventHandler.Create<ReleaseEventHandler>(
        h => Release += h, // アタッチ
        h => Release -= h, // デタッチ
        false, // 単発か否か
        cancellationToken); // キャンセレーショントークン

    // キャンセルがかかるまでループ
    while(cancellationToken.IsCancellationRequested is false)
    {
        var momo = CreateMomoToDrop(); // 落とすモモフレンズの生成処理

        // リリース入力を待機
        await releaseEventHandler.OnInvokeAsync();

        ReleaseMomoToDrop(momo); // 実際に落とす処理(というか Freeze = false)
    }
}

awaitできるようにしたことで、同期的な流れで記述できました。

なお実際には、各種の処理を含めて以下のようになっています。

private async Task ReleaseLoopAsync(CancellationToken cancellationToken)
{
    var releaseEventHandler = AsyncEventHandler.Create<ReleaseEventHandler>(
        h => Release += h,
        h => Release -= h,
        false,
        cancellationToken);

    // キャンセルがかかるまでループ
    while(cancellationToken.IsCancellationRequested is false)
    {
        var momo = CreateMomoToDrop(); // 落とすモモフレンズの生成処理

        void setPosition(MouseMotionNotification v) => SetPosition(momo, v.Position);
        using (MessageBroker.Default.Subscribe<MouseMotionNotification>(setPosition))
        {
            // リリース入力を待機
            await releaseEventHandler.OnInvokeAsync();
        }
        ReleaseMomoToDrop(momo); // 実際に落とす処理(というか Freeze = false)

        // ディレイ
        if (cancellationToken.IsCancellationRequested) return;
        await Task.Delay(300, cancellationToken);
    }
}

落下させるアイテムのマウス追従は、楽をして、InputEventMouseMotionの座標を発行してそれを購読するかたちにしました。ReactiveProperty というライブラリのMessageBrokerを利用しています。


落下動作の発火は_Inputで、デッドライン到達の発火は_PhysicsProcessで行いました。

internal readonly struct MouseMotionNotification(Vector2 position)
{
    public readonly Vector2 Position = position;
}

public override void _Input(InputEvent @event)
{
    if(@event.IsActionPressed("ui_accept")
        || @event.IsActionPressed("mouse_click_left"))
    {
        EmitSignal(SignalName.Release);
    }

    if(@event is InputEventMouseMotion motion)
    {
        MessageBroker.Default.Publish<MouseMotionNotification>(new(motion.Position));
    }
}

// private Area2D deadline
public override void _PhysicsProcess(double delta)
{
    if(deadline.HasOverlappingBodies())
    {
        foreach(var body in deadline.GetOverlappingBodies())
        {
            if (body is not Momo { IsDropping: false }) continue;

            // 落下判定を受けたモモフレンズがデッドライン上にいたら発火
            EmitSignal(SignalName.ReachDead);
            break;
        }
    }
}

async/await な流れで、と銘打ちつつ_PhysicsProcessを使っています。とはいえ、すべての処理をフレームループから追い出したいわけではありません。適材適所です。

……正直に言えば今回は、「_Processレスでやりたかったな~」と思いながら泣く泣く使いました。(デッドラインとなるArea2DBodyEnteredイベントは、対象がBodyEntered ⇨ 落下判定の順でデッドライン上に留まってしまった場合に、ゲームオーバーを発火できなかった)

リザルトシーン(複数ボタンの待機)

リザルト画面では、ゲームを続けるかどうか確認します。言い換えれば、複数のボタンのいずれかが押されるまで待機します。

play again?

前項でAsyncEventHandlerを用意したので、それを利用します。ボタンくらい頻出の UI であれば、ショートハンドの拡張メソッドをつくるとよさそうです。

internal static class Extensions
{
    public static ValueTask OnPressedAsync(this BaseButton button, CancellationToken cancellationToken)
    {
        return AsyncEventHandler.OnInvokeAsync(
            h => button.Pressed += h,
            h => button.Pressed -= h,
            cancellationToken);
    }
}

これで、以下のように書けます。

public partial class ResultScene : CanvasLayer
{
    private Label scoreLabel;
    private Button okButton;
    private Button quitButton;

    public override void _Ready()
    {
        scoreLabel = GetNode<Label>("Container/ScoreLabel");
        okButton = GetNode<Button>("Container/OkButton");
        quitButton = GetNode<Button>("Container/QuitButton");
    }

    public async Task<bool> ConfirmAsync(int score)
    {
        scoreLabel.Text = $"{score} pts.";
        Show();

        using CancellationTokenSource cts = new();

        // Pressed イベントを WhenAny で待機
        int result = await ValueTaskEx.WhenAny( // ValueTaskSupplement ライブラリ利用
            okButton.OnPressedAsync(cts.Token),
            quitButton.OnPressedAsync(cts.Token));

        // キャンセル
        cts.Cancel();

        return result == 0;
    }
}

アタッチ・デタッチを外に追い出せていると、見た目がすっきりしますね。

おわりに

というわけで、async/await な流れで冒頭の apng のような落ち物ゲームをつくることができました。

色々試すためのものだったのですが、Godot のフレンドリーさもあり、とても楽しかったです。


一方、async/await アプローチには課題もありました。

まず、UniTask がない点です。UniTask は単に Task-like を提供しているだけでなく、Unity にも C# にも寄り添ったつくりになっています。ない以上、同様のことをしたいと思えば、何らかの無理が生じると感じました。

二点目として、デバッグです。これはまぁ……という感じですね。

Godot に入っては Godot に従えと現状ある流儀に従えばよい、というのもあるでしょうが、async/await の有用性は明白なのでこの方向も発展してほしいなと感じます。


最後に。Godot は不思議と触って楽しいと感じさせてくれます。これからも情報を追っていきたいです。



  1. 記事用に「アイテム」みたいな表記に変えようかと思いましたが、コードの Momo の意味が分からなくなるし、命名を変えるのも手間と思い、注釈することにしました

【C#】分岐最適化時代の Compare の書き方

※ 大仰なタイトルですが、下記記事の一部分を引き伸ばした内容になります。


上記記事の Branching の項目で、分岐レス化による最適化が解説されている。

詳細は元記事参照として、Compare 的な処理が例として挙げられており、興味深かった。

まずは素直な書き方。

static int Compare(int x, int y)
{
    if (x < y) return -1;
    if (x > y) return 1;
    return 0;
}

普通に思える。どこに改善の余地があるのだろうか。

分岐最適化時代は以下。

static int Compare(int x, int y)
{
    int gt = (x > y) ? 1 : 0;
    int lt = (x < y) ? 1 : 0;
    return gt - lt;
}

???

ILを見ると一目瞭然。

// ひとつめの Compare
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: bge.s IL_0006

IL_0004: ldc.i4.m1
IL_0005: ret

IL_0006: ldarg.0
IL_0007: ldarg.1
IL_0008: ble.s IL_000c

IL_000a: ldc.i4.1
IL_000b: ret

IL_000c: ldc.i4.0
IL_000d: ret
// ふたつめの Compare
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: cgt
IL_0004: ldarg.0
IL_0005: ldarg.1
IL_0006: clt
IL_0008: stloc.0
IL_0009: ldloc.0
IL_000a: sub
IL_000b: ret

前者は分岐のジャンプ(bge.s IL_0006のような)があるが、後者は分岐がないのが見て取れる。


元記事の内容を繰り返す以上のことはできそうにないので原理等は元記事参照として、元記事にCompare の例によるベンチマークはなかったので測ってみる。(BenchmarkDotNet)

なお、ベンチマークとして正しくないのを承知のうえで下記のようにした。

  • ベンチマーク内で分岐予測を撹乱する良い方法が分からなかったので、都度乱数生成した数値を比較する
  • 数値の比較が処理として相対的に軽すぎるので、大量にループさせる

ベンチマークコード折りたたみ

[ShortRunJob]
public class CompareBenchmark
{
    private static int Compare(double x, double y)
    {
        if (x < y) return -1;
        if (x > y) return 1;
        return 0;
    }

    private static int CompareBranchless(double x, double y)
    {
        int gt = (x > y) ? 1 : 0;
        int lt = (x < y) ? 1 : 0;
        return gt - lt;
    }

    private readonly Random rand = Random.Shared;
    private const int N = 1 << 16;

    [Benchmark(Baseline = true)]
    public int Regular()
    {
        int result = 0;
        for (int i = 0; i < N; i++)
        {
            result += Compare(rand.NextDouble(), rand.NextDouble());
        }
        return result;
    }

    [Benchmark]
    public int Branchless()
    {
        int result = 0;
        for (int i = 0; i < N; i++)
        {
            result += CompareBranchless(rand.NextDouble(), rand.NextDouble());
        }
        return result;
    }
}

| Method     | Mean       | Error    | StdDev  | Ratio |
|----------- |-----------:|---------:|--------:|------:|
| Regular    | 1,205.4 μs | 72.52 μs | 3.98 μs |  1.00 |
| Branchless |   911.3 μs | 97.30 μs | 5.33 μs |  0.76 |

図り方の関係で絶対値に意味はないが、分岐ミスがある状況での影響は明らかそう。

本当に必要とされる領域は限られるとは思うが、単純に恩恵があるタイプの知識だし、人が書いていたときに知っていれば意味が取れるので、知っておきたいと感じた。

【C#】Span<byte>.SequenceEqual に代えて数値型で == する方法の確認

下記スライドを学習した際、記事名の内容の効用が気になったので、マイクロベンチマークする。


byte列の照合を取る際は通常、SequenceEqualメソッドを用いる。比較するReadOnlySpan<byte>が静的に定まればアロケーションもない。

代えて、サイズが適するなら Span を数値型として読むことで単に==でき、それが最速というのがスライドの内容。

文字列、Span<byte>.SequenceEqualintで BenchmarkDotNet。

private static ReadOnlySpan<byte> Target => "%PDF-1.5"u8;

private static bool AsString(ReadOnlySpan<byte> span) => Encoding.ASCII.GetString(span[..4]) == "%PDF";
private static bool AsReadOnlySpan(ReadOnlySpan<byte> span) => span[..4].SequenceEqual("%PDF"u8);
private static bool AsNumber(ReadOnlySpan<byte> span) => Unsafe.ReadUnaligned<int>(ref MemoryMarshal.GetReference(span)) == 1178882085;

[Benchmark]
public bool String() => AsString(Target);
[Benchmark(Baseline = true)]
public bool Span() => AsReadOnlySpan(Target);
[Benchmark]
public bool Int() => AsNumber(Target);
 Method | Mean       | Error     | StdDev    | Ratio | RatioSD | Gen0   | Allocated | Alloc Ratio |
------- |-----------:|----------:|----------:|------:|--------:|-------:|----------:|------------:|
 String | 16.3037 ns | 3.7117 ns | 0.2034 ns | 8.317 |    0.07 | 0.0102 |      32 B |          NA |
 Span   |  1.9603 ns | 0.2682 ns | 0.0147 ns | 1.000 |    0.00 |      - |         - |          NA |
 Int    |  0.0190 ns | 0.0549 ns | 0.0030 ns | 0.010 |    0.00 |      - |         - |          NA |

ナノ秒とはいえ、ふた桁速くて笑った。

引き出しには入れておきたい。

【C#】辞書の key への列挙型の利用

enumを数値型にキャストして辞書の key にする、というコードを見たのをきっかけ。enumそのままとintにキャストした key で比較。

結論から言えば、単純な get/set のマイクロベンチマークでは有意な差はなかった。 レガシーな環境以外では実益はなさそう?

  • BenchmarkDotNet
  • .NET 7 と .NET Framework 4.8
  • インデクサによる get と set を確認する
[ShortRunJob(RuntimeMoniker.Net48)]
[ShortRunJob(RuntimeMoniker.Net70)]
[MemoryDiagnoser]
[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
public class EnumKeyBenchmark
{
    private KnownColor[] colors;
    private Dictionary<KnownColor, Color> enumDic;
    private Dictionary<int, Color> intDic;
    private int randomNumber;

    [GlobalSetup]
    public void Setup()
    {
        colors = (KnownColor[])Enum.GetValues(typeof(KnownColor));
        enumDic = colors.ToDictionary(v => v, v => Color.FromKnownColor(v));
        intDic = colors.ToDictionary(v => (int)v, v => Color.FromKnownColor(v));

        var rand = new Random();
        randomNumber = rand.Next(colors.Length);
    }

    [Benchmark(Baseline = true)]
    [BenchmarkCategory("Get")]
    public Color EnumKeyGet() => enumDic[colors[randomNumber]];
    [Benchmark]
    [BenchmarkCategory("Get")]
    public Color IntKeyGet() => intDic[(int)colors[randomNumber]];

    [Benchmark(Baseline = true)]
    [BenchmarkCategory("Set")]
    public Color EnumKeySet() => enumDic[colors[randomNumber]] = default;
    [Benchmark]
    [BenchmarkCategory("Set")]
    public Color IntKeySet() => intDic[(int)colors[randomNumber]] = default;
}
| Method     | Job                         | Runtime            | Mean     | Error    | StdDev   | Ratio | RatioSD | Allocated | Alloc Ratio |
|----------- |---------------------------- |------------------- |---------:|---------:|---------:|------:|--------:|----------:|------------:|
| EnumKeyGet | ShortRun-.NET 7.0           | .NET 7.0           | 11.14 ns | 4.309 ns | 0.236 ns |  1.00 |    0.00 |         - |          NA |
| IntKeyGet  | ShortRun-.NET 7.0           | .NET 7.0           | 11.89 ns | 2.528 ns | 0.139 ns |  1.07 |    0.01 |         - |          NA |
|            |                             |                    |          |          |          |       |         |           |             |
| EnumKeyGet | ShortRun-.NET Framework 4.8 | .NET Framework 4.8 | 15.65 ns | 2.017 ns | 0.111 ns |  1.00 |    0.00 |         - |          NA |
| IntKeyGet  | ShortRun-.NET Framework 4.8 | .NET Framework 4.8 | 15.86 ns | 0.301 ns | 0.016 ns |  1.01 |    0.01 |         - |          NA |
|            |                             |                    |          |          |          |       |         |           |             |
| EnumKeySet | ShortRun-.NET 7.0           | .NET 7.0           | 12.46 ns | 0.164 ns | 0.009 ns |  1.00 |    0.00 |         - |          NA |
| IntKeySet  | ShortRun-.NET 7.0           | .NET 7.0           | 13.99 ns | 8.514 ns | 0.467 ns |  1.12 |    0.04 |         - |          NA |
|            |                             |                    |          |          |          |       |         |           |             |
| EnumKeySet | ShortRun-.NET Framework 4.8 | .NET Framework 4.8 | 32.13 ns | 1.433 ns | 0.079 ns |  1.00 |    0.00 |         - |          NA |
| IntKeySet  | ShortRun-.NET Framework 4.8 | .NET Framework 4.8 | 31.45 ns | 2.553 ns | 0.140 ns |  0.98 |    0.00 |         - |          NA |