IMGUIとエディター拡張の深層へ

これは奇妙なことだと思われるかもしれません。せっかく新しいやつが出たのに、今さら古いUIシステムなんかどうでも良くね〜?そうですね…新しいUIシステムはすべてのゲーム内のユーザーインターフェイスについて使えるように設計されていますが、IMGUIもまだ、それも極めて重要なシチュエーションで利用されています:それは、Unityエディターそのものです。もしあなたがUnityエディターをカスタムツールや機能のために拡張したいと思った場合、やらなければならないことの一つに、IMGUIと正面から向き合うことがあります。
さて、では最初の質問です。なぜアレは「IMGUI」と呼ばれているのでしょう?IMGUIとは、イミディエイトモードGUI(Immediate Mode GUI)の略称です。オーケー…で、それって何?武器?ええと、GUIシステムの設計には、大きく分けて2つのアプローチがあります。「イミディエイト(immediate)」と「リテインド(retained)」です。
リテインドモードGUI(retained mode GUI) はGUIシステムがGUIに関する情報をあらかじめ保持する手法です。このシステムでは、まずさまざまなGUIの部品をセットアップします:ラベル、ボタン、スライダー、テキストフィールド等々 - そしてその情報がどこかに維持され、それを元にシステムがGUIをスクリーンにレンダリングし、イベントに応答し…と言った具合に動作します。もしあなたがテキストのラベルを変更したいとか、ボタンの位置を動かしたいとかを行う場合には、その大元の情報をどうにかして操作し、システムがこれを受けて新しい状態を反映します。ユーザーが値を変更したりスライダーを動かしたりすると、システムは単にその変更を保存し、保存された値を参照したり、コールバックとして応答するかどうかはあなた次第です。新しいUnityのUIシステムはリテインドモードGUIの例です:ラベルをUI.Labelsコンポーネントで、ボタンをUI.Buttonsコンポーネントで作成し、セットアップをあらかじめ行うと、UIはあとは何もしなくても新しいUIシステムがだいたいやってくれます。
ところが、それに対してイミディエイトモードGUIは、GUIに関する情報を持たないGUIシステムで、その代わりにあなたの表示したいコントロールが何で、どこに表示されるべきか…といったことを繰り返し聞いてきます。UIの各部分は関数の形で指定していき、その処理はその場で行われます: 描画、クリック、等々 - そしてユーザーとのインタラクションの結果はクエリするのではなく、あなたに直接返されます。すべてのUIがコード手動で行われるので、これはゲームのUIとしては非効率だし、アーティストが作業するのにも向いていません。しかしながら、このやりかたはリアルタイム性を求められないシチュエーション(例えばエディターのUIとか)、コードドリブンなUI(例えばエディターのUIとか)、それに現在のステートに合わせて即座に表示するUIを切り替えたいシチュエーション(例えばエディターのUIとか!)ではとても便利なので、巨大な工事とかに備えるにはいい選択なのです。あれ?ちょっと待って…ええーとそうそう、エディターのUIでした!エディターのUIにとってはいい選択なんです。
イミディエイトモードGUIについてもっとよく知りたい場合は、Casey MuratoriさんがIMGUIのいいところと原理原則に関する素晴らしいビデオをアップされています。もしくは、このまま読み進めてくれてもいいですよ!(日本語訳注:日本語では安藤 圭吾さんの「Unity エディター拡張入門」という本もすばらしい選択肢です)
IMGUIのコードが実行されるとき、そこには処理されるべき「Event」が存在します - これはたとえば「ユーザーがマウスボタンをクリックしました」とか「GUIが再描画される必要があります」とか、そういうやつです。現在のイベントが何かを知りたい場合は、 Event.current.type
をチェックすることで確認できます。
例えば、どこかにいくつかのボタンがあるウインドウを実装するとき、「ユーザーがマウスのボタンをクリック」とか「GUIの再描画」のイベントについて対応するコードをそれぞれ別に書いていく必要があるシチュエーションを想像してみてください。ブロックレベルではこんな感じになります:

別々のGUIイベントごとに違う関数を書くのは正直ダルい訳ですが、書いてみるとこの2つの関数には構造的な相似性があることに気がつきます。それぞれのステップで、わたしたちは同じコントロールに対して共通する何かをしています(button 1, button 2, button 3 等)。今回具体的には何をするかというのはイベントや状態に依存しますが、構造は同じです。つまりこれは、イベントについてそれぞれ別々に書く代わりに下記のようなアプローチが出来るということを意味します:

