AIエージェントにUnity UIを操作させる取り組み

AIエージェントにUnity UIを操作させる取り組み

AIエージェントにUnity UIを操作させる取り組み

はじめに

株式会社QualiArtsでUnityエンジニアをしている松平です。

AIエージェントを使った開発で理想的なのは、実装→動作確認→修正のループをAIエージェント自身が回せることです。コードを書いて、実際にUIを操作して動作確認し、問題があれば自分で修正する。この一連の流れをAIエージェントが自走できれば、開発効率は大きく向上します。

しかし、このループの中で動作確認が大きなボトルネックになります。

Webアプリケーションの場合、PlaywrightやSeleniumといったブラウザ自動操作ツールが成熟しており、中でもPlaywrightを経由した動作確認フローが一定有効となっています。

一方、UnityではHierarchyという形で構造が定義されています。 しかし、HierarchyはUI以外のオブジェクトも数多く存在しており、Hierarchyの情報を直接AIエージェントに与えたとしても、どの要素に対して作用させればいいかの判断が難しい状況です。

本稿では、この課題を解決するために開発したuloopのカスタムツール get-interactable-ui-elements の設計と実装を紹介します。AIエージェントがUnity UIを認識・操作できるようにすることで、実装から動作確認までを一気通貫で任せられる開発ループの実現を目指しています。

※本稿では、Claude CodeやCodexのような開発支援のAIエージェントを指して、以降AIエージェントと呼びます。
※Unity EditorとAIエージェントの橋渡しにはunity-cli-loopを使用しています。以降uloopと呼びます。

AIエージェントがUI操作を行うために必要なこと

AIエージェントがUnityのUIを操作するためには、主に次の3つの情報が必要です。

  • UI要素の認知

    ボタンが4つある、スライダーが2つある、といった画面上の要素の把握です。WebでいえばDOM要素の列挙に相当します。

  • 操作種別

    このボタンはタップ、このスライダーはドラッグ、といった操作方法の判別です。Webでいえば <button><input type="range">のような要素種別が担う役割に近いです。

  • UI要素のコンテキスト

    設定画面に遷移するボタン、音量を設定するスライダー、といった意味情報です。WebでいえばinnerTextaria-labelに相当します。

Webの場合、これらの情報はDOMとアクセシビリティ属性から取得できます。しかしUnity UIでは、これらに相当する標準的な仕組みがありません。

そこで、uloopのカスタムツールとして get-interactable-ui-elements を開発しました。 Play Mode中のUnity Editorから操作可能なUI要素を自動収集し、AIエージェントが理解できる構造化されたJSONとして出力するツールです。

以下のシーケンス図は、AIエージェントがUI操作を行う際の全体的な流れを示しています。

General Sequence

UI要素の収集

Selectable経由による全件取得

最もシンプルなアプローチとして、UnityのSelectableクラスが提供するallSelectablesArrayから、シーン内の全Selectableを直接取得する方法があります。

var selectables = Selectable.allSelectablesArray;
foreach (var selectable in selectables)
{
    if (selectable != null
        && selectable.gameObject.activeInHierarchy
        && selectable.interactable)
    {
        rawElements.Add(selectable.gameObject);
    }
}

しかし、この方式だけではSelectableを継承していないカスタムUIコンポーネント(IPointerClickHandlerを直接実装したものなど)を拾うことができません。そこで、Raycastによるグリッドサンプリングを併用しています。

Raycastによるグリッドサンプリング

画面をN×Nのグリッドに分割し、各点に対してEventSystem.RaycastAllを実行します。

var stepX = Screen.width / (float)gridSize;
var stepY = Screen.height / (float)gridSize;

for (var x = 0; x < gridSize; x++)
{
    for (var y = 0; y < gridSize; y++)
    {
        var samplePoint = new Vector2(
            x * stepX + stepX * 0.5f,
            y * stepY + stepY * 0.5f);

        var eventData = new PointerEventData(EventSystem.current)
        {
            position = samplePoint
        };

        var results = new List<RaycastResult>();
        EventSystem.current.RaycastAll(eventData, results);

        foreach (var result in results)
        {
            if (result.gameObject != null)
                rawElements.Add(result.gameObject);
        }
    }
}

EventSystem.RaycastAllはUnityのUIイベントシステムが実際にタッチ判定に使っている仕組みです。これを画面全体に対してグリッド状に実行することで、Selectableを継承していないカスタムコンポーネントであっても、Raycast Targetが有効であれば検出できます。

また、実際のプロダクトで動作するコードではないので、ある程度パフォーマンスについては許容しています。

ただし、Raycastは画面上に見えている要素しか拾えないため、他の要素の裏に隠れた要素や画面外の要素は検出できません。先述のSelectable直接収集と組み合わせることで、互いの弱点を補完しています。

子要素に対する正規化

Raycastでヒットするのは、ImageTextといったGraphicクラスのRaycastターゲットが有効になっているオブジェクトです。しかし、AIエージェントが操作すべき単位はButtonSliderといったUIの操作を行うコンポーネントです。

