Cygames Tech Fes フォローアップ: 内製タイムラインツールCuttの紹介


みなさん、こんにちは!Cygamesエンジニアの中川と申します。

先日開催させていただきました社外勉強会“Cygames Tech Fes”では「生産効率を上げる内製ツール」のコマで内製タイムラインツールについて発表させていただきました。

今回は、時間の都合などで発表に含めることのできなかったUnityEditor上でのツール作成Tipsのうち“レイアウト違反例外”について書かせていただこうと思います。
この例外は、タイムラインツール制作時に発生した際調査に苦労したので、同じ場面に遭遇した方の解決の一助になればと思います。

もろもろの条件

  • Windows / Unity5.2.2.p3 / C# にて動作検証しています
  • 掲載したコードから省略していますが、using UnityEditorをしています

レイアウト違反例外について

ツールの実装が複雑になっていき、あるユーザー入力が内部データの変更を連鎖的に引き起こすようなケースが出てくるとOnGUIのレイアウト違反例外が発生するようになりました。

「レイアウト違反例外」と言っていますが、これは便宜上私が勝手に命名しただけで実際には、レイアウト違反を起こしたときに発生する例外内容は状況によって異なります。
なんだか名前は自己流だし、なんのことだかよくわからないと思いますので、最初にこの例外が発生するコードを示したいと思います。

class TestEditorWindow : EditorWindow {
	int _a = 0;

	void OnGUI() {
		if (_a == 0)
		{
			if (GUILayout.Button("button"))
			{
				_a++;
			}
		}
		else if (_a == 1)
		{
			GUILayout.Label("Label1");
			_a++;
		}
		else
		{
			//ArgumentException: Getting control 1's position in a group with only 1 controls when doing Repaint
			GUILayout.Label("Label1");
			GUILayout.Label("Label2");
		}
	}
}

何をやっているんだこのコードは、と思われるかもしれませんが何もやっていません。
現象再現のためだけの無意味なコードとお考え下さい。

このコードでbuttonを押下した時
ArgumentException: Getting control 1’s position in a group with only 1 controls when doing Repaint
という例外が発生します。これが私が言っている「レイアウト違反例外」というものです。


この例外が発生する原因を説明するにあたって、前提知識としてOnGUIの特殊な挙動について説明します。

OnGUIは一瞬の間に別々のイベントで複数回実行されることがあります。
具体的にどんなイベントがあるかというのはこちらに一覧されています。

https://docs.unity3d.com/ja/current/ScriptReference/EventType.html

実際にどういう感じで複数回実行されるのかコードで示したいと思います。

class TestEditorWindow : EditorWindow {
	void OnGUI() {
		Debug.Log(Event.current.type);
		if(GUILayout.Button("button")){
			Debug.Log("Push");
		}
	}
}

このbuttonをクリックすると下記のようにログが出力されます。(実際にはGUIにフォーカスしたタイミングなどでさらにOnGUIコールがされていますがここでは省略します)

  • Event.Layout
  • Event.mouseDown
  • Event.Layout
  • Event.Repaint
  • Event.Layout
  • Event.mouseUp
  • Push
  • Event.Layout
  • Event.Repaint

どうやらLayoutイベントの後にmouseDown/Up, Repaintといったイベントが続くようです。GUILayout.Buttonの中はMouseUpイベントで実行されているみたいですね。
というわけで。ボタンを押しただけでOnGUIは沢山実行されるんだなーということがお分かり頂けたかと思います。

さて、肝心のレイアウト違反例外ですが、もちろんこの中のEvent.Layoutが関係してきます。Event.Layoutとはなんなのでしょうか?

https://docs.unity3d.com/ja/current/ScriptReference/EventType.Layout.html

リファレンスによれば、

  • 他のイベントの前に実行されるイベント
  • 自動レイアウトシステムとして使用されるイベント

という事のようです。
この「自動レイアウトシステム」とはGUILayout.Button()などGUI要素を良い感じに自動配置してくれるもののことです。つまりは、このLayoutイベントでGUILayout.Buttonの配置矩形が計算されるようです。


このことを踏まえたうえで例外コードを見直すとどうでしょうか?例外が発生する原因がなんとなくわかるかと思います。

class TestEditorWindow : EditorWindow {
	int _a = 0;

	void OnGUI() {
		if (_a == 0)
		{
			if (GUILayout.Button("button"))
			{
				_a++;
			}
		}
		else if (_a == 1)
		{
			//Event.Layoutで実行される
			GUILayout.Label("Label1");
			_a++;
		}
		else
		{
			//Event.Repaintで実行される
			//ArgumentException: Getting control 1's position in a group with only 1 controls when doing Repaint
			GUILayout.Label("Label1");
			GUILayout.Label("Label2");
		}
	}
}

Else If(_a== 1)のブロックと、elseのブロックがいつ実行されるのかコメントを入れました。
この2つのブロックはどちらもGUILayoutでLabelを描画していますが、a==2のときLabelが1つ増えています。
「自動レイアウトシステム」からすると、Layoutイベント時にLabel1の配置座標(レイアウト情報)を計算しておいたのに、Repaintイベントでいざ描画しようとおもったらLabel2というレイアウトしていないGUIコントロールが増えていた。 何を言っているのかわからねーと思うが、俺も何をされたのか(以下略)ということですね。


最後に対処方法を提示して〆たいところなのですが…。
今回提示したコードは現象を再現させるためだけの無意味なものなので、これが正解という対処法はありません。とにかくLayoutとRepaintの間でレイアウトの変更が起きないようにすればOKです。
実際のコードではレイアウト変更を起こさないためにとれる手段はいくつもあるかと思います。例えばGUILayoutを使わないというのも一つの手です。この再現コードであればGUI.Label()で自分で配置矩形を渡してやれば例外が起こることはありません。


OnGUIはGUIとそれに対するレスポンスを手軽に実装できる一方、暗黙のうちに複数回実行されるなど混乱を招きやすい仕様となっています。
タイムラインツール開発を通して感じたことですが

  • OnGUIの中でやたらとデータ変更しない

というのを設計・実装の際に気を付けて頂くと処理の安全につながるかと思います。