La sérialisation dans Unity

Dans l'esprit de partager plus de technologie dans les coulisses, et les raisons pour lesquelles certaines choses sont comme elles sont, ce billet contient un aperçu du système de sérialisation d'Unity. Une bonne compréhension de ce système peut avoir un impact important sur l'efficacité de votre développement et sur la performance des produits que vous fabriquez. Nous y voilà.
La sérialisation des "choses" est au cœur même d'Unity. Nombre de nos fonctionnalités s'appuient sur le système de sérialisation :
- Stockage des données stockées dans vos scripts. Celle-ci est probablement connue de la plupart des gens.
- Fenêtre de l'inspecteur. La fenêtre de l'inspecteur ne s'adresse pas à l'API C# pour connaître les valeurs des propriétés de ce qu'elle inspecte. Il demande à l'objet de se sérialiser, puis affiche les données sérialisées.
- Préfabriqués. En interne, un préfabriqué est le flux de données sérialisé d'un (ou plusieurs) objet(s) et composant(s) de jeu. Une instance préfabriquée est une liste de modifications à apporter aux données sérialisées pour cette instance. Le concept de préfabriqué n'existe en fait qu'au moment de la rédaction. Les modifications apportées au préfabriqué sont intégrées dans un flux de sérialisation normal lorsque Unity crée un build, et lorsque celui-ci est instancié, les GameObjects instanciés n'ont aucune idée qu'ils étaient un préfabriqué lorsqu'ils vivaient dans l'éditeur.
- Instantiation. Lorsque vous appelez Instantiate() sur un prefab, ou un gameobject qui vit dans la scène, ou sur n'importe quoi d'autre d'ailleurs (tout ce qui dérive de UnityEngine.Object peut être sérialisé), nous sérialisons l'objet, puis nous créons un nouvel objet, et enfin nous "désérialisons" les données sur le nouvel objet. (Nous exécutons ensuite le même code de sérialisation dans une variante différente, où nous l'utilisons pour signaler quels autres UnityEngine.Object sont référencés. Nous vérifions ensuite tous les UnityEngine.Object référencés s'ils font partie des données en cours d'instanciation(). Si la référence pointe vers quelque chose d'"externe" (comme une texture), nous conservons cette référence telle quelle, si elle pointe vers quelque chose d'"interne" (comme un GameObject enfant), nous patchons la référence vers la copie correspondante).
- Économiser. Si vous ouvrez un fichier de scène .unity avec un éditeur de texte, et que vous avez configuré Unity pour "forcer la sérialisation du texte", nous exécutons le sérialiseur avec un backend yaml.
- Chargement. Cela ne semble pas surprenant, mais le chargement rétrocompatible est un système qui repose également sur la sérialisation. Le chargement des fichiers yaml dans l'éditeur utilise le système de sérialisation, ainsi que le chargement des scènes et des actifs au moment de l'exécution. Les regroupements d'actifs utilisent également le système de sérialisation.
- Rechargement à chaud du code de l'éditeur. Lorsque vous modifiez un script d'éditeur, nous sérialisons toutes les fenêtres de l'éditeur (elles dérivent de UnityEngine.Object !), nous détruisons ensuite toutes les fenêtres, nous déchargeons l'ancien code c#, nous chargeons le nouveau code c#, nous recréons les fenêtres et enfin nous désérialisons les flux de données des fenêtres pour les réintégrer dans les nouvelles fenêtres.
- Resource.GarbageCollectSharedAssets(). Il s'agit de notre collecteur d'ordures natif, différent du collecteur d'ordures C#. C'est la chose que nous exécutons après le chargement d'une scène pour déterminer quels éléments de la scène précédente ne sont plus référencés, afin de pouvoir les décharger. Le ramasse-miettes natif exécute le sérialiseur dans un mode où nous l'utilisons pour que les objets signalent toutes les références aux objets externes UnityEngine.Objects. C'est ce qui fait que les textures utilisées par la scène 1 sont déchargées lorsque vous chargez la scène 2.
Le système de sérialisation est écrit en C++, nous l'utilisons pour tous nos types d'objets internes (Textures, AnimationClip, Camera, etc). La sérialisation se fait au niveau de UnityEngine.Object, chaque UnityEngine.Object est toujours sérialisé comme un tout. Ils peuvent contenir des références à d'autres UnityEngine.Objects et ces références sont sérialisées correctement.
Vous pouvez dire que tout cela ne vous préoccupe pas beaucoup, que vous êtes simplement heureux que cela fonctionne et que vous voulez passer à la création de contenu. Cependant, cela vous concernera, car nous utilisons ce même sérialiseur pour sérialiser les composants MonoBehaviour, qui sont soutenus par vos scripts. En raison des exigences très élevées en matière de performances, le sérialiseur ne se comporte pas toujours exactement comme ce qu'un développeur C# attendrait d'un sérialiseur. Nous décrirons ici le fonctionnement du sérialiseur et quelques bonnes pratiques pour l'utiliser au mieux.
Que doit être un champ de mon script pour être sérialisé ?
- Être public ou avoir l'attribut [SerializeField].
- Ne pas être statique
- Ne pas être const
- Ne pas être en lecture seule
- Le type de champ doit être d'un type que nous pouvons sérialiser.
Quels types de champs pouvons-nous sérialiser ?
- Classes non abstraites personnalisées avec l'attribut [Serializable].
- Structures personnalisées avec l'attribut [Serializable]. (nouveau dans Unity4.5)
- Références aux objets qui dérivent de UntiyEngine.Object
- Types de données primitives (int, float, double, bool, string, etc.)
- Tableau d'un type de champ que nous pouvons sérialiser
- List<T> d'un type de champ que nous pouvons sérialiser
Jusqu'à présent, tout va bien. Quelles sont donc ces situations où le sérialiseur se comporte différemment de ce à quoi je m'attends ?
Les classes personnalisées se comportent comme des structures
[Serializable]
classe Animal
{
public string name ;
}
classe MyScript : MonoBehaviour
{
public Animal[] animals ;
}
Si vous remplissez le tableau des animaux avec trois références à un seul objet Animal, vous trouverez trois objets dans le flux de sérialisation. Lorsqu'il est désérialisé, il y a maintenant trois objets différents. Si vous avez besoin de sérialiser un graphe d'objets complexe avec des références, vous ne pouvez pas compter sur le sérialiseur d'Unity pour faire tout cela automatiquement pour vous, et vous devez faire un peu de travail pour sérialiser ce graphe d'objets vous-même. Voyez l'exemple ci-dessous pour savoir comment sérialiser des choses qu'Unity ne sérialise pas de lui-même.
Notez que cela n'est vrai que pour les classes personnalisées, car elles sont sérialisées "en ligne" parce que leurs données font partie des données de sérialisation complètes pour le MonoBehaviour dans lequel elles sont utilisées. Lorsque vous avez des champs qui ont une référence à quelque chose qui est une classe dérivée de UnityEngine.Object, comme "public Camera myCamera", les données de cette caméra ne sont pas sérialisées en ligne, et une référence réelle à la caméra UnityEngine.Object est sérialisée.
Pas de prise en charge de null pour les classes personnalisées
Petit quiz. Combien d'allocations sont effectuées lors de la désérialisation d'un MonoBehaviour qui utilise ce script :
classe Test : MonoBehaviour
{
public Trouble t ;
}
[Serializable]
classe Trouble
{
public Trouble t1 ;
public Trouble t2 ;
public Trouble t3 ;
}
Il ne serait pas étrange d'attendre 1 allocation, celle de l'objet Test. Il ne serait pas non plus étrange de prévoir deux allocations, l'une pour l'objet Test et l'autre pour l'objet Trouble. La bonne réponse est 729. Le sérialiseur ne prend pas en charge null. S'il sérialise un objet et qu'un champ est nul, il suffit d'instancier un nouvel objet de ce type et de le sérialiser. Il est évident que cela pourrait conduire à des cycles infinis, c'est pourquoi nous avons fixé une limite de profondeur relativement magique de 7 niveaux. À ce stade, nous arrêtons simplement de sérialiser les champs qui ont des types de classes/structures personnalisées, des listes et des tableaux. [1]
Étant donné qu'un grand nombre de nos sous-systèmes s'appuient sur le système de sérialisation, ce flux de sérialisation trop important pour le MonoBehaviour Test ralentira les performances de tous ces sous-systèmes. Lorsque nous enquêtons sur des problèmes de performance dans des projets clients, nous trouvons presque toujours ce problème et nous avons ajouté un avertissement pour cette situation dans Unity 4.5. Nous avons en fait modifié la mise en œuvre des avertissements de manière à ce qu'ils soient si nombreux que vous n'ayez pas d'autre choix que de les corriger immédiatement. L'avertissement n'a pas disparu, mais vous n'en recevrez qu'un par "entrée en mode de jeu", ce qui vous évitera d'être spammé. Vous voudrez toujours corriger votre code, mais vous devriez pouvoir le faire au moment qui vous convient.
Pas de prise en charge du polymorphisme
Si vous avez un
public Animal[] animals
et que vous introduisez une instance de chien, de chat et de girafe, vous obtiendrez, après sérialisation, trois instances d'Animal.
Une façon de gérer cette limitation est de réaliser qu'elle ne s'applique qu'aux "classes personnalisées", qui sont sérialisées en ligne. Les références à d'autres UnityEngine.Object sont sérialisées en tant que références réelles et pour celles-ci, le polymorphisme fonctionne réellement. Vous devez créer une classe dérivée de ScriptableObject ou une autre classe dérivée de MonoBehaviour, et y faire référence. L'inconvénient de cette méthode est que vous devez stocker ce MonoBehaviour ou cet objet scriptable quelque part et que vous ne pouvez pas le sérialiser en ligne.
La raison de ces limitations est que l'un des fondements du système de sérialisation est que la disposition du flux de données pour un objet est connue à l'avance et dépend des types de champs de la classe, au lieu de ce qui est stocké à l'intérieur des champs.
Je veux sérialiser quelque chose que le sérialiseur d'Unity ne prend pas en charge. Que dois-je faire ?
Dans de nombreux cas, la meilleure approche consiste à utiliser des rappels de sérialisation. Ils vous permettent d'être informé avant que le sérialiseur ne lise les données de vos champs et après qu'il ait fini d'écrire dans ces derniers. Vous pouvez l'utiliser pour avoir une représentation différente de vos données difficiles à sérialiser au moment de l'exécution que lorsque vous les sérialisez réellement. Vous les utiliserez pour transformer vos données en quelque chose que Unity comprend juste avant que Unity ne veuille les sérialiser, vous les utiliserez également pour transformer la forme sérialisée en la forme dans laquelle vous aimeriez avoir vos données au moment de l'exécution, juste après qu'Unity ait écrit les données dans vos champs.
Supposons que vous souhaitiez disposer d'une structure de données arborescente. Si vous laissez Unity sérialiser directement la structure de données, la limitation "no support for null" fera que votre flux de données deviendra très volumineux, ce qui entraînera une dégradation des performances dans de nombreux systèmes :
en utilisant UnityEngine ;
using System.Collections.Generic ;
en utilisant System ;
public class VerySlowBehaviourDoNotDoThis : MonoBehaviour
{
[Serializable]
public class Node
{
public string interestingValue = "valeur" ;
//Le champ ci-dessous est ce qui rend les données de sérialisation énormes parce que
//Il introduit un "cycle de classe".
public List<Node> children = new List<Node>() ;
}
//cela est sérialisé
public Node root = new Node() ;
void OnGUI()
{
Affichage (racine) ;
}
void Display(Node node)
{
GUILayout.Label ("Valeur : ") ;
node.interestingValue = GUILayout.TextField(node.interestingValue, GUILayout.Width(200)) ;
GUILayout.BeginHorizontal () ;
GUILayout.Space (20) ;
GUILayout.BeginVertical () ;
Foreach (var child in node.children)
Affichage (enfant) ;
if (GUILayout.Button ("Ajouter un enfant"))
node.children.Add (new Node ()) ;
GUILayout.EndVertical () ;
GUILayout.EndHorizontal () ;
}
}
Au lieu de cela, vous dites à Unity de ne pas sérialiser l'arbre directement, et vous créez un champ séparé pour stocker l'arbre dans un format sérialisé, adapté au sérialiseur de Unity :
en utilisant UnityEngine ;
using System.Collections.Generic ;
en utilisant System ;
public class BehaviourWithTree : MonoBehaviour, ISerializationCallbackReceiver
{
//classe de nœud utilisée au moment de l'exécution
public class Node
{
public string interestingValue = "valeur" ;
public List<Node> children = new List<Node>() ;
}
//classe de nœud que nous utiliserons pour la sérialisation
[Serializable]
public struct SerializableNode
{
public string interestingValue ;
public int childCount ;
public int indexOfFirstChild ;
}
//la racine de ce que nous utilisons au moment de l'exécution. non sérialisé.
Node root = new Node() ;
//le champ que nous donnons à Unity à sérialiser.
public List<SerializableNode> serializedNodes ;
public void OnBeforeSerialize()
{
//Unity est sur le point de lire le contenu du champ Nœuds sérialisés. assurons-nous que
//Nous écrivons les données correctes dans ce champ "juste à temps".
serializedNodes.Clear();
AddNodeToSerializedNodes(root) ;
}
void AddNodeToSerializedNodes(Node n)
{
var serializedNode = new SerializableNode () {
valeur intéressante = n.valeur intéressante,
childCount = n.children.Count,
indexOfFirstChild = serializedNodes.Count+1
};
serializedNodes.Add (serializedNode) ;
Foreach (var child in n.children)
AddNodeToSerializedNodes (enfant) ;
}
public void OnAfterDeserialize()
{
//Unity vient d'écrire de nouvelles données dans le champ serializedNodes.
//remplissons nos données d'exécution avec ces nouvelles valeurs.
if (serializedNodes.Count > 0)
root = ReadNodeFromSerializedNodes (0) ;
autre
root = nouveau Node () ;
}
Node 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));
return new Node() {
interestingValue = serializedNode.interestingValue,
enfants = enfants
};
}
void OnGUI()
{
Affichage (racine) ;
}
void Display(Node node)
{
GUILayout.Label ("Valeur : ") ;
node.interestingValue = GUILayout.TextField(node.interestingValue, GUILayout.Width(200)) ;
GUILayout.BeginHorizontal () ;
GUILayout.Space (20) ;
GUILayout.BeginVertical () ;
Foreach (var child in node.children)
Affichage (enfant) ;
if (GUILayout.Button ("Ajouter un enfant"))
node.children.Add (new Node ()) ;
GUILayout.EndVertical () ;
GUILayout.EndHorizontal () ;
}
}
Sachez que le sérialiseur, y compris les rappels provenant du sérialiseur, ne s'exécute généralement pas sur le fil d'exécution principal, de sorte que vous êtes très limité dans ce que vous pouvez faire en termes d'invocation de l'API d'Unity. (La sérialisation qui se produit dans le cadre du chargement d'une scène se produit sur un thread de chargement. La sérialisation se produit lorsque vous invoquez Instantiate() à partir d'un script, ce qui se produit sur le thread principal). Vous pouvez toutefois effectuer les transformations de données nécessaires pour faire passer vos données d'un format non compatible avec le sérialiseur d'unités à un format compatible avec le sérialiseur d'unités.
Vous êtes arrivé au bout !
Merci d'avoir lu jusqu'ici, j'espère que vous pourrez mettre à profit certaines de ces informations dans vos projets.
Au revoir, Lucas.(@lucasmeijer)
PS : Nous ajouterons également toutes ces informations à la documentation.
[1] J'ai menti, la bonne réponse n'est pas 729. C'est parce que dans le passé, avant que nous ayons cette limite de profondeur de 7 niveaux, Unity faisait des boucles sans fin et manquait de mémoire si vous créiez un script comme celui de Trouble que je viens d'écrire. Notre toute première solution, il y a 5 ans, a été de ne pas sérialiser les types de champs qui étaient du même type que la classe elle-même. Évidemment, ce n'était pas la solution la plus robuste, car il est facile de créer un cycle en utilisant la classe Trouble1->Trouble2->Trouble1->Trouble2. Peu de temps après, nous avons donc mis en place la limite de profondeur de 7 niveaux afin de tenir compte de ces cas également. Ce qui compte, c'est que vous vous rendiez compte que s'il y a un cycle, vous êtes dans le pétrin.
