跳到正文
Hacker News· dmit·· 7 小时前AI 评分20

Raph Levien 用 JIT 为 RP2350 微控制器实现极速位图文字渲染

JIT bitmap text rendering for microcontrollers

AI 导读

Raph Levien 为 RP2350 微控制器项目 vic-2350 实现了一种 JIT 位图文字渲染技术,动态生成高度优化的渲染代码,在逐扫描线填充视频缓冲的 race-the-beam 模式下每像素仅约 6 个周期预算。渲染器用其自创的 symbolasm 语言编写,采用双缓冲显示列表和活动列表管理显示对象,文字对象仅存字符串、字体指针与前景背景色以节省 RAM。

正文 · AI 翻译

Raph Levien,2026年11月7日

我一直在开发 vic-2350,这是对 80 年代家用电脑在 RP2350 微控制器板上的重新构想。 当然,这样的项目需要文本渲染。 尽管位图字体如今已不受青睐,但我选择它们既是为了效率,也是因为在低分辨率下,我更偏爱清晰、经过刻意设计的位图字体的美感,而不是抗锯齿字体渲染那种模糊的一团。 它们仍然有一席之地,尽管很狭窄。

我还想要比例间距和字距调整,同样是因为我喜爱早期 Macintosh 及其前身的美感。 RP2350 项目中的大多数文本渲染都基于固定字符单元,因为要实现良好性能很棘手。

Demo of bitmap text rendering on RP2350

这篇博文介绍了一种在 RP2350 等微控制器上极快渲染位图文本的技术。 我已将其作为我的 race-the-beam 渲染器的一部分实现,但同样的技术也适用于写入帧缓冲。 核心思想是一个 JIT,用于即时生成高度优化的代码来渲染字符串。

与光束赛跑

大约在 80 年代中期,计算机显示文本的方式发生了分化。 既有的技术是固定单元尺寸,以及硬件在图像扫描输出到显示器时即时从字体 ROM 中查找字符。 其优势是在有限硬件下实现非常快的更新和响应性。 许多终端都是这样工作的,家用电脑也是如此。 缺点是不够灵活。 特别是只支持等宽字体。

较新的技术是位图帧缓冲,最早由源自 Xerox Alto 的一系列计算机开创,经 Apple Lisa 延续,最终随 Macintosh 流行起来。 帧缓冲灵活得多,但需要多得多的计算能力来保持更新。 在 80 年代早期,你通常会看到文本逐步渲染出来。 我还应该提到,家用电脑通常提供图形模式,但它们的分辨率通常低于文本模式,而 1MHz 的 6502(或类似处理器)根本不够强大,因此文字处理器和电子表格等严肃的基于文本的程序都使用文本模式。

快进到 RP2350。 借助 HSTX 外设,它能够输出 DVI 视频,但填充视频缓冲仍然完全取决于软件。 有些项目选择帧缓冲,但芯片上只有 520kB 的 SRAM,因此帧缓冲必须在分辨率或位深或两者上受到限制,而且让这些 RAM 可供应用用于其他目的也会更好。 我选择的是与光束赛跑,逐扫描线填充视频缓冲。 这允许更丰富的视觉效果和低得多的延迟,但对渲染器是严格的性能约束。 每像素大约有 6 个周期的预算。 我们低于预算越多,就有越多可用于渲染其他图形对象和其他应用功能。

Rust 无法满足性能要求,所以我开始编写 ARM 汇编语言。 渲染器以及 vic-2350 的许多部分现在都用 symbolasm 编写,这是我为极高性能 Cortex-M33 编程创建的一种编程语言。 采用 JIT 进一步加快了渲染速度。

渲染管线

渲染通常被建模为一条流水线,vic-2350 渲染器也不例外。 应用与渲染器之间的接口是一个显示列表。本质上,它就是字面意思:一个显示对象的列表。显示列表是双缓冲的;一个正在被渲染并扫描输出,另一个由应用构建。 在每个 vsync 时,如果应用已就绪,显示列表就会被交换。 构建显示列表在计算上并不密集,但应用当然可能忙于其他事情。

由于显示列表在编译时大小未知,它会进行内存分配。 显示对象被打包进两个向量;一个存放显示对象本身,另一个存放指向第一个向量的索引列表。 一个显示对象由 vtable、边界矩形和对象特定的负载组成。 分配是可以接受的,因为构建显示列表不是时间关键路径。 渲染则是另一回事。 话虽如此,显示列表保持内存高效很重要,尤其是因为有两份副本。

在呈现之前,索引会按 y0 升序排序。

在渲染过程中,渲染器维护一个活动列表,它只是与当前扫描线相交的显示对象子集。 在每条扫描线上,y1 = y 的对象会从活动列表中删除,而 y0 = y 的新对象会被插入,这就是之前排序的原因;这正是对象插入活动列表的顺序。 活动列表按显示对象顺序保持。

