28
32

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

便利なスクリプトの詰め合わせ【Unity/.NET】

Last updated at Posted at 2025-03-18

年度末の棚卸。

全て 👇 コチラの Unity パッケージ(UPM)に入ってます。.NET 環境でもファイル単位でつまめます。

もくじ

ObservableEvent<T>

購読と同時に解除タイミングが指定できる Subscribe が Rx 唯一の利点、それ以外は別に要らないんじゃね? という考えに基づいた実験的 API です。

標準イベント向け ObservableEvent<T>

private ObservableEvent<int> m_myEvent = new();
public IObservableEvent<int> MyEvent => m_myEvent;  // Sub/Unsub のみ可能

MyEvent.Subscribe(myAction)
    .BindTo(cancellationToken);  // トークンにバインドする。
    .AddTo(disposables);         // または IDisposable コレクションに追加する。
MyEvent.Unsubscribe(myAction);   // 👈 自力で解除

// イベントの呼び出し
m_myEvent.Invoke(310);

// イベント側からサブスクライバーを解除する
m_myEvent.Dispose();

ObservableAction<T>

こっちはラッパー噛ませてないので、もしかしたらほんのちょっとだけ効率が良いかも?

private event Action<int>? Ev_MyEvent;
IObservableAction<int>? cache_MyEvent;
public IObservableAction<int> MyEvent => cache_MyEvent ??= new ObservableAction<T>(
    cb => Ev_MyEvent += cb,
    cb => Ev_MyEvent -= cb);

Ev_MyEvent.Invoke(310);

Unity イベント向け拡張メソッド

myButton.onClick.Subscribe(() => ...).BindTo(ct);
mySlider.onValueChanged.Subscribe(val => ...).AddTo(disposables);

後述の Sentinel と組み合わせると、非同期コールバックのハンドリングやタッチ操作での同時押しへの対応等、幅が広がります。

イベントの依存関係のグラフ化

Subscribe しか提供していないのはマイナスではなく、逆にイベントの相互依存関係を Mermaid でグラフ化するのが容易なのでは? と AI に聞いてみると

  • Microsoft.Build.Locator
  • Microsoft.Build.Evaluation
  • Microsoft.CodeAnalysis.CSharp

これらのライブラリを使えば C# プロジェクトの静的構文解析が出来るとのことなので、いずれ Subscribe 呼び出しを根こそぎ走査してグラフ化する処理を実装したいですね。

(Subscribe と AddTo/BindTo の組み合わせでそんなのが要らなくなるくらい見通しが良くなりますが)

--

コード例(Microsoft Copilot が生成)

using System;
using System.IO;
using Microsoft.Build.Locator;
using Microsoft.Build.Evaluation;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.MSBuild;

class Program
{
    static void Main(string[] args)
    {
        // 1. MSBuildの初期化
        MSBuildLocator.RegisterDefaults();

        // 2. プロジェクトファイルのパスを指定
        string projectPath = @"path\to\your\project.csproj";

        // 3. MSBuildプロジェクトをロード
        var projectCollection = new ProjectCollection();
        var project = projectCollection.LoadProject(projectPath);

        Console.WriteLine("Project Properties:");
        foreach (var property in project.AllEvaluatedProperties)
        {
            Console.WriteLine($"{property.Name}: {property.EvaluatedValue}");
        }

        // 4. コンパイル対象のファイルリストを取得
        Console.WriteLine("\nSource Files:");
        foreach (var item in project.GetItems("Compile"))
        {
            Console.WriteLine(item.EvaluatedInclude);
        }

        // 5. Roslynを使って各ファイルを解析
        Console.WriteLine("\nRoslyn Analysis:");
        using (var workspace = MSBuildWorkspace.Create())
        {
            var roslynProject = workspace.OpenProjectAsync(projectPath).Result;

            foreach (var document in roslynProject.Documents)
            {
                Console.WriteLine($"Analyzing: {document.Name}");
                var syntaxTree = document.GetSyntaxTreeAsync().Result;
                
                // 必要に応じて更なる解析処理を追加
            }
        }
    }
}

