Chapitre -115 · SDF texte

Signed Distance Field — Text

Du texte dans ta vue Metal, depuis les polices d'écriture que tu aimes.

Distance Field (champ de distances) : Pour chaque pixel d'une image, tu stockes sa distance au bord le plus proche d'une forme.

Signed (signée) : Cette distance est positive à l'extérieur, négative à l'intérieur (ou l'inverse selon la convention).

Forme : un cercle
  extérieur            bord            intérieur
  +3  +2  +1     +0.5 [ 0.0 ] -0.5     -1  -2  -3
Ce n'est pas une invention GPU au départ —
c'est un concept de géométrie computationnelle qui existait bien avant.

Il n'est pas essentiel de tout connaitre, ce sont les étapes recommandé pour afficher du texte à l'écran et c'est une technique très utilisé. Néanmoins, si tu as des débordements, des caractères coupés ou autres, il est bien de savoir modifier le programme à ta gise (et parce que tu veux une super police géniale).

Histoire

80 s'
Les SDF sont utilisés en robotique et en planification de trajectoire. Si ton robot sait la distance à l'obstacle le plus proche en chaque point, il peut naviguer proprement.
2007
Chris Green chez Valve publie le paper qui change tout → "Improved Alpha-Tested Magnification for Vector Textures and Special Effects" (SIGGRAPH 2007). Il applique l'idée aux glyphes de texte pour les jeux. Avant ça, Source Engine avait du texte moche qui pixelisait. Après, un seul atlas petit = texte net à toutes les tailles.
C'est littéralement un ingénieur de Valve qui a popularisé la technique pour le rendu temps réel..

Un exemple d'atlas : Syne / IBM

La photo n'est pas représentative exactement, j'ai réduis la qualité volontairement pour le web.

Comparaison avec une image bitmap (ou matricielle)

Bitmap
Tu photographies la forme.
Utilisé par ton OS, ImGUI, ton navigateur web,…
Pour du corps de texte à 13px dans une UI système, le bitmap rasterisé pixel-perfect gagne.
Image numérique composée d'une grille de points colorés, appelés pixels, où chaque pixel possède sa propre couleur. Contrairement aux images vectorielles, les bitmaps sont dépendants de la résolution et peuvent se pixelliser s'ils sont agrandis. Les formats courants incluent .bmp, .jpg, .png, et .gif
Bitmap atlas — chaque glyphe est rasterisé pixel-perfect à une taille fixe. Si tu veux du texte à 12px ET à 48px → deux atlas différents (ou un atlas énorme avec les deux tailles).
SDF
Tu cartographies à quelle distance tu en es depuis chaque pixel.
Utilisé par les moteurs de jeu, les logiciels de graphique vectoriels, Ray marching,…
Introduit un flou inhérent aux petites tailles et perd le hinting subpixel qui rend le texte lisible à 12px sur écran non-Retina. Pour un rendu à 48px+ c'est parfait !
Carte de distances beaucoup plus robuste au zoom qu'une image, parce que les distances interpolent bien. Un champ de distances reste mathématiquement cohérent. Police vectorielle, format .svg,…
SDF atlas — chaque glyphe encode des distances. Un seul atlas couvre toutes les tailles avec le même fichier.
Le ray marching est une technique de rendu 3D entière basée sur des SDF de formes géométriques (sdSphere, sdBox...), popularisée par Inigo Quilez sur https://www.shadertoy.com, c'est la même idée étendue en 3D : au lieu de distances à des bords de glyphes, c'est des distances à des surfaces 3D.

Un programme écrit en C++ uniquement, qui va nous générer un atlas de texture

Tu peux créer ta propre police d'écriture, en télécharger sur https://www.dafont.com et https://fonts.google.com ou utiliser celles que tu possèdes déjà dans ton Font-Book (App Native macOS)

FreeType

Bibliothèque logicielle disponible gratuitement pour rendre les polices.

Écrite en C, conçue pour être petite, efficace, hautement personnalisable et portable tout en produisant une sortie de haute qualité (images glyph) de la plupart des formats de polices vectorielles et bitmap.

# Shell
brew install freetype # update / install
Libpng

Bibliothèque de référence PNG officielle. Il prend en charge presque toutes les fonctionnalités PNG, est extensible et a été largement testé depuis plus de 30 ans.

# Shell
man libpng
brew install libpng # update / install

Génère un atlas SDF à partir d'un fichier TTF

Une fois créé/téléchargé, choisis le grammage qu'il te faut. SemiBold (IBM Plex Mono SemiBold) et Bold (Syne Bold) sont présentés.

Fichier .ttf (TrueType Font)

Format de police numérique standard développé par Apple et adopté par Microsoft, offrant une compatibilité maximale sur Windows et Mac. Il stocke des contours vectoriels, permettant d'afficher et d'imprimer des textes avec une grande netteté à n'importe quelle taille.

Quelques presets d'exemple (à modifier jusqu'à résultat acceptable) :
# Shell
./text_sdf_gen IBMPlexMono-SemiBold.ttf ibmplexmonosemibold4096.png metricsibmplexmonosemibold4096.csv
# Police: IBM Plex Mono SemiBold
# Taille: 640 px, Padding: 8 (default), Rayon SDF: 8 (default)
# Glyphes générés: 111
# Atlas: 4096x4096, utilisé jusqu'à Y=3997 (parfait)

./text_sdf_gen "Syne-ExtraBold.ttf" "syne2048xBold.png" "metrics.csv" "8" "8"
# Police: Syne ExtraBold
# Taille: 224 px, Padding: 8, Rayon SDF: 8
# Glyphes générés: 111 (sans Trucs)
# Atlas: 2048x2048, utilisé jusqu'à Y=1925

# Si tous les caractères n'ont pas pu être inclus, le programme te le dira :
# Attention: Atlas trop petit! Augmenter atlasSize. (ou réduit fontSize)

