はじめに

この記事は Unity 6 (6000.3) で動作確認しています。

Unity UI Toolkit でコードから要素を取るには、こう書きます。

var submitButton = root.Q<Button>("submit-button");
submitButton.clicked += OnSubmit;

この呼び出しは、次のいずれかが間違っていてもコンパイルが通ってしまいます。

  • 要素名 "submit-button" のタイポ
  • 型引数 <Button> と実体の不一致
  • そもそも UXML 側に該当 name の要素がない

実行時に NullReference や型不整合で初めて気付くことになります。 同じ問題は USS のクラス名(AddToClassList("is-active"))にも共通しています。

そこで、UXML と USS をコンパイル時に解析し、これらをまとめて型安全化する Source Generator を実験的に作りました。 この記事では何ができるか、内部でどう動かしているかを紹介します。

できること

1. UXML 要素への型安全アクセス

UXML はそのまま書きます。

<ui:UXML xmlns:ui="UnityEngine.UIElements">
    <ui:VisualElement name="root-container">
        <ui:Label name="title-label" text="Sample" />
        <ui:TextField name="name-input" label="Name" />
        <ui:Button name="submit-button" text="Submit" />
        <ui:Label name="result-label" />
    </ui:VisualElement>
</ui:UXML>

C# 側は partial class に [UxmlView] 属性を付けるだけです。

[UxmlView("Assets/UI/SampleScreen.uxml")]
public partial class SampleScreenController : MonoBehaviour
{
    [SerializeField] private UIDocument _document;

    private void OnEnable()
    {
        InitializeUI(_document);

        UI.SubmitButton.clicked += OnSubmit;
        UI.TitleLabel.text = "Welcome";
    }

    private void OnSubmit()
        => UI.ResultLabel.text = $"Hello, {UI.NameInput.value}!";
}

UI.SubmitButton は Button、UI.NameInput は TextField として解決されます。 要素名のタイポはコンパイルエラー、型違いの操作(Label に value を代入する等)も同じくコンパイル時に止まります。

裏で生成されているのは UXML を Q<T>(...) で引くだけの薄いラッパーです。ランタイムの挙動は手書きと同じで、ユーザーが文字列を書かなくて済む点だけが違います。

生成されるコードは 2 ファイルに分かれます。 1つ目は UXML の要素をプロパティとして持つビュークラスです。

// SampleScreenUxmlView.g.cs (自動生成)
public sealed class SampleScreenUxmlView
{
    public VisualElement Root { get; }
    public VisualElement RootContainer { get; }
    public Label TitleLabel { get; }
    public TextField NameInput { get; }
    public Button SubmitButton { get; }
    public Label ResultLabel { get; }

    public SampleScreenUxmlView(VisualElement root)
    {
        Root = root;
        RootContainer = root.Q<VisualElement>("root-container");
        TitleLabel = root.Q<Label>("title-label");
        NameInput = root.Q<TextField>("name-input");
        SubmitButton = root.Q<Button>("submit-button");
        ResultLabel = root.Q<Label>("result-label");
    }
}

2つ目はコントローラの partial 拡張で、UI プロパティと InitializeUI メソッドを追加します。

// SampleScreenController.UxmlView.g.cs (自動生成)
public partial class SampleScreenController
{
    protected SampleScreenUxmlView UI { get; private set; } = null!;

    protected void InitializeUI(UIDocument document)
        => UI = new SampleScreenUxmlView(document.rootVisualElement);

    protected void InitializeUI(VisualElement root)
        => UI = new SampleScreenUxmlView(root);
}

2. USS クラス名の定数化

USS のクラス名やカスタムプロパティを定数化します。

.is-active {
  opacity: 1;
}
.card--highlighted {
  border-color: yellow;
}
:root {
  --primary-color: #3498db;
}

[UssConstants] を付けた static partial class に対して、定数を生成します。

[UssConstants("Assets/UI/Styles/Common.uss")]
internal static partial class CommonStyles { }

// 使用側
element.AddToClassList(CommonStyles.IsActive);
element.AddToClassList(CommonStyles.CardHighlighted);

