今日だけ頑張るんだ

主に軽めの開発記録

UnityでSourceGeneratorに入門したが自分の目的の用途には合ってなさそうだったので断念した話

概要

以前よりC#の SourceGenerator という機能に興味はあったのですが、使う機会に恵まれず年末年始の時間を利用して入門していました。 今までの自分にはない発想のプログラミング手法や設定を乗り越えて、目的のソースコードを自動生成されるまで漕ぎつけましたが、自分の予定していた用途で SourceGenerator を充実させていくと沼りそうでしたので一時断念しました。 将来の自分に向けて Unity6 で SourceGenerator(IIncrementalGenerator) を設定するまでの道のりと合わせて解説します。

環境

筆者の検証当時の環境は以下です。

  • Unity 6000.0.28.f1
  • Rider 2024.3

また、実装例は自作プロジェクトから持ってきてますが、一部 namespace などの置換がうまくいってない可能性があります。

Unityで SourceGenerator を使えるようにするまで

こちらの LT の内容を参考にさせてもらいました。

learning.unity3d.jp

注意点としては Unity6 では Microsoft.CodeAnalysis.Csharp4.3.0 を使用するようです。

docs.unity3d.com

Install the Microsoft.CodeAnalysis.Csharp NuGet package for the project. Your source generator must use Microsoft.CodeAnalysis.Csharp 4.3 to work with Unity.

参考資料と内容が重複しますが、SourceGenerator を使う際はUnityのプロジェクトでは完結せず別で .net standard2.0 のクラスライブラリプロジェクトを作る必要があります。 そして、プロジェクトをビルドしてできたdllをUnityプロジェクトの Plugins/ ディレクトリに取り込んでからUnity内で利用する形になります。 また、Unity内での SourceGenerator用 dllの設定は初見では難しいと思われるポイントの一つかと思います。

Incremental SourceGenerator

さて、参考資料では Microsoft.CodeAnalysis3.8.0 を使っていましたが、Unity6では 4.2.0 を使えますので Incremental SourceGenerator が使えるようです。パフォーマンスを改善した SourceGenerator とのことなのでこっちが使える環境なら IIncrementalGenerator のほうを使うのがベターなようです。 以下は参考資料の実装を IIncrementalGenerator に差し替えたものです。

using System;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;

namespace MyGenerator;

[Generator(LanguageNames.CSharp)]
public class ExampleSourceGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        context.RegisterSourceOutput(context.CompilationProvider, (spc, compilation) => Execute(compilation, spc));
    }

    private void Execute(Compilation compilation, SourceProductionContext context)
    {
        var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
        var sb = new StringBuilder();
        sb.AppendLine( /*lang=cs*/ "using System;");
        sb.AppendLine( /*lang=cs*/ "namespace MyGenerator.Generated");
        sb.AppendLine( /*lang=cs*/ "{");
        sb.AppendLine( /*lang=cs*/ "    internal static class GeneratedSourceCode");
        sb.AppendLine( /*lang=cs*/ "    {");
        sb.AppendLine( /*lang=cs*/ "        public static void PrintHelloWorld()");
        sb.AppendLine( /*lang=cs*/ "        {");
        sb.AppendLine( /*lang=cs*/$"            global::UnityEngine.Debug.Log(\"Hello World! at {timestamp}\");");
        sb.AppendLine( /*lang=cs*/ "        }");
        sb.AppendLine( /*lang=cs*/ "    }");
        sb.AppendLine( /*lang=cs*/ "}");

        context.AddSource("GeneratedSourceCode.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8));
    }
}

IIncrementalGenerator では interface の実装先が void Initialize(IncrementalGeneratorInitializationContext context) のみになるようです。

どこかで見た記憶があったので試してみたのですが、Rider環境では文字列に /*lang=cs*/ という文字列を挟むとシンタックスハイライトが変わってソースコードっぽくなるので SourceGenerator 開発時には使ってみるとよさそうです。

lang=csによるシンタックスハイライト

属性

