Chapitre -11 · Extern Textures

Textures externes

Objectif de ce chapitre
  • Charger un PNG, un JPG, un BMP, un PSD, un GIF, un HDR, un PIC ou un TGA avec stb_image et le transférer en MTL::Texture
  • Générer les mipmaps au chargement avec un blit sur une texture privée
  • Dessiner des milliers de sprites avec un seul draw call par texture (instancing + baseInstance)
  • Blending alpha, blending additif, et le qualificateur d'interpolation [[flat]]
⏱ ~35 min
Pré-requis

Une image n'est pas une texture

Un fichier .png sur ton disque est un flux compressé : en-tête, palette éventuelle, données zippées. Le GPU ne sait pas lire ça. Ce qu'il attend, c'est un MTL::Texture — un bloc de pixels au format qu'il connaît, rangé dans une mémoire à laquelle il a accès.

Entre les deux, il faut un décodeur. ↘ stb_image fait exactement ça, et rien d'autre : il prend un chemin de fichier, il rend un tableau d'octets, il te dit combien de pixels de large et de haut. À toi de faire le reste.

note

Une texture n'apporte aucune géométrie. Une image de 2560×1880, c'est un rectangle — quatre coins que tu sais calculer toi-même. Charger un .obj pour afficher une image serait absurde : on générera le quad directement dans le vertex shader, comme le triangle plein-écran du chapitre Viewport.

Structure du projet

MSLearn/ ├── Renderer/ … │ ├── SpriteRenderer.cpp │ ├── SpriteRenderer.hpp │ ├── Renderer.cpp │ └── Renderer.hpp ├── Shaders/ … │ └── Sprite.metal ├── includes/ … │ ├── SharedGPU/ … │ │ └── SpriteTypes.hpp │ └── extern/ │ └── stb_image.h ├── explosion.png # ta spritesheet └── Makefile

Le fichier partagé

Un sprite ne change pas de forme : seulement de position, de taille, de couleur et de case dans l'atlas.

/// C++ & MSL — SpriteTypes_shared.h — update:09/09/26
#ifndef SpriteTypes_shared_h
#define SpriteTypes_shared_h

#include <simd/simd.h>

/// Un sprite = une instance. Le quad (6 sommets) est déplié dans le vertex shader,
/// on n'envoie donc aucun vertex buffer : seulement ce tableau d'instances.
/// Ordre des champs : les float4 d'abord → 80 octets pile, aucun trou d'alignement.
struct alignas(16) SpriteInstance
{
    simd_float4 uvRect;       // x, y, largeur, hauteur dans l'atlas (0..1)
    simd_float4 tint;         // couleur multipliée (RGBA)
    simd_float4 position;     // centre du sprite, en pixels monde, w = mode
    simd_float2 size;         // largeur / hauteur en pixels
    simd_float2 pivot;        // point de rotation, 0..1 (0.5, 0.5 = centre)
    float       rotation;     // radians
    float       depth;        // 0 = premier plan, 1 = arrière-plan (z NDC)
    float       flash;        // 0..1 : blanchit le sprite (dégâts, invincibilité)
    float       alphaCutoff;  // seuil de discard_fragment()
};

/// Comment un sprite monde s'oriente. Rangé dans position.w : un float libre, zéro octet de plus.
enum SpriteFacing
{
    SpriteFacingCamera = 0,   // face la caméra en permanence (particules, impacts, halos)
    SpriteFacingYAxis  = 1,   // ne pivote qu'autour de Y (arbres, ennemis façon Doom)
    SpriteFacingFixed  = 2    // plan XY du monde, ne tourne pas (panneau, décal au mur)
};

/// Constantes du batch écran : envoyées en setVertexBytes (< 4 Ko).
struct alignas(16) SpriteUniforms
{
    simd_float2 viewportSize;  // taille du viewport en pixels
    simd_float2 cameraOffset;  // scroll de la caméra 2D en pixels
    float       cameraZoom;    // 1.0 = 100 %

};

/// Constantes du batch monde : la matrice de la scène, et les deux axes du plan écran.
struct alignas(16) SpriteWorldUniforms
{
    simd_float4x4 viewProjectionMatrix;
    simd_float4   cameraRight;   // xyz, w ignoré
    simd_float4   cameraUp;
};

#endif /* SpriteTypes_shared_h */

Structurer les données

De manière a utiliser la classe pour plusieurs utilisations différentes tu peux créer des structures :

Charger — décoder, allouer, transférer

Trois étapes distinctes. stb décode vers la RAM, Metal alloue en VRAM, un BlitCommandEncoder fait le pont.

/// C++ — SpriteRenderer.cpp — update:07/09/26
SpriteRenderer::TextureID SpriteRenderer::loadTexture(const std::string& resourcesPath,
                                                      Filter filter, bool sRGB, bool mipmaps)
{
    if (auto it = m_lookup.find(resourcesPath); it != m_lookup.end())
        return it->second; // déjà en VRAM : on ne recharge pas

    // stb écrit la première ligne du fichier en premier, comme l'origine d'une
    // texture Metal (coin haut-gauche) : pas de flip pour la 2D. On force la valeur
    // à chaque appel car le flag est GLOBAL — Assimp ou le SDF ont pu le basculer.
    stbi_set_flip_vertically_on_load(false);

    int width = 0, height = 0, sourceChannels = 0;
    // STBI_rgb_alpha : on force 4 canaux, Metal n'a pas de format RGB8 sur 3 octets.
    stbi_uc* pixels = stbi_load(resourcesPath.c_str(), &width, &height, &sourceChannels, STBI_rgb_alpha);
    if (!pixels)
    {
        printf("[SpriteRenderer] chargement impossible : %s (%s)\n",
               resourcesPath.c_str(), stbi_failure_reason());
        return kInvalidTexture;
    }

    NS::UInteger mipLevels = 1;
    if (mipmaps)
        mipLevels = 1 + (NS::UInteger)std::floor(std::log2((float)std::max(width, height)));

    auto textureDescriptor = NS::TransferPtr(MTL::TextureDescriptor::alloc()->init());
    textureDescriptor->setTextureType(MTL::TextureType2D);
    textureDescriptor->setPixelFormat(sRGB ? MTL::PixelFormatRGBA8Unorm_sRGB : MTL::PixelFormatRGBA8Unorm);
    textureDescriptor->setWidth((NS::UInteger)width);
    textureDescriptor->setHeight((NS::UInteger)height);
    textureDescriptor->setMipmapLevelCount(mipLevels);
    textureDescriptor->setUsage(MTL::TextureUsageShaderRead);
    textureDescriptor->setStorageMode(MTL::StorageModePrivate);   // lue par le GPU uniquement

    auto texture = NS::TransferPtr(m_device->newTexture(textureDescriptor.get()));

    // Une texture privée n'est pas adressable par le CPU : on passe par un buffer
    // de transit partagé, puis un blit. Même schéma que le streaming d'assets d'un jeu.
    const NS::UInteger bytesPerRow = (NS::UInteger)width * 4;
    const NS::UInteger byteCount   = bytesPerRow * (NS::UInteger)height;
    auto staging = NS::TransferPtr(m_device->newBuffer(pixels, byteCount, MTL::ResourceStorageModeShared));

    MTL::CommandBuffer*      commandBuffer = m_uploadQueue->commandBuffer();
    MTL::BlitCommandEncoder* blitEncoder   = commandBuffer->blitCommandEncoder();
    blitEncoder->copyFromBuffer(staging.get(), 0, bytesPerRow, byteCount,
                                MTL::Size::Make((NS::UInteger)width, (NS::UInteger)height, 1),
                                texture.get(), 0, 0, MTL::Origin::Make(0, 0, 0));
    if (mipLevels > 1)
        blitEncoder->generateMipmaps(texture.get());   // le GPU fabrique les niveaux tout seul
    blitEncoder->endEncoding();
    commandBuffer->commit();
    commandBuffer->waitUntilCompleted();               // chargement synchrone, hors boucle de rendu

    stbi_image_free(pixels);                           // le staging buffer a copié : on libère

    GPUTexture entry;
    entry.texture = texture;
    entry.width   = (uint32_t)width;
    entry.height  = (uint32_t)height;
    entry.filter  = filter;

    const TextureID id = (TextureID)m_textures.size();
    m_textures.push_back(entry);
    m_lookup.emplace(resourcesPath, id);

    printf("[SpriteRenderer] %s → %dx%d, %d canaux source, %lu niveaux de mip\n",
           resourcesPath.c_str(), width, height, sourceChannels, (unsigned long)mipLevels);
    return id;
}
StorageModePrivate
→ la texture vit dans une mémoire que seul le GPU lit. C'est le mode le plus rapide à l'échantillonnage, et le seul raisonnable pour un asset qui ne change plus après le chargement.
Le staging buffer
→ puisque le CPU ne peut pas écrire dans une texture privée, on écrit d'abord dans un MTL::Buffer partagé, et le blit recopie côté GPU. C'est une copie de plus, payée une fois au chargement.
generateMipmaps()
→ le GPU réduit lui-même l'image de moitié à chaque niveau. Sans mipmaps, une texture affichée petite scintille dès que la caméra bouge : le sampler pioche un pixel au hasard d'une frame à l'autre.
m_uploadQueue
→ une MTL::CommandQueue dédiée aux transferts. Le waitUntilCompleted() bloque le CPU, ce qui est acceptable au chargement et inacceptable dans draw().
important

