← ブログ

タグとレイヤーをアセットにする

タグとレイヤーをアセットにする

2026-10-05
このページは自動翻訳されており、誤りが含まれる場合があります。誤りを見つけた場合は「このページを編集」リンクからご協力ください。

タグとレイヤーは小さな機能ですが、驚くほど広い範囲に関わります。どのオブジェクト同士が衝突するか、カメラに何が映るか、ゲームプレイのコードが何を検索できるかを決めます。そのデータはシーン、プレハブ、エディターの操作部品、セーブファイル、ときにはネットワークメッセージにも入り込みます。

Turian にはすでに、一般的な方式を実装していました。物理レイヤー、描画レイヤー、タグ、そしてそれらを使うフィルタリングと検索の API です。固定のテーブルを使えば、実装は単純です。レイヤーは小さなインデックスに、マスクは数ビットになり、実行時の判定を低コストで行えます。

ところが実装を見直すうちに、エンジンが成長したときにそのテーブルがどうなるか、気になり始めました。物理と描画は、利用する側のほんの二例です。オーディオ、ネットワーク、セーブシステム、ゲームプレイのモジュールにも、同じような分類が役立つはずです。個別に配布されるモジュールには、既存のテーブルが埋まっているプロジェクトに導入しても変わらない識別子も必要です。

Turian には、共有して編集できるゲームデータのための DataAsset システムがすでにあります。そこから疑問が生まれました。タグとレイヤー自体をアセットにしつつ、数千のノードで使えるほど小さな実行時表現を維持できないだろうか。

この記事では、最終的に選んだアーキテクチャと、その判断に伴うトレードオフを説明します。永続化する状態の基盤は実装とテストが済んでいます。ノード、利用側のシステム、エディターの作業フローとの全面的な統合は、まだ進行中です。

設計で守りたいこと

同時に満たしたい要件は三つあります。

  1. 安定した識別。 レイヤーの名前や表示を変えても、アセットを移動しても、参照は維持されること。
  2. 組み合わせられること。 プロジェクト自身のコードによる宣言、デザイナーが作るアセット、独立して開発されたモジュールを組み合わせられること。
  3. 実行時コストが小さいこと。 カリングと物理は所属情報を頻繁に読みます。読み取りにはコンパクトなデータを使い、モジュールを追加するだけで全ノードに大きなテーブルが確保されないこと。

識別情報と表示情報の置き場所は、アセットシステムが用意してくれます。一方、実行時には、アクセスの仕方に合った表現が別途必要です。

グループ、値、タグを定義する

制作時のモデルは、主に四つの要素から成ります。

アセット 保持するもの
LayerValueAsset 一つの値の GUID、表示名、色、説明
LayerGroupAsset 値への参照を順番に並べたマニフェストと、明示的なデフォルト値
TagAsset タグの GUID と表示用メタデータ
NodeLayerSettings プロジェクトのグループの順序、利用可能なタグ、物理・描画用グループの割り当て

一つのグループが、一つの分類体系を表します。たとえば物理グループには Default、Water、Actors、描画グループには Default、World、Interface を定義し、オーディオグループにはまた別の値を持たせられます。

ノードは各グループで一つの値を選びます。そのグループを使う側のマスクは、グループ内の値の部分集合を選びます。タグの所属はそれぞれ独立しています。ノードは、グループ内で一つだけ選ぶ方式とは別に、複数のタグを持てます。

名前は表示用メタデータです。変更できますし、別々のアセットが同じ表示名を持ってもかまいません。参照は GUID でアセットを識別します。値のマニフェストは各グループが持つため、すべての宣言を一つの巨大な共有テーブルに集めずに、各モジュールが自分のグループを編集できます。

物理と描画のグループ割り当ても、明示的な参照です。デザイナーがグループ名を変えても、利用側が参照するグループは変わりません。

三つの役割に、三つの表現

設計の中心にあるのは、制作、セッションの初期設定、実行時の検索に、それぞれ専用の表現を与えるという判断です。

段階 表現 役割
制作と永続化 アセットの GUID と、グループ・値の GUID の組 識別情報と編集の意図を保つ
セッションの初期設定 GUID をスロット・インデックスに対応付ける不変のレイアウト マニフェストを検証し、識別子を結び付ける
実行時 byte の所属情報、ushort のタグ ID、ビットマスク 頻繁な検索に低コストで答える
flowchart LR
    subgraph A["制作と永続化"]
        direction TB
        A1["アセットと型付きのコードキー"]
        A2["GUID による識別と表示情報"]
    end
    subgraph B["セッションの初期設定"]
        direction TB
        B1["LayerInterningService"]
        B2["検証、内部 ID 化、フィンガープリント生成"]
    end
    subgraph C["実行時の検索"]
        direction TB
        C1["NodeLayers — ノードごとに 24 B"]
        C2["ushort のタグ ID と 256 ビットのマスク"]
    end
    A -- "セッション開始時にバインド" --> B
    B -- "コンパクトで不変のレイアウト" --> C