Si ton écran possède 1898 pixels de hauteur et que tu veux qu'un caractère recouvre 1/10 de ceux-ci (ce qui est énorme). Choisis une fontSize de 192.

Le plus important reste la taille de ton atlas (image) en pixel, là où tu dois optimiser la place pour ne pas avoir trop de perte (zone noire).

Tu peux évidemment travailler avec des polices d'écriture utilisant des charactères d'une autre langue (certains caractères peuvent manquer mais c'est rare) ou que tu aurais inventé.

Il n'y a pas besoin de fichier header, ni de l'inclure au projet Metal, on ne veut que la sortie du programme.

/// C++ — Exemple de programme pour l'atlas — text_SDF.cpp — update:18/05/26
#include </opt/homebrew/Cellar/freetype/2.14.3/include/freetype2/ft2build.h>
#include FT_FREETYPE_H // Inclus les 2 librairies selon leur documentation

#include </opt/homebrew/Cellar/libpng/1.6.58/include/png.h>
#include <cmath>
#include <vector>
#include <string>
#include <fstream>
#include <algorithm>
#include <cstring>

struct Config // Ici tu modifies fontSize & atlasSize. le reste c'est pour les curieux
{
    std::string inputFont;
    std::string outputAtlas;
    std::string outputMetrics;
    int fontSize = 256 - 64; // plus grand = meilleure qualité
    int padding = 8;         // Padding autour de chaque glyphe
    int sdfRadius = 8;       // Rayon de la distance signée
    int atlasSize = 4096;    // Taille de l'atlas (carré)
    
    std::string charset =    // Caractères que tu veux inclure
    // Juste un espace, mais ultra important
    " "
    // Chiffres
    "0123456789"
    // Alphabet minuscule
    "abcdefghijklmnopqrstuvwxyz"
    // Alphabet majuscule
    "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    // ASCII
    "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
    // Symboles
    "€£¥¢©®™°±×÷≤≥≠≈∞"
    // Trucs
    "ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ";
};
Remarque : La structure suivante est quasiment identique à la structure GlyphInfo qui nous permettra de stocker nos données dans notre application.
/// C++ — Exemple de programme pour l'atlas — text_SDF.cpp suite — update:18/05/26
struct GeneratedGlyph // Informations sur un glyphe généré
{
    uint32_t codepoint;
    int atlasX, atlasY;
    int atlasWidth, atlasHeight;
    int width, height;
    int bearingX, bearingY;
    int advance;
    std::vector<uint8_t> sdfData;
};

// Calcul de la distance signée pour un pixel
float const computeSDF(const uint8_t* bitmap, int bmpWidth, int bmpHeight, int px, int py, int radius)
{
    // Déterminer si le pixel est "inside" (dans le glyphe)
    bool inside = false;
    
    if (px >= 0 && px < bmpWidth && py >= 0 && py < bmpHeight)
        inside = bitmap[py * bmpWidth + px] > 127;
    
    float minDist = static_cast<float>(radius);
    
    // Chercher le pixel de bordure le plus proche
    for (int dy = -radius; dy <= radius; dy++)
    {
        for (int dx = -radius; dx <= radius; dx++)
        {
            int nx = px + dx;
            int ny = py + dy;
            
            if (nx < 0 || nx >= bmpWidth || ny < 0 || ny >= bmpHeight)
                continue;
            
            bool neighborInside = bitmap[ny * bmpWidth + nx] > 127;
            
            // Bordure trouvée
            if (neighborInside != inside)
            {
                float dist = std::sqrt(static_cast<float>(dx * dx + dy * dy));
                minDist = std::min(minDist, dist);
            }
        }
    }
    return inside ? minDist : -minDist; // Normaliser: inside = positif, outside = négatif
}

// Génération du SDF pour un glyphe
std::vector<uint8_t> generateGlyphSDF(FT_GlyphSlot glyph, int padding, int radius)
{
    int bmpWidth = glyph->bitmap.width;
    int bmpHeight = glyph->bitmap.rows;
    
    // Taille finale avec padding
    int sdfWidth = bmpWidth + padding * 2;
    int sdfHeight = bmpHeight + padding * 2;
    
    std::vector<uint8_t> sdf(sdfWidth * sdfHeight);
    
    for (int y = 0; y < sdfHeight; y++)
    {
        for (int x = 0; x < sdfWidth; x++)
        {
            // Position dans le bitmap original
            int bx = x - padding;
            int by = y - padding;
            
            float dist = computeSDF(glyph->bitmap.buffer, bmpWidth, bmpHeight, bx, by, radius);
            
            // Normaliser en 0-255 (128 = bord)
            float normalized = (dist / static_cast<float>(radius)) * 0.5f + 0.5f;
            normalized = std::clamp(normalized, 0.0f, 1.0f);
            
            sdf[y * sdfWidth + x] = static_cast<uint8_t>(normalized * 255.0f);
        }
    }
    return sdf;
}

std::vector<uint32_t> decodeUTF8(const std::string& text)
{
    std::vector<uint32_t> result;
    
    for (size_t i = 0; i < text.size();)
    {
        uint32_t cp = 0;
        unsigned char c = text[i];
        
        if ((c & 0x80) == 0)
        {
            cp = c;
            i += 1;
        }
        else if ((c & 0xE0) == 0xC0)
        {
            cp = (c & 0x1F) << 6;
            cp |= (text[i + 1] & 0x3F);
            i += 2;
        }
        else if ((c & 0xF0) == 0xE0)
        {
            cp = (c & 0x0F) << 12;
            cp |= (text[i + 1] & 0x3F) << 6;
            cp |= (text[i + 2] & 0x3F);
            i += 3;
        }
        else if ((c & 0xF8) == 0xF0)
        {
            cp = (c & 0x07) << 18;
            cp |= (text[i + 1] & 0x3F) << 12;
            cp |= (text[i + 2] & 0x3F) << 6;
            cp |= (text[i + 3] & 0x3F);
            i += 4;
        }
        else
            i += 1;

        result.push_back(cp);
    }
    return result;
}

bool savePNG(const std::string& path, const uint8_t* data, int width, int height)
{
    FILE* fp = fopen(path.c_str(), "wb");
    if (!fp)
        return false;

    png_structp png = png_create_write_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr);

    png_infop info = png_create_info_struct(png);
    
    if (setjmp(png_jmpbuf(png)))
    {
        fclose(fp);
        return false;
    }
    
    png_init_io(png, fp);
    
    png_set_IHDR(png, info, width, height, 8, PNG_COLOR_TYPE_GRAY,
                 PNG_INTERLACE_NONE, PNG_COMPRESSION_TYPE_DEFAULT, PNG_FILTER_TYPE_DEFAULT);
    png_write_info(png, info);
    
    std::vector<png_bytep> rows(height);
    
    for (int y = 0; y < height; y++)
        rows[y] = const_cast<uint8_t*>(&data[y * width]);
    
    png_write_image(png, rows.data());
        png_write_end(png, nullptr);
    
    png_destroy_write_struct(&png, &info);
    fclose(fp);
    
    return true;
}

