【Unity】次世代のUniRx「R3」を導入してみる

Unity 2022.3.16
R3 0.1.2

はじめに

2024年1月9日、Cysharp社からR3というライブラリがプレビュー公開されました。

github.com

UnityでReactive Extensions(Rx)を実現するライブラリとしては既にUniRxがありました。UniRxもCysharp社の代表を務められているneueccさんがリリースされているライブラリになっています。 github.com ただ、最新版である7.1.0のリリースは2020年2月26日とほぼ4年前でそれ以降新機能の追加やUnity・C#の更新に伴うパフォーマンス向上のアップデートは行われておらず、個人的にはUniRxの更新ないしRxの実現を図るハイパフォーマンスなライブラリの登場は待ち望んでいたところでした。R3自体はUnityでの使用に限定されたものではなく、UniRxとの完全な互換性があるわけでもありませんが、Unityに統合するためのパッケージも同時に提供されておりUnityを使用する上ではUniRxの進化版ライブラリといえそうです。
具体的に自分が更新を望んでいた機能の例として、AddToという拡張メソッドの存在があります。AddToメソッドはIDisposableに対する拡張メソッドの形で定義されており、ストリームの購読破棄をゲームオブジェクトの破棄タイミングに紐づけて行えるという非常に便利な機能を提供してくれます。以下のサンプルコードはかなり恣意的なものですが、AddToメソッドを用いることでsampleStaticSubjectに対して購読と購読破棄をメソッドチェーンの形で記述できています。

using UniRx;
using UnityEngine;

public sealed class AddToTest : MonoBehaviour
{
    private static readonly Subject<Unit> sampleStaticSubject = new();

    private void Start()
    {
        sampleStaticSubject
            // 購読
            .Subscribe(_ => Debug.Log("Test"))
            // 購読破棄をゲームオブジェクトの破棄と同時に行う
            .AddTo(this);

        sampleStaticSubject.OnNext(Unit.Default);
    }
}

一方でこのAddToメソッドを使用すると、実行時にゲームオブジェクトに対してObservableDestroyTriggerという購読しているストリームを管理するためのコンポーネントが自動的にアタッチされます。 これは従来のUnityの仕組み上このような形で実装せざるを得なかったと思いますが、できればゲームオブジェクトに余計にコンポーネントを増やすことは避けたいところです。しかしUnity 2022.2からMonoBehaviourにdestroyCancellationTokenというCancellationToken型のプロパティが追加され、これを用いることでコンポーネントを増やすことなく購読の寿命の紐付けが簡易かつハイパフォーマンスに行えるようになりました。(非同期処理の文脈においてですが、以下のUnity公式の動画でdestroyCancellationTokenについて軽く触れられています。)

youtu.be

新しいR3ではここについても現代化が図られています。先ほどのサンプルと同様のコードをR3を使って書くとこんな感じになります。

using R3;
using UnityEngine;

public sealed class AddToTest : MonoBehaviour
{
    private static readonly Subject<Unit> sampleStaticSubject = new();

    private void Start()
    {
        sampleStaticSubject
            // 購読
            .Subscribe(_ => Debug.Log("Test"))
            // 購読破棄をゲームオブジェクトの破棄と同時に行う
            .AddTo(this);

        sampleStaticSubject.OnNext(Unit.Default);
    }
}

実際、using UniRx;using R3;に書き換わっただけで他の部分は完全に同じです。ですがR3の方のAddToは以下のように内部的にdestroyCancellationTokenに対する紐付けをする実装に変わっており、管理用のコンポーネントがアタッチされたりすることはないコスパの良いものになっています。

public static CancellationTokenRegistration AddTo(this IDisposable disposable, MonoBehaviour value)
{
    return disposable.AddTo(value.destroyCancellationToken);
}

