Chapitre -105 · Assimp

Assimp — Chargement de modèles 3D

Assimp (Open Asset Import Library) est une bibliothèque C++ open source qui charge plus de 40 formats 3D — OBJ, FBX, GLTF, GLB, DAE, 3DS … et les convertit en une structure unifiée que tu peux envoyer à Metal. C'est ce que notre classe AssimpBlender utilise sous le capot.

Grâce aux logiciels de création 3D professionnels tels que Blender (gratuit),
3ds Max ou encore Maya, tu peux plus facilement produire des scènes et des personnages détaillés, sans avoir à définir manuellement chaque sommet, chaque normale, chaque coordonnée de texture, ou coder toi-même les animations par squelette (bones).

Dans ces programmes, on applique les textures via un processus appelé cartographie UV (UV mapping). On exporte ensuite la scène vers un format de fichier modèle que ton moteur peut lire. C'est la meilleure façon d'afficher à l'écran ce que tu
"ne peux pas" coder à la main.

D'une certaine manière, tu dois donc parser le format de fichier choisi et coder un importateur pour Metal ou OpenGL — c'est exactement le rôle qu'Assimp joue ici.

Licence — ce que tu peux faire

BSD 3-Clause License
Assimp est sous licence BSD 3-Clause — tu peux l'utiliser dans un projet commercial, open source ou fermé, gratuit ou payant, sans redevance. Tu dois juste conserver l'avis de copyright dans ta documentation ou ton about screen. Tu n'as pas à rendre ton code source public.
// Mention obligatoire quelque part dans ton app (About, README.md, CREDITS) :
// "This software uses the Assimp library (https://github.com/assimp/assimp)"
// "Copyright (c) 2006-2026, assimp team — BSD 3-Clause License"
Licenses

Les licences CC-PDDC, MIT, BSL-1.0, Unlicense et Zlib est la somme \( \Sigma \) des licences de tous les parseurs intégrés dans Assimp (chaque loader de format tiers a sa propre licence).

Documentation officielle

Les liens essentiels pour ne pas se perdre :

Installer Assimp sur macOS

Via Git (moins recommandé)

# Shell
## clône le projet Assimp :
cd && git clone https://github.com/assimp/assimp.git repoAssimp
        
# dans le dossier repoAssimp, retrouves le dossier assimp
## copie récursivement le dossier "assimp" dans le dossier "Frameworks"
cp -R ~/repoAssimp/include/assimp ~/MSLearn/Frameworks/

Via Homebrew (le plus simple)

# Shell
brew install assimp
## vérifier l'installation
brew info assimp
## → /opt/homebrew/Cellar/assimp/6.0.5 (Apple Silicon)
## → /usr/local/Cellar/assimp/5.x.x (Retina)

Flags Makefile déjà en place dans le chapitre dédié

# Shell
## headers et librairie dans le Makefile :
ASSIMP_PREFIX := $(shell brew --prefix assimp)

CXXFLAGS += -I$(ASSIMP_PREFIX)/include
LDFLAGS  += -L$(ASSIMP_PREFIX)/lib -lassimp

Assimp sur Xcode

# Shell — update:25/06/26
## trouves ton identité de signature :
security find-identity -v -p basic
## signer la dylib Homebrew avec ton certificat Apple Developer :
codesign --force --sign "Apple Development: toi@icloud.com (HELLO4YOU2)" /opt/homebrew/opt/assimp/lib/libassimp.6.0.5.dylib

Inclus Assimp : MSLearn.xcodeproj (le 1er en bleu), dans "Targets" → "LearnMSL MacOS" → dans Build Settings : 2 étapes

→ Header Search Paths → ajoutes /opt/homebrew/opt/assimp/include (double-click)

→ Other Linker Flags → ajoutes les 2 flags séparement -lassimp
-L/opt/homebrew/opt/assimp/lib

Structure minimale du projet (brew)

4 fichiers supplémentaires comme d'habitude + tes modèles & scènes externes.

MSLearn/
├── MSLearn.entitlements
├── Application/
│   ├── main.m
│   ├── AppViewController.h
│   ├── AppViewController.mm
│   └── macOS/ …
│       └── macOSInfo.plist
├── Frameworks/
│   ├── metal-cpp/ …
│   └── metal-cpp-extensions/ …
├── Renderer/ …
│   ├── AssimpBlender.cpp
│   ├── AssimpBlender.hpp
│   ├── Renderer.cpp
│   └── Renderer.hpp
├── Shaders/ …
│   └── AssimpBlender.metal
├── includes/ …
│   ├── SharedGPU/ …
│   │   └── AssimpBlender_shared.h
│   ├── extern/
│   │   └── stb-image.h
│   └── SharedAPP/
│       └── InputState.h
├── Odonata.glb                     # export glFT2.0 (Binary .glb)
├── .gitignore
├── .git
└── Makefile

Ton fichier partagé

Pour les modèles ne possédant pas de squelette, nous ne déclarons pas de matrices pour stocker les os :

/// C++ — AssimpBlender_shared.h — update:25/06/26
#include <simd/simd.h>

struct BlenderUniforms
{
    simd::float4x4 modelMatrix;
    simd::float4x4 viewProjectionMatrix;
};

struct BlenderUniformsFull
{
    simd::float4x4 modelMatrix;
    simd::float4x4 viewProjectionMatrix;
    simd::float4x4 boneMatrices[89];  // nombre de bones maximum que possède ton + grand modèle
};

Ton fichier header AssimpBlender

/// C++ — AssimpBlender.hpp — update:25/06/26
#ifndef AssimpBlender_hpp
#define AssimpBlender_hpp

#include <assimp/Importer.hpp>
#include <assimp/scene.h>
#include <assimp/postprocess.h>

#include "../includes/SharedGPU/AssimpBlender_shared.h"
#include "../includes/SharedGPU/Renderer_shared.h"

#endif /* AssimpBlender_hpp */

Un seul modèle comprend énormément d'éléments

Nous voulons que notre code puisse charger beaucoup de modèles différents, gérer les animations de manière pratique et ne pas rencontrer de crash discret.

Le chapitre présente alors un modèle de slot handle, un chargement et déchargement des éléments sans limites. Comprenant toutes les nomenclatûres

Pour commencer, 2 fonctions pour convertir les données et créer nos matrices 4x4, nous travaillons avec la bibliothèque simd alors que aiScene utilise aiMatrix4x4.

/// C++ — AssimpBlender.cpp — update:23/08/26
#include "AssimpBlender.hpp"

#include "../includes/extern/stb_image.h"

static simd::float4x4 aiToSimd(const aiMatrix4x4& m) // ai vers simd
{
    return simd::float4x4{simd::float4{m.a1, m.b1, m.c1, m.d1},
                          simd::float4{m.a2, m.b2, m.c2, m.d2},
                          simd::float4{m.a3, m.b3, m.c3, m.d3},
                          simd::float4{m.a4, m.b4, m.c4, m.d4}};
}

static simd::float4x4 makeTRS(simd::float3 t, simd::quatf r, simd::float3 s)
{   // faire la translation, rotation et mise à l'échelle
    simd::float4x4 T{simd::float4{   1,   0,   0, 0 },
                     simd::float4{   0,   1,   0, 0 },
                     simd::float4{   0,   0,   1, 0 },
                     simd::float4{ t.x, t.y, t.z, 1 }};

    simd::float4x4 S{simd::float4{ s.x,   0,   0, 0 },
                     simd::float4{ 0,   s.y,   0, 0 },
                     simd::float4{ 0,     0, s.z, 0 },
                     simd::float4{ 0,     0,   0, 1 }};

    return T * simd::float4x4(r) * S;
}

Une seule classe qui va charger et afficher tes modèles — du solide

/// C++ — Exemple — update:23/08/26
AssimpBlender::AssimpBlender(MTL::Device* device, MTL::Library* shaderLibrary,
                             MTL::PixelFormat colorPixelFormat, MTL::PixelFormat depthPixelFormat,
                             const std::string& resourcesPath)
: m_device(device->retain())
{
    createPipeline(shaderLibrary, colorPixelFormat, depthPixelFormat);
    createSampler();
}

AssimpBlender::~AssimpBlender()
{
    for (auto& slot : m_slots)
        if (slot.alive) // booléan dans la structure ModelSlot
            slot.model.release();

    m_renderPipelineStateFull->release();
    m_renderPipelineState->release();
    m_depthStencilState->release();
    m_samplerState->release();
    m_device->release();
}
/// C++ — AssimpBlender.hpp — update:23/08/26
// c'est littéralement le même principe que le système d'InstanceID d'Unity
struct ModelHandle
{
    uint32_t index      = UINT32_MAX;
    uint32_t generation = 0;

    bool isValid() const { return index != UINT32_MAX; }
    // voir chapitre POO
    bool operator==(const ModelHandle& other) const
    {
        return index == other.index && generation == other.generation;
    }
    bool operator!=(const ModelHandle& other) const {
        return !(*this == other);
    }
};

// Handle nul explicite, pratique pour initialiser des membres ailleurs dans le moteur.
static constexpr ModelHandle kInvalidModelHandle{};
Handle
→ est juste une valeur (souvent un entier, ou une petite struct comme ici) que tu passes à un système qui, lui, sait comment la traduire en accès réel — via une table de lookup interne (resolveSlot() dans ton cas). Le système peut détecter si le handle est encore valide avant de te donner l'accès.
Pointeur
→ Donne l'adresse mémoire exacte de l'objet. Si l'objet est déplacé, détruit, ou que le vector qui le contient réalloue, ton pointeur devient invalide silencieusement — accès mémoire indéfini (crash, ou pire, corruption silencieuse).
/// C++ — AssimpBlender.hpp — update:23/08/26
AssimpBlender::ModelSlot* AssimpBlender::resolveSlot(ModelHandle handle)
{
    if (!handle.isValid() || handle.index >= m_slots.size())
    return nullptr;

    ModelSlot& slot = m_slots[handle.index];
    if (!slot.alive || slot.generation != handle.generation)
    return nullptr; // handle périmé : slot recyclé pour un autre modèle

    return &slot;
}