int main(int argc, char* argv[])
{
    Config config;
    
    if (argc < 4)
    {
        printf("Usage: %s <font.ttf> <atlas.png> <metrics.csv> [fontSize] [padding]\n", argv[0]);
        return 1;
    }
    
    config.inputFont = argv[1];
    config.outputAtlas = argv[2];
    config.outputMetrics = argv[3];
    if (argc > 4)
        config.fontSize = std::stoi(argv[4]);
    if (argc > 5)
        config.padding = std::stoi(argv[5]);
    
    // Initialisation FreeType
    FT_Library ft;
    
    if (FT_Init_FreeType(&ft))
    {
        printf("Erreur: Impossible d'initialiser FreeType\n");
        return 1;
    }
    
    FT_Face face;
    
    if (FT_New_Face(ft, config.inputFont.c_str(), 0, &face))
    {
        printf("Erreur: Impossible de charger la police '%s'\n", config.inputFont.c_str());
        return 1;
    }
    
    FT_Set_Pixel_Sizes(face, 0, config.fontSize);
    
    printf("Police: %s %s\n", face->family_name, face->style_name);
    printf("Size: %d px, Pad: %d, RayonSDF: %d\n", config.fontSize, config.padding, config.sdfRadius);
    
    // Génération des glyphes
    std::vector<GeneratedGlyph> glyphs;
    std::vector<uint32_t> codepoints = decodeUTF8(config.charset);
    
    for (uint32_t cp : codepoints)
    {
        FT_UInt glyphIndex = FT_Get_Char_Index(face, cp);
        
        if (glyphIndex == 0 && cp != ' ')
            continue;
        
        if (FT_Load_Glyph(face, glyphIndex, FT_LOAD_RENDER))
            continue;
        
        GeneratedGlyph g;
        g.codepoint = cp;
        g.width = face->glyph->bitmap.width;
        g.height = face->glyph->bitmap.rows;
        g.bearingX = face->glyph->bitmap_left;
        g.bearingY = face->glyph->bitmap_top;
        g.advance = face->glyph->advance.x >> 6;
        
        // Générer le SDF
        g.sdfData = generateGlyphSDF(face->glyph, config.padding, config.sdfRadius);
        g.atlasWidth = g.width + config.padding * 2;
        g.atlasHeight = g.height+ config.padding * 2;
        
        glyphs.push_back(std::move(g));
    }
    
    printf("Glyphes générés: %zu\n", glyphs.size());
    
    // Tri par hauteur décroissante (meilleur packing)
    std::sort(glyphs.begin(), glyphs.end(), [](const auto& a, const auto& b) {
        return a.atlasHeight > b.atlasHeight;
    });
    
    // Packing simple en lignes
    int atlasWidth = config.atlasSize;
    int atlasHeight = config.atlasSize;
    std::vector<uint8_t> atlas(atlasWidth * atlasHeight, 0);
    
    int cursorX = 0;
    int cursorY = 0;
    int rowHeight = 0;
    
    for (auto& g : glyphs)
    {
        // Nouvelle ligne si nécessaire
        if (cursorX + g.atlasWidth > atlasWidth)
        {
            cursorX = 0;
            cursorY += rowHeight + 1;
            rowHeight = 0;
        }
        
        if (cursorY + g.atlasHeight > atlasHeight)
        {
            printf("Attention: Atlas trop petit! Augmenter atlasSize.\n");
            break;
        }
        
        // Copier le SDF dans l'atlas
        g.atlasX = cursorX;
        g.atlasY = cursorY;
        
        for (int y = 0; y < g.atlasHeight; y++)
        {
            memcpy(&atlas[(cursorY + y) * atlasWidth + cursorX,
                   &g.sdfData[y * g.atlasWidth], g.atlasWidth);
        }
        
        cursorX += g.atlasWidth + 1;
        rowHeight = std::max(rowHeight, g.atlasHeight);
    }
    
    printf("Atlas: %dx%d, utilisé jusqu'à Y=%d\n", atlasWidth, atlasHeight, cursorY + rowHeight);
    
    // Sauvegarde de l'atlas PNG
    if (!savePNG(config.outputAtlas, atlas.data(), atlasWidth, atlasHeight))
    {
        printf("Erreur: Impossible de sauvegarder l'atlas\n");
        return 1;
    }
    
    printf("Atlas sauvegardé: %s\n", config.outputAtlas.c_str());
    
    // Sauvegarde des métriques
    std::ofstream metricsFile(config.outputMetrics);
    metricsFile << "baseSize=" << config.fontSize << "\n";
    metricsFile << "# codepoint,atlasX,atlasY,atlasW,atlasH,width,height,bearingX,bearingY,advance\n";
    
    for (const auto& g : glyphs)
    {
        metricsFile << g.codepoint << ","
                    << g.atlasX << "," << g.atlasY << ","
                    << g.atlasWidth << "," << g.atlasHeight << ","
                    << g.width << "," << g.height << ","
                    << g.bearingX << "," << g.bearingY << ","
                    << g.advance << "\n";
    }
    
    printf("Métriques sauvegardées: %s\n", config.outputMetrics.c_str());
    
    FT_Done_Face(face);
    FT_Done_FreeType(ft);
    
    return 0;
}
# Shell
# Compilation (depending of your implementation) :
clang++ -std=c++17 text_SDF.cpp -o text_sdf_gen -I/usr/local/include/freetype2 -lfreetype -lpng
# or
clang++ -std=c++17 text_SDF.cpp -o text_sdf_gen -I/opt/homebrew/include/freetype2 \
    -L/opt/homebrew/lib -lfreetype -lpng
# Usage : [] optionnel, va utiliser celui par défaut dans le C++
./text_sdf_gen font.ttf output_atlas.png output_metrics.csv [size] [padding]

En sortie du programme, tu retrouves deux fichiers pour chaques polices :

metrics.csv
positions UV de chaques caractères sur l'image générée.
atlas.png
Image blanc sur noir avec des reliefs de profondeur. 72 pixels/inch (DPI pour un usage sur un écran), un canal (Grey).
On peut les ajouter à notre dossier assets/ & s'assurer qu'ils soient intégré correctement pour la compilation.
Avec Makefile
Avec Xcode
Dans la configuration Xcode (blue icon) → Build Phases → Copy Bundle Resources → Tu ajoutes si nécessaire l'asset au bundle → + → atlas.png → & → metrics.csv

Le fichier .csv contient les données de chaque glyphe :

baseSize=224

# codepoint,atlasX,atlasY,atlasW,atlasH,width,height,bearingX,bearingY,advance

47,0,0,134,242,118,226,11,184,139

92,135,0,134,242,118,226,11,184,139

91,270,0,106,239,90,223,20,179,123

...

On peut empêcher le fichier de s'enregistrer sur git (il n'aime pas les fichiers volumineux).
Avec Makefile
à la racine du projet → vim .gitignore → ajoute une ligne → *.png
Avec Xcode
Settings → Source Control → Git Settings → Ignored Files → + → *.png

