제9장 CustomEditor

2019. 8. 14. 20:26유니티/(Old)에디터 확장

 

Unity エディター拡張入門

Web版無償公開中!Unityエディター拡張の入門書!

anchan828.github.io

커스텀 에디터는 인스펙터, 씬 뷰에 표시되는 GUI를 커스텀하기 위한 기능입니다. 본 장에서는 커스텀 에디터의 기본적인 사용법과 인스펙터의 내부 구조에 대해서 소개합니다.

9.1 인스펙터 Debug 모드

예를 들면 Cube를 작성하고 인스펙터를 살펴보면 BoxCollider와 MeshRenderer와 같은 컴포넌트가 부착되어 있는 것을 알 수 있습니다.

그림 9.1 : Cube를 작성하고 Cube를 선택한 상태

이 때 인스펙터의 탭 부분을 오른쪽 마우스로 클릭, 혹은 ≡를 클릭하면 그림 9.2와 같이 컨텍스트 메뉴가 표시되며 Normal, Debug 항목을 볼 수 있습니다.

그림 9.2 : 통상 Normal에 체크가 되어 있습니다.

여기서 Debug를 선택하면 그림 9.3과 같이 주로 보고 있는 인스펙터와는 조금 달라집니다.

그림 9.3 : 보통 보이지 않는 Instance ID, File ID 의 프로퍼티가 보여집니다.

Debug 모드는 인스펙터가 커스텀 마이즈 되기 전의 요소의 상태를 표시합니다. 유니티 에디터는 디폴트로 인스펙터에 표시해야하는 요소들 중에서 필요한 부분만 추려내고 GUI를 커스텀 마이즈해서 표시합니다.

9.2 객체와 Editor 클래스

Editor 클래스는 객체의 정보를 인스펙터, 씬 뷰에 표시하기 위한 기반이 되는 기능입니다. 인스펙터에 특정 정보가 표시될 때 각 객체에 대응하는 Editor 객체가 생성되어 Editor 객체를 토대로 필요한 정보를 GUI에 표시합니다.

 

그림 9.4 : 박스 충돌체를 Editor 객체를 통해서 GUI로 표시합니다.

인스펙터에 표시할 필요가 없는 요소가 있거나 버튼과 같이 독자적인 GUI 요소가 있는데 이 경우에는 CustomEditor의 기능을 사용하는 것으로 Editor 객체를 커스텀 마이즈 할 수 있습니다.

 

평소에 유저가 보고 있는 인스펙터는 커스텀 마이즈가 되어있습니다.

평소에 사용하는 인스펙터의 컴포넌트는 이미 커스텀 에디터에 의해 커스텀 마이즈 되어있습니다. 본래의 모습은 본 장의 최초에 설명했던 Debug 모드의 상태입니다.

그림 9.5 : 말 끝에 Inspector 와 Editor로 구분되어 있지만 기능 상으로는 큰 차이점이 없습니다.

즉 보통 사용하고 있는 인스펙터의 표시를 사용자의 손으로 커스텀 에디터를 통해 처리할 수 있습니다. 사용자의 손으로 처리하는 하기에 앞서 다시 한 번 위의 이미지를 참고하여 살펴보는 것을 추천합니다.

9.3 커스텀 에디터를 사용하기

예를 들면 게임 중에 사용하는 공격력에 대한 처리는 케릭터의 힘 과 무기의 공격력와 같은 각 각의 요소들의 합으로 이루어져 있습니다. 이 때 프로그램에서 사용하는 공격력이라는 프로퍼티를 가진 getter로 공격력을 합산하여 처리합니다.

 

소스 코드는 계산식을 알기 쉽게 하기 위하여 한국어로 표시해보았습니다.

using UnityEngine;
 
public class Character : MonoBehaviour
{
    [Range(0255)]
    public int 기본공격력;
    [Range(099)]
    public int 검의공격력;
    [Range(099)]
    public int 힘;
 
    //플레이어의 능력과 검의 공격력으로부터 공격력을 리턴합니다.
    public int 공격력
    {
        get
        {
            return 기본공격력 + Mathf.FloorToInt(기본공격력 * (검의공격력 + 힘 - 8/ 16);
        }
    }
}

프로그램의 동작에서만 보면 이대로도 좋습니다만 공격력의 값을 유니티 에디터의 인스펙터로 확인하고자 할 때 조금 불편한 부분이 있습니다. 인스펙터는 직렬화를 통하여 필드에 프로퍼티를 표시할 수 있지만 직렬화 대상이 될 수 없는 프로퍼티는 표시할 수가 없습니다.(Getter/Setter(프로퍼티)는 직렬화 대상이 아닙니다. 자세한 것은 제5장에서 [직렬화 대상이 되는 클래스 변수]를 참고해주세요.)

그림 9.6 : 스크립트를 부착, 인스펙터로 확인

이번에는 밑의 그림과 같이 프로퍼티인 공격력을 인스펙터에 표시하여 확인하면서 파라메터를 조절할 수 있도록 해보겠습니다.

그림 9.7 : 인스펙터에 공격력을 표시

Editor 클래스의 파생 클래스를 작성

Editor 클래스의 파생 클래스를 작성한 뒤, Character 컴포넌트와 관련된 Editor 클래스인 CustomEditor 속성을 부가합니다. 이 것으로 커스텀 에디터로 커스텀 마이즈를 할 준비가 갖추집니다.

using UnityEngine;
using UnityEditor;
 
[CustomEditor(typeof(Character))]
public class CharacterInspector : Editor
{
 
}

인스펙터의 GUI를 커스텀 마이즈

인스펙터의 GUI는 OnInspectorGUI를 오버라이드하는 것으로 커스텀화할 수 있습니다.

using UnityEngine;
using UnityEditor;
 
[CustomEditor(typeof(Character))]
public class CharacterInspector : Editor
{
    Character character = null;
 
    void OnEnable()
    {
        //Character 컴포넌트를 취득
        character = (Character)target;
    }
 