const AssimpBlender::ModelSlot* AssimpBlender::resolveSlot(ModelHandle handle) const
{
    if (!handle.isValid() || handle.index >= m_slots.size())
        return nullptr;
        
    const ModelSlot& slot = m_slots[handle.index];
    if (!slot.alive || slot.generation != handle.generation)
        return nullptr;
        
    return &slot;
}

bool AssimpBlender::isValid(ModelHandle handle) const
{
    return resolveSlot(handle) != nullptr;
}

/// C++ — AssimpBlender.hpp — update:23/08/26
#include <MetalKit/MetalKit.hpp>

#include <simd/simd.h>
#include <vector>
#include <string>
#include <unordered_map>
#include <functional>
#include <cstdint>

struct Blender // structure reprenant toutes les données de ton modèle exporté
{
    std::string name;
};

class AssimpBlender
{
public:
    AssimpBlender(MTL::Device*, MTL::Library*,
                  MTL::PixelFormat colorPixelFormat, MTL::PixelFormat depthPixelFormat,
                  const std::string& resourcePath);
    ~AssimpBlender();
        
    ModelHandle loadModel(const std::string& resourcesPath, const std::string& name = "");
    void        unloadModel(ModelHandle handle);
        
    void createPipeline(MTL::Library*, MTL::PixelFormat, MTL::PixelFormat);
    void update(float deltaTime);
    void draw(MTL::RenderCommandEncoder*, const GlobalUniforms& uniforms);
    void drawBlender(MTL::RenderCommandEncoder*, ModelHandle handle,
                     const simd::float4x4& model, const GlobalUniforms& uniforms);

private:
    struct ModelSlot
    {
        Blender  model;
        uint32_t generation = 0;
        bool     alive      = false;
    };  // m_slots[5] alive = true generation = 3

    MTL::Device*                m_device;
    MTL::SamplerState*          m_samplerState = nullptr;
    MTL::DepthStencilState*     m_depthStencilState;
    MTL::RenderPipelineState*   m_renderPipelineState;
    MTL::RenderPipelineState*   m_renderPipelineStateFull;

    std::vector<ModelSlot>                        m_slots;
    std::vector<uint32_t>                         m_freeList; // nb slot libre
    std::unordered_map<std::string, ModelHandle>  m_modelNameToHandle;
    size_t                                        m_aliveCount = 0; // nb modèle load

    // Résout un handle vers son slot, ou nullptr si invalide/périmé.
    // Toutes les méthodes publiques passent par ici — un seul point de vérité
    // pour la validation, pas de borne-check dupliqué partout.
    ModelSlot* resolveSlot(ModelHandle handle);
    const ModelSlot* resolveSlot(ModelHandle handle) const;
};

Pour une meilleure compréhension de ce qui se passe sous le capot, je t'invite à te rendre dans le parcourt, tu y trouveras un programme qui simule la gestion des modèles.

Après lecture des documentations

glTF 2.0 — en particulier la section "Meshes" qui liste exactement les attributs standards et leurs types & les headers Assimp et leurs commentaires — assimp/mesh.h qui documente aiMesh et aiBone en détail, la classe peut se construire.

Inclus toutes ces données de tes modèles.

/// C++ — AssimpBlender.hpp — update:23/08/26
struct VertexBlender
{
    simd::float3 position;
    simd::float3 normal;
    simd::float2 texCoord;
};

struct VertexBlenderFull
{
    simd::float3 position;
    simd::float3 normal;
    simd::float2 texCoord;
    simd::int4   joints; // 4 os d'influences par vertex est le standard de l'industrie
    simd::float4 weights;
};

La structure aiScene — comment tout est organisé

Quand Assimp charge un fichier, il retourne un pointeur vers un objet / une aiScene qui contient tout. La scène est organisée en une hiérarchie de nœuds (nodes) : chaque nœud possède sa propre matrice de transformation locale, un ensemble d'indices vers les meshes de la scène, et peut avoir n'importe quel nombre d'enfants. C'est cette hiérarchie que notre fonction copyNodeHierarchy() parcourt pour reconstruire ton propre arbre de nodes côté moteur.

/// C++ — AssimpBlender.cpp — update:23/08/26
ModelHandle AssimpBlender::loadModel(const std::string& resourcesPath, const std::string& name)
{
    Assimp::Importer importer;

    const aiScene* scene = importer.ReadFile(resourcesPath,
                                             aiProcess_Triangulate |       // convertit quads → triangles
                                             aiProcess_FlipUVs |       // export en Y-up, Metal en Y-down
                                             aiProcess_CalcTangentSpace | // calcule tan pour normal maps
                                             aiProcess_GenNormals |        // génère normales si absentes
                                             aiProcess_JoinIdenticalVertices | // déduplique les vertices
                                             aiProcess_LimitBoneWeights |   // 4 bones/vertex → GPU limit
                                             aiProcess_SortByPType |
                                             aiProcess_GenSmoothNormals |
                                             aiProcess_MakeLeftHanded |     // Metal = left-handed !!
                                             aiProcess_FlipWindingOrder);   // avec MakeLeftHanded

    if (!scene || scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || !scene->mRootNode)
    {
        printf("Assimp Error: %s\n", importer.GetErrorString());
        return kInvalidModelHandle;
    }

    Blender model;
    model.name = name.empty() ? resourcesPath : name;

    if (m_modelNameToHandle.find(model.name) != m_modelNameToHandle.end())
        printf("Name '%s' exist, the new one will take over the name lookup", model.name.c_str());

    model.rootNode = copyNodeHierarchy(scene->mRootNode);

    bool hasBones = modelHasBones(scene->mRootNode, scene);

    if (hasBones && scene->mNumAnimations > 0)
    {
        model.hasAnimation = true;
        processNodeSkinned(scene->mRootNode, scene, model);
        model.boneMatrices.resize(model.boneCount, matrix_identity_float4x4);
        model.uniformBuffer = m_device->newBuffer(sizeof(BlenderUniformsFull),
                                                  MTL::ResourceStorageModeShared);
        loadAnimations(scene, model);

        model.vertexBuffer = m_device->newBuffer(model.verticesFull.data(),
                                                 model.verticesFull.size() * sizeof(VertexBlenderFull),
                                                 MTL::ResourceStorageModeShared);
    }
    else
    {
        model.hasAnimation = false;
        processNodeStatic(scene->mRootNode, scene, model);
        model.uniformBuffer = m_device->newBuffer(sizeof(BlenderUniforms),
                                                  MTL::ResourceStorageModeShared);

        model.vertexBuffer = m_device->newBuffer(model.vertices.data(),
                                                 model.vertices.size() * sizeof(VertexBlender),
                                                 MTL::ResourceStorageModeShared);
    }

    loadTextures(scene, model, resourcesPath);

    model.indexBuffer = m_device->newBuffer(model.indices.data(),
                                            model.indices.size() * sizeof(uint32_t),
                                            MTL::ResourceStorageModeShared);

    ModelHandle handle;

    if (!m_freeList.empty())
    {
        uint32_t idx = m_freeList.back();
        m_freeList.pop_back();

        ModelSlot& slot = m_slots[idx];
        slot.model = std::move(model);
        slot.alive = true;
        // slot.generation a déjà été incrémentée par unloadModel()
        handle = { idx, slot.generation };
    }
    else
    {
        ModelSlot slot;
        slot.model      = std::move(model);
        slot.alive      = true;
        slot.generation = 0;
        m_slots.push_back(std::move(slot));

        handle = { static_cast<uint32_t>(m_slots.size() - 1), 0 };
    }

    m_modelNameToHandle[m_slots[handle.index].model.name] = handle;
    m_aliveCount++;
    return handle;
}

Déchargement éventuel de modèles/scènes

/// C++ — AssimpBlender.cpp — update:23/08/26
void AssimpBlender::unloadModel(ModelHandle handle)
{
    ModelSlot* slot = resolveSlot(handle);
    if (!slot)
        return; // déjà déchargé, ou handle jamais valide -> no-op silencieux

    m_modelNameToHandle.erase(slot->model.name);

    slot->model.release();
    slot->model = Blender{}; // vide les vecteurs CPU (vertices, indices, animations...)
    slot->alive = false;
    slot->generation++; // périme tous les handles existants vers ce slot

    m_freeList.push_back(handle.index);
    m_aliveCount--;
}

Meshes — extraire vertices et indices

// processMeshStatic() — 
void processMeshStatic(aiMesh* pMesh, Blender& model)
{
    uint32_t baseVertex = model.vertices.size(); // offset si plusieurs meshes

    for (unsigned i = 0; i < pMesh->mNumVertices; i++) {
        VertexBlender v;
        // Position — toujours présente
        v.position = {pMesh->mVertices[i].x,
                      pMesh->mVertices[i].y,
                      pMesh->mVertices[i].z};

        // Normale — vérifier avec HasNormals() avant d'accéder
        v.normal = pMesh->HasNormals()
            ? simd::float3{pMesh->mNormals[i].x, pMesh->mNormals[i].y, pMesh->mNormals[i].z}
            : simd::float3{0, 1, 0}; // fallback Y-up

        // UV — mTextureCoords[0] = premier canal UV
        // Assimp supporte jusqu'à AI_MAX_NUMBER_OF_TEXTURECOORDS (8) canaux
        v.texCoord = pMesh->mTextureCoords[0]
            ? simd::float2{pMesh->mTextureCoords[0][i].x, pMesh->mTextureCoords[0][i].y}
            : simd::float2{0, 0};

        model.vertices.push_back(v);
    }

    // Indices — aiFace contient les indices d'un triangle
    for (unsigned i = 0; i < pMesh->mNumFaces; i++) {
        aiFace& face = pMesh->mFaces[i];
        // aiProcess_Triangulate garantit mNumIndices == 3
        for (unsigned j = 0; j < face.mNumIndices; j++)
            model.indices.push_back(baseVertex + face.mIndices[j]);
    }
}

