TL;DR
Unityのユーザ定義PropertyDrawerの描画を毎フレーム自動更新する方法。
EditorWindowを継承していればUpdateがあるし、Editorを継承していればRequiresConstantRepaintがあります。
でもPropertyDrawerにはなくて不便なので頑張ってどうにかしました。
記事の下の方に基底クラスとして汎用化したものを置いてあります。
一部Reflectionを使用、Unity2019.3.0f3にて動作確認済。
モチベーション
タイムラインと再生機能つきのPropertyDrawerを作りたかったのです。
現在位置を表示したいわけですが、マウスを動かしたりクリックしたりしないとRepaintが呼ばれないので、常に適切に表示するには別途Inspectorを拡張してoverride RequiresConstantRepaint() => true;
する必要があります。
何もしなくても、あるいはAttributeをつけるだけで手軽に拡張できるのがPropertyDrawerの良さだというのに、これではあんまりです。
実現までの道のり
過程はいいからモノを出せという方は飛ばしてどうぞ。
PropertyDrawer内部から自身をRepaintする
PropertyDrawerそのものにはRepaintという概念がないので、Repaintするためには親であるEditorのRepaintを呼ぶ必要があります。
アクティブなEditorはActiveEditorTracker.sharedTracker.activeEditors
でアクセスできるので、以下のように定義してRepaint()
すればよいですね。
SerializedObjectparentSerializedObject;publicoverridevoidOnGUI(Rectposition,SerializedPropertyproperty,GUIContentlabel){parentSerializedObject=property.serializedObject;/*EditorGUI.BeginProperty(position, label, prop);
... (いつもの部分。以降のコードでは省略されます)
EditorGUI.EndProperty();*/}voidRepaint(){foreach(vareditorinActiveEditorTracker.sharedTracker.activeEditors){if(editor.serializedObject==parentSerializedObject){editor.Repaint();return;}}}
ではこれをどこから呼ぶかですが、EditorWindowと違ってPropertyDrawerにはUpdateがありません。
EditorApplication.update
そこで出てくるのが、EditorのUpdateをフックするためのこのevent。EditorApplication.update += Repaint;
とすれば、毎UpdateごとにRepaint()
が実行されるようになります。これを使っていきましょう。
eventのadd/removeは大抵の場合OnEnabled/OnDisabled的な部分に書きますが、PropertyDrawerにはその類の「最初と最後に一度だけ呼ばれる」イベント、virtualメソッドが存在しません。
仕方がないのでOnGUI内に書きます。OnGUIは何度も呼ばれるので、addの前にremoveするのを忘れずに。
SerializedObjectparentSerializedObject;publicoverridevoidOnGUI(Rectposition,SerializedPropertyproperty,GUIContentlabel){parentSerializedObject=property.serializedObject;EditorApplication.update-=Repaint;//増殖を防ぐEditorApplication.update+=Repaint;}voidRepaint(){/*略*/}
これで、とりあえず毎フレームRepaintはされるようになりました。
event購読を解除する
addの前にremoveを挟むことで同一PropertyDrawer内での増殖は防いでいますが、開き直したPropertyDrawerは別のインスタンスになるようで、このままでは「選択しているGameObjectを変えて再び元のGameObjectを選択し直す」を繰り返すことでリークします。
これを防ぐため、選択項目が変わったらRepaintをremoveするようにしましょう。
Selection.selectionChanged
を使う
選択中のObjectの変化をフックするためのeventです。
SerializedObjectparentSerializedObject;publicoverridevoidOnGUI(Rectposition,SerializedPropertyproperty,GUIContentlabel){parentSerializedObject=property.serializedObject;EditorApplication.update-=Repaint;EditorApplication.update+=Repaint;Selection.selectionChanged-=OnSelectionChanged;Selection.selectionChanged+=OnSelectionChanged;}voidRepaint(){/*略*/}voidOnSelectionChanged(){if(parentSerializedObject==null||parentSerializedObject.targetObject!=Selection.activeObject){EditorApplication.update-=Repaint;Selection.selectionChanged-=OnSelectionChanged;}}
これで大丈夫な気がしますね。nullチェックもバッチリです。
早速PropertyDrawerを表示した状態で、別のGameObjectを選択してみましょう。
ダメみたいですね……_unity_self
なるものがnullだそうです。知らんがな。
VisualStudioでエラー箇所を確認してみると、
なんとparentSerializedObject
にまだ実体があり、しかしそのプロパティにアクセスできない状態。targetObject
のget内でエラーが出てるみたいですね。
これは……Unityのバグかなあ。気が向いたらバグレポートでも出しますかね。
m_NativeObjectPtr
で判断
上のスクショを見ると、parentSerializedObjectのうち、ただ一つだけ正常にアクセスできているメンバがあります。m_NativeObjectPtr
、型はSystem.IntPtr
。publicでないフィールド。選択解除時の値は0。
targetObjectのアドレスを保有するための内部フィールドだと考えられますね。
この値が0ならnullだと見なせばよさそうです。Reflectionしましょう。
また、同スクリプトを載せた他のObjectを選択するときや選択解除するときも同様にm_NativeObjectPtr
は0だったので、選択中オブジェクトの比較は不要そうです。
nullチェックもいらなさそうですが、ちょっと怖いのでこっちは一応入れておきます。
SerializedObjectparentSerializedObject;publicoverridevoidOnGUI(Rectposition,SerializedPropertyproperty,GUIContentlabel){/*略*/}voidRepaint(){/*略*/}//キャッシュstaticreadonlyFieldInfofi_m_NativeObjectPtr=typeof(SerializedObject).GetField("m_NativeObjectPtr",BindingFlags.NonPublic|BindingFlags.Instance);voidOnSelectionChanged(){if(parentSerializedObject==null||(IntPtr)fi_m_NativeObjectPtr.GetValue(parentSerializedObject)==IntPtr.Zero){EditorApplication.update-=Repaint;Selection.selectionChanged-=OnSelectionChanged;}}
これでエラーは出なくなりました。
本当にリークしていないかどうかは、OnGUI()
あたりに
Debug.Log($"UpdateEvent Count = {EditorApplication.update.GetInvocationList().Length}");
とでも書いておけばConsoleで確認できます。いくら選択し直しても数字が増えていかなければOK。
できたもの
コンパイルやUndo/Redoのフック、Updateフレームレートの変更、その他諸々追加して基底クラス化したものがこちら。
カスタムインスペクタ上で表示する場合はカスタムインスペクタ側でRequiresConstantRepaintするはずなので、二重にRepaintが走らないようフィルタリングしています。
常識的な範囲でご自由に使ってどうぞ。リーク確認漏れとかバグとかあったらぜひ教えてくださいませ。
usingUnityEditor;usingUnityEngine;usingSystem;usingSystem.Reflection;usingUnityEditor.Compilation;publicabstractclassConstantRepaintPropertyDrawer:PropertyDrawer{SerializedObjectparentSerializedObject;staticreadonlyFieldInfofi_m_NativeObjectPtr=typeof(SerializedObject).GetField("m_NativeObjectPtr",BindingFlags.NonPublic|BindingFlags.Instance);staticdoublelastUpdateTime=0;voidRepaint(){if(Framerate<=0||EditorApplication.timeSinceStartup>lastUpdateTime+1/Framerate){lastUpdateTime=EditorApplication.timeSinceStartup;foreach(vareditorinActiveEditorTracker.sharedTracker.activeEditors){if(editor.serializedObject==parentSerializedObject){editor.Repaint();OnRepaint();return;}}}}void_OnSelectionChanged(){OnSelectionChanged();if(parentSerializedObject==null||(IntPtr)fi_m_NativeObjectPtr.GetValue(parentSerializedObject)==IntPtr.Zero){EditorApplication.update-=Repaint;Selection.selectionChanged-=_OnSelectionChanged;CompilationPipeline.compilationStarted-=OnCompilationStarted;CompilationPipeline.compilationFinished-=OnCompilationFinished;Undo.undoRedoPerformed-=OnUndoRedoPerformed;}}/// <summary>/// Repaintの目標フレームレート。0以下で無制限(EditorApplication.updateごと)。既定値は60。/// </summary>protectedvirtualfloatFramerate=>60;/// <summary>/// Repaint終了時に毎回呼ばれる。/// </summary>protectedvirtualvoidOnRepaint(){}/// <summary>/// Selection変化時に呼ばれる。/// </summary>protectedvirtualvoidOnSelectionChanged(){}/// <summary>/// コンパイル開始時に呼ばれる。/// </summary>protectedvirtualvoidOnCompilationStarted(objectobj){}/// <summary>/// コンパイル終了時に呼ばれる。/// </summary>protectedvirtualvoidOnCompilationFinished(objectobj){}/// <summary>/// Undo/Redoが行われた後に呼ばれる。/// </summary>protectedvirtualvoidOnUndoRedoPerformed(){}/// <summary>/// sealed. OnGUIの代わりにOnGUIMainをoverrideしてください。/// </summary>publicsealedoverridevoidOnGUI(Rectposition,SerializedPropertyproperty,GUIContentlabel){if(!ActiveEditorTracker.HasCustomEditor(property.serializedObject.targetObject)){parentSerializedObject=property.serializedObject;EditorApplication.update-=Repaint;EditorApplication.update+=Repaint;}Selection.selectionChanged-=_OnSelectionChanged;Selection.selectionChanged+=_OnSelectionChanged;CompilationPipeline.compilationStarted-=OnCompilationStarted;CompilationPipeline.compilationStarted+=OnCompilationStarted;CompilationPipeline.compilationFinished-=OnCompilationFinished;CompilationPipeline.compilationFinished+=OnCompilationFinished;Undo.undoRedoPerformed-=OnUndoRedoPerformed;Undo.undoRedoPerformed+=OnUndoRedoPerformed;OnGUIMain(position,property,label);}/// <summary>/// Override this method to make your own IMGUI based GUI for the property./// </summary>protectedvirtualvoidOnGUIMain(Rectposition,SerializedPropertyproperty,GUIContentlabel){base.OnGUI(position,property,label);}}
要改善点
同じGameObject上でこのPropertyDrawerが複数回表示されている場合、その回数分Repaintが無駄に走ります。
PropertyDrawerが載ってるserializedObjectをどこかに保持しておけば比較でどうにかなりそうな気がしますが、めんどい。
あと複数選択時の挙動は未確認です。
おわりに
デフォルトEditorもRequiresConstantRepaintをtrueにできればもうちょっと単純にできるのになぁと思いました。
あれは基底クラス(Editor)のvirtualメソッドの中身がreturn false;
なのでReflectionじゃどうにもならないやつ。
メソッドの中身を動的に書き換える手段とか、実はどこかにあったりするんだろうか。