Retour à notre projet, on crée un fichier pour les stuctures de données partagées

/// C++ — TextRendering_shared.h — update:18/05/26
#ifndef TextRendering_shared_h
#define TextRendering_shared_h

struct TextVertex
{
    simd::float2 position;
    simd::float2 texCoord;
    simd::float4 color;
    float        thickness;    // Épaisseur (0.5 = normal)
    float        outlineWidth; // 0 = pas de contour
    simd::float4 outlineColor; // ces 3 derniers vont te permettre d'avoir un effet 3D etc
};

struct TextUniforms
{
    simd::float4x4 projectionMatrix;
    float smoothing; // anti-aliasing (typiquement 0.1-0.25)
};

#endif /* TextRendering_shared_h */

pour lire les fichiers générés et un shader pour rendre du texte à l'écran.
/// C++ — TextRendering.hpp — update:18/05/26
#ifndef TEXTRENDERING_hpp
#define TEXTRENDERING_hpp

#include <simd/simd.h>
#include <string>
#include <unordered_map>
#include <vector>
#include <fstream>
#include <sstream>
#include <cmath>

#include <Metal/Metal.hpp>

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

#include "Math.hpp"

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

namespace TextRendering {

enum class FontID : uint8_t {
    IBMPlexMono = 0,
    Syne        = 1,
    COUNT
};

struct GlyphInfo
{   // Position dans l'atlas (normalisée 0-1)
    float atlasX;   // précision à virgule
    float atlasY;
    float atlasWidth;
    float atlasHeight;
    
    // Métriques du glyphe (en pixels à la taille de base)
    float width;
    float height;
    float bearingX; // Offset horizontal depuis le curseur
    float bearingY; // Offset vertical depuis la baseline
    float advance;  // Avancement horizontal pour le prochain caractère
};

struct TextRenderOptions
{
    simd::float4 color = {1.0f, 1.0f, 1.0f, 1.0f};
    float scale = 1.0f;
    float thickness = 0.5f;           // 0.3 = fin, 0.5 = normal, 0.7 = gras
    float outlineWidth = 0.0f;        // 0 = pas de contour
    simd::float4 outlineColor = {0.6f, 0.6f, 0.0f, 1.0f};
    
    enum class Alignment { Left, Center, Right };
    Alignment alignment = Alignment::Left;
};

struct FontAtlas
{
    NS::SharedPtr<MTL::Texture>             texture;
    std::unordered_map<uint32_t, GlyphInfo> glyphs;
    float baseSize    = 384;
    float atlasWidth  = 4096.0f;
    float atlasHeight = 4096.0f;
    bool  loaded      = false;
};

struct FontBatch
{
    std::vector<TextVertex>     vertices;
    std::vector<uint16_t>       indices;
    NS::SharedPtr<MTL::Buffer>  vertexBuffer;
    NS::SharedPtr<MTL::Buffer>  indexBuffer;
};

class SDFTextSystem
{
public:
    static constexpr uint32_t MAX_VERTICES = 8192;
    static constexpr uint32_t MAX_INDICES  = 16384;
    
    SDFTextSystem(MTL::Device* device, MTL::Library* shaderLibrary,
                  MTL::PixelFormat colorPixelFormat, MTL::PixelFormat depthPixelFormat,
                  const std::string& resourcePath);
    ~SDFTextSystem();
    
    void generateTextMesh(const std::string& text, float x, float y,
                          const TextRenderOptions& options = {}, FontID font = FontID::IBMPlexMono);
    
    void render(MTL::RenderCommandEncoder* renderCommandEncoder, float width, float height);
    
    void clearBatch();

    simd::float2 measureText(const std::string& text, float scale = 1.0f,
    FontID font = FontID::IBMPlexMono) const;