    public override void OnInspectorGUI()
    {
        base.OnInspectorGUI();
 
        //공격력의 수치를 라벨로 표시
        EditorGUILayout.LabelField("공격력", character.공격력.ToString());
    }
}

이 것으로 그림 9.7과 같이 표시가 됩니다. 이와 같이 인스펙터의 GUI를 커스텀화하려면 OnInspectorGUI를 오버라이드를 해야하며 base.OnInspectorGUI를 호출하는 것으로 기존의 GUI 요소를 인스펙터에 같이 추가하여 그릴 수 있습니다.

 

씬 뷰의 GUI의 커스텀 마이즈하기

씬 뷰의 GUI는 OnSceneGUI를 사용하는 것으로 커스텀 마이즈할 수 있습니다. OnSceneGUI는 주로 게임 객체에 관한 목적으로 사용됩니다. 그리고 OnSceneGUI가 처리되는 시기는 게임 객체를 선택하였을 때(인스펙터가 표시될 때) 입니다.

 

OnSceneGUI에서는 조금 특수한 3D에 특화한 GUI를 사용합니다. 이에 대한 설명은 제 17장 Handle에서 상세하게 설명되어 있으니 참고해주세요.

9.4 커스텀 에디터를 사용하여 데이터를 주고 받기

커스텀 에디터로부터 컴포넌트의 값에 엑세스하는 방법은 2가지가 있습니다. [유니티의 직렬화를 통하여 접근하는 방법]과 [컴포넌트에 직접 접근하는 방법] 입니다.

그림 9.8 : 컴포넌트의 값에 접근했을 때 내부에서 처리되는 과정

위의 이미지처럼 2종류의 방법에 대해서 설명합니다. 먼저 이 방법을 설명하기 위하여 사용할 코드는 다음과 같으며 이를 토대로 커스텀 에디터를 작성하여 진행합니다.

using UnityEngine;
 
public class Character : MonoBehaviour
{
    public int hp;
}

Unity의 직렬화를 통해서 접근하는 방법

유니티 에디터는 데이터를 보유하는 방법으로 SerializedObject로 모든 데이터를 관리합니다. SerializedObject 경유하여 데이터에 접근하는 것으로 데이터를 조작할 때 유연하게 대응할 수 있습니다. SerializedObject에 대한 상세한 설명은 제5장 SerializedObject을 참고해주세요.

 

Editor 객체가 생성되면 동시에 컴포넌트가 직렬화되어 Editor 클래스의 SerializedObject 변수에 저장됩니다. 그리고 이 SerializedObject 변수로부터 직렬화된 각 값에 접근할 수 있습니다.

 

다음 코드에 의해 [SerializedProperty에 접근하기 전에는 반드시 SerializedObject를 최신 상태로 갱신]하지 않으면 안됩니다. 최신 상태로 만드는 것은 컴포넌트의 SerializedObject가 다른 곳에서도 갱신된 경우 최신 상태로 적용하기 위함입니다.

 

[SerializedProperty에 접근한 후에는 반드시 프로퍼티의 변경 사항을 SerializedObject에 적용]해야합니다. 이러한 과정을 통해 데이터를 보존할 수 있습니다.

using UnityEngine;
using UnityEditor;
 
[CustomEditor (typeof(Character))]
public class CharacterInspector : Editor
{
    SerializedProperty hpProperty;
 
    void OnEnable ()
    {
        hpProperty = serializedObject.FindProperty ("hp");
    }
 
    public override void OnInspectorGUI ()
    {
        serializedObject.Update ();
 
        EditorGUILayout.IntSlider (hpProperty, 0100);
 
        serializedObject.ApplyModifiedProperties ();
    }
}

컴포넌트에 직접 접근하는 방법

컴포넌트에 직접 접근하는 것으로 값의 변경, GUI의 작성을 간단하게 진행할 수 있습니다.

대상의 컴포넌트는 Editor 객체의 target 변수로 접근할 수 있습니다. UnityEngine.Object 형과 같은 형 변환(캐스팅)을 할 필요가 있습니다.

 

Undo를 처리하는 것에 대해서

컴포넌트에 직접 접근하는 것은 무척 편한 방법입니다. 문자열로 프로퍼티에 접근하는 SerializedObject와 비교하면 typo(Typographical : 오타)와 같은 줄일 수 있습니다. 하지만 이 방법으로는 변경한 값을 취소하기 위한 Undo 처리를 할 수가 없습니다. Undo는 자동으로 등록되는 것이 아니라 값을 보존/변경할 시 자체적으로 처리해야됩니다.

SerializedObject는 Undo와 자동으로 등록하기 때문에 Undo에 관한 별도로 생각하지 않아도 됩니다. Undo의 관한 상세한 설명은 제 12장 [Undo에 관해서]를 참고해주세요.

 

에셋이 갱신된 것을 에디터에 통지하는 SetDirty

Unity 5.2 까지는 마지막에 EditorUtiilty.SetDirty를 호출하는 것으로, 변경된 값을 보존(컴포넌트의 값이면 씬에 보존)하는 것이 가능합니다. 하지만 Unity 5.3부터는 SetDirty는 에셋에 대해서만 동작하도록 변경되었습니다.

에셋의 값을 변경한 경우에는 반드시 EditorUtiilty.SetDirty를 호출해주세요. 이 것은 Unity 에디터에 에셋의 상태가 변경되었다는 것을 통지하는 목적으로 사용합니다.

 

에셋에는 Dirty flag라는 것이 있는데 이 플러그를 설정하는 것으로 Unity 에디터는 에셋을 최신 상태로 만듭니다. 예를 들면 프리팹에 부착되어 있는 컴포넌트의 값을 변경한 경우 EditorUtiilty.SetDirty를 사용합니다. 그리고 Unity 프로젝트를 보존(File - Save Project 혹은 AssetDatabase.SavAssets)했을 때 Dirty flag에 해당되는 객체 전부를 갱신합니다.

Character character;
 
void OnEnable ()
{
    character = (Character) target;
}
 
public override void OnInspectorGUI ()
{
    EditorGUI.BeginChangeCheck ();
 
    var hp = EditorGUILayout.IntSlider ("Hp", character.hp, 0100);
 
    if (EditorGUI.EndChangeCheck ()) {
 
        //갱신 전에 Undo를 등록
        Undo.RecordObject (character, "Change hp");
 
        character.hp = hp;
    }
}

9.5 복수 컴포넌트의 동시 편집

유니티에서는 GameObject를 동시에 여러 개를 선택하여 동일한 프로퍼티의 값을 편집할 수 있습니다. 동시 편집이 가능한 경우는 동시 편집을 사용 가능하도록 설정한 컴포넌트만 가능합니다.

그림 9.9 : 동시 편집이 허가되지 않을 경우의 컴포넌트

사용자가 커스텀 에디터로 처리하지 않는 컴포넌트는 보통 동시에 편집할 수 있습니다만 커스텀 에디터로 처리한 컴포넌트의 경우 디폴트로 동시 편집이 되지 않도록 되어있습니다.

 

CanEditMultipleObjects

동시 편집을 사용하려면 CanEditMultipleObjects 속성을 Editor의 파생 클래스에 추가하면 됩니다.

using UnityEngine;
using UnityEditor;
 
[CanEditMultipleObjects]
[CustomEditor (typeof(Character))]
public class CharacterInspector : Editor
{
}

이렇게 CanEditMultipleObjects 속성을 추가한 것으로 동시 편집을 할 수 있습니다. 여기에서도 프로퍼티의 접근을 SerializedObject를 경유할 것인지 직접 컴포넌트에 접근하는 것인지에 따라서 처리하는 방식이 달라집니다.

 

SerializedObject를 사용한 동시 편집

SerializedObject를 통해서 편집하는 경우 CanEditMultipleObjects 속성을 추가하는 것만으로 SerializedObject 측에서 동시 편집을 대응할 수 있게 됩니다.

using UnityEngine;
using UnityEditor;
 
[CanEditMultipleObjects]
[CustomEditor (typeof(Character))]
public class CharacterInspector : Editor
{
    SerializedProperty hpProperty;
 