上記であげた例は非常に表層的なもので、実際には抜本的な設計の変更や大幅なパフォーマンス向上、ストリームの購読状況が可視化できるTracker Windowの付属など聞いただけでわくわくするような機能が多く盛りこまれているライブラリになっています。R3のGitHubリポジトリにあるREADMEには詳細についてまとめられているため、そちらを読めば大体の内容がわかると思います。R3の具体的な進化点については調査中のところもあるので、また別記事にまとめさせていただきたいと思います。
R3はUniRxと導入方法(配布形式)が変わっているため、この記事ではUnityにR3を導入するところまでを行ってみます。

注意

R3はプレビュー段階のライブラリです。バグが含まれていたりAPIが大きく変更されたりする可能性が十分にあるので、プロジェクトに導入する際はバックアップを取るなどの準備を行ってからにした方が良いでしょう。

インストール

R3はNuGetパッケージとして配布されています。

www.nuget.org

ここから直接dllをダウンロードしてくるのも良いのですがアップデートが面倒だったり依存しているパッケージもあったりするので、今回はR3のREADMEで紹介されているようなNuGetForUnityを使ったインストール方法にします。

NuGetForUnityのインストール

NuGetForUnityはNuGetのパッケージをUnityへ簡単に導入することができるようにするライブラリです。

github.com

この記事ではOpenUPM経由でインストールしますが、git urlでも問題なく入れることができます。
Project SettingsからPackage Managerのタブを開き、Scoped Registryとして以下のように入力し、Applyを押します。

Name OpenUPM(任意の名前)
URL https://package.openupm.com
Scope(s) com.github-glitchenzo.nugetforunity

これでPackage Managerの選択肢にMy Registriesが追加されます。その後Window/Package ManagerからPackage Managerを開きMy Registriesを選択、表示されているNuGetForUnityをインストールします。

R3(コア部分)のインストール

次にR3のコア部分をインストールします。NuGetForUnityのインストールが成功していればUnityのツールバーにNuGetの表示が増えているはずなので、そこからManage NuGet Packagesを開き検索バーに入力されているSearchの文字をR3に置き換えてSearchを押します。余談ですが、NuGetForUnityを使う際プレースホルダとしてのSearchの文字がラベルではなく実際に入力されている文字なのがいつも気になります。 表示された中からR3 by Cysharpとなっているパッケージを見つけ、インストールします。2024年1月11日現在のバージョンは0.1.2のようです。

R3(Unity統合部分)のインストール

最後にR3のUnityに対する機能を提供する部分をインストールします。まだOpenUPMでの提供は行われていないため(プレビュー版ということもあり今後提供されるかも不明です)、git url経由でインストールします。またWindow/Package Managerを開き、Add package from git URL...を選択して出た入力欄にhttps://github.com/Cysharp/R3.git?path=src/R3.Unity/Assets/R3.Unityと入力してAddします。 これでR3のUnity統合部分もインストールされ、使用する準備が整いました。

【Unity】Source GeneratorでMessagePipeのセットアップを行う拡張メソッドを自動生成する

はじめに

MessagePipeというUnityで利用できるメッセージングライブラリがあります。 とても便利なライブラリで自分もよく個人的な開発で使用させていただいているのですが、このライブラリはDI前提の作りになっています。 例えば、以下のようなメッセージMoveInputをMessagePipe上でやり取りしたいという状況を考えます。

public static class SampleMessages
{
    public struct MoveInput
    {
        public UnityEngine.Vector2 Move;
    }
}

メッセージのPublish/Subscribeを行うためには、使用しているDIコンテナでメッセージを配信するためのセットアップを行う必要があります。下記はDIコンテナとしてVContainerを使用した時の例です。

using MessagePipe;
using VContainer;
using VContainer.Unity;

public class SampleLifetimeScope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        var options = builder.RegisterMessagePipe();
        builder.RegisterMessageBroker<SampleMessages.MoveInput>(options);
    }
}

そしてセットアップされたDIコンテナからのインジェクションを受けることで、メッセージの送受信を行いたいオブジェクトはIPublisher/ISubscriberのインスタンスを取得することができます。以下は方向キーの入力を受け付けて移動するスクリプトの実装例です。SampleInputが入力メッセージであるMoveInputを送信し、SamplePlayerMoveInputを受信して移動処理を行なっています。

