JIT bitmap text rendering for microcontrollers

Hacker News by 14 min read 10x views
JIT bitmap text rendering for microcontrollers

Share Post

Raph Levien, November 7, 2026

I've been operating on vic-2350, a re-envisioning of an 80s residence device on an RP2350 microcontroller board. Of course, specified a project needs content rendering. Though bitmap fonts are now out of favor, I chose them the two for effectiveness and because, at low resolutions, I favor the imaginative of a crisp, intentionally designed bitmap font complete the blurry mush of antialiased font rendering. They motionless have a niche, if a narrow one.

I additionally wanted proportional spacing and kerning, again since I affection the imaginative of first Macintosh and its predecessors. Most content rendering in RP2350 projects is according to a fixed character cell, as achieving fine achievement is tricky.

Demo of bitmap content rendering on RP2350

This blog article presents a method for extremely accelerated rendering of bitmap content on microcontrollers specified as the RP2350. I've implemented it as part of my race-the-beam renderer, but the identical method would activity whenever penning into a framebuffer as well. The center idea is a JIT to create extremely optimized code on the fly for rendering a string.

Racing the beam

Around the mid-80s, a divided was happening in the way computers displayed text. The established method was a fixed division size, and hardware that looked up characters in a font ROM on the fly as the depiction was scanned out to the monitor. The advantage was extremely accelerated update and responsiveness alongside constricted hardware. A lot of terminals worked this way, as did residence computers. The downside was the deficiency of flexibility. In particular, lone monospace fonts were supported.

The newer method was a bitmap framebuffer, as pioneered on a row of computers originating alongside the Xerox Alto, continuing through the Apple Lisa, and eventually becoming famous alongside the Macintosh. The framebuffer was vastly additional flexible, but required a lot additional computing power to keep updated. In the first 80s, you would mostly observe content increasingly render. I should additionally mention, graphics modes were mostly accessible on residence computers, but they were typically lesser resolution than content manner and a 1MHz 6502 (or similar) was fair not mighty enough, so grave text-based programs akin term processors and spreadsheets used content mode.

Fast onward to the RP2350. It is capable of DVI video out alongside the assistance of the HSTX peripheral, but it is motionless entirely up to application to inhabit the video buffer. Some projects choose a framebuffer, but there's lone 520kB of SRAM on the chip, so a framebuffer must be constricted in either resolution or bit degree or both, and it would additionally be fine for that RAM to be accessible to the app for another purposes. I've chosen alternatively to competition the beam, filling a video buffer all scanline. That allows additional ocular richness and much lesser latency, but is a difficult achievement constraint on the renderer. There's a prosperity of about 6 cycles per pixel. The lesser we arrive in under budget, the additional is accessible for rendering another graphical objects, and for another app functions.

Rust is not capable of gathering the achievement requirement, so I started penning ARM gathering language. The renderer, and much of vic-2350, is now written in symbolasm, a programming tongue I've created for extremely elevated achievement Cortex-M33 programming. Employing a JIT speeds up rendering further.

The rendering pipeline

Rendering is frequently modeled as a pipeline, and the vic-2350 renderer is no exception. The interface between use and renderer is a display list. At heart, that's fair what it says, a catalog of display objects. The display catalog is double-buffered; one is being rendered and scanned out, and the another is built by the application. At all vsync, if the app is ready, the display catalog is swapped. Building the display catalog is not computationally intensive, but of way the app power be occupied doing another things.

As the display catalog is of unidentified size at compile time, it allocates. Display objects are packed into two vectors; one holds the display objects themselves, the another a catalog of indices into the archetypal vector. A display entity consists of a vtable, a bounding rectangle, and the object-specific payload. Allocation is satisfactory since building the display catalog is not timing critical. Rendering is a distinct story. That said, it is crucial for the display catalog to be recollection efficient, particularly as there are two copies.

Before presenting, the indices are sorted into ascending y0 order.