セッションの初期設定では、LayerInterningService が解決済みの設定を検証し、スナップショットを作ります。グループには整数のスロットを割り当てます。各グループのデフォルト値を値インデックス 0 とし、残りの値をマニフェスト順に続けます。タグには GUID の昇順でコンパクトな ID を割り当てます。

テーブルができれば、実行時に所属を読むために必要なのはスロットと 1 バイトだけです。表示名、色、説明、アセットオブジェクトは、制作用の共有データとして残ります。

これは Open Asset Package の動機にもなった、安定した識別という原則と同じです。データの命名や整理の仕方が変わっても、参照は維持されるべきです。今回はそこに、稼働中のセッション向けに識別子をコンパクトなレイアウトへ変換する段階を加えています。

文字列キーを使わないコード登録

アセットの定義は、C# で書かれたモジュールでも使えなければなりません。登録 API は、明示的な TypeId 属性を持つ型を受け取ります。

using MASS4.Attributes;
using Turian.Engine.Core;

[TypeId("ea3c1636-272b-453b-8a73-2187d01581a5")]
public sealed record Physics : ILayerGroupKey;

[TypeId("89414f36-2137-4d82-b7ea-cced74c4864e")]
public sealed record Water : ILayerValueKey<Physics>;

[TypeId("812a8d5a-ad5e-4f26-ae8a-6d5e6d120d91")]
public sealed record Player : ITagKey;

var settings = new LayerRegistrationBuilder()
    .Group<Physics>(group => group.Default().Value<Water>())
    .Tag<Player>()
    .Build();

settings.PhysicsGroup = settings.Groups[0];

この例の GUID はキーを識別します。同じ TypeId を維持すれば、型名や名前空間を変えても識別情報は保たれます。マーカーのインスタンスは作りません。Water は ILayerValueKey<Physics> を通じて所属グループを宣言するため、別のグループへの登録はコンパイラーが拒否できます。

現在のマーカー API は class と record class を使います。共有の TypeId 属性がクラスを対象としているため、readonly record struct のキーに対応できない点は、既知の制約として残っています。

デザイナーが作るアセットには、コードのキーの TypeId と同じ GUID を使えます。これは意図的に、コード側の識別情報を補う仕組みです。一致する値アセットやタグアセットは表示情報を提供し、一致するグループアセットは完全なマニフェストとデフォルト値を提供します。DataAsset クラス自体の TypeId は、シリアライズされたペイロードの型を識別するという別の役割を持ちます。

16 個のインラインスロットと、疎な追加領域

初期案では、グループ数の上限を 16 に固定していました。魅力は明快です。グループごとに 1 バイトで、所属データは 16 バイトです。ところがモジュールを考えた途端に問題が見えました。値を少ししか使わないシステムでも、プロジェクト全体で共有する 16 スロットの一つを消費してしまいます。

実装した所属キャッシュは、最初の 16 グループをインラインに保持し、それを超えるグループでデフォルト以外が指定された場合だけ、ソート済みの疎な配列に保存します。

64 ビットのランタイムでは、NodeLayers は 24 バイトです。インラインの所属情報が 16 バイト、配列への参照が 8 バイトです。追加領域への割り当てがなければ、その参照は null です。グループを増やすだけでは、ノードごとのメモリ確保は増えません。未指定の追加グループは、デフォルトのインデックス 0 に解決されます。

追加領域の各エントリーはパディング込みで 8 バイトを使い、別途配列のヘッダーと確保のオーバーヘッドがかかります。読み取りには二分探索を使います。編集時は小さな配列をコピーし、struct を値としてコピーしたときの独立性を保ちます。指定値が変わらなければ、メモリは確保しません。

このため、グループの順序は性能上の判断になります。物理と描画の所属は毎フレーム多数のノードで読まれるので、先頭付近に置くべきです。明示的に割り当てたどちらかのグループが最初の 16 スロットから外れると、レイアウト構築時に警告します。プロファイリングで追加領域のグループが高頻度に使われると分かった場合は、その利用側で、ノードハンドルを添字とするシーン所有の列へ所属情報を移すべきです。

