Rendre du texte sans texture
(poniesandlight.co.uk)- Dans les situations où l’on veut afficher rapidement des messages de debug dans une render pass, préparer un atlas de polices peut être contraignant ; une approche qui dessine du texte uniquement avec des constantes du fragment shader est alors utile
- Les glyphes sont représentés par des bitmaps de 8x16 pixels, et les 96 caractères ASCII imprimables sont stockés dans un tableau
uvec4de 1536 octets, consulté directement dans le shader - Les données de police peuvent être produites à partir d’une police de terminal PSF1 en sautant l’en-tête de 4 octets et la zone de 512 octets de glyphes non imprimables, puis en extrayant les 96 glyphes suivants
- Le rendu utilise des
uint32_tpar groupes de 4 caractères et une structure per-instance contenant position et échelle, afin de dessiner tout le texte en un seul instanced draw call - Comme la technique vise une sortie de debug simple, l’application et le shader doivent gérer ensemble des contraintes comme le padding par groupes de 4 caractères, le caractère de remplissage
\0avecdiscard, et la correction d’endianness
Utiliser des constantes de shader au lieu d’un atlas de polices
- Le rendu de texte classique consiste à rendre les glyphes possibles dans un atlas de polices, à le binder comme texture, puis à dessiner des triangles adaptés à chaque glyphe
imguietstb_truetypeutilisent aussi cette approche, mais pour afficher rapidement des messages de debug, la phase de préparation peut être pénible- Une alternative consiste à stocker les données équivalentes à l’atlas de polices sous forme de constantes entières dans le fragment shader
- Les entiers peuvent être utilisés comme un bitmap : on mappe la position
xydu fragment sur une position de bit donnée, puis on émet la couleur de premier plan si ce bit est activé, ou la couleur d’arrière-plan s’il ne l’est pas
Glyphes bitmap 8x16 et table ASCII
- Un octet ne représente qu’une ligne de pixels ; pour obtenir des glyphes plus lisibles, on utilise donc 16 octets par glyphe
- Un glyphe devient un canevas de 8x16 pixels, et un seul
uvec4GLSL peut contenir exactement les 16 octets nécessaires - Stocker les 96 glyphes ASCII imprimables donne un total de 1536 octets de données
- Le tableau
font_data[96]utilise comme index la valeur ASCII moins0x20- Il cible les glyphes ASCII imprimables en commençant par
0x20SPACE - Dans l’exemple de code, seuls certains éléments sont montrés pour économiser de la place
- Il cible les glyphes ASCII imprimables en commençant par
- La table bitmap complète est incluse dans le code source d’Island
Extraire le bitmap d’une police PSF1
- L’encodage bitmap nécessaire correspond presque exactement à celui d’une police de terminal au format PSF1
- La procédure d’extraction des données depuis une police de terminal PSF1 est simple
- Ouvrir le fichier de police avec un éditeur hexadécimal comme ImHex
- Sauter l’en-tête de 4 octets
- Sauter la section de 512 octets de glyphes non imprimables
- Exporter les 96 glyphes suivants, soit 1536 octets, avec « Copy as → C Array »
- Le tableau de
charextrait peut être édité en tableau deuint, puis regroupé par unitésuvec4 - Si l’on concatène les raw chars tels quels en
uint, l’endianness est inversée ; il faut donc la corriger à nouveau au moment du sampling - Les données originales de la police pixel utilisée proviennent de Tamsyn, la police pixel gratuite de Scott Fial
Construire un seul instanced draw call
- Le rendu du texte est traité avec un seul instanced draw call
- Le draw call utilise deux attribute streams
- Le flux per-draw ne contient que les informations nécessaires pour dessiner un quad ordinaire
- Le flux per-instance contient l’offset de position à l’écran et le texte à afficher
- L’offset de position utilise des floats x et y, et l’espace de float restant peut contenir la valeur d’échelle de la police
- Dans Vulkan, tous les composants d’un vertex output binding doivent avoir les mêmes caractéristiques d’interpolation ; il est donc difficile de mélanger proprement
vec3etuintdans un même binding - Le texte est packé en
uint32_tpar groupes de 4 caractères- Comme l’unité minimale des types de données de vertex attribute de base est généralement de 32 bits, on stocke 4 caractères à la fois
- La longueur du message doit être divisible par 4
- Les parties manquantes sont remplies avec le caractère
\0
- Les données per-instance sont représentées par la structure
word_datapos_and_scale[3]: positionxyet scaleword: les quatre caractères à afficher
- L’application découpe le message en chunks de 4 caractères, convertit chaque chunk en
uint32_t, puis les accumule dans un tableauword_dataavec l’offset de position - Lors du rendu, ce tableau est bindé comme per-instance binding du pipeline de dessin de debug text, puis autant d’instances que de quads sont dessinées
Transmettre position et caractères depuis le Vertex Shader
- Le vertex shader émet
gl_Position, lewordà rendre, et une valeur correspondant aux coordonnées de texture gl_Positionutilise les données per-instancepos_and_scalepour placer les sommets du triangle à l’écran dans le repère NDC- Le word à rendre est transmis tel quel au fragment shader depuis l’attribut d’entrée
uint- Le qualifier
flatest utilisé pour éviter l’interpolation
- Le qualifier
- Les coordonnées de texture sont synthétisées avec
gl_VertexIndex12 >> gl_VertexIndex & 1produit la séquence0, 0, 1, 19 >> gl_VertexIndex & 1produit la séquence1, 0, 0, 1- Cette combinaison génère les coordonnées uv
(0,1), (0,0), (1,0), (1,1)sans branchement
- Le vertex shader reçoit aussi les couleurs de premier plan et d’arrière-plan comme données per-instance et les transmet au fragment shader
Sampling des glyphes dans le Fragment Shader
- Pour rendre le texte, le fragment shader a besoin de trois informations
- Les coordonnées uv interpolées du fragment
- Les données de caractère à afficher,
in_word - Le tableau de bitmaps de glyphes
font_data
- Les coordonnées uv vont de
vec2(0.f,0.f)àvec2(1.f,1.f)en floats normalisés, tandis que les coordonnées de pixels de glyphe vont deuvec2(0,0)àuvec2(7,15) - Le word complet de 4 caractères est traité comme une zone de 32 pixels de large sur 16 pixels de haut
- On quantifie les coordonnées de pixel du word en appliquant
flooràuv.xy * vec2(8 * WORD_LEN, 16) - La plage de coordonnées est limitée à
uvec2(0..31, 0..15) word_pixel_coord.x / 8permet de déterminer à laquelle des quatre zones de caractère on se trouveword_pixel_coord.x % 8donne la coordonnée x à l’intérieur du glyphe
- On quantifie les coordonnées de pixel du word en appliquant
- Le code caractère est converti en index dans
font_data- Comme le premier glyphe est
0x20SPACE, on utiliseprintable_character - 0x20comme offset - Le bitmap du glyphe
uvec4est récupéré avec cet offset
- Comme le premier glyphe est
- La coordonnée y sélectionne un
uintdonné dans leuvec4viaglyph_pixel_coord.y / 4- Ce
uintcontient les données de pixels de 4 lignes - Comme les chars extraits depuis ImHex ont été concaténés tels quels pour former des
uint, l’ordre des lignes est inversé - On corrige cela en indexant depuis la fin avec
(8*(3-(glyph_pixel_coord.y)%4))
- Ce
- La coordonnée x sélectionne le bit avec
7-glyph_pixel_coord.x- Le bit de poids fort de l’octet étant stocké à l’index le plus élevé, un indexage inversé est nécessaire pour faire correspondre la lecture de gauche à droite
- La valeur finale
current_pixelsert à appliquermix(background_colour, foreground_colour, current_pixel)afin de choisir la couleur
Gérer les chaînes courtes et le caractère de remplissage
- Si la longueur de la chaîne n’est pas divisible par 4, l’application remplit la partie manquante avec le caractère
\0 - Le fragment shader vérifie si le caractère à afficher est
\0 - Lorsqu’il rencontre le caractère de remplissage
\0, il exécutediscardsans dessiner non plus l’arrière-plan - Ce traitement permet d’afficher des chaînes courtes tout en conservant la contrainte de packing par groupes de 4 caractères
Forme d’utilisation et emplacement du code
- Dans le projet Island, on peut afficher du texte de debug à l’écran en appelant
le::DebugPrint - Le code complet du fragment shader est disponible sur github
- L’exemple de code transmet des données de chaîne pour afficher à l’écran un message de la forme
"That's all, %s"
1 commentaires
Commentaires sur Hacker News
Si vous voulez essayer par vous-même, il suffit de suivre les calculs. C’est très facile à implémenter de zéro dans ShaderToy, et si ce genre de chose vous plaît, c’est aussi un passe-temps amusant pour un samedi matin.
Partir de zéro est amusant, mais si vous avez besoin d’un indice pour démarrer, voici un exemple que je viens de créer : https://www.shadertoy.com/view/Mc3cW2
Il y a aussi beaucoup de hacks de texte ingénieux créés par d’autres, comme cet exemple Matrix en moins de 300 caractères https://www.shadertoy.com/view/llXSzj ou cet effet d’affichage CRT vert https://www.shadertoy.com/view/XtfSD8
vec2(30, -30)par 300, des artefacts apparaissent.Je me demande s’il existe une astuce pour gérer cela correctement. Dans mon cas, le multisampling de la texture dans le fragment shader a donné les meilleurs résultats, mais ce n’était toujours pas aussi bon que l’état de l’art.
Il y a quelque temps, j’ai essayé de créer une app qui ressemble à une police de console native, et il m’a fallu plus de 2 heures de bidouillage rien que pour arriver à environ 90 % du résultat voulu.
C’est inventif, hacky, et donc plaisant. En fait, c’est le cas de presque toutes les techniques de rendu 3D, mais le résultat n’est pas vraiment beau, sauf si l’objectif est de recréer un vieux BBS.
On pourrait l’améliorer en ajoutant plus de bits, mais bien avant que cela devienne joli, on finira par chercher une méthode plus simple pour définir tous ces bits. Au final, il n’existera sans doute guère de solution plus efficace que de les dessiner en pixels noir et blanc dans un logiciel de dessin puis de les enregistrer comme texture ; on revient donc au point de départ.
Si vous voulez savoir comment les moteurs de rendu 3D modernes dessinent plus généralement du texte, cherchez texte SDF et des techniques apparentées comme MSDF. Lors d’une étape de prétraitement, elles utilisent un atlas de textures traditionnel pour produire un atlas de champs de distance signés.
Cet article parle de matériel, mais le logiciel aussi connaît la réincarnation.
J’avais autrefois créé une version très basique de ce concept : https://www.shadertoy.com/view/sdXBDs
Je ne dis pas que ce n’est pas un super tour de passe-passe ; c’en est effectivement un.
Il y a aussi l’option de rendre le texte sous forme de maillage. TextMeshPro va un cran plus loin en utilisant des champs de distance signés pour gérer des mises à l’échelle arbitraires.
https://docs.unity3d.com/Packages/com.unity.textmeshpro@4.0/...
Exemple : https://sluglibrary.com
Les maillages et les SDF sont beaucoup plus simples côté GPU, mais ils peuvent perdre en précision lorsqu’on zoome trop, et si l’on réduit trop fortement un maillage, de l’aliasing peut apparaître.
Très sympa. Ce serait intéressant d’avoir une comparaison de performances avec l’approche « traditionnelle » par textures.
Avec les GPU actuels, pour ce genre de tâche simple, la réponse à la question « et les performances ? » semble le plus souvent être : « ça passe ».
Sebastian Lague a fait une bonne vidéo qui couvre plusieurs techniques de rendu de polices.
https://youtu.be/SO83KQuuZvg
J’ai déjà expérimenté une technique similaire, où j’avais mis toutes les données de police directement dans le code source du fragment shader. On peut alors écrire directement avec
snprintfdans un buffer GPU mappé côté CPU. Je sais que c’est une approche risquéeAu lieu de dessiner chaque caractère avec le vertex shader, je ne dessinais qu’un seul triangle plein écran et j’utilisais
gl_FragCoordplutôt que des coordonnées UV. Ce n’est pas la méthode la plus efficace, mais c’était une fonction de debug et, en pratique, c’était suffisamment rapideMalgré le nom du fichier, j’utilise la police de la ROM IBM PC, pas celle de la NES. La « NES font » et d’autres polices 8x8 pixels se trouvent sur le Web
https://github.com/rikusalminen/triangles/blob/nesfont/shade...
Fait intéressant, ce jeu stockait les questions et les réponses sur une bande 8 pistes
Super. Il est rare que je voie un algorithme de rendu de texte que je n’aie pas déjà essayé moi-même. Dans ma startup, nous en avons implémenté plusieurs, mais comme j’avais besoin d’indépendance vis-à-vis de la résolution et d’anticrénelage, cette approche ne m’aurait pas été utile
Elle ne se généralise peut-être pas à tous les fichiers de polices à courbes de Bézier. Convertir des courbes en pixels peut être difficile, surtout quand un glyphe s’intersecte lui-même. Globalement, le rendu de texte standard donne l’impression d’être un problème résolu, et les cas d’usage non standard sont très pénibles à explorer
Conceptuellement, cette approche ressemble à celle de Will Dobbie, que j’aime bien. Elle est toutefois beaucoup plus simple. Les deux prennent les données brutes de police et les utilisent directement dans le shader. La différence est que cette méthode stocke les données de pixels dans un tableau, tandis que Will stocke les données de chemins SVG comme une « texture vectorielle »
Si cela vous intéresse, Will a une superbe démo : https://wdobbie.com/warandpeace/
J’avais déjà envisagé d’essayer ce genre de chose, mais je comprenais que les GPU étaient particulièrement efficaces pour le rendu de textures et relativement lents pour la manipulation de bits. Même si l’on économise un peu de mémoire ici, je me demande si c’est vraiment plus rapide que d’utiliser un atlas
On pourrait peut-être obtenir le meilleur des deux mondes en faisant du bit packing dans une texture classique et en laissant le fragment shader la décoder
Je connais très peu l’infographie moderne, donc ma question est purement naïve : le coût d’upload d’une petite texture vers le GPU est-il si important ? Je me demande si l’on ne pourrait pas rendre toute la chaîne de caractères dans une texture 2D, puis afficher cette texture sur deux triangles
À l’inverse, il faut écrire du code pour générer un atlas de police, ou trouver et charger un atlas existant, ce qui nécessite aussi du code de chargement. Ou bien il faut dessiner tout le message dans une texture et mettre ce résultat en cache jusqu’à ce que le message change
Il faut en plus gérer les ressources et les bindings, alors que cette approche ne nécessite aucune ressource. Autrement dit, ce n’est pas une solution générale pour le texte, mais une technique pour afficher du texte de debug à l’écran
À noter que la plupart des navigateurs et des OS fonctionnent en dessinant le texte dans des textures. Ils dessinent dynamiquement les polices dans un atlas de textures, puis utilisent les glyphes de cet atlas pour créer d’autres textures correspondant à certaines parties de la fenêtre de l’application. Dans un navigateur, si vous affichez les limites des textures, vous pouvez voir toutes les textures ; Rendering->Layer borders encadre chaque texture en cyan
Si un CPU relativement lent se met à rendre beaucoup de zones de texte indépendantes sous forme de textures, cela s’accumule vite et grignote le budget
Dessiner avec un atlas de glyphes reste bien meilleur du point de vue de l’utilisation des ressources. Les pipelines modernes de rendu de texte utilisent souvent des SDF ou des courbes de Bézier encodées pour améliorer la lisibilité des glyphes lors des zooms, ce qui est aussi une bonne manière d’économiser de la mémoire
Du point de vue de l’upload, au final on a X octets de glyphes qui doivent se retrouver d’une façon ou d’une autre dans la mémoire GPU. Que ce soit une texture, des données uniformes ou des constantes de shader, il n’y a pas de grande différence de performance. Au contraire, si on les met dans des constantes de shader comme dans l’article original, le compilateur de shaders doit traiter toutes ces déclarations de constantes, ce qui peut coûter plus cher côté CPU
Côté GPU, l’important est de savoir quelle hiérarchie mémoire est sollicitée lors de la lecture des données de glyphes. Les fetches de texture utilisent un cache L1 dédié sur la plupart des GPU, probablement plus grand que le cache L1 général. L’ordre des données compte aussi. Les textures sont généralement stockées dans une variante de l’ordre de Morton pour éviter les ratés de cache lors de l’ombrage de blocs de pixels. Pour un moteur de rendu de texte à base d’atlas en production, il vaut sans doute mieux utiliser des textures
Correction : j’avais mal lu la question. Si la comparaison porte sur le rendu des glyphes individuels sur GPU versus le rendu d’un bloc de texte entier sur CPU, c’est un compromis entre vitesse et espace. La réponse dépend de la quantité de mémoire que vous voulez consacrer au texte, du fait que le texte change ou non, du besoin d’effets par caractère, etc.
En outre, même si on parle de « sans texture », cette approche est aussi une texture. La texture est simplement stockée dans un autre format et à un autre endroit. Un vrai rendu de police sans texture évaluerait les courbes vectorielles à la volée
C’est assez déroutant de dire qu’on ne va pas stocker de bitmap dans le shader, puis d’expliquer précisément comment stocker un bitmap dans le shader
En résumé, il s’agit d’intégrer une police bitmap dans le shader
On peut comparer cela à la différence entre stocker des données dans un fichier séparé à lire à l’exécution, et inclure les données directement dans le code source