GUI.Button
のようなライブラリ関数を呼ぶ単一の「OnGUI
」関数を用意して、それらのライブラリ関数が処理中のイベントによってそれぞれ違う処理をしていく - わぁ、簡単だ!
一番よく使われるイベントタイプは以下の5つです:
EventType.MouseDown
ユーザーがマウスボタンを押下したときに設定される。 EventType.MouseUp
ユーザーがマウスボタンを離したときに設定される。 EventType.KeyDown
ユーザーがキーを押下したときに設定される。 EventType.KeyUp
ユーザーがキーを離したときに設定される。 EventType.Repaint
IMGUIがスクリーンに再描画しなければならないときに設定される。
これは完全なリストではありませんので、より詳しくはEventTypeのドキュメントを確認してください。
それでは、GUI.Button
のような標準コントロールはこれらのイベントにどういう風に応答するのでしょうか?
EventType.Repaint
指定された矩形の中にボタンを描画する。 EventType.MouseDown
マウスポインターがボタンの矩形の中にあるかをチェックする。もしあれば、ボタンが押下されているフラグを設定して、押下中の表示に切り替わるように、再描画をトリガーする。 EventType.MouseUp
ボタン押下のフラグを外して再描画をトリガーし、その後にマウスポインターがボタンの矩形の中にあるかどうかをチェックする。もし中にあったら、true
を返す。
現実には、これよりもうちょっと複雑です。ボタンはキーボードイベントにも応答しますし、確実にクリックしたボタンだけがMouseUp
イベントに応答するようにするためのコードなんかもあります。が、基本的なアイディアは分かるかと思います。つまり、イベントの度に毎回GUI.Button
をコード中の同じポイントで同じ内容(位置やラベル名等)で呼んでさえいれば、ボタンを使うために必要な異なる振る舞いがすべて提供されるというわけです。
これらのイベントごとに異なる振る舞いをまとめてうまく扱うために、IMGUIには「コントロールID(control ID)」というコンセプトが備わっています。コントロールIDのアイディアは、すべてのイベントタイプについて特定のコントロールに対して一貫した参照方法を提供するためのものです。複雑なインタラクションを提供するUIでは、それぞれの個別なUI部分がコントロールIDを要求します。これはたとえば現在どのコントロールがキーボードのフォーカスを持っているかとか、コントロールに関連したちょっとした情報を保存するために利用されます。コントロールIDはそれを要求したコントロールに、要求した順番で与えられます。つまり繰り返しになりますが、あなたが異なるイベントに対して同じGUI関数を同じ順番で呼んでいれば、それらのコントロールはすべて同じコントロールIDが与えられて、上手い具合に同期するというわけです。
もし自分でカスタムなエディターを作りたい場合、Editor
、EditorWindow
、PropertyDrawer
、GUI
やEditorGUI
といったクラスを通して、Unity全体で利用されている標準コントロールを利用出来ます。
(ところで、エディター拡張プログラミング初心者が陥る共通のミスに、GUI
クラスのことを忘れるという点があります。GUI
クラスのコントロールはエディター拡張の用途でもEditorGUI
のコントロールと同様に利用できます。実はGUI
はEditorGUI
にくらべて別段特別な点はなく、どちらもあなたの役に立つべく存在しています - が、違いはEditorGUI
はゲーム側のコードには使用できないという点です。なぜならGUI
クラスがゲームエンジンの一部なのに対して、EditorGUI
のコードはエディターの一部だからです。)
では、標準ライブラリーを超えたコントロールを作りたいという場合はどうしたらよいでしょうか?
それでは、カスタムユーザーコントロールを作る方法を探求してみましょう。ちょっとしたカスタムコントロールのデモを作ってみました。下の色つきのボックスをクリック&ドラッグしてみてください:
(このデモを実行するには、最新版のFirefoxなどWebGLをサポートするブラウザーが必要です。)
これらのカスタムスライダーは、それぞれ別々のfloat値をO~1の間で移動します。たとえば宇宙船のオブジェクトがあって、1が「無傷」で0が「大破」みたいな感じでダメージ状況を表現したい時、こういうものをインスペクターで使ったら便利かもしれません。バーが値を示し、色が宇宙船のダメージ状態を表現したら、一目で分かるように出来ます。これをIMGUIで作るためのコードはその他の概ね全てのコントロールを作るために使えるので、順番に見ていくことにしましょう。
最初のステップは関数の形式(シグネチャ)を決めることです。すべての異なるイベントタイプをカバーするには、このコントロールは3つの引数が必要となります:
- マウスクリックに反応する領域と描画領域を決める
Rect
(矩形)。 - バーが表現する現在の
float
値。 - スペース、フォント、テクスチャなどのコントロールが必要とする情報が詰め込まれた
GUIStyle
。私たちのケースではGUIStyleにはバーを描画するために使用するテクスチャが含まれます。これについては後述します。
この関数はユーザーがバーをドラッグした結果の値を返す必要があります。返り値は特定のイベントが起きた時のみ意味があり(たとえばマウスの移動とか)、再描画のイベントなどでは意味はありません。つまりデフォルトでは、単に受け取った値をそのまま返します。このアイディアはどういうイベントが起きているかを気にせずに“value = MyCustomSlider(... value …)
” みたいなコードが書けるという点にあるので、ユーザーが設定した新しい値を返さないのであれば、元の値を返して状態を保存する必要があります。
つまり、関数の形式は最終的にこんな感じになります:
public static float MyCustomSlider(Rect controlRect, float value, GUIStyle style)
では、この関数を実装していきましょう。最初のステップではまずコントロールIDを取得します。コントロールIDはマウスイベントに応答する時など、特定の用途で使用します。しかし、たとえ今回処理するイベントが今回処理したかったイベントでなかったとしてもこのIDが別のコントロールに割り当てられないことを保証するためにどちらにしろ取得自体は行っておく必要があります。IMGUIはリクエストされた順にコントロールIDを配るので、処理するイベントによってコントールIDを取得するかどうかの処理を変えてしまうと次のコントロールが受け取るIDがイベントによって変わってしまうので、結果として正しく動作しなくなってしまいます。つまり、IDの利用を検討する時は、全てのイベントタイプで必ず取得するか、全てのイベントタイプで全く使わないかのどちらかを選択する必要があります。たとえばデータの表示のみを行うコントロールなど、すごくシンプルなものやインタラクティブでないものなら、IDは必要ないかもしれません。
{
int controlID = GUIUtility.GetControlID (FocusType.Passive);
コントロールIDの取得時に、上記では「FocusType.Passive
」がIMGUIに渡されていますが、これはキーボードのナビゲーションでどういう動作をするかを伝えています - これはこのコントロールにフォーカスが当たった時にキー入力に応答可能かどうかです。私のカスタムスライダーではキー入力にはまったく応答しないので、今回は「Passive
」を指定します。キーボードに応答するコントロールを作りたい場合は、「Native
」や「Keyboard
」を指定します。より詳しくはFocusTypeのドキュメントを参照してください。
次に、ほとんどのカスタムコントロールが実装時にどこかで行うことをやりましょう:つまり、switch文を使ったイベントタイプ別の分岐処理です。Event.current.type
を直接使用する代わりに、Event.current.GetTypeForControl()
に取得したコントロールIDを指定したものを使います。この関数を使うことで、特定のシチュエーションでキーボードイベントなどのイベントが間違ったコントロールに送られないようにフィルターすることができます。この関数はすべてをうまくフィルターしてくれるわけではないので、自分自身でもちょっとしたチェックをさらに行う必要があります。
switch (Event.current.GetTypeForControl(controlID))
{
さて、これで特定のイベント時に特定の振る舞いを実装し始める準備が整いました。実際にコントロールを描画していきましょう:
case EventType.Repaint:
{
// バーの幅のピクセル値を線形補間(lerp)で算出する
int pixelWidth = (int)Mathf.Lerp (1f, controlRect.width, value);
// バーを覆う矩形を作成
// コントロールの矩形全体をコピーした上で、幅を設定して調整
Rect targetRect = new Rect (controlRect){ width = pixelWidth };
// 描画する要素に色をつける
GUI.color = Color.Lerp (Color.red, Color.green, value);
// GUIStyleに設定されているテクスチャを描画し、さきほど設定した色合いを適用
GUI.DrawTexture (targetRect, style.normal.background);
// 色合いを白にリセット(例:untinted)
GUI.color = Color.white;
break;
}
この時点で関数の実装を終了しても、0〜1のfloat値を可視化する「読み出し専用」のコントロールとして動作します。
しかし、私たちの目標はここではないので、実装を続けてコントロールをインタラクティブにしていきましょう。
コントロールが気持ちのよいマウス操作を実装するためには、やらなくてはならないことがあります: マウスでコントロールをクリックしてドラッグを始めたら、マウスカーソルはコントロール内に位置していなくてもよいように実装するべきです。ユーザーはマウスの並行移動操作にだけ集中して、縦の動きは気にしなくて良いようにした方が親切です。その場合、ユーザーはマウスをドラッグ中にカーソルを別のコントロールの上に動かしてしまうかもしれないので、ユーザーがマウスボタンを放すまで他のコントロールはマウスを無視するようにしておくべきです。
この問題に対する解決策は、GUIUtility.hotControl
を使用することです。これは簡単な変数で、マウス操作を現在担当しているコントロールのコントロールIDを保持するために用意されています。IMGUIはこの値をGetTypeForControl()
の中で使用しています - この値が0でない場合、コントロールIDがhotControl
に該当しないマウスイベントはすべてフィルターされます。
hotControl
のセットとリリースは結構簡単です:
case EventType.MouseDown:
{
// 本当にこのコントロール上でクリックされていたら...
if (controlRect.Contains (Event.current.mousePosition)
// ...そして左クリック(button 0) だったら...
&& Event.current.button == 0)
// ...hotControlを設定して、マウスのイベントを占領する.
GUIUtility.hotControl = controlID;
break;
}
case EventType.MouseUp:
{
// If we were the hotControl, we aren't any more.
if (GUIUtility.hotControl == controlID)
GUIUtility.hotControl = 0;
break;
}
ちなみに、なにか別のコントロールがホットだったとした場合、つまりGUIUtility.hotControl
が0もしくは私たちのコントロールID以外の何かだった場合は、GetTypeForControl()
はmouseUp/mouseDownイベントの代わりに「Ignore
」イベントを返します。つまりこれらのコードは実行されないので、心配する必要はありません。
hotControl
の設定に関しては分かりました。でも、まだ実際にマウスを押下してから実際に値を変更する処理をしていません。もっとも簡単な方法は単にswitch文を閉じて、hotControl
状態中ならどのようなマウスイベント(クリック、ドラッグ、リリース)でも値を変更することです。コードにするとこんな感じになります:
(実際には、今回の例では値の変更はクリック+ドラッグ中に行われ、リリース時にはhotControl
を0にしてしまうので行われません)
if (Event.current.isMouse && GUIUtility.hotControl == controlID) {
// マウスのX座標の位置とコントロールの左端の差分を計算する
float relativeX = Event.current.mousePosition.x - controlRect.x;
// コントロールの幅で割り、0~1の値を取得する
value = Mathf.Clamp01 (relativeX / controlRect.width);
// GUI内のデータが変更されたことを通達する
GUI.changed = true;
// イベントを「used」にマークしておくことで他のコントロールがイベントに応答しないようにし、
// さらに自動的な再描画を呼び起こす
Event.current.Use ();
}
最後の2ステップ(GUI.changed
のセットとEvent.current.Use()
の呼び出し)はコントロールが正しく振る舞うためだけでなく、他のIMGUIコントロールや機能と合わせて上手く動作するために特に重要です。GUI.changed
をtrueに設定することで、プログラムがEditorGUI.BeginChangeCheck()
/EditorGUI.EndChangeCheck()
を使ってユーザーが実際に値を変えたのかを検出することができます。ただし、絶対にGUI.changed
をfalseに設定することはやめましょう。それを行ってしまうと、前のコントロールが値を変更した事実を隠してしまう可能性があるためです。
最後に、関数の値を返す必要があります。変更したfloat値を返すという話をしていたのを覚えていますか? あるいは、何も起きていなければ元の値を返します。もっとも、だいたいの場合は元の値を返すことになります:
return value;
}
これで終わりです!MyCustomSlider
はIMGUIのシンプルなコントロールとして利用する準備が整いました。あとはカスタムエディター(Editor)、プロパティドロワー(PropertyDrawer)、エディターウインドウ(EditorWindow)等々の場で使用するだけです。実は複数編集などまだ強化したい部分があるのですが、その点については後述します。
IMGUIに関する非常に重要でありながら、決して明確ではないことがもう一つあります - それは、IMGUIとシーンビュー(Scene View)との関係です。シーンビューを触れていれば、ビュー内の操作補助用のUI要素には馴染みがあるはずです。たとえばオブジェクトを移動・回転・スケーリングする時に出る、マウスで操作できる矢印や円環、ボックスのキャップ付きの線があります。あのUI要素は「ハンドル(Handles)」と呼ばれています。
では何が明確でないかというと、ハンドルもIMGUIで描画されているということです!
結局のところ、IMGUIで出来ることというのは、2D限定でも、エディターやエディターウインドウ限定のことでもないのです。GUI
やEditorGUI
のクラスに存在する標準コントロールはすべてIDですが、EventType
やコントロールIDのような基本的なコンセプトは2Dであることに依存しません。GUI
とEditorGUI
がエディターウインドウやエディターのインスペクター用コンポーネント向けに2Dのコントロールを提供することを目的に提供されているのに対して、Handle
クラスはシーンビューで利用される3Dコントロールを提供するために存在しています。EditorGUI.IntField
がint値を編集するコントロールを描画するのと同じように、Handleには以下のような関数があります:
Vector3 PositionHandle(Vector3 position, Quaternion rotation);
これはシーンビューに操作可能な矢印のセットを提供することで、ユーザーにVector3
の値をビジュアルに編集する機能を提供します。さらに、前述のような要領で、自分でHandle関数を定義してカスタムなUI要素を描画することもできます。こんどはマウスインタラクションを扱うのはコントロールの矩形内にカーソルが納まっているかどうかのチェックだけではすまないのでもう少し複雑ですが、そこはHandleUtility
クラスが手助けをしてくれます。その他の基本的なコンセプトと構造は全て同じです。
自作のEditorクラスにOnSceneGUI
関数を提供すると、Handle関数をつかってシーンビューに描画できます。描画されたものはワールド座標上の指定した位置に表示されます。Handleをカスタムエディター内に2Dのコンテキストで利用することや、GUI関数をシーンビュー内で利用可能なことも覚えておきましょう。単に描画前にGLのマトリックスを自分でセットアップするか、Handles.BeginGUI()
/Handles.EndGUI()
を使って描画コンテキストをセットアップする必要があることを覚えておけば大丈夫です。
MyCustomSlider
のケースでは、実際には維持する必要があるのは2つの情報だけでした: スライダーの現在値(ユーザーから毎回受取り、ユーザーに毎回返す)と、ユーザーが現在情報を変更中かどうか (hotControl
をうまく使って管理) の2つです。しかし、もしコントロール自体が何かそれ以上の情報を保持しておく必要があったらどうでしょうか?
IMGUIは「ステートオブジェクト(State Object)」というコントロールに紐付いた簡単なストレージ機構を提供しています。あなたは単に値を保存するための自分のクラスを定義して、IMGUIにそのインスタンスを管理するよう頼み、コントロールIDと紐付けしてもらえばOKです。一つのコントロールIDに対して許可されているのは一つのステートオブジェクトだけで、また自分でインスタンス化してはいけません - IMGUIがステートオブジェクトのデフォルトコンストラクタを使って、あなたの代わりに行ってくれます。ステートオブジェクトはエディターのスクリプトをリロードするときにはシリアライズされないので、プログラムが再コンパイルされるなどのことがあると消失します。ですので、ここには生存期間が短くてもよい情報を使うべきでしょう(ステートオブジェクトのクラスに[Serializable]
アトリビュートをつけたとしても無駄です。シリアライザーはヒープの一部エリアについては処理を行わないのです。)
こちらに例を載せてみます。たとえば押下したときに「true
」を返し、かつ2秒以上押し続けていると赤く点滅するボタンが欲しいとします。その場合、ボタンが押されるたびに押下された時間を記録しておく必要がありますので、そのためにステートオブジェクトを使います。今回使うステートオブジェクトは以下のようなものです:
public class FlashingButtonInfo
{
private double mouseDownAt;
public void MouseDownNow()
{
mouseDownAt = EditorApplication.timeSinceStartup;
}
public bool IsFlashing(int controlID)
{
if (GUIUtility.hotControl != controlID)
return false;
double elapsedTime = EditorApplication.timeSinceStartup - mouseDownAt;
if (elapsedTime < 2f)
return false;
return (int)((elapsedTime - 2f) / 0.1f) % 2 == 0;
}
}
MouseDownNow()
が呼ばれたらマウスが押下された時間をmouseDownAt に保存します。IsFlashing
関数はボタンが赤く光るべきかどうかを計算して返します。押された時間から2秒以上が経過していたら、0.1秒ごとに色が明滅するように結果を返します。
こちらが実際のボタンコントロールのコードです:
public static bool FlashingButton(Rect rc, GUIContent content, GUIStyle style)
{
int controlID = GUIUtility.GetControlID (FocusType.Native);
// ステートオブジェクトを取得する(なければIMGUIが新しく作る)
var state = (FlashingButtonInfo)GUIUtility.GetStateObject(
typeof(FlashingButtonInfo),
controlID);
switch (Event.current.GetTypeForControl(controlID)) {
case EventType.Repaint:
{
GUI.color = state.IsFlashing (controlID)
? Color.red
: Color.white;
style.Draw (rc, content, controlID);
break;
}
case EventType.mouseDown:
{
if (rc.Contains (Event.current.mousePosition)
&& Event.current.button == 0
&& GUIUtility.hotControl == 0)
{
GUIUtility.hotControl = controlID;
state.MouseDownNow();
}
break;
}
case EventType.mouseUp:
{
if (GUIUtility.hotControl == controlID)
GUIUtility.hotControl = 0;
break;
}
}
return GUIUtility.hotControl == controlID;
}
結構単純ですよね?mouseDown/mouseUpの部分はカスタムスライダーの時とほとんど変わらないことが分かると思います。違いはマウスを押下した時にstate.MouseDownNow()
を呼んでいるのと、再描画のイベントでGUI.color
を変更している点です。
上記のコードに対して鋭い観察眼を発揮すると、再描画のイベントでstyle.Draw()
というのが使われています。コレは一体なんでしょうか?
カスタムスライダーコントロールを作っていたときには、バーの描画にはGUI.DrawTexture
を使いました。まぁ、あれはあれで良かったのですが、今回のFlashingButton
はボタンの上にキャプションが必要だし、ボタンのために角の丸い矩形を描画する必要があります。GUI.DrawTexture
と GUI.Label
をうまく使っていい感じに描画しても良いのですが、じつはもっと良い方法があります。GUI.Label
自体が描画に使っているテクニックと同じ方法を使って、不要な仲介者をカットしましょう。
GUIStyle
はGUI要素の視覚的なプロパティ情報を保持しています。フォントやテキストカラーに使われるべき色など基本的なことから、レイアウトのスペース情報などもっと微妙なものまでが含まれています。これらの情報は関数群がGUIStyle
と一緒に使って、内容の高さや幅をスタイルにそって定義し、実際に画面に描画するために使われます。
実際、GUIStyle
は単にコントロールのスタイルだけを世話するわけではありません。GUIの要素が描画されるときに陥るさまざまなシチュエーションについて - ホバー状態の時、キーボードのフォーカスが当たっている時、無効な時、「アクティブ」な時(例えばボタンが押され中など)に異なる描画をする状況もカバーします。これらのシチュエーションの時に使いたい色や背景イメージを設定しておけば、GUIStyle
がコントロールIDを見て、描画時に適切なスタイルを選択してくれます。
コントロールを描画するためのGUIStyle
を持つには、主に4つの方法があります:
- コード上で新しく作成(
new GUIStyle()
)し、値を設定する。 - EditorStylesクラスからビルトインのスタイルを利用する。独自のツールバーやインスペクターのスタイルでコントロールを描画したいなど、作成するカスタムコントロールがデフォルトのもののように見えて欲しいなら、ここをチェックするといいでしょう。
- 既存のスタイルを複製する。普通のボタンでテキストだけ右揃えになっているとか、既存のスタイルから小さな変更を加えたバリエーションを作りたいなら、
EditorStyles
のクラスを複製(new GUIStyle(existingStyle)
)して変更したいプロパティだけ操作すると良いでしょう。 GUISkin
から取得する。
GUISkin
というのはGUIStyle
の大きなバンドルのことです。重要な点として、これはプロジェクト内にアセットとして作成してインスペクターから自由に編集することができます。実際に一つ作ってから中を見てみると、ボックス、ボタン、ラベル、トグルスイッチなど、標準コントロールタイプのスロットが沢山ならんでいるのが分かります。しかし、カスタムコントロールの作者としては、下の方にある「カスタムスタイル」のセクションに関心があつまるはずです。ここにはあなたが定義したいカスタムのGUIStyle
をいくつでも設定することができ、個別の名前をつけた上でGUISkin.GetStyle(“nameOfCustomStyle”)
というコードで取得する事が出来ます。すると、このパズルでまだ失われているピースは、どうやったら自分のコードから目当てのGUISkin
オブジェクトを見つけられるのか、というところですね。作成したスキンは「Editor Default Resources」というフォルダに保存して、EditorGUIUtility.LoadRequired()
を使うことができます。その他にも、AssetDatabase.LoadAssetAtPath()
のような機能を使って任意の場所からロードすることもできます。(ただし、エディターでしか使わないアセットをアセットバンドルでパッキングされる場所やResourcesフォルダにうっかり置かないようにしましょう!)
GUIStyle
で武装したらば、GUIStyle.Draw()
を使用して、テキスト、アイコンやツールチップなどが一緒くたになったGUIContent
を描画できるようになります。描画する領域となる矩形、描画したいGUIContent
そのものと、コンテンツがキーボードフォーカスを受け取るかどうかなどを処理するためのコントロールIDを渡せばOKです。
これまで俎上に載せられてきたGUIのコントロールは、コントロールの位置がRect
パラメータで決められて来ていることに気がついたでしょう。そして今GUIStyle
の話をしましたが、もしかしたらGUIStyle
が「スペース情報などのレイアウトプロパティ」を含んでいると書いた時に「え、どういうこと…?」と思ったかもしれません。え〜…これって描画するRect
に対してスタイルのスペース情報を参照してアレコレ自分で計算しないといけないってこと?
まぁ、実際そういう風なアプローチもとっても良いんですが、幸いもっと簡単な方法があります。IMGUIにはレイアウト処理のメカニズムが備わっており、私たちのコントロールに対して、スタイルのスペースなどを考慮した適切なRect
を計算してくれます。これは一体どういう仕組みで動作するんでしょうか?
ミソはコントロールが応答するもう一つのイベントタイプEventType.Layout
にあります。IMGUIはこのイベントをGUIコードに送信し、あなたが呼び出したコントロールはIMGUIのレイアウト関数を呼び出すことで応答します。GUILayoutUtility.GetRect()
、GUILayout.BeginHorizonal
/ Vertical
、GUILayout.EndHorizontal
/ Vertical
、その他諸々 - IMGUIはこれらを記録し、効率的にあなたが指定するレイアウトでコントロールの木構造を構築し、必要なスペースを割り出します。コントロールの木構造が出来上がったら、IMGUIは再帰的にこの木構造を訪ねて要素の実際の幅と高さ、お互いの関係からの位置、次のコントロールの位置…という風にレイアウトをしていきます。
そして、EventType.Repaint
をやるべき時が来たら - あるいは別の種類のイベントでもいいのですが、コントロールはIMGUIの同じレイアウト関数を呼び出します。この時には、IMGUIは呼び出しを記録する代わりに、前回のLayoutイベントの呼び出し時に記録して計算しておいた矩形を返します。ようするに、レイアウトイベント中にGUILayoutUtility.GetRect()
を呼び出すことで矩形が必要であることを記録し、リペイントイベントでもう一度呼び出すときに実際に使用すべきサイズの矩形を返すわけです。
これはつまり、コントロールIDについてと同じように、レイアウトの呼び出しについても、Layoutイベントと他のイベントで同じように呼び出す必要があるということです。
そうしないと、あなたは別のコントロール用の矩形を受け取ってしまいます。さらに、これはLayoutイベント中のGUILayoutUtility.GetRect()
は役に立たない、ということも意味します。IMGUIはレイアウトツリーを回ってレイアウトイベントの処理が完了するまで、あなたに実際に返すべき矩形が何かわからないためです。
これは私たちのカスタムスライダーコントロールではどういう風に見えるんでしょうか?レイアウト対応したバージョンを書くのは実はとっても簡単です。IMGUIからレイアウト済の矩形を返してもらって、それを使ってすでに書いたコードを呼べばOKです:
public static float MyCustomSlider(float value, GUIStyle style)
{
Rect position = GUILayoutUtility.GetRect(GUIContent.none, style);
return MyCustomSlider(position, value, style);
}
GUILayoutUtility.GetRect
は2つのことをします。レイアウトイベント中は、これの関数は指定されたスタイルで何か空のコンテンツを描画するための領域を記録します。空のコンテンツである理由は、ここには特定の文字や画像のためにスペースを空けておく必要がないためです。そして他のイベント中は、実際にこの矩形を返します。これは、このコードはレイアウトイベント中にはインチキな値の矩形を使ってMyCustomSlider
を呼び出しているということを意味しますが、大丈夫。どちらにしろコード自体は、GetControlID()
のために呼び出しておく必要がありますしね。矩形はどちらにしろレイアウトイベント中は何に使われることもありませんので、心配する必要はありません。
IMGUIが「空の」コンテンツとスタイルから、一体どうやってスライダーのサイズを決めているのか気になるかもしれません。そんなに沢山の情報を処理する必要はありません。IMGUIは単にスタイルからすべての必要な情報を取得して、アサインする矩形を計算しています。では、ユーザーコントロールが例えば、スタイルで固定の高さを設定していつつ、ユーザーに幅を決めて欲しいというものの場合はどうでしょうか。
答えはGUILayoutOption
の中にあります。このクラスのインスタンスはレイアウトシステム中で特定の矩形がどのように扱われるべきかを指定します。たとえば、「30ピクセルの高さを持っているべき」とか「横方向には伸張してスペースを埋めるべき」とか、「最低20ピクセルの幅がないとダメ」とかです。GUILayoutOption
インスタンスは、GUILayout
クラスのファクトリー関数を呼ぶことで生成します - GUILayout.ExpandWidth()
、GUILayout.MinHeight()
とかです。そしてこれらをGUILayoutUtility.GetRect()
に配列として渡します。すると、これらはレイアウトツリーのなかに保持されて、レイアウトイベントの最後に処理されます。
ユーザーが出来るだけ少ない・あるいは多いGUILayoutOption
を好きなように作れるようにしながら、配列の管理をする面倒を削減するために、IMGUIはC#の「params」キーワードを活用しています。これはメソッドに任意の数のパラメーターを渡せるようにする機能で、渡されたパラメーターは受け取り側で自動的に配列にパッキングされます。この機能を使ってスライダーの関数を作ってみると、以下のようになります:
public static float MyCustomSlider(float value, GUIStyle style, params GUILayoutOption[] opts)
{
Rect position = GUILayoutUtility.GetRect(GUIContent.none, style, opts);
return MyCustomSlider(position, value, style);
}
見て分かるように、私たちはユーザーが好きなように指定したものを単にGetRect
に投げ直しているだけです。
ここで私たちがとっているアプローチ、つまりマニュアルでIMGUIのコントロールの位置を指定する関数を自動レイアウトのバージョンでラップする方法は、GUI
クラスで提供されている組み込みのコントロールを含む、概ねすべてのIMGUIコントロールで使えます。実際、GUILayout
クラスが提供している自動レイアウトバージョンのGUIクラス群は、まさにこの方法を採っているのです。(ついでに、EditorGUI
のコントロール用にEditorGUILayout
も用意していますので、お忘れなく) 自分でIMGUIコントロールを作成する際には、この2つのクラスの作法を踏襲するといいかもしれません。
さらに、マニュアルで位置指定をするコントロールと、自動レイアウトのものを混ぜることも可能です。GetRect
を呼んで必要なスペースを確保し、その中を自分で計算して好きなように分割してコントロールを配置していくということも出来ます。レイアウトシステムはコントロールIDを全く使わないので、一つのレイアウト上の矩形要素の中に複数のコントロールが配置することができます。もしくは、一つのコントロールが複数のレイアウト要素を使うことも出来ます。こういう方法は時にすべてをレイアウトシステムにまかせるより早く済みます。
もう一つ、もしプロパティドロワー(PropertyDrawers
)を書いている場合は、レイアウトシステムを使うべきではないという点にも触れておきます。代わりに、PropertyDrawer.OnGUI()
のオーバーライド関数に渡された矩形を使いましょう。この理由は、処理速度の改善のためにEditor
クラス自体がプロパティドロワーの描画時に内部的にレイアウトシステムを使っていないためです。プロパティドロワーの場合は単純にそれ自身の矩形から最初のプロパティの領域を計算して、続くプロパティには順に位置を下げて返せばよいからです。ですので、もしプロパティドロワー内でレイアウトシステムを使っても、システムはあなたのコントロール以前に描かれたプロパティに関する知識を全く持っていないので、他のプロパティの上に位置させようとしてしまいます。これはやりたいこととは違いますよね!
ここまででお話しした全てのことを使えば、かなりスムーズに動作するIMGUIコントロールを自作することが可能になったはずです。しかし、ここからさらにUnityに組み込まれているコントロールと同じレベルに磨き上げるためには、あともういくつかの要素が存在します。
最初の点はSerializedProperty
の利用です。このブログ記事ではあまりSerializedProperty
自体の詳細には入って行きたくはないので、それはまた別の機会にしますが、ここでは要点だけまとめておきます:SerializedProperty
はUnityのシリアライズ(セーブおよびロード)システムで扱われる単一の変数を「ラップ」するクラスです。インスペクターに登場するすべてのスクリプトの全ての値は、エンジンが提供するすべてのオブジェクトのすべての値(これもインスペクターで見られますね) と同じく、SerializedProperty
APIを通してアクセス出来ます。少なくともEditor
クラス内からは可能です。
SerializedProperty
は変数の値へのアクセスを提供するだけでなく、変数の値がPrefabに設定されている値と違うかどうか、変数が構造体のように子のフィードを持っていてインスペクターで展開中か折りたたみ中か等の情報を与えてくれるので便利です。SerializedProperty
はさらに値の変更をシーンの変更検出(scene dirtying)やUndoシステムとも統合してくれます。これはあなたに管理オブジェクトなどの仕組みをイチイチ作らせることなくそうしたことを可能にしてくれるので、パフォーマンス上の観点でも非常に役経ちます。ですので、もし自作のIMGUIコントロールがUndo、シーン変更検出、Prefabのオーバーライドなど、数あるエディター機能といい感じに親和性をもって動いて欲しいのであれば、SerializedProperty
を利用した作りにするべきです。
EditorGUI
のSerializedProperty
を受け取るメソッドを見渡してみると、関数のシグネチャがちょっと違うことが分かります。私たちのカスタムスライダーで採った「float値を受け取ってfloat値を返す」のようなやり方のかわりに、SerializedProperty
を使ったIMGUIコントロールでは単にSerializedProperty
のインスタンスを引数として受け取り、特に何も返しません。なぜなら、何か値の変更が必要になる場合は直接SerializedProperty
に変更を適用するからです。ですので、私たちのカスタムスライダーのコードは今度は下記のような見た目になります:
public static void MyCustomSlider(Rect controlRect, SerializedProperty prop, GUIStyle style)
今までの「value」パラメーターとその返り値が消失して、今度はSerializedProperty
を渡す「prop」パラメーターが登場しました。スライダーバーを描画するためにプロパティから現在の値を取り出すには単にprop.floatValue
にアクセスすればよく、また値を変更する際にもprop.floatValue
に新しい値をセットすればOKです。
IMGUIでSerializedProperty
を使うことにはその他のメリットもあります。たとえば、Prefabから変更された値を太字で表示する方法を考えてみてください。この場合はSerializedProperty
のprefabOverride
がtrueかどうかをチェックして、それによってコントロールの表示方法を変えればよいだけです。嬉しいことに、テキストを単に太字にしたいだけなら、GUIStyle
でフォントを設定しないでさえおけば、IMGUIが自動的にやってくれます。(フォントを設定する場合は、自分で対応する必要があります。太字フォントと通常状態のフォントの2種類を登録して、prefabOverride
で切り替えるようにするわけです)
その他にサポートが必要な機能は複数オブジェクト編集です。例えば、複数のオブジェクトが選択された時にコントロールが異なる値だった場合、複数の値を同時に表示するなどといったことをします。選択中のオブジェクトで複数の異なる値が割り当てられているかどうかはEditorGUI.showMixedValue
をチェックすれば分かるので、この値がtrueであれば何らかの表示を促すなど、自分で表示を変える処理を実装する必要があります。
prefabOverride
の時の太字処理とshowMixedValue
への対応はEditorGUI.BeginProperty()
とEditorGUI.EndProperty()
をつかってコンテキストのセットアップを行う必要があります。お勧めのパターンは、たとえばあなたのコントロールがSerializedProperty
を引数として受け取る関数の場合、その関数の中で BeginProperty
とEndProperty
を呼び、その中で生の値を扱うやりかたです。つまり元の関数がBeginProperty
とEndProperty
の呼び出しの責任を引き受けて、そのブロックの中でSerializedProperty
を介さずに値を直接やりとりするコントロール(例えば最初のカスタムスライダーコントールの例やEditorGUI.IntField
のようなintを受け取ってintを返すもの)に処理を移譲するわけです。(これは理にかなっています。なぜなら、もしあなたのコントロールが生の値を扱っているなら、BeginProperty
に渡せるSerializedProperty
はどちらにしろ持っていないからです。)
public class MySliderDrawer : PropertyDrawer
{
public override float GetPropertyHeight (SerializedProperty property, GUIContent label)
{
return EditorGUIUtility.singleLineHeight;
}
private GUISkin _sliderSkin;
public override void OnGUI (Rect position, SerializedProperty property, GUIContent label)
{
if (_sliderSkin == null)
_sliderSkin = (GUISkin)EditorGUIUtility.LoadRequired ("MyCustomSlider Skin");
MyCustomSlider (position, property, _sliderSkin.GetStyle ("MyCustomSlider"), label);
}
}
// 次に、更新された MyCustomSlider の定義:
public static void MyCustomSlider(Rect controlRect, SerializedProperty prop, GUIStyle style, GUIContent label)
{
label = EditorGUI.BeginProperty (controlRect, label, property);
controlRect = EditorGUI.PrefixLabel (controlRect, label);
// 以前のMyCustomSliderの定義を利用しつつ
// EditorGUI.showMixedValue がtrueの時に適切に扱えるように処理
EditorGUI.BeginChangeCheck();
float newValue = MyCustomSlider(controlRect, prop.floatValue, style);
if(EditorGUI.EndChangeCheck())
prop.floatValue = newValue;
EditorGUI.EndProperty ();
}
このブログ記事がIMGUIの核心に多少の光を当てて、より良いエディター拡張を作る時に必要な事柄の理解につながるといいなと思っています。真のエディター達人になるには、SerializedObject
/SerializedProperty
のシステム、CustomEditor
・EditorWindow
とPropertyDrawer
の違いや、Undoの処理方法など、もうすこし知るべき事があります。しかしどちらにしろ確かなことは、アセットストアでの販売や自分のチームの強化を考える時Unityのカスタムツール制作がもつ大きな可能性は、IMGUI大きな役割を担うことで解放されているということです。
質問やフィードバックはぜひコメントでお寄せ下さい!