    void OnEnable ()
    {
        hpProperty = serializedObject.FindProperty ("hp");
    }
 
    public override void OnInspectorGUI ()
    {
        serializedObject.Update ();
 
        EditorGUILayout.IntSlider (hpProperty, 0100);
 
        serializedObject.ApplyModifiedProperties ();
    }
}

 

컴포넌트에 직접 접근해서 동시 편집하기

동시 편집을 사용하는 경우 복수의 컴포넌트에 접근해야만 합니다. 복수 선택한 경우 target 변수를 사용하지 말고 복수명인 targets 변수를 사용합니다. targets에 현재 선택 중인 객체 전부를 얻어올 수 있습니다.

 

복수 선택 했을 때 인스펙터에 표시되는 형태는 최초 선택한 컴포넌트입니다. 이 것은 target에 저장되어 있거나 targets의 첫 번째 요소이기 때문입니다.

 

선택한 컴포넌트의 각 프로퍼티가 전부 같은 값이라면 같은 형태로 표시되며 그렇지 않을 경우 Unity는 - 를 표시합니다.

그림 9.10 : 복수 선택 시 왼쪽은 같은 값, 오른쪽은 다른 값의 경우를 표시합니다.

- 를 표시하는 처리 구조는 컴포넌트에 직접 접근하는 방법이라면 자동적으로 적용되지 않으며, 직접 구현할 필요가 있습니다. EditorGUI.showMixedValue라는 static 변수가 있는데 GUI의 코드를 갱신하기 전에 true를 설정하는 것으로 다른 값일 때 -를 표시할 수 있습니다.

 

다음 코드는 위에서 설명한 전부를 포함한 코드입니다.

using UnityEngine;
using UnityEditor;
using System.Linq;
 
[CanEditMultipleObjects]
[CustomEditor (typeof(Character))]
public class CharacterInspector : Editor
{
    Character[] characters;
 
    void OnEnable ()
    {
        characters = targets.Cast<Character> ().ToArray ();
    }
 
    public override void OnInspectorGUI ()
    {
        EditorGUI.BeginChangeCheck ();
 
        //다른 값이 2개 이상이면 true
        EditorGUI.showMixedValue =
            characters.Select (x => x.hp).Distinct ().Count () > 1;
 
        var hp = EditorGUILayout.IntSlider ("Hp", characters [0].hp, 0100);
 
        EditorGUI.showMixedValue = false;
 
        if (EditorGUI.EndChangeCheck ()) {
 
            //모든 컴포넌트를 Undo에 등록
            Undo.RecordObjects (characters, "Change hp");
 
            //모든 컴포넌트에 값을 대입하여 변경
            foreach (var character in characters) {
                character.hp = hp;
            }
        }
    }
}

9.6 커스텀 에디터 내부에서 PropertyDrawer를 사용하기

커스텀 에디터 내에서도 PropertyDrawer를 사용할 수 있습니다. 사용하는 방법은 EditorGUILayout.PropertyField에 해당되는 SerializedProperty를 건내는 것 입니다. PropertyDrawer에 대한 상세한 설명은 제 10장 PropertyDrawer를 참고해주세요.

 

그림 9.11과 같이 PropertyDrawer를 작성하여 커스텀 에디터 내에 표시할 수 있습니다.

그림 9.11 : MinMaxSlider

먼저 PropertyDrawer를 구현할 Example 클래스를 작성하여 Character 변수에 기술합니다.

[System.Serializable]
public class Example
{
    public int minHp;
    public int maxHp;
}
using UnityEngine;
 
public class Character : MonoBehaviour
{
    public Example example;
}

다음은 Example 클래스의 PropertyDrawer를 작성합니다. MinMaxSlider의 처리와 각 각의 값을 라벨로 표시합니다.

using UnityEditor;
using UnityEngine;
 
[CustomPropertyDrawer(typeof(Example))]
public class ExampleDrawer : PropertyDrawer
{
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        using (new EditorGUI.PropertyScope(position, label, property))
        {
            //각 프로퍼티를 얻기
            var minHpProperty = property.FindPropertyRelative("minHp");
            var maxHpProperty = property.FindPropertyRelative("maxHp");
 
            //표시 위치를 조정
            var minMaxSliderRect = new Rect(position)
            {
                height = position.height * 0.5f
            };
 
            var labelRect = new Rect(minMaxSliderRect)
            {
                x = minMaxSliderRect.x + EditorGUIUtility.labelWidth,
                y = minMaxSliderRect.y + minMaxSliderRect.height
            };
 
            float minHp = minHpProperty.intValue;
            float maxHp = maxHpProperty.intValue;
 
            EditorGUI.BeginChangeCheck();
 
            EditorGUI.MinMaxSlider(label, minMaxSliderRect, ref minHp, ref maxHp, 0100);
            EditorGUI.LabelField(labelRect, minHp.ToString(), maxHp.ToString());
 
            if (EditorGUI.EndChangeCheck())
            {
                minHpProperty.intValue = Mathf.FloorToInt(minHp);
                maxHpProperty.intValue = Mathf.FloorToInt(maxHp);
            }
        }
    }
 