Skeletal animation — bones et weights

Le skinning GPU consiste à déformer chaque vertex selon la position actuelle de ses os. Chaque vertex a jusqu'à 4 paires (bone_id, weight). C'est la limite hardware GPU standard.

// loadBones()
for (unsigned i = 0; i < pMesh->mNumBones; i++) {
    aiBone* pBone = pMesh->mBones[i];
    std::string name = pBone->mName.C_Str();

    // BoneMap : nom → {id, offset matrix}
    // L'offset matrix = inverse bind pose = transform pour ramener
    // le vertex de world space vers bone space
    if (model.boneMap.find(name) == model.boneMap.end()) {
        model.boneMap[name] = {model.boneCount++, aiToSimd(pBone->mOffsetMatrix)};
    }

    // Remplir joints[] et weights[] pour chaque vertex influencé
    for (unsigned j = 0; j < pBone->mNumWeights; j++) {
        unsigned vertID = baseVertex + pBone->mWeights[j].mVertexId;
        float weight    = pBone->mWeights[j].mWeight;
        // Chercher un slot libre dans les 4 joints
        for (int k = 0; k < 4; k++) {
            if (v.joints[k] < 0) { v.joints[k] = boneID; v.weights[k] = weight; break; }
        }
    }
}

// Après tous les bones — normaliser les weights (somme = 1.0) :
float sum = v.weights.x + v.weights.y + v.weights.z + v.weights.w;
if (sum > 0.0f) v.weights /= sum;
else { v.joints.x = 0; v.weights.x = 1.0f; } // fallback bone 0
Le fallback bone 0
Si un vertex n'a aucun weight (possible sur des meshes mal exportés depuis Blender), il faut lui assigner le bone 0 avec weight 1.0 — sinon il reste à l'origine et crée des artefacts visuels. C'est ce que fait ton code dans processMeshSkinned() pour les meshes sans bones.

Hiérarchie de nodes — copyNodeHierarchy()

Assimp organise la scène en arbre de aiNode. Ton code copie cet arbre en NodeData avant que l'importer soit détruit — c'est crucial car les pointeurs aiNode deviennent invalides après.

// PIÈGE : l'Importer détruit la scene à la fin du scope
// → tu ne peux PAS garder des pointeurs aiNode* après loadModel()
// → c'est pour ça que copyNodeHierarchy() existe dans AssimpBlender

NodeData copyNodeHierarchy(aiNode* node) {
    NodeData data;
    data.name      = node->mName.C_Str();
    data.transform = aiToSimd(node->mTransformation); // copie la matrice
    for (unsigned i = 0; i < node->mNumChildren; i++)
        data.children.push_back(copyNodeHierarchy(node->mChildren[i]));
    return data; // tout est copié par valeur — safe
}

Animations — aiAnimation → BoneAnimation

// loadAnimations() — structure d'une animation Assimp :
for (unsigned i = 0; i < scene->mNumAnimations; i++) {
    aiAnimation* anim = scene->mAnimations[i];

    // duration en ticks, ticksPerSec pour convertir en secondes
    // ⚠ ton code hardcode 500.f pour ticksPerSec — à vérifier selon l'export Blender
    a.ticksPerSec = anim->mTicksPerSecond > 0 ? (float)anim->mTicksPerSecond : 24.0f;

    // Chaque channel = un bone animé
    for (unsigned c = 0; c < anim->mNumChannels; c++) {
        aiNodeAnim* ch = anim->mChannels[c];
        // ch->mNodeName = nom du bone dans la hiérarchie
        // ch->mPositionKeys, mRotationKeys, mScalingKeys = keyframes TRS
    }
}
⚠ ticksPerSecond hardcodé à 500
Dans ton loadAnimations(), ticksPerSec est forcé à 500.f. Si tes animations jouent trop vite ou trop lentement, c'est la cause. Utilise anim->mTicksPerSecond directement — Blender exporte généralement à 24 ou 30 FPS. Le 500.f était probablement un test.

Textures embarquées — GLB

Le format GLB embarque les textures directement dans le fichier binaire. Assimp les expose via scene->mTextures[]. Ton loadEmbeddedTexture() les décode avec stb_image.

// Identifier une texture embarquée — le path commence par '*' :
if (path.data[0] == '*') {
    int idx = atoi(path.data + 1);           // index dans scene->mTextures
    aiTexture* tex = scene->mTextures[idx];

    if (tex->mHeight == 0) {
        // Texture compressée (PNG/JPG) — mWidth = taille en bytes
        data = stbi_load_from_memory(
            (unsigned char*)tex->pcData, tex->mWidth,
            &width, &height, &channels, 4);
    } else {
        // Texture décompressée — mWidth/mHeight = dimensions
        data = (unsigned char*)tex->pcData;
    }
}

Si le fichier stb_image.h est déjà inclus ailleurs dans ton projet dans un fichier header, pour le chapitre à propos du texte par exemple, inclus-le dans les .cpp afin de ne pas rencontrer d'erreur(s).

/// C++ — AssimpBlender.cpp — update:25/06/26
#include "AssimpBlender.hpp"

#include "../includes/extern/stb_image.h"

static simd::float4x4 aiToSimd(const aiMatrix4x4& m)
{
    return simd::float4x4{simd::float4{m.a1, m.b1, m.c1, m.d1},
                          simd::float4{m.a2, m.b2, m.c2, m.d2},
                          simd::float4{m.a3, m.b3, m.c3, m.d3},
                          simd::float4{m.a4, m.b4, m.c4, m.d4}};
}

static simd::float4x4 makeTRS(simd::float3 t, simd::quatf r, simd::float3 s)
{
    simd::float4x4 T{simd::float4{   1,   0,   0, 0 },
                     simd::float4{   0,   1,   0, 0 },
                     simd::float4{   0,   0,   1, 0 },
                     simd::float4{ t.x, t.y, t.z, 1 }};

    simd::float4x4 S{simd::float4{ s.x,   0,   0, 0 },
                     simd::float4{ 0,   s.y,   0, 0 },
                     simd::float4{ 0,     0, s.z, 0 },
                     simd::float4{ 0,     0,   0, 1 }};

    return T * simd::float4x4(r) * S;
}

AssimpBlender::AssimpBlender(MTL::Device* device, MTL::Library* shaderLibrary,
                             MTL::PixelFormat colorPixelFormat, MTL::PixelFormat depthPixelFormat,
                             const std::string& resourcesPath)
: m_device(device->retain())
{
    createPipeline(shaderLibrary, colorPixelFormat, depthPixelFormat);
    createSampler();
}

AssimpBlender::~AssimpBlender()
{
    for (auto& model : m_models) model.release();
    if (_pPipelineStateBlender) _pPipelineStateBlender->release();
    if (_pPipelineStateBlenderFull) _pPipelineStateBlenderFull->release();
    if (_pDepthState) _pDepthState->release();
    if (_pSampler) _pSampler->release();
    if (m_device) m_device->release();
}

size_t AssimpBlender::loadModel(const std::string& resourcesPath, const std::string& name)
{
    Assimp::Importer importer;

    const aiScene* scene = importer.ReadFile(resourcesPath, aiProcess_Triangulate | aiProcess_FlipUVs |
                                             aiProcess_CalcTangentSpace | aiProcess_GenNormals |
                                             aiProcess_JoinIdenticalVertices | aiProcess_LimitBoneWeights |
                                             aiProcess_SortByPType | aiProcess_GenSmoothNormals |
                                             aiProcess_MakeLeftHanded | aiProcess_FlipWindingOrder);
    
    if (!scene || scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || !scene->mRootNode)
    {
        printf("Assimp Error: %s\n", importer.GetErrorString());
        return static_cast<size_t>(-1);
    }

    Blender model;
    model.name = name.empty() ? resourcesPath : name;
    if (m_modelNameToIndex.find(model.name) != m_modelNameToIndex.end())
    {
        printf("Warning: model name '%s' already loaded, overwriting index in name map\n", model.name.c_str());
    }
    model.rootNode = copyNodeHierarchy(scene->mRootNode);
    
    bool hasBones = modelHasBones(scene->mRootNode, scene);
    
    if (hasBones && scene->mNumAnimations > 0)
    {
        model.hasAnimation = true;
        processNodeSkinned(scene->mRootNode, scene, model);
        model.boneMatrices.resize(model.boneCount, matrix_identity_float4x4);
        model.uniformBuffer = m_device->newBuffer(sizeof(BlenderUniformsFull), MTL::ResourceStorageModeShared);
        loadAnimations(scene, model);
        
        model.vertexBuffer = m_device->newBuffer(model.verticesFull.data(), model.verticesFull.size() * sizeof(VertexBlenderFull), MTL::ResourceStorageModeShared);
    }
    else
    {
        model.hasAnimation = false;
        processNodeStatic(scene->mRootNode, scene, model);
        model.uniformBuffer = m_device->newBuffer(sizeof(BlenderUniforms), MTL::ResourceStorageModeShared);
        
        model.vertexBuffer = m_device->newBuffer(model.vertices.data(), model.vertices.size() * sizeof(VertexBlender), MTL::ResourceStorageModeShared);
    }
    
    std::string path = resourcesPath.substr(0, resourcesPath.find_last_of('/'));
    loadTextures(scene, model, resourcesPath);
    
    model.indexBuffer = m_device->newBuffer(model.indices.data(), model.indices.size() * sizeof(uint32_t), MTL::ResourceStorageModeShared);
    
    size_t newIndex = m_models.size();
    m_modelNameToIndex[model.name] = newIndex; // enregistrement O(1)
    m_models.push_back(std::move(model));
    return newIndex;
}