using MessagePipe;
using UnityEngine;
using VContainer;

public class SampleInput : MonoBehaviour
{
    [Inject] private IPublisher<SampleMessages.MoveInput> sampleMessagePublisher;

    private void Update()
    {
        var move = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical"));
        // 方向入力を送信
        sampleMessagePublisher.Publish(new SampleMessages.MoveInput { Move = move });
    }
}
using Cysharp.Threading.Tasks;
using MessagePipe;
using UnityEngine;
using VContainer;

public class SamplePlayer : MonoBehaviour
{
    [SerializeField] private float speed = 1f;

    [Inject] private ISubscriber<SampleMessages.MoveInput> messageDSubscriber;
    private Vector2 move;

    private void Start()
    {
        messageDSubscriber
            // 方向入力を受信
            .Subscribe(x => move = x.Move)
            .AddTo(destroyCancellationToken);
    }

    private void Update()
    {
        transform.position += new Vector3(move.x, 0f, move.y) * (speed * Time.deltaTime);
    }
}

このように、MessagePipeを用いることでメッセージの送信側であるSampleInputとメッセージの受信側であるSamplePlayerはお互いのことを一切知らずにメッセージの型であるMoveInputのみを介してやりとりすることができます。
ただこの送受信するメッセージが増えてくると、コンテナへの登録が徐々に大変になってきます。

public static class SampleMessages
{
    public struct MessageA
    {
    }

    public struct MessageB
    {
        public float Value;
    }

    public class MessageC
    {
    }

    public class MessageD
    {
        public bool Value;
    }

    public record MessageE
    {
    }
}
using MessagePipe;
using VContainer;
using VContainer.Unity;

public class SampleLifetimeScope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        var options = builder.RegisterMessagePipe();
        builder.RegisterMessageBroker<SampleMessages.MessageA>(options);
        builder.RegisterMessageBroker<SampleMessages.MessageB>(options);
        builder.RegisterMessageBroker<SampleMessages.MessageC>(options);
        builder.RegisterMessageBroker<SampleMessages.MessageD>(options);
        builder.RegisterMessageBroker<SampleMessages.MessageE>(options);
    }
}

人力でいちいち登録する処理を記述するのはただ大変なだけではなく、メッセージの登録をうっかり忘れたり間違えたりすることによって本来発行されているはずのメッセージを受け取れないということも発生し得ます。 このようなボイラープレートは何らかの手段で自動生成した方が効率的で間違いを減らせそうです。 そこで今回は、Source Generatorを使い自動でMessageBroker登録用の拡張メソッドを生成しようと思います。

仕様を考える

先ほどのSampleMessagesを例に考えてみます。

public static class SampleMessages
{
    public struct MessageA
    {
    }

    public struct MessageB
    {
        public float Value;
    }

    public class MessageC
    {
    }

    public class MessageD
    {
        public bool Value;
    }

    public record MessageE
    {
    }
}

staticなクラス内に使用するメッセージの型宣言を列挙していますが、このstaticクラスを1つのスコープとみた場合クラス内のメッセージを一括で登録できるようにすれば便利そうです。

public static void RegisterSampleMessages(this IContainerBuilder builder, MessagePipeOptions options)
{
    builder.RegisterMessageBroker<MessageA>(options);
    builder.RegisterMessageBroker<MessageB>(options);
    builder.RegisterMessageBroker<MessageC>(options);
    builder.RegisterMessageBroker<MessageD>(options);
    builder.RegisterMessageBroker<MessageE>(options);
}

このstaticクラスをpartialで宣言し、何らかの属性を付加することでSource Generator側でフックして拡張メソッドを追加するという方針で実装してみます。属性の名前はここではRegistrableMessagesとします。

実装

まず、生成する属性およびpartialクラスのT4テンプレートを作成します。

<#@ template debug="false" hostspecific="false" language="C#" linePragmas="false" #>
#pragma warning disable CS8669
#pragma warning disable CS8625
using System;
namespace RegisterMessageBrokerGenerator
{
    [AttributeUsage(AttributeTargets.Class)]
    internal class RegistrableMessagesAttribute : Attribute
    {
    }
}
<#@ template debug="false" hostspecific="false" language="C#" linePragmas="false" #>
#pragma warning disable CS8669
#pragma warning disable CS8625
using MessagePipe;
using VContainer;

