Exploration du Système de Sérialisation Interne d'Unity

La sérialisation est un pilier fondamental du moteur Unity, ne se limitant pas à la persistance des données pour les sauvegardes de scènes ou les Prefabs. Elle joue un rôle crucial dans plusieurs systèmes internes, notamment le mécanisme d'annulation (Undo). Le code source relatif à la sérialisation est principalement localisé dans le répertoire Runtime/Serialize.

Le système d'Undo de Unity, particulièrement le PropertyDiffUndoRecorder, s'appuie fortement sur cette infrastructure de sérialisation. Pour chaque classe enregistrable, la logique de sérialisation est encapsulée manuellement dans une méthode de transfert spécifique, souvent nommée Transfer ou similaire.

La Fonction de Transfert : Le Cœur de la Sérialisation

Chaque classe Unity qui doit être sérialisée expose une fonction de transfert qui détaille quels membres de la classe doivent être inclus dans le processus. Cette fonction est typiquement un template, permettant à différents "agents de transfert" (pour l'écriture, la lecture, la génération de métadonnées, etc.) de l'utiliser. Prenons l'exemple d'un composant de transformation et d'une structure vectorielle :

// Fichier conceptuel : Components/TransformComponent.cpp
template<typename TTransferAgent>
void TransformComponent::SerializeMembers(TTransferAgent& agent)
{
    // Sérialise d'abord les membres de la classe de base
    BaseComponent::SerializeMembers(agent);

    // Ensuite, les propriétés locales de la transformation
    agent.ProcessField(m_LocalRotation, "m_LocalRotation");
    agent.ProcessField(m_LocalPosition, "m_LocalPosition");
    agent.ProcessField(m_LocalScale, "m_LocalScale");

    // Une logique conditionnelle simplifiée post-transfert
    if (agent.IsReadingData()) {
        // Recalcule la matrice de transformation après la lecture des données
        RecalculateTransformMatrix();
    }
}

// Fichier conceptuel : Math/Vector3D.h
template<typename TTransferAgent>
void Vector3D::SerializeMembers(TTransferAgent& agent)
{
    // Indique un style de mapping de flux pour les structures simples
    agent.BeginStructureMapping("Vector3D"); 
    agent.ProcessField(x_coord, "x");
    agent.ProcessField(y_coord, "y");
    agent.ProcessField(z_coord, "z");
}

Ici, ProcessField est une méthode générique sur l'agent de transfert qui prend la référence au membre et son nom comme une chaîne de caractères. Cela permet à l'agent de décider comment traiter la donnée (écrire des octets, lire des octets, enregistrer des informations de type, etc.).

Le Rôle dans le Système d'Annulation (Undo)

Le système PropertyDiffUndoRecorder enregistre les modifications des propriétés pour permettre l'annulation. Sa méthode Flush déclenche GenerateUndoDiffs, qui est essentielle pour ce processus. Avant de générer les différences, l'état actuel de l'objet est sérialisé en mémoire à l'aide de la fonction WriteObjectToVector, capturant ainsi ses valeurs de propriétés. Une fois les deux états (avant modification et après modification) sérialisés, une comparaison est effectuée pour identifier les changements.

// Extrait conceptuel de PropertyDiffUndoUtilities.cpp
void GenerateUndoDifferences(const std::list<RecordedObject>& recordedItems, PropertyModifications& outputChanges)
{
    for (const auto& item : recordedItems)
    {
        if (item.targetObject.IsNull())
            continue;
        
        // 1. Obtenir l'arbre de types de l'état actuel de l'objet
        const TypeDefinitionTree& currentTypeTree = GenerateObjectSchema(item.targetObject);

        // 2. Sérialiser l'état actuel de l'objet en mémoire
        std::vector<UInt8> currentStateBuffer;
        SerializeObjectToVector(item.targetObject, &currentStateBuffer);

        // 3. Comparer l'état actuel avec l'état pré-édition pour générer les modifications
        std::vector<PropertyModification> detectedModifications;
        PerformPropertyDifference(currentTypeTree, currentStateBuffer, item.preEditStateBuffer, detectedModifications);
        
        // Assignation de la cible aux modifications
        for (auto& mod : detectedModifications) {
            mod.targetObject = item.targetObject;
        }

        if (!detectedModifications.empty()) {
            outputChanges.insert(outputChanges.end(), detectedModifications.begin(), detectedModifications.end());
        }
    }
}

Dans ce processus, GenerateObjectSchema produit une TypeDefinitionTree qui décrit la structure de l'objet, utilisée ensuite par PerformPropertyDifference pour interpréter les flux d'octets.

TransferTraits et le Mécanisme de Distribution

Plusieurs "agents de transfert" dérivent d'une classe de base et utilisent un mécanisme de distribution basé sur TransferTraits. Ce système agit comme un proxy, dirigeant la sérialisation en fonction du type de donnée :

  • TransferTraitsForFundamentalType : Gère les types de données de base (comme int, float, double). L'agent de transfert (par exemple, un écrivain de flux ou un générateur d'arbre de types) décide comment traiter ces données primitives.
  • TransferTraitsForCustomType : Gère les types de données personnalisés. Dans ce cas, il délègue le traitement à la méthode SerializeMembers de la classe elle-même, permtetant ainsi une sérialisation récursive et une traversée de l'arbre des types.