early finally パターン

Go とかにある defer です。https://go-tour-jp.appspot.com/flowcontrol/12

例えば ArrayPool<T> を使う場合、try-finally だと貸出/返却コードを離れた場所に書く必要がありますが、Defer を使えば finally 句を近くに書けます。

using var rentalBuffer = ArrayPool<byte>.Shared.Rent(128)
        .Defer(buffer => ArrayPool<byte>.Shared.Return(buffer));  // 👈 finally 句の中身

var span = rentalBuffer.Value.AsSpan(0, 128);  // .Value は拡張メソッドのレシーバー

//...

👇 に同じ。

var buffer = ArrayPool<byte>.Shared.Rent(128);

try
{
    var span = buffer.AsSpan(0, 128);

    //...
}
finally
{
    ArrayPool<byte>.Shared.Return(buffer);
}

ArrayPool 以外にも Interlocked を使った排他処理や、

if (Interlocked.Exchange(ref _value, 1) != 0) return;
using var _ = Defer.New(() => Interlocked.Exchange(ref _value, 0));

//...

一時的に設定した値を確実に復帰したい時、

using var _ = foo.Defer(foo.Value, (x, restore) => x.Value = restore);
foo.Value = tempValue;

//...

終了時にストリームを頭出ししたい時等にも使えます。

using var _ = stream.Defer(stream => stream.Position = 0);

--

Defer を使ってもパフォーマンス上のデメリットはほぼ無しですが、値型と組み合わせるとちょっと面倒なことになるので言語機能として付けて欲しかった。(去年末にクローズ)

Sentinel

排他制御ユーティリティーです。

内部で使っている ConcurrentDictionary はキーが既に存在するときにロックを行わない、という非常に良くできたクラスなのでロックフリーでスレッドセーフに動作します。

readonly static SentinelToken _token = Sentinel.GetUniqueToken();

using (Sentinel.ExclusiveScope(_token, out var rejected))
{
    if (rejected) return;  // 既に別のイベントが排他ブロックに入っている場合は true

    // ココから排他ブロック
}

排他制御はマルチスレッド処理以外にも、非同期処理をイベントコールバックに登録したときに起きる「スレッドをブロックせず即時終了してしまう」問題への対処や、複数の UI コントロールの入力成立制御等にも使えます。

例えば1秒に1回、どれか一つのボタンの入力しか成立させたくない場合は、

// 全てのボタンで共有するトークン
readonly static SentinelToken _sharedToken = Sentinel.GetUniqueToken();

myButton.onClick.Subscribe(async () =>
{
    using (Sentinel.ExclusiveScope(_sharedToken, out var rejected))
    {
        if (rejected) return;

        Foo();
        await Task.Delay(1000);  // 1秒待ってから排他ブロックを抜ける
    }
});

// 同じトークンを使う別のボタン
otherButton.onClick.Subscribe(async () => ...);

とすると、複数のボタンに跨る Debounce() として動作するので、スクリーン上のボタンをタップしたまま指がズレて他のボタンに引っかかって入力成立、みたいなシチュエーション防止にも使えます。

その他の細かい挙動は実装次第なので await の置き方等々で良しなにします。

// イベント到達直後の値で1秒後に実行する
var val = GetValue();

await Task.Delay(1000);
Foo(val);

UI Toolkit 関連ヘルパー

Unity Discussions だったかで既に話題に上がっていたように思いますが、Visual Element 用のカスタム Manipulator を UI Builder から設定できるようにならないと UITK の根本的な開発体験は上がりませんね。

流石にデータバインディングだけじゃキツいです。今思えば uGUI の Persistent Callback は良くできたシステムでしたね。

UIToolkitCoreExtensions

細々としたコントロールを提供します。

ExecuteAfter

ExecuteAfter は公式 API ExecuteLater(ミリ秒指定)の描画フレーム版です。

DropdownField なんかは、設定した値が不正だった場合に元の値に書き戻す処理が UI コントロール側が行う処理と競合する等、RegisterCallback 内で1描画フレーム待たないと上手く動かないなんてシチュエーションが多いです。

