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
SDFsont 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 Valvepublie 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.
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)
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
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.
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.
# 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 :
- 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 - Avec Makefile
- à la racine du projet →
vim .gitignore→ ajoute une ligne →*.png - Avec Xcode
Settings→ Source Control → Git Settings → Ignored Files →+→*.png
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).
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)
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);