1 points par GN⁺ 2024-11-11 | 1 commentaires | Partager sur WhatsApp
  • 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 uvec4 de 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_t par 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 \0 avec discard, 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
  • imgui et stb_truetype utilisent 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 xy du 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 uvec4 GLSL 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 moins 0x20
    • Il cible les glyphes ASCII imprimables en commençant par 0x20 SPACE
    • Dans l’exemple de code, seuls certains éléments sont montrés pour économiser de la place
  • 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 char extrait peut être édité en tableau de uint, puis regroupé par unités uvec4
  • 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 vec3 et uint dans un même binding
  • Le texte est packé en uint32_t par 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_data
    • pos_and_scale[3] : position xy et scale
    • word : 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 tableau word_data avec 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, le word à rendre, et une valeur correspondant aux coordonnées de texture
  • gl_Position utilise les données per-instance pos_and_scale pour 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 flat est utilisé pour éviter l’interpolation
  • Les coordonnées de texture sont synthétisées avec gl_VertexIndex
    • 12 >> gl_VertexIndex & 1 produit la séquence 0, 0, 1, 1
    • 9 >> gl_VertexIndex & 1 produit la séquence 1, 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 de uvec2(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 / 8 permet de déterminer à laquelle des quatre zones de caractère on se trouve
    • word_pixel_coord.x % 8 donne la coordonnée x à l’intérieur du glyphe
  • Le code caractère est converti en index dans font_data
    • Comme le premier glyphe est 0x20 SPACE, on utilise printable_character - 0x20 comme offset
    • Le bitmap du glyphe uvec4 est récupéré avec cet offset
  • La coordonnée y sélectionne un uint donné dans le uvec4 via glyph_pixel_coord.y / 4
    • Ce uint contient 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))
  • 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_pixel sert à appliquer mix(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écute discard sans 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

 
GN⁺ 2024-11-11
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

    • À chaque fois que j’ai essayé le rendu de texte en mode immédiat, je n’ai pas réussi à obtenir un texte agréable à petite taille. Même dans le premier ShaderToy, si l’on remplace le 30 de 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.
    • Je développe des jeux avec Unity, et de ce côté-là, on a vraiment l’impression de régresser. Il y a quelques années, Unity a racheté le meilleur outil de rendu de polices créé par une seule personne et l’a intégré comme fonctionnalité de base ; depuis, son développement est quasiment à l’arrêt et le marché des solutions concurrentes de rendu de polices a disparu.
      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.
    • Hors sujet, mais comme exemple intéressant, un effet Matrix en HTML/CSS/JS tient dans 1024 octets : https://codegolf.stackexchange.com/a/17414
  • 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.

    • À propos de « revenir au point de départ », si vous ne l’avez pas encore lu, l’article de 1968 On the Design of Display Processors vaut le détour : http://cva.stanford.edu/classes/cs99s/papers/myer-sutherland...
      Cet article parle de matériel, mais le logiciel aussi connaît la réincarnation.
    • Les approches comme le texte SDF ou MSDF datent maintenant d’au moins une génération. Aujourd’hui, presque tout le monde utilise des approches comme https://sluglibrary.com, où les courbes de Bézier des polices sont rastérisées directement dans le shader.
      J’avais autrefois créé une version très basique de ce concept : https://www.shadertoy.com/view/sdXBDs
    • Par exemple, c’est assez malin pour du texte de débogage dans les situations où l’upload de textures ne fonctionne pas correctement. Cela dit, la comparaison faite dans l’article original entre une feuille de sprites et la composition typographique manuelle du XVIe siècle est mignonne, mais en pratique, il fallait une heure à un assistant d’imprimerie pour composer des caractères métalliques, alors qu’uploader une feuille de sprites sur le GPU prend moins de 10 ms et devient ensuite configurable à l’infini.
      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/...

    • En allant encore plus loin, on peut aussi évaluer directement les courbes de la police sur le GPU, ce qui permet une haute qualité quel que soit le facteur d’échelle ou la perspective. C’est très difficile à implémenter efficacement, mais c’est possible.
      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 ».

    • La réponse que j’aimerais entendre à « et les performances ? », c’est : « VSCode arrête de consommer des centaines de Mo à plusieurs Go de VRAM supplémentaires ».
  • 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 snprintf dans un buffer GPU mappé côté CPU. Je sais que c’est une approche risquée
    Au lieu de dessiner chaque caractère avec le vertex shader, je ne dessinais qu’un seul triangle plein écran et j’utilisais gl_FragCoord plutô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 rapide
    Malgré 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...

    • Mon pack de polices pixel préféré est celui-ci : https://int10h.org/oldschool-pc-fonts/
    • Je n’ai appris que récemment que la police « NES » venait du jeu d’arcade Quiz Show de 1976. Elle a aussi été utilisée dans d’autres jeux monochromes Kee/Atari, et les données de police se trouvent dans le set de ROM MAME de quizshow, mais pour une raison quelconque elles sont découpées en nibbles
      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

    • Cette vision est assez datée. Sur les GPU des quelque 15 dernières années, une lecture de texture est environ 100 fois plus lente qu’une opération sur les bits
  • 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

    • Le coût n’est pas élevé. Cette technique sert moins à la performance qu’à afficher du texte de debug à l’écran aussi facilement que possible. Ajoutez un peu de données au shader et le texte apparaît
      À 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
    • Même aujourd’hui, il vaut généralement mieux éviter de gaspiller trop de mémoire GPU. Une grande texture de zone de texte doit être transférée via le bus PCI, et des pauses peuvent survenir selon le moment de l’upload ou selon que des ressources GPU ont été évincées
      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
    • Dessiner un seul rectangle couvrant N caractères et sélectionner les glyphes dans le shader sera probablement plus rapide que de dessiner un rectangle séparé pour chaque caractère. Du moins pour une police à chasse fixe. Cela dit, le nombre de caractères pouvant remplir l’écran est limité, donc la différence réelle risque de ne pas être énorme
      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.
    • On peut rendre toute la chaîne avant l’upload, mais cela revient essentiellement à faire du rendu CPU, ce qui sera plus lent que de laisser le GPU faire le même travail
      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
    • Cela dépend de l’application. C’est surtout l’approche la plus simple si vous risquez de rencontrer des écritures de droite à gauche, du CJK ou des emojis. La plupart des textes ne changent pas à chaque frame, donc il vaut la peine de mettre la texture en cache, et pour nous c’est suffisamment bon
  • 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

    • Non, il était question de ne pas stocker de bitmap dans une texture. Ce n’est pas la même chose que de l’intégrer directement dans le code du 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