困ったら1描画フレーム待ってみてください。上手く行くかもしれません。

※ 可能なら Dropdown は使わずラジオボタンやトグルだけで UI をまとめるのが吉。

DisableStyleTransitionScope

USS トランジションは便利な一方で厄介な側面もあって、例えば初期化時に値を即時適用したいのにアニメーションが発生してしまう等の問題があります。

そんな時は using (ve.DisableStyleTransitionScope()) を使うとスコープ内に限定してトランジションを無効化できます。

UIToolkitEventSubscription

ve.RegisterCallbackAsSubscription でイベント登録解除用の IDisposable を取得できます。

駄文

IObservable<T>Subscribe を実装するにはどうしたものかって感じなので、とりあえずで作ったやつです。

RegisterCallback は VisualElement のメソッド、ChangeEvent<T>INotifyValueChanged<T> を実装していなきゃいけないという訳ではないという曲者。

valueChangeEvent<T> を発行する義務はなく、発行しなくてもコンパイルは通る)

UITK だと RegisterCallback<TEvent, TState>(...) の使用がほぼ必須で、シグネチャを Subscribe<TEvent, TState> にしても Subscribe<TVal, TState> にしても型名の省略が不可能というのもあり、、、

UI Toolkit イベントの特殊な癖

UI Toolkit では TState だけを入れ替えて複数回登録する、みたいなことが出来ない仕様になっています。

readonly static EventCallback<ChangeEvent<bool>, VisualElement> OnToggleChanged = (e, ve) => { ... };

// チェックボックスの状態によって表示非表示を切り替えたい(非表示にする処理は同じなのでコールバックは使いまわしたい)
myToggle.RegisterCallback(OnToggleChanged, controlA);    // 無反応!
myToggle.RegisterCallback(OnToggleChanged, textFieldA);  // 無反応!
myToggle.RegisterCallback(OnToggleChanged, sliderA);     // sliderA だけがイベントに反応する

// コールバックが同一インスタンスだった場合は重複した登録を回避し TState に渡すオブジェクトだけを更新する、
// って感じの内部処理が行われているので、面倒だが以下のようにするしかない。
myToggle.RegisterCallback<ChangeEvent<bool>>((e) =>
{
    OnToggleChanged.Invoke(e, controlA);
    OnToggleChanged.Invoke(e, textFieldA);
    OnToggleChanged.Invoke(e, sliderA);
});

// 呼び出し回数が多い場合にアロケ回避する為には、、、
myToggle.RegisterCallback<ChangeEvent<bool>, (VisualElement, VisualElement, VisualElement)>(
    static (e, controls) =>
    {
        OnToggleChanged.Invoke(e, controls.Item1);
        OnToggleChanged.Invoke(e, controls.Item2);
        OnToggleChanged.Invoke(e, controls.Item3);
    },
    (controlA, textFieldA, sliderA));

登録解除も面倒というか、登録したものと同じインスタンスを解除時に指定する必要があります。

なので変数名を決めて、入れて、、、とする必要が出てきます。IDisposable なんて要らなそーww て感じのメソッドですが必要になる時もあるでしょう。

UIToolkitDropdownHelper

後述の StrictEnum を使って色々とやるやつです。

xxHash32 / xxHash64 ハッシュの最小実装

UTF-8 のバイト列で名前を受け取った場合、その文字列自体に意味があったり UI に表示したりする場合は string(UTF-16)に変換する意味がありますが、辞書のキーとして使う等、単なる識別子でしかない場合には違いが分かればいいという事になります。

var name = Encoding.UTF8.GetString(utf8bytes);
nameToDataDict[name] = receivedData;

👇 これで良くね?

var hash = MiniXXHash.XXH32(utf8bytes, seed);
nameHashToDataDict[hash] = receivedData;

ネットワーク通信においては、プロトコルの仕様上 UTF-8 を毎フレーム受け取ったりする場合もあるかと思います。そういう場合に結構効いてきます。

(キーを4文字や8文字にして UTF-8 バイト列を整数型として咀嚼する、というのが一番ベスト)

