🧰

ProBuilderのコードを読む (1) ProBuilderEditor

2022/07/18に公開

Editorの仕組み

モチベーション

  • 自分で作ってるツールの参考のために読んでいます.
  • 網羅的に理解しようとしているわけではありません.
  • 画面の描画など細かい処理は追いません

ProBuilderEditorクラス

概要

ProBuilderEditor.cs
public sealed class ProBuilderEditor : EditorWindow, IHasCustomMenu
{
    // ...
    void MenuOpenWindow() { // 初期化処理 }
    void OnEnable() { // EditorWindowが読み込まれたと後に呼び出される }
    void OnDisable() { // EditorWindowがスコープから外れたときに呼び出される }
    void OnGui() {  // GUIに関係する描画処理を記述する }
    void IconModeChanged() {  // アイコン表示かテキスト表示かを変更する }
    void AddItemsToMenu() {  // メニュー・アイテムの描画処理 }
    void OnSceneGUI() { // 実際の画面の描画処理 }
}

クラス図はこんな感じ.

メニュー項目への追加

Probuilderのツールバー画面はTools/Probuilder Windowから表示できます. これはどのように実装されているのでしょうか?

OpenEditorWindowというメソッドがある. これはMenuItem属性が付いていています. MenuItem属性はメニューバーに項目を追加するための属性です.

そのためメニューバーにProBuilder Windowという項目を追加してくれます.

MenuItems.cs
[MenuItem("Tools/" + PreferenceKeys.pluginTitle + "/" + PreferenceKeys.pluginTitle + " Window", false,
     PreferenceKeys.menuEditor)]
public static void OpenEditorWindow() {
    ProBuilderEditor.MenuOpenWindow();
}

このメニューをクリックするとProBuilderEditor.MenuOpenWindowが呼び出されツールバーが表示されます.

ProBuilder Windowをクリックすると以下のようなツールバーと呼ばれる画面が表示されます.

コンテクスト・メニューの表示

IHasCustomMenuはEditorWindowにコンテクスト・メニュー項目を追加するための必要になる. 右クリックするとメニューが表示される. ここに項目を追加することができる.

ProBuilderの場合は以下のようになる. AddItemsToMenuの内容がそのまま表示されているのが分かる.

ProBuilderEditorツールバーの描画

EditorToolbarの描画

OnEnableはAwakeの直後に一回だけ呼び出されます.[1] つまりProBuilderEditorのインスタンスが新規に生成された時だけ呼び出されると考えられます.

実際の処理はEditorToolbarに委譲されています.

まずOnEnableでインスタンス化された後, OnGUIで描画されます.

void OnGUI() {
    // ...
    m_Toolbar.OnGUI();
}

ボタン描画

EditorToolbarは複数のMenuActionを管理しています. EditorToolbarインスタンス化の時点でEditorToolbarLoader.GetActionsメソッドで読み込まれます.

後はEditorToolbar.OnGUI()内でループで順次描画されます.

for (int actionIndex = 0; actionIndex < m_ActionsLength; actionIndex++)
{
    // action.DoButton()を呼び出して描画する
}

ツールチップの表示

各ボタンにShiftを押しながらマウスオーバーするか, しばらくたつと表示されます.

CreateInstanceはScriptableObject由来のメソッドです. 動的にEditorWindowを生成し表示しています.

アイコンモードとテキストモードの切り替え

isIconModeとコンテクスト・メニュの設定から判断します.

コンテクスト・メニューではMenu_ToggleIconModeというメソッドが呼び出されています. やってることは同じです.

public void AddItemsToMenu(GenericMenu menu) {
    // ...
    menu.AddItem(new GUIContent("Use Icon Mode", ""), s_IsIconGui,
        Menu_ToggleIconMode);
    menu.AddItem(new GUIContent("Use Text Mode", ""), !s_IsIconGui,
        Menu_ToggleIconMode);
}

ツールの切り替え

EditorTool

ツールはEditorToolを継承したクラスです. 例えば形状を作るツールであるDrawShapeToolだと以下のように表せます.

ツールの管理

各ツールはToolManagerへの参照を持っています. ちょっと図は怪しいですがこんな感じでしょうか.

実際はactiveToolChangedデリゲートに登録します.

ToolManager.activeToolChanged += OnActiveToolChanged;

各ツールは独自にToolManagerを使って自分がアクティブなのか判断します.

void OnActiveToolChanged() {
    if (ToolManager.IsActiveTool(this))
        SetBounds(currentShapeInOverlay.size);
}

ボタンのアクティブ判定

ツールバーのボタンを押すとそのツールがアクティブになります. MenuToolToggle.DoButtonメソッド内部で判定されます.

bool isActiveTool = m_Tool != null && (ToolManager.IsActiveTool(m_Tool) || ToolManager.activeToolType == m_Tool.GetType());

感想

Editor拡張は便利だなぁと実感しました. ドキュメントや記事などもの断片的だったりするのでこういう実際に使われているツールは勉強になります.

一方UI Toolkit使うと簡単そうだなと思うところもあり, 自分のツールではできるだけそちらを使う感じでやりたいです.

例えばTooltipとかも複雑なのはできないのかもしれませんがUI Builderを使うとちょっとしたことは簡単に表示できます.

次回

実際にどうやってユーザーとのインタラクションが行われるか調べる.

Appendix

実際にProBuilderEditorインスタンスが作られるタイミング

ではどのタイミングでインスタンスが生成されるのでしょうか? 上のCustomMenuExample.csを改良してタイミングを確認しましょう.

CustomMenuExample.cs
using UnityEngine;
using UnityEditor;

public class CustomMenuExample : EditorWindow, IHasCustomMenu {
    [MenuItem("MyWindows/Custom Menu Window Example")]
    static void ShowCustomWindow() {
        Debug.Log("ShowCustomWindow");
        GetWindow<CustomMenuExample>().Show();
    }

    public void AddItemsToMenu(GenericMenu menu) {
        menu.AddItem(new GUIContent("Hello"), false, OnHello);
    }

    void OnEnable() {
        Debug.Log("Enable Custom Menu Example");
    }

    void OnDisable() {
        Debug.Log("Disable Custom Menu Example");
    }

    void OnHello() {
        Debug.Log("Hello!");
    }
    
    void OnGUI() {
        Debug.Log("OnGUI");
    }
}

これを実行するとメニューからMyWindowをクリックするたびにコンストラクタが呼ばれインスタンスが生成されていることが分かります. EditorWindowにもそのようなことが書いてあります.

Returns the first EditorWindow of type t which is currently on the screen.
If there is none, creates and shows new window and returns the instance of it.

中身の表示はOnGUIで実行されます. 一度表示された後はドラッグやマウスオーバーの時など再描画が必要なタイミングで繰り返しOnGUIが呼び出されます.

ツールチップの描画

ツールバーのメニューにマウスオーバーして少し経つとツールチップが表示されます. これはToolbar.OnGUI()のShowToolTipの呼び出しで処理されます.

表示されるのはEditorWindowを継承したTooltipEditorです.

NewShapeToggleにToolTipContent

static readonly TooltipContent s_Tooltip = new TooltipContent
   (
       "New Shape",
       "Opens the Shape window.\n\nThis tool allows you to interactively create new 3d primitives.",
       keyCommandSuper, keyCommandShift, 'K'
   );

というのがあります. TooltipContentのコンストラクタのシグニチャは以下です.

public TooltipContent(string title, string summary, params char[] shortcut) : this(title, summary, "")

NewShapeToggleはMenuActionを継承したクラスです. ツールバーに対応するアクションを表すクラスでしょうか.

public abstract class MenuToolToggle: MenuAction
{
    // ...
}

EditorToolbarは複数のツールを表示するのでループで個々のツールチップを生成しています.

for (int actionIndex = 0; actionIndex < m_ActionsLength; actionIndex++)
{
    var action = m_Actions[actionIndex];
    // なんやかんや
    
    if (evt.shift || showTooltipTimer) {
        tooltipShown = true;
        ShowTooltip(buttonRect, action.tooltip, m_Scroll);
    }
}

action.tooltipの一つがNewShapeToggleです. tooltipプロパティはTooltipContentなので,

ShowTooltip(buttonRect, action.tooltip, m_Scroll);

以下のメソッドが呼び出されます.

void ShowTooltip(Rect rect, TooltipContent content, Vector2 scrollOffset) {
    Rect buttonRect = new Rect(
            (window.position.x + rect.x) - scrollOffset.x,
            (window.position.y + rect.y) - scrollOffset.y,
            rect.width,
            rect.height);
    TooltipEditor.Show(buttonRect, content);
}

ようやくTooltipEditorが登場しました. TooltipEditor.ShowはTooltipEditor.instance()で初期化されていないならインスタンスを生成します. この時にウィンドウを表示します.

if (s_ShowPopupWithModeMethod != null && s_ShowModeEnum != null)
    s_ShowPopupWithModeMethod.Invoke(s_Instance, new[] { Enum.ToObject(s_ShowModeEnum, 1), false });
else
    s_Instance.ShowPopup();

下のShowPopupはドキュメント記載ですが, インターナルなShowPopupWithModeというのもあるようです.

internal void ShowPopupWithMode(ShowMode mode, bool giveFocus)

How to show an EditorWindow without focusing it?

ShowPopupは内部ではShowPopupWithModeを呼び出しています.

 internal void ShowPopup() {
    ShowPopupWithMode(ShowMode.PopupMenu, true);
}

giveFocusをfalseにしたいというのがShowPopupWithModeを呼び出している理由のようです. ShowPopupの場合フォーカスが外れても消えないから・・・ですかね? PopupWindowではダメだったのでしょうか.

脚注
  1. Order of execution for event functions ↩︎

Discussion