제4장 ScriptableObject

2019. 7. 28. 15:36유니티/(Old)에디터 확장

 

Unity エディター拡張入門

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

anchan828.github.io

4.1 ScriptableObject 란

ScriptableObject는 독자적인 에셋을 작성하기 위한 기능입니다. 혹은 Unity의 Serializable 구조가 다루는 형식이라고 할 수도 있습니다.

Unity에서 독자적인 Serialize 구조를 가지고 있으며, 모든 객체 (UnityEngine.Object)는 이 Serialize 구조를 통해 데이터를 Serialize/Deserialize을 처리할 수 있으며, 필드와 Unity 에디터 간 주고 받는 것이 가능합니다. Serialize에 대해서는 제 5장 SerializedObject에 대해서]를 참고해주세요.

Unity 내부의 에셋(재질, 애니메이션 등)은 모두 UnityEngine.Object 의 파생입니다. 독자적인 에셋을 작성하기 위해서는 UnityEngine.Object의 파생 클래스를 작성하면 되지만 사용자의 입장에서 UnityEngine.Object의 파생 클래스를 작성하는 것은 금지되어있습니다. 사용자의 입장에서 Unity의 Serialize를 이용하거나 독자적인 에셋을 작성하려면 ScriptableObject를 이용해야합니다.

4.2 ScriptableObject는 Unity 에디터의 핵심 중 하나입니다.

ScriptableObject는 Unity 에디터가 미치는 거의 대부분에서 사용됩니다. 예를 들면 씬 뷰, 게임 뷰와 같은 에디터 윈도우는 ScriptableObject의 파생 클래스로부터 생성됩니다. 혹은 인스팩터의 GUI를 표시하기 위해서 Editor 객체도 ScriptableObject의 파생 클래스로부터 생성됩니다. Unity 에디터는 ScriptableObject로 작성되어 있다고 봐도 됩니다.

그림4.1: 에셋 브라우저로 본 ScriptableObject 가 계승되고 있는 것을 알 수 있습니다.

 

위 이미지는 Visual Studio에서 클래스 명을 범위로 지정한 다음 F12를 누른 것과 비슷합니다.

4.3 ScriptableObject는 Unity 에디터의 핵심 중 하나입니다.

ScriptableObject를 작성하려면 일단 ScriptableObject 클래스를 계승한 클래스를 작성할 필요가 있습니다. 이 때 클래스 이름과 에셋 이름은 반드시 같아야합니다. MonoBehaviour와 같은 제한입니다.

using UnityEngine;
 
public class ExampleAsset : ScriptableObject
{
}

객체화

ScriptableObject를 생성하려면 ScriptableObject.CreateInstance로 생성합니다. new를 사용하여 객체화를 하면 안됩니다. MonoBehaviour과 같이 Unity의 Serialize 구문을 경유해서 작성할 필요가 있기 때문입니다.

using UnityEngine;
using UnityEditor;
 
public class ExampleAsset : ScriptableObject
{
    [MenuItem("Example/Create ExampleAsset Instance")]
    static void CreateExampleAssetInstance()
    {
        var exampleAsset = CreateInstance<ExampleAsset>();
    }
}

에셋으로 보존

다음은 객체화한 객체를 에셋으로 보존합니다. 에셋의 작성은 AssetDatabase.CreateAsset을 사용합니다.

에셋의 확장자는 반드시 .asset으로 설정해야합니다. 다른 확장자로 지정하면 Unity가 ScriptableObject으로 파생된 클래스로 인지하지 못합니다

[MenuItem("Example/Create ExampleAsset")]
static void CreateExampleAsset()
{
    var exampleAsset = CreateInstance<ExampleAsset>();
 
    AssetDatabase.CreateAsset(exampleAsset, "Assets/Editor/ExampleAsset.asset");
    AssetDatabase.Refresh();
}

혹은 CreateAssetMenu 속성을 사용을 사용하는 것으로 간단하게 에셋을 작성할 수 있습니다.

using UnityEngine;
using UnityEditor;
 
[CreateAssetMenu = "Example/Create ExampleAsset Instance")]
public class ExampleAsset : ScriptableObject
{
}

CreateAssetMenu를 사용한 경우 「Assets/Create」 의 하위 메뉴가 작성됩니다.

 

스크립트에서 에셋의 ScriptableObject를 불러오기

읽어오는 방법도 AssetDatabase.LoadAssetAtPath를 사용합니다.

