Annexe B · AABB

AABB — Axis-Aligned Bounding Box

Une AABB est une boîte alignée sur les axes (X, Y, Z). Elle est utilisée partout en 3D pour des tests de collision ultra-rapides — c'est l'une des structures les plus importantes d'un moteur de jeu.

↗ Démo interactive Three.js — gravité + collisions AABB

À ne pas négliger

/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/*                                        +       +          */
/*      File: AABB.cpp            +++    +++                 */
/*                                        +       +          */
/*      By: Laboitederemdal                +       +         */
/*                                       +           +       */
/*      Created: 27/10/2025 18:44:15      + + + + + +        */
/*                                                           */
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */

#ifndef AABB_hpp
#define AABB_hpp

struct AABB
{
    simd::float3 minimum;
    simd::float3 maximum;

    simd::float3 center()  const { return (minimum + maximum) * 0.5f; }
    simd::float3 extents() const { return (maximum - minimum) * 0.5f; }

    bool intersects(const AABB& o) const
    {
        return (minimum.x <= o.maximum.x && maximum.x >= o.minimum.x)
            && (minimum.y <= o.maximum.y && maximum.y >= o.minimum.y)
            && (minimum.z <= o.maximum.z && maximum.z >= o.minimum.z);
    }

    static AABB fromVertices(const std::vector<simd::float3>& verts)
    {
        AABB b{ verts[0], verts[0] };
        for (auto& v : verts)
        {
            b.minimum = simd::min(b.minimum, v);
            b.maximum = simd::max(b.maximum, v);
        }
        return b;
    }
};

AABB transformAABB(const AABB& local, const simd::float4x4& M)
{
    simd::float3 newMin = simd_make_float3(M.columns[3].x, M.columns[3].y, M.columns[3].z);
    simd::float3 newMax = newMin;

    for (int i = 0; i < 3; i++)
    {
        for (int j = 0; j < 3; j++)
        {
            float e = M.columns[j][i] * local.minimum[j];
            float f = M.columns[j][i] * local.maximum[j];
            if (e < f)
            {
                newMin[i] += e;
                newMax[i] += f;
            }
            else
            {
                newMin[i] += f;
                newMax[i] += e;
            }
        }
    }
    return { newMin, newMax };
}

struct BoxCollider
{
    AABB     localAABB;
    AABB     worldAABB;
    bool     isTrigger  = false;
    bool     isStatic   = false;
    uint32_t layerMask  = 0xFFFFFFFF;
};

#endif /* AABB_hpp */

Schéma mental — ce qu'est une AABB

          maximum (coin haut-droit-avant)
           +---------+
          / |        /|
         /  |       / |
        +---------+   |
        |   |     |   |
        |   +-----|---+
        |  /      |  /
        | /       | /
        +---------+
       minimum (coin bas-gauche-arrière)

Une AABB est définie uniquement par deux points : minimum (coin le plus petit sur tous les axes) et maximum (coin le plus grand). Tout le reste — centre, taille, test de collision — se calcule depuis ces deux valeurs.

center() — milieu de la boîte

simd::float3 center() const { return (minimum + maximum) * 0.5f; }

// Exemple :
// minimum = {1, 0, 1}  maximum = {3, 2, 3}
// center  = {2, 1, 2}  ← milieu exact sur chaque axe

