Serialización en Unity

Con el espíritu de compartir más de la tecnología detrás de escena y las razones por las que algunas cosas son como son, esta publicación contiene una descripción general del sistema de serialización de Unity. Entender muy bien este sistema puede tener un gran impacto en la efectividad de tu desarrollo y en el rendimiento de las cosas que haces. Aquí vamos.
La serialización de “cosas” es el núcleo mismo de Unity. Muchas de nuestras funciones se basan en el sistema de serialización:
- Almacenamiento de datos guardados en sus scripts. Probablemente la mayoría de la gente esté familiarizada con este.
- Ventana de inspección. La ventana del inspector no se comunica con la API de C# para averiguar cuáles son los valores de las propiedades de lo que está inspeccionando. Le pide al objeto que se serialice a sí mismo y luego muestra los datos serializados.
- Prefabricados. Internamente, un prefab es el flujo de datos serializado de uno (o más) objetos y componentes del juego. Una instancia prefabricada es una lista de modificaciones que deben realizarse en los datos serializados para esta instancia. El concepto prefabricado en realidad sólo existe en el momento del editor. Las modificaciones prefabricadas se integran en un flujo de serialización normal cuando Unity realiza una compilación y, cuando esta se instancia, los objetos de juego instanciados no tienen idea de que eran prefabricados cuando vivían en el editor.
- Instanciación. Cuando llamas a Instantiate() en un prefab, en un gameobject que se encuentra en la escena o en cualquier otra cosa (todo lo que se deriva de UnityEngine.Object se puede serializar), serializamos el objeto, luego creamos un nuevo objeto y luego “deserializamos” los datos en el nuevo objeto. (Luego ejecutamos el mismo código de serialización nuevamente en una variante diferente, donde lo usamos para informar a qué otros UnityEngine.Object se hace referencia). Luego verificamos todos los UnityEngine.Object a los que se hace referencia para ver si son parte de los datos que se están instanciando(). Si la referencia apunta a algo “externo” (como una textura), mantenemos esa referencia como está; si apunta a algo “interno” (como un gameobject hijo), parcheamos la referencia a la copia correspondiente).
- Ahorro. Si abre un archivo de escena .unity con un editor de texto y ha configurado Unity para “forzar la serialización de texto”, ejecutamos el serializador con un backend yaml.
- Cargando. Puede que no parezca sorprendente, pero la carga compatible con versiones anteriores es un sistema que también se basa en la serialización. La carga de yaml en el editor utiliza el sistema de serialización, así como la carga en tiempo de ejecución de escenas y recursos. Los paquetes de activos también utilizan el sistema de serialización.
- Recarga en caliente del código del editor. Cuando cambia un script del editor, serializamos todas las ventanas del editor (¡derivan de UnityEngine.Object!), luego destruimos todas las ventanas, descargamos el código C# antiguo, cargamos el código C# nuevo, recreamos las ventanas y, finalmente, deserializamos los flujos de datos de las ventanas en las nuevas ventanas.
- Resource.GarbageCollectSharedAssets(). Este es nuestro recolector de basura nativo y es diferente del recolector de basura de C#. Es lo que ejecutamos después de cargar una escena para determinar qué cosas de la escena anterior ya no están referenciadas, para poder descargarlas. El recolector de basura nativo ejecuta el serializador en un modo en el que lo usamos para que los objetos informen todas las referencias a UnityEngine.Objects externos. Esto es lo que hace que las texturas que se usaron en la escena 1 se descarguen cuando se carga la escena 2.
El sistema de serialización está escrito en C++, lo usamos para todos nuestros tipos de objetos internos (Texturas, AnimationClip, Cámara, etc.). La serialización ocurre en el nivel UnityEngine.Object, cada UnityEngine.Object siempre se serializa como un todo. Pueden contener referencias a otros UnityEngine.Objects y esas referencias se serializan correctamente.
Ahora bien, usted puede decir que nada de esto le preocupa demasiado, que simplemente está contento de que funcione y que desea continuar creando algún contenido. Sin embargo, esto le preocupará, ya que utilizamos este mismo serializador para serializar componentes MonoBehaviour , que están respaldados por sus scripts. Debido a los altos requisitos de rendimiento que tiene el serializador, no en todos los casos se comporta exactamente como un desarrollador de C# esperaría de un serializador. Aquí describiremos cómo funciona el serializador y algunas prácticas recomendadas sobre cómo aprovecharlo al máximo.
¿Qué debe contener un campo de mi script para poder serializarse?
- Ser público o tener el atributo [SerializeField]
- No ser estático
- No ser constante
- No ser de solo lectura
- El tipo de campo debe ser de un tipo que podamos serializar.
¿Qué tipos de campos podemos serializar?
- Clases personalizadas no abstractas con atributo [Serializable].
- Estructuras personalizadas con atributo [Serializable]. (nuevo en Unity 4.5)
- Referencias a objetos que se derivan de UntiyEngine.Object
- Tipos de datos primitivos (int, float, double, bool, string, etc.)
- Matriz de un tipo de campo que podemos serializar
- Lista<T> de un tipo de campo que podemos serializar
Hasta ahora, todo bien. Entonces, ¿cuáles son estas situaciones en las que el serializador se comporta de manera diferente a lo que espero?
Las clases personalizadas se comportan como estructuras
[Serializable]
Clase Animal
{
nombre de cadena pública;
}
clase MyScript : MonoBehaviour
{
público Animal[] animales;
}
Si rellena la matriz de animales con tres referencias a un único objeto Animal, en el flujo de serialización encontrará 3 objetos. Cuando se deserializa, ahora hay tres objetos diferentes. Si necesita serializar un gráfico de objetos complejo con referencias, no puede confiar en que el serializador de Unity lo haga todo automáticamente por usted, y debe realizar algo de trabajo para serializar ese gráfico de objetos usted mismo. Vea el ejemplo a continuación sobre cómo serializar cosas que Unity no serializa por sí solo.
Tenga en cuenta que esto solo es cierto para las clases personalizadas, ya que se serializan “en línea” porque sus datos se convierten en parte de los datos de serialización completos para el MonoBehaviour en el que se utilizan. Cuando tiene campos que tienen una referencia a algo que es una clase derivada de UnityEngine.Object, como una “cámara pública myCamera”, los datos de esa cámara no se serializan en línea, y se serializa una referencia real a la cámara UnityEngine.Object.
No se admite el null para clases personalizadas
Examen sorpresa. ¿Cuántas asignaciones se realizan al deserializar un MonoBehaviour que utiliza este script?
Prueba de clase: MonoBehaviour
{
Problema público t;
}
[Serializable]
Problemas de clase
{
Problema público t1;
Problema público t2;
Problema público t3;
}
No sería extraño esperar 1 asignación, la del objeto de prueba. Tampoco sería extraño esperar 2 asignaciones, una para el objeto de prueba y otra para un objeto de problema. La respuesta correcta es 729. El serializador no admite valores null. Si serializa un objeto y un campo es null, simplemente instanciamos un nuevo objeto de ese tipo y lo serializamos. Obviamente, esto podría conducir a ciclos infinitos, por lo que tenemos un límite de profundidad relativamente mágico de 7 niveles. En ese punto, simplemente dejamos de serializar campos que tienen tipos de clases/estructuras personalizadas, listas y matrices. [1]
Dado que muchos de nuestros subsistemas se basan en el sistema de serialización, este flujo de serialización inesperadamente grande para el comportamiento único de prueba hará que todos estos subsistemas funcionen más lentamente de lo necesario. Cuando investigamos problemas de rendimiento en proyectos de clientes, casi siempre encontramos este problema y agregamos una advertencia para esta situación en Unity 4.5. En realidad, arruinamos la implementación de advertencias de tal manera que te muestra tantas advertencias que no tienes otra opción que solucionarlas de inmediato. Pronto enviaremos una solución para esto en un lanzamiento de parche, la advertencia no ha desaparecido, pero solo recibirás una por "ingresar al modo de juego", para que no te bombardeen con spam. Aún querrás arreglar tu código, pero deberías poder hacerlo en el momento que te convenga.
No hay soporte para polimorfismo
Si tienes una
público Animal[] animales
y pones una instancia de un perro, un gato y una jirafa, después de la serialización, tendrás tres instancias de Animal.
Una forma de lidiar con esta limitación es darse cuenta de que solo se aplica a las “clases personalizadas”, que se serializan en línea. Las referencias a otros UnityEngine.Object se serializan como referencias reales y, para ellas, el polimorfismo realmente funciona. Crearías una clase derivada de ScriptableObject u otra clase derivada de MonoBehaviour y harías referencia a esa. La desventaja de hacer esto es que necesitas almacenar ese monocomportamiento o ese objeto programable en algún lugar y no puedes serializarlo en línea correctamente.
La razón de estas limitaciones es que uno de los fundamentos fundamentales del sistema de serialización es que el diseño del flujo de datos de un objeto se conoce de antemano y depende de los tipos de campos de la clase, en lugar de lo que esté almacenado dentro de los campos.
Quiero serializar algo que el serializador de Unity no admite. ¿Qué debo hacer?
En muchos casos, el mejor enfoque es utilizar devoluciones de llamadas de serialización. Le permiten recibir notificaciones antes de que el serializador lea datos de sus campos y después de que termine de escribir en ellos. Puede usar esto para tener una representación diferente de sus datos difíciles de serializar en tiempo de ejecución que cuando realmente los serializa. Los usarías para transformar tus datos en algo que Unity entienda justo antes de que Unity quiera serializarlos, también los usarías para transformar el formato serializado nuevamente en el formato en el que te gustaría tener tus datos en tiempo de ejecución, justo después de que Unity haya escrito los datos en tus campos.
Digamos que desea tener una estructura de datos de árbol. Si permite que Unity serialice directamente la estructura de datos, la limitación de “sin soporte para null” provocaría que su flujo de datos se volviera muy grande, lo que provocaría degradaciones del rendimiento en muchos sistemas:
utilizando UnityEngine;
utilizando Collections;
utilizando Sistema;
clase pública VerySlowBehaviourDoNotDoThis : MonoBehaviour
{
[Serializable]
Clase pública Nodo
{
cadena pública interestingValue = "valor";
//El campo a continuación es lo que hace que los datos de serialización se vuelvan enormes porque
//Introduce un 'ciclo de clase'.
pública Lista<Nodo> hijos = nueva Lista<Nodo>();
}
//esto se serializa
público Nodo raíz = nuevo Nodo();
vacío OnGUI()
{
Pantalla (raíz);
}
void Mostrar(Nodo nodo)
{
GUILayout.Label("Valor: ");
node.interestingValue = GUILayout.TextField(node.interestingValue, GUILayout.Width(200));
GUILayout.BeginHorizontal();
GUILayout.Space (20);
GUILayout.BeginVertical();
foreach (var hijo en nodo.hijos)
Pantalla (niño);
si (GUILayout.Button ("Agregar hijo"))
nodo.hijos.Agregar(nuevo Nodo());
GUILayout.EndVertical();
GUILayout.EndHorizontal();
}
}
En lugar de eso, le indica a Unity que no serialice el árbol directamente y crea un campo separado para almacenar el árbol en un formato serializado, adecuado para el serializador de Unity:
utilizando UnityEngine;
utilizando Collections;
utilizando Sistema;
clase pública BehaviourWithTree : MonoBehaviour, receptor de devolución de llamada de serialización
{
//clase de nodo que se utiliza en tiempo de ejecución
Clase pública Nodo
{
cadena pública interestingValue = "valor";
pública Lista<Nodo> hijos = nueva Lista<Nodo>();
}
//clase de nodo que usaremos para la serialización
[Serializable]
estructura pública SerializableNode
{
cadena pública interestingValue;
público int childCount;
público int índiceDelPrimerHijo;
}
//la raíz de lo que usamos en tiempo de ejecución. no serializado.
Raíz del nodo = nuevo nodo();
//el campo al que le damos unidad para serializar.
lista pública<SerializableNode> serializedNodes;
vacío público OnBeforeSerialize()
{
//unity está a punto de leer el contenido del campo serializedNodes. Asegurémonos
//escribimos los datos correctos en ese campo "justo a tiempo".
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 hijo en n.hijos)
AddNodeToSerializedNodes (child);
}
vacío público OnAfterDeserialize()
{
// Unity acaba de escribir nuevos datos en el campo serializedNodes.
//Completemos nuestros datos de tiempo de ejecución reales con esos nuevos valores.
si (serializedNodes.Count > 0)
root = ReadNodeFromSerializedNodes (0);
demás
raíz = nuevo Nodo();
}
Nodo ReadNodeFromSerializedNodes(int índice)
{
var serializedNode = serializedNodes [index];
var hijos = nueva Lista<Nodo> ();
for(int i=0; i!= serializedNode.childCount; i++)
children.Add(ReadNodeFromSerializedNodes(serializedNode.indexOfFirstChild + i));
devolver nuevo nodo() {
interestingValue = serializedNode.interestingValue,
niños = niños
};
}
vacío OnGUI()
{
Pantalla (raíz);
}
void Mostrar(Nodo nodo)
{
GUILayout.Label("Valor: ");
node.interestingValue = GUILayout.TextField(node.interestingValue, GUILayout.Width(200));
GUILayout.BeginHorizontal();
GUILayout.Space (20);
GUILayout.BeginVertical();
foreach (var hijo en nodo.hijos)
Pantalla (niño);
si (GUILayout.Button ("Agregar hijo"))
nodo.hijos.Agregar(nuevo Nodo());
GUILayout.EndVertical();
GUILayout.EndHorizontal();
}
}
Tenga en cuenta que el serializador, incluidas estas devoluciones de llamadas que provienen del serializador, generalmente no se ejecutan en el hilo principal, por lo que está muy limitado en lo que puede hacer en términos de invocar la API de Unity . (La serialización que ocurre como parte de la carga de una escena ocurre en un hilo de carga. La serialización ocurre cuando usted invoca Instantiate() desde el script y ocurre en el hilo principal). Sin embargo, puede realizar las transformaciones de datos necesarias para pasar sus datos de un formato no compatible con el serializador de Unity a un formato compatible con el serializador de Unity.
¡Llegaste hasta el final!
Gracias por leer hasta aquí, espero que puedas poner en práctica esta información en tus proyectos.
Adiós, Lucas. (@lucasmeijer)
PS: También agregaremos toda esta información a la documentación.
[1] Mentí, la respuesta correcta en realidad no es 729. Esto se debe a que en los viejos tiempos, antes de que tuviéramos este límite de profundidad de 7 niveles, Unity simplemente hacía un bucle infinito y luego se quedaba sin memoria si creabas un script como el de Trouble que acabo de escribir. Nuestra primera solución para eso hace 5 años fue simplemente no serializar los tipos de campo que eran del mismo tipo que la clase misma. Obviamente, esta no fue la solución más sólida, ya que es fácil crear un ciclo usando la clase Trouble1->Trouble2->Trouble1->Trouble2. Poco tiempo después implementamos el límite de profundidad de 7 niveles para detectar también esos casos. Sin embargo, el punto que estoy tratando de plantear no importa, lo que importa es que te des cuenta de que si hay un ciclo, estás en problemas.