Blender* AssimpBlender::getModel(size_t index)
{
    return index < m_models.size() ? &m_models[index] : nullptr;
}

Blender* AssimpBlender::getModel(const std::string& name)
{
    auto it = m_modelNameToIndex.find(name);
    if (it == m_modelNameToIndex.end())
        return nullptr;
    return getModel(it->second);
}

NodeData AssimpBlender::copyNodeHierarchy(aiNode* node)
{
    NodeData data;
    data.name = node->mName.C_Str();
    data.transform = aiToSimd(node->mTransformation);
    data.children.reserve(node->mNumChildren);
    for (unsigned i = 0; i < node->mNumChildren; i++)
        data.children.push_back(copyNodeHierarchy(node->mChildren[i]));
    return data;
}

bool AssimpBlender::modelHasBones(aiNode* pNode, const aiScene* pScene)
{
    for (unsigned i = 0; i < pNode->mNumMeshes; i++)
        if (pScene->mMeshes[pNode->mMeshes[i]]->mNumBones > 0) return true;
    for (unsigned i = 0; i < pNode->mNumChildren; i++)
        if (modelHasBones(pNode->mChildren[i], pScene)) return true;
    return false;
}

void AssimpBlender::processNodeStatic(aiNode* pNode, const aiScene* pScene, Blender& model)
{
    for (unsigned i = 0; i < pNode->mNumMeshes; i++)
        processMeshStatic(pScene->mMeshes[pNode->mMeshes[i]], model);
    for (unsigned i = 0; i < pNode->mNumChildren; i++)
        processNodeStatic(pNode->mChildren[i], pScene, model);
}

void AssimpBlender::processNodeSkinned(aiNode* pNode, const aiScene* pScene, Blender& model)
{
    for (unsigned i = 0; i < pNode->mNumMeshes; i++)
        processMeshSkinned(pScene->mMeshes[pNode->mMeshes[i]], model);
    for (unsigned i = 0; i < pNode->mNumChildren; i++)
        processNodeSkinned(pNode->mChildren[i], pScene, model);
}

void AssimpBlender::processMeshStatic(aiMesh* pMesh, Blender& model)
{
    uint32_t baseVertex = static_cast<uint32_t>(model.vertices.size());
    
    for (unsigned i = 0; i < pMesh->mNumVertices; i++)
    {
        VertexBlender v;
        v.position = {pMesh->mVertices[i].x, pMesh->mVertices[i].y, pMesh->mVertices[i].z};
        v.normal = pMesh->HasNormals()
            ? simd::float3{pMesh->mNormals[i].x, pMesh->mNormals[i].y, pMesh->mNormals[i].z}
            : simd::float3{0, 1, 0};
        v.texCoord = pMesh->mTextureCoords[0]
            ? simd::float2{pMesh->mTextureCoords[0][i].x, pMesh->mTextureCoords[0][i].y}
            : simd::float2{0, 0};
        model.vertices.push_back(v);
    }
    
    for (unsigned i = 0; i < pMesh->mNumFaces; i++)
    {
        aiFace& face = pMesh->mFaces[i];
        for (unsigned j = 0; j < face.mNumIndices; j++)
            model.indices.push_back(baseVertex + face.mIndices[j]);
    }
}

void AssimpBlender::processMeshSkinned(aiMesh* pMesh, Blender& model)
{
    uint32_t baseVertex = static_cast<uint32_t>(model.verticesFull.size());
    
    for (unsigned i = 0; i < pMesh->mNumVertices; i++)
    {
        VertexBlenderFull v;
        v.position = {pMesh->mVertices[i].x, pMesh->mVertices[i].y, pMesh->mVertices[i].z};
        v.normal = pMesh->HasNormals()
            ? simd::float3{pMesh->mNormals[i].x, pMesh->mNormals[i].y, pMesh->mNormals[i].z}
            : simd::float3{0, 1, 0};
        v.texCoord = pMesh->mTextureCoords[0]
            ? simd::float2{pMesh->mTextureCoords[0][i].x, pMesh->mTextureCoords[0][i].y}
            : simd::float2{0, 0};
        v.joints = {-1, -1, -1, -1};
        v.weights = {0, 0, 0, 0};
        model.verticesFull.push_back(v);
    }
    
    if (pMesh->mNumBones > 0)
    {
        loadBones(pMesh, model, baseVertex);

        for (unsigned i = baseVertex; i < model.verticesFull.size(); i++)
        {
            auto& v = model.verticesFull[i];
            float sum = v.weights.x + v.weights.y + v.weights.z + v.weights.w;
            
            if (sum > 0.0f)
                v.weights /= sum;
            else
            {
                v.joints.x = 0;
                v.weights.x = 1.0f;
            }
        }
    }
    else
    {
        for (unsigned i = baseVertex; i < model.verticesFull.size(); i++)
        {
            model.verticesFull[i].joints = {0, 0, 0, 0};
            model.verticesFull[i].weights = {1, 0, 0, 0};
        }
    }
    
    for (unsigned i = 0; i < pMesh->mNumFaces; i++)
    {
        aiFace& face = pMesh->mFaces[i];

        for (unsigned j = 0; j < face.mNumIndices; j++)
            model.indices.push_back(baseVertex + face.mIndices[j]);
    }
}

void AssimpBlender::loadBones(aiMesh* pMesh, Blender& model, uint32_t baseVertex)
{
    for (unsigned i = 0; i < pMesh->mNumBones; i++)
    {
        aiBone* pBone = pMesh->mBones[i];
        std::string name = pBone->mName.C_Str();
        int boneID;
        
        auto it = model.boneMap.find(name);
        if (it == model.boneMap.end())
        {
            boneID = model.boneCount++;
            model.boneMap[name] = {boneID, aiToSimd(pBone->mOffsetMatrix)};
        }
        else
            boneID = it->second.id;
        
        for (unsigned j = 0; j < pBone->mNumWeights; j++)
        {
            unsigned vertID = baseVertex + pBone->mWeights[j].mVertexId;
            float weight = pBone->mWeights[j].mWeight;
            auto& v = model.verticesFull[vertID];
            
            for (int k = 0; k < 4; k++)
            {
                if (v.joints[k] < 0)
                {
                    v.joints[k] = boneID;
                    v.weights[k] = weight;
                    break;
                }
            }
        }
    }
}

void AssimpBlender::createPipeline(MTL::Library* shaderLibrary, MTL::PixelFormat colorPixelFormat, MTL::PixelFormat depthPixelFormat)
{
    NS::SharedPtr<MTL::VertexDescriptor> vertexDescriptor = NS::TransferPtr(MTL::VertexDescriptor::alloc()->init());

    vertexDescriptor->attributes()->object(0)->setFormat(MTL::VertexFormatFloat3);
    vertexDescriptor->attributes()->object(0)->setOffset(offsetof(VertexBlender, position));
    vertexDescriptor->attributes()->object(0)->setBufferIndex(0);

    vertexDescriptor->attributes()->object(1)->setFormat(MTL::VertexFormatFloat3);
    vertexDescriptor->attributes()->object(1)->setOffset(offsetof(VertexBlender, normal));
    vertexDescriptor->attributes()->object(1)->setBufferIndex(0);
    
    vertexDescriptor->attributes()->object(2)->setFormat(MTL::VertexFormatFloat2);
    vertexDescriptor->attributes()->object(2)->setOffset(offsetof(VertexBlender, texCoord));
    vertexDescriptor->attributes()->object(2)->setBufferIndex(0);
    
    vertexDescriptor->layouts()->object(0)->setStride(sizeof(VertexBlender));
    
    NS::SharedPtr<MTL::VertexDescriptor> vertexDescriptorAnyme = NS::TransferPtr(MTL::VertexDescriptor::alloc()->init());

    vertexDescriptorAnyme->attributes()->object(0)->setFormat(MTL::VertexFormatFloat3);
    vertexDescriptorAnyme->attributes()->object(0)->setOffset(offsetof(VertexBlenderFull, position));
    vertexDescriptorAnyme->attributes()->object(0)->setBufferIndex(0);

    vertexDescriptorAnyme->attributes()->object(1)->setFormat(MTL::VertexFormatFloat3);
    vertexDescriptorAnyme->attributes()->object(1)->setOffset(offsetof(VertexBlenderFull, normal));
    vertexDescriptorAnyme->attributes()->object(1)->setBufferIndex(0);
    
    vertexDescriptorAnyme->attributes()->object(2)->setFormat(MTL::VertexFormatFloat2);
    vertexDescriptorAnyme->attributes()->object(2)->setOffset(offsetof(VertexBlenderFull, texCoord));
    vertexDescriptorAnyme->attributes()->object(2)->setBufferIndex(0);
    
    vertexDescriptorAnyme->attributes()->object(3)->setFormat(MTL::VertexFormatInt4);
    vertexDescriptorAnyme->attributes()->object(3)->setOffset(offsetof(VertexBlenderFull, joints));
    vertexDescriptorAnyme->attributes()->object(3)->setBufferIndex(0);
    
    vertexDescriptorAnyme->attributes()->object(4)->setFormat(MTL::VertexFormatFloat4);
    vertexDescriptorAnyme->attributes()->object(4)->setOffset(offsetof(VertexBlenderFull, weights));
    vertexDescriptorAnyme->attributes()->object(4)->setBufferIndex(0);
    
    vertexDescriptorAnyme->layouts()->object(0)->setStride(sizeof(VertexBlenderFull));
//    vertexDescFull->layouts()->object(0)->setStepFunction(MTL::VertexStepFunctionPerVertex);
    
    NS::SharedPtr<MTL::RenderPipelineDescriptor> renderPipelineDesc = NS::TransferPtr(MTL::RenderPipelineDescriptor::alloc()->init());
    renderPipelineDesc->setVertexFunction(shaderLibrary->newFunction(NS::String::string("vertex_Assimp", NS::UTF8StringEncoding)));
    renderPipelineDesc->setFragmentFunction(shaderLibrary->newFunction(NS::String::string("fragment_Assimp", NS::UTF8StringEncoding)));
    renderPipelineDesc->setVertexDescriptor(vertexDescriptor.get());
    renderPipelineDesc->colorAttachments()->object(0)->setPixelFormat(colorPixelFormat);
    renderPipelineDesc->setDepthAttachmentPixelFormat(depthPixelFormat);

    NS::Error* pError = nullptr;
    _pPipelineStateBlender = m_device->newRenderPipelineState(renderPipelineDesc.get(), &pError);
    
    renderPipelineDesc->setVertexFunction(shaderLibrary->newFunction(MTLSTR("vertex_AssimpAnyme")));
    renderPipelineDesc->setVertexDescriptor(vertexDescriptorAnyme.get());
    _pPipelineStateBlenderFull = m_device->newRenderPipelineState(renderPipelineDesc.get(), &pError);

    NS::SharedPtr<MTL::DepthStencilDescriptor> depthStencilDesc = NS::TransferPtr(MTL::DepthStencilDescriptor::alloc()->init());
    depthStencilDesc->setDepthCompareFunction(MTL::CompareFunctionLess);
    depthStencilDesc->setDepthWriteEnabled(true);
    _pDepthState = m_device->newDepthStencilState(depthStencilDesc.get());
}

