https://poniesandlight.co.uk/reflect/debug_print_text/ Ponies & Light * Work * Research * Reflect * About Texture-less Text Rendering Written on Oct 25 2024, by @tgfrerer. [look_ma] Sometimes, all you want is to quickly print some text into a Renderpass. But traditionally, drawing text requires you first to render all possible glyphs of a font into an atlas, to bind this atlas as a texture, and then to render glyphs one by one by drawing triangles on screen, with every triangle picking the correct glyph from the font atlas texture. This is how imgui does it, how anyone using stb_truetype does it, and it's delightfully close to how type setting used to be done ages bygone on physical letterpresses. Composing cases of an early letterpress Case in point: Some ancient Letterpress Type Cases (public domain) - source In case you wonder - yes That's enough (ed). Quaint, correct, but also quite cumbersome. What if - for quick and dirty debug messaging - there was a simpler way to do this? Here, I'll describe a technique for texture-less rendering of debug text. On top of it all, it draws all the text in a single draw call. The Font: Pixels Sans Texture How can we get rid of the font atlas texture? We'd need to store a font atlas or something similar directly inside the fragment shader. Obviously, we can't store bitmaps inside our shaders, but we can store integer constants, which, if you squint hard enough, are nothing but maps of bits. Can we pretend that an integer is a bitmap? The integer 0x42 as a bitmap An 8 bit integer as a bitmap. The value 66, or 0x42 in hex notation, translates to 0b01000010 in binary notation. If we assume that every bit is a pixel on/off value, we get something like this. We can draw this to the screen using a GLSL fragment shader by mapping a fragment's xy position to the bit that is covered by it in the "bitmap". If the bit is set, we draw in the foreground colour. If the bit is not set, we draw in the background colour. To calculate the bit that is covered by a normalized (0..1) .x coordinate, we discretize the .x value to 8 steps (note that we apply a final min() because the initial .x range includes the final 1.0) 1 uint bitmap = 0x42; 2 vec4 col_fg = vec4(1,1,1,1); 3 vec4 col_bg = vec4(0,0,0,1); 4 5 // vec2 uv is the normalized texture coordinate for the fragment 6 // with the origin top-left 7 uint which_bit = 7 - min(7,floor(uv.x * 8)); 8 9 out_color = mix(col_bg, col_fg, (bitmap >> which_bit) & 1); glsl Now, one byte will only draw one line of pixels for us. If we want to draw nicer glyphs, we will need more bytes. If we allowed 16 bytes (that's 16 lines) per glyph, this would give us an 8x16 pixel canvas to work with. A single uvec4, which is a built-in type in GLSL, covers exactly the correct amount of bytes that we need. The Glyph A encoded as an uvec4 The character A encoded in 16 bytes, stored as an uvec4, that's 4 uints with each 4 bytes. 16 bytes per glyph seems small enough; It should allow us to encode the complete ASCII subset of 96 printable glyphs in all of 1536 bytes of shader memory. (We could probably compress this further, but we would lose simplicity and/or readability). Where do we get the bitmaps from? Conveniently, the encoding of a font into bitmaps such as described above is very much the definition of the venerable PSF1 format, give or take a few header bytes. We can therefore harvest the glyph pixels from any PSF1 terminal font by opening it in a hex editor such as ImHex, travelling past the header (4 bytes) and the first section of non-printable glyphs (512 bytes), and then exporting the raw data for the next 96 glyphs (1536 bytes) by using "Copy as - C Array". A Screenshot of ImHex The ImHex hex editor has a really useful feature: you can copy binary data as a c-array. This will give us a nicely formatted array of chars, which we can easily edit into an array of uints, which we then group into uvec4s. We need to remember that just concatenating the raw chars into uints flips the endianness of our uints, but we can always flip this back when we sample the font data... Once we're done, this is how our font bitmap data table looks like in the fragment shader: You can look up the complete bitmap table as part of the Island source code 1 const uvec4 font_data[96] = { 2 { 0x00000000, 0x00000000, 0x00000000, 0x00000000 }, // 0x1e: SPACE 3 { 0x00000000, 0x08080808, 0x08080800, 0x08080000 }, // 0x21: '!' 4 { 0x00002222, 0x22220000, 0x00000000, 0x00000000 }, // 0x22: '\' 5 { 0x00000000, 0x1212127E, 0x24247E48, 0x48480000 }, // 0x23: '#' 6 // ... etc ... 7 8 { 0x00000808, 0x08080808, 0x08080808, 0x08080808 }, // 0x7C: '|' 9 { 0x00000030, 0x08081010, 0x08040810, 0x10080830 }, // 0x7D: '}' 10 { 0x00000031, 0x49460000, 0x00000000, 0x00000000 }, // 0x7E: '~' 11 { 0xFC1B26EF, 0xC8E04320, 0x8958625E, 0x79BAEE7E }, // 0x7F: BACKSPACE 12 }; glsl I say table, because the font_data array now stores the bitmaps for 96 character glyphs, indexed by their ASCII value (minus 0x20). This table therefore covers the full printable ASCII range from 0x20 SPACE to 0x7F BACKSPACE (inclusive), but in the snippet above I'm showing only 8 of them, to save space. So far, all this is just so that we don't have to bind a texture when drawing our text. But how to draw the text itself? Text output This is what we want to print at the end of this process One Draw Call, That's All. We're going to use a single instanced draw call. Ok, I cheated: It is a single, but instanced draw call. But still... With instanced drawing, we don't have to repeatedly issue draw instructions, since we encode the logic into per-instance data. One draw call contains everything we need, provided it uses two attribute streams. The fist stream, per-draw, has just the necessary information to draw a generic quad. And the second stream, per-instance, packs the two pieces of information that change with every instance of such a quad: First, a position offset, so that we know where in screen space to draw the quad. And second, of course, the text that we want to print. For the position offset we can use one float each for x and y, which leaves two floats for this particular attribute binding unused (attribute bindings in GLSL/Vulkan are at minimum the equivalent of 4 floats wide). We have more than enough space to use one extra float to pack in a font scale parameter, if we like. I really would have liked to pack this shader-side into a tidy vec3+uint combination, too, but unfortunately Vulkan requires all components of a vertex output binding to have the same interpolation characteristics- you can't mix uints and float-based types... For the text that we want to print, we have a similarly wasteful situation - the smallest basic vertex attribute data type is usually 32bit wide, and so it makes sense to make best use of this and pack at least 4 characters at a time. If we do this, we must make sure that the message that we want to print has a length divisible by 4. If it was shorter, we need to fill up the difference with zero byte (\0) characters. Conveniently, the zero byte is also used to signal the end of a c-string. Our per-instance data looks like this: A word is four bytes is four characters - it's nice when variable names echo a historical term 1 struct word_data { 2 float pos_and_scale[ 3 ]; // xy position + scale 3 uint32_t word; // four characters that we want to print 4 }; cpp It's the application's responsibility to split up the message into chunks of 4 characters, to convert these four characters into an unit32_t, and to store it into a word_data struct together with the position offset for where on screen to render these four characters. Once a word_data is filled, we append it into an array where we accumulate all the data for our text draw calls. Once we are ready to draw, we can then bind this array as a per-instance binding to our debug text drawing pipeline, and draw all text with a single instanced draw call, with the number of instances being the number of quads that we want to draw. More interesting things happen in the vertex and fragment shader of the debug text drawing pipeline. Vertex Shader Our vertex shader produces three outputs. First, it writes to gl_Position to place the vertices for our triangles on the screen. This operates in NDC = Normalised Device "screen space" Coordinates. We calculate an offset for each vertex using the per-instance pos_and_scale attribute data. The second output of the vertex shader is the word that we want to render: We just pass though the attribute uint as an output to the fragment shader - but we make sure to use the flat qualifier so that it does not get interpolated. And then, the vertex shader synthesizes texture coordinates (via gl_VertexIndex). It does so pretty cleverly: * 12 >> gl_VertexIndex & 1 will give a sequence 0, 0, 1, 1, * 9 >> gl_VertexIndex & 1 will give a sequence 1, 0, 0, 1, This creates a sequence of uv coordinates (0,1), (0,0), (1,0), (1,1) in a branchless way. 1 #version 450 core 2 3 #extension GL_ARB_separate_shader_objects : enable 4 #extension GL_ARB_shading_language_420pack : enable 5 6 // Inputs 7 // Uniforms - Push Constants 8 layout (push_constant) uniform Params 9 { 10 vec2 u_resolution; // screen canvas resolution in physical pixels 11 }; 12 13 // Input Attributes 14 layout (location = 0) in vec3 pos; // "vanilla" vertex position attribute - given in pixels 15 layout (location = 1) in uint word; // per-instance: four chars 16 layout (location = 2) in vec3 word_pos; // per-instance: where to place the word in screen space 17 layout (location = 3) in vec4 col_fg; // per-instance: foreground colour 18 layout (location = 4) in vec4 col_bg; // per-instance: background colour 19 20 // Vertex Outputs 21 struct per_word_data { 22 uint msg; 23 vec4 fg_colour; 24 vec4 bg_colour; 25 }; 26 27 out gl_PerVertex { vec4 gl_Position; }; 28 layout (location = 0) out vec2 outTexCoord; 29 layout (location = 1) flat out per_word_data outMsg; 30 31 void main() 32 { 33 outMsg.msg = word; 34 outMsg.fg_colour = col_fg; 35 outMsg.bg_colour = col_bg; 36 37 vec2 scale_factor = vec2(1.,2.)/(u_resolution); 38 outTexCoord = vec2((12 >> gl_VertexIndex) &1, (9 >> gl_VertexIndex ) &1); 39 vec4 position = vec4(0,0,0,1); 40 position.xy = vec2(-1, -1) + (pos.xy * word_pos.z + word_pos.xy) * scale_factor; 41 gl_Position = position; 42 } glsl If we at this point visualise just the output of the vertex shader, we will get something like this: Quad visualisation with uv coords Visualisation of per-quad outTexCoord uv coords. Note that these are continuous (smooth). Fragment Shader You can find the complete code of the fragment shader on github Our fragment shader needs three pieces of information to render text, two of which it receives from the vertex shader stage: 1. The fragment's interpolated uv coordinate, uv 2. The character that we want to draw, in_word 3. The font data array, font_data To render a glyph, each fragment must map its uv-coordinate to the correct bit of the glyph bitmap. If the bit at the lookup position is set, then render the fragment in the foreground colour, otherwise render it in background colour. This mapping works like this: First, we must map the uv coordinates to word - word not, world! - pixel coordinates. The nice thing about these two coordinate systems is that they both have their origin at the top left, so we only need to bother with scaling, and not origin transformation. We know that our uv coordinates are normalised floats going from vec2 (0.f,0.f) to vec2(1.f,1.f), while our font pixel coordinates are integers, going from uvec2(0,0) to uvec2(7,15). We also must find out which one of the four characters in the word to draw. Mapping uv coordinates to per-glyph pixel coordinates 1 const uint WORD_LEN = 4; // 4 characters in a word 2 3 // quantize uv coordinate to discrete steps 4 uvec2 word_pixel_coord = uvec2(floor(uv.xy * vec2( 8 * WORD_LEN, 16))); 5 // limit pixel coord range to uvec2(0..31, 0..15) 6 word_pixel_coord = min(uvec2( 8 * WORD_LEN -1, 16 -1), word_pixel_coord); 7 // Find which of the four characters in the word this fragment falls onto 8 uint printable_character = in_word >> (WORD_LEN - (word_pixel_coord.x / 8)); 9 // Map fragment coordinate to pixel coordinate inside character bitmap 10 uvec2 glyph_pixel_coord = uvec2(word_pixel_coord.x % 8, word_pixel_coord.y); glsl Quad visualisation of word_pixel_coord A visualisation of word_pixel_coord (normalised) Quad visualisation of glyph_pixel_coord A visualisation of glyph_pixel_coord (normalised) Remember, to draw a character, we must look up the character in the font bitmap table, where we must find the correct bit to check based on the uv coordinate of the fragment. You will notice that in the first GLSL example above, we were only worried about the .x coordinate. Now, let's focus on .y, so that we can draw more lines of pixels by looking up the correct line to sample from. Let's do this step by step. First, we fetch the character bitmap from our font_data as an uvec4. Then we use the glyph_pixel_coord.y to pick the correct one of 4 uints that make up the glyph. This will give us four lines of pixels. 1 // First, map character ASCII code to an index offset into font_data table. 2 // The first character in the font_data table is 0x20, SPACE. 3 offset = printable_character - 0x20; 4 // Then get the bitmap for this glyph 5 uvec4 character_bitmap = font_data[offset]; 6 // Find the uint that contains one of the four lines that 7 // are touched by our pixel coordinate 8 uint four_lines = character_bitmap[glyph_pixel_coord.y / 4]; glsl Once we have the uint covering four lines, we must pick the correct line from it. Note that lines are stored in reverse order because after we used ImHex to lift the bitmap bytes out of the font file, we just concatenated the chars into uint. This means that our bitmap uints have the wrong endianness; We want to keep it like this though, because it is much less work to just concatenate chars copied form ImHex than to manually convert endianness in a text editor. We compensate for the flipped endianness by indexing four_lines 'backwards' 1 uint current_line = (four_lines >> (8*(3-(glyph_pixel_coord.y)%4))) & 0xff; glsl And, lastly, we must pick the correct bit in the bitmap. Note the 7- - this is because bytes are stored with the most significant bit at the highest index. To map this to a left-to-right coordinate system, we must index backwards, again. 1 uint current_pixel = (current_line >> (7-glyph_pixel_coord.x)) & 0x01; glsl We now can use the current pixel to shade our fragment, so that if the pixel is set in the bitmap, we shade our fragment in the foreground colour, and if it is not set, shade our fragment in the background colour: 1 vec3 color = mix(background_colour, foreground_colour, current_pixel); glsl Quad visualisation Text printed with uv coordinates overlaid What about the fill chars that get inserted if our printable text is too short to be completely divisible by 4? We detect these in the fragment shader: In case were are about to render such a fill character, we should do absolutely nothing, not even draw the background. We can do this by testing printable_character, and issuing a discard in case the printable character is \0. A Visual Summary It is said that an image is worth a thousand words. Why not have both? Here is a diagram which summarises the mapping from quad-uv space to glyph bitmap space: [summary] Note: Our Fragment position is marked by the blue speck. 1 pick the correct character from our per-quad word. 2 calculate the offset into font_data using the character ASCII code. 3 fetch the uvec4 that holds the bitmap for our glyph from font_data 4 pick the uint representing the four lines of the glyph that our fragment falls in (via its y-coord) 5 pick the correct line using the fragment's .y coord 6 pick the correct bit using the per-glyph x coordinate. Full Implementation & More Source Code Island preview image You can find an implementation of the technique described above in the source code for le_print_debug_print_text, which is a new Island module that allows you to easily print debug messages to screen. It has some extra nice bits around text processing and caching which, however, would be too wordy to describe here. Using this technique, it is now possible, from nearly anywhere in an Island project, to call: 1 char const msg_2[] = { 70, 111, 108, 107, 115, '!', 0 }; 2 le::DebugPrint( "That's all, %s", msg_2 ); cpp And see the following result on screen: Image That’s all Folks Acknowledgements * Diagrams drawn with Excalidraw * Original source data for the pixel font came from Tamsyn, a free pixel font by Scott Fial Backlinks This article was featured on Graphics Programming Weekly, and discussed on Lobste.rs, and Hacker News. If you like more of this, subscribe to the rss feed, and if you want the very latest, and hear about occasional sortees into generative art and design, follow me on bluesky or mastodon, or maybe even Instagram. Shameless plug: my services are also available for contract work. --------------------------------------------------------------------- Tagged: codeglslislandwriteuptypography --------------------------------------------------------------------- RSS: Find out first about new posts by subscribing to the RSS Feed --------------------------------------------------------------------- Further Posts: 2024-05-23 Colour Emulsion Simulations research real-time island art 2024-04-29 Watercolours Experiments research real-time island art 2023-12-20 Vulkan Video Decode: First Frames h.264 video island rendergraph synchronisation vulkan code 2023-06-21 C++20 Coroutines Driving a Job System code coroutines c++ job-system 2022-09-18 Vulkan Render-Queues and how they Sync island rendergraph synchronisation vulkan code 2022-09-10 Rendergraphs and how to implement one island rendergraph vulkan code 2022-09-01 Poiesis - A Real-Time Generative Artwork work writeup 2021-02-01 Implementing Bitonic Merge Sort in Vulkan Compute code algorithm compute glsl island 2020-09-02 Callbacks and Hot-Reloading Reloaded: Bring your own PLT code hot-reloading c assembly island 2020-06-29 Callbacks and Hot-Reloading: Must JMP through extra hoops code hot-reloading c assembly island 2019-09-07 Love Making Waves fft real-time island research 2019-05-16 2D SDF blobs v.1 research real-time island 2017-06-10 OpenFrameworks Vulkan Renderer: The Journey So Far writeup vulkan real-time software design 2017-01-10 Simulated surface crazing using a fragment shader writeup math 2015-02-17 Earth Normal Maps from NASA Elevation Data tutorial code 2014-12-15 Using ofxPlaylist tutorial code 2014-11-01 Presidential Holiday Trees work writeup 2013-05-01 High Flying Ultrabooks work writeup 2013-02-10 Ghost art writeup work 2012-09-20 Flat Shading using legacy GLSL on OSX tutorial code glsl 2012-06-01 Supermodel Interactions work writeup 2012-05-01 The Making of a Cannon work writeup Enquiries: studio@poniesandlight.co.uk Unit 3, 410 Hackney Road London E2 7AP +44 7503 76 29 77 Ponies & Light Ltd. Registered in England and Wales Company 9506073 VAT GB 209611130