USS のクラス名をリネームすれば、参照側もコンパイルエラーで気付けます。

仕組み

以降は Roslyn の Incremental Source Generator(IIncrementalGenerator)を前提に書きます。 Source Generator は C# のコンパイル過程に介入して、追加のソースコードを生成する仕組みです。 Incremental 版は入力(属性付きクラス、Compilation、外部ファイルなど)の差分だけを再処理し、ビルド時間への影響を抑えます。 詳しくは 2022年(2024年)のC# Incremental Source Generator開発手法 がわかりやすいです。

UXML をどう読むか

C# 以外のファイルは、Roslyn では AdditionalFiles(コンパイラに渡される追加ファイル)として扱います。 IIncrementalGenerator からは context.AdditionalTextsProvider で参照します。 これは ISourceGenerator の context.AdditionalFiles と同じデータを、Incremental 版のインターフェースで見るものです。 世代に関係なく、ファイルの内容変更まで追跡できます。

ただ、Unity は既定では .uxml / .uss をコンパイラに渡しません。 Filename.[AnalyzerName].additionalfile という命名規則で追加ファイルを渡す仕組みはありますが、UXML をこの規則に合わせてリネームするわけにはいきません。 そこで AssetPostprocessor でプロジェクト内の .uxml / .uss を集め、Assets/csc.rsp に -additionalfile: 行として自動で書き出しています。 これで Roslyn 側の AdditionalTextsProvider に UXML が届きます。

// AssetPostprocessor 側:対象アセットを csc.rsp に -additionalfile: で同期
internal sealed class AdditionalFilesRspSync : AssetPostprocessor
{
    private static void OnPostprocessAllAssets(string[] imported, ...)
    {
        if (HasUxmlOrUssChange(imported)) UpdateRsp();  // csc.rsp を書き換え
    }
}

届いた内容は、パスと content-hash だけを持つ小さな値型(IEquatable な record 相当)に落としてから、増分パイプラインに乗せます。 追加ファイルを増分パイプラインに乗せるときの定石です。 この値型だけを入力にすれば、「.uxml のバイトが変わったときだけ後段が動く」形になります。 ただし次節のとおり、実際には型解決のため Compilation も併用するので、この利点は完全には活かせません。 ここでの狙いは、ディスク I/O を避けて最新の内容をスナップショットとして持ち込むことです。

var uxmlTexts = context.AdditionalTextsProvider
    .Where(static t => t.Path.EndsWith(".uxml", StringComparison.OrdinalIgnoreCase))
    .Select(static (t, ct) => new UxmlText(t.Path, t.GetText(ct)?.ToString() ?? ""))  // content-hash な値型
    .Collect();

ジェネレータ内で File.ReadAllText を使い、ディスクから直接読む手もあります。 ただし、アナライザやジェネレータでのファイル I/O は RS1035 で禁止されています。同じ入力から異なる出力が生まれると決定性が壊れ、増分キャッシュや分散ビルドの前提が崩れるからです。 additionalfile 経由なら Roslyn が内容変更まで追跡してくれるので、こちらを正道にしています。

コード生成

エントリポイントは IIncrementalGenerator.Initialize です。 [UxmlView] を付けたクラスを ForAttributeWithMetadataName で拾い、前節の UXML(AdditionalTextsProvider 由来)と、型解決に使う Compilation を Combine します。 その結果を RegisterSourceOutput に渡して書き出します。

[Generator(LanguageNames.CSharp)]
public sealed class UxmlViewIncrementalGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        var classes = context.SyntaxProvider
            .ForAttributeWithMetadataName(
                "UIToolkitSourceGenerator.UxmlViewAttribute",
                predicate: static (node, _) => node is ClassDeclarationSyntax,
                transform: static (ctx, ct) => GetUxmlViewInfo(ctx, ct))
            .Where(static info => info is not null);

        var uxmlTexts = context.AdditionalTextsProvider
            .Where(static t => t.Path.EndsWith(".uxml", StringComparison.OrdinalIgnoreCase))
            .Select(static (t, ct) => new UxmlText(t.Path, t.GetText(ct)?.ToString() ?? ""))
            .Collect();

        var input = context.CompilationProvider.Combine(classes.Collect()).Combine(uxmlTexts);

        context.RegisterSourceOutput(input,
            static (spc, src) => Execute(src.Left.Left, src.Left.Right!, src.Right, spc));
    }
}