そこで、ResolveToInteractableメソッドで、ヒットした子要素を最寄りの操作コンポーネントである祖先のオブジェクトに解決します。

private GameObject ResolveToInteractable(GameObject go)
{
    if (go.TryGetComponent<Selectable>(out _)) return go;
    var selectable = go.GetComponentInParent<Selectable>();
    return selectable?.gameObject;
}

上記コードはサンプルとしてSelectableを対象としていますが、こちらはプロジェクトごとの任意の型に置き換えることが可能です。

この正規化により、同じButtonImageTextがそれぞれヒットしても、最終的には1つのButtonとして集約されます。Selectable祖先が見つからない場合は非インタラクタブルな要素として除外します。

tap / longPress / drag の3分類

収集した要素は、操作方法ごとに分類します。取得したUIコンポーネントは tap ではなく drag すべきとAIエージェントが判断できるようにするためです。

// tap: IPointerClickHandlerまたはButton
var canTap = go.GetComponentInParent<IPointerClickHandler>() != null ||
             go.GetComponentInParent<Button>() != null;

// longPress: IPointerDownHandler
var canLongPress = go.GetComponentInParent<IPointerDownHandler>() != null &&
                   go.GetComponentInParent<Button>() == null;

// drag: ScrollRect / Slider / Scrollbar
var canDrag = go.GetComponentInParent<ScrollRect>() != null ||
              go.GetComponentInParent<Slider>() != null ||
              go.GetComponentInParent<Scrollbar>() != null;

1つの要素が複数のカテゴリに属する場合もあります。例えばSliderはドラッグ操作が主ですが、IPointerDownHandlerを実装しているためlongPressにも分類されます。

テキストコンテキストの取得設計

UI要素の収集と分類ができても、AIエージェントにとってはまだ不十分です。Buttonが4つあるとわかっても、それがSettingsなのかQuitなのかがわからなければ、自然言語の指示と結びつけることができません。

今回の実装では、このボタンが何を意味するかを伝えるテキスト情報が、AIエージェントにとって特に重要なメタデータでした。

TextとImageが同階層にある問題

Unity UIの典型的なButton構造を見てみます。

Text Button Sample

Continue (Button) => 操作対象
├── Background (Image) =>  Raycastでヒットする
└── Label (Text) => 自然言語で見た時の該当コンポーネントの役割

ImageTextはどちらもButtonの子要素ですが、別々のGameObjectです。RaycastでImageがヒットしたとき、隣にあるTextの内容は自動的には取得できません。

GetComponentsInChildrenによるテキスト収集

この課題を解決するため、操作コンポーネント起点で子孫のTextを全収集する設計にしました。

private string GetTextContent(GameObject go)
{
    var texts = new List<string>();

    foreach (var tmp in go.GetComponentsInChildren<Text>())
    {
        // 別のSelectableに属するテキストは除外(ネスト要素の混入防止)
        var parentSelectable = tmp.GetComponentInParent<Selectable>();
        if (parentSelectable != null && parentSelectable.gameObject != go)
            continue;

        if (!string.IsNullOrEmpty(tmp.text))
            texts.Add($"- {tmp.text}");
    }

    return string.Join(Environment.NewLine, texts);
}

SerializedObjectによる参照元の逆引き

もう一つの工夫として、GetReferencingParentがあります。

UnityのUIでは、ButtonSliderが親階層内の別のGameObjectのコンポーネントから参照されることがあります。例えば、あるパネルのButtonGroupコンポーネントがCloseボタンへの参照を持っているようなケースです。 自然言語の指示として考えられるパターンだと、

オプション画面の閉じるボタンをタップしたら

といった場合、AIエージェントにとってはButtonの名前やテキストよりも、親階層内でそれを参照している管理オブジェクトの名前の方が文脈として有用な場合があります。

private GameObject GetReferencingParent(GameObject go)
{
    // 対象コンポーネント(Button, ScrollRect, Slider, Scrollbar)を取得
    Component targetComponent = null;
    if (go.TryGetComponent<Button>(out var button))
        targetComponent = button;
    // ... 他のコンポーネントも同様

    if (targetComponent == null)
        return go;

    // 親階層を遡って、targetComponentを参照しているオブジェクトを探す
    var current = go.transform.parent;
    while (current != null)
    {
        var components = current.gameObject.GetComponents<Component>();
        foreach (var comp in components)
        {
            if (comp == null) continue;

            using (var so = new SerializedObject(comp))
            using (var prop = so.GetIterator())
            {
                while (prop.NextVisible(true))
                {
                    if (prop.propertyType == SerializedPropertyType.ObjectReference
                        && prop.objectReferenceValue == targetComponent)
                    {
                        return current.gameObject;
                    }
                }
            }
        }
        current = current.parent;
    }

    return go;
}

SerializedObjectを使って親階層のコンポーネントのプロパティを全走査し、対象のButtonSliderへの参照を持っているオブジェクトを見つけます。これにより、UIの意味的な構造がAIエージェントに伝わりやすくなります。

出力されるメタデータの全体像

以上の処理を経て、1つのUI要素は以下のメタデータとして出力されます。

フィールド役割
pathHierarchyのPath。操作対象の一意特定に使用Canvas/MainMenu/Window/Settings
referenceTargetNameGetReferencingParentで取得した管理オブジェクト名。自然言語との照合に使用MainMenu
componentTypeコンポーネント種別。操作方法の判断に使用Button
textContentテキストコンテキスト。意味の理解に使用- Settings
screenRectスクリーン座標。ドラッグ方向の計算に使用{x: 1434, y: 255, width: 279, height: 91}
canvasName所属Canvas。レイヤーの区別に使用MainMenu
sortingOrder描画順。重なり時の前後関係判定に使用1

これらが操作方法ごとに分類されたJSONとして出力されます。実際のサンプルプロジェクトでの出力例を示します。

{
  "tap": [
    {
      "path": "Canvas/MainMenu/Window/Settings",
      "referenceTargetName": "Settings",
      "componentType": "Button",
      "screenRect": {
        "x": 1434.03,
        "y": 255.45,
        "width": 279.18,
        "height": 91.69
      },
      "canvasName": "MainMenu",
      "sortingOrder": 1,
      "textContent": "- Settings"
    },
    {
      "path": "Canvas/MainMenu/Window/Quit",
      "referenceTargetName": "Quit",
      "componentType": "Button",
      "screenRect": {
        "x": 1434.51,
        "y": 163.76,
        "width": 279.18,
        "height": 91.69
      },
      "canvasName": "MainMenu",
      "sortingOrder": 1,
      "textContent": "- Quit"
    }
  ],
  "longPress": [],
  "drag": []
}

Hierarchy

AIエージェントはこのJSONを受け取ることで、Settingsという名前のButtonがあり、タップで操作できるということを理解できるようになります。

自然言語からのUI操作

構造化メタデータを取得したAIエージェントは、自然言語の指示をUIの操作コードに変換できるようになります。

この操作には、独自に実装したUIEmulatorを使用しています。UIEmulatorは、Finder(UI要素の検索)とOperator(操作の実行)の2つのAPIを提供します。

// Finder: HierarchyのPathでUI要素を検索
var target = UIEmulator.UIEmulatorEntry.Finder.FindByPath(
    "Canvas/MainMenu/Window/Settings");

// Operator: タップ操作を実行
UIEmulator.UIEmulatorEntry.Operator.Tap(target);

タップ操作はExecuteEventsを使い、PointerDown→PointerUp→PointerClickのイベントシーケンスを発火します。ドラッグ操作にはInputSystemの仮想Touchscreenを使い、実機タッチに近い挙動でエミュレートしています。

AIエージェントにこのAPIを教えるために、Skillを活用しています。referencesディレクトリにuloop経由でのC#の動的実行で利用するAPIの使い方やエラーハンドリングをまとめておくことで、AIエージェントは必要に応じてこれらを参照しながら操作コードを組み立てます。

例えば「設定画面を開いてQuality Sliderを最小にして」という指示に対して、AIエージェントは以下のように動作します。

Control Sequence

実行時サンプル

まとめ

本稿では、AIエージェントにUnity UIを認識させるためのMCPカスタムツールの設計と実装を紹介しました。

Unity UIにはWebアプリケーションで言うDOMのような標準的な構造表現が成熟しておらず、AIエージェントが画面上のUI要素をそのまま認識することはできません。この課題に対して、本稿では次のようなアプローチを取りました。

  • UI要素の認知 Raycastグリッドサンプリングとコンポーネント直接収集を組み合わせ、見えている要素と構造上存在する要素の両方を拾うようにしました。
  • 操作方法の判断 要素をtap / longPress / dragに分類し、AIエージェントが適切な操作方法を選べるようにしました。
  • テキスト情報の取得 GetComponentsInChildrenで子孫のテキストを収集しつつ、Selectableの境界で絞り込むことで、関係ないラベルの混入を防ぎました。
  • UI要素の意味理解 SerializedObjectで親階層内の参照元を逆引きし、管理オブジェクト由来の文脈も使えるようにしました。

設計を通じて感じたのは、今回の実装ではAIエージェントの判断に特に効いたのがテキストコンテキストだったということです。コンポーネント種別やスクリーン座標も必要ですが、何のボタンかを理解するには、結局のところ人間と同様にテキストラベルが最も有効な手がかりになります。

一方で、テキストを持たないUI要素(アイコンだけのボタン等)はAIエージェントにとって認識が困難であるという課題も残っています。どの画像がUI要素として組み込まれているかは取得可能なので、将来的には画像名や画像内容自体の判断を直接AIが理解可能な形で提供したいと考えています。

本稿は以上となります。Unity開発におけるAIエージェント活用に取り組む皆さまの参考になれば幸いです。

著者

(Ataru Matsudaira)

株式会社サイバーエージェントに2024年新卒入社。その後、QualiArtsにてUnityエンジニアとして開発に携わる。

この記事をシェア

目次