개발 조각글

Unity - Scriptable Object

BaekNohing 2022. 7. 24. 01:35

a cute cat snail

Unity - Scriptable Object 요약
ScriptableObject는 유니티 엔진이 쉽게 읽고 쓸 수 있는 직렬화 방식이다. 이를 통해 에셋단계에서 바로 읽고 쓸 수 있으므로 씬에서 인스턴스를 생성하지 않아도 된다. (= 메모리를 아낄 수 있다)
Scriptable Object도 static instance를 통해 싱글톤처럼 사용할 수 있다. 
단 ScriptableObject는 유니티에 특화되어 있으므로 유니티 외부로 보낼때는 주의가 필요하다. 또한 상태를 영구적으로 저장할 수 없다 (앱 종료 시 변화량이 휘발된다)

유니티 도큐먼트에 따르면 ScriptableObject는 다음과 같다. 

ScriptableObject는 클래스 인스턴스와는 별도로 대량의 데이터를 저장하는 데 사용할 수 있는 데이터 컨테이너입니다. ScriptableObject의 주요 사용 사례 중 하나는 값의 사본이 생성되는 것을 방지하여 프로젝트의 메모리 사용을 줄이는 것입니다. 이는 연결된 MonoBehaviour 스크립트에 변경되지 않는 데이터를 저장하는 프리팹이 있는 프로젝트의 경우 유용합니다.
이러한 프리팹을 인스턴스화할 때마다 해당 데이터의 자체 사본이 생성됩니다. 이러한 방법을 사용하여 중복 데이터를 저장하는 대신 ScriptableObject를 이용하여 데이터를 저장한 후 모든 프리팹의 레퍼런스를 통해 액세스할 수 있습니다. 즉, 메모리에 데이터 사본을 하나만 저장합니다.
MonoBehaviour와 마찬가지로 ScriptableObject는 기본 Unity 오브젝트에서 파생되나, MonoBehaviour와는 달리 게임 오브젝트에 ScriptableObject를 연결할 수 없으며 대신 프로젝트의 에셋으로 저장해야 합니다.
- [출처 : https://docs.unity3d.com/kr/2021.1/Manual/class-ScriptableObject.html]

즉 우리가, player의 상태를 관리하기 위해 playerInfo 클래스를 만들었다면, 해당 클래스를 사용하기 위해서는 게임 씬에다 인스턴스로 메모리에다 할당해야 하는 것과 달리. Scriptable Object는 프로젝트에 에셋의 형태로 저장되어있기 때문에, Scriptable Object의 값을 사용할때는 복제를 위한 메모리 할당이 일어나지 않는다. 

내가 이해한 것이 맞다면, 가령 아래와 같은 클래스가 있다고 하자. 

public class Test : Monobehavior{
	public int textInt = 10;
}

씬에서 이 클래스의 값을 불러와서 사용하기 위해서는, 

Test test = new Test();

로 초기화해서 사용하거나, 씬 내부의 오브젝트에 해당 클래스를 붙인 뒤에 GetComponent<Text>()로 가져와야 한다. 그리고 이 과정에서 클래스의 인스턴스를 생성하기 위해 힙 영역에 클래스 메모리가 할당되고 저 test 자리에는 해당 클래스의 포인터가 들어가게 된다. 

즉, 상단의 클래스는 설계도이며 우리가 실제로 사용하는 대상이 아니기 때문에, 추가적인 메모리가 필요하게 되는 것이다. (즉 에셋 내 testInt, 힙 메모리의 testInt가 각각 존재하기 때문에 총 8Byte를 차지하게 된다.) 

ScriptableObject는 직렬화의 한 방식으로써, 유니티 엔진이 읽고 쓸 수 있는 형태로 클래스 데이터를 직렬화하여 에셋에 저장해 둘 수 있는 형태라고 이해했다. 즉 엔진이 접근해서 바로 읽고 쓸 수 있는 형태이기 때문에, 값을 메모리에 복사해서 역직렬화 할 필요가 없는 것이다. (따라서 에셋 영역에 testInt 하나만 있으면 되므로 4Byte만 차지하게 된다)


단 ScriptableObject를 사용할때는 다음과 같은 주의가 필요하다. 

https://forum.unity.com/threads/scriptableobject-size.602707/ 해당 포럼의 내용에 따르면, ScriptableObject의 저장 형식은, 값을 저장할 때 [Plain Text]형태로 저장되게 된다고 한다. 즉 GUID의 경우 보통 16byte를 차지하지만 일반 텍스트로 변환되면서 각각의 자릿수가 2byte짜리 char형식으로 변환되기 때문에, 총 32byte를 차지하게 된다. (= 메모리 손해가 발생할 수 있다) 

또한, 빌드 시 에셋 형태로 디스크에 보관되기 때문에 실 플레이에서 오브젝트 내 값을 변경하고 종료 시 변경된 데이터가 휘발된다. 그러므로 저장해놓아야 하는 데이터가 필요하다면 ScriptableObject > 저장하는 절차가 하나 더 필요하다. 


ScriptableObject 예제 ↓

더보기
Enemy의 상태를 관리하기 위해 생성한 ScriptableObject, DataManager에서 값을 불러온 뒤 홀드한다.
using System;
using System.Collections;
using System.Collections.Generic;

using Consts;

using UnityEngine;

[CreateAssetMenu(fileName = "EnemyObject", menuName = "SCObjects/Enemy")]
public class EnemyObject : ScriptableSingleTone<EnemyObject>
{
    [SerializeField]
    Dictionary<string, GameObject> enemies = new Dictionary<string, GameObject>();

    [SerializeField]
    GameObject returned_enemies;

    public GameObject GetPrefab(int code) => GetPrefab(code.ToString());
    public GameObject GetPrefab(string code, string stage = "Debug")
    {
        if (!enemies.ContainsKey(code))
            SetPrefab(stage);
        if (enemies.ContainsKey(code))
            return (returned_enemies = enemies[code]);
        else
            return Utility.LogAndNull($"Enemyenemies not Contain code : {code}") as GameObject;
    }

    GameObject SetPrefab(string stage)
    {
        enemies.Clear();

        List<GameObject> loadedEnemies = DataManager.instance.PreFabLoadAll<GameObject>
            (DataManager.ResourceCategories.GameObject, $"Enemies/{stage}");
        if (loadedEnemies == null) return Utility.LogAndNull("EnemyLoad : Fail") as GameObject;
        foreach (GameObject prefab in loadedEnemies)
            enemies.Add(prefab.name, prefab);
        return Utility.LogAndNull("EnemyLoad : Success") as GameObject;
    }

    [Serializable]
    public struct EnemyStateValue 
    {
        public int code;
        public float delay_min;
        public float delay_max;
        public float speed_min;
        public float speed_max;
        public string nextStateName;

        public EnemyStateValue(int code, float delay_min, float delay_max, float speed_min, float speed_max, string nextStateName)
        {
            this.code = code;
            this.delay_min = delay_min;
            this.delay_max = delay_max;
            this.speed_min = speed_min;
            this.speed_max = speed_max;
            this.nextStateName = nextStateName;
        }
    }

    Dictionary<int, Dictionary<string, EnemyStateValue>> stateDictionary;
    [SerializeField]
    EnemyStateValue returnedStateValue;

    public EnemyStateValue GetStateValue(string code, string stage = "Debug", string stateName = "Idle")
        => GetStateValue(int.Parse(code), stage, stateName);

    public EnemyStateValue GetStateValue(int code, string stage = "Debug", string stateName = "Idle")
    {
        if (stateDictionary == null) stateDictionary = SetEnemyDictionary();
        return (returnedStateValue = stateDictionary[code][stateName]);
    }

    Dictionary<int, Dictionary<string, EnemyStateValue>> SetEnemyDictionary()
    {
        Dictionary<int, Dictionary<string, EnemyStateValue>> result = new Dictionary<int, Dictionary<string, EnemyStateValue>>();
        var loadData = DataManager.instance.EnemyLoadAll();
        foreach (Dictionary<string, object> keyPair in loadData)
            if (keyPair.ContainsKey("EnemyCode"))
            {
                int keyCode = (int)keyPair["EnemyCode"];
                Dictionary<string, EnemyStateValue> info = new Dictionary<string, EnemyStateValue>();
                for (int i = 0; i <= 6; i++)
                    if (keyPair.ContainsKey($"state_{i}") && ((string)keyPair[$"state_{i}"]).Length > 10)
                    {
                        var parisedPair = SetStateKayPair(keyCode, (string)keyPair[$"state_{i}"]);
                        info.Add(parisedPair.Key, parisedPair.Value);
                    }
                result.Add((int)keyPair["EnemyCode"], info);
            }
        return result;
    }

    KeyValuePair<string, EnemyStateValue> SetStateKayPair(int code, string str)
    {
        KeyValuePair<string, EnemyStateValue> resultPair;

        EnemyStateValue result = new EnemyStateValue();
        var lines = str.Split(':');
        result.code = code;
        result.delay_min = float.Parse(lines[1]);
        result.delay_max = float.Parse(lines[2]);
        result.speed_min = float.Parse(lines[3]);
        result.speed_max = float.Parse(lines[4]);
        result.nextStateName = lines[5];
        resultPair = new KeyValuePair<string, EnemyStateValue> (lines[0], result);
        return resultPair;
    }
}

+ 아래처럼 static instance를 통해 ScriptableObject도 싱글톤으로 만들어서 전역으로 접근할 수 있다.

public class ScriptableSingleTone<T> : ScriptableObject where T : ScriptableObject
{
    static T instance;
    public static T Get()
    {
        if (!instance)
            instance = Resources.Load("SCObjects/" + typeof(T).Name) as T;
        return instance;
    }
}