// Interface conceptuelle pour un agent de transfert de données
class IDataTransferAgent {
public:
    virtual ~IDataTransferAgent() = default;

    // Méthodes pour gérer les types fondamentaux
    virtual void ProcessField(int& value, const char* name = nullptr) = 0;
    virtual void ProcessField(float& value, const char* name = nullptr) = 0;
    // ... autres types fondamentaux

    // Méthode générique pour gérer les objets personnalisés
    template<typename T>
    void ProcessField(T& obj, const char* name = nullptr) {
        // Appelle la méthode de sérialisation spécifique de l'objet
        obj.SerializeMembers(*this); 
    }

    // Méthode pour indiquer si l'agent est en mode lecture
    virtual bool IsReadingData() const { return false; } 
    // Méthode conceptuelle pour le mapping de structure (comme kTransferUsingFlowMappingStyle)
    virtual void BeginStructureMapping(const char* typeName) {} 
};

// Exemple d'agent écrivant vers un flux binaire
class BinaryWriterAgent : public IDataTransferAgent {
    std::vector<unsigned char> m_buffer;
public:
    void ProcessField(int& value, const char* name = nullptr) override { 
        // Écrire la valeur 'int' dans m_buffer
    }
    void ProcessField(float& value, const char* name = nullptr) override { 
        // Écrire la valeur 'float' dans m_buffer
    }
    // ... implémentations pour d'autres types fondamentaux
    const std::vector<unsigned char>& GetBuffer() const { return m_buffer; }
};

Génération de l'Arbre de Types (GenerateObjectSchema)

La fonction GenerateObjectSchema (équivalent à GenerateCachedTypeTree dans l'original) utilise un agent de transfert spécialisé, que nous pourrions appeler TypeSchemaRecorder. Cet agent ne sérialise pas les valeurs des données, mais parcourt la structure de l'objet en utilisant ses méthodes SerializeMembers pour enregistrer la hiérarchie des types, les noms des champs et leurs tailles en octets.

class TypeSchemaRecorder : public IDataTransferAgent {
    // Représentation interne de l'arbre de types en cours de construction
    TypeDefinitionTree* m_activeNode; 
public:
    void ProcessField(int& value, const char* fieldName) override {
        // Enregistrer la taille d'un int pour le nœud parent actuel
        // Mettre à jour l'offset en octets simulé
        m_activeNode->AddChild(fieldName, FieldType::INT, sizeof(int));
    }
    // ... implémentations pour d'autres types, y compris des descentes récursives pour les objets personnalisés
};

Lorsqu'un TypeSchemaRecorder traverse un type fondamental, il consigne simplement la taille en octets de ce type pour le nœud actif de l'arbre de types.

Sérialisation vers un Vecteur d'Octets (SerializeObjectToVector)

La fonction SerializeObjectToVector (correspondant à WriteObjectToVector) est chargée de tarnsformer un objet Unity en un tableau d'octets. Elle initialise un agent de transfert de type BinaryWriterAgent (ou StreamedBinaryWrite dans l'original) qui écrira les données dans un flux mémoire. L'objet cible est ensuite invité à "transférer" ses données à cet agent, ce qui déclenche l'appel de sa méthode SerializeMembers.

void SerializeObjectToVector(Object& object, std::vector<UInt8>* dataBuffer)
{
    BinaryWriterAgent writerAgent;
    object.SerializeMembers(writerAgent); // L'objet sérialise ses membres via l'agent
    *dataBuffer = writerAgent.GetBuffer(); // Récupère le tampon d'octets rempli
}