Utilisé pour le frustum culling (tester si le centre d'un objet est visible), les LOD, et les effets de particules centrés sur un objet.

extents() — demi-tailles

simd::float3 extents() const { return (maximum - minimum) * 0.5f; }

// minimum = {0, 0, 0}  maximum = {4, 2, 6}
// extents = {2, 1, 3}  ← rayon de la boîte sur chaque axe

Les extents sont les "rayons" de la boîte. Très utilisés pour les tests de collision avec des sphères (length(point - center) < length(extents)) et pour le frustum culling côté GPU.

intersects() — test de collision AABB vs AABB

bool intersects(const AABB& o) const
{
    return (minimum.x <= o.maximum.x && maximum.x >= o.minimum.x)  // axe X
        && (minimum.y <= o.maximum.y && maximum.y >= o.minimum.y)  // axe Y
        && (minimum.z <= o.maximum.z && maximum.z >= o.minimum.z); // axe Z
}

Principe du théorème de l'axe séparateur simplifié : deux AABB ne se touchent pas s'il existe au moins un axe sur lequel elles sont séparées. On teste les 3 axes — si toutes les conditions sont vraies, il y a collision.

// Visualisation 2D (vue de dessus) :
//
//  [A]──────         ──────[B]    séparées sur X → pas de collision
//
//  [A]─────[B]              chevauchement sur X ET Y → collision !
//       ───
Coût O(1) constant
Un test AABB vs AABB coûte exactement 6 comparaisons — indépendant de la complexité des meshes. C'est pour ça qu'on l'utilise comme premier filtre avant des tests plus précis (OBB, raycast, GJK).

fromVertices() — construire depuis un mesh

static AABB fromVertices(const std::vector<simd::float3>& verts)
{
    AABB b{ verts[0], verts[0] }; // init avec le premier vertex
    for (auto& v : verts)
    {
        b.minimum = simd::min(b.minimum, v); // garde le plus petit
        b.maximum = simd::max(b.maximum, v); // garde le plus grand
    }
    return b;
}

// Usage depuis assimpBlender :
std::vector<simd::float3> positions;

for (auto& v : model.vertices)
    positions.push_back(v.position);
AABB localBox = AABB::fromVertices(positions);

transformAABB() — local → monde

Problème classique : une AABB est définie dans l'espace local de l'objet (aligned). Après rotation de l'objet, elle n'est plus alignée sur les axes mondiaux. On ne peut pas la tourner — on reconstruit une nouvelle AABB mondiale qui englobe le résultat transformé.

// Sans transformAABB — FAUX après rotation :
//
//  Local AABB   Après rotation 45°   AABB recalculée (correcte)
//  ┌──────┐        ╱╲                 ┌────────┐
//  │      │       ╱  ╲                │  ╱╲    │
//  │      │      ╱    ╲               │ ╱  ╲   │
//  └──────┘      ╲    ╱               │ ╲  ╱   │
//                 ╲  ╱                │  ╲╱    │
//                  ╲╱                 └────────┘
AABB transformAABB(const AABB& local, const simd::float4x4& M)
{
    // Départ : position de la translation (colonne 3 de la matrice)
    simd::float3 newMin = { M.columns[3].x, M.columns[3].y, M.columns[3].z };
    simd::float3 newMax = newMin;

    // Pour chaque axe de sortie i (X, Y, Z) :
    for (int i = 0; i < 3; i++)
    {
        for (int j = 0; j < 3; j++)
        {
            // Projeter les deux extrêmes de la boîte locale sur l'axe transformé
            float e = M.columns[j][i] * local.minimum[j];
            float f = M.columns[j][i] * local.maximum[j];
            // Accumuler min et max
            if (e < f)
            {
                newMin[i] += e;
                newMax[i] += f;
            }
            else
            {
                newMin[i] += f;
                newMax[i] += e;
            }
        }
    }
    return { newMin, newMax };
}

// Usage — à appeler quand l'objet bouge :
collider.worldAABB = transformAABB(collider.localAABB, model.transform);

BoxCollider — le composant complet

struct BoxCollider
{
    AABB     localAABB;           // espace local — ne change pas
    AABB     worldAABB;           // espace monde — mis à jour chaque frame si mobile
    bool     isTrigger  = false; // détecte sans réponse physique
    bool     isStatic   = false; // ne bouge pas → pas de recalcul worldAABB
    uint32_t layerMask  = 0xFFFFFFFF; // filtre par couche (0x01 = sol, 0x02 = ennemis…)
};

// Boucle de collision simple :
for (auto& a : colliders)
{
    for (auto& b : colliders)
    {
        if (&a == &b) continue;
        if (!(a.layerMask & b.layerMask))
            continue; // filtre couches
        if (a.worldAABB.intersects(b.worldAABB))
            resolveCollision(a, b);
    }
}

AABBRenderer — un rendu pour voir ses boîtes

Une AABB qu'on ne voit pas est une AABB qu'on ne débugge pas. Tant que intersects() n'est qu'un bool dans la console, impossible de savoir si la boîte du joueur est deux fois trop grande ou décalée de 50 unités sur Y. On ajoute donc une classe de rendu — AABBRenderer — au même format que Perspective ou Game2D : constructeur (device, library, pixel formats), buildGeometry(), buildPipeline(), draw().

Le principe du rendu
Une seule boîte unitaire en mémoire GPU — 8 sommets, 12 arêtes — instanciée autant de fois qu'il y a de colliders. Chaque instance porte son centre, ses demi-tailles et sa couleur ; le vertex shader fait center + corner * extents. Résultat : toutes les boîtes de la scène en un seul draw call, en MTL::PrimitiveTypeLine.

Les fichiers à ajouter

MSLearn/
├── Renderer/
│   ├── Renderer.cpp              ← intégration
│   ├── Renderer.hpp              ← intégration
│   ├── AABB.hpp                  ← 2 ajouts (inline + hit + layers)
│   ├── AABBRenderer.cpp          ← nouveau
│   └── AABBRenderer.hpp          ← nouveau
├── Shaders/
│   └── AABBRenderer.metal        ← nouveau
└── includes/
    └── SharedGPU/
        └── Mesh_shared.h         ← BoxInstance

1 — BoxInstance : la donnée partagée CPU ↔ GPU

/// C++ — includes/SharedGPU/Mesh_shared.h — à côté de CubeInstance
struct alignas(16) BoxInstance
{
    simd::float4 center;  // xyz = centre monde,  w inutilisé
    simd::float4 extents; // xyz = demi-tailles,  w inutilisé
    simd::float4 color;   // rgba — a < 1.0 pour un trigger translucide
};

48 octets, alignés sur 16 : le GPU lit ce tableau tel quel, sans padding surprise. On stocke centre + extents plutôt que minimum + maximum parce que c'est exactement ce dont le shader a besoin — center() et extents() de l'annexe sont faits pour ça.

2 — Deux ajouts dans AABB.hpp

/// C++ — AABB.hpp — update:11/09/26
enum : uint32_t // une couche par bit — 32 couches maximum
{
    kLayerDefault = 0x01,
    kLayerPlayer  = 0x02,
    kLayerTerrain = 0x04,
    kLayerTrigger = 0x08,
};

struct BoxCollider
{
    AABB     localAABB;
    AABB     worldAABB;
    bool     isTrigger  = false;
    bool     isStatic   = false;
    bool     hit        = false; // ← ajout : résultat de la frame, sert à colorer
    uint32_t layerMask  = 0xFFFFFFFF;
};

// ← ajout : inline. Le .hpp est maintenant inclus par Renderer.cpp ET AABBRenderer.cpp,
//   sans inline le linker hurle "duplicate symbol _transformAABB"
inline AABB transformAABB(const AABB& local, const simd::float4x4& M)
Pourquoi inline
Une fonction libre définie dans un header est recopiée dans chaque translation unit qui l'inclut → deux symboles identiques à la liaison. inline autorise explicitement ces définitions multiples (ODR). Les méthodes définies dans la struct (center, intersects…) sont implicitement inline, elles n'ont rien à changer.

3 — AABBRenderer.hpp

/// C++ — AABBRenderer.hpp — update:11/09/26
#ifndef AABBRenderer_hpp
#define AABBRenderer_hpp

#include "AABB.hpp"
#include "../includes/SharedGPU/Mesh_shared.h"
#include "../includes/SharedGPU/Renderer_shared.h"

class AABBRenderer
{
public:
    AABBRenderer(MTL::Device* device, MTL::Library* shaderLibrary,
                 MTL::PixelFormat colorPixelFormat, MTL::PixelFormat depthPixelFormat);
    ~AABBRenderer();

    void   beginFrame();                                      // remet le compteur d'instances à 0
    void   submit(const AABB& box, simd::float4 color);     // 1 boîte = 1 instance
    void   submit(const BoxCollider& collider);              // couleur automatique
    void   draw(MTL::RenderCommandEncoder* renderCommandEncoder,
                const GlobalUniforms& globalUniforms);
    size_t count() const { return m_count; }

private:
    void buildGeometry(MTL::Device* device);
    void buildPipeline(MTL::Device* device, MTL::Library* shaderLibrary,
                       MTL::PixelFormat colorPixelFormat, MTL::PixelFormat depthPixelFormat);

    static constexpr size_t kMaxBoxes = 1024; // 1024 × 48 o = 48 Ko de buffer

    NS::SharedPtr<MTL::Buffer>              m_cornerBuffer;   // 8 sommets ±1
    NS::SharedPtr<MTL::Buffer>              m_indexBuffer;    // 24 indices = 12 arêtes
    NS::SharedPtr<MTL::Buffer>              m_instanceBuffer; // kMaxBoxes × BoxInstance
    NS::SharedPtr<MTL::RenderPipelineState> m_renderPipelineState;
    NS::SharedPtr<MTL::DepthStencilState>   m_depthStencilState;
    size_t                                 m_count = 0;
};

#endif /* AABBRenderer_hpp */

4 — buildGeometry() : 8 sommets, 12 arêtes

Les sommets sont en ±1, pas en ±0.5 : le shader multiplie directement par extents() (qui est déjà une demi-taille), aucun facteur 2 à traîner.

//        7 ________ 6          z
//         /|      /|           ↑  y
//      3 /_|____ / | 2         | ↗
//       |  |4___|__| 5         |/___→ x
//       | /     | /
//       |/______|/             0 = (-1,-1,-1) → minimum
//       0        1             6 = (+1,+1,+1) → maximum
/// C++ — AABBRenderer.cpp — update:11/09/26
#include "AABBRenderer.hpp"

AABBRenderer::AABBRenderer(MTL::Device* device, MTL::Library* shaderLibrary,
                           MTL::PixelFormat colorPixelFormat, MTL::PixelFormat depthPixelFormat)
{
    buildGeometry(device);
    buildPipeline(device, shaderLibrary, colorPixelFormat, depthPixelFormat);
}

AABBRenderer::~AABBRenderer() {} // NS::SharedPtr relâche tout seul

void AABBRenderer::buildGeometry(MTL::Device* device)
{   // Boîte unitaire : le shader fera center + corner * extents
    const simd::float3 corners[8] = {
        { -1, -1, -1 }, // 0 ← minimum
        {  1, -1, -1 }, // 1
        {  1,  1, -1 }, // 2
        { -1,  1, -1 }, // 3
        { -1, -1,  1 }, // 4
        {  1, -1,  1 }, // 5
        {  1,  1,  1 }, // 6 ← maximum
        { -1,  1,  1 }, // 7
    };
    const uint16_t edges[24] = { // 12 arêtes × 2 sommets — index list en lignes
        0,1, 1,2, 2,3, 3,0, // face arrière  z = -1
        4,5, 5,6, 6,7, 7,4, // face avant    z = +1
        0,4, 1,5, 2,6, 3,7, // les 4 montants
    };
    m_cornerBuffer = NS::TransferPtr(device->newBuffer(corners, sizeof(corners), MTL::ResourceStorageModeShared));
    m_indexBuffer  = NS::TransferPtr(device->newBuffer(edges,   sizeof(edges),   MTL::ResourceStorageModeShared));
    m_instanceBuffer = NS::TransferPtr(device->newBuffer(sizeof(BoxInstance) * kMaxBoxes,
                                                         MTL::ResourceStorageModeShared));
}
simd::float3 = 16 octets
Un simd::float3 occupe 16 octets (12 de données + 4 de padding), exactement comme un float3 MSL. Le constant float3* corners [[buffer(0)]] du shader lit donc le tableau avec le bon stride, sans descripteur de vertex — même stratégie que vertex_main_perspective.

5 — buildPipeline() : lignes, et surtout pas d'écriture de profondeur

/// C++ — AABBRenderer.cpp — update:11/09/26
void AABBRenderer::buildPipeline(MTL::Device* device, MTL::Library* shaderLibrary,
                                 MTL::PixelFormat colorPixelFormat, MTL::PixelFormat depthPixelFormat)
{
    auto vertexFunction   = NS::TransferPtr(shaderLibrary->newFunction(MTLSTR("vertex_main_aabb")));
    auto fragmentFunction = NS::TransferPtr(shaderLibrary->newFunction(MTLSTR("fragment_main_aabb")));

    auto renderPipelineDescriptor = NS::TransferPtr(MTL::RenderPipelineDescriptor::alloc()->init());
    renderPipelineDescriptor->setVertexFunction(vertexFunction.get());
    renderPipelineDescriptor->setFragmentFunction(fragmentFunction.get());
    renderPipelineDescriptor->setDepthAttachmentPixelFormat(depthPixelFormat);

    auto colorAttachment = renderPipelineDescriptor->colorAttachments()->object(0);
    colorAttachment->setPixelFormat(colorPixelFormat);
    colorAttachment->setBlendingEnabled(true); // alpha < 1 pour les triggers
    colorAttachment->setRgbBlendOperation(MTL::BlendOperationAdd);
    colorAttachment->setAlphaBlendOperation(MTL::BlendOperationAdd);
    colorAttachment->setSourceRGBBlendFactor(MTL::BlendFactorSourceAlpha);
    colorAttachment->setSourceAlphaBlendFactor(MTL::BlendFactorSourceAlpha);
    colorAttachment->setDestinationRGBBlendFactor(MTL::BlendFactorOneMinusSourceAlpha);
    colorAttachment->setDestinationAlphaBlendFactor(MTL::BlendFactorOneMinusSourceAlpha);

    NS::Error* error = nullptr;
    m_renderPipelineState = NS::TransferPtr(device->newRenderPipelineState(renderPipelineDescriptor.get(), &error));
    assert(!error && "AABB pipeline creation failed");

    auto _depthStencilDescriptor = NS::TransferPtr(MTL::DepthStencilDescriptor::alloc()->init());
    _depthStencilDescriptor->setDepthCompareFunction(MTL::CompareFunction::CompareFunctionLessEqual);
    _depthStencilDescriptor->setDepthWriteEnabled(false); // ← le plus important de ce fichier
    m_depthStencilState = NS::TransferPtr(device->newDepthStencilState(_depthStencilDescriptor.get()));
}
setDepthWriteEnabled(false)
Le wireframe est un calque de debug : il se teste contre la profondeur de la scène (LessEqual, même sens que le m_depthStencilState de Renderer avec setClearDepth(1.0)) mais il n'écrit rien. Sinon une arête à 1 pixel de large creuse un trou dans le Z-buffer et découpe les sprites ou le texte dessinés après elle.

6 — beginFrame() / submit() : remplir le buffer d'instances

/// C++ — AABBRenderer.cpp — update:11/09/26
void AABBRenderer::beginFrame()
{
    m_count = 0; // on réécrit par-dessus, rien à effacer
}

void AABBRenderer::submit(const AABB& box, simd::float4 color)
{
    if (m_count >= kMaxBoxes)
        return; // perdre une boîte de debug > écrire hors du buffer

    BoxInstance* instances = static_cast<BoxInstance*>(m_instanceBuffer->contents());
    simd::float3 c = box.center();
    simd::float3 e = box.extents();

    instances[m_count].center  = { c.x, c.y, c.z, 1.f };
    instances[m_count].extents = { e.x, e.y, e.z, 0.f };
    instances[m_count].color   = color;
    m_count++;
}

void AABBRenderer::submit(const BoxCollider& collider)
{   // la couleur EST l'information de debug
    constexpr simd::float4 kGreen  = { 0.15f, 0.95f, 0.35f, 1.0f };  // libre
    constexpr simd::float4 kRed    = { 1.0f,  0.18f, 0.18f, 1.0f };  // en collision
    constexpr simd::float4 kYellow = { 1.0f,  0.92f, 0.10f, 0.55f }; // trigger
    constexpr simd::float4 kGrey   = { 0.55f, 0.58f, 0.65f, 1.0f };  // statique

    simd::float4 color = kGreen;
    if      (collider.hit)       color = kRed;
    else if (collider.isTrigger) color = kYellow;
    else if (collider.isStatic)  color = kGrey;

    submit(collider.worldAABB, color);
}

7 — draw() : un seul draw call pour toute la scène

/// C++ — AABBRenderer.cpp — update:11/09/26
void AABBRenderer::draw(MTL::RenderCommandEncoder* renderCommandEncoder,
                        const GlobalUniforms& globalUniforms)
{
    if (m_count == 0)
        return; // rien soumis cette frame → on ne touche même pas au pipeline

    renderCommandEncoder->setRenderPipelineState(m_renderPipelineState.get());
    renderCommandEncoder->setDepthStencilState(m_depthStencilState.get());
    renderCommandEncoder->setVertexBuffer(m_cornerBuffer.get(), 0, 0);
    renderCommandEncoder->setVertexBytes(&globalUniforms, sizeof(GlobalUniforms), 1);
    renderCommandEncoder->setVertexBuffer(m_instanceBuffer.get(), 0, 2); // pas setVertexBytes : 48 Ko > 4 Ko
    renderCommandEncoder->drawIndexedPrimitives(MTL::PrimitiveTypeLine,
                                                NS::UInteger(24),        // 12 arêtes × 2
                                                MTL::IndexTypeUInt16,
                                                m_indexBuffer.get(), 0,
                                                NS::UInteger(m_count)); // instanceCount
}

8 — AABBRenderer.metal

/// MSL — Shaders/AABBRenderer.metal — update:11/09/26
#include <metal_stdlib>
#include "../includes/SharedGPU/Mesh_shared.h"
#include "../includes/SharedGPU/Renderer_shared.h"
using namespace metal;

struct v2fBox
{
    float4 position [[position]];
    half4  color;
};

v2fBox vertex vertex_main_aabb(constant float3*          corners  [[buffer(0)]],
                               constant GlobalUniforms& uniforms [[buffer(1)]],
                               constant BoxInstance*    boxes    [[buffer(2)]],
                               uint                      vertexId [[vertex_id]],
                               uint                      iid      [[instance_id]])
{
    BoxInstance box = boxes[iid];
    // corners est en ±1 → on retombe exactement sur minimum / maximum
    float3 worldPosition = box.center.xyz + corners[vertexId] * box.extents.xyz;

    v2fBox out;
    out.position = uniforms.cameraUniforms.viewProjectionMatrix * float4(worldPosition, 1.0);
    out.color    = half4(box.color);
    return out;
}

half4 fragment fragment_main_aabb(v2fBox in [[stage_in]])
{
    return in.color; // couleur plate : une ligne de debug ne s'éclaire pas
}

Tout le travail tient en une ligne : center + corner * extents. 8 sommets lus, 24 indices, et la matrice viewProjectionMatrix déjà calculée par Camera — aucun uniform supplémentaire, aucune matrice modèle. C'est le gros avantage d'une boîte alignée sur les axes : elle n'a pas d'orientation à transporter.

9 — Intégration dans Renderer

/// C++ — Renderer.hpp — update:11/09/26
#include "AABBRenderer.hpp"

private:
    SpriteRenderer            m_sprites;
    AABBRenderer              m_aabb;          // ← déclare-le APRÈS m_sprites…
    std::vector<BoxCollider>  m_colliders;
    size_t                    m_playerCollider = 0;
    size_t                    m_cameraCollider = 0;
    bool                      m_showColliders  = true;
    bool                      m_togglePressed  = false; // front montant de la touche

    void buildColliders();
    void updateColliders();
/// C++ — Renderer.cpp — update:11/09/26 — liste d'initialisation
m_sprites(m_device, m_shaderLibrary, m_pixelFormat, m_depthPixelFormat),
m_aabb(m_device, m_shaderLibrary, m_pixelFormat, m_depthPixelFormat), // …et initialise-le au même rang
frame(0)
L'ordre compte
Les membres sont construits dans l'ordre de déclaration du .hpp, pas dans l'ordre de la liste d'initialisation. Si les deux divergent, clang sort -Wreorder — garde la même position dans les deux fichiers.
/// C++ — Renderer.cpp — update:11/09/26 — à la fin du constructeur
m_assimp.printAnimations(m_odonata);
m_assimp.printAnimations(m_scene);
buildColliders(); // ← après les loadModel() : on a besoin des vertices
/// C++ — Renderer.cpp — update:11/09/26
void Renderer::buildColliders()
{
    m_colliders.clear();

    // 1 — le joueur : AABB locale calculée UNE fois depuis le mesh chargé
    Blender* odonata = m_assimp.getModel(m_odonata);
    std::vector<simd::float3> positions;
    positions.reserve(odonata->vertices.size());
    for (const VertexBlender& v : odonata->vertices)
        positions.push_back(v.position);

    BoxCollider player;
    player.localAABB = AABB::fromVertices(positions);
    player.worldAABB = transformAABB(player.localAABB, odonata->transform);
    player.layerMask = kLayerPlayer | kLayerDefault;
    m_playerCollider = m_colliders.size();
    m_colliders.push_back(player);

    // 2 — la caméra : une boîte « corps » de 1 × 1.8 × 1 autour de l'œil
    BoxCollider camera;
    camera.localAABB = { { -0.5f, -1.6f, -0.5f }, { 0.5f, 0.2f, 0.5f } };
    camera.worldAABB = camera.localAABB;
    camera.layerMask = kLayerPlayer | kLayerDefault | kLayerTrigger;
    m_cameraCollider = m_colliders.size();
    m_colliders.push_back(camera);

    // 3 — des obstacles statiques : aucun mesh, juste des boîtes posées à la main
    const simd::float3 obstacles[] = {
        { 0.f, 6.f, 12.f }, { 10.f, 6.f, -8.f },
        { -14.f, 6.f, 4.f }, { 6.f, 6.f, 22.f },
    };
    for (const simd::float3& p : obstacles)
    {
        BoxCollider box;
        box.localAABB = { { -3.f, -3.f, -3.f }, { 3.f, 3.f, 3.f } };
        box.worldAABB = transformAABB(box.localAABB, math::makeTranslate(p));
        box.isStatic  = true; // jamais recalculée → gratuit
        box.layerMask = kLayerDefault;
        m_colliders.push_back(box);
    }

    // 4 — un trigger : il détecte, il ne repousse pas
    BoxCollider trigger;
    trigger.localAABB = { { -5.f, -2.f, -5.f }, { 5.f, 2.f, 5.f } };
    trigger.worldAABB = transformAABB(trigger.localAABB, math::makeTranslate({ 0.f, 4.f, -18.f }));
    trigger.isStatic  = true;
    trigger.isTrigger = true;
    trigger.layerMask = kLayerTrigger;
    m_colliders.push_back(trigger);

    printf("BoxColliders : %zu\n", m_colliders.size());
}
/// C++ — Renderer.cpp — update:11/09/26
void Renderer::updateColliders()
{
    // 1 — ce qui bouge : local → monde (les isStatic ne sont JAMAIS retouchés)
    Blender* odonata = m_assimp.getModel(m_odonata);
    BoxCollider& player = m_colliders[m_playerCollider];
    player.worldAABB = transformAABB(player.localAABB, odonata->transform);

    BoxCollider& camera = m_colliders[m_cameraCollider];
    camera.worldAABB = transformAABB(camera.localAABB, math::makeTranslate(m_camera.getPosition()));

    // 2 — broad phase : n(n-1)/2 paires × 6 comparaisons
    for (BoxCollider& c : m_colliders)
        c.hit = false;

    for (size_t i = 0; i < m_colliders.size(); i++)
    {
        for (size_t j = i + 1; j < m_colliders.size(); j++)
        {
            BoxCollider& a = m_colliders[i];
            BoxCollider& b = m_colliders[j];

            if (a.isStatic && b.isStatic)
                continue; // deux murs ne se rentrent jamais dedans
            if (!(a.layerMask & b.layerMask))
                continue; // filtre de couches
            if (!a.worldAABB.intersects(b.worldAABB))
                continue;

            a.hit = true;
            b.hit = true; // ici, plus tard : resolveCollision(a, b)
        }
    }

    // 3 — soumettre au rendu : une instance par collider
    m_aabb.beginFrame();
    for (const BoxCollider& c : m_colliders)
        m_aabb.submit(c);
}
/// C++ — Renderer.cpp — update:11/09/26 — update(), à la fin
    m_sprites.setCamera({ 0.f, 0.f }, 1.f);

    // toggle du debug draw sur front montant — sinon il clignote 60 fois par seconde
    if (m_input.keyLeft && !m_togglePressed)
        m_showColliders = !m_showColliders;
    m_togglePressed = m_input.keyLeft;

    updateColliders(); // après m_camera.updateUniforms() : la caméra a sa position finale
};
/// C++ — Renderer.cpp — update:11/09/26 — draw(), dans la passe principale
    m_assimp.draw(renderCommandEncoder, m_globalUniforms);

    if (m_showColliders)
        m_aabb.draw(renderCommandEncoder, m_globalUniforms); // après les opaques, avant les sprites

    m_sprites.drawFrame(m_explosion, m_explosionAnim.frame, /* … */);
    m_sprites.flush(renderCommandEncoder);
    renderCommandEncoder->endEncoding();