ForAttributeWithMetadataName は Roslyn 4.3.0 で追加された API です。 属性名による一次絞り込みを Roslyn 側で行ってくれます。 なお 4.3.0 には既知の不具合があるので、自分でパッケージ参照するなら実用上は 4.3.1 以降が無難です。

ここで Compilation を後段まで持ち回っているのには理由があります。UXML の要素名から実際の型(Q<T> の T)を、Roslyn のシンボル解決で確定するためです。

UXML の中身は文字列です。ForAttributeWithMetadataName の transform で得られる意味情報だけでは、そこに書かれた要素名に対応する型までは解決できません。

その代わり、Compilation は C# を1文字編集するたびに別インスタンスになります。 これを終端まで Combine している以上、出力段の増分キャッシュは効きません。 Execute は毎コンパイル走りますし、IDE では毎編集で再実行され得ます。 冒頭で触れた「差分だけ再処理」という理想からは外れますが、これは型検証と引き換えの割り切りです。

前節の content-hash 値型にも、意味はあります。 この制約下でも .uxml の再読み込みをディスク I/O ではなくスナップショットで賄えますし、Combine を外せば本来の増分が効く形に保っておけるからです。

出力は「Q<T>(...) を呼ぶラッパークラス」と「元の partial class を拡張する側」を別ファイルに分けています。こうしておくと、ユーザーがデバッグで生成コードを覗いたときに責務が一目で分かります。

UXML/USS 変更の検知

もう1つ、Unity 特有の壁があります。 Unity は .uxml / .uss などアセットだけが変わっても、C# を再コンパイルしません。 そのままでは、UXML を編集してもジェネレータは動きません。

そこで AssetPostprocessor で対象アセットの変更を検知したら、CompilationPipeline.RequestScriptCompilation() で再コンパイルを促します。 あわせて、内容がタイムスタンプだけのダミー C# ファイル(GeneratorTrigger.cs)を書き換えます。

// GeneratorTrigger.cs — 書き換え後の中身はこれだけ
// Auto-touched when UXML/USS files change.
// Last touch: 2026-04-24T05:49:23.0773340Z
private static void ForceRecompilation()
{
    string content = $"// Last touch: {DateTime.UtcNow:O}\n";
    File.WriteAllText("Assets/UIToolkitGen/Runtime/GeneratorTrigger.cs", content);
    AssetDatabase.ImportAsset("Assets/UIToolkitGen/Runtime/GeneratorTrigger.cs",
        ImportAssetOptions.ForceUpdate);
}

ダミーを書き換えるのは、C# 側の入力を実際に変えるためです。 こうすると、Unity が「入力は変わっていない」と判断してコンパイル自体をスキップするのを防げます。

再コンパイルが走ると、Unity は毎回新しいコンパイラプロセスでジェネレータを起動します。 ここには IDE のような常駐状態(プロセスを跨いで残る増分キャッシュ)がありません。 そのため Execute は必ず実行され、AdditionalTextsProvider(=csc.rsp 経由で渡した最新の .uxml / .uss)から更新後の内容を読み直します。 IDE 上での編集についても、前節の content-hash によって「実際に変わった UXML」だけが後段を再実行します。

まとめ

最近は UI Toolkit が World Space にも対応していて XR でも使いやすくなっていたり、コーディングエージェントとの相性の良さから uGUI からの移行を進めたい気持ちがあります。ただ、UI Toolkit は Q<T>("...") や AddToClassList("...") のように文字列で要素を引く API も多く、タイポやリネーム漏れが実行時まで露見しないという辛さもあります。Source Generator で UXML/USS をコンパイル時に解析することで UI Toolkit も型安全に扱えるようになりました。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる