9
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

UI Toolkit でデバッグ UI を作る

Last updated at Posted at 2022-12-19

作ったもの

ゲーム画面の上に重ねて表示する簡易的なデバッグUIを試作しました。
screenshot-01.png

例えば、このようなウィンドウを作りたい場合
screenshot-02.png

以下のコードのように、生成したいUIとそれに応じる設定や機能を上から下にだらだら書くといい感じになるという使い勝手です。

書き味の雰囲気をお伝えするための.cs
var window = root.AddElement(new DebugWindow() { Value = true, Text = "サンプルウィンドウ" });
var person = FindObjectOfType<ThirdPersonController>(); // ゲーム画面のキャラクターコンポーネント取得
window.AddElement(new Slider(1f, 10f), slider =>
{
    slider.label = "Speed";
    slider.value = person.MoveSpeed;
    slider.RegisterValueChangedCallback(e => person.MoveSpeed = e.newValue);
});
window.AddElement(new DropdownField(), dropdown =>
{
    dropdown.label = "Jump";
    dropdown.choices = new List<string>() { "0.1", "1.2", "5.0", "10.0" };
    dropdown.value = person.JumpHeight.ToString("0.0");
    dropdown.RegisterValueChangedCallback(e => person.JumpHeight = float.Parse(e.newValue));
});

この記事では実装における要点をいくつかお伝えしたいと思います。

そもそも何故 UI Toolkit を利用するか

デバッグUIを作るにあたって個人的に重要視したい条件として以下のようなものがありました。

  • デバッグ機能の追加はなるべくコードに寄せたい (Prefabなどのメンテしたくない)
  • でもレイアウトやビジュアルは手間かけずにそこそこいい感じになってほしい

上の条件に対して uGUI や OnGUI(IMGUI) はどうかというと

  • uGUI: コードベースで動的に UI 組み立てていくというのは手間がかかる
  • IMGUI: コードに寄せれる反面、ビジュアルやレイアウトを整えるのは手間かかる

対して UI Toolkit を使うことを考えた時、今回ポイントになる点としては

  • デバッグ UI であれば手間少なく良い感じに表示してくれたら十分なので、その点で Flexbox というレイアウトシステムや USS の仕組みとは相性が良い
  • IMGUI ほど愚直な書き味ではないものの、 UI 要素をコードで動的生成することが気軽に書ける

私は今のところ uGUI を置き換える形で UI Toolkit を利用しようとは思えませんが、今回のようなシチュエーションは凄くマッチすると思います。

ビジュアル作成

UI Toolkit は UXML(HTMLのようなもの) と USS(CSSのようなもの) を用意すれば表示まで辿り着けるかと思いますが、今回は動的に UI を組み立てるので最終的に利用するのは USS のみです。

最悪 USS を用意しなくてもデバッグウィンドウは作れますが、余白や並べ方や視認性で使いやすさが結構変わったりしますし、あとビジュアルが多少整ってると私はモチベが違うので少しは整えます。

ということで、左が標準の見た目、右が変更後のものです。基本的には UI Builder 上で編集しながら時々 USS を直接修正して作成しました。

今回 USS についてのピックアップポイントは二点あります。

  1. 変数利用
  2. 強権的スタイル適用

1. 変数利用

CSS と同じように USS でも変数の利用が可能です。
uGUI だと Prefab などで見た目の共通化をするかと思いますが、それより細かい単位で共通化しやすい仕組みがあるのは嬉しいですね。
最初に見た目を整えた段階では以下の画像のようなデザインだったのですが、3分くらいで先ほど上で紹介した変更後の画像の状態に持っていくことができました。

変数の定義自体は USS を直接編集する必要がありますが、UI Builder の編集画面上で変数をどこに利用するかの割り当てはサポートされています。
ちなみに今回テクスチャは利用していませんが、UI Toolkit標準の .uss を見てみたら --theme-image-toggle-checkmark: url("/Packages/com.unity.ui/PackageResources/Images/Vanilla/check.png"); のような記載があったのでテクスチャも変数で扱うことができそうです。

2. 強権的スタイル適用

ボタンやテキストフィールドなど、標準で用意されているUIパーツの階層構造には多くのUSSクラスが割り当てられており、デフォルトのUSSでは大量にスタイル設定が定義されています。
今回はそれらに加えて追加でスタイル設定をする形で見た目を整えていますが、そこそこ手間があった部分を強引に解決したので、あまりおすすめできませんが紹介します。

手間と感じていた部分としては「私の定義したスタイル設定を優先してほしいけど、そのためにセレクタを詳細かつ大量に書かなくてはいけなくてつらい」というものです。

一例としては UI Toolkit が標準で用意している .unity-foldout__toggle > .unity-toggle__input:focus > .unity-label に対する設定より、私が定義した .dwd .unity-label に対する設定を優先したい、みたいな状況です。セレクタの優先度的には前者の方が優先されてしまいます。
このようなパターンが数箇所であれば正しくセレクタを指定するのが良いかと思いますが、非常に大量のパターンに遭遇し途中で作るのを辞めようと思ったくらいでした。

解決方法として、今回作っている USS をデバッグウィンドウ用のルート要素に対して直接流し込む手段を取りました。
要素の子孫全てに対して、流し込んだ USS が優先的に適用されます。

var root = new VisualElement();
root.styleSheets.Add(styleSheet); // styleSheet は UnityEngine.UIElements.StyleSheet 型
uiDocument.rootVisualElement.Add(root);

HTML でいうとタグに直接 style 属性を書くイメージに近いですが複数のセレクタに対するスタイリングを丸々流し込めることは出来ないと思うので非常に力の強い手段です。とりあえず私は救われました。

流し込んだ USS のコードの一部としてはこんな感じです。
.unity-label .unity-button が標準で用意されているUIパーツで利用しているUSSクラス名です。

.dwd .unity-label {
    color: var(--dwd-colors-default-theme-20);
    margin-top: var(--dwd-metrics-default-space-10);
    margin-bottom: var(--dwd-metrics-default-space-10);
}

.dwd .unity-button {
    color: var(--dwd-colors-default-theme-20);
    margin-top: var(--dwd-metrics-default-space-10);
    margin-bottom: var(--dwd-metrics-default-space-10);
    (以下省略)

ウィンドウ機能の実装

標準で用意されているUIに対して外から機能を追加することもできますが、今回はデバッグウィンドウを自作コンポーネントとして作成して、ややこしい処理はそこに押し付けます。

自作コンポーネントの基本的な作り方は 公式ドキュメント に記載があるので省略させていただくとして、今回のウィンドウ作成に関わるピックアップポイントとしては二点あります。

  1. ドラッグ機能の実装
  2. 深度コントロール

1. ドラッグ機能の実装

数行何かを書いたらパッとドラッグできるようになったりしないかなと思いましたが、押下やポインタ移動など基本的なインタラクションイベントやそれに応じた状態管理を組み合わせてドラッグ処理を作成する必要がありそうでした。
簡単なインタラクションは任意の階層に対してコールバックを登録するだけになるのですが、ちょっと複雑になりそうなインタラクション処理は Manipulator を作成して登録することになります。今回はドラッグ機能を担う DragManipulator を作成しました。

Manipulator は登録した階層で処理されるので今回はドラッグエリアに登録することになりますが、エリアをドラッグすることによって動かしたい要素はウィンドウ全体なのでその2要素をうまいこと考慮しないといけません。また、押下座標を覚えて差分で処理しないと押下した瞬間マウスにウィンドウの中心が吸着したり…最初はしてました。

その辺りを考慮して出来上がったコードを以下に置いておきます。

DragManipulator
public class DragManipulator : MouseManipulator
{
    readonly VisualElement moveTarget;
    Vector2 targetStartPosition;
    Vector3 pointerStartPosition;
    bool enabled;
    
    public DragManipulator(VisualElement moveTarget)
    {
        this.moveTarget = moveTarget;
    }

    protected override void RegisterCallbacksOnTarget()
    {
        target.RegisterCallback<PointerDownEvent>(PointerDownHandler, TrickleDown.TrickleDown);
        target.RegisterCallback<PointerMoveEvent>(PointerMoveHandler, TrickleDown.TrickleDown);
        target.RegisterCallback<PointerUpEvent>(PointerUpHandler, TrickleDown.TrickleDown);
        target.RegisterCallback<PointerCaptureOutEvent>(PointerCaptureOutHandler, TrickleDown.TrickleDown);
    }

    protected override void UnregisterCallbacksFromTarget()
    {
        target.UnregisterCallback<PointerDownEvent>(PointerDownHandler, TrickleDown.TrickleDown);
        target.UnregisterCallback<PointerMoveEvent>(PointerMoveHandler, TrickleDown.TrickleDown);
        target.UnregisterCallback<PointerUpEvent>(PointerUpHandler, TrickleDown.TrickleDown);
        target.UnregisterCallback<PointerCaptureOutEvent>(PointerCaptureOutHandler, TrickleDown.TrickleDown);
    }

    void PointerDownHandler(PointerDownEvent e)
    {
        targetStartPosition = moveTarget.transform.position;
        pointerStartPosition = e.position;
        target.CapturePointer(e.pointerId);
        enabled = true;
    }

    void PointerMoveHandler(PointerMoveEvent e)
    {
        if (enabled && target.HasPointerCapture(e.pointerId))
        {
            var pointerDelta = e.position - pointerStartPosition;
            moveTarget.transform.position = new Vector2(targetStartPosition.x + pointerDelta.x, targetStartPosition.y + pointerDelta.y);
        }
    }

    void PointerUpHandler(PointerUpEvent e)
    {
        if (enabled && target.HasPointerCapture(e.pointerId))
        {
            target.ReleasePointer(e.pointerId);
        }
    }

    void PointerCaptureOutHandler(PointerCaptureOutEvent e)
    {
        enabled = false;
    }
}

利用方法はただ登録するだけです。

var dragArea = new VisualElement() { name = "drag-area" };
var manipulator = new DragManipulator(this); // this はウィンドウのコンポーネント
dragArea.AddManipulator(manipulator);

2. 深度コントロール

ウィンドウを複数表示している場合、最後に触ったウィンドウを一番前面に表示するべきかと思いますが、CSS でいうところの z-index は USS では実装されていません。(ロードマップ確認したら計画自体はあるみたいですが)

現状は uGUI のように階層を調整するしかないようです。
ということで、ウィンドウ(VisualElement型)のコンストラクタでこの記述をしています。

RegisterCallback<PointerDownEvent>(_ => BringToFront(), TrickleDown.TrickleDown);

最後に

ゲーム開発を加速させるデバッグ機能、低コストでいい感じにしたいですね。
要点をかいつまんでお伝えしましたが、何かの参考になればと思います。

リポジトリも置いておきます。
https://github.com/v9129/UIToolkitDebugWindow

9
11
0

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
9
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?