Unity を検索

GUI diagram 1
GUI diagram 1
取り上げているトピック
シェア

Unityの新しいUIがリリースされてからすでに一年以上経ちました。さて、そこでわたしは、古いUIシステム…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 diagram 1

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

GUI diagram 2

GUI.Buttonのようなライブラリ関数を呼ぶ単一の「OnGUI」関数を用意して、それらのライブラリ関数が処理中のイベントによってそれぞれ違う処理をしていく - わぁ、簡単だ!

一番よく使われるイベントタイプは以下の5つです:

EventType.MouseDownユーザーがマウスボタンを押下したときに設定される。
EventType.MouseUpユーザーがマウスボタンを離したときに設定される。
EventType.KeyDownユーザーがキーを押下したときに設定される。
EventType.KeyUpユーザーがキーを離したときに設定される。
EventType.RepaintIMGUIがスクリーンに再描画しなければならないときに設定される。

これは完全なリストではありませんので、より詳しくはEventTypeのドキュメントを確認してください。

それでは、GUI.Buttonのような標準コントロールはこれらのイベントにどういう風に応答するのでしょうか?

EventType.Repaint指定された矩形の中にボタンを描画する。
EventType.MouseDownマウスポインターがボタンの矩形の中にあるかをチェックする。もしあれば、ボタンが押下されているフラグを設定して、押下中の表示に切り替わるように、再描画をトリガーする。
EventType.MouseUpボタン押下のフラグを外して再描画をトリガーし、その後にマウスポインターがボタンの矩形の中にあるかどうかをチェックする。もし中にあったら、trueを返す。

現実には、これよりもうちょっと複雑です。ボタンはキーボードイベントにも応答しますし、確実にクリックしたボタンだけがMouseUpイベントに応答するようにするためのコードなんかもあります。が、基本的なアイディアは分かるかと思います。つまり、イベントの度に毎回GUI.Buttonをコード中の同じポイントで同じ内容(位置やラベル名等)で呼んでさえいれば、ボタンを使うために必要な異なる振る舞いがすべて提供されるというわけです。

これらのイベントごとに異なる振る舞いをまとめてうまく扱うために、IMGUIには「コントロールID(control ID)」というコンセプトが備わっています。コントロールIDのアイディアは、すべてのイベントタイプについて特定のコントロールに対して一貫した参照方法を提供するためのものです。複雑なインタラクションを提供するUIでは、それぞれの個別なUI部分がコントロールIDを要求します。これはたとえば現在どのコントロールがキーボードのフォーカスを持っているかとか、コントロールに関連したちょっとした情報を保存するために利用されます。コントロールIDはそれを要求したコントロールに、要求した順番で与えられます。つまり繰り返しになりますが、あなたが異なるイベントに対して同じGUI関数を同じ順番で呼んでいれば、それらのコントロールはすべて同じコントロールIDが与えられて、上手い具合に同期するというわけです。

カスタムコントロールという難問

もし自分でカスタムなエディターを作りたい場合、EditorEditorWindowPropertyDrawerGUIEditorGUIといったクラスを通して、Unity全体で利用されている標準コントロールを利用出来ます。

(ところで、エディター拡張プログラミング初心者が陥る共通のミスに、GUIクラスのことを忘れるという点があります。GUIクラスのコントロールはエディター拡張の用途でもEditorGUIのコントロールと同様に利用できます。実はGUIEditorGUIにくらべて別段特別な点はなく、どちらもあなたの役に立つべく存在しています - が、違いは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限定でも、エディターやエディターウインドウ限定のことでもないのです。GUIEditorGUIのクラスに存在する標準コントロールはすべてIDですが、EventTypeやコントロールIDのような基本的なコンセプトは2Dであることに依存しません。GUIEditorGUIがエディターウインドウやエディターのインスペクター用コンポーネント向けに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()を使って描画コンテキストをセットアップする必要があることを覚えておけば大丈夫です。

GUI化とステート

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を描画する

カスタムスライダーコントロールを作っていたときには、バーの描画にはGUI.DrawTextureを使いました。まぁ、あれはあれで良かったのですが、今回のFlashingButtonはボタンの上にキャプションが必要だし、ボタンのために角の丸い矩形を描画する必要があります。GUI.DrawTextureGUI.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 / VerticalGUILayout.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を利用した作りにするべきです。

EditorGUISerializedPropertyを受け取るメソッドを見渡してみると、関数のシグネチャがちょっと違うことが分かります。私たちのカスタムスライダーで採った「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から変更された値を太字で表示する方法を考えてみてください。この場合はSerializedPropertyprefabOverrideがtrueかどうかをチェックして、それによってコントロールの表示方法を変えればよいだけです。嬉しいことに、テキストを単に太字にしたいだけなら、GUIStyleでフォントを設定しないでさえおけば、IMGUIが自動的にやってくれます。(フォントを設定する場合は、自分で対応する必要があります。太字フォントと通常状態のフォントの2種類を登録して、prefabOverrideで切り替えるようにするわけです)

その他にサポートが必要な機能は複数オブジェクト編集です。例えば、複数のオブジェクトが選択された時にコントロールが異なる値だった場合、複数の値を同時に表示するなどといったことをします。選択中のオブジェクトで複数の異なる値が割り当てられているかどうかはEditorGUI.showMixedValueをチェックすれば分かるので、この値がtrueであれば何らかの表示を促すなど、自分で表示を変える処理を実装する必要があります。

prefabOverrideの時の太字処理とshowMixedValueへの対応はEditorGUI.BeginProperty()EditorGUI.EndProperty()をつかってコンテキストのセットアップを行う必要があります。お勧めのパターンは、たとえばあなたのコントロールがSerializedPropertyを引数として受け取る関数の場合、その関数の中で BeginPropertyEndPropertyを呼び、その中で生の値を扱うやりかたです。つまり元の関数がBeginPropertyEndPropertyの呼び出しの責任を引き受けて、そのブロックの中で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のシステム、CustomEditorEditorWindowPropertyDrawerの違いや、Undoの処理方法など、もうすこし知るべき事があります。しかしどちらにしろ確かなことは、アセットストアでの販売や自分のチームの強化を考える時Unityのカスタムツール制作がもつ大きな可能性は、IMGUI大きな役割を担うことで解放されているということです。

質問やフィードバックはぜひコメントでお寄せ下さい!

2015年12月22日 カテゴリ: テクノロジー | 32 分 で読めます
取り上げているトピック