C#には属性(Attribute)と呼ばれる仕様があり、SourceGeneratorではこのAttributeを使用することが多いようです。 参考資料の初期化メソッドの追加にあるように、対象のクラスに特定の属性を付けたうえで partial キーワードを付けるのがスタンダードになっているように思います。 実際、IIncrementalGenerator からは特定の属性を指定して、対応する属性を持つクラスのみ取り出す ForAttributeWithMetadataName というメソッドが標準で搭載されるようになったようでした。

using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;

namespace MyGenerator.SourceGenerator;

[Generator(LanguageNames.CSharp)]
public class PropertyInitializer : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        //Providerの生成
        var provider = context.SyntaxProvider.ForAttributeWithMetadataName
            (
                // 属性名をnamespace含めて指定してフィルタ
                "MyGenerator.GeneratedInitializeMethodAttribute",
                // クラスの宣言のみをフィルタ
                static (node, ct) => node is ClassDeclarationSyntax,
                static (cont, ct) => cont
            )
            .Collect();

        // 引数で与えたアクションをコンパイル時のパイプラインに登録
        context.RegisterSourceOutput(
            provider,
            static (sourceProductionContext, metaDataArray) =>
            {
                var sb = new StringBuilder();
                //collectしたProviderをForeachループで一つずつ取り出す
                foreach (var meta in metaDataArray)
                {
                    if (meta.TargetSymbol is not INamedTypeSymbol symbol) return;
                    
                    var className = symbol.Name;
                    var isNamespaceGlobal = symbol.ContainingNamespace.IsGlobalNamespace;
                    var namespaceName = isNamespaceGlobal ? string.Empty : symbol.ContainingNamespace.ToDisplayString();
                    var classAccessibility = symbol.DeclaredAccessibility.ToString().ToLower();

                    //シンボルの情報を使って、コード本体の文字列を生成する
                    if (!isNamespaceGlobal)
                    {
                        sb.AppendLine( /*lang=cs*/ $"namespace {namespaceName}");
                        sb.AppendLine( /*lang=cs*/ "{");
                    }
                    sb.AppendLine( /*lang=cs*/ $"{classAccessibility} partial class {className}");
                    sb.AppendLine( /*lang=cs*/ "{");
                    sb.AppendLine( /*lang=cs*/ "    public void Initialize()");
                    sb.AppendLine( /*lang=cs*/ "    {");
                    
                    // 各プロパティの初期化
                    foreach (var member in symbol.GetMembers())
                    {
                        // フィールド
                        if (member is IFieldSymbol field)
                        {
                            var variableDeclaratorSyntaxes = field.DeclaringSyntaxReferences
                                .SelectMany(x => x.GetSyntax().DescendantNodes().OfType<VariableDeclaratorSyntax>());
                            foreach (var variable in variableDeclaratorSyntaxes)
                            {
                                if (variable.Initializer == null) continue;
                                sb.AppendLine( /*lang=cs*/$"        this.{variable.Identifier} = {variable.Initializer.Value};");
                            }
                        }
                        // プロパティ
                        else if (member is IPropertySymbol property)
                        {
                            if (property.DeclaringSyntaxReferences.Length == 0) return;
                            var propertySyntax = property.DeclaringSyntaxReferences[0].GetSyntax() as PropertyDeclarationSyntax;
                            if (propertySyntax?.Initializer == null) return;
                            sb.AppendLine( /*lang=cs*/$"        this.{property.Name} = {propertySyntax.Initializer.Value};");
                        }
                    }
                    
                    sb.AppendLine( /*lang=cs*/ "    }");
                    sb.AppendLine( /*lang=cs*/ "}");
                    if (!isNamespaceGlobal)
                    {
                        sb.AppendLine( /*lang=cs*/ "}");
                    }
                    
                    sourceProductionContext.AddSource($"{className}.Initializer.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8));
                    sb.Clear();
                }
            });
    }
}

参考資料内のサンプル実装から IIncrementalGenerator への移行は下記記事を参考にさせてもらいました

qiita.com

おそらく無理に属性でなくても、特定namespace下にあるかとかクラス名が特定のキーワードで終わってるかで SourceGeneratorの処理の対象かを判断する方針もあり得ると思いますが、属性を使う方が安全で確実ということなのでしょう。

デバッグについて