[MenuItem("Example/Load ExampleAsset")]
static void LoadExampleAsset()
{
    var exampleAsset =
    AssetDatabase.LoadAssetAtPath<ExampleAsset>
                               ("Assets/Editor/ExampleAsset.asset");
}

인스펙터에 프로퍼티를 표시하기

MonoBehaviour과 같이 필드에 SerializeField를 선언하면 표시됩니다. 그리고 PropertyDrawer를 적용합니다.

스크립트에 Range를 선언했는데 이 Range는 표준 PropertyDrawer 중 하나입니다.

자세한 것은 [제 10장 PropertyDrawer를 참고해주세요.]

using UnityEngine;
using UnityEditor;
 
public class ExampleAsset : ScriptableObject
{
    [SerializeField]
    string str;
 
    [SerializeField, Range(010)]
    int number;
 
    [MenuItem("Example/Create ExampleAsset Instance")]
    static void CreateExampleAssetInstance()
    {
        var exampleAsset = CreateInstance<ExampleAsset>();
 
        AssetDatabase.CreateAsset(exampleAsset, "Assets/Editor/ExampleAsset.asset");
        AssetDatabase.Refresh();
    }
}

4.4 ScriptableObject의 부모자식 관계

먼저 「부모의 ScriptableObject」 와 이 부모 클래스가 변수로써 가지고 있는 「자식의 ScriptableObject」를 머리속으로 그려주세요.

밑에 기술된 코드는 이 이미지화한 것을 코드로 풀어보았습니다.

부모의 ScriptableObject

using UnityEngine;
 
public class ParentScriptableObject : ScriptableObject
{
    [SerializeField]
    ChildScriptableObject child;
}

자식의 ScriptableObject

using UnityEngine;
 
public class ChildScriptableObject : ScriptableObject
{
    //아무 것도 없으면 허전해보여서 변수를 추가.
    //(-_-...번역대로 그대로 옮기긴 했습니다만 사용되지 않는 변수입니다.)
    [SerializeField]
    string str;
 
    public ChildScriptableObject()
    {
        //초기 객체 명을 설정합니다.
        name = "New ChildScriptableObject";
    }
}

다음에는 ParentScriptableObject를 에셋으로 보존합니다. 변수인 ChildScriptableObject도 인스턴스화합니다.

부모의 ScriptableObject

원래 사이트에서는 [자식의 ScriptableObject]로 되어있지만 스크립트 내용이 [부모의 ScriptableObject]로 되어있어서 타이틀을 수정했습니다.

using UnityEngine;
using UnityEditor;
 
public class ParentScriptableObject : ScriptableObject
{
    const string PATH = "Assets/Editor/New ParentScriptableObject.asset";
 
    [SerializeField]
    ChildScriptableObject child;
 
    [MenuItem("Assets/Create ScriptableObject")]
    static void CreateScriptableObject()
    {
        //부모를 인스턴스화
        var parent = ScriptableObject.CreateInstance<ParentScriptableObject>();
 
        //자식을 인스턴스화
        parent.child = ScriptableObject.CreateInstance<ChildScriptableObject>();
 
        //부모를 에셋으로 보존
        AssetDatabase.CreateAsset(parent, PATH);
 
        //임포트 처리해서 최신 상태로 합니다.
        AssetDatabase.ImportAsset(PATH);
    }
}

ParentScriptableObject를 에셋으로 보존한 후, 인스펙터를 확인해보면 그림 4.2와 같이 child 프로퍼티가 Type mismatch으로 되어있습니다.

그림 4.2: child 프로퍼터가 Type mismatch로 되어있습니다.

시험삼아 Type mismatch 부분을 더블클릭을 해보면 ChildScriptableObject의 정보가 인스펙터에 표시되어 문제없이 제대로 동작합니다.

그림 4.3: ChildScriptableObject의 프로퍼티를 조작하여 갱신합니다.

P.S Unity2017버전에서는 ChildScriptableObject의 스크립트에서 생성자에서 name을 호출할 수 없다는 에러가 발생합니다.

UnityEngine.Object를 에셋으로 사용하려면 디스크에 보존해야합니다.

Type mismatch 상태의 child를 가진 ParentScriptableObject를 작성한 뒤 유니티를 재기동해봅니다. 다시 ParentScriptableObject의 인스펙터를 확인해보면 child 의 링크가 None(null)로 되어있습니다.