    //GUI 요소의 높이
    public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
    {
        return base.GetPropertyHeight(property, label) * 2;
    }
}

다음은 커스텀 에디터 내에서 사용하면 됩니다.

using UnityEngine;
using UnityEditor;
 
[CanEditMultipleObjects]
[CustomEditor (typeof(Character))]
public class CharacterInspector : Editor
{
    SerializedProperty exampleProperty;
 
    void OnEnable ()
    {
        exampleProperty = serializedObject.FindProperty ("example");
    }
 
    public override void OnInspectorGUI ()
    {
        serializedObject.Update ();
 
        EditorGUILayout.PropertyField (exampleProperty);
 
        serializedObject.ApplyModifiedProperties ();
    }
}

이처럼 세부적인 부분은 PropertyDrawer로 처리하면 복잡하게 코드를 구성할 필요없게 됩니다. 현재는 코드의 분량이 적지만 실제 개발을 하다보면 이렇게 분리하지 않고 구현하게 되면 유지보수가 힘들어집니다.

9.7 프리 뷰

인스펙터에는 메뉴, 텍스쳐, 스프라이트 등을 프리 뷰로 볼 수 있는 요소들 입니다.

그림 9.12 : Cube의 프리 팹을 선택하면 인스펙터의 프리 뷰 윈도우에서 확인할 수 있습니다.

프리 뷰 화면에 표시

커스텀 에디터를 사용할 때 프리 뷰의 표시는 기본적으로 무효 처리가 되며 표시되지 않습니다. 프리 뷰에 그리기 위해서 인스펙터에게 그려질 대상이라고 알리기 위해서는 HasPreviewGUI 함수를 오버라이드해서 리턴 값을 true로 설정해서 돌려주면 됩니다.

public override bool HasPreviewGUI()
{
    //프리 뷰에 표시하려면 true를 리턴
    return true;
}

이로써 그림 9.13과 같이 보통 빈 게임 오브젝트의 경우에는 프리 뷰에 표시가 되지 않지만 true를 리턴하면 프리 뷰 화면에 표시가 됩니다.

그림 9.13 : 왼쪽이 무효상태(false), 오른쪽이 유효상태(true)

프리 뷰의 표시

사전 준비

먼저 다음 스크립트를 작성합니다.

using UnityEngine;
 
public class PreviewExample : MonoBehaviour {
 
}
using UnityEngine;
using UnityEditor;
 
[CustomEditor (typeof(PreviewExample))]
public class PreviewExampleInspector : Editor
{
    public override bool HasPreviewGUI ()
    {
        return true;
    }
}

그리고 큐브를 하나 만들어서 PreViewExample 스크립트를 부착합니다.

프리 뷰의 기본 처리

프리 뷰의 윈도우가 표시하기 위한 기본 처리를 익히기 위해서는 제공하는 3개의 함수를 알아둬야합니다.

 

GetPreviewTitle

프리 뷰의 이름을 설정합니다. 1개의 객체에 대해 복수의 프리 뷰를 가질 경우 식별하기 위한 용도로도 사용됩니다.

그림 9.14 : 프리 뷰가 복수일 경우 프리 뷰 이름을 드랍 다운하여 식별할 수 있습니다.

public override GUIContent GetPreviewTitle ()
{
    return new GUIContent ("프리 뷰 이름");
}

OnPreviewSettings

오른쪽 위의 헤더 부분에 GUI를 추가하기 위한 용도로 사용합니다. 프리 뷰 환경을 변경할 버튼이나 정보를 기재합니다. 여기에서는 적절한 GUIStyle가 도큐먼트화되어 있지 않아서 찾기 힘들지만 [라벨은 preLabel], [버튼은 preButton], [드롭 다운은 preDropDown], [슬라이드는 preSlider]를 사용합니다. 혹은 (Editor)GUILayout를 사용하는 것을 추천합니다.

EditorGUILayout.BeginHorizontal에 의해 수평으로 GUI가 나열되도록 할 수 있습니다.

그림 9.15 : 오른쪽 끝에서부터 나열됩니다.

public override void OnPreviewSettings ()
{
    GUIStyle preLabel = new GUIStyle ("preLabel");
    GUIStyle preButton = new GUIStyle ("preButton");
 
    GUILayout.Label ("라벨", preLabel);
    GUILayout.Button ("버튼", preButton);
}

OnPreviewGUI

프리 뷰를 표시(즉 텍스처 혹은 랜더링의 결과를 표시하기 위한 GUI를 표시)하는 공간입니다. 함수의 인수에 화면에 그리기 위한 영역의 Rect를 취득할 수 있기에 프리 뷰에 포함된 Rect를 커스텀 마이즈할 수 있습니다.

그림 9.16 : 프리 뷰의 영역 전체에 Box가 그려집니다.

public override void OnPreviewGUI (Rect r, GUIStyle background)
{
    GUI.Box (r, "Preview");
}

9.8 프리 뷰에 카메라를 사용하기