Piège repéré dans Renderer.cpp — + n'est pas ×
math::makeTranslate({0, 50, 0}) + math::makeScale({3, 3, 3}) additionne les deux matrices terme à terme : la diagonale devient 4 au lieu de 3, et le w de la colonne 3 vaut 2 au lieu de 1. Composer deux transformations c'est makeTranslate(…) * makeScale(…) — et l'ordre compte (scale d'abord, puis translation). Avec +, transformAABB() renverra une boîte fausse, puisqu'il lit la colonne 3 comme une translation et les colonnes 0-2 comme la rotation/échelle.

Ce que tu vois

CouleurÉtatOrigine
Vertcollider mobile librehit == false
Rougeau moins une paire en intersectionhit == true
Jaune translucidevolume de triggerisTrigger
Grisobstacle statiqueisStatic

Déplace-toi avec Z/Q/S/D, A/E pour monter-descendre : dès que la boîte de la caméra touche un obstacle, les deux passent au rouge. Flèche gauche coupe et remet l'affichage. Si une boîte est énorme ou au mauvais endroit, c'est ton fromVertices() ou ta matrice modèle qui parle — pas le rendu.

⬡CheckPoint Compilation — boîtes vertes qui rougissent au contact

Les pièges de ce rendu

4 Ko, épaisseur, et O(n²)
  • setVertexBytes est limité à 4 Ko : 1024 instances × 48 o = 48 Ko, donc buffer obligatoire — setVertexBytes ne sert plus qu'aux GlobalUniforms.
  • Un seul instanceBuffer suffit ici parce que Renderer::draw() termine par waitUntilCompleted(). Le jour où tu enlèves cette attente, il faut 2 ou 3 buffers tournants, sinon le CPU réécrit les instances que le GPU lit encore.
  • L'épaisseur des lignes n'est pas réglable en Metal : toujours 1 pixel. Pour des arêtes épaisses, il faut passer les arêtes en quads orientés écran — un autre shader, une autre histoire.
  • La broad phase est en O(n²) : 8 colliders = 28 tests, parfait. 1000 colliders = 499 500 tests par frame. Au-delà de quelques centaines de boîtes, il faut une grille uniforme ou un BVH — les AABB restent la brique de base, c'est juste le parcours qui change.

Résumé

AABB = boîte définie par minimum + maximum
center()       → milieu entre les deux coins
extents()      → "rayon" sur chaque axe (taille / 2)
intersects()   → 6 comparaisons, O(1), test le plus rapide
fromVertices() → encapsule un mesh complet
transformAABB()→ reconstruit la boîte après déplacement/rotation
BoxCollider    → wrapping complet : local + world + layers

AABBRenderer   → 1 boîte unitaire instanciée, 1 draw call, MTL::PrimitiveTypeLine
  beginFrame() → remet le compteur d'instances à 0
  submit()     → centre + extents + couleur dans le buffer d'instances
  draw()       → drawIndexedPrimitives(Line, 24, UInt16, indices, 0, count)
  depthWrite   → false : un calque de debug ne creuse pas le Z-buffer

👉 Si tu comprends ça tu comprends déjà 70% des systèmes de collision d'un moteur 3D, le célèbre jeu Minecraft utilise ce système !