Le format de pixel doit répondre à la question : que représentent ces octets ? Une couleur (albedo, sprite, HUD) est stockée en sRGB par ton logiciel de dessin ; un masque, une normal map ou une roughness ne sont pas des couleurs et se chargent en RGBA8Unorm.

Le piège : si ta cible de rendu est en BGRA8Unorm (non-sRGB) et que tu charges ton sprite en …_sRGB, le sampler convertit vers le linéaire et personne ne reconvertit derrière — image délavée. D'où le défaut à false pour les sprites.

⬡CheckPoint Compilation — la console affiche les dimensions et le nombre de mips de ton image.
question
Ton projet compile mais le linker sort duplicate symbol _stbi_load. Que s'est-il passé ?

Le quad qui n'existe pas

Six sommets, deux triangles, et aucun vertex buffer. On reprend le principe du triangle plein-écran : vertex_id n'est pas une lecture mémoire, c'est un simple compteur d'invocations. Quand tu demandes 6 sommets, Metal appelle ton vertex shader 6 fois avec 0 à 5 — libre à toi d'aller chercher des données, ou d'en fabriquer.

/// MSL — Sprite.metal — update:07/09/26
// Le quad n'existe nulle part en mémoire : on le déplie depuis le vertex_id.
constant float2 kCorners[6] =
{
    { 0.f, 0.f }, { 1.f, 0.f }, { 0.f, 1.f },
    { 1.f, 0.f }, { 1.f, 1.f }, { 0.f, 1.f }
};

L'astuce du motif : corner sert deux fois. Transformé, c'est la position du sommet. Brut, c'est directement l'UV — les deux vont de 0 à 1 dans le même sens. Une seule variable, deux rôles, et c'est ce qui permet à uvRect de découper une case d'atlas en une multiplication.

note

Cet ordre de sommets produit des triangles clockwise en NDC, donc des faces avant avec la convention Metal par défaut. Tant que ton cull mode est CullModeNone, aucune importance. Le jour où tu actives le culling, un quad qui disparaît sans erreur de validation vient neuf fois sur dix de là.

Le shader

/// MSL — Sprite.metal — update:07/09/26
#include <metal_stdlib>
#include "../includes/SharedGPU/SpriteTypes.hpp"

using namespace metal;

struct SpriteVertexOut
{
    float4 position [[position]];
    float2 uv;
    float4 tint;
    float  flash       [[flat]]; // constant sur l'instance : pas d'interpolation
    float  alphaCutoff [[flat]];
};

vertex SpriteVertexOut vertex_sprite(uint vertexID                        [[vertex_id]],
                                     uint instanceID                      [[instance_id]],
                                     const device SpriteInstance* sprites [[buffer(0)]],
                                     constant SpriteUniforms&  uniforms   [[buffer(1)]])
{
    const SpriteInstance sprite = sprites[instanceID];
    const float2 corner = kCorners[vertexID];

    // 1. coin 0..1 → espace local, centré sur le pivot
    float2 local = (corner - sprite.pivot) * sprite.size;

    // 2. rotation 2D autour du pivot
    const float c = cos(sprite.rotation);
    const float s = sin(sprite.rotation);
    local = float2(local.x * c - local.y * s, local.x * s + local.y * c);

    // 3. monde → écran (caméra 2D : scroll + zoom)
    const float2 world  = sprite.position + local;
    const float2 screen = (world - uniforms.cameraOffset) * uniforms.cameraZoom;

    // 4. pixels → NDC. Y est inversé : on garde l'origine en haut à gauche,
    //    comme la position souris et comme les UV d'une texture Metal.
    const float2 ndc = float2(screen.x / uniforms.viewportSize.x * 2.f - 1.f,
                              1.f - screen.y / uniforms.viewportSize.y * 2.f);

    SpriteVertexOut out;
    out.position    = float4(ndc, sprite.depth, 1.f);
    out.uv          = sprite.uvRect.xy + corner * sprite.uvRect.zw; // zw < 0 = miroir
    out.tint        = sprite.tint;
    out.flash       = sprite.flash;
    out.alphaCutoff = sprite.alphaCutoff;
    return out;
}

fragment float4 fragment_sprite(SpriteVertexOut  in           [[stage_in]],
                                texture2d<float> atlas        [[texture(0)]],
                                sampler          atlasSampler [[sampler(0)]])
{
    float4 color = atlas.sample(atlasSampler, in.uv) * in.tint;

    if (color.a <= in.alphaCutoff)
        discard_fragment();

    // Flash de dégâts : on blanchit le sprite sans toucher à sa silhouette.
    color.rgb = mix(color.rgb, float3(1.f), in.flash);
    return color;
}

Entre les deux étages : le rastériseur interpole

Tu écris trois sorties de sommets, et le fragment shader en reçoit des milliers. Personne n'a écrit le dégradé : le rastériseur calcule pour chaque fragment trois poids barycentriques — sa position relative aux trois sommets — et mélange. C'est ce qui fait varier uv continûment sur toute la surface.

[[flat]] coupe ce mélange. Le fragment reçoit la valeur d'un seul sommet, le provoking vertex, qui en Metal est le premier sommet de la primitive. Pas de moyenne, pas de pente, une constante sur tout le triangle.

Interpolation d'une sortie de vertex shader center_perspective (défaut) mélange des 3 sommets v0 = 1.0 0.0 0.0 0.5 chaque fragment a sa propre valeur [[flat]] valeur du provoking vertex v0 = 1.0 0.0 ignoré 0.0 ignoré 1.0 une constante sur toute la primitive [[flat]] sur un quad 2 triangles = 2 provoking vertices v0 v3 la diagonale devient visible

Trois raisons de l'écrire

Ce n'est pas négociable pour les entiers. Il n'existe pas d'interpolation entière. Un uint ou un int dans une structure stage_in doit être [[flat]], sinon le compilateur MSL refuse. Le jour où tu fais passer un materialID au fragment shader, tu tomberas dessus.

La dérive flottante. Un float qui sert d'index — 3.0 pour dire « troisième case » — n'est pas interpolé exactement. Entre deux sommets valant 3.0, l'arithmétique peut sortir 2.9999998 au milieu du triangle. Un cast en int derrière renvoie 2 sur une poignée de fragments, et tu obtiens des pixels isolés d'une autre couleur, presque impossibles à diagnostiquer.

Le coût, marginalement. Tu économises l'évaluation barycentrique de deux flottants par fragment. À l'échelle d'un sprite, ça ne se mesure pas — autant le dire.

note

Dans notre shader, [[flat]] ne change rien à l'image : flash et alphaCutoff viennent de l'instance, donc les six sommets portent déjà la même valeur. Le qualificateur documente une intention — « cette donnée est constante sur le sprite » — il ne corrige pas un bug.

Les autres qualificateurs existent aussi : center_no_perspective interpole linéairement en espace écran, sans diviser par w. Sur un sprite 2D où w vaut 1 partout, c'est rigoureusement identique au défaut. Ça ne devient visible que sur une surface inclinée en perspective — c'est exactement la raison pour laquelle les textures des premiers jeux PlayStation ondulent quand la caméra bouge.

question
Tu marques [[flat]] une valeur qui diffère d'un sommet à l'autre sur ton quad. Qu'est-ce que tu vois à l'écran ?

Le blending — deux pipelines, un seul shader

Sans blending activé, un PNG transparent dessine un rectangle noir. C'est l'erreur numéro un, et elle ne produit aucun message d'erreur.

/// C++ — SpriteRenderer.cpp — update:07/09/26
void SpriteRenderer::buildPipelines(MTL::Library* shaderLibrary,
                                    MTL::PixelFormat colorPixelFormat, MTL::PixelFormat depthPixelFormat)
{
    auto vertexFunction   = NS::TransferPtr(shaderLibrary->newFunction(MTLSTR("vertex_sprite")));
    auto fragmentFunction = NS::TransferPtr(shaderLibrary->newFunction(MTLSTR("fragment_sprite")));
    assert(vertexFunction && fragmentFunction && "vertex_sprite / fragment_sprite absents de la library");

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

    descriptor->colorAttachments()->object(0)->setPixelFormat(colorPixelFormat);
    descriptor->colorAttachments()->object(0)->setBlendingEnabled(true);
    descriptor->colorAttachments()->object(0)->setRgbBlendOperation(MTL::BlendOperationAdd);
    descriptor->colorAttachments()->object(0)->setAlphaBlendOperation(MTL::BlendOperationAdd);
    descriptor->colorAttachments()->object(0)->setSourceRGBBlendFactor(MTL::BlendFactorSourceAlpha);
    descriptor->colorAttachments()->object(0)->setSourceAlphaBlendFactor(MTL::BlendFactorSourceAlpha);
    descriptor->colorAttachments()->object(0)->setDestinationRGBBlendFactor(MTL::BlendFactorOneMinusSourceAlpha);
    descriptor->colorAttachments()->object(0)->setDestinationAlphaBlendFactor(MTL::BlendFactorOneMinusSourceAlpha);

    NS::Error* error = nullptr;
    m_pipelineAlpha = NS::TransferPtr(m_device->newRenderPipelineState(descriptor.get(), &error));
    assert(!error && "SpriteRenderer : pipeline alpha impossible à créer");

    // Additif : source + destination. Le noir devient transparent → parfait pour
    // les explosions, les halos et les traînées de particules.
    descriptor->colorAttachments()->object(0)->setDestinationRGBBlendFactor(MTL::BlendFactorOne);
    descriptor->colorAttachments()->object(0)->setDestinationAlphaBlendFactor(MTL::BlendFactorOne);
    m_pipelineAdditive = NS::TransferPtr(m_device->newRenderPipelineState(descriptor.get(), &error));
    assert(!error && "SpriteRenderer : pipeline additif impossible à créer");
}

La formule est la même dans les deux cas — source × facteurSource + destination × facteurDestination — seul le facteur de destination change. En alpha, la source remplace la destination à hauteur de son opacité. En additif, elle s'ajoute : le noir n'a plus aucun effet, et deux halos qui se superposent brillent davantage. C'est exactement ce que tu veux pour du feu.

La profondeur — deux états, deux usages

/// C++ — SpriteRenderer.cpp — update:07/09/26
void SpriteRenderer::buildDepthStates()
{
    auto descriptor = NS::TransferPtr(MTL::DepthStencilDescriptor::alloc()->init());

    // Overlay (HUD) : toujours visible, et surtout on n'écrit PAS la profondeur.
    descriptor->setDepthCompareFunction(MTL::CompareFunctionAlways);
    descriptor->setDepthWriteEnabled(false);
    m_depthOverlay = NS::TransferPtr(m_device->newDepthStencilState(descriptor.get()));

    // World : le sprite est occulté par la géométrie 3D (billboards, décals).
    descriptor->setDepthCompareFunction(MTL::CompareFunctionLess);
    m_depthWorld = NS::TransferPtr(m_device->newDepthStencilState(descriptor.get()));
}
important

Dans les deux cas, l'écriture de profondeur reste false. Un sprite transparent qui écrit sa profondeur découpe un trou rectangulaire dans tout ce qui sera dessiné derrière lui ensuite — le fond disparaît autour du personnage, sans erreur de validation, sans warning.

Un draw call par texture

Mille sprites, ce n'est pas mille draw calls. Le quad est identique pour tous : seules les données d'instance changent. On accumule tout dans un std::vector, on trie, on recopie dans un buffer, et on encode une plage par texture.

/// C++ — SpriteRenderer.cpp — update:07/09/26
void SpriteRenderer::flush(MTL::RenderCommandEncoder* encoder, Depth depthMode)
{
    m_drawCalls = 0;
    if (m_batch.empty())
        return;

    // Tri : le plus loin d'abord (l'alpha n'est pas commutatif), puis par blend et
    // par texture pour former des plages contiguës = un seul draw call chacune.
    std::stable_sort(m_batch.begin(), m_batch.end(),
                     [](const BatchEntry& a, const BatchEntry& b)
                     {
                         if (a.instance.depth != b.instance.depth)
                             return a.instance.depth > b.instance.depth;
                         if (a.blend != b.blend)
                             return a.blend < b.blend;
                         return a.texture < b.texture;
                     });

    reserveInstances(m_batch.size());
    auto* gpuInstances = static_cast<SpriteInstance*>(m_instanceBuffer->contents());
    for (size_t i = 0; i < m_batch.size(); ++i)
        gpuInstances[i] = m_batch[i].instance;

    encoder->setDepthStencilState(depthMode == Depth::Overlay ? m_depthOverlay.get() : m_depthWorld.get());
    encoder->setVertexBuffer(m_instanceBuffer.get(), 0, 0);
    encoder->setVertexBytes(&m_uniforms, sizeof(SpriteUniforms), 1);

    size_t start = 0;
    while (start < m_batch.size())
    {
        const TextureID id    = m_batch[start].texture;
        const Blend     blend = m_batch[start].blend;

        size_t end = start;
        while (end < m_batch.size() && m_batch[end].texture == id && m_batch[end].blend == blend)
            ++end;

        const GPUTexture& source = m_textures[id];
        encoder->setRenderPipelineState(blend == Blend::Alpha ? m_pipelineAlpha.get() : m_pipelineAdditive.get());
        encoder->setFragmentTexture(source.texture.get(), 0);
        encoder->setFragmentSamplerState(source.filter == Filter::Nearest ? m_samplerNearest.get()
                                                                         : m_samplerLinear.get(), 0);
        // baseInstance = start : tout le batch tient dans un seul buffer.
        encoder->drawPrimitives(MTL::PrimitiveTypeTriangle,
                                NS::UInteger(0), NS::UInteger(6),
                                (NS::UInteger)(end - start), (NS::UInteger)start);
        ++m_drawCalls;
        start = end;
    }
    m_batch.clear();
}
baseInstance

Le cinquième paramètre décale l'index d'instance lu par le shader. Sans lui, il faudrait un buffer par plage, ou recopier les instances au bon offset avant chaque draw. Avec lui, un seul buffer contient tout le batch et chaque draw call démarre là où le précédent s'est arrêté.

Intégration

/// C++ — Renderer.hpp — update:07/09/26
#include "SpriteRenderer.hpp"

private:
    SpriteRenderer              m_sprites;
    SpriteRenderer::SpriteSheet m_explosion;
    SpriteRenderer::Animator    m_explosionAnim;
/// C++ — Renderer.cpp — update:00/09/26 — liste d'initialisation
m_assimp(m_device, m_shaderLibrary, m_pixelFormat, m_depthPixelFormat, resourcePath),
m_sprites(m_device, m_shaderLibrary, m_pixelFormat, m_depthPixelFormat),
/// C++ — Renderer.cpp — update:09/09/26 — constructeur, chemin complet
{
    m_explosion = m_sprites.makeSheet(m_sprites.loadTexture(resourcePath + "/explosion.png",
                                                            SpriteRenderer::Filter::Nearest), 8, 4);
    m_explosionAnim.play({ 0, 32, 60.f, true });
}
    /// C++ — Renderer.cpp — update:07/09/26 — update
    m_explosionAnim.update(delta);
/// C++ — Renderer.cpp — update:07/09/26 — draw, dans l'encoder, avant endEncoding()
    m_sprites.begin({ (float)m_viewport.width, (float)m_viewport.height });
    m_sprites.setCamera({ 0.f, 0.f }, 1.f);
    m_sprites.drawFrame(m_explosion, m_explosionAnim.frame,
                        { (float)m_viewport.width * 0.5f, (float)m_viewport.height * 0.5f },
                        3.f, false, { 1, 1, 1, 1 }, 0.2f, 0.f,
                        SpriteRenderer::Blend::Additive);
    m_sprites.flush(renderCommandEncoder);
note

flush() laisse son pipeline et son état de profondeur liés à l'encoder. Si tu redessines de la 3D derrière, rebinde les tiens — c'est la même discipline que pour setViewport() : l'état persiste jusqu'au prochain changement.

⬡CheckPoint Compilation — ton explosion tourne en boucle au centre de la fenêtre, par-dessus la scène 3D.
question
Pourquoi trier les sprites du plus lointain au plus proche avant de les encoder ?

Points clés à retenir

Décoder ≠ uploader
→ stb rend des octets en RAM ; il faut un descripteur, une allocation privée et un blit pour arriver en VRAM.
Géométrie compilée
→ connue à la compilation, elle vit dans le .metal. Chargée à l'exécution, elle vit dans un buffer. Un rectangle est du premier type.
[[flat]]
→ obligatoire pour les entiers, salutaire pour les index flottants, décoratif pour une valeur déjà constante sur l'instance.
Transparence
→ blending activé, écriture de profondeur désactivée, tri arrière vers avant. Les trois ensemble, sinon rien.
break point ☕️

Header complet

/// C++ — ParserJSON.hpp — update:30/09/26
#ifndef SpriteRenderer_hpp
#define SpriteRenderer_hpp

#include <Metal/Metal.hpp>
#include <simd/simd.h>

#include <string>
#include <vector>
#include <unordered_map>

#include "../includes/SharedGPU/SpriteTypes_shared.h"

/// Gestionnaire de textures : texture(id) rend le MTL::Texture* brut, réutilisable
/// pour les matériaux Assimp ou l'atlas SDF.
/// Batch de sprites : spritesheets, animations, tint, flash de dégâts, blending additif,
/// tri par profondeur — à l'écran (HUD, jeu 2D) comme dans la scène 3D (billboards, décals).
/// Un seul draw call par (space × blend × texture) grâce à baseInstance.
///
/// La classe ne connaît ni ta Camera ni tes GlobalUniforms : tu lui passes une matrice et
/// deux axes. Elle reste copiable-collable d'un projet à l'autre.
class SpriteRenderer
{
public:
    static constexpr uint32_t kInvalidTexture = ~0u;

    enum class Filter : uint8_t { Nearest, Linear };  // Nearest = pixel art (pas de flou)
    enum class Blend  : uint8_t { Alpha, Additive };  // Additive = feu, halos, particules
    enum class Space  : uint8_t { World, Screen };    // World d'abord : il passe sous l'écran

#pragma mark - Spritesheet & animation

    struct SpriteSheet
    {
        uint32_t  texture = kInvalidTexture;
        uint32_t  columns = 1;
        uint32_t  rows    = 1;

        uint32_t frameCount() const { return columns * rows; }
    };

    /// Une animation = une plage de cases dans la spritesheet.
    struct Clip
    {
        uint32_t firstFrame = 0;
        uint32_t frameCount = 1;
        float    fps        = 12.f;
        bool     loop       = true;
    };

    /// État d'animation d'une entité. Une instance par personnage, pas par sprite.
    struct Animator
    {
        Clip     clip;
        float    time     = 0.f;
        uint32_t frame    = 0;
        bool     finished = false;

        void play(const Clip& newClip);   // ne redémarre pas si le clip tourne déjà
        void update(float delta);
    };

#pragma mark - Cycle de vie

    SpriteRenderer(MTL::Device*, MTL::Library*,
                   MTL::PixelFormat colorPixelFormat, MTL::PixelFormat depthPixelFormat);
    ~SpriteRenderer() = default;

    SpriteRenderer(const SpriteRenderer&)            = delete;  // le cache GPU n'est pas copiable
    SpriteRenderer& operator=(const SpriteRenderer&) = delete;

#pragma mark - Chargement (stb_image)

    /// Charge (ou retrouve dans le cache) une image : PNG, JPG, BMP, PSD, GIF, HDR, PIC, TGA.
    /// sRGB : true pour un albedo 3D éclairé si ta cible est en …_sRGB,
    ///        false pour le HUD / les sprites (sinon décodage en double → image délavée).
    uint32_t loadTexture(const std::string& resourcesPath,
                         Filter filter  = Filter::Linear,
                         bool   sRGB    = false, bool mipmaps = true);

    MTL::Texture* texture(uint32_t id) const;      // nullptr si l'id est invalide
    simd::float2  textureSize(uint32_t id) const;  // en pixels, comme le viewport

    /// Pixels → unités monde. 100 px pour 1 unité : une image de 512 fait 5,12 unités de large.
    simd::float2  worldSize(uint32_t id, float pixelsPerUnit = 100.f, float scale = 1.f) const;

    SpriteSheet   makeSheet(uint32_t id, uint32_t columns, uint32_t rows) const;
    simd_float4   frameUV(const SpriteSheet& sheet, uint32_t frame, bool flipX = false) const;

#pragma mark - Caméras

    void begin(simd::float2 viewportSize);                 // vide le batch de la frame
    void setCamera(simd::float2 offset, float zoom = 1.f); // scroll 2D en pixels
    /// Axes du plan écran + matrice de la scène : tout ce qu'il faut pour un billboard.
    /// right = -camera.getLeft(), up = camera.getUp(), position sert de clé de tri.
    void setCamera3D(const simd::float4x4& viewProjectionMatrix,
                     simd::float3 right, simd::float3 up, simd::float3 position);

#pragma mark - Batch écran (HUD, menus, jeu 2D)

    void draw(uint32_t id, const SpriteInstance& instance,
              Blend blend = Blend::Alpha, Space space = Space::Screen);