SourceGenerator の処理は実際のソースコード食わせることで初めて動作します。動作中の過程を Debug.Log に出すとか、入出力をユニットテストするなども難しそうでした。特に構文木などからどのように情報を取り出すかわかってない初見の状態では何となくで実装して判定してみることになると思いますのでこれでは捗りません。

こちらでデバッガ実行するための方法が解説されています。

qiita.com

SourceGenerator で作ったプロジェクトとはさらに別のプロジェクトを作り、そのプロジェクトをデバッガー実行の対象とするようです。

おまけ

SourceGenerator のプロジェクトをビルドした際に自動でUnityプロジェクトの Plugins/ へコピーしたい場合は以下を参考にしてください。

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>12</LangVersion>
    <Nullable>enable</Nullable>
    <IncludeBuildOutput>false</IncludeBuildOutput>
    <DevelopmentDependency>true</DevelopmentDependency>
    <IsRoslynComponent>true</IsRoslynComponent>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.Csharp" Version="4.3.0" />
  </ItemGroup>

 <!--ここから-->
  <Target Name="CopyToUnityPlugins" AfterTargets="Build">
    <PropertyGroup>
      <UnityPluginsPath>..\MyGenerator.Unity\Assets\MyGenerator\Plugins\</UnityPluginsPath>
    </PropertyGroup>
    <ItemGroup>
      <BuiltDll Include="$(OutputPath)$(AssemblyName).dll" />
    </ItemGroup>
    <Copy SourceFiles="@(BuiltDll)" DestinationFolder="$(UnityPluginsPath)" />
  </Target>
 <!--ここまで-->
</Project>

なぜ断念したか

今回の動機となった用途が、ゲームなどの設定値をファイルに保存する際にプロパティごとにSaveメソッドを実装するのが面倒くさいというものでした。

// 例えば以下のような定義があるとして
namespace Sample
{
    public partial class GameSettings
    {
        public string PlayerName { set; get; } = "tanaka";
        public int PlayerAge { set; get; } = 17;
        public float AudioVolume { set; get; } = 1.0F;
        public Language Language { set; get; } = Language.English;
    }

    public enum Language { English, Japanese }
}