24 バイトという数字は、所属キャッシュのサイズです。タグ、マスク、永続化する GUID の状態、ノードのほかの部分には、それぞれ別の保存コストがあります。識別と実行を分けても、永続状態が無料になるわけではありません。表示用メタデータを共有したまま、実行時の保存方法を意識して選べるようになるのです。

1 バイトで一つの値を選び、マスクで部分集合を選ぶ

各グループは 256 個の値に対応します。ノードが持つ 1 バイトは、その中の値を指すインデックスです。利用側のマスクは値ごとに 1 ビットを持ち、インラインの ulong 4 個に格納します。

この違いは重要です。独立した 8 個のフラグには 256 通りの組み合わせがあります。一方、所属を表す byte は 256 個の値から一つを選び、利用側の 256 ビットマスクはその任意の部分集合を表せます。同じグループで複数の値に所属するなら、別のノード表現と API が必要です。

タグには ushort の 65,536 個の ID をすべて使います。0 も有効な ID です。検索の成否は別に返すため、未検出を表す特別な ID を予約する必要がありません。あるモジュールが 8,192 個のタグを宣言しても、ほかのモジュールやプロジェクトのコンテンツに 57,344 個の ID が残ります。

これらは、いずれも明示的に選んだ容量です。不正なマニフェストは、利用可能なレイアウトを構築する前に拒否します。プロジェクト全体のグループ数に 16 スロットという固定上限はありません。ただし、共有テーブルと、実際に割り当てた追加領域のエントリーはメモリを使います。

実行時インデックスには有効期間がある

あるグループのデフォルト以外の値が Water と Actors だとします。その前に別の値を挿入すると、次のセッションでは byte のインデックスがずれることがあります。GUID は変わりません。

ここから厳密な永続化ルールが導かれます。実行時インデックスはセッション内だけで有効であり、永続化しません。 シーン、プレハブ、ゲームのセーブデータ、制作時に設定したマスクは、識別子を保存します。新しいレイアウトで読み込む際に、もう一度解決します。

NodeLayerState は、グループと値の明示的な GUID の組、およびタグの GUID を保存します。LayerMaskState は、グループの GUID と選択した値の GUID を保存します。バインドによって NodeLayers、コンパクトなタグ ID、CompiledLayerMask が得られます。実行時の所属キャッシュとマスクキャッシュを JSON にシリアライズしようとすると拒否し、代わりに GUID の状態オブジェクトを保存するよう案内します。

ゲームのセーブと読み込みの往復は、保存後から読み込み前までの間に値を挿入するケースも含め、すでにテスト済みです。Node のシーン・プレハブへの自動永続化と複製は、まだこの基盤に接続する必要があります。

デフォルト値と、選択の意図を保つ

グループのデフォルトに従うことと、たまたま現在デフォルトになっている値を明示的に選ぶことには、微妙な違いがあります。

デフォルトが X だとします。一方のノードは未指定で、もう一方は X を明示的に選んでいます。後でデザイナーがデフォルトを Y に変えたら、未指定のノードは Y になり、明示的に指定したノードは X のままであるべきです。

最初は両方とも実行時インデックス 0 に解決されるので、その byte だけでは違いを残せません。永続状態には、明示的な GUID の組を残す必要があります。「デフォルトに従う」またはリセットの操作を別に設け、そこで参照を消去します。この動作には回帰テストがあり、Inspector の操作でも違いが分かるようにする必要があります。

削除されたコンテンツにも、編集上の似た問題があります。存在しない値を参照するノードは、参照先を失った GUID を残しつつ、既知のグループのデフォルトにフォールバックします。マスクも存在しない値の GUID を残しますが、その値のビットは立てません。存在しないタグも識別子を残し、実行時の所属には加えません。未解決の識別子ごとに、スナップショットあたり一度警告します。コンテンツが戻れば、参照は再び解決できます。

決定性には明示的な入力が必要

登録の入口は、設定アセットの順序付きリストと、明示的なビルダー呼び出しです。グループの検出は、ファイルシステムの走査順、アセットデータベースの列挙順、アセンブリの検出順に依存しません。モジュールの起動も、明示的な登録順序に従う必要があります。

明示的な登録は、決定性の好みだけでなく NativeAOT の制約でもあります。Turian は事前コンパイルしたバイナリーとして配布します。よくある代案、つまり起動時に読み込み済みアセンブリからマーカー属性を探す方式は、まさにトリミングで問題になるパターンです。リンカー向けの注釈で保持を指定しない限り型がバイナリーから消え、その注釈はモジュールが増えるたびに増殖します。ビルダー API は検出をコンパイル時へ移します。実行時に見えるのは明示的な登録手順であり、TypeId の GUID は、特別な助けなしにコンパイラーが保持する普通の文字列リテラルです。