然后渲染器只需对活动列表中的每个显示对象调用 render 方法。 我很快会做更多优化,跟踪不透明跨度并省略对之后会被覆盖的像素跨度的渲染工作,但这对于此处呈现的文本渲染并非根本性的。

在对象的开始和结束处(当它被插入和删除时),渲染器会对显示对象调用 start 和 end 方法。 这些方法可以分配暂存空间并做其他工作来为对象的渲染做准备。 有一个专门的位图分配器,既快速又确定。

文本显示对象

所有这些为我们提供了实现文本渲染作为显示对象实例所需的一切。

文本对象本身包含字符串(每字符 1 字节)、指向字体的指针以及前景色和背景色。 RAM 很宝贵,对于显示列表更是如此,因为它是双缓冲的。 在渲染器的早期迭代中,我在显示列表中存储了文本的扩展版本,当屏幕充满文本时,这造成了不可接受的内存压力。

start 方法为 JIT 代码和每像素 1 位的中间缓冲区分配暂存空间,并调用 JIT 编译器,将字符串展开为代码。

render 方法调用该代码,填充每像素 1 位的缓冲区,然后运行第二遍,将该每像素 1 位缓冲区扩展为每像素 16 位 RGB(565 格式)。 第二遍以每像素 1.75 个周期的持续速率运行。

字体以极其紧凑的格式存储。 字形的所有“墨水”都存储在一个宽位图图像中,高度与字体相同。 这种打包还确保没有字形跨越 32 像素块边界,因此一行字体像素始终可以通过单次 32 位加载读取。 如果字形两侧有空间,该空间不会存储在打包位图中;相反,该空间位于每个字形的元数据中。

本着微优化的精神,打包算法还会检测重叠,因为并不要求像素必须唯一属于单个字形。 这一优化将字体位图的大小从 1140 字节减少到 960 字节。 每个字节都很重要,随着我们添加更多字体,这一点更加明显。 字体位图需要放在 SRAM 中,因为放在 flash 中可能会在视频速率渲染时导致停顿。

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

我特别喜欢 {][} 序列(靠近第二行的开头)。 任何包含在另一个字形内的字形都不需要单独打包。 有几个例子,有些令人惊讶。 例如,l 是 U 的左侧部分,而 0 与去掉中间空格的 () 完全相同。

每个字形的元数据(不计字距调整)在概念上有四个字段:位图中墨迹的 x 位置和宽度、左侧空白以及前进宽度。 所有这些都被紧凑地打包进一个 32 位整数中。

JIT

在生成的 JIT 代码中,寄存器的分配如下:

  • r0 指向打包字体位图中该行的起始位置
  • r1 是输出缓冲区,一个单行、每像素 1 位的图像缓冲区
  • r2 是输出位图的累加器
  • r3 保存从一个字形中提取的位,这些位来自 r4
  • r4 保存从字形位图中加载的 32 位

在最简单的情况下,我们向累加器中添加一个字形。 代码如下所示:

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

还有额外的代码用于在完成时输出一个字(stmia r1!, {r2}),以及处理字形跨越 32 位边界的情况。 这些在 text.sy 中有文档说明。 但这里我想提一个技巧:上述代码在 ldr 和 ubfx(无符号位字段提取)之间存在结果使用延迟。 这通过稍微调整指令顺序来解决:字形 i 的最后一条指令被重新排序到字形 i + 1 的第一条指令(ldr)之后,填充了延迟槽,并将周期成本从 4 降低到 3。

这是代码生成器的内层循环,只关注字形不跨越累加器的情况。 对于每个字形,几个值被打包进 g 变量,然后在下面解包:打包字体位图中的偏移量(进一步分解为位偏移和字偏移)、墨迹部分的宽度、左侧边距以及前进宽度。

    loop {
        g @r12 = *(glyphs + c * 4)
        // the next character, reading one past 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 above it.
        word_offset @r4 = g >> 5
        ldr @r4 = ldr_base + (word_offset << 6)
        ubfx_instr += width << 16
        *slot = ldr
        // left side bearing
        left @r4 = (g as i32)[16..20]
        *(code as *u32) = ubfx_instr
        // shift: where the glyph starts, relative to the word in r2. Branch is
        // also taken if negative.
        shift @r4 = pen + left
        room @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 + shift * 4)
        *((code + 6) as *u32) = d
        slot = code + 4
        code += 10
        #(n -= 1)
        if #ne goto loop
    }

以下是相应的机器码、ARM 汇编代码以及来自 symbolasm 分析工具的周期计数分析:

  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
   loop 134-164: 23 cycles per iteration