모델 데이터나 애니메이션 편집 때에 프리뷰 화면에서 마우스를 드래그하는 것으로 대상의 객체를 회전하여 구석 구석 살펴볼 수 있는 기능입니다.

 

그림 9.17 : AnimationClip 의 프리 뷰. 마우스로 드래그하여 살펴볼 수 있습니다.

이러한 기능은 특별한 기능을 사용하는 것이 아닙니다. 그림 9.17에서 다루는 많은 기능에 관해서는 본장에서 다루지는 않지만 사전에 알아둬야하는 기능을 간략하게 소개하겠습니다.

 

PreviewRenderUtility

프리 뷰 유틸리티 클래스인 PreviewRenderUtility가 존재합니다. 이 클래스는 프리 뷰 전용의 카메라를 제공하고 있으며 간단하게 씬 내에 배경을 프리 뷰에 표시할 수 있습니다.

 

예를 들면 [해당 게임 객체를 카메라로 투영시키는 프리 뷰의 화면]을 제작할 수 있습니다.

그림 9.18 : 완성 그림, 프리 뷰 화면에 특정 위치의 게임 객체를 보는 것이 가능합니다.

먼저 OnEnable 함수에 PreviewRenderUtility의 인스턴스를 작성하여 LookAt의 대상의 게임 객체를 컴포넌트를 통해서 얻습니다.

using UnityEngine;
using UnityEditor;
 
[CustomEditor (typeof(PreviewExample))]
public class PreviewExampleInspector : Editor
{
    PreviewRenderUtility previewRenderUtility;
    GameObject previewObject;
 
    void OnEnable ()
    {
        //true로 설정하는 것으로 씬 내에 게임 객체를 화면에 그리는 것이 가능합니다.
        previewRenderUtility = new PreviewRenderUtility (true);
 
        //FieldOfView를 30. 이 값은 사용자의 화면에 따라서 설정하시면 됩니다.
        previewRenderUtility.m_CameraFieldOfView = 30f;
 
        //화면에 따라서 nearClipPlane 과 farClipPlane 를 설장하시면 됩니다.
        //필요없으면 주석처리를 하면 됩니다.
        previewRenderUtility.m_Camera.nearClipPlane = 0.3f;
        previewRenderUtility.m_Camera.farClipPlane = 1000;
 
        //컴포넌트를 통해서 게임 객체를 얻어옵니다.
        var component = (Component)target;
        previewObject = component.gameObject;
    }
}

이후 그리기 위해서 OnPreviewGUI를 오버라이드하여 구현합니다. 먼저 BeginPreview 와 EndAndDrawPreview 사이에서 Camera.Render를 호출합니다. 이렇게 하는 것으로 프리 뷰 화면에 [PreviewRenderUtility가 가진 카메라로부터 랜더링 결과]를 표시하는 것이 가능합니다.

public override void OnPreviewGUI (Rect r, GUIStyle background)
{
    previewRenderUtility.BeginPreview (r, background);
 
    var previewCamera = previewRenderUtility.m_Camera;
 
    previewCamera.transform.position =
        previewObject.transform.position + new Vector3 (02.5f, -5);
 
    previewCamera.transform.LookAt (previewObject.transform);
 
    previewCamera.Render ();
 
    previewRenderUtility.EndAndDrawPreview (r);
 
 
    //랜더링 타이밍이 빈번하지 않기 때문에
    //화면이 끊기는 현상(프레임 드랍)이 발생하면 Reapint를 호출해주세요.(과부하)
    //Repaint ();
}

이 것으로 그림 9.18과 같이 프리 뷰에 표시할 수 있습니다.

using UnityEngine;
using UnityEditor;
 
[CustomEditor (typeof(PreviewExample))]
public class PreviewExampleInspector : Editor
{
    PreviewRenderUtility previewRenderUtility;
    GameObject previewObject;
 
    void OnEnable ()
    {
        previewRenderUtility = new PreviewRenderUtility (true);
        previewRenderUtility.m_CameraFieldOfView = 30f;
 
        previewRenderUtility.m_Camera.farClipPlane = 1000;
        previewRenderUtility.m_Camera.nearClipPlane = 0.3f;
 
        var component = (Component)target;
        previewObject = component.gameObject;
    }
 
    void OnDisable ()
    {
        previewRenderUtility.Cleanup ();
        previewRenderUtility = null;
        previewObject = null;
    }
 
    public override bool HasPreviewGUI ()
    {
        return true;
    }
 
    public override void OnPreviewGUI (Rect r, GUIStyle background)
    {
        previewRenderUtility.BeginPreview (r, background);
 
        var previewCamera = previewRenderUtility.m_Camera;
 
        previewCamera.transform.position =
            previewObject.transform.position + new Vector3 (02.5f, -5);
 
        previewCamera.transform.LookAt (previewObject.transform);
 
        previewCamera.Render ();
 
        previewRenderUtility.EndAndDrawPreview (r);
 
    }
}

 

프리 뷰 용 객체를 작성하기

다음은 그림 9.19과 같이 마우스로 드래그를 할 수 있는 방법에 대해서 설명합니다.

그림 9.19 : 마우스로 드래그를 하면 큐브가 움직입니다.

프리 뷰의 게임 객체의 생성장소

프리 뷰에서 사용되는 게임 객체도 씬 안에서 생성됩니다.

 

다음 순서로 진행하는 것으로, 씬 내에 있는 프리 뷰 용 게임 객체를 프리 뷰 영역에 표시하는 것이 가능합니다.

1. Object.Instantiate로 프리 뷰 용 게임 객체를 생성합니다.

2. 프리 뷰 용 게임 객체에 Preview 전용의 레이어 [PreviewCullingLayer]를 설정합니다.

3. Camera.Render의 직전 후에 프리 뷰 용 객체를 활성화/비활성화합니다.

 

1. Object.Instantiate로 프리 뷰 용 게임 객체를 생성합니다.