L'agent BinaryWriterAgent gère l'écriture effective des octets, y compris l'éventuelle inversion de l'ordre des octets (endianness) si nécessaire.

Comparaison de Propriétés (PerformPropertyDifference)

Enfin, la fonction PerformPropertyDifference (équivalente à GeneratePropertyDiff) compare deux jeux de données sérialisées (l'état avant et après modification) en utilisant la TypeDefinitionTree pour interpréter leur structure. Cette fonction est récursive et identifie chaque propriété qui a changé, générant une liste de PropertyModification.

// Structure simplifiée d'information sur un champ
struct FieldSchema {
    std::string name;
    FieldType type; // ex: INT, FLOAT, STRING, CUSTOM_OBJECT, ARRAY, PTR
    size_t byteSize; // Taille en octets pour les types fondamentaux
    std::vector<FieldSchema> children; // Pour les objets personnalisés ou les éléments d'array
};

// Définition d'un changement de propriété
struct PropertyChange {
    std::string path; // Le chemin de la propriété (ex: "transform.localPosition.x")
    std::vector<unsigned char> newValueBytes; // Les octets de la nouvelle valeur
    // Potentiellement, un PPtr pour les références d'objets
};

// Fonction récursive pour comparer deux tampons d'octets
void CompareObjectStatesRecursive(const FieldSchema& currentSchema,
                                  const unsigned char* oldDataPtr, size_t& oldOffset,
                                  const unsigned char* newDataPtr, size_t& newOffset,
                                  std::string currentPath,
                                  std::vector<PropertyChange>& detectedChanges)
{
    // Mettre à jour le chemin de la propriété
    if (!currentSchema.name.empty()) {
        if (!currentPath.empty()) currentPath += ".";
        currentPath += currentSchema.name;
    }

    if (currentSchema.type == FieldType::INT || currentSchema.type == FieldType::FLOAT) {
        // Comparer les données brutes des types fondamentaux
        if (memcmp(oldDataPtr + oldOffset, newDataPtr + newOffset, currentSchema.byteSize) != 0) {
            // Enregistrer la modification
            detectedChanges.push_back({currentPath, std::vector<unsigned char>(newDataPtr + newOffset, newDataPtr + newOffset + currentSchema.byteSize)});
        }
        oldOffset += currentSchema.byteSize;
        newOffset += currentSchema.byteSize;
    } else if (currentSchema.type == FieldType::STRING) {
        // Logique de comparaison spécifique pour les chaînes (taille + contenu)
        // ...
        // Avancer les offsets pour les deux tampons
        // ...
    } else if (currentSchema.type == FieldType::ARRAY) {
        // Logique de comparaison pour les tableaux (taille du tableau, puis éléments)
        // ...
        // Appels récursifs pour chaque élément
        // ...
    } else if (currentSchema.type == FieldType::PTR) { // PPtr : Pointeur d'objet persisté
        // Comparer les IDs d'instance
        // ...
        // Enregistrer la modification si les IDs diffèrent
        // ...
    } else if (currentSchema.type == FieldType::CUSTOM_OBJECT) {
        // Récurrence pour les objets imbriqués
        for (const auto& childSchema : currentSchema.children) {
            CompareObjectStatesRecursive(childSchema, oldDataPtr, oldOffset, newDataPtr, newOffset, currentPath, detectedChanges);
        }
    }

    // Gérer l'alignement des octets si le schéma l'indique
    // ...
}

La fonction gère différents types de données :

  • Types fondamentaux : Elle compare directement les blocs d'octets. Si une différence est détectée, une PropertyChange est enregistrée.
  • Chaînes de caractères : Elle compare à la fois la taille et le contenu.
  • Tableaux : Elle compare d'abord la taille du tableau, puis récursivement chaque élément.
  • Pointeurs d'objets (PPtr) : Elle compare les identifiants d'instance des objets référencés, avec une logique de remappage si l'objet a été cloné (par exemple, pour les Prefabs).
  • Objets imbriqués : Elle itère récursivement sur les champs enfants de l'objet.

À chaque étape, un chemin de propriété (par exemple, "Transform.LocalPosition.x") est construit pour identifier précisément la propriété modifiée. Les problèmes d'alignement des octets sont également pris en compte lors de la progression dans les tampons de données.

Étiquettes: Unity sérialisation Undo C++ Moteur de Jeu

Publié le 17 août à 10h31