Textures externes
- 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]]
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.
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().
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.
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.
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.
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.
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.
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()));
}
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();
}
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);
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.
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.
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 */