Unity의 직렬화

이 게시물에는 유니티의 직렬화 시스템에 대한 개요와 그 배경이 되는 기술을 더 많이 공유하고자 하는 취지에서 유니티의 직렬화 시스템에 대한 개요가 포함되어 있습니다. 이 시스템을 잘 이해하면 개발의 효율성과 제작물의 성과에 큰 영향을 미칠 수 있습니다. 여기 있습니다.
'사물'의 시리얼라이제이션은 Unity의 핵심입니다. 많은 기능이 직렬화 시스템 위에 구축되어 있습니다:
- 스크립트에 저장된 데이터 저장. 대부분의 사람들이 어느 정도 익숙하게 알고 있을 것입니다.
- 인스펙터 창. 인스펙터 창은 검사 대상의 속성 값이 무엇인지 파악하기 위해 C# API와 대화하지 않습니다. 객체에 직렬화하도록 요청한 다음 직렬화된 데이터를 표시합니다.
- 프리팹. 내부적으로 프리팹은 하나 이상의 게임 오브젝트와 컴포넌트의 직렬화된 데이터 스트림입니다. 프리팹 인스턴스는 이 인스턴스의 직렬화된 데이터에 대해 수정해야 하는 수정 사항의 목록입니다. 개념 프리팹은 실제로 편집자 시점에만 존재합니다. Unity가 빌드를 생성할 때 프리팹 수정 사항은 일반 직렬화 스트림에 구워지고, 인스턴스화되면 인스턴스화된 게임 오브젝트는 에디터에 있을 때는 프리팹이었다는 사실을 전혀 알지 못합니다.
- 인스턴스화. 프리팹이나 씬에 존재하는 게임 오브젝트 또는 그 밖의 모든 것(UnityEngine.Object에서 파생되는 모든 것은 직렬화 가능)에서 Instantiate()를 호출하면 오브젝트를 직렬화한 다음 새 오브젝트를 생성하고 데이터를 새 오브젝트로 '역직렬화'합니다. (그런 다음 다른 변형에서 동일한 직렬화 코드를 다시 실행하여 어떤 다른 UnityEngine.Object가 참조되고 있는지 보고합니다. 그런 다음 인스턴스화() 중인 데이터의 일부인지 참조된 모든 UnityEngine.Object를 확인합니다. 참조가 텍스처와 같이 "외부"를 가리키는 경우 해당 참조를 그대로 유지하고, 자식 게임 오브젝트와 같이 "내부"를 가리키는 경우 해당 복사본에 참조를 패치합니다.
- 저장. 텍스트 에디터로 .unity 씬 파일을 열고 유니티에서 "텍스트 직렬화 강제"를 설정한 경우, yaml 백엔드로 직렬화기를 실행합니다.
- 로드 중입니다. 놀랍지 않을 수도 있지만, 이전 버전과의 호환 로딩은 직렬화를 기반으로 구축된 시스템이기도 합니다. 에디터 내 yaml 로딩은 직렬화 시스템과 씬 및 에셋의 런타임 로딩을 사용합니다. 에셋 번들은 직렬화 시스템도 활용합니다.
- 에디터 코드 핫 리로드. 에디터 스크립트를 변경하면 모든 에디터 창을 직렬화한 다음(UnityEngine.Object에서 파생됩니다!) 모든 창을 파괴하고, 이전 c# 코드를 언로드하고, 새 c# 코드를 로드하고, 창을 다시 생성한 다음 마지막으로 창의 데이터 스트림을 다시 새 창으로 역직렬화합니다.
- Resource.GarbageCollectSharedAssets(). 이것은 네이티브 가비지 컬렉터이며 C# 가비지 컬렉터와는 다릅니다. 씬을 로드한 후 실행하여 이전 씬에서 더 이상 참조되지 않는 것을 파악하여 언로드할 수 있도록 하는 기능입니다. 네이티브 가비지 컬렉터는 직렬화기를 실행하여 오브젝트가 외부 UnityEngine.Objects에 대한 모든 참조를 보고하도록 하는 모드에서 직렬화기를 실행합니다. 장면1에서 사용하던 텍스처가 장면2를 로드할 때 언로드되는 이유입니다.
직렬화 시스템은 C++로 작성되었으며, 모든 내부 오브젝트 유형(텍스처, 애니메이션 클립, 카메라 등)에 사용합니다. 직렬화는 UnityEngine.Object 레벨에서 이루어지며, 각 UnityEngine.Object는 항상 전체적으로 직렬화됩니다. 다른 UnityEngine.오브젝트에 대한 레퍼런스를 포함할 수 있으며 이러한 레퍼런스는 제대로 직렬화됩니다.
이제 이 모든 것이 크게 신경 쓰이지 않고 그저 잘 작동한다는 사실에 만족하며 실제로 콘텐츠를 제작하고 싶다고 말할 수 있습니다. 그러나 스크립트로 지원되는 MonoBehaviour 컴포넌트를 직렬화할 때 동일한 직렬화기를 사용하기 때문에 이 점이 걱정될 수 있습니다. 직렬화에는 매우 높은 성능 요구 사항이 있기 때문에 모든 경우에 C# 개발자가 직렬화에서 기대하는 것과 똑같이 동작하는 것은 아닙니다. 여기에서는 직렬화기의 작동 방식과 직렬화기를 최대한 활용하는 방법에 대한 몇 가지 모범 사례를 설명합니다.
내 스크립트를 연재하려면 어떤 필드가 있어야 하나요?
- 공개 또는 [SerializeField] 속성이 있어야 합니다.
- 정적이지 않기
- 구성되지 않음
- 읽기 전용이 아님
- 필드 유형은 직렬화할 수 있는 유형이어야 합니다.
어떤 필드 유형을 직렬화할 수 있나요?
- 직렬화 가능] 속성을 가진 사용자 정의 비추상 클래스.
- 직렬화 가능] 속성이 있는 커스텀 구조체. (Unity4.5에 추가됨)
- UntiyEngine.Object 에서 파생된 오브젝트에 대한 레퍼런스
- 원시 데이터 유형(정수, 부동 소수점, 이중, 부울, 문자열 등)
- 직렬화할 수 있는 필드 타입의 배열
- 직렬화할 수 있는 필드 유형의 List<T>
지금까지는 괜찮습니다. 그렇다면 시리얼라이저가 예상과 다르게 작동하는 상황은 어떤 것일까요?
사용자 정의 클래스는 구조체처럼 동작합니다.
[직렬화 가능]
동물 클래스
{
공개 문자열 이름;
}
MyScript 클래스 : 모노비헤이비어
{
public Animal[] 동물;
}
동물 배열을 하나의 동물 객체에 대한 세 개의 참조로 채우면 직렬화 스트림에서 3개의 객체를 찾을 수 있습니다. 역직렬화하면 이제 세 개의 다른 객체가 생깁니다. 참조가 포함된 복잡한 오브젝트 그래프를 직렬화해야 하는 경우 Unity의 시리얼라이저가 모든 작업을 자동으로 처리할 수 없으며, 직접 오브젝트 그래프를 직렬화하기 위해 몇 가지 작업을 수행해야 합니다. Unity가 자체적으로 직렬화하지 않는 항목을 직렬화하는 방법은 아래 예제를 참조하세요.
사용자 정의 클래스는 데이터가 사용되는 모노비헤이비어에 대한 전체 직렬화 데이터의 일부가 되기 때문에 '인라인'으로 직렬화되므로 사용자 정의 클래스의 경우에만 해당됩니다. "public Camera myCamera"와 같이 UnityEngine.Object 파생 클래스인 것에 대한 참조가 있는 필드가 있는 경우 해당 카메라의 데이터는 인라인 직렬화되지 않고 카메라에 대한 실제 참조인 UnityEngine.Object가 직렬화됩니다.
사용자 지정 클래스에 대해 null을 지원하지 않습니다.
팝 퀴즈. 이 스크립트를 사용하는 모노비헤이비어를 역직렬화할 때 할당되는 할당 횟수입니다:
클래스 테스트 : 모노비헤이비어
{
공개 문제 t;
}
[직렬화 가능]
클래스 트러블
{
공개 트러블 t1;
공개 트러블 t2;
공개 트러블 t3;
}
테스트 오브젝트의 할당량인 1개를 기대하는 것은 이상하지 않습니다. 또한 테스트 오브젝트와 트러블 오브젝트에 각각 하나씩 2개의 할당을 기대하는 것도 이상하지 않습니다. 정답은 729입니다. 직렬화기는 null을 지원하지 않습니다. 객체를 직렬화하고 필드가 null인 경우 해당 유형의 새 객체를 인스턴스화하여 직렬화하면 됩니다. 물론 이것은 무한 주기로 이어질 수 있으므로 7단계라는 비교적 마법 같은 깊이 제한을 두었습니다. 이 시점에서 사용자 정의 클래스/구조체, 목록 및 배열 유형이 있는 필드의 직렬화를 중단합니다. [1]
많은 하위 시스템이 직렬화 시스템 위에 구축되어 있기 때문에, 테스트 단일 동작에 대한 예기치 않은 큰 직렬화 스트림으로 인해 이러한 모든 하위 시스템이 필요 이상으로 느리게 작동하게 됩니다. 고객 프로젝트의 성능 문제를 조사할 때 거의 항상 이 문제를 발견하게 되며, Unity 4.5에서 이 상황에 대한 경고를 추가했습니다. 실제로 경고 구현을 엉망으로 만들어서 경고가 너무 많이 표시되어 바로 수정하는 것 외에는 다른 방법이 없었습니다. 곧 패치 릴리스를 통해 이 문제를 수정할 예정이며, 경고는 사라지지 않지만 '플레이 모드 진입' 당 한 번만 표시되므로 스팸에 시달리지 않도록 주의하세요. 여전히 코드를 수정하고 싶겠지만 자신에게 적합한 시간에 수정할 수 있어야 합니다.
다형성 미지원
다음과 같은 경우
public Animal[] 동물
에 개, 고양이, 기린의 인스턴스를 넣으면 직렬화 후 세 개의 동물 인스턴스를 갖게 됩니다.
이 제한을 해결하는 한 가지 방법은 인라인으로 직렬화되는 '사용자 정의 클래스'에만 적용된다는 점을 인식하는 것입니다. 다른 UnityEngine.Object에 대한 참조는 실제 레퍼런스로 직렬화되며, 이러한 경우 다형성이 실제로 작동합니다. 스크립터블 오브젝트 파생 클래스나 다른 모노비헤이비어 파생 클래스를 만들고 이를 참조하면 됩니다. 이렇게 할 때의 단점은 해당 모노비헤이비어 또는 스크립트 가능한 객체를 어딘가에 저장해야 하고 인라인으로 멋지게 직렬화할 수 없다는 것입니다.
이러한 제한이 발생하는 이유는 직렬화 시스템의 핵심 기반 중 하나는 객체에 대한 데이터 스트림의 레이아웃이 미리 알려져 있고, 필드 내부에 저장되는 것이 아니라 클래스의 필드 유형에 따라 달라지기 때문입니다.
유니티의 시리얼라이저가 지원하지 않는 콘텐츠를 시리얼라이즈하고 싶습니다. 어떻게 해야 하나요?
대부분의 경우 가장 좋은 방법은 직렬화 콜백을 사용하는 것입니다. 이를 통해 직렬화기가 필드에서 데이터를 읽기 전과 쓰기가 완료된 후에 알림을 받을 수 있습니다. 이 기능을 사용하면 직렬화하기 어려운 데이터를 런타임에 실제 직렬화할 때와 다른 방식으로 표현할 수 있습니다. 유니티가 데이터를 직렬화하기 직전에 데이터를 유니티가 이해할 수 있는 형태로 변환하는 데 사용할 수 있으며, 유니티가 필드에 데이터를 쓴 직후에 직렬화된 형태를 런타임에 원하는 형태로 다시 변환하는 데도 사용할 수 있습니다.
트리 데이터 구조가 필요하다고 가정해 보겠습니다. Unity에서 데이터 구조를 직접 직렬화하면 '널을 지원하지 않음' 제한으로 인해 데이터 스트림이 매우 커져 많은 시스템에서 성능이 저하될 수 있습니다:
using UnityEngine;
System.Collections.Generic을 사용합니다;
System;
공용 클래스 VerySlowBehaviourDoNotDoThis : 모노비헤이비어
{
[직렬화 가능]
공용 클래스 노드
{
public string interestingValue = "value";
//아래 필드가 직렬화 데이터를 거대하게 만드는 이유는 다음과 같습니다.
//'클래스 주기'를 도입합니다.
public List<Node> children = new List<Node>();
}
//이것은 직렬화됩니다.
public Node root = new Node();
void OnGUI()
{
디스플레이(루트);
}
void Display(노드 노드)
{
GUILayout.Label ("값: ");
node.interestingValue = GUILayout.TextField(node.interestingValue, GUILayout.Width(200));
GUILayout.BeginHorizontal ();
GUILayout.Space (20);
GUILayout.BeginVertical ();
foreach (var child in node.children)
디스플레이(자식);
if (GUILayout.Button ("아이 추가"))
node.children.Add (새 노드 ());
GUILayout.EndVertical ();
GUILayout.EndHorizontal ();
}
}
대신 트리를 직접 직렬화하지 않도록 Unity에 지시하고, 별도의 필드를 만들어 Unity의 직렬화기에 적합한 직렬화된 형식으로 트리를 저장합니다:
using UnityEngine;
System.Collections.Generic을 사용합니다;
System;
공용 클래스 BehaviourWithTree : MonoBehaviour, ISerializationCallbackReceiver
{
//런타임에 사용되는 노드 클래스
공용 클래스 노드
{
public string interestingValue = "value";
public List<Node> children = new List<Node>();
}
//직렬화에 사용할 노드 클래스
[직렬화 가능]
public struct SerializableNode
{
public string interestingValue;
public int childCount;
public int indexOfFirstChild;
}
//런타임에 사용하는 것의 루트입니다. 직렬화되지 않습니다.
노드 root = 새 노드();
//직렬화할 유니티 필드를 지정합니다.
public List<SerializableNode> serializedNodes;
public void OnBeforeSerialize()
{
//unity가 serializedNodes 필드의 내용을 읽으려고 합니다.
//해당 필드에 올바른 데이터를 '제때' 기록합니다.
serializedNodes.Clear();
AddNodeToSerializedNodes(root);
}
void AddNodeToSerializedNodes(Node n)
{
var serializedNode = new SerializableNode () {
interestingValue = n.interestingValue,
childCount = n.children.Count,
indexOfFirstChild = serializedNodes.Count+1
};
serializedNodes.Add(serializedNode);
foreach(var child in n.children)
AddNodeToSerializedNodes (child);
}
public void OnAfterDeserialize()
{
//유니티가 방금 serializedNodes 필드에 새 데이터를 썼습니다.
//이 새로운 값으로 실제 런타임 데이터를 채워 보겠습니다.
if (serializedNodes.Count > 0)
root = ReadNodeFromSerializedNodes (0);
else
root = 새 노드 ();
}
노드 ReadNodeFromSerializedNodes(int index)
{
var serializedNode = serializedNodes [index];
var children = new List<Node> ();
for(int i=0; i!= serializedNode.childCount; i++)
children.Add(ReadNodeFromSerializedNodes(serializedNode.indexOfFirstChild + i));
반환 새 노드() {
interestingValue = serializedNode.interestingValue,
어린이 = 어린이
};
}
void OnGUI()
{
디스플레이(루트);
}
void Display(노드 노드)
{
GUILayout.Label ("값: ");
node.interestingValue = GUILayout.TextField(node.interestingValue, GUILayout.Width(200));
GUILayout.BeginHorizontal ();
GUILayout.Space (20);
GUILayout.BeginVertical ();
foreach (var child in node.children)
디스플레이(자식);
if (GUILayout.Button ("아이 추가"))
node.children.Add (새 노드 ());
GUILayout.EndVertical ();
GUILayout.EndHorizontal ();
}
}
시리얼라이저에서 오는 이러한 콜백을 포함한 시리얼라이저는 일반적으로 메인 스레드에서 실행되지 않으므로 Unity API를 호출할 때 수행할 수 있는 작업이 매우 제한적이라는 점에 유의하세요. (씬 로딩의 일부로 발생하는 직렬화는 로딩 스레드에서 발생합니다. 스크립트에서 인스턴스화()를 호출할 때 발생하는 직렬화는 메인 스레드에서 발생합니다). 그러나 필요한 데이터 변환을 수행하여 유니티 직렬화 프로그램에 적합하지 않은 형식의 데이터를 유니티 직렬화 프로그램에 적합한 형식으로 변환할 수 있습니다.
끝까지 해냈군요!
여기까지 읽어주셔서 감사드리며, 이 정보를 프로젝트에 유용하게 활용하시기 바랍니다.
안녕, 루카스.(@lucasmeijer)
추신: 이 모든 정보를 문서에도 추가할 예정입니다.
[1] 거짓말입니다. 정답은 실제로 729가 아닙니다. 7단계 깊이 제한이 있기 전 아주 옛날에는 방금 작성한 트러블과 같은 스크립트를 만들면 Unity가 끝없이 반복되다가 메모리가 부족해졌기 때문입니다. 5년 전 이 문제를 처음 수정한 것은 클래스 자체와 같은 유형의 필드 타입을 직렬화하지 않는 것이었습니다. 물론 이것은 Trouble1->Trouble2->Trouble1->Trouble2 클래스를 사용하여 사이클을 쉽게 만들 수 있기 때문에 가장 강력한 수정은 아니었습니다. 그래서 얼마 지나지 않아 이러한 사례도 포착하기 위해 실제로 7단계 깊이 제한을 구현했습니다. 제가 말하고자 하는 요점은 중요하지 않지만, 중요한 것은 주기가 있으면 문제가 있다는 것을 깨닫는 것입니다.