64bit long 版の ULID

プレハブのように Unity のセッションを超えて永続化されるオブジェクトに ID を振る時に便利です。

long なので扱いやすく GuidUlid 等の特別な型に対応していない API やファイル形式でも使えるのがポイント。

※ 43ビットの整数が約279年をミリ秒で表せるとは思いもしなかったので、内部でスゴイ無駄な処理をしてます。
加えてマルチスレッド環境で使えない(訳ではないがちょっと微妙)という問題もあります。直したいですね。

StrictEnum

列挙型のアロケ回避系ユーティリティーです。

特徴は、独自に文字列表現をキャッシュせず C# の内部ライブラリが抱えているキャッシュをぶっこ抜く形で実装している点です。他にも

  • Unity の InspectorName と C# の EnumMember による文字列表現のカスタマイズに標準対応
  • Flags アトリビュート付き列挙型のフォーマットとパースに対応
  • IsOrdinalFromZero 等で怪しい Enum を排除

なんかがあります。

フィールド名そのままの文字列表現なんて Json の書き出し位にしか使えなくね? ということで、StrictEnum.DisplayNameFactory を使って表示名をカスタマイズ/翻訳する機能も付いてます。

列挙型の型変換を絶対に許さないマン

(脱線)

C# の列挙型の問題として (MyEnum)0 で簡単に好きな値が作れちゃう問題があります。(Sentinel では逆にその性質を悪用しています)
フィールド名その物の文字列表現なんて実際のところ使い道なんてないんですが、Enum を文字列にするたびにアロケーションが起きるのも問題です。

それら点だけに目を向ければ Java / Kotlin や Rust の列挙型実装は魅力的ですが、Google が Enum 使うな! という公式チュートリアルを出す程度には Kotlin/Java の実装にも問題があって、その代替案として IntDef、要は C# と同じ単なる名前付き定数を使うことを推奨していたりします。

結論、C# の列挙型がキャストできなきゃ何の問題もないのでは? となりその解決策がコチラ。

https://github.com/sator-imaging/CSharp-StaticFieldAnalyzer?tab=readme-ov-file#enum-analyzer-and-code-fix-provider

Kotlin 風の列挙型を C# で実装する

リフレクションを使って不正な値を作ってもインスタンスが一致しないから、安心安全の完璧な列挙型じゃん! と思ってた時期がありました。

record や ValueObject として実装したらダメ。

public sealed class EnumLike
{
    public static readonly EnumLike A = new("A");
    public static readonly EnumLike B = new("B");

    public static ReadOnlySpan<EnumLike> Entries => EntriesAsMemory.Span;
    public static readonly ReadOnlyMemory<EnumLike> EntriesAsMemory = new(new[] { A, B });


    /* ===  Kotlin style enum template  === */

    static int AUTO_INCREMENT = 0;  // iota

    public readonly int Ordinal;
    public readonly string Name;

    private EnumLike(string name) { Ordinal = AUTO_INCREMENT++; Name = name; }

    public override string ToString()
    {
        const string SEP = ": ";
        Span<char> span = stackalloc char[Name.Length + 32];

        Ordinal.TryFormat(span, out var written);
        SEP.AsSpan().CopyTo(span.Slice(written));
        written += SEP.Length;
        Name.AsSpan().CopyTo(span.Slice(written));
        written += Name.Length;

        return span.Slice(0, written).ToString();
    }
}

switch する方法

switch (val)
{
    case {} when val == EnumLike.A:
        System.Console.WriteLine(val);
        break;

    case {} when val == EnumLike.B:
        System.Console.WriteLine(val);
        break;
}

Run

async/await 汚染を広げないための API と、実行スレッド数制御の為の機能等々があります。

Run.OnMainThread(...);
Run.OnMainThreadAndForget(...);

Run.InThreadPool(...);

--

Unity 6 で Awaitable.MainThreadAsyncAwaitable.BackgroundThreadAsync が付いたことでより効率的に CPU を使える! ってことで