    void drawLogo(float cx, float cy, float scale);
    
private:
    void createPipelineState(MTL::Device* device, MTL::Library* shaderLibrary,
                             MTL::PixelFormat colorPixelFormat, MTL::PixelFormat depthPixelFormat);

    void loadAtlasTexture(MTL::Device* device, FontAtlas& atlas, const std::string& resourcePath);
    void loadFontMetrics(FontAtlas& atlas, const std::string& resourcePath);
    void loadFont(MTL::Device* device, FontID id,
                  const std::string& atlasPath, const std::string& metricsPath);
    
    std::vector<uint32_t> utf8ToCodepoints(const std::string& text) const;
    
    NS::SharedPtr<MTL::RenderPipelineState> m_pipelineState;
    NS::SharedPtr<MTL::Buffer>              m_uniformBuffer;
    NS::SharedPtr<MTL::SamplerState>        m_samplerState;
    
    TextUniforms m_uniforms;
    
    FontAtlas  m_fonts[static_cast<uint8_t>(FontID::COUNT)];
    FontBatch  m_batches[static_cast<uint8_t>(FontID::COUNT)];
};
}

#endif /* TEXTRENDERING_hpp */

État d'échantillonement (MTL::SamplerState)

échantillonneur (sampler) de fragments

Celui-ci définit comment un shader de fragments extrait les données de couleur d'une texture, en gérant des propriétés telles que le filtrage (linéaire/le plus proche), l'anisotropie. Passer les paramètres de filtrage à chaque draw call aurait un coût énorme en terme de performance. Ici tu as un sampler pour tous tes modèles mais tu peux tout à fait créer un second MTL::SamplerState pour un rendu pixel art ou à l’inverse du filtrage lisse.

Metal (comme Vulkan, DirectX 12) sépare volontairement la donnée (MTL::Texture) de la politique de lecture (MTL::SamplerState). Ça permet de mixer : même texture avec deux samplers différents (un nearest pour du pixel art UI, un linear+anisotropic pour la même texture utilisée en 3D), sans dupliquer la texture en mémoire.

Cache-friendly côté GPU. Sur Apple Silicon (architecture TBDR), les samplers sont des états très légers une fois compilés — le GPU les binde par référence, pas par copie. Créer un objet une fois et le réutiliser des milliers de fois par frame est exactement le pattern que le matériel attend.

Le prochain exemple de SamplerState sur LearnMSL.dev est à retrouver dans le chapitre Assimp.

Le shader

/// MSL — TextRendering.metal — update:18/05/26
#include <metal_stdlib>

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

using namespace metal;

struct VertexOut
{
    float4 position [[position]];
    float2 texCoord;
    float4 color;
    float  thickness;
    float  outlineWidth;
    float4 outlineColor;
};

vertex VertexOut textVertexShader(uint                     vid      [[vertex_id]],
                                  device const TextVertex* verts    [[buffer(0)]],
                                  constant TextUniforms&   uniforms [[buffer(1)]])
{
    TextVertex in = verts[vid];
    VertexOut out;
    out.position = uniforms.projectionMatrix * float4(in.position, 0.0, 1.0);
    out.texCoord = in.texCoord;
    out.color = in.color;
    out.thickness = in.thickness;
    out.outlineWidth = in.outlineWidth;
    out.outlineColor = in.outlineColor;
    return out;
}

fragment float4 textFragmentShader(VertexOut in [[stage_in]],
                                   constant TextUniforms& uniforms [[buffer(0)]],
                                   texture2d<float> atlas [[texture(0)]],
                                   sampler samp [[sampler(0)]])
{
    // Échantillonnage de la distance signée
    float distance = atlas.sample(samp, in.texCoord).r;
    
    // Calcul du smoothing adaptatif basé sur les dérivées d'écran
    float2 dTexCoord = fwidth(in.texCoord);
    float smoothingBase = max(dTexCoord.x, dTexCoord.y) * 16.0;
    float smoothing = max(uniforms.smoothing, smoothingBase);
    
    // Seuil pour le texte principal
    float threshold = in.thickness;
    
    // Anti-aliasing avec smoothstep
    float alpha = smoothstep(threshold - smoothing, threshold + smoothing, distance);
    
    // Couleur finale du texte
    float4 color = in.color;
    color.a *= alpha;
    
    // Contour optionnel
    if (in.outlineWidth > 0.0)
    {
        float outlineThreshold = threshold - in.outlineWidth;
        float outlineAlpha = smoothstep(outlineThreshold - smoothing, outlineThreshold + smoothing, distance);
        
        // Blend entre contour et texte
        float4 outlineColor = in.outlineColor;
        outlineColor.a *= outlineAlpha;
        
        // Le texte est au-dessus du contour
        color = mix(outlineColor, color, alpha);
        color.a = max(color.a, outlineColor.a);
    }
    // Discard les pixels complètement transparents
    if (color.a < 0.01) {
        discard_fragment();
    }
    return color;
}

À quoi devrait ressembler la structure de ton projet

MSLearn/
├── MSLearn.entitlements
├── Application/ …
├── Frameworks/ …
├── Renderer/ …
│   ├── TextRendering.hpp
│   ├── TextRendering.cpp
│   ├── Renderer.cpp
│   └── Renderer.hpp
├── Shaders/ …
│   ├── Shapes.metal
│   └── TextRendering.metal
├── includes/
│   ├── SharedGPU/ …
│   │   ├── Renderer_shared.h
│   │   └── TextRendering_shared.h
│   ├── extern/
│   │   └── stb-image.h         ← pré-requis (chapitre dédié)
│   └── SharedAPP/
│       └── InputState.h
├── syne2048xBold.png           ← Ajoute tous tes fichiers générés précédemment
├── metrics.csv                 ← Avec son .csv associé
└── Makefile

Et le C++

/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/*                                        +       +          */
/*      File: TextRendering.cpp            +++     +++       */
/*                                        +       +          */
/*      By: Laboitederemdal                +       +         */
/*                                       +           +       */
/*      Created: 14/05/2026 14:56:59      + + + + + +        */
/*                                                           */
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */

#include "TextRendering.hpp"

namespace TextRendering {

SDFTextSystem::SDFTextSystem(MTL::Device* device, MTL::Library* shaderLibrary,
                             MTL::PixelFormat colorPixelFormat, MTL::PixelFormat depthPixelFormat,
                             const std::string& resourcePath)
{
    loadFont(device, FontID::IBMPlexMono,
             resourcePath + "/ibmplexmonosemibold4096.png", resourcePath + "/metricsibmplexmonosemibold4096.csv");
    loadFont(device, FontID::Syne,
             resourcePath + "/syne2048xBold.png", resourcePath + "/metrics.csv");
    
    createPipelineState(device, shaderLibrary, colorPixelFormat, depthPixelFormat);
    
    m_uniformBuffer = NS::TransferPtr(device->newBuffer(sizeof(TextUniforms), MTL::ResourceStorageModeShared));
    
    for (uint8_t i = 0; i < static_cast<uint8_t>(FontID::COUNT); ++i)
    {
        auto& batch = m_batches[i];
        batch.vertices.reserve(MAX_VERTICES);
        batch.indices.reserve(MAX_INDICES);
        batch.vertexBuffer = NS::TransferPtr(device->newBuffer(MAX_VERTICES * sizeof(TextVertex), MTL::ResourceStorageModeShared));
        batch.indexBuffer  = NS::TransferPtr(device->newBuffer(MAX_INDICES  * sizeof(uint16_t),   MTL::ResourceStorageModeShared));
    }
}

SDFTextSystem::~SDFTextSystem()
{}

void SDFTextSystem::createPipelineState(MTL::Device* device, MTL::Library* shaderLibrary,
                                        MTL::PixelFormat colorPixelFormat, MTL::PixelFormat depthPixelFormat)
{
    auto vertexFunction = NS::TransferPtr(shaderLibrary->newFunction(MTLSTR("textVertexShader")));
    auto fragmentFunction = NS::TransferPtr(shaderLibrary->newFunction(MTLSTR("textFragmentShader")));
    auto renderPipelineDescriptor = NS::TransferPtr(MTL::RenderPipelineDescriptor::alloc()->init());
    
    renderPipelineDescriptor->setVertexFunction(vertexFunction.get());
    renderPipelineDescriptor->setFragmentFunction(fragmentFunction.get());
    
    renderPipelineDescriptor->colorAttachments()->object(0)->setPixelFormat(colorPixelFormat);
    renderPipelineDescriptor->setDepthAttachmentPixelFormat(depthPixelFormat);
    
    renderPipelineDescriptor->colorAttachments()->object(0)->setBlendingEnabled(true);
    renderPipelineDescriptor->colorAttachments()->object(0)->setSourceRGBBlendFactor(MTL::BlendFactorSourceAlpha);
    renderPipelineDescriptor->colorAttachments()->object(0)->setDestinationRGBBlendFactor(MTL::BlendFactorOneMinusSourceAlpha);
    renderPipelineDescriptor->colorAttachments()->object(0)->setSourceAlphaBlendFactor(MTL::BlendFactorOne);
    renderPipelineDescriptor->colorAttachments()->object(0)->setDestinationAlphaBlendFactor(MTL::BlendFactorOneMinusSourceAlpha);
    
    NS::Error* error = nullptr;
    m_pipelineState = NS::TransferPtr(device->newRenderPipelineState(renderPipelineDescriptor.get(), &error));
    if (!m_pipelineState)
        printf("Erreur création pipeline texte: %s\n", error->localizedDescription()->utf8String());
    
    auto samplerDescriptor = NS::TransferPtr(MTL::SamplerDescriptor::alloc()->init());
    samplerDescriptor->setMinFilter(MTL::SamplerMinMagFilterLinear);
    samplerDescriptor->setMagFilter(MTL::SamplerMinMagFilterLinear);
    samplerDescriptor->setMipFilter(MTL::SamplerMipFilterNotMipmapped);
    samplerDescriptor->setSAddressMode(MTL::SamplerAddressModeClampToEdge);
    samplerDescriptor->setTAddressMode(MTL::SamplerAddressModeClampToEdge);
    m_samplerState = NS::TransferPtr(device->newSamplerState(samplerDescriptor.get()));
}

void SDFTextSystem::loadAtlasTexture(MTL::Device* device, FontAtlas& atlas, const std::string& resourcePath)
{
    int width, height, channels;
    unsigned char* data = stbi_load(resourcePath.c_str(), &width, &height, &channels, 1);
    
    if (!data)
    {
        printf("Erreur chargement atlas: %s\n", resourcePath.c_str());
        return;
    }
    
    atlas.atlasWidth = static_cast<float>(width);
    atlas.atlasHeight = static_cast<float>(height);
    
    auto textureDescriptor = NS::TransferPtr(MTL::TextureDescriptor::alloc()->init());
    textureDescriptor->setTextureType(MTL::TextureType2D);
    textureDescriptor->setPixelFormat(MTL::PixelFormatR8Unorm); // Un seul canal (gris)
    textureDescriptor->setWidth(width);
    textureDescriptor->setHeight(height);
    textureDescriptor->setUsage(MTL::TextureUsageShaderRead);
    
    atlas.texture = NS::TransferPtr(device->newTexture(textureDescriptor.get()));
    atlas.texture->replaceRegion(MTL::Region::Make2D(0, 0, width, height), 0, data, width);
    
    stbi_image_free(data);
}

void SDFTextSystem::loadFont(MTL::Device* device, FontID id,
                             const std::string& atlasPath, const std::string& metricsPath)
{
    uint8_t idx = static_cast<uint8_t>(id);
    FontAtlas& atlas = m_fonts[idx];
 
    loadAtlasTexture(device, atlas, atlasPath);
    loadFontMetrics(atlas, metricsPath);
 
    atlas.loaded = true;
    printf("[TextSystem] Font %u chargée : %zu glyphes, baseSize=%.0f\n",
           idx, atlas.glyphs.size(), atlas.baseSize);
}

void SDFTextSystem::loadFontMetrics(FontAtlas& atlas, const std::string& resourcePath)
{
    std::ifstream file(resourcePath);
    if (!file.is_open())
    {
        printf("Erreur ouverture métriques: %s\n", resourcePath.c_str());
        return;
    }
    
    std::string line;
    
    if (std::getline(file, line))
    {
        std::istringstream iss(line);
        std::string key;
        
        if (std::getline(iss, key, '='))
            iss >> atlas.baseSize;
    }
    // Format: codepoint,atlasX,atlasY,atlasW,atlasH,width,height,bearingX,bearingY,advance
    while (std::getline(file, line))
    {
        if (line.empty() || line[0] == '#')
            continue;
        
        std::istringstream iss(line);
        std::string token;
        std::vector<float> values;
        
        while (std::getline(iss, token, ','))
            values.push_back(std::stof(token));
        
        if (values.size() >= 10)
        {
            uint32_t codepoint = static_cast<uint32_t>(values[0]);
            GlyphInfo glyph;
            glyph.atlasX = values[1] / atlas.atlasWidth;
            glyph.atlasY = values[2] / atlas.atlasHeight;
            glyph.atlasWidth = values[3] / atlas.atlasWidth;
            glyph.atlasHeight = values[4] / atlas.atlasHeight;
            glyph.width = values[5];
            glyph.height = values[6];
            glyph.bearingX = values[7];
            glyph.bearingY = values[8];
            glyph.advance = values[9];
            
            atlas.glyphs[codepoint] = glyph;
        }
    }
}

std::vector<uint32_t> SDFTextSystem::utf8ToCodepoints(const std::string& text) const
{
    std::vector<uint32_t> codepoints;
    codepoints.reserve(text.size());
    
    for (size_t i = 0; i < text.size();)
    {
        uint32_t cp = 0;
        unsigned char c = text[i];
        if      ((c & 0x80) == 0)   { cp = c;                                                                i += 1; }
        else if ((c & 0xE0) == 0xC0){ cp = (c & 0x1F) << 6  | (text[i+1] & 0x3F);                            i += 2; }
        else if ((c & 0xF0) == 0xE0){ cp = (c & 0x0F) << 12 | (text[i+1] & 0x3F) << 6  | (text[i+2] & 0x3F); i += 3; }
        else if ((c & 0xF8) == 0xF0){ cp = (c & 0x07) << 18 | (text[i+1] & 0x3F) << 12 | (text[i+2] & 0x3F) << 6 | (text[i+3] & 0x3F); i += 4; }
        else                        { i += 1; /* Skip invalid */ continue; }
        codepoints.push_back(cp);
    }
    return codepoints;
}

void SDFTextSystem::generateTextMesh(const std::string& text, float x, float y,
                                      const TextRenderOptions& options, FontID fontID)
{
    uint8_t idx = static_cast<uint8_t>(fontID);
    const FontAtlas& atlas = m_fonts[idx];
    FontBatch&       batch = m_batches[idx];

    if (!atlas.loaded)
    {
        printf("[TextSystem] Font %u non chargée\n", idx);
        return;
    }

    std::vector<uint32_t> codepoints = utf8ToCodepoints(text);
    float scale = options.scale / atlas.baseSize;

    // Alignement horizontal
    float offsetX = 0;
    if (options.alignment != TextRenderOptions::Alignment::Left)
    {
        float totalWidth = measureText(text, options.scale, fontID).x;
        if (options.alignment == TextRenderOptions::Alignment::Center)
            offsetX = -totalWidth * 0.5f;
        else
            offsetX = -totalWidth;
    }

    float cursorX = x + offsetX;
    float cursorY = y;

    for (uint32_t cp : codepoints)
    {
        if (cp == '\n')
        {
            cursorX = x + offsetX;
            cursorY -= atlas.baseSize * scale;
            continue;
        }

        auto it = atlas.glyphs.find(cp);
        if (it == atlas.glyphs.end())
        {
            it = atlas.glyphs.find(' ');
            if (it == atlas.glyphs.end()) continue;
        }

        const GlyphInfo& g = it->second;

        float px = cursorX + g.bearingX * scale;
        float py = cursorY - (g.height - g.bearingY) * scale;
        float pw = g.width  * scale;
        float ph = g.height * scale;

        uint16_t base = static_cast<uint16_t>(batch.vertices.size());

        batch.vertices.push_back({{ px,      py      }, { g.atlasX,                g.atlasY + g.atlasHeight }, options.color, options.thickness, options.outlineWidth, options.outlineColor });
        batch.vertices.push_back({{ px + pw, py      }, { g.atlasX + g.atlasWidth, g.atlasY + g.atlasHeight }, options.color, options.thickness, options.outlineWidth, options.outlineColor });
        batch.vertices.push_back({{ px + pw, py + ph }, { g.atlasX + g.atlasWidth, g.atlasY                 }, options.color, options.thickness, options.outlineWidth, options.outlineColor });
        batch.vertices.push_back({{ px,      py + ph }, { g.atlasX,                g.atlasY                 }, options.color, options.thickness, options.outlineWidth, options.outlineColor });

        batch.indices.push_back(base + 0); batch.indices.push_back(base + 1); batch.indices.push_back(base + 2);
        batch.indices.push_back(base + 0); batch.indices.push_back(base + 2); batch.indices.push_back(base + 3);

        cursorX += g.advance * scale;
    }
}

simd::float2 SDFTextSystem::measureText(const std::string& text, float scale, FontID fontID) const
{
    uint8_t idx = static_cast<uint8_t>(fontID);
    const FontAtlas& atlas = m_fonts[idx];
    if (!atlas.loaded)
        return {0, 0};

    auto  codepoints = utf8ToCodepoints(text);
    float adjustedScale  = scale / atlas.baseSize;
    float width = 0, lineWidth = 0, maxH = 0;
    int   lines = 1;

    for (uint32_t cp : codepoints)
    {
        if (cp == '\n')
        {
            width = std::max(width, lineWidth);
            lineWidth = 0;
            lines++;
            continue;
        }
        
        auto it = atlas.glyphs.find(cp);
        if (it == atlas.glyphs.end())
        {
            it = atlas.glyphs.find(' ');
            if (it == atlas.glyphs.end())
                continue;
        }
        
        lineWidth += it->second.advance * adjustedScale;
        maxH = std::max(maxH, it->second.height * adjustedScale);
    }
    width = std::max(width, lineWidth);
    return { width, maxH + (lines - 1) * atlas.baseSize * adjustedScale };
}

void SDFTextSystem::drawLogo(float cx, float cy, float scale)
{
    TextRenderOptions opMono;
    opMono.scale        = scale;
    opMono.color        = { 0.f, 0.f, 0.f, 1.0f };
    opMono.thickness    = 0.50f;
    opMono.outlineWidth = 0.0f;
    
    TextRenderOptions opSyne;
    opSyne.scale        = scale;
    opSyne.color        = { 1.0f, 0.373f, 0.122f, 1.0f };  // #ff5f1f
    opSyne.thickness    = 0.52f;
    opSyne.outlineWidth = 0.0f;
    
    float wLearn = measureText("learn", scale, FontID::IBMPlexMono).x;
    float wMSL   = measureText("MSL",   scale, FontID::Syne).x;
    float wDev   = measureText(".dev",  scale, FontID::IBMPlexMono).x;
    
    float totalWidth = wLearn + wMSL + wDev;
    
    float startX = cx - totalWidth * 0.5f;
    float y      = cy;
    
    generateTextMesh("Learn", startX,                 y, opMono, FontID::IBMPlexMono);
    generateTextMesh("MSL",   startX + wLearn,        y, opSyne, FontID::Syne);
    generateTextMesh(".dev",  startX + wLearn + wMSL, y, opMono, FontID::IBMPlexMono);
}

void SDFTextSystem::render(MTL::RenderCommandEncoder* renderCommandEncoder, float screenWidth, float screenHeight)
{
    m_uniforms.projectionMatrix = math::makeOrthographic(0, screenWidth, screenHeight, 0, 0, 1);
    m_uniforms.smoothing = 0.15f;
    memcpy(m_uniformBuffer->contents(), &m_uniforms, sizeof(TextUniforms));
    
    renderCommandEncoder->setRenderPipelineState(m_pipelineState.get());
    renderCommandEncoder->setVertexBuffer(m_uniformBuffer.get(), 0, 1);
    renderCommandEncoder->setFragmentBuffer(m_uniformBuffer.get(), 0, 0);
    renderCommandEncoder->setFragmentSamplerState(m_samplerState.get(), 0);
    for (uint8_t i = 0; i < static_cast<uint8_t>(FontID::COUNT); ++i)
    {
        FontBatch&       batch = m_batches[i];
        const FontAtlas& atlas = m_fonts[i];
        
        if (batch.vertices.empty())
            continue;
        
        memcpy(batch.vertexBuffer->contents(), batch.vertices.data(), batch.vertices.size() * sizeof(TextVertex));
        memcpy(batch.indexBuffer->contents(),  batch.indices.data(),  batch.indices.size()  * sizeof(uint16_t));
        
        renderCommandEncoder->setVertexBuffer(batch.vertexBuffer.get(), 0, 0);
        renderCommandEncoder->setFragmentTexture(atlas.texture.get(), 0);
        
        renderCommandEncoder->drawIndexedPrimitives(MTL::PrimitiveTypeTriangle,
                                                    static_cast<NS::UInteger>(batch.indices.size()),
                                                    MTL::IndexTypeUInt16, batch.indexBuffer.get(), 0);
    }
}

void SDFTextSystem::clearBatch()
{
    for (auto& batch : m_batches)
    {
        batch.vertices.clear();
        batch.indices.clear();
    }
}
}
/// C++ — Renderer.hpp — update:18/05/26
#include "TextRendering.hpp"