void AssimpBlender::update(float delta)
{
//    float bounce = sin(t * M_PI * 2.0f) * 0.5f;
//    boneMatrix = simd::float4x4{ simd::make_float4(1, 0, 0, 0), simd::make_float4(0, 1, 0, 0), simd::make_float4(0, 0, 1, 0), simd::make_float4(0, bounce, 0, 1) };
    for (auto& model : m_models)
    {
        if (!model.hasAnimation || model.animations.empty())
            continue;
        if (model.useLayeredAnimation && !model.animationLayers.empty())
        {
            for (auto& m : model.boneMatrices)
                m = matrix_identity_float4x4;
            
            for (auto& layer : model.animationLayers)
            {
                if (!layer.isPlaying)
                    continue;
                
                AnimationAssimp& anim = model.animations[layer.animationIndex];
                layer.currentTime += delta * anim.ticksPerSec * layer.speedMultiplier;
                
                if (layer.currentTime > anim.duration)
                {
                    if (layer.loop)
                        layer.currentTime = fmod(layer.currentTime, anim.duration);
                    else
                    {
                        layer.currentTime = anim.duration;
                        layer.isPlaying = false;
                    }
                }

                std::vector<simd::float4x4> layerBones(model.boneCount, matrix_identity_float4x4);
                computeBoneTransforms(layer.currentTime, model.rootNode, matrix_identity_float4x4, model);
                
                // Blender avec le poids
                if (layer.weight > 0.0f)
                {
                    for (size_t i = 0; i < model.boneCount; i++)
                    {
                        // Interpolation linéaire entre identity et la pose du layer
                        // C'est simplifié, pour un vrai blend il faudrait décomposer en TRS
                        model.boneMatrices[i] = model.boneMatrices[i] * (1.0f - layer.weight) +
                                                 model.boneMatrices[i] * layer.weight;
                    }
                }
            }
        }
        else
        {
            if (!model.animController.isPlaying)
                continue;
            
            if (model.name == "player")
            {
                model.shouldAnimate = false;
            }
            
            if (model.animController.isTransitioning)
            {
                model.animController.transitionTime += delta;
                float t = model.animController.transitionTime / model.animController.transitionDuration;
                
                if (t >= 1.0f)
                {
                    model.currentAnimation = model.animController.targetAnimation;
                    model.currentTime = 0.0f;
                    model.animController.isTransitioning = false;
                }
                else
                {
                    if (t > 0.5f)
                    {
                        model.currentAnimation = model.animController.targetAnimation;
                        model.currentTime = 0.0f;
                    }
                }
            }
            
            AnimationAssimp& anim = model.animations[model.currentAnimation];
            model.currentTime += delta * anim.ticksPerSec * model.animController.speedMultiplier;
            
            if (model.currentTime > anim.duration)
            {
                if (model.animController.loop)
                    model.currentTime = fmod(model.currentTime, anim.duration);
                else
                {
                    model.currentTime = anim.duration;
                    model.animController.isPlaying = false;
                }
            }
            
            for (size_t i = 0; i < model.boneMatrices.size(); i++)
            {
                // Trouver la bind pose pour ce bone
                bool foundBone = false;
                for (const auto& bonePair : model.boneMap)
                {
                    if (bonePair.second.id == i)
                    {
                        // La bind pose est l'inverse de l'offset
                        model.boneMatrices[i] = bonePair.second.offset;
                        foundBone = true;
                        break;
                    }
                }
                if (!foundBone)
                    model.boneMatrices[i] = matrix_identity_float4x4;
            }
//            for (auto& m : model.boneMatrices)
//                m = matrix_identity_float4x4;
            
            computeBoneTransforms(model.currentTime, model.rootNode, matrix_identity_float4x4, model);
        }
    }
}

MTL::Texture* AssimpBlender::loadEmbeddedTexture(aiTexture *aiTexture, bool sRGB)
{
    int width, height, channels;
    unsigned char* data = nullptr;
    if (aiTexture->mHeight == 0) // compressed PNG && JPG
        data = stbi_load_from_memory(reinterpret_cast<unsigned char*>(aiTexture->pcData), aiTexture->mWidth, &width, &height, &channels, 4);
    else {
        width = aiTexture->mWidth;
        height = aiTexture->mHeight;
        data = reinterpret_cast<unsigned char*>(aiTexture->pcData);
    }

    NS::SharedPtr<MTL::TextureDescriptor> textureDescriptor = NS::TransferPtr(MTL::TextureDescriptor::alloc()->init());
    textureDescriptor->setPixelFormat(sRGB ? MTL::PixelFormatRGBA8Unorm_sRGB : MTL::PixelFormatRGBA8Unorm);
    textureDescriptor->setWidth(width);
    textureDescriptor->setHeight(height);
    textureDescriptor->setUsage(MTL::TextureUsageShaderRead);

    MTL::Texture* texture = m_device->newTexture(textureDescriptor.get());
    MTL::Region region = MTL::Region::Make2D(0, 0, width, height);
    texture->replaceRegion(region, 0, data, width * 4);

    if (aiTexture->mHeight == 0)
        stbi_image_free(data);
    return texture;
}

void AssimpBlender::loadTextures(const aiScene* scene, Blender& model, const std::string& resourcesPath)
{
    if (scene->mNumMaterials == 0) return;
    aiMaterial* mat = scene->mMaterials[0];
    aiString path;
    
    auto loadTex = [&](aiTextureType type, bool sRGB) -> MTL::Texture*
    {
        if (mat->GetTexture(type, 0, &path) != AI_SUCCESS) return nullptr;
        if (path.data[0] == '*')
        {
            int idx = atoi(path.data + 1);
            if (idx < (int)scene->mNumTextures)
                return loadEmbeddedTexture(scene->mTextures[idx], sRGB);
        }
        return nullptr; // External texture loading omitted for brevity
    };
    
    model.diffuseTexture = loadTex(aiTextureType_DIFFUSE, true);
    model.normalTexture = loadTex(aiTextureType_NORMALS, false);
    model.roughnessTexture = loadTex(aiTextureType_DIFFUSE_ROUGHNESS, false);
    model.metallicTexture = loadTex(aiTextureType_METALNESS, false);
    model.ambientOcclusion = loadTex(aiTextureType_AMBIENT_OCCLUSION, false);
}

MTL::Texture* AssimpBlender::loadTexture(const std::string& resourcesPath, const char* path, const aiScene *scene, bool sRGB)
{
    int texIndex = atoi(path + 1);
    if (texIndex < scene->mNumTextures)
    {
        aiTexture* embeddedTex = scene->mTextures[texIndex];
        return loadEmbeddedTexture(embeddedTex, sRGB);
    }
    std::string fullPath = resourcesPath + "/" + std::string(path);
    int width, height, channels;
    unsigned char* data = stbi_load(fullPath.c_str(), &width, &height, &channels, 4);
    MTL::PixelFormat pixelFormat = sRGB ? MTL::PixelFormatRGBA8Unorm_sRGB : MTL::PixelFormatRGBA8Unorm;

    NS::SharedPtr<MTL::TextureDescriptor> textureDescriptor = NS::TransferPtr(MTL::TextureDescriptor::alloc()->init());
    textureDescriptor->setTextureType(MTL::TextureType2D);
    textureDescriptor->setPixelFormat(pixelFormat);
    textureDescriptor->setWidth(width);
    textureDescriptor->setHeight(height);
    textureDescriptor->setUsage(MTL::TextureUsageShaderRead);

    MTL::Texture* texture = m_device->newTexture(textureDescriptor.get());
    MTL::Region region = MTL::Region::Make2D(0, 0, width, height);
    texture->replaceRegion(region, 0, data, width * 4);

    stbi_image_free(data);

    return texture;
}