컴포넌트로부터 게임 객체를 취득한 뒤, Instantiate로 복제합니다. 이 때 반드시 HideFlags.HideAndDontSave를 설정합니다. 이로 인해 게임 객체는 계층 뷰에 게임 객체를 표시하지 않고 씬에 보존하지 않을 수 있습니다.

 

마지막에 게임 객체를 비활성화하는 것으로 메시와 같이 씬 내에서 그려지는 요소를 표시하지 않도록 합니다.

GameObject previewObject;
 
void OnEnable ()
{
    var component = (Component)target;
    previewObject = Instantiate (component.gameObject);
    previewObject.hideFlags = HideFlags.HideAndDontSave;
    previewObject.SetActive (false);
}

 

2. 프리 뷰 용 게임 객체에 Preview 전용의 레이어 [PreviewCullingLayer]를 설정합니다.

프리 뷰 전용의 레이어인 Camera.PreviewCullingLayer가 제공됩니다. 하지만 접근 제한자가 public이 아닌 관계로 Reflection으로 접근할 필요가 있습니다.

var flags = BindingFlags.Static | BindingFlags.NonPublic;
var propInfo = typeof(Camera).GetProperty ("PreviewCullingLayer", flags);
int previewLayer = (int)propInfo.GetValue (nullnew object[0]);

이렇게 얻은 다음 previewLayer를 프리 뷰 용의 카메라와 게임 객체에 설정합니다.

previewRenderUtility = new PreviewRenderUtility (true);
 
//previewLayer만 표시합니다.
previewRenderUtility.m_Camera.cullingMask = 1 << previewLayer;

자식 아래에 모든 객체의 레이어를 previewLayer로 설정합니다.

previewObject.layer = previewLayer;
foreach (Transform transform in previewObject.transform) {
    transform.gameObject.layer = previewLayer;
}

 

3. Camera.Render의 직전 후에 프리 뷰 용 객체를 활성화/비활성화합니다.

Camera.Render를 실행하기 전 후에 게임 객체를 유효/무효로 합니다. 이렇게 함으로써 프리 뷰의 게임 객체는 프리 뷰 때에만 그려집니다.

 

혹시, 게임 재생 중에 프리 뷰를 표시할 경우 게임에 영향이 있는 컴포넌트는 무효 처리를 하던가 파기할 필요가 있습니다. 프리 뷰 화면에 표시되는 게임 객체는 씬의 게임 객체를 표시할 뿐이기에 게임 사이클의 아무런 영향이 없습니다.

public override void OnInteractivePreviewGUI (Rect r, GUIStyle background)
{
    previewRenderUtility.BeginPreview (r, background);
 
    previewObject.SetActive (true);
 
    previewRenderUtility.m_Camera.Render ();
 
    previewObject.SetActive (false);
 
    previewRenderUtility.EndAndDrawPreview (r);
}

드래그 하기

마우스로 드래그하여 프리 뷰의 게임 객체를 드래그합니다.

마우스 드래그 때 마우스의 위치의 얻을려면 Event.current.delta 를 통해서 얻을 수 있습니다. 여기서 delta는 타입 형이 Vector2입니다. 이렇게 얻은 위치를 transform.RotateAround를 사용하여 프리 뷰 용의 게임 객체를 회전할 수 있습니다.

 

하지만 이 경우 문제가 하나 발생합니다. transform.RotateAround로 회전할 경우 게임 객체의 중심 위치를 파악해둬야합니다.

 

중심 위치를 얻기

transform.position으로 취득할 때 반드시 게임 객체의 중심이라고 볼 수는 없습니다. 모델로 볼 때 발을 중심으로 얻는 경우가 있습니다. 중심 위치를 얻으려면 프리 뷰의 대상이 메시일 경우 Bounds를 얻은 다음, 이를 토대로 중심값을 직접 구해야합니다.

 

약간의 편법이지만 프리 뷰 대상의 게임 객체가 씬 내에 존재하는 것이면 간단하게 중심 위치를 얻을 수 있습니다. Pivot가 PivotMode.Center의 경우 게임 객체는 설정된 원점(발을 기준으로 한 중심)이 아니라 게임 객체의 전체의 중심 위치에 Pivot을 설정하도록 되어있습니다. 이를 통해서 툴에서 사용되는 각 요소(위치, 회전, 스케일과 같은 핸들링, 이하 툴계)의 표시 위치를 변화합니다. 이러한 구조를 사용하여 Tools.handlePosition으로 게임 객체의 중심 위치를 얻는 것이 가능합니다.

그림 9.20 : 왼쪽이 PivotMode.Pivot, 오른쪽이 PivotMode.Center

지금까지는 씬의 객체를 프리 뷰에 나타내기 위한 설명이었지만 이 방법을 사용하면 프리 팹을 프리 뷰에 나타내기 위한 처리가 잘 되지 않습니다.

프리 팹은 에셋으로써 씬에서 존재하는 것이 아니기에 툴계를 표시할 위치인 Tool.handlePosition은 사용할 수 없습니다. 프리 팹 이외에서는 이 방법을 사용할 수 있습니다만 많이 쓰이는 방식이 아니므로 이러한 방법도 있다는 것만 알아두면 좋습니다.

 

씬 내의 게임 객체, 그리고 프리 팹, 둘 다 같은 방식으로 처리하고 싶을 경우 중심 위치를 구하는 계산을 독자적으로 처리할 필요가 있습니다. 이 때 Mesh가 있다면 Bounds로부터 게산하여 중심 값을 구할 수 있습니다.

 

일반적으로 사용되는 Cube라면 게임 객체에 부착되어있는 Renderer 컴포넌트를 얻어서 Renderer.bounds.center로 중심 위치를 구할 수 있습니다.

 

다만 모델과 같은 복수의 Renderer를 가진 게임 객체라면 조금 고민할 필요가 있습니다. 어떤 Renderer 컴포넌트를 통해서 Bounds.center를 사용할 지 판단해야합니다.

그림 9.21 : 어느 쪽의 Renderer 컴포넌트를 사용하면 좋을지 곤란한 상황