Func<Task> job = async () =>
{
    await Awaitable.BackgroundThreadAsync();  // 👈 裏で回す
    var result = HeavyJob();                  // 裏で回すなら非 async の方がほんのり速い

    await Awaitable.MainThreadAsync();  // 今まではコレが無かったのでメインスレッドを浪費してた
    myUnityObj.DoIt(result);
}

// !!
var tasks = new Task[100];
for (int i = 0; i < tasks.Length; i++)
{
    tasks[i] = job.Invoke();
}

一気に 100 スレッドで回す! とかやると逆に遅くなります。

並列スレッド数は基本、CPU のコア数と同じ数に抑えたほうが効率が良いので Run.InThreadPool を使って並列実行するスレッド数を制御してやると良いでしょう。

※ 内部で ConcurrentExclusiveSchedulerPair を使ってますが、うーーんという実装です。一応並列化による恩恵は受けられますが無駄にスレッド数を消費するという状態なので、別の選択肢を探したほうが良いでしょう。

System.Threading.Channels Channel<T> を使っているライブラリがおススメ。

スレッド使い分け表

  • ネットワーク処理のようにほぼ CPU を使わず待ち時間が長いだけの場合
    • メインスレッドで回す(非同期メソッドを使う)
    • ConfigureAwait(false) するべき勢と、するべきではない勢がいる
  • メッシュ編集のように不可分でかつ CPU を使う重い処理の場合
    • 裏で同期的に回す(Task.Run を使う)
    • 並列実行数をちゃんと管理する

UString

Unity 以外で使う意味が無いので Unity String。DefaultInterpolatedStringHandler より速い!

👇 からの勢いで作った物です。

--

AggressiveInlining 無しで優れたパフォーマンスを実現していたり、ToCharSpan や内部バッファーへのアクセス等も提供します。

// リングバッファーを使った簡易フォーマッター ※256文字を超えるたびに古い結果が書き換えられる
var val = UString.Concat("Value: ", intVal.ToCharSpan("D5"));
//                                        ~~~~~~~~~~~~~~~~~

// 256文字のバッファーじゃ足りない場合
using (UString.RingBufferScope(new char[512]))
{
    _ = int.ToCharSpan("D512");
}

// 内部バッファーに直接アクセス
var (buffer, consumed) = sb.GetRawBuffer();

// 良きようにやってくれる TextMesh Pro 用のワンライナー
UString.SetCharArrayNonAlloc(textComponent, "Value: ", intVal);
// --- or ---
textComponent.SetCharArrayNonAlloc("Value: ", intVal);

SpanList<T>

Memory<T> にインデックスアクセサーを付けた Span<T> のジャグ配列実装。