K 标志表示分支可能存在延迟,因为条件直到分支前一个周期才确定。 这里,0/2 表示如果分支不跳转则为 0 个周期,如果跳转则为 2 个周期。 这两者都无法优化。 H 标志表示由于使用了 "hi" 寄存器(r8-r15),该指令为 32 位而非 16 位。 这些在 LSP 中也可用。

性能

基本循环对于 JIT 每个字形需要 23 个周期,再加上位图生成的每行高度 3 个周期。 对于每 32 位输出宽度,还有大约 20 个周期的 JIT 额外成本和每行高度 2 个周期的成本。

这快得惊人。 vic-2350 中的 textdemo 大约需要 21k 个周期用于 JIT,42k 个周期用于运行 JIT 代码。 这加起来大约占 RP2350 单核可用吞吐量的 2.6%。 扩展到 565 实际上大约贵 3 倍。 所以可以说它已经优化到极致了。

虽然这是为竞速光束渲染设计的,但它同样适用于写入帧缓冲区,尤其是每像素 1 位的帧缓冲区。 只需生成代码,然后对每条扫描线运行即可。

缓存 JIT 输出很诱人,但节省 RAM 更重要。 JIT 输出大约是输入字符串的 10 倍膨胀。 而且,从上面的数字可以清楚看出,运行 JIT 的成本极小。

“just in time”这个名字很贴切;在 vic-2350 的实现中,JIT 编译器在视频行扫描输出前不到 100µs 内运行。

我未能找到与现有 RP2350 文本渲染器的直接性能对比。 最接近的可能是 Michael Bell 开发、由 Adafruit 分叉的 dvhstx。 它最终也大约每像素花费 2-2.5 个周期,但受限于 14x24 网格,并且还使用 RGB 222 模式,因此其输出带宽只有一半(而时间的大部分都花在这里)。

点积字距调整

字距调整是文本渲染中非常重要的改进,否则间距看起来会不规则。 OpenType 字体中的通用机制相当复杂。 对于这个项目,我想要简单高效的东西,于是重新拾起了一个最初由尝试在 GPU 上进行文本布局所激发的想法。

基本上,每个字形有两个位向量,分别对应左侧和右侧。 要对两个字形进行字距调整,取第一个字形右位向量与第二个字形左位向量的 and,然后计算其 popcount(1 位的数量),得到需要调整的像素数。 这实际上就是两个向量的点积,位被解释为 0 和 1。 字距表每个字形只需要两个字节,并且字距调整的求值速度极快。

即便如此,字距调整求值是在显示列表构建时进行的,而不是在光束扫描时。 当字距非零时,会在文本显示对象的字符串中插入一个特殊的负空格字符。

对于这个字体,我手动构建了字距表。 每一位代表一个特定特征,例如第 0 位表示第一个字形的右上角突出,而下一个字形的左上角相应缺少墨迹。 因此它在 T 和 Y 的右侧设置,也在大多数字母的左侧以及 A 处设置。 不难想象一个可以帮助自动化的工具,也许可以提炼以其他方式准备的字距信息。

我还没有做真正的实现,但我怀疑这个想法可能扩展到真实字体:向量将存储连续值(合理量化),而不是位和整数字距量。 在乘法之后、求和之前增加一个非线性步骤也可能有帮助。 点积在 GPU 上计算起来当然极其高效,而在 OpenType 表中进行索引追踪则不然。 但那个探索是另一天的事了。

字距调整显著提高了质量:

Comparison of kerning, first line is not kerned, second is

AI 披露

这项工作是 pico-dvi-rs 中文本渲染的改进,该渲染是手写的。 我的 JIT 核心草稿是用 Rust 手写的,然后我用 Claude 将其翻译为 symbolasm 并改进和优化。 这篇博客是手写的。

symbolasm 编译器和分析工具大量借助了 LLM,不过它也是从最初的手写版本演变而来的。

我做了一个微观排版实验,结果相当酷,但决定不将其包含在内,部分原因是它会增加 LLM 内容的占比。

和业内大多数人一样,我也在质量与 LLM 带来的加速之间艰难权衡。 Claude 在低层编码任务上极为出色;它轻松地在我草拟的 JIT 编译器里发现了一个边缘情况。 我发现,只要审慎使用,LLM 能提升质量,而我也在为此努力。

我没能找到多少专门针对文本渲染的 JIT 先例。 当然,关于 JIT 的通用文献浩如烟海。 为 bitblt 进行即时代码生成的做法,大约在 1982 年就已在 Blit 上实现。 更多内容参见 Bitmap Graphics SIGGRAPH 84。 一个有趣的细节是,他们声称大约每秒 2500 个字符;而在 RP2350 硬件上,仅计算输出到 1bpp(Blit 的帧缓冲就是这样配置的)的原始性能就几乎高出 3 个数量级。

来源:Hacker News · linebender.org