void AssimpBlender::addAnimationLayer(size_t modelIndex, const std::string& animName, float weight)
{
    if (modelIndex >= m_models.size())
        return;
    
    Blender& model = m_models[modelIndex];
    
    if (!model.hasAnimations(animName))
    {
        printf("Warning: AnimationAssimp '%s' not found\n", animName.c_str());
        return;
    }
    
    AnimationLayer layer;
    layer.animationIndex = model.getAnimationIndex(animName);
    layer.weight = weight;
    layer.currentTime = 0.0f;
    layer.isPlaying = true;
    layer.loop = true;
    
    model.animationLayers.push_back(layer);
    model.useLayeredAnimation = true;
    
    printf("Added layer: '%s' (weight: %.2f)\n", animName.c_str(), weight);
}

void AssimpBlender::clearAnimationLayers(size_t modelIndex)
{
    if (modelIndex >= m_models.size())
        return;
    
    m_models[modelIndex].animationLayers.clear();
    m_models[modelIndex].useLayeredAnimation = false;
}

void AssimpBlender::loadAnimations(const aiScene *scene, Blender& model)
{
    for (unsigned i = 0; i < scene->mNumAnimations; i++)
    {
        aiAnimation* anim = scene->mAnimations[i];
        AnimationAssimp anyme;
        anyme.name = anim->mName.C_Str();
        anyme.duration = (float)anim->mDuration;
        anyme.ticksPerSec = 500.f;//anim->mTicksPerSecond > 0 ? (float)anim->mTicksPerSecond : 120.0f;
        
        for (unsigned c = 0; c < anim->mNumChannels; c++)
        {
            aiNodeAnim* ch = anim->mChannels[c];
            BoneAnimation ba;
            ba.boneName = ch->mNodeName.C_Str();
            
            for (unsigned p = 0; p < ch->mNumPositionKeys; p++)
            {
                auto& key = ch->mPositionKeys[p];
                ba.positions.push_back({(float)(key.mTime), {key.mValue.x, key.mValue.y, key.mValue.z}}); //ch->mPositionKeys[p].mTime, {ch->mPositionKeys[p].mValue.x, ch->mPositionKeys[p].mValue.y, ch->mPositionKeys[p].mValue.z}});
            }
            
            for (unsigned r = 0; r < ch->mNumRotationKeys; r++)
            {
                auto& key = ch->mRotationKeys[r];
                ba.rotations.push_back({(float)(key.mTime), simd_quaternion(key.mValue.x, key.mValue.y, key.mValue.z, key.mValue.w)});//ch->mRotationKeys[r].mTime, simd_quaternion(q.x, q.y, q.z, q.w)});
            }
            
            for (unsigned s = 0; s < ch->mNumScalingKeys; s++)
            {
                auto& key = ch->mScalingKeys[s];
                ba.scales.push_back({(float)(key.mTime), {key.mValue.x, key.mValue.y, key.mValue.z}});
            }
            
            anyme.channels.push_back(ba);
        }
        model.animationMap[anyme.name] = i;
        model.animations.push_back(anyme);
    }
}

void AssimpBlender::createSampler()
{
    NS::SharedPtr<MTL::SamplerDescriptor> samplerDesc = NS::TransferPtr(MTL::SamplerDescriptor::alloc()->init());
    samplerDesc->setMinFilter(MTL::SamplerMinMagFilterLinear);
    samplerDesc->setMagFilter(MTL::SamplerMinMagFilterLinear);
    samplerDesc->setMipFilter(MTL::SamplerMipFilterLinear);
    samplerDesc->setSAddressMode(MTL::SamplerAddressModeRepeat);
    samplerDesc->setTAddressMode(MTL::SamplerAddressModeRepeat);
    samplerDesc->setMaxAnisotropy(16);
    _pSampler = m_device->newSamplerState(samplerDesc.get());
}

void AssimpBlender::computeBoneTransforms(float time, const NodeData& node, const simd::float4x4& parentTf, Blender& model)
{
    simd::float4x4 localTf = node.transform;
    bool wasAnimated = false;

    if (!model.animations.empty())
    {
        AnimationAssimp& anim = model.animations[model.currentAnimation];
        
        for (auto& ch : anim.channels)
        {
            if (ch.boneName == node.name)
            {
                localTf = makeTRS(interpolatePosition(time, ch), interpolateRotation(time, ch), interpolateScale(time, ch));
                wasAnimated = true;
                break;
            }
        }
    }
    simd::float4x4 globalTf = parentTf * localTf;

    auto it = model.boneMap.find(node.name);
    
    if (it != model.boneMap.end())
        model.boneMatrices[it->second.id] = globalTf * it->second.offset;
    
    for (const auto& child : node.children)
        computeBoneTransforms(time, child, globalTf, model);
}

simd::float3 AssimpBlender::interpolatePosition(float time, const BoneAnimation& anim)
{
    if (anim.positions.size() == 1) return anim.positions[0].value;
    
    size_t i = 0;
    while (i < anim.positions.size() - 1 && anim.positions[i + 1].time < time) i++;
    size_t j = (i + 1) % anim.positions.size();
    
    float dt = anim.positions[j].time - anim.positions[i].time;
    float t = (dt > 0) ? (time - anim.positions[i].time) / dt : 0;
    return simd_mix(anim.positions[i].value, anim.positions[j].value, t);
}

simd::quatf AssimpBlender::interpolateRotation(float time, const BoneAnimation& anim)
{
    if (anim.rotations.size() == 1) return anim.rotations[0].value;
    
    size_t i = 0;
    while (i < anim.rotations.size() - 1 && anim.rotations[i + 1].time < time) i++;
    size_t j = (i + 1) % anim.rotations.size();
    
    float dt = anim.rotations[j].time - anim.rotations[i].time;
    float t = (dt > 0) ? (time - anim.rotations[i].time) / dt : 0;
    return simd::slerp(anim.rotations[i].value, anim.rotations[j].value, t);
}

simd::float3 AssimpBlender::interpolateScale(float time, const BoneAnimation& anim)
{
    if (anim.scales.size() == 1) return anim.scales[0].value;
    
    size_t i = 0;
    while (i < anim.scales.size() - 1 && anim.scales[i + 1].time < time) i++;
    size_t j = (i + 1) % anim.scales.size();
    
    float dt = anim.scales[j].time - anim.scales[i].time;
    float t = (dt > 0) ? (time - anim.scales[i].time) / dt : 0;
    return simd_mix(anim.scales[i].value, anim.scales[j].value, t);
}

void AssimpBlender::printMemoryStats() const
{
    size_t totalVertexMem = 0, totalIndexMem = 0, totalUniformMem = 0;
    size_t staticCount = 0, skinnedCount = 0;
    
    printf("\n=== AssimpBlender Memory Stats ===\n");
    for (const auto& m : m_models)
{
        size_t vertMem, unifMem;
        if (m.hasAnimation) {
            vertMem = m.verticesFull.size() * sizeof(VertexBlenderFull);
            unifMem = sizeof(BlenderUniformsFull);
            skinnedCount++;
        } else {
            vertMem = m.vertices.size() * sizeof(VertexBlender);
            unifMem = sizeof(BlenderUniforms);
            staticCount++;
        }
        size_t idxMem = m.indices.size() * sizeof(uint32_t);
        
        printf("  %-20s %s  V:%.1fKB  I:%.1fKB  U:%.1fKB\n",
               m.name.c_str(), m.hasAnimation ? "[SKIN]" : "[STAT]",
               vertMem / 1024.f, idxMem / 1024.f, unifMem / 1024.f);
        
        totalVertexMem += vertMem;
        totalIndexMem += idxMem;
        totalUniformMem += unifMem;
    }
    printf("--------------------------------\n");
    printf("  Models: %zu static, %zu skinned\n", staticCount, skinnedCount);
    printf("  Total: %.1f KB (V:%.1f + I:%.1f + U:%.1f)\n",
           (totalVertexMem + totalIndexMem + totalUniformMem) / 1024.f,
           totalVertexMem / 1024.f, totalIndexMem / 1024.f, totalUniformMem / 1024.f);
    printf("================================\n\n");
}

void AssimpBlender::printAnimations(size_t modelIndex) const
{
    if (modelIndex >= m_models.size()) return;
    
    const Blender& model = m_models[modelIndex];
    printf("\n=== Animations for '%s' ===\n", model.name.c_str());
    
    for (size_t i = 0; i < model.animations.size(); i++)
    {
        const AnimationAssimp& anim = model.animations[i];
        printf("  [%zu] '%s' - %.2fs @ %.1f tps\n",
               i, anim.name.c_str(),
               anim.duration / anim.ticksPerSec,
               anim.ticksPerSec);
    }
    printf("========================\n\n");
}

void AssimpBlender::debugBones(size_t modelIndex) const
{
    if (modelIndex >= m_models.size()) return;
    
    const Blender& model = m_models[modelIndex];
    
    printf("\n=== BONES DEBUG ===\n");
    printf("Model: %s\n", model.name.c_str());
    printf("Bone count: %d\n\n", model.boneCount);
    
    for (const auto& bonePair : model.boneMap)
    {
        const std::string& boneName = bonePair.first;
        const BoneInfo& boneInfo = bonePair.second;
        
        printf("Bone [%d]: %s\n", boneInfo.id, boneName.c_str());
        printf("  Offset matrix:\n");
        for (int row = 0; row < 4; row++)
        {
            printf("    [%.3f, %.3f, %.3f, %.3f]\n",
                   boneInfo.offset.columns[row].x,
                   boneInfo.offset.columns[row].y,
                   boneInfo.offset.columns[row].z,
                   boneInfo.offset.columns[row].w);
        }
        printf("\n");
    }
}