일반적으로 보면 가장 큰 Bounds를 사용하면 큰 문제는 없기에 Bounds.Encapsulate를 사용합니다. 이 것은 인수를 하여 전달된 Bounds와 비교해서 최대 크기를 설정합니다.

Bounds bounds = new Bounds (component.transform.position, Vector3.zero);
 
//계층 밑의 Renderer 컴포넌트를 전부 얻기
foreach (var renderer in previewObject.GetComponentsInChildren<Renderer>()) {
        bounds.Encapsulate (renderer.bounds);
}
 
//가장 큰 Bounds 의 중심 위
var centerPosition = bounds.center;

bounds.Encapsulate를 사용하여 해당 객체의 최대 영역을 설정할 수 있습니다.

자세한 것은 BoundsEncapsulate를 참고해주세요.

 

객체를 회전시키기

중심 위치를 구했다면 Event.current.delta 와 transform.RotateAround를 조합하여 객체를 회전시킬 수 있습니다.

 

먼저 마우스의 이동량을 취득합니다. 이 것은 Event.current.type이 드래그를 나타내는 EventType.MouseDrag일 경우라면 Event.current.delta를 얻으면 됩니다.

public override void OnInteractivePreviewGUI (Rect r, GUIStyle background)
{
    var drag = Vector2.zero;
 
    if (Event.current.type == EventType.MouseDrag) {
        drag = Event.current.delta;
    }
    //...생략...
}

그리고 Bounds를 통해서 구한 중심 위치를 사용하여 X축과 Y축에 맞추어서 회전합니다.

private void RotatePreviewObject (Vector2 drag)
{
  previewObject.transform.RotateAround (centerPosition, Vector3.up, -drag.x);
  previewObject.transform.RotateAround (centerPosition, Vector3.right, -drag.y);
}

이 것만으로 마우스를 드래그했을 때 프리 뷰에서 객체가 회전합니다.

전체 소스코드는 다음과 같습니다.

using UnityEngine;
using UnityEditor;
using System.Reflection;
 
[CustomEditor(typeof(PreviewExample))]
public class PreviewExampleInspector : Editor
{
    PreviewRenderUtility previewRenderUtility;
    GameObject previewObject;
    Vector3 centerPosition;
 
    void OnEnable()
    {
        var flags = BindingFlags.Static | BindingFlags.NonPublic;
        var propInfo = typeof(Camera).GetProperty("PreviewCullingLayer", flags);
        int previewLayer = (int)propInfo.GetValue(nullnew object[0]);
 
        previewRenderUtility = new PreviewRenderUtility(true);
        previewRenderUtility.cameraFieldOfView = 30f;
        previewRenderUtility.camera.cullingMask = 1 << previewLayer;
 
        var component = (Component)target;
 
        previewObject = Instantiate(component.gameObject);
        previewObject.hideFlags = HideFlags.HideAndDontSave;
        previewObject.layer = previewLayer;
 
        foreach (Transform transform in previewObject.transform)
        {
            transform.gameObject.layer = previewLayer;
        }
        
        //초기 값의 Bounds를 작성
        Bounds bounds = new Bounds(component.transform.position, Vector3.zero);
        
        //모든 Renderer 컴포넌트를 취득
        foreach (var renderer in previewObject.GetComponentsInChildren<Renderer>())
        {
            //가장 큰 Bounds를 취득
            bounds.Encapsulate(renderer.bounds);
        }
        
        //프리 뷰 객체의 중심 위치를 대입
        centerPosition = bounds.center;
 
        previewObject.SetActive(false);
        
        //객체 각도를 초기화
        //설정한 값으로 전달하면 객체가 비스듬히 내려다보는 형태가 됩니다.
        RotatePreviewObject(new Vector2(-12020));
    }
 
    public override GUIContent GetPreviewTitle()
    {
        return new GUIContent(target.name + " Preview");
    }
 
    void OnDisable()
    {
        DestroyImmediate(previewObject);
 
        previewRenderUtility.Cleanup();
        previewRenderUtility = null;
    }
 
    public override bool HasPreviewGUI()
    {
        return true;
    }
 
    public override void OnInteractivePreviewGUI(Rect r, GUIStyle background)
    {
        previewRenderUtility.BeginPreview(r, background);
 
        var drag = Vector2.zero;
        
        //드래그 때 마우스의 이동량을 취득
        if (Event.current.type == EventType.MouseDrag)
        {
            drag = Event.current.delta;
        }
        
        //중심 위치로부터 일정 거리 떨어진 곳에서 설치
        previewRenderUtility.camera.transform.position =
                                        centerPosition + Vector3.forward * -5;
        
        //마우스의 이동량을 객체의 각도로 적용
        RotatePreviewObject(drag);
 
        previewObject.SetActive(true);
        previewRenderUtility.camera.Render();
        previewObject.SetActive(false);
 
        previewRenderUtility.EndAndDrawPreview(r);
        
        //드래그 시 화면을 다시 그립니다.
        //이를 적용하지 않으면 드래그 했을 때 값은 적용이 되지만 프리 뷰에서는 회전되지 않는 것처럼 보입니다.
        if (drag != Vector2.zero)
            Repaint();
    }
 
    private void RotatePreviewObject(Vector2 drag)
    {
        previewObject.transform.RotateAround(centerPosition, Vector3.up, -drag.x);
        previewObject.transform.RotateAround(centerPosition, Vector3.right, -drag.y);
    }
}

9.9 Unity가 지원하지 않는 에셋을 커스텀 마이즈하기

예를 들면 Zip 파일이나 Excel 파일을 Unity에서 사용하고자 할 경우가 있습니다. 하지만 이러한 파일들은 Unity가 지원하지 않으며, Unity로 접근할 수 없습니다.

 

Zip 혹은 Excel파일이 Unity에 들어왔을 때 Unity에서는 지원하지 않는 파일에 대해서 전부 DefaultAsset으로 처리합니다. 즉, DefaultAsset의 커스텀 에디터를 작성하면 지원하지 않는 파일도 다른 에셋과 같이 다룰 수 있는 것이 가능합니다.

 