    void drawSprite(uint32_t id,
                    simd::float2 position, simd::float2 size,
                    simd_float4 tint     = { 1.f, 1.f, 1.f, 1.f },
                    float       rotation = 0.f,
                    float       depth    = 0.5f,
                    Blend       blend    = Blend::Alpha);

    void drawFrame(const SpriteSheet& sheet, uint32_t frame,
                   simd::float2 position,
                   float       scale = 1.f,
                   bool        flipX = false,
                   simd_float4 tint  = { 1.f, 1.f, 1.f, 1.f },
                   float       depth = 0.5f,
                   float       flash = 0.f,
                   Blend       blend = Blend::Alpha);

#pragma mark - Batch monde (billboards, décals, particules dans la 3D)

    /// size est en unités monde, pas en pixels : une texture 4K et une 64×64 peuvent
    /// occuper exactement le même mètre carré.
    void drawSprite3D(uint32_t id,
                      simd::float3 position, simd::float2 size,
                      SpriteFacing facing   = SpriteFacingCamera,
                      simd_float4  tint     = { 1.f, 1.f, 1.f, 1.f },
                      float        rotation = 0.f,
                      simd::float2 pivot    = { 0.5f, 0.5f },  // (0.5, 1) = posé au sol
                      Blend        blend    = Blend::Alpha);

    /// worldHeight en unités monde ; la largeur suit le ratio de la case.
    void drawFrame3D(const SpriteSheet& sheet, uint32_t frame,
                     simd::float3 position,
                     float        worldHeight = 1.f,
                     SpriteFacing facing      = SpriteFacingCamera,
                     bool         flipX       = false,
                     simd_float4  tint        = { 1.f, 1.f, 1.f, 1.f },
                     float        flash       = 0.f,
                     simd::float2 pivot       = { 0.5f, 0.5f },
                     Blend        blend       = Blend::Alpha);

#pragma mark - Encodage

    /// Trie, remplit le buffer d'instances et encode les draw calls.
    /// À appeler dans ton encoder, après la 3D et avant endEncoding().
    void flush(MTL::RenderCommandEncoder* encoder);

    size_t drawCallCount() const { return m_drawCalls; }   // pour ton HUD de debug
    size_t spriteCount()   const { return m_spriteCount; } // sprites du dernier flush

private:
    struct GPUTexture
    {
        NS::SharedPtr<MTL::Texture> texture;
        uint32_t width  = 0;
        uint32_t height = 0;
        Filter   filter = Filter::Linear;
    };

    struct BatchEntry
    {
        SpriteInstance instance;
        uint32_t       texture;
        Blend          blend;
        Space          space;
    };

    NS::SharedPtr<MTL::RenderPipelineState> makePipeline(MTL::Function* vertexFunction,
                                                         MTL::Function* fragmentFunction,
                                                         Blend blend,
                                                         MTL::PixelFormat, MTL::PixelFormat);
    void buildPipelines(MTL::Library* shaderLibrary,
                        MTL::PixelFormat colorPixelFormat, MTL::PixelFormat depthPixelFormat);
    void buildSamplers();
    void buildDepthStates();
    void reserveInstances(size_t count);

    MTL::Device*                            m_device = nullptr; // possédé par le Renderer, pas retenu ici

    NS::SharedPtr<MTL::CommandQueue>        m_uploadQueue;      // blits de chargement uniquement
    // [Space][Blend] : deux vertex shaders × deux blendings, un seul fragment shader.
    NS::SharedPtr<MTL::RenderPipelineState> m_pipelines[2][2];
    NS::SharedPtr<MTL::DepthStencilState>   m_depthOverlay;
    NS::SharedPtr<MTL::DepthStencilState>   m_depthWorld;
    NS::SharedPtr<MTL::SamplerState>        m_samplerNearest;
    NS::SharedPtr<MTL::SamplerState>        m_samplerLinear;
    NS::SharedPtr<MTL::Buffer>              m_instanceBuffer;

    size_t                                     m_instanceCapacity = 0;
    std::vector<GPUTexture>                    m_textures;
    std::unordered_map<std::string, uint32_t>  m_lookup;        // chemin → id, évite les doublons
    std::vector<BatchEntry>                    m_batch;
    SpriteUniforms                             m_uniforms {};
    SpriteWorldUniforms                        m_uniformsWorld {};
    simd::float3                               m_cameraPosition = { 0.f, 0.f, 0.f };
    size_t                                     m_drawCalls   = 0;
    size_t                                     m_spriteCount = 0;
};

#endif /* SpriteRenderer_hpp */