void AssimpBlender::playAnimation(size_t modelIndex, const std::string& animName, bool loop)
{
    if (modelIndex >= m_models.size())
        return;
    
    Blender& model = m_models[modelIndex];
    
    if (!model.hasAnimations(animName))
    {
        printf("Warning: AnimationAssimp '%s' not found in model '%s'\n",
               animName.c_str(), model.name.c_str());
        return;
    }
    
    size_t animIndex = model.getAnimationIndex(animName);
    model.currentAnimation = animIndex;
    model.currentTime = 0.0f;
    model.animController.isPlaying = true;
    model.animController.loop = loop;
    model.animController.isTransitioning = false;
    
    printf("Playing animation '%s' on model '%s'\n", animName.c_str(), model.name.c_str());
}

void AssimpBlender::playAnimation(const std::string& modelName, const std::string& animName, bool loop)
{
    auto it = m_modelNameToIndex.find(modelName);
    if (it == m_modelNameToIndex.end())
    {
        printf("Warning: Model '%s' not found\n", modelName.c_str());
        return;
    }
    playAnimation(it->second, animName, loop);
}

void AssimpBlender::transitionToAnimation(size_t modelIndex, const std::string& animName, float duration)
{
    if (modelIndex >= m_models.size())
        return;
    
    Blender& model = m_models[modelIndex];
    
    if (!model.hasAnimations(animName))
        return;
    
    size_t targetIndex = model.getAnimationIndex(animName);
    
    if (targetIndex == model.currentAnimation && !model.animController.isTransitioning)
        return;
    
    model.animController.isTransitioning = true;
    model.animController.targetAnimation = targetIndex;
    model.animController.transitionDuration = duration;
    model.animController.transitionTime = 0.0f;
}

void AssimpBlender::stopAnimation(size_t modelIndex)
{
    if (modelIndex >= m_models.size())
        return;
    
    m_models[modelIndex].animController.isPlaying = false;
}

void AssimpBlender::setAnimationSpeed(size_t modelIndex, float speed)
{
    if (modelIndex >= m_models.size())
        return;
    
    m_models[modelIndex].animController.speedMultiplier = speed;
}

void AssimpBlender::draw(MTL::RenderCommandEncoder* renderCommandEncoder, const GlobalUniforms &uniforms)
{
    for (size_t i = 0; i < m_models.size(); i++)
        drawBlender(renderCommandEncoder, i, m_models[i].transform, uniforms);
}

void AssimpBlender::drawBlender(MTL::RenderCommandEncoder* renderCommandEncoder, size_t index, const simd::float4x4 &modelMatrix, const GlobalUniforms &uniforms)
{
    if (index >= m_models.size()) return;
    Blender& model = m_models[index];
    
    if (model.hasAnimation)
    {
        BlenderUniformsFull* modelUniforms = (BlenderUniformsFull *)model.uniformBuffer->contents();
        modelUniforms->modelMatrix = modelMatrix;
        modelUniforms->viewProjectionMatrix = uniforms.cameraUniforms.viewProjectionMatrix;
        memcpy(modelUniforms->boneMatrices, model.boneMatrices.data(), model.boneCount * sizeof(simd::float4x4));
        renderCommandEncoder->setRenderPipelineState(_pPipelineStateBlenderFull);
    }
    else
    {
        BlenderUniforms* modelUniforms = (BlenderUniforms *)model.uniformBuffer->contents();
        modelUniforms->modelMatrix = modelMatrix;
        modelUniforms->viewProjectionMatrix = uniforms.cameraUniforms.viewProjectionMatrix;
        renderCommandEncoder->setRenderPipelineState(_pPipelineStateBlender);
    }
    renderCommandEncoder->setDepthStencilState(_pDepthState);
    renderCommandEncoder->setCullMode(MTL::CullModeNone);
    renderCommandEncoder->setFrontFacingWinding(MTL::WindingCounterClockwise);
    renderCommandEncoder->setVertexBuffer(model.vertexBuffer, 0, 0);
    renderCommandEncoder->setVertexBuffer(model.uniformBuffer, 0, 1);
    renderCommandEncoder->setFragmentBuffer(model.uniformBuffer, 0, 0);
    renderCommandEncoder->setFragmentBytes(&uniforms.sun, sizeof(Sun), 1);
    renderCommandEncoder->setFragmentTexture(model.diffuseTexture, 0);
    renderCommandEncoder->setFragmentTexture(model.normalTexture, 1);
    renderCommandEncoder->setFragmentTexture(model.roughnessTexture, 2);
    renderCommandEncoder->setFragmentTexture(model.metallicTexture, 3);
    renderCommandEncoder->setFragmentSamplerState(_pSampler, 0);
    renderCommandEncoder->drawIndexedPrimitives(MTL::PrimitiveTypeTriangle, model.indices.size(), MTL::IndexTypeUInt32, model.indexBuffer, 0);
}

Le Shader

/// MSL — AssimpBlender.metal — update:25/06/26
#include <metal_stdlib>
#include "../includes/SharedGPU/AssimpBlender_shared.h"
#include "../includes/SharedGPU/Renderer_shared.h"
using namespace metal;

struct VertexBlender
{
    float3 position [[attribute(0)]];
    float3 normal [[attribute(1)]];
    float2 texCoord [[attribute(2)]];
};

struct VertexBlenderFull
{
    float3 position [[attribute(0)]];
    float3 normal [[attribute(1)]];
    float2 texCoord [[attribute(2)]];
    int4 joints [[attribute(3)]];
    float4 weights [[attribute(4)]];
};

struct VertexOut
{
    float4 position [[position]];
    float3 normal;
    float2 texCoord;
    float3 worldPos;
};

vertex VertexOut vertex_Assimp(VertexBlender              in        [[stage_in]],
                               constant BlenderUniforms&  uniforms  [[buffer(1)]])
{
    VertexOut out;
    float4 worldPos = uniforms.modelMatrix * float4(in.position, 1.0);
    out.position = uniforms.viewProjectionMatrix * worldPos;
    out.worldPos = worldPos.xyz;
    out.normal = normalize((uniforms.modelMatrix * float4(in.normal, 0.0)).xyz);
    out.texCoord = in.texCoord;
    return out;
}

vertex VertexOut vertex_AssimpAnyme(VertexBlenderFull              in        [[stage_in]],
                                    uint                           vertexID  [[vertex_id]],
                                    constant BlenderUniformsFull&  uniforms  [[buffer(1)]])
{
    VertexOut out;
    float4x4 skin = uniforms.boneMatrices[in.joints.x] * in.weights.x + uniforms.boneMatrices[in.joints.y] * in.weights.y + uniforms.boneMatrices[in.joints.z] * in.weights.z + uniforms.boneMatrices[in.joints.w] * in.weights.w;
    float4 skinnedPos = skin * float4(in.position, 1.0);
    float3 skinnedNormal = normalize((skin * float4(in.normal, 0.0)).xyz);

    float4 worldPos = uniforms.modelMatrix * skinnedPos;
    out.position = uniforms.viewProjectionMatrix * worldPos;
    out.worldPos = worldPos.xyz;
    out.normal = normalize((uniforms.modelMatrix * float4(skinnedNormal, 0.0)).xyz);
    out.texCoord = in.texCoord;
    
    return out;
}

fragment float4 fragment_Assimp(VertexOut         in                [[stage_in]],
                                texture2d<float>  diffuseTexture    [[texture(0)]],
                                texture2d<float>  normalTexture     [[texture(1)]],
                                texture2d<float>  roughnessTexture  [[texture(2)]],
                                texture2d<float>  metallicTexture   [[texture(3)]],
                                sampler           textureSampler    [[sampler(0)]],
                                constant Sun&     sun               [[buffer(1)]])
{
    float4 albedo = diffuseTexture.sample(textureSampler, in.texCoord);
    float3 normal = normalTexture.sample(textureSampler, in.texCoord).rgb;
    float roughness = roughnessTexture.sample(textureSampler, in.texCoord).g;
    float metallic = metallicTexture.sample(textureSampler, in.texCoord).b;
    normal = normal * 2.0 - 1.0;
    
    // Construire TBN (tangent devrait venir du vertex shader idéalement)
    float3 N = normalize(in.normal);
    float3 T = normalize(cross(N, float3(0.0, 1.0, 0.0)));
    if (length(cross(N, float3(0.0, 1.0, 0.0))) < 0.001)
        T = normalize(cross(N, float3(1.0, 0.0, 0.0)));
    float3 B = cross(N, T);
    float3x3 TBN = float3x3(T, B, N);
    
    float3 normalIze = normalize(TBN * normal);
    
    float3 lightDir = normalize(sun.sunDirection.xyz);
    float NdotL = max(dot(normalIze, lightDir), 0.0);
    
    float3 diffuse = albedo.rgb * NdotL * sun.sunColor.xyz;
    float3 ambient = albedo.rgb * 0.3;
    
    // Specular
    float3 viewDir = normalize(sun.cameraPosition.xyz - in.worldPos);
    float3 halfDir = normalize(lightDir + viewDir);
    float shininess = 32.f;
    float spec = pow(max(dot(normalIze, halfDir), 0.0), shininess * (1.0 - roughness));
    float3 specular = float3(1.0) * spec * (1.0 - roughness);
    
    float3 finalColor = ambient + diffuse + specular;
    
    return float4(finalColor, albedo.a);
}
AssimpBlender::loadModel ReadFile Assimp::Importer modelHasBones bool processNodeSkinned récursif loadBones boneMap loadAnimations aiAnimation* loadTextures embedded

En-tête de notre classe

/// C++ — AssimpBlender.hpp — update:25/06/26
#include <MetalKit/MetalKit.hpp>

#include <simd/simd.h>
#include <vector>
#include <string>
#include <map>
#include <unordered_map>
#include <queue>
#include <functional>