セッションのレイアウトには、バージョン付きの SHA-256 フィンガープリントもあります。対象は順序付きのグループ・値・タグの識別子と、その実行時スロット・インデックス、さらに物理と描画のグループ割り当てです。表示用メタデータは影響しません。バイト列の仕様は文書化し、C# 実装とは独立に生成した参照データに対してテストしています。

このフィンガープリントにより、通信相手やインデックスベースのリプレイは、コンパクトなデータが同じ意味を持つか確認できます。一致しなければ互換性を認めません。通常の GUID ベースのセーブは変更後のレイアウトにバインドできますが、コンパクトなインデックスの交換には、レイアウトとコンテンツの一致が必要です。

エディターでの変更も、同じ境界を守る必要があります。予定している操作フローでは、現在のフィンガープリントと変更後のものを表示し、再検証が必要な接続相手を示し、再起動・再バインドするか、変更を保留するか選べます。保留する場合は既存のセッションスナップショットを使い続け、変更待ちのバナーを表示します。稼働中のセッションでテーブルを差し替え、既存のバイト列の意味を変えてはいけません。

CPU の容量で GPU のビット幅は決まらない

現在のレンダラーは、ライティング処理で 32 ビットのレイヤーデータを使います。当初は 256 ビットマスクへの対応には、CPU、永続化、uniform、シェーダー、SPIR-V をまとめて変更する必要があると考えていました。

詳しく調べると、GPU が必要とするのは描画グループのマスクだと分かりました。ほかのグループは、描画用 uniform を変えずに独自の幅を持てます。

CompiledLayerMask.TryGetLegacyBits() は、そのグループの宣言する値が 32 個以下の場合、現行のパッキングを許可します。ある選択でたまたま下位ビットしか使っていなくても、それより大きなグループは拒否します。レンダラーは、そのグループで有効な所属をすべて解釈する必要があるからです。

この仕組みなら、描画グループが既存の GPU 経路に収まる間は、CPU と永続化の統合を先に進められます。それより大きな描画グループは、uniform とシェーダーの対応が済むまで、レンダラーへのバインド時に明示的に拒否する必要があります。黙って切り捨てると、CPU と GPU のどちらで判定したかによってフィルタリング結果が変わってしまいます。

ゲームプレイのコードで維持するもの

タグの保存方法を変えても、プログラマーがすでに使っている検索の振る舞いは維持する必要があります。検索はアクティブなノードを選び、祖先のアクティブ状態を考慮し、破棄済みノードを除外します。一件を返す検索は見つからなければ null を、全件を返す検索は空のリストを返します。

シーン内の結果はノードの登録順です。シーンマネージャーの検索は、ロード済みシーンをロード順にたどり、その後で永続ノードを調べます。タグやアクティブ状態を変えても登録位置は変わらず、タグの所属が重複しても結果は重複しません。

予定している公開検索 API は、GUID と型付きのタグキーを受け取り、セッションのレジストリー内で ushort ID に解決します。保存方法を変えても、タグの編集、親の変更、破棄、シーンのアンロードに対する既存のフックを維持する必要があります。Play Mode は、複製したシーン用に独立した登録を作らなければなりません。

実装の現状

制作用アセット、型付き登録、識別情報の補完、不変のレイアウト、コンパクトな所属キャッシュ、GUID の状態オブジェクト、参照切れへの対応、マスク、フィンガープリントによる保護、検証テストは実装済みです。

残るのは、これらをセッションの起動処理、Node のシリアライズと複製、タグレジストリーの保存と公開検索、物理・描画の利用側、エディターの操作部品、テンプレート、通信相手・リプレイの読み込みにつなぐ作業です。描画グループが 32 値を超える場合には、GPU 側のビット幅拡張も必要です。

トレードオフは具体的です。初期設定で検証と識別子の解決を行うこと。追加領域の読み取りはインラインより高コストであること。追加領域の編集ではメモリを確保すること。コンパクトな ID には容量制限があること。そして、レイアウトの変更には明示的な切り替えの境界が必要なことです。

このアーキテクチャで気に入っているのは、責任の分担が明確な点です。アセットは識別情報とデザイナーの意図を保ちます。セッションの初期設定がそのデータを検証し、バインドします。実行時の利用側は、コンパクトな所属情報とマスクを使います。頻繁にアクセスするデータを小さく保ちながら、タグとレイヤーをより多くのシステムへ広げられます。そしてレイアウトが変わったときには、互換性の問題を明示できます。

#architecture#engine#csharp#gamedev