During rendering, the renderer maintains an active list, which is fair the subset of display objects that intersect the current scanline. At all scanline, objects for which y1 = y are deleted from the energetic list, and new objects alongside y0 = y are inserted, which is the logic for the kind earlier; it is exactly the command objects are inserted into the energetic list. The energetic catalog is kept in display entity order.

Then the renderer fair calls the render method for all display entity in the energetic list. I'll do a bit additional optimization soon, tracking opaque spans and eliding rendering activity for spans of pixels that volition be overwritten later, but that's not essential to the content rendering as presented here.

At the commencement and end of an entity (when it's inserted and deleted) the renderer calls commencement and end methods on the display object. These methods can allocate scratch area and do another activity to set up rendering of the object. There's a specialized bitmap allocator, which is the two accelerated and deterministic.

Text display objects

All this gives us everything we need to execute content rendering as an case of display objects.

The content entity itself contains the cord (1 byte per character), a pointer to the font, and foreground and backdrop colors. RAM is precious, and additional so for the display catalog since it's twice buffered. In an before repeat of the renderer, I had an expanded type of the content stored in the display list, and that created unacceptable recollection force whenever the display was filled alongside text.

The commencement method allocates scratch area for the two the JIT code and a 1 bit per pixel intermediate buffer, and invokes the JIT compiler, expanding the cord into code.

The render method calls that code, which fills the 1 bit per pixel buffer, afterward runs a second continue to develop that 1 bit per pixel buffer into 16 bit per pixel RGB (565 format). This second continue runs at a sustained charge of 1.75 cycles per pixel.

The font is stored in extremely packed format. All the "ink" for glyphs is stored in a broad bitmap image, identical height as the font. The packing additionally makes certain no glyph straddles a 32-pixel chunk boundary, so one row of font pixels can continually be peruse alongside a sole 32 bit load. If a glyph has area on either flank of the glyph, that's not stored in the packed bitmap; rather, the area is in the per-glyph metadata.

In the soul of micro-optimization, the packing algorithm additionally detects overlaps, as there's no necessity that pixels be distinctive to a sole glyph. This optimization reduces the size of the font bitmap from 1140 to 960 bytes. Every byte counts, and additional so as we add additional fonts. The font bitmap needs to live in SRAM, as having it in flash could logic booths during rendering at video rate.

A bitmap font packed extremely tightly into 32 bit words, alongside overlap. Word boundaries are alternating blue and yellow shading, and overlap is slightly darker shading

I particularly liked the {][} sequence (near the commencement of the second row). Any glyph contained inside another glyph doesn't need to be packed separately. There are a few examples, several surprising. For example, l is the remaining flank of U, and 0 is identical to () alongside the area between them removed.

The per-glyph metadata (not counting kerning) is conceptually four fields: the x stance and width of the ink inside the bitmap, the area on the left, and the advance. All this is tightly packed into a 32 bit integer.

JIT

In the generated JIT code, registers are assigned as follows:

  • r0 points to the commencement of the row of the packed font bitmap
  • r1 is the output buffer, a one-line 1 bit per pixel depiction buffer
  • r2 is the accumulator for the output bitmap
  • r3 holds the bits of one glyph, extracted from r4
  • r4 holds 32 bits loaded from the glyph bitmap

In the simplest case, we add one glyph to the accumulator. The code looks akin this:

 ldr r4, [r0, #(x_offset / 32 * 4)]  ubfx r3, r4, #(x_offset % 32), #(width)  orr r2, r3, lsl #(x % 32) 

There's additional code to output a term whenever it's completed (stmia r1!, {r2}), and to grip cases whenever a glyph straddles a 32 bit boundary. These are documented in text.sy. But there is one trick I do desire to citation here: the code as complete has a result-use postpone between the ldr and ubfx (unsigned bit site extract). This is solved by shuffling the command of instructions somewhat: the final education for glyph i is reordered to prosecute the archetypal education (ldr) of glyph i + 1, filling the postpone slot, and reducing the sequence disbursal from 4 to 3.

Here's the inner iteration of the code generator, focusing lone on the case anywhere the glyph doesn't straddle the accumulator. For all glyph, multiple values are packed into the g variable, afterward unpacked below: the offset inside the packed font bitmap (which additional breaks downward into a bit offset and a term offset), the width of the inked part, the remaining flank bearing, and the advance.

 loop {  g @r12 = *(glyphs + c * 4)  // the next character, study one former the end  c = *text++  width @r7 = g[20..26]  bit_offset @r4 = g & 31  if width == 0 goto blank  // ubfx r3, r4, #offset % 32, #width  ubfx_instr @r11 = *(ubfx_table + bit_offset * 4)  // ldr r4, [r0, #offset / 32]; the font is narrower than 32 words,  // so the offset fits, and strh leaves out what's complete it.  word_offset @r4 = g >> 5  ldr @r4 = ldr_base + (word_offset << 6)  ubfx_instr += width << 16  *slot = ldr  // remaining flank bearing  remaining @r4 = (g as i32)[16..20]  *(code as *u32) = ubfx_instr  // shift: anywhere the glyph starts, related to the term in r2. Branch is  // additionally taken if negative.  change @r4 = pen + left  area @r7 = 32 - width  pen += g >> 26  #(shift - room)  if #hi goto off_word  // the delay: orr r2, r2, r3, lsl #shift  d @r12 = *(orr_table + change * 4)  *((code + 6) as *u32) = d  slot = code + 4  code += 10  #(n -= 1)  if #ne goto loop  } 

Here's the corresponding device code, ARM gathering code, and sequence figure inspection from the symbolasm inspection tool:

 134: f859 c028 1 ldr r12, [r9, r8, lsl #2]  136: f811 8b01 1 ldrb r8, [r1], #1  137: f3cc 5705 1 ubfx r7, r12, #20, #6  138: f00c 041f 1 and r4, r12, #31  139: b387 1/2 cbz r7, .Ljit_text.blank  141: f855 b024 1 ldr r11, [r5, r4, lsl #2]  144: ea4f 145c H 1 lsr r4, r12, #5  145: eb0a 1484 1 add r4, r10, r4, lsl #6  146: eb0b 4b07 1 add r11, r11, r7, lsl #16  147: 801c 1 strh r4, [r3]  149: f34c 4403 1 sbfx r4, r12, #16, #4  150: f8c0 b000 H 1 str r11, [r0]  153: 1934 1 adds r4, r6, r4  154: f1c7 0720 1 rsb r7, r7, #32  155: eb06 669c 1 add r6, r6, r12, lsr #26  156: 42bc 1 cmp r4, r7  157: d821 K 0/2 bhi .Ljit_text.off_word  159: f85e c024 1 ldr r12, [lr, r4, lsl #2]  160: f8c0 c006 1 str r12, [r0, #6]  161: 1d03 1 adds r3, r0, #4  162: 300a 1 adds r0, r0, #10  163: 3a01 1 subs r2, r2, #1  164: d1da K 0/2 bne .Ljit_text_0_loop  iteration 134-164: 23 cycles per iteration 

The K flags signify a imaginable postpone in branches since the circumstance isn't known until the sequence before the branch. Here, 0/2 indicates 0 cycles if the branch is not taken, and 2 if it is. Neither of these can be optimized. The H emblem indicates the education is 32 bits fairly than 16 since of the use of "hi" registers (r8-r15). These are accessible in the LSP as well.

Performance

The essential iteration is 23 cycles per glyph for the JIT affirmative 3 cycles times height for the bitmap generation. There's an additional disbursal of concerning 20 cycles for JIT and 2 cycles times height per 32 bits of output width.

This is astonishingly fast. The textdemo in vic-2350 takes concerning 21k cycles for the JIT and 42k cycles to run the JIT code. That adds up to concerning 2.6% of the accessible throughput of a sole center of the RP2350. Expanding to 565 is really concerning 3x additional expensive. So it's fair to say it's as optimized as it can get.

While this was designed for race-the-beam rendering, it would activity fair as fine penning into a framebuffer, particularly a one bit per pixel framebuffer. Just create the code, run it for all scanline.

It's tempting to cache the JIT output, but preservation RAM is additional important. The JIT output is concerning a 10x enlargement complete the input string. And, from the numbers above, it's apparent that the disbursal of operating the JIT is minimal.

The name "just in time" is apt; in the implementation in vic-2350, the JIT compiler runs under 100µs before scanout of the video line.

I wasn't capable to discover a straightforward achievement difference to an existing RP2350 content renderer. Probably the closest is dvhstx by Michael Bell and forked by Adafruit. That additionally ends up spending about 2-2.5 cycles total per pixel, but is constricted to a 14x24 grid and additionally uses an RGB 222 mode, so its output bandwidth is lone fractional as much (and this is anywhere the lion's portion of the period goes).

Dot Product Kerning

Kerning is a extremely crucial refinement for content rendering, alternatively the spacing looks irregular. The broad scheme in OpenType fonts is fairly complex. For this project, I wanted item uncomplicated and efficient, so dusted off an idea that was initially motivated by trying to do content layout on GPU.

Basically, all glyph has two bitvectors, one all for the remaining and correct side. To kern two glyphs, obtain the and of the correct bitvector of the archetypal glyph and the remaining bitvector of the second, afterward the popcount (number of 1 bits) of that, resulting in the figure of pixels to kern. That's really fair the dot merchandise of the two vectors, bits interpreted as 0 and 1. The kern tables necessitate lone two bytes per glyph, and kerning is excellent accelerated to evaluate.

Even so, kerning evaluation is done at display catalog build time, not racing the beam. When the kern is nonzero, a particular negative area character is inserted into the cord in the content display object.

For this font, I built the kerning table manually. Each bit is a particular feature, for example bit 0 represents the top correct of the archetypal glyph projecting out, and a corresponding deficiency of ink in the top remaining of the next glyph. So it is set on the correct of T and Y, and additionally at the remaining of most lowercase alphabet and additionally A. It's not difficult to ideate a tool that power assistance automate this, perchance distilling kerning data prepared in another ways.

I haven't done a genuine implementation yet, but I doubtful this idea power measure to genuine fonts: alternatively of bits and an entire figure kerning amount, the vectors would shop uninterrupted values (reasonably quantized). An additional nonlinear stage following the multiplication but before the sum may additionally help. Dot products are of way ridiculously productive to compute on GPU, during doing indicator chasing through OpenType tables is not. But that exploration is for another day.

Kerning improves norm considerably:

Comparison of kerning, archetypal row is not kerned, second is

AI disclosure

This activity is a refinement of the content rendering in pico-dvi-rs, which was hand-written. My outline of the JIT center was hand-written in Rust, afterward I used Claude to translate it to symbolasm and refine and optimize it. This blog is hand-written.

The symbolasm compiler and inspection tool is heavily LLM assisted, although it too evolved from an first hand-written version.

I did a microtypography test that was a beautiful chill result, but decided not to contain it, partially since it would have risen the fraction of LLM content.

Like most another group in the industry, I battle alongside the balance between norm and the acceleration that LLMs provide. Claude is exceedingly fine at the low flat tasks of coding; it effortlessly established an border case in my outline of the JIT compiler. I discover that whenever used judiciously, LLM can enhance quality, and I strive for that.

I wasn't capable to discover much precedent for JIT specifically for content rendering. Of course, there is a enormous writings on JIT in general. On-the-fly code generation for bitblt was done for the Blit about 1982. See Bitmap Graphics SIGGRAPH 84 for more. An engaging item is that they assertion concerning 2500 characters per second; the raw achievement of this on RP2350 hardware is nearly 3 orders of dimension greater, fair counting the output to 1bpp (which is how the Blit's framebuffer was configured).

Other Article Hacker News
↑
Close Right Ads
Close Left Ads