이를 바탕으로 범용성을 생각한 CustomEditor 속성과 비슷한 CustomAsset 속성을 작성합니다. CustomAsset은 Unity에서 제공하지 않습니다. 즉 속성을 직접 제작하여 제공하도록 코드를 작성합니다.

[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class CustomAssetAttribute : Attribute
{
    public string[] extensions;
 
    public CustomAssetAttribute(params string[] extensions)
    {
        this.extensions = extensions;
    }
}

다음 코드는 CustomAsset 의 사용하는 방법입니다. 속성의 인수에 확장자를 건내는 것으로 대응할 에셋의 인스펙터를 커스텀 마이즈할 수 있습니다.

//Zip 파일
[CustomAsset(".zip")]
public class ZipInspector : Editor
{
    public override void OnInspectorGUI()
    {
        GUILayout.Label("예 : zip의 파일 구조를 프리 뷰로 계층형태로 표시해보기");
    }
}
 
//Excel 파일
[CustomAsset(".xlsx"".xlsm"".xls")]
public class ExcelInspector : Editor
{
    public override void OnInspectorGUI()
    {
        GUILayout.Button("예 : ScriptableObject로 변환할 버튼을 추가");
    }
}

그림 9.22: 엑셀의 아이콘은 자동적으로 해당 아이콘으로 처리해줍니다.

마지막으로 DefaultAsset에 대한 커스텀 에디터를 작성합니다. 모든 처리가 끝나면 그림 9.22처럼 표시됩니다.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using UnityEditor;
using UnityEngine;
 
[CustomEditor(typeof(DefaultAsset))]
public class DefaultAssetInspector : Editor
{
    private Editor editor;
    private static Type[] customAssetTypes;
 
    [InitializeOnLoadMethod]
    static void Init()
    {
        customAssetTypes = GetCustomAssetTypes();
    }
 
    /// <summary>
    /// CustomAsset 속성을 가진 클래스를 찾아서 취득.
    /// </summary>
    private static Type[] GetCustomAssetTypes()
    {
        //Library/ScriptAssemblies의 DLL의 전부를 가져옵니다.
        //테스트를 해보면서 사용자가 작성한 클래스는 Assembly-CSharp에 저장되어있는 것을 확인했습니다.
        //해당 코드는 원본 사이트를 따라가기 위하여 수정은 하지 않았습니다. 
        var assemblyPaths = Directory.GetFiles("Library/ScriptAssemblies""*.dll");//리턴 값이 string[]로써 파일의 경로 배열
        var types = new List<Type>();
        var customAssetTypes = new List<Type>();
 
        foreach (var assembly in assemblyPaths
            .Select(assemblyPath => Assembly.LoadFile(assemblyPath)))
        {
            //assembly의 GetTypes는 DLL 파일 내에 있는 모든 클래스를 가져오는 함수입니다.
            //즉 types는 DLL 파일 내에 있는 모든 클래스를 보관하는 리스트 변수라고 보시면 됩니다.
            types.AddRange(assembly.GetTypes());
        }
 
        foreach (var type in types)
        {
            //type(DLL 파일 내의 클래스들)로부터 앞서 작성한 CustomAssetAttribute들을 가져옵니다.
            var customAttributes =
                type.GetCustomAttributes(typeof(CustomAssetAttribute), false)
                                                      as CustomAssetAttribute[];
 
            //CustomAssetAttribute들이 존재하면 customAssetTypes 리스트에 보관합니다.
            if (0 < customAttributes.Length)
                customAssetTypes.Add(type);
        }
 
        //customAssetTypes를 배열로 변환하여 리턴합니다.
        return customAssetTypes.ToArray();
    }
 
    /// <summary>
    /// 확장자에 해당하는 CustomAsset 속성을 가진 클래스를 취득합니다.
    /// </summary>
    /// <param name="extension">확장자(예 : .zip)</param>
    private Type GetCustomAssetEditorType(string extension)
    {
        foreach (var type in customAssetTypes)
        {
            var customAttributes =
              type.GetCustomAttributes(typeof(CustomAssetAttribute), false)
                                                      as CustomAssetAttribute[];
 
            foreach (var customAttribute in customAttributes)
            {
                if (customAttribute.extensions.Contains(extension))
                    return type;
            }
        }
 
        return typeof(DefaultAsset);
    }
 
    private void OnEnable()
    {
        var assetPath = AssetDatabase.GetAssetPath(target);
 
        var extension = Path.GetExtension(assetPath);
        var customAssetEditorType = GetCustomAssetEditorType(extension);
        editor = CreateEditor(target, customAssetEditorType);
    }
 
    public override void OnInspectorGUI()
    {
        if (editor != null)
        {
            GUI.enabled = true;
            editor.OnInspectorGUI();
        }
    }
 
    public override bool HasPreviewGUI()
    {
        return editor != null ? editor.HasPreviewGUI() : base.HasPreviewGUI();
    }
 
    public override void OnPreviewGUI(Rect r, GUIStyle background)
    {
        if (editor != null)
            editor.OnPreviewGUI(r, background);
    }
 
    public override void OnPreviewSettings()
    {
        if (editor != null)
            editor.OnPreviewSettings();
    }
 
    public override string GetInfoString()
    {
        return editor != null ? editor.GetInfoString() : base.GetInfoString();
    }
 
    //이하, 임의로 다루고 싶은 Editor 클래스의 확장을 진행하면 됩니다.
}

이것으로 지원하지 않는 에셋의 커스텀 에디터를 작성할 수 있게 되었습니다. 다만 기반이 되는 DefaultAsset을 커스텀 마이즈를 하고 있기에 다른 에셋으로 DefaultAsset을 확장할 경우 동작하지 않는 것에 대해 주의해주세요.

'유니티 > (Old)에디터 확장' 카테고리의 다른 글

제8장 MenuItem  (0) 2019.08.13
제7장 EditorWindow  (0) 2019.08.11
제 6장 EditorGUI (EditorGUILayout)  (0) 2019.07.28
제 5장 SerializedObject에 대해서  (0) 2019.07.28
제4장 ScriptableObject  (0) 2019.07.28