그림 4.4 : 에셋으로 만들기 전에 child의 인스펙터를 작성하니 null이 됩니다.

이러한 원인을 해결하려면 ScriptableObject의 최상위 클래스인 UnityEngine.Object를 Serialize로 다룰려면 디스크 상에 에셋을 보존해야합니다. Type mismatch 상태는 인스펙터에 존재하지만 디스크 상에 에셋이 존재하지 않는 것을 의미합니다. 즉 이 인스펙터가 어떤 상황(유니티 재기동 같은)에 의해 파기되면 데이터에 접근할 수 없습니다.

ScriptableObject는 전부 에셋으로 보존합니다.

Type mismatch의 상황을 해결하려면 ScriptableObject를 전부 에셋으로 보존해서 Serialize 가능한 필드에 연결시켜주면 됩니다.

그림 4.5: 텍스처 같은 에셋을 드래그 & 드랍으로 연결시켜주면 됩니다.

단 지금처럼 부모와 자식 관계인 상태인데 각 자 독립된 에셋을 작성하면 관리측면에서 불편합니다. 자식의 수가 증가하면 자식의 수만큼 별도로 관리해야됩니다.

이를 해결하기 위하여 [서브 에셋] 기능을 사용하여 부모 자식관계로 에셋을 하나로 정리하는 것을 해보겠습니다

서브 에셋

부모가 되는 메인 에셋에 에셋의 정보를 설정하는 것으로 UnityEngine.Object가 서브 에셋을 사용할 수 있습니다. 이 서브 에셋의 열겨하는 것으로 가장 알기 쉬운 방법은 모달 에셋입니다.

모달 에셋을 사용하는 에셋 중에서 Mesh와 애니메이션과 같은 에셋이 있습니다. 이 에셋들은 보통 독립한 에셋으로 존재해야하지만 서브 에셋으로 취급하는 것으로 Mesh와 애니메이션 클립의 에셋을 메인 에셋의 정보 안에 포괄해서 디스크 상에 보존하지 않고 사용하는 것이 가능합니다. 즉 부모 에셋은 정보를 저장하고 서브 에셋은 저장하지 않고 부모 에셋이 가진 정보를 바탕으로 서브 에셋을 생성하는 방식합니다.

그림 4.6: 모달 에셋 중에 Mesh와 아바타, 애니메이션 등이 포함되어있습니다.

ScriptableObject도 서브 에셋의 기능을 사용하는 것으로, 디스크 상에 불필요한 에셋을 추가할 필요없이 부모 자식 관계의 ScriptableObject를 구축하는 것이 가능합니다.

AssetDatabase.AddObjectToAsset

UnityEngine.Object를 서브 에셋으로 등록하려면, 메인이 되는 에셋의 객체를 추가합니다.

부모 ScriptableObject

using UnityEngine;
using UnityEditor;
 
public class ParentScriptableObject : ScriptableObject
{
    const string PATH = "Assets/Editor/New ParentScriptableObject.asset";
 
    [SerializeField]
    ChildScriptableObject child;
 
    [MenuItem("Assets/Create ScriptableObject")]
    static void CreateScriptableObject()
    {
        //부모를 객체화
        var parent = ScriptableObject.CreateInstance<ParentScriptableObject>();
 
        //자식을 객체화
        parent.child = ScriptableObject.CreateInstance<ChildScriptableObject>();
 
        //부모에 child 객체를 추가
        AssetDatabase.AddObjectToAsset(parent.child, PATH);
 
        //부모를 에셋으로 보존
        AssetDatabase.CreateAsset(parent, PATH);
 
        //임포트 처리를 해서 최신 상태로 합니다.
        AssetDatabase.ImportAsset(PATH);
    }
}

밑의 그림 4.7을 보시면, 부모인 ParentScriptableObject가 2개가 있습니다만 실질적인 데이터를 가진 에셋은 계층적으로 보면 서브 에셋의 ParentScriptableObject로 되어있는 특수한 계층구조로 되어있습니다.

그림 4.7: 메인 에셋에 서브 에셋을 추가한 상태

이 상태는 사용자가 (서브 에셋을 작성한 것으로 인해) 특수한 에셋을 작성한 것이라고 Unity가 판단하여 메인 에셋을 아무 것도 해당하지 않는 DefautAsset으로 표시합니다.

메인 에셋으로 다루기 위한 에셋이 서브 에셋과 같은 위치에 이동해버렸으니 보기에 좋지 않습니다. 이런 식으로 사용자가 직접 서브 에셋을 작성할 수 있지만 이 것을 모달과 같이 최대한 활용하는 것이 아닙니다.