private:
    MTL::PixelFormat            m_depthPixelFormat;
    TextRendering::SDFTextSystem m_text;
/// C++ — Renderer.cpp — update:18/05/26
// liste d'init du constructeur
m_perspective(m_device, m_shaderLibrary, m_pixelFormat, m_depthPixelFormat, resourcePath),
m_text(m_device, m_shaderLibrary, m_pixelFormat, m_depthPixelFormat, resourcePath),
/// C++ — Renderer.cpp — update:18/05/26
renderCommandEncoder->setViewport(m_viewport); // le projet ne contient qu'un Viewport actuellement
// dans draw en dernier pour qu'il soit tout devant
m_text.clearBatch();
TextRendering::TextRenderOptions opts;
opts.color = {1.0f, 0.37254901960784315f, 0.12156862745098039, 1.0f};
opts.scale = 128.f;
opts.thickness = 0.5f;
opts.outlineWidth = 0.23f;
opts.outlineColor = {0.0f, 0.0f, 0.0f, 1.0f}; // Contour noir
opts.alignment = TextRendering::TextRenderOptions::Alignment::Center;
m_text.generateTextMesh("LearnMSL.dev",
                        m_viewport.width / 2, m_viewport.height / 2 - 50,
                        opts, TextRendering::FontID::Syne);
opts.color = {1.0f, 1.0f, 0.7f, 0.82f};
opts.scale = 129.f;
m_text.generateTextMesh("LearnMSL.dev",
                        m_viewport.width / 2, m_viewport.height / 2 - 50,
                        opts, TextRendering::FontID::Syne);
m_text.drawLogo(m_viewport.width * 0.5f, m_viewport.height * 0.5f + 50, 128.f);
m_text.render(renderCommandEncoder, m_viewport.width, m_viewport.height);
⬡CheckPoint Compilation — héhé