Text ohne Texturen rendern
(poniesandlight.co.uk)- Wenn in einem Render-Pass schnell Debug-Meldungen ausgegeben werden müssen, ist die Vorbereitung eines Font-Atlas oft zu aufwendig; daher ist ein Ansatz nützlich, der Text nur mit Fragment-Shader-Konstanten zeichnet
- Glyphen werden als 8x16-Pixel-Bitmaps dargestellt, und 96 druckbare ASCII-Zeichen werden in einem
uvec4-Array mit 1536 Byte gespeichert, das der Shader direkt abfragt - Die Font-Daten lassen sich aus einer PSF1-Terminalschrift erzeugen, indem nach dem Überspringen des 4-Byte-Headers und des 512-Byte-Bereichs für nicht druckbare Glyphen die nächsten 96 Glyphen extrahiert werden
- Das Rendering verwendet eine Per-Instance-Struktur mit
uint32_t-Wörtern zu je 4 Zeichen sowie Position und Skalierung und zeichnet den gesamten Text mit einem einzigen instanziierten Draw Call - Da die Technik auf einfache Debug-Ausgabe fokussiert ist, müssen Einschränkungen wie 4-Zeichen-Padding, das Füllzeichen
\0mitdiscardund Endianness-Korrekturen gemeinsam in Anwendung und Shader behandelt werden
Shader-Konstanten statt Font-Atlas
- Übliches Text-Rendering rendert die möglichen Glyphen in einen Font-Atlas, bindet diesen als Textur und zeichnet dann für jede Glyphe die passenden Dreiecke
- Auch
imguiundstb_truetypenutzen diesen Ansatz, doch für schnelle Debug-Meldungen kann der Vorbereitungsaufwand lästig sein - Eine Alternative besteht darin, die Daten des Font-Atlas als Ganzzahlkonstanten im Fragment-Shader zu speichern
- Ganzzahlen lassen sich wie Bitmaps verwenden: Die
xy-Position des Fragments wird auf eine bestimmte Bit-Position abgebildet, und je nachdem, ob dieses Bit gesetzt ist, wird Vorder- oder Hintergrundfarbe ausgegeben
8x16-Bitmap-Glyphen und ASCII-Tabelle
- Da ein Byte nur eine einzelne Pixelzeile darstellen kann, werden pro Glyphe 16 Byte verwendet, um besser lesbare Glyphen zu erhalten
- Eine Glyphe bildet damit eine 8x16-Pixel-Leinwand, und ein einzelnes
uvec4in GLSL kann die benötigten 16 Byte exakt aufnehmen - Für 96 druckbare ASCII-Glyphen ergibt sich eine Gesamtdatenmenge von 1536 Byte
- Das Array
font_data[96]verwendet als Index den ASCII-Wert minus0x20- Ziel sind die druckbaren ASCII-Glyphen beginnend bei
0x20SPACE - Im Beispielcode werden aus Platzgründen nur einige Einträge gezeigt
- Ziel sind die druckbaren ASCII-Glyphen beginnend bei
- Die vollständige Bitmap-Tabelle ist im Island source code enthalten
Bitmaps aus einer PSF1-Schrift extrahieren
- Die benötigte Bitmap-Kodierung entspricht fast exakt der einer Terminalschrift im PSF1-Format
- Das Extrahieren der Daten aus einer PSF1-Terminalschrift ist einfach
- Die Font-Datei in einem Hex-Editor wie ImHex öffnen
- Den 4-Byte-Header überspringen
- Den 512-Byte-Abschnitt mit nicht druckbaren Glyphen überspringen
- Die nächsten 96 Glyphen, also 1536 Byte, per „Copy as → C Array“ exportieren
- Das extrahierte
char-Array kann zu einemuint-Array angepasst und anschließend inuvec4-Einheiten gruppiert werden - Wenn rohe
char-Werte direkt zuuintzusammengesetzt werden, wird die Endianness vertauscht; beim Sampling wird das wieder korrigiert - Die Quelldaten der verwendeten Pixel-Schrift stammen aus Scott Fials freier Pixel-Font Tamsyn
Aufbau eines einzelnen instanziierten Draw Calls
- Das Text-Rendering wird mit einem einzigen instanziierten Draw Call ausgeführt
- Der Draw Call verwendet zwei Attribut-Streams
- Der Per-Draw-Stream enthält nur die Informationen, die zum Zeichnen eines normalen Quads nötig sind
- Der Per-Instance-Stream enthält den Positionsoffset auf dem Bildschirm und den auszugebenden Text
- Für den Positionsoffset werden
x- undy-Floats genutzt; im verbleibenden Float-Platz kann der Font-Scale-Wert untergebracht werden - In Vulkan müssen alle Komponenten eines Vertex-Output-Bindings dieselben Interpolationseigenschaften haben, daher lassen sich
vec3unduintinnerhalb eines Bindings nicht sauber mischen - Der Text wird in 4-Zeichen-Einheiten in
uint32_tgepackt- Der kleinste Standarddatentyp für Vertex-Attribute ist meist 32 Bit, daher werden 4 Zeichen auf einmal gespeichert
- Die Nachrichtenlänge muss durch 4 teilbar sein
- Fehlende Zeichen werden mit
\0aufgefüllt
- Die Per-Instance-Daten werden als
word_data-Struktur dargestelltpos_and_scale[3]:xy-Position und Skalierungword: die vier auszugebenden Zeichen
- Die Anwendung zerlegt die Nachricht in 4-Zeichen-Blöcke, wandelt jeden Block in ein
uint32_tum und sammelt diese zusammen mit dem Positionsoffset in einemword_data-Array - Beim Rendering wird dieses Array als Per-Instance-Binding an die Debug-Text-Zeichenpipeline gebunden, und es werden so viele Instanzen wie Quads gezeichnet
Position und Zeichen im Vertex-Shader übergeben
- Der Vertex-Shader gibt
gl_Position, das zu renderndewordund einen Wert entsprechend den Texturkoordinaten aus gl_Positionnutzt die Per-Instance-Datenpos_and_scale, um die Dreiecks-Vertices im NDC-Koordinatensystem auf dem Bildschirm zu platzieren- Das zu rendernde word wird als Eingabeattribut vom Typ
uintunverändert an den Fragment-Shader weitergereicht- Damit keine Interpolation stattfindet, wird der Qualifier
flatverwendet
- Damit keine Interpolation stattfindet, wird der Qualifier
- Die Texturkoordinaten werden aus
gl_VertexIndexsynthetisiert12 >> gl_VertexIndex & 1erzeugt die Sequenz0, 0, 1, 19 >> gl_VertexIndex & 1erzeugt die Sequenz1, 0, 0, 1- Damit entstehen ohne Verzweigungen die UV-Koordinaten
(0,1), (0,0), (1,0), (1,1)
- Der Vertex-Shader übernimmt außerdem Vorder- und Hintergrundfarbe aus den Per-Instance-Daten und gibt sie an den Fragment-Shader weiter
Glyphen-Sampling im Fragment-Shader
- Damit der Fragment-Shader den Text rendern kann, benötigt er drei Informationen
- die interpolierten UV-Koordinaten des Fragments
- die auszugebenden Zeichendaten
in_word - das Glyphen-Bitmap-Array
font_data
- Die UV-Koordinaten liegen im normalisierten Float-Bereich von
vec2(0.f,0.f)bisvec2(1.f,1.f), während die Pixelkoordinaten der Glyphe vonuvec2(0,0)bisuvec2(7,15)reichen - Das gesamte 4-Zeichen-Word wird als Bereich von 32 Pixeln Breite und 16 Pixeln Höhe behandelt
uv.xy * vec2(8 * WORD_LEN, 16)wird mitfloorauf Word-Pixelkoordinaten quantisiert- Der Koordinatenbereich wird auf
uvec2(0..31, 0..15)begrenzt - Mit
word_pixel_coord.x / 8wird ermittelt, zu welchem der vier Zeichen der Bereich gehört - Mit
word_pixel_coord.x % 8wird die x-Koordinate innerhalb der Glyphe bestimmt
- Der Zeichencode wird in einen Index für
font_dataumgewandelt- Da die erste Glyphe
0x20SPACEist, wirdprintable_character - 0x20als Offset verwendet - Mit diesem Offset wird die Glyphen-Bitmap als
uvec4geladen
- Da die erste Glyphe
- Über die y-Koordinate wird mit
glyph_pixel_coord.y / 4ein bestimmtesuintinnerhalb desuvec4ausgewählt- Dieses
uintenthält die Pixeldaten von 4 Zeilen - Da die aus ImHex exportierten
char-Werte direkt zuuintverkettet wurden, ist die Zeilenreihenfolge vertauscht - Das wird mit einer Indizierung der Form
(8*(3-(glyph_pixel_coord.y)%4))wieder korrigiert
- Dieses
- Für die x-Koordinate wird das Bit mit
7-glyph_pixel_coord.xgewählt- Das höchstwertige Bit eines Bytes ist am höchsten Index gespeichert, daher ist für die Abbildung von links nach rechts eine umgekehrte Indizierung nötig
- Mit dem finalen
current_pixelwird die Farbe permix(background_colour, foreground_colour, current_pixel)bestimmt
Kurze Strings und Behandlung des Füllzeichens
- Wenn die Stringlänge nicht durch 4 teilbar ist, füllt die Anwendung die fehlenden Stellen mit
\0auf - Der Fragment-Shader prüft, ob das auszugebende Zeichen
\0ist - Trifft er auf ein Füllzeichen
\0, zeichnet er auch keinen Hintergrund und führtdiscardaus - So bleibt die 4-Zeichen-Packung erhalten, und dennoch lassen sich kurze Strings ausgeben
Nutzungsform und Speicherort des Codes
- Im Island-Projekt kann über
le::DebugPrintDebug-Text auf dem Bildschirm ausgegeben werden - Der vollständige Fragment-Shader ist auf github verfügbar
- Das Codebeispiel zeigt, wie String-Daten übergeben werden, um eine Meldung in der Form
"That's all, %s"auf dem Bildschirm auszugeben
1 Kommentare
Hacker-News-Kommentare
Wenn man es selbst ausprobieren möchte, kann man einfach die Arithmetik nachvollziehen. Es ist extrem einfach, das von Grund auf in ShaderToy zu implementieren, und wenn man auf so etwas steht, ist es auch ein netter Samstagmorgen-Zeitvertreib.
Es macht Spaß, es von Grund auf zu bauen, aber falls man einen Starthinweis braucht, gibt es ein Beispiel, das ich gerade erstellt habe: https://www.shadertoy.com/view/Mc3cW2
Es gibt auch viele clevere Text-Hacks von anderen Leuten, etwa das Matrix-Beispiel mit unter 300 Zeichen https://www.shadertoy.com/view/llXSzj oder den grünen CRT-Anzeigeeffekt https://www.shadertoy.com/view/XtfSD8
vec2(30, -30)die 30 in 300 ändert.Ich frage mich, ob es dafür einen guten Trick gibt. Bei mir hat mehrfaches Sampeln von Texturen im Fragment-Shader am besten funktioniert, aber selbst das war nicht so gut wie moderne Verfahren.
Vor Kurzem wollte ich eine App bauen, die wie eine native Konsolenschrift aussieht, und ich musste mehr als zwei Stunden herumprobieren, nur um ungefähr 90 % zu erreichen.
Es ist einfallsreich und herrlich hacky. Eigentlich gilt das für fast alle 3D-Rendering-Techniken, aber sofern das Ergebnis nicht absichtlich ein altes elektronisches Schwarzes Brett nachbilden soll, ist es nicht wirklich schön.
Mit mehr Bits könnte man es verbessern, aber lange bevor es wirklich gut aussieht, wird man wahrscheinlich einen einfacheren Weg finden, alle Bits zu setzen. Am Ende landet man wieder am Ausgangspunkt, denn es gibt kaum eine effizientere Lösung, als die Zeichen in einem Zeichenprogramm als Schwarzweiß-Pixel zu erstellen und als Textur zu speichern.
Wenn man wissen will, wie moderne 3D-Rendering-Engines Text normalerweise rendern, sollte man nach SDF-Text und verwandten Verfahren wie MSDF suchen. Dabei wird in einem Vorverarbeitungsschritt mit einem traditionellen Texture-Atlas ein Signed-Distance-Field-Atlas erzeugt.
Das Paper handelt zwar von Hardware, aber auch Software hat ihre Wiedergeburten.
Ich habe einmal eine sehr grundlegende Version dieses Konzepts gebaut: https://www.shadertoy.com/view/sdXBDs
Das soll nicht heißen, dass es kein cooler Trick ist — es ist tatsächlich ein cooler Trick.
Es gibt auch die Möglichkeit, Text als Mesh zu rendern. TextMeshPro geht noch einen Schritt weiter und verwendet Signed Distance Fields, um beliebige Skalierung zu unterstützen.
https://docs.unity3d.com/Packages/com.unity.textmeshpro@4.0/...
Beispiel: https://sluglibrary.com
Meshes und SDF sind auf der GPU-Seite deutlich einfacher, können aber bei sehr starker Vergrößerung an Präzision verlieren, und wenn man Meshes zu stark verkleinert, kann Aliasing auftreten.
Sehr cool. Ein Performance-Vergleich mit dem „traditionellen“ Texturansatz wäre interessant.
Bei modernen GPUs scheint die Antwort auf die Frage „Wie ist die Performance?“ bei solchen einfachen Aufgaben meistens „Sie reicht“ zu sein.
Sebastian Lague hat ein gutes Video über verschiedene Schrift-Rendering-Techniken gemacht.
https://youtu.be/SO83KQuuZvg
Ich habe einmal eine ähnliche Technik verwendet und die kompletten Schriftdaten direkt in den Quellcode des Fragment-Shaders eingebettet. Dann kann man mit
snprintfdirekt in einen GPU-Puffer schreiben, der auf die CPU gemappt ist. Mir ist klar, dass das ein riskanter Ansatz istStatt einzelne Zeichen im Vertex-Shader zu zeichnen, habe ich nur ein einziges Fullscreen-Dreieck gezeichnet und
gl_FragCoordstatt UV-Koordinaten verwendet. Das ist nicht die effizienteste Methode, aber es war eine Debug-Funktion und in der Praxis schnell genugTrotz des Dateinamens wird nicht die NES-, sondern die Schrift aus dem IBM-PC-ROM verwendet. Andere 8x8-Pixel-Schriften als die „NES font“ findet man im Web
https://github.com/rikusalminen/triangles/blob/nesfont/shade...
Interessanterweise speicherte dieses Spiel seine Fragen und Antworten auf 8-Spur-Band
Cool. Man sieht nicht oft einen Text-Rendering-Algorithmus, den ich noch nicht selbst ausprobiert habe. In meinem Startup haben wir verschiedene Varianten implementiert, aber für mich wären Auflösungsunabhängigkeit und Anti-Aliasing notwendig gewesen, daher hätte mir dieser Ansatz wohl nicht geholfen
Es lässt sich vielleicht nicht auf allgemeine Schriftdateien mit Bézier-Kurven verallgemeinern. Kurven in Pixel umzuwandeln kann schwierig sein, besonders wenn sich Glyphen selbst schneiden. Insgesamt wirkt Standard-Text-Rendering wie ein gelöstes Problem, und bei nicht standardmäßigen Anwendungsfällen sind Experimente ziemlich unerquicklich
Konzeptionell scheint das dem Ansatz von Will Dobbie ähnlich zu sein, den ich mag, nur deutlich einfacher. Beide nehmen rohe Schriftdaten und verwenden sie direkt im Shader. Der Unterschied ist, dass hier Pixeldaten in einem Array gespeichert werden, während Will SVG-Pfaddaten als „Vektor-Textur“ speichert
Falls es jemanden interessiert: Will hat eine coole Demo: https://wdobbie.com/warandpeace/
Ich hatte früher auch einmal über so etwas nachgedacht, aber ich war davon ausgegangen, dass GPUs besonders effizient beim Textur-Rendering und vergleichsweise langsam bei Bit-Operationen sind. Selbst wenn man hier etwas Speicher spart, frage ich mich, ob das tatsächlich schneller ist als die Verwendung eines Atlas
Vielleicht ließen sich die Vorteile beider Ansätze kombinieren, indem man Bit-Packing in einer normalen Textur speichert und der Fragment-Shader das Dekodieren übernimmt
Ich kenne mich mit moderner Computergrafik kaum aus, daher die naive Frage: Ist es wirklich so teuer, kleine Texturen auf die GPU hochzuladen? Könnte man nicht einfach die komplette Zeichenkette als 2D-Textur rendern und diese Textur dann auf zwei Dreiecken anzeigen?
Andernfalls muss man Code zum Erzeugen eines Font-Atlas schreiben oder einen vorhandenen Atlas finden und laden, wofür wiederum Ladecode nötig ist. Oder man muss die gesamte Nachricht in eine Textur zeichnen und das Ergebnis cachen, bis sich die Nachricht ändert
Dazu kommen Ressourcenverwaltung und Binding, während dieser Ansatz gar keine Ressourcen benötigt. Anders gesagt: Das ist keine allgemeine Textlösung, sondern eine Technik, um Debug-Text auf dem Bildschirm anzuzeigen
Übrigens funktionieren die meisten Browser und Betriebssysteme, indem sie Text in Texturen zeichnen. Sie rendern Schrift dynamisch in Texturatlanten und erzeugen aus den Glyphen dieser Atlanten weitere Texturen für Teile des Anwendungsfensters. Wenn man sich im Browser Texturgrenzen anzeigen lässt, kann man alle Texturen sehen, und Rendering->Layer borders markiert jede Textur mit einem türkisfarbenen Rahmen
Wenn eine vergleichsweise langsame CPU viele unabhängige Textur-Textboxen rendern muss, summiert sich das schnell und zehrt am Budget
Rendering mit Glyphenatlas ist hinsichtlich der Ressourcennutzung weiterhin deutlich besser. Moderne Text-Rendering-Pipelines verwenden beim Skalieren häufig SDFs oder kodierte Bézier-Kurven, um die Lesbarkeit von Glyphen zu verbessern, und auch das ist eine gute Methode, um noch sparsamer mit Speicher umzugehen
Aus Upload-Sicht hat man am Ende X Byte an Glyphen, die irgendwie in den GPU-Speicher gelangen müssen. Ob als Textur, Uniform-Daten oder Shader-Konstanten, macht für die Performance keinen großen Unterschied. Im Gegenteil: Wenn man sie wie im Original als Shader-Konstanten einbettet, kann der CPU-Aufwand sogar höher sein, weil der Shader-Compiler all diese Konstantendeklarationen verarbeiten muss
Wichtig auf der GPU-Seite ist, welche Speicherebene beim Lesen der Glyphendaten genutzt wird. Texturzugriffe verwenden auf den meisten GPUs einen dedizierten L1-Cache, der vermutlich größer ist als der allgemeine L1-Cache. Auch die Datenreihenfolge spielt eine Rolle. Texturen werden gewöhnlich in einer Variante der Morton-Reihenfolge gespeichert, um Cache-Misses beim Shading von Pixelblöcken zu vermeiden. Für einen atlasbasierten Text-Renderer im Praxiseinsatz sind Texturen wahrscheinlich die bessere Wahl
Korrektur: Ich habe die Frage falsch gelesen. Wenn es um den Vergleich zwischen dem Zeichnen einzelner Glyphen auf der GPU und dem Rendern eines gesamten Textblocks auf der CPU geht, dann ist das ein Trade-off zwischen Geschwindigkeit und Speicherplatz. Die Antwort hängt davon ab, wie viel Speicher man für Text verwenden will, ob sich der Text ändert und ob Effekte pro Zeichen nötig sind
Außerdem: Auch wenn man es „ohne Textur“ nennt, ist dieser Ansatz letztlich ebenfalls eine Textur. Die Textur ist nur in einem anderen Format und an einem anderen Ort gespeichert. Wirklich texturloses Font-Rendering würde Vektorkurven direkt zur Laufzeit auswerten
Es ist ziemlich verwirrend, dass erst gesagt wird, man werde kein Bitmap im Shader speichern, und dann genau erklärt wird, wie man ein Bitmap im Shader speichert
Kurz gesagt: Eine Bitmap-Schriftart wurde im Shader eingebettet.
Man kann es mit dem Unterschied vergleichen zwischen dem Speichern von Daten in einer separaten Datei, die zur Laufzeit eingelesen werden muss, und dem direkten Einfügen der Daten in den Quellcode.