HideFlags.HideInHierarchy로 서브 에셋을 숨기기

서브 에셋 자체를 숨기는 것으로, 메인 에셋만 존재하는 것으로 외간을 작성하는 것이 가능합니다.

using UnityEngine;
using UnityEditor;
 
public class ParentScriptableObject : ScriptableObject
{
    const string PATH = "Assets/Editor/New ParentScriptableObject.asset";
 
    [SerializeField]
    ChildScriptableObject child;
 
    [MenuItem("Assets/Create ScriptableObject")]
    static void CreateScriptableObject()
    {
        var parent = ScriptableObject.CreateInstance<ParentScriptableObject>();
        parent.child = ScriptableObject.CreateInstance<ChildScriptableObject>();
 
        //서브 에셋이 되는 child를 표시하지 않습니다.
        parent.child.hideFlags = HideFlags.HideInHierarchy;
 
        AssetDatabase.AddObjectToAsset(parent.child, PATH);
        AssetDatabase.CreateAsset(parent, PATH);
        AssetDatabase.ImportAsset(PATH);
    }
}

이처럼 계층 표시는 없어졌지만 2개의 에셋을 1개로 정리해서 사용할 수 있게 되었습니다.

그림 4.8: ParentScriptableObjec만 표시되어있지만 서브 에셋의 ChildScriptableObject의 참조를 바르게 처리하고 있습니다.

이 서브 에셋을 숨기는 방법은 유니티에서 제공하는 AnimatorController 역시 사용되고 있습니다. 시험삼아 확인해보겠습니다.

[MenuItem("Assets/Set to HideFlags.None")]
static void SetHideFlags()
{
    //AnimatorController를 선택한 상태로 메뉴를 실행
    var path = AssetDatabase.GetAssetPath(Selection.activeObject);
 
    //서브 에셋을 포함해서 전 아이템을 획득
    foreach (var item in AssetDatabase.LoadAllAssetsAtPath(path))
        //플래그를 전부 None으로 해서 비표시 설정을 해제
        item.hideFlags = HideFlags.None;
    }
 
    //다시 임포트해서 최신 상태로 합니다.
    AssetDatabase.ImportAsset(path);
}

위의 코드를 실행하면 HideFlags가 해제되서 서브 에셋이 표시됩니다.

그림 4.9 : Layer, BrendTree와 같은 서브 에셋들이 추가되어 나타납니다.

메인 에셋으로부터 서브 에셋을 제거

서브 에셋을 제거하는 방법은 Object.DestroyImmediate를 사용하는 것으로 서브 에셋을 제거할 수 있습니다.

[MenuItem("Assets/Remove ChildScriptableObject")]
static void Remove()
{
    var parent = AssetDatabase.LoadAssetAtPath<ParentScriptableObject>(PATH);
 
    //에섯의 CarentScriptableObject를 파기
    Object.DestroyImmediate(parent.child, true);
 
    //파기하면 Missing 상태가 되기 때문에 null을 대입
    parent.child = null;
 
    //다시 임포트해서 최신상태로 갱신
    AssetDatabase.ImportAsset(PATH);
}

4.5 아이콘 변경

디폴트 아이콘은 다음과 같습니다. 이 디폴트 아이콘을 변경하는 방법이 있습니다.

 

스크립트에 아이콘을 설정

스크립트 에셋을 선택해서 아이콘을 선택하면 아이콘을 변경을 할 수 있는 패널이 표시됩니다. 여기서 「Other」 버튼을 클릭해서 변경하고 싶은 아이콘을 터치해서 선택하면 됩니다. 변경이 되지 않을 경우 컴파일 에러가 발생했는지 확인을 해보세요.

그림 4.10 : 스크립트와 ScriptableObject의 아이콘을 설정합니다.

Gizmos에 아이콘을 배치

다른 방법으로 아이콘을 변경하는 방법이 있습니다. Gizmos 폴더에 [클래스 명]Icon 과 같은 이름으로 아이콘 이미지를 두는 것으로 변경할 수 있습니다. Gizmos 폴더가 Assets 폴더 바로 밑에 두는 것 떄문에 사용함에 불편함이 있을지도 모르겠지만 같은 아이콘 이미지가 3개가 나열될 필요가 없다는 점에서 이 방법도 기억해두면 편리합니다.