<# if (!string.IsNullOrEmpty(Property.Namespace)) { #>
namespace <#= Property.Namespace #>
{
<# } #>
    public static partial class <#= Property.ClassName #>
    {
        public static void Register<#= Property.ClassName #>(this IContainerBuilder builder, MessagePipeOptions options)
        {
<# foreach (var message in Property.Messages) { #>
            builder.RegisterMessageBroker<<#= message #>>(options);
<# } #>
        }
    }
<# if (!string.IsNullOrEmpty(Property.Namespace)) { #>
}
<# } #>

次に、SourceGeneratorの実装を示します。

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace RegisterMessageBrokerGenerator
{
    [Generator(LanguageNames.CSharp)]
    public class SourceGenerator : ISourceGenerator
    {
        public void Initialize(GeneratorInitializationContext context)
        {
            context.RegisterForSyntaxNotifications(SyntaxContextReceiver.Create);
        }

        public void Execute(GeneratorExecutionContext context)
        {
            var registrableAttr = new RegistrableMessagesAttributeTemplate().TransformText();
            context.AddSource("RegistrableMessagesAttribute.g.cs", registrableAttr);

            if (context.SyntaxContextReceiver is not SyntaxContextReceiver receiver ||
                receiver.ClassDeclarations.Count == 0)
            {
                return;
            }

            foreach (var classDeclaration in receiver.ClassDeclarations)
            {
                var innerClassDeclarations = classDeclaration.Members.OfType<TypeDeclarationSyntax>().ToArray();
                var property = new MessagesProperty
                {
                    Namespace = GetNamespaceFrom(classDeclaration),
                    ClassName = classDeclaration.Identifier.ToString(),
                    Messages = innerClassDeclarations.Select(x => x.Identifier.ToString()).ToArray()
                };

                var generatingClass = new MessagesTemplate(property).TransformText();
                context.AddSource($"{classDeclaration.Identifier}.g.cs", generatingClass);
            }
        }

        private static string GetNamespaceFrom(SyntaxNode s) => s.Parent switch
        {
            NamespaceDeclarationSyntax namespaceDeclarationSyntax => namespaceDeclarationSyntax.Name.ToString(),
            null => string.Empty,
            _ => GetNamespaceFrom(s.Parent)
        };
    }

    internal class SyntaxContextReceiver : ISyntaxContextReceiver
    {
        internal static ISyntaxContextReceiver Create()
        {
            return new SyntaxContextReceiver();
        }

        public HashSet<TypeDeclarationSyntax> ClassDeclarations { get; } = new();

        public void OnVisitSyntaxNode(GeneratorSyntaxContext context)
        {
            var node = context.Node;

            // 対象nodeが型宣言でない場合は処理しない
            if (node is not (ClassDeclarationSyntax
                or StructDeclarationSyntax
                or RecordDeclarationSyntax
                or InterfaceDeclarationSyntax)) return;

            var typeSyntax = (TypeDeclarationSyntax)node;
            // 対象nodeが属性を持たない場合は処理しない
            if (typeSyntax.AttributeLists.Count <= 0) return;
            var attr = typeSyntax.AttributeLists.SelectMany(x => x.Attributes)
                .FirstOrDefault(x =>
                {
                    // 属性がRegistrableMessagesの場合は処理する
                    var hasRegistrable = x.Name.ToString() is "RegistrableMessages" or "RegistrableMessagesAttribute";
                    return hasRegistrable;
                });
            if (attr != null)
            {
                ClassDeclarations.Add(typeSyntax);
            }
        }
    }
}

これをビルドしてRoslynAnalyzerのタグをつけた上でUnityに追加することで、動作してくれるようになります。

参考

2022年のC# (Incremental) Source Generator開発手法

SourceGeneratorでらくらく(でもない)ソースコード自動生成