struct VertexBlender
{
    simd::float3 position;
    simd::float3 normal;
    simd::float2 texCoord;
};

struct VertexBlenderFull
{
    simd::float3 position;
    simd::float3 normal;
    simd::float2 texCoord;
    simd::int4   joints;
    simd::float4 weights;
};

struct BoneInfo
{
    int id;
    simd::float4x4 offset; // inverse bind pose
};

template<typename T>
struct KeyFrame
{
    float time;
    T value;
};

struct BoneAnimation
{
    std::string boneName; // Bone -> Bone.026
    std::vector<KeyFrame<simd::float3>> positions;
    std::vector<KeyFrame<simd::quatf>>  rotations;
    std::vector<KeyFrame<simd::float3>> scales;
};

struct AnimationAssimp
{
    std::string name;
    float duration;
    float ticksPerSec;
    std::vector<BoneAnimation> channels;
};

struct AnimationLayer
{
    size_t animationIndex = 0;
    float weight = 1.0f;
    float currentTime = 0.0f;
    bool isPlaying = false;
    bool loop = true;
    float speedMultiplier = 1.0f;
};

struct AnimationController
{
    bool isPlaying = false;
    float speedMultiplier = 1.0f;
    bool loop = true;
    bool isTransitioning = false;
    size_t targetAnimation = 0;
    float transitionDuration = 0.3f;
    float transitionTime = 0.0f;
};

struct NodeData
{
    std::string name;
    simd::float4x4 transform;
    std::vector<NodeData> children;
};

struct Blender
{
    std::string name;
    std::string parent;
    
    std::vector<VertexBlender> vertices;
    std::vector<VertexBlenderFull> verticesFull;
    std::vector<uint32_t> indices;
    MTL::Buffer* vertexBuffer = nullptr;
    MTL::Buffer* indexBuffer = nullptr;
    
    bool hasAnimation = false;
    bool shouldAnimate = true;
    std::unordered_map<std::string, BoneInfo> boneMap; // std::map<std::string, BoneInfo> m_boneMap;
    std::vector<simd::float4x4> boneMatrices;
    std::vector<AnimationAssimp> animations;
    std::unordered_map<std::string, size_t> animationMap;
    AnimationController animController;
    std::vector<AnimationLayer> animationLayers;
    bool useLayeredAnimation = false;
    NodeData rootNode;
    int boneCount = 0;
    size_t currentAnimation = 0;
    float currentTime = 0.0f;
    MTL::Buffer* uniformBuffer = nullptr;
    
    MTL::Texture* diffuseTexture = nullptr;
    MTL::Texture* normalTexture = nullptr;
    MTL::Texture* roughnessTexture = nullptr;
    MTL::Texture* metallicTexture = nullptr;
    MTL::Texture* ambientOcclusion = nullptr;
    
    simd::float4x4 transform = matrix_identity_float4x4;
    simd::float3 position = {};
    
    bool hasAnimations(const std::string& name) const
    {
        return animationMap.find(name) != animationMap.end();
    }
    
    size_t getAnimationIndex(const std::string& name) const
    {
        auto it = animationMap.find(name);
        return it != animationMap.end() ? it->second : 0;
    }
    
    size_t getVertexCount() const
    {
        return hasAnimation ? verticesFull.size() : vertices.size();
    }
    
    void release()
    {
        if (vertexBuffer) vertexBuffer->release();
        if (indexBuffer) indexBuffer->release();
        if (uniformBuffer) uniformBuffer->release();
        if (diffuseTexture) diffuseTexture->release();
        if (normalTexture) normalTexture->release();
        if (roughnessTexture) roughnessTexture->release();
        if (metallicTexture) metallicTexture->release();
        if (ambientOcclusion) ambientOcclusion->release();
    }
};

class AssimpBlender
{
public:
    AssimpBlender(MTL::Device*, MTL::Library*,
                  MTL::PixelFormat colorPixelFormat, MTL::PixelFormat depthPixelFormat,
                  const std::string& resourcePath);
    ~AssimpBlender();

    size_t loadModel(const std::string& resourcesPath, const std::string& name = "");
    
    void printMemoryStats() const;
    void printAnimations(size_t modelIndex) const;
    void debugBones(size_t modelIndex) const;

    Blender* getModel(size_t index);
    Blender* getModel(const std::string& name);
    size_t getModelCount() const { return m_models.size(); }
    
    void addAnimationLayer(size_t modelIndex, const std::string& animName, float weight);
    void clearAnimationLayers(size_t modelIndex);
    
    void playAnimation(size_t modelIndex, const std::string& animName, bool loop = true);
    void playAnimation(const std::string& modelName, const std::string& animName, bool loop = true);
    void transitionToAnimation(size_t modelIndex, const std::string& animName, float duration = 0.3f);
    void stopAnimation(size_t modelIndex);
    void setAnimationSpeed(size_t modelIndex, float speed);
        
    void createPipeline(MTL::Library* shaderLibrary, MTL::PixelFormat, MTL::PixelFormat);
    void update(float deltaTime);
    void draw(MTL::RenderCommandEncoder*, const GlobalUniforms &uniforms);
    void drawBlender(MTL::RenderCommandEncoder*, size_t index, const simd::float4x4& model, const GlobalUniforms &uniforms);
    
private:
    MTL::Device*                m_device;
    MTL::SamplerState*          _pSampler = nullptr;
    MTL::DepthStencilState*     _pDepthState;
    MTL::RenderPipelineState*   _pPipelineStateBlender;
    MTL::RenderPipelineState*   _pPipelineStateBlenderFull;
    
    std::vector<Blender> m_models;
    std::unordered_map<std::string, size_t> m_modelNameToIndex;
    
    bool modelHasBones(aiNode* node, const aiScene* scene);
    MTL::Texture* loadTexture(const std::string& resourcesPath, const char* path, const aiScene* scene, bool sRGB);
    MTL::Texture* loadEmbeddedTexture(aiTexture* aiTexture, bool sRGB);

    void createSampler();
    
    void processNodeStatic(aiNode* node, const aiScene* scene, Blender& model);
    void processNodeSkinned(aiNode* node, const aiScene* scene, Blender& model);
    void processMeshStatic(aiMesh* mesh, Blender& model);
    void processMeshSkinned(aiMesh* mesh, Blender& model);
    void loadBones(aiMesh* mesh, Blender& model, uint32_t baseVertex);
    void loadAnimations(const aiScene* scene, Blender& model);
    void loadTextures(const aiScene* scene, Blender& model, const std::string& resourcesPath);
    
    NodeData copyNodeHierarchy(aiNode* node);
    void computeBoneTransforms(float time, const NodeData& node, const simd::float4x4& parentTf, Blender& model);
    simd::float3 interpolatePosition(float time, const BoneAnimation& anim);
    simd::quatf interpolateRotation(float time, const BoneAnimation& anim);
    simd::float3 interpolateScale(float time, const BoneAnimation& anim);
};

class AnimationStateMachine
{
public:
    void addState(const std::string& name, const std::string& animName)
    {
        states[name] = animName;
    }
    
    void addTransition(const std::string& from, const std::string& to, std::function<bool()> condition)
    {
        transitions[from].push_back({to, condition});
    }
    
    void update(AssimpBlender& blender, size_t modelIndex)
    {
        for (auto& trans : transitions[currentState])
        {
            if (trans.condition())
            {
                setState(blender, modelIndex, trans.targetState);
                break;
            }
        }
    }
    
    void setState(AssimpBlender& blender, size_t modelIndex, const std::string& state)
    {
        if (states.find(state) == states.end() || state == currentState)
            return;
        
        currentState = state;
        blender.playAnimation(modelIndex, states[state]);
    }
    
    std::string getCurrentState() const { return currentState; }
    
private:
    struct Transition
    {
        std::string targetState;
        std::function<bool()> condition;
    };
    
    std::unordered_map<std::string, std::string> states;
    std::unordered_map<std::string, std::vector<Transition>> transitions;
    std::string currentState;
};

Implémentation / Intégration dans la classe principale

/// C++ — Renderer.hpp — update:25/06/26
#include "AssimpBlender.hpp"

private:
    GlobalUniforms              m_globalUniforms;
    AssimpBlender m_assimp;
/// C++ — Renderer.cpp — update:25/06/26
m_cursor3D(m_device, m_shaderLibrary, m_pixelFormat, m_depthPixelFormat), // ou m_skybox du projet
m_assimp(m_device, m_shaderLibrary, m_pixelFormat, m_depthPixelFormat, resourcePath),
/// C++ — Renderer.cpp — update:25/06/26 — constructeur — debug
{
    m_assimp.printAnimations(0);
}
/// C++ — Renderer.cpp — update:25/06/26 — constructeur
{
    m_assimp.loadModel(resourcePath + "/Odonata.glb", "player_libell");
    m_assimp.getModel("player_libell")->transform = math::makeTranslate({0, 50, 0}) *
                                                   math::makeScale({3, 3, 3});
}
    /// C++ — Renderer.cpp — update:25/06/26 — update
    m_camera.setPosition(m_globalUniforms.sun.cameraPosition.xyz);
    m_assimp.update(delta);
    
    if (m_input.keyE)
        m_assimp.playAnimation("player_lib", "ArmatureAction", true);
    /// C++ — Renderer.cpp — update:25/06/26 — & draw
    renderCommandEncoder->setViewport(m_viewport);
    m_assimp.draw(renderCommandEncoder, m_globalUniforms);
⬡CheckPoint Compilation — magnificat

Le chemin : aiScene → MTL::Buffer

AssimpBlender fait exactement ce pipeline. Voici comment les pièces s'articulent :

ReadFile()
.glb / .fbx / .obj
→
processNode()
parcours récursif
→
processMesh()
vertices + indices
→
newBuffer()
MTL::Buffer GPU