可能だから、でジェネリック型になっていますが MessageTemplate (https://messagetemplates.org/) フォーマットの文字列を一括置き換えする以外の用途はないと思います。もし何かあったら教えてください。

return "おお{yushaName}よ、しんでしまうとは{nasakenaiType}".FormatNonAlloc(_fromList, _toList);
  • 最終出力の string 以外の全てを ReadOnlySpan<char> で指定可能
  • ホール名が指定可能なので元文字列の可読性が高い
  • 事前に結果の最大長を予測できるので効率よく処理可能

被りが無いようにホール名を付けるのが面倒という欠点がありますが、全てのホール名を決められた場合は fromList も toList も一回の初期化でアプリ存命中ずっと使いまわすことが出来るようになります。

(ホール名が 100, 200 あるとその数だけループが回るのでアレですが)

Leaked Managed Shell の防止

ManagedShell は Leaked managed shell を防止するためのヘルパーです。エディターとランタイムで共通して使えるようになっていて、ヌル許容性も適切にハンドリングします。

var unityObj = new GameObject();
UnityEngine.Debug.Log(unityObj.name);  // unityObj にヌル警告は出ない

ManagedShell.Dispose(ref unityObj);    // Dispose を通した後は、、、
UnityEngine.Debug.Log(unityObj.name);
//                    ~~~~~~~~ヌルの可能性があります!

非常にレアなケースで起きる「削除したはずのオブジェクトが見つかってしまう」問題への対処や IDisposable を実装した UnityEngine.ComponentDispose を呼びだす機能があります。

機能を無効化するためのプリプロセッサーシンボル

  • MANAGED_SHELL_DISABLE_DEEP_DESTROY
  • MANAGED_SHELL_DISABLE_DISPOSABLE_CHECKS

アロケなしで文字列を分割

分割数制限なし。

foreach (Range range in "Foo Bar Baz Qux Quux Quuux".SplitEnumerator(' '))
{
    Console.WriteLine(span[range].ToString());
}

最大10分割まで。

result = "A, B, C".SplitNonAlloc(" ,");     // ["A, B, C"]
result = "A, B, C".SplitAnyNonAlloc(" ,");  // ["A", "B", "C"]

foreach (var item in result) { /* ... */ }

for (int i = 0; i < result.Count; i++) { /* ... */ }

URL とかファイルパスとかに便利な2分割メソッド。

if ("Folder/FileName.ext".TrySplit('/', out var parentDirName, out var fileName))
{
    //...
}

if (url.TrySplitLast('#', out var urlAndQuery, out var anchorName))
{
    //...
}

その他

WhenEachEnumerator

EnumeratorCancellation なんてものがある

https://learn.microsoft.com/ja-jp/dotnet/api/system.runtime.compilerservices.enumeratorcancellationattribute?view=netstandard-2.1

UnityEngine.Object?? ?. を使う

Nullable()?? ?. 演算子を正しく使うための拡張メソッドです。

obj = unityObj.Nullable() ?? throw new NullReferenceException();
go = go.Nullable() ?? new GameObject();

アナライザーで Unity オブジェクトの Null-coalesce の扱いを提案や警告ではなくエラーに設定している場合は役に立ちます。

Leaked Managed Shell Detector

Leaked Managed Shell を引き起こす可能性がある型を一覧します。構文解析等は行っておらず false positive なチェックなので大量にリストされます。

Assembly Definition エディター

プロジェクト内のすべての .asmdef を一覧/編集できます。Unity Scripting Fundamentals を楽に追加するためだけに作りました。

一か所、例外を握りつぶしている場所があるので .asmdef のバックアップを取ってからのご利用をお勧めします。

オブジェクトプール

ThreadSafeSingleton

UnityEditorMainToolbar

CancellationToken based Lifecycle Manager for Unity Editor

排除すべきは Unity への依存ではなく、「System.Math.Clamp で良いのに UnityEngine.Mathf.Clamp を使っている」こういう意味なく Unity に依存してしまっている部分だけです。

NUnit-compatible Framework for Unity Editor

無用の長物。Assert.ThatIs.EqualToThrows.TypeOf<T> しか実装していません。

おまけ)レコード型

IsExternalInit はあくまで init アクセサーに必要なだけであり、record 自体はプロパティーの定義さえ行えばバニラ C# 9.0(Unity)でも使うことが出来ます。

Java っぽいクラス定義が出来て省エネですが、参照型の ValueObject としての挙動が自動実装されるので気を付けましょう。

public record MyRecord(object Obj, int Value)
{
    private object Obj { get; } = Obj;  // この Obj はプライマリーコンストラクターの Obj
    public int Value { get; internal set; } = Value;  // 同上

    // sealed record にするか virtual メソッドにして上書きすれば脱 ValueObject できる
    public virtual bool Equals(MyRecord? other) => other is not null && ReferenceEquals(other);
    public override int GetHashCode() => HashCode.Combine(typeof(MyRecord), Obj, Value);
}

おわりに

AI のコード生成を試しているとライブラリに存在する API を呼ぶ/テスト書くのは得意だけど、呼ばれる側の API を作るのは苦手って感じなので、これらの API 群も暫くは使えるんじゃないでしょうか。

再利用可能なコルーチン実装とか、列挙型についてのあれこれとか、ネットワーク関連とか RunHalfUlid が微妙とか他にもいろいろとあるんですが、気の向くままに作ってたら低レベル・低依存の API が一通り揃ってたって感じで感慨深いです。

以上です。お疲れ様でした。

28
32
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28
32

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?