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()
すればよいですね。
SerializedObject parentSerializedObject;
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
parentSerializedObject = property.serializedObject;
/*EditorGUI.BeginProperty(position, label, prop);
... (いつもの部分。以降のコードでは省略されます)
EditorGUI.EndProperty();*/
}
void Repaint()
{
foreach (var editor in ActiveEditorTracker.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するのを忘れずに。
SerializedObject parentSerializedObject;
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
parentSerializedObject = property.serializedObject;
EditorApplication.update -= Repaint; //増殖を防ぐ
EditorApplication.update += Repaint;
}
void Repaint(){/*略*/}
これで、とりあえず毎フレームRepaintはされるようになりました。
event購読を解除する
addの前にremoveを挟むことで同一PropertyDrawer内での増殖は防いでいますが、開き直したPropertyDrawerは別のインスタンスになるようで、このままでは**「選択しているGameObjectを変えて再び元のGameObjectを選択し直す」**を繰り返すことでリークします。
これを防ぐため、選択項目が変わったらRepaintをremoveするようにしましょう。
Selection.selectionChanged
を使う
選択中のObjectの変化をフックするためのeventです。
SerializedObject parentSerializedObject;
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
parentSerializedObject = property.serializedObject;
EditorApplication.update -= Repaint;
EditorApplication.update += Repaint;
Selection.selectionChanged -= OnSelectionChanged;
Selection.selectionChanged += OnSelectionChanged;
}
void Repaint(){/*略*/}
void OnSelectionChanged()
{
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チェックもいらなさそうですが、ちょっと怖いのでこっちは一応入れておきます。
SerializedObject parentSerializedObject;
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label){/*略*/}
void Repaint(){/*略*/}
//キャッシュ
static readonly FieldInfo fi_m_NativeObjectPtr = typeof(SerializedObject)
.GetField("m_NativeObjectPtr", BindingFlags.NonPublic | BindingFlags.Instance);
void OnSelectionChanged()
{
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が走らないようフィルタリングしています。
リーク確認漏れとかバグとかあったらぜひ教えてくださいませ。
using UnityEditor;
using UnityEngine;
using System;
using System.Reflection;
using UnityEditor.Compilation;
public abstract class ConstantRepaintPropertyDrawer : PropertyDrawer
{
SerializedObject parentSerializedObject;
static readonly FieldInfo fi_m_NativeObjectPtr = typeof(SerializedObject)
.GetField("m_NativeObjectPtr", BindingFlags.NonPublic | BindingFlags.Instance);
static double lastUpdateTime = 0;
void Repaint()
{
if(Framerate <= 0 || EditorApplication.timeSinceStartup > lastUpdateTime + 1 / Framerate)
{
lastUpdateTime = EditorApplication.timeSinceStartup;
foreach (var editor in ActiveEditorTracker.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>
protected virtual float Framerate => 60;
/// <summary>
/// Repaint終了時に毎回呼ばれる。
/// </summary>
protected virtual void OnRepaint() { }
/// <summary>
/// Selection変化時に呼ばれる。
/// </summary>
protected virtual void OnSelectionChanged() { }
/// <summary>
/// コンパイル開始時に呼ばれる。
/// </summary>
protected virtual void OnCompilationStarted(object obj) { }
/// <summary>
/// コンパイル終了時に呼ばれる。
/// </summary>
protected virtual void OnCompilationFinished(object obj) { }
/// <summary>
/// Undo/Redoが行われた後に呼ばれる。
/// </summary>
protected virtual void OnUndoRedoPerformed() { }
/// <summary>
/// sealed. OnGUIの代わりにOnGUIMainをoverrideしてください。
/// </summary>
public sealed override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
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>
protected virtual void OnGUIMain(Rect position, SerializedProperty property, GUIContent label)
{
base.OnGUI(position, property, label);
}
}
要改善点
同じGameObject上でこのPropertyDrawerが複数回表示されている場合、その回数分Repaintが無駄に走ります。
PropertyDrawerが載ってるserializedObjectをどこかに保持しておけば比較でどうにかなりそうな気がしますが、めんどい。
あと複数選択時の挙動は未確認です。
おわりに
デフォルトEditorもRequiresConstantRepaintをtrueにできればもうちょっと単純にできるのになぁと思いました。
あれは基底クラス(Editor)のvirtualメソッドの中身がreturn false;
なのでReflectionじゃどうにもならないやつ。
メソッドの中身を動的に書き換える手段とか、実はどこかにあったりするんだろうか。