// 各設定値の保存メソッドをすべて実装するのめんどくね?自動生成されて欲しいって話でした
public async UniTask SavePlayerNameAsync(string playerName, CancellationToken ct) {
...

利用ライブラリの壁

仮に保存メソッドの返り値を UniTask にしたい場合、SourceGenerator のプロジェクトに Cysharp.Threading.Tasks.UniTask という文字列が埋め込まれることになります。 また、仮に設定値をJSONに変換してファイル保存するという方針だった場合、Newtonsoft.Json といった文字列も組み込まれることになります。 これらのライブラリが実際にインストールされているのはUnityプロジェクトの方で SourceGenerator のプロジェクトではないという点が気になってました。(そりゃそうだよねという話ですが、気持ちの問題です)

内部実装仕様の壁

保存処理の仕様として以下があるとします

  • 設定値はファイルにJSONで保存する
  • 最新の保存値はオンメモリで保持しておく

この仕様を満たした場合、実装は以下のようになるかと思います。

private string _filePath;
private GameSettings _current;

public async UniTask SavePlayerNameAsync(string playerName, CancellationToken ct)
{
    _current.PlayerName = playerName;
    var json = JsonConvert.SerializeObject(Current);
    await System.IO.File.WriteAllTextAsync(_filePath, json, ct);
}

しかし、SourceGeneratorでこれらのメソッドを生成する場合、以下の二つの知識をSourceGenerator側に引っ張ってくる必要がありました

  • ファイルの保存先のパスである _filePath
  • オンメモリで保持するためのインスタンスの変数名である _current

_filePath_current という文字列を SourceGenerator側に埋め込んだ場合、Unity側に存在するこれらの変数名を変えただけで自動生成のコードと差分ができてコンパイルエラーになってしまいます。

これはいかんなということで SourceGenerator で使用する想定の以下の interface を定義し、対象となるクラスがこのinterfaceを実装していない場合はSourceGenerator 内でコンパイラエラー扱いすると言う大層なことをしました。

namespace MyGenerator
{
    // 保存メソッドを生成させたいクラスには[Persistable]属性をつけ
    [AttributeUsage(AttributeTargets.Class, Inherited = false)]
    public sealed class PersistableAttribute : Attribute
    {
    }

    // 以下のinterfaceを実装させる
    public interface IPersistableContext<out T>
    {
        T Current { get; }
        string PersistFilePath { get; }
    }

    // 例えばこんな感じ
    [Persistable]
    public partial class GameSettingsDataStore : IPersistableContext<GameSettings>
    {...
}

考慮すること多すぎ問題の壁

↑でやっぱ SourceGenerator を使うのは違うのではないかと流石に思い始めました。また生成コードを眺めていて気付いたことの一つが仮に Object というプロパティ名がある場合に自動生成されるメソッドの引数が object となり、C#予約語と被ってコンパイルエラーになるというものでした。

↑の実装では一応その点も考慮に入れていますが、考慮することが計り知れないなと思い、この用途で使うのはやはり厳しいのではという結論に至りました。 個人開発想定なので気にする必要はないといえばないのですが、もしチーム開発で投入された場合「なんかコンパイルエラーになるんだけど」という問い合わせに対応するのはかなり大変なのではと思います。 このあたり詳しい人はどの程度までカバーしているのは気になるところです。

終わりに

ぶっちゃけ保存メソッドを自動実装したい程度であれば最近ならAI君がいい感じにサジェストしれくれます。とはいえ元々SourceGeneratorには興味があったので今回実際に使ってみて良い経験になりました。

アプリケーションコードの自動実装を行いたい場合に、SourceGenerator側にどこまでの知識をどうやって持たせるのか、結構深い課題な気がします。 あと本質ではないですが、生成コードのインデントはどこまで拘るものなんですかね。

自作アプリでUniRxからR3に乗り換えるときにコンパイルが通るまでにやったこと

世間ではUniRxがarchiveされR3への移行が行われ始めているそうです。

github.com

I have started distributing an evolved version of UniRx in Cysharp/R3, please use it instead of UniRx.

github.com

自分は仕事でも個人開発でもUniRxにはお世話になっていました。自作アプリの開発をしていたのでR3に関する情報は傍目に見るだけでしたが、リリースしてひと段落したタイミングでR3への移行を行いました。

naninunenoy.hatenablog.com

所要時間

自作アプリでも System.IObservable<T>UniRx.IReadonlyProperty<T> は結構使ってますし、MessagePipe も使ってます。 そんなプロジェクトですが、4時間程の作業でUniRxからR3へ移行できました。

しかし、とりあえずコンパイルエラーが通ってEditor実行で動いている風なのを確認しただけで、ちゃんと動作確認するとまだ修正点があるかもしれません。

やったこと

やったことを順序だてて解説します。説明の都合で実際の手順と異なる場合があります。

作業当時の環境は以下でした

  • Unity: 2022.3.17f1
  • UniRx: 7.1.0
  • R3: 1.1.7
  • MessagePiep: 1.7.4

1. R3のインストール

こちらの記事を参考にインストールします。 zenn.dev

そしてインストールが自分的に一番の落とし穴だったのですが、ちゃんと記事の通りにインストールしましょう。 上記記事には

以下のスクショのようにUnity NuGet内の R3 (NuGet) とOpenUPM内の R3 からインストールします。

とあります。自分はUnity NuGetからだけR3をインストールして満足しており、後の作業で using R3.Triggers; ができずに30分ぐらい悩んでました。 R3.Triggers はUnity依存の実装であり、OpenUPMの方のR3から取得する必要があるようです。また、プロジェクトでasmdefを切っている場合は R3.Unity への参照を追加する必要があります。 なお、R3本体の方はdllなのでasmdefに参照を追加する必要はなく、勝手に参照できるようになります。

また、このときMessagePipeをUnity NuGetの方に乗り換えようとしたのですが、VContainerとの参照でコンパイルエラーが出たので止めました。 (GitHub経由のインストールのままとしました)

2. UniRxをR3に置換する

"using UniRx" を一気に "using R3" に置換してしまいます。当然コンパイルエラーが出ますがこれを一つずつ直していくことになります。

3. UniRxを消す

UniRxをpackage manager からremoveします。UniRxとR3は同名の拡張メソッドを多数持つので早めにかぶりを無くしてしまった方が良いでしょう。 プロジェクトのasmdefに追加したUniRxの参照もMissingになるので削除しておきましょう。 同時に代わりではないですがasmdefの参照に R3.Unity を追加しておきましょう。 これでいくらかコンパイルエラーが減るかと思います。

4. IReadOnlyReactiveProperty を変える

筆者は UniRx.IReadOnlyReactiveProperty を公開用に多用してました。R3ではinterfaceは消えてしまったので R3.ReadOnlyReactiveProperty を使います。 これも "IReadOnlyReactiveProperty" を "ReadOnlyReactiveProperty" に置換してしまっていいでしょう。

ただ、UniRx.IReadOnlyReactiveProperty.ValueR3.ReadOnlyReactiveProperty.CurrentValue となります。

5. IObservable を変える

System.IObservable のinterfaceを使うことが多かったと思いますが、R3ではクラスの Observable を使います。 変数名が置換対象ならないよう、型名のみ置換されるように "IObservable<" を "Observable<" に置換しました。

6. TakeUntillDestroy を消す

オブジェクトの破棄時に購読を止める際、有名なのは .AddTo(this) ですが、プロジェクトによっては .TakeUntillDestroy(this) を推奨している場合もあると思います。 R3では TakeUntillDestroy はないようなので、AddToRegisterTo に移行します。 筆者は RegisterTo を使いましたが、渡すのが thisgameObject でなく destroyCancellationToken になります。

7. ToUniTaskFirstAsync に変える

筆者のプロジェクトでは IObservable<T> の最初の値を待ち受けるために

await observable.ToUniTask(true, cancellationToken: ct);

のように書いていました。同様の処理は FirstAsync で書けるようです

await observable.FirstAsync(cancellationToken: ct).AsUniTask();

8. EveryFixedUpdate を変える

Observable.EveryFixedUpdate() は直接呼べなくなっています。Observable.EveryUpdateUnityFrameProvider.FixedUpdate を渡してやることで FixedUpdate を購読できます。また、フレーム数をとるには Index() を購読する必要があるようでした。

-Observable.EveryFixedUpdate()
+Observable.EveryUpdate(UnityFrameProvider.FixedUpdate)
+  .Index()
   .Subscribe(i => {

9. ObserveEveryValueChanged を変える

文章では説明しずらいですが ObserveEveryValueChanged の書き方が変わりました。 これはとり先生の説明を見てもらえればわかるかと思います

qiita.com

10. MessagePipe.ISubscriber<T> の変換

先ほどのとり先生の記事には

(この記事の執筆時点では MessagePipe -> R3.Observable の変換メソッドは存在していませんでした。将来的に実装される可能性があります)

とありましたが、下記のように .AsObservable()ToObservable() を挟むと一応 MessagePipe.ISubscriber に関しては R3.Observable に変換でき、Select などを挟むことが出来るようになりました。

using MessagePipe;
using R3;
using UnityEngine;
using VContainer;

public class SubscriberToR3 : MonoBehaviour
{
    [Inject] ISubscriber<int> _subscriber = default;

    void Start()
    {
        _subscriber
            .AsObservable()
            .ToObservable()
            .Select(x => x * 2)
            .Subscribe(x => Debug.Log(x))
            .RegisterTo(destroyCancellationToken);
    }
}

あとやること

自分のプロジェクトでは上記でコンパイルエラーはなくなりました。

ただ、Prefabやシーンのオブジェクトに UniRx.ObservableEventTrigger をアタッチしていた場合は先のUniRxの削除により Missing になっているはずなので、R3.ObservableEventTrigger に差し替えてやりましょう。

後は折角R3にアップデートしたので新しい機能を使いましょう。とりあえず SubscribeAwait を積極的に使いたいと思います

-.Subscribe(_ => UniTask.Void(async () =>
+.SubscribeAwait(async (_, ct) =>

KenzenViewerを作ったときに考えていたこと

この度、VR(Windows/MetaQuest)で画像を見られるアプリをリリースしました。

naninunenoy.booth.pm

経緯

元々画像ファイルの収集癖があり、好きなアニメのDVDのジャケット画像を集めて保存したりしてました。 PCに保存したから後で何かに使うわけでもないのに保存だけしてました。 たまに眺めるとかはしたかもしれません。

「好みの画像だったから保存した」みたいなミーム?があるので他にも同じ人がいるんだろうと思います。

dic.nicovideo.jp

ただ、画像を見る際PCやスマホのディスプレイ以上に大きく表示はできません。 画像を大きく表示したくても当然ディスプレイ外が描画されるわけもありません。

ある日、OculusQuest2を買って遊んでいるとOculusLinkで簡単にデスクトップをミラーリングできることを知りました。 そしてそのミラーリングしたディスプレイは自由に移動・拡縮でき好きな大きさで画像を見られることが出来て感動しました。

そしてUnityの画面を割と簡単にVRビューで表示できることを知り自分用に画像を見られるアプリを作ってみるかというアイデアが生まれました。

Windowsのデスクトップがミラーリングできるのでブラウザや標準のフォトアプリで同様のこともできます。最初はそれで満足してましたが、ミラーリングは自分の用途ではTooMuchに感じましたし、フォトアプリもページ送りの挙動や拡大の操作などは当然VRのコントローラは想定してないので使いづらい面も感じました。

仕事ではUnity使った開発をしていますし、個人開発というものをオレもやってみるかーと開発を始めました。

そしてリリース

開発開始から1~2年1ありましたが、ひとまず他人にも使えるレベルになったと判断したのでリリースとしました。 開発でこだわった点は以下です

  • 画像の読み込みと表示を可能な限り早くする
  • 左右両手のコントローラで同じ操作をできるようにする

です。

画像の読み込みと表示を可能な限り早くする

別スレッドのファイル読み込みのよりメインスレッド上で UnityWebRequest 経由で読む方がなんだかんだ早い2みたいな発見を経ましたが、OSSで用途に合ったものがあったのでそれを使っています。

github.com

また、一度読み込んだ画像の縮小画像をwebpに変換して保存し、次回の表示時に利用するなどしています。大量の画像を表示することを想定したうえでの仕様です。3

webpはpngよりファイルサイズが小さいくらいの認識でしたが、以下の記事でUnityでも使えることを知り採用しました。

note.com

また、2度ほど抜本的な作り直しをしましたが上記の詳細も併せて別の機会にまとめようと思います。

左右両手のコントローラで同じ操作をできるようにする

ナニとは言いませんが、片手だけで操作したい場面があるのです。

アプデの構想

MetaQuest以外でも使えるように

QuestLink(OculusLink)頼りなのでMetaQuestでしか動きません。SteamVRを経由すれば他のVR機器でも動くらしいと教えてもらったので試したいと思っています。 ただ、現状ユーザが自分だけなので優先度は低めかも。

動画再生

Unityの VideoPlayer がPC上のローカルの動画を再生するのに使えそうなので動画の再生の対応はしたいです。しかし時間送りとかUIが面倒そうなのでひとまずは再生と停止だけかな。

音声再生

音声もUnityの AudioClipAudioSource で行けそうですが、現状のアプリの都合上画像を表示しながらの音声再生はやっかいなので後回しになりそう。

複数画像表示

ほんとは画像を複数枚表示して動かしたりしたかったのですが、これをやろうとするといつまで経ってもリリースできないと思ったので、一枚表示の時点でリリースしました。

Apple Vision Pro の Safari では標準機能のようです。まさにこんなことがしたいですねー。自分としてもここまで出来るようになれば完成といっていいかなと思っています。

今後

アプデだったり、人目に付くように記事を書いたり、LTなどの機会を作っていきたいなと思っています。


  1. Gitの最初のコミットが2022年の6月でした
  2. 結局 Texture2D の生成はメインスレッド実行でボトルネックになるので
  3. 自分が収集した画像は1万とか超えているのでそれらを表示することを視野に入れました