PFB 局部帧缓冲设计

背景介绍

一般来说,要实现屏幕显示,就是向特定像素点写入颜色值。最简单的办法是直接通过 SPI 接口,向显示器芯片的特定显存地址写入像素点。常见显示器芯片通常会提供两类基础接口:WritePointWriteRegion

  • WritePoint 用于给单个像素点写入颜色,常用于绘制文字等离散内容。

  • WriteRegion 用于向一块连续区域写入像素数据,常用于绘制图片。

如下图所示,主控通过 SPI 直接向显存写入数据。调用 WriteRegion 可以在 (x0, y0) 坐标绘制整块图像;多次调用 WritePoint,配合字体像素偏移和基准坐标 (x1, y1),则可以完成文字绘制。

这种方式最大的优点是简单直接,对主控 RAM 的占用最少,因为显示缓存基本都由显示器芯片内部显存承担。很多简单项目或早期原型都可以用这种方式满足需求。

image-20241024095939664

但如果界面需要频繁更新,问题就出现了。文字、图标或局部内容一旦变化,往往需要清除旧区域,再重新向新坐标写入数据。

动态刷新需求

当屏幕内容不再是一成不变,而是需要由状态机持续管理和刷新时,直接依赖显示器芯片显存会逐渐变得难以维护。对于简单局部变化,这种方式还能勉强应对;但当 UI 结构变复杂后,局限会越来越明显。

image-20241024101744908

带透明通道图片叠加

如果只是单张不透明图片,直接写入即可;如果图片带 Alpha 通道,情况就不同了。

  • 没有背景时,Alpha=0xFF 直接使用前景像素,Alpha=0x00 不绘制,中间值做混合即可。

  • 有背景且背景不是纯色时,必须先拿到背景像素,再与前景图像做混合。

这意味着主控侧需要缓存背景色,否则无法正确完成透明图层叠加。

image-20241024103223469

字体本质上也是类似的情况。带抗锯齿效果的字体通常也包含透明通道,最终显示效果取决于字体像素与背景色的混合结果。

image-20241024110328220

image-20241024110448996

页面滑动需求

如果进一步出现页面滑动、惯性滚动、转场动画等需求,而且页面中还包含透明图片和复杂控件,那么单纯依赖显示器显存就很难优雅实现了。虽然理论上仍可以由主控自己计算,但从复杂度和可维护性来看,通常都需要引入中间缓存。

20240831_164234_viewpage.gif

部分帧缓存 PFB(Partial Frame Buffer)

一个直觉上的办法,是在主控里做完整帧缓存。但这在嵌入式场景里往往不现实。

例如一个 320x240 的 RGB565 屏幕,完整帧缓存需要:

320 * 240 * 2 = 153600B = 150KB

对很多 MCU 来说,这已经接近甚至超过可用 RAM 总量。

PFB 的思路,就是只分配一小块缓冲区,分块渲染屏幕内容,再把每块结果刷到显示器上。这样既能保留较灵活的绘制模型,又不用承担完整帧缓存的 RAM 代价。

本项目的 PFB 机制支持任意尺寸配置,哪怕只有 8x8 大小,也仍然可以驱动完整屏幕。更关键的是,应用层 API 不需要关心 PFB 的具体大小,框架会负责把渲染拆分成多个局部块。

通过配置 EGUI_CONFIG_PFB_WIDTHEGUI_CONFIG_PFB_HEIGHT 就可以指定 PFB 尺寸。

PFB 刷新机制

屏幕刷新时,框架会从 (0, 0) 坐标开始,按既定扫描顺序覆盖整个屏幕。每次只计算当前 PFB 区域需要显示的内容,再通过显示驱动将该区域结果写回显示器。

只有真正发生变化的区域才会参与刷新,这里会结合脏矩形机制一起工作,从而显著降低整体刷新成本。

image-20241024112832708

核心遍历逻辑位于 egui_core_draw_view_group()。对于每个脏矩形区域,框架会计算需要多少个 PFB 分块覆盖该区域,然后逐块执行以下流程:

  1. 初始化当前分块对应的 canvas

  2. 清空当前 PFB

  3. 绘制视图树

  4. 提交该块到显示硬件

// 伪代码:PFB 分块遍历
for (y = 0; y < pfb_height_count; y++) {
    for (x = 0; x < pfb_width_count; x++) {
        egui_canvas_init(&core->canvas, core, pfb, &region);    // 设置当前 PFB 区域
        egui_api_pfb_clear(pfb, size);                          // 清空缓冲区
        view_group->api->draw(view_group);                      // 绘制视图树
        egui_core_draw_data(core, &region);                     // 提交到显示设备
    }
}

PFB 实现机制

本项目底层图像绘制统一通过 egui_canvas_t 完成。egui_canvas_t 会维护当前 PFB 指针(egui_color_int_t *pfb)以及当前 PFB 对应的区域信息(egui_region_t pfb_region)。

最终大多数绘制流程都会落到 egui_canvas_draw_point() 这一类底层接口。绘制时会先判断目标像素是否落在当前 PFB 覆盖范围内;如果在,则换算成 PFB 内部坐标,再写入或混合到对应像素。

void egui_canvas_draw_point(egui_dim_t x, egui_dim_t y, egui_color_t color, egui_alpha_t alpha)
{
    egui_canvas_t *self = &canvas_data;

    ...

    if (egui_region_pt_in_rect(&self->base_view_work_region, x, y))
    {
        egui_dim_t pos_x = x - self->pfb_location_in_base_view.x;
        egui_dim_t pos_y = y - self->pfb_location_in_base_view.y;

        egui_color_t *back_color = (egui_color_t *)&self->pfb[pos_y * self->pfb_region.size.width + pos_x];

        if (alpha == EGUI_ALPHA_100)
        {
            *back_color = color;
        }
        else
        {
            egui_rgb_mix_ptr(back_color, &color, back_color, alpha);
        }
    }
}

因此应用层无需显式处理“当前是不是在局部缓冲里”,只需要按正常方式在 canvas 上绘制即可,canvas 会在最后一步完成裁剪、坐标转换和像素混合。

PFB 尺寸选择指南

PFB 的宽度和高度可以任意配置,但合理的选择能在 RAM 占用和性能之间取得更好的平衡。

配置原则

  1. PFB 总像素数 = PFB_WIDTH * PFB_HEIGHT,这直接决定 RAM 占用。

  2. PFB 宽度尽量接近屏幕宽度,这样每次覆盖的横向范围更大,WriteRegion 调用次数更少。

  3. PFB 高度至少建议达到屏幕高度的 1/10,否则线段、圆弧、圆角等图形在多次分块中会产生明显额外开销。

  4. PFB 尺寸不要求一定是屏幕尺寸的整数约数,框架会自动处理边界块。

推荐配置表

屏幕分辨率

PFB 配置

PFB 像素数

RAM 占用 (RGB565)

适用场景

128x128

128x16

2048

4 KB

小屏手表,极低 RAM

240x240

240x24

5760

11.25 KB

圆形手表屏

240x320

240x32

7680

15 KB

常见 TFT 屏,推荐配置

240x320

24x32

768

1.5 KB

极限低 RAM,性能会明显下降

320x480

320x32

10240

20 KB

大屏 TFT

480x320

480x20

9600

18.75 KB

横屏大屏

配置示例

app_egui_config.h 中配置:

// 推荐配置:240x320 屏幕,15KB PFB
#define EGUI_CONFIG_PFB_WIDTH  240
#define EGUI_CONFIG_PFB_HEIGHT 32

// 极限低 RAM 配置:仅 1.5KB
#define EGUI_CONFIG_PFB_WIDTH  24
#define EGUI_CONFIG_PFB_HEIGHT 32

双缓冲与多缓冲机制

单缓冲工作模式

默认情况下,系统使用单缓冲模式:CPU 渲染一个 PFB 分块,然后通过 SPI 把它发送到显示屏;发送完成后再渲染下一个分块。此时 CPU 和 SPI 基本是串行工作的。

CPU:  [渲染PFB0][等待SPI][渲染PFB1][等待SPI][渲染PFB2][等待SPI]
SPI:  [  空闲  ][发送PFB0][  空闲  ][发送PFB1][  空闲  ][发送PFB2]

双缓冲工作模式

如果启用双缓冲或多缓冲,CPU 可以在 DMA 发送当前分块的同时,开始渲染下一个分块,从而实现渲染与传输并行。

CPU:  [渲染PFB0][渲染PFB1][渲染PFB2][渲染PFB3]...
DMA:  [  空闲  ][发送PFB0][发送PFB1][发送PFB2]...

这种流水线方式通常能显著提高帧率,尤其适合 SPI 发送时间和 CPU 渲染时间接近的场景。

PFB Manager 环形缓冲队列

当前多缓冲由 egui_pfb_manager_t 统一管理。推荐方式是使用编译期声明的 PFB 数组,并直接交给 egui_init()

EGUI_CONFIG_PFB_BUFFER_DECLARE(egui_pfb);
static egui_core_t core;

void app_main(void)
{
    egui_init(&core, egui_pfb);
}

如果是子屏幕或运行时确定尺寸的场景,也可以显式传入 PFB 数组,使用 egui_init_display()

egui_color_int_t *pfb_bufs[] = { pfb0, pfb1, pfb2 };

egui_init_display(
    &core,
    screen_w,
    screen_h,
    pfb_bufs,
    3,
    pfb_w,
    pfb_h
);

egui_pfb_manager_t 内部会维护渲染队列和发送队列,应用层不需要再手动切换“主 PFB / 备份 PFB 指针”。

SPI 总线共享

如果 LCD 和外部 Flash 共用同一条 SPI 总线,可以通过总线锁机制避免冲突:

egui_pfb_bus_acquire(&core);   // 暂停 PFB flush,获取总线使用权
// ... 读写外部 Flash ...
egui_pfb_bus_release(&core);   // 释放总线,恢复 flush

总线锁定期间,CPU 仍可继续渲染,结果会暂存在 PFB 队列中;释放后再继续发送。

PFB 性能分析

PFB 的本质是“用时间换空间”。PFB 越小,RAM 占用越低,但渲染和提交分块的次数也会越多,因此刷新速度会下降。

本项目对基础图形做了较完整的二次裁剪,尽量减少“明明不在当前 PFB 区域里、却依然参与计算”的无效开销,以便在小 PFB 场景下仍保持可接受性能。

如下图所示,理想情况下只让真正落在当前区域内的部分参与绘制,可以有效减少 CPU 浪费。

image-20241024115741046

PFB 不同尺寸测试

项目在设计阶段就针对“小 PFB”场景做了专项性能测试,用于评估不同尺寸下 CPU 的绘制代价。建议在真实 MCU 或 QEMU 环境中测试,不建议直接用 PC 模拟器数据作为性能基准。

HelloPerformance 为例,可以在 stm32g0 平台运行:

make run APP=HelloPerformance PORT=stm32g0

EGUI_CONFIG_PFB_WIDTHEGUI_CONFIG_PFB_HEIGHT 可按需调整。

image-20241024135911010

测试统计结果如下。以 24x32 为例,这类“宽高约为屏幕十分之一”的配置,在很多基础图形场景下已经能取得不错的平衡。

场景

12x8

24x32

240x32

24x320

LINE

104.8ms

21.6ms

10.7ms

9.6ms

IMAGE_565

49.1ms

27.4ms

23.6ms

24.8ms

TEXT

92.1ms

19.5ms

12.5ms

17.1ms

TEXT_RECT

135.0ms

24.8ms

13.0ms

17.6ms

RECTANGLE

40.7ms

15.7ms

12.3ms

12.6ms

CIRCLE

135.4ms

93.8ms

84.8ms

89.1ms

ARC

122.3ms

51.0ms

36.3ms

42.4ms

ROUND_RECTANGLE

138.8ms

96.0ms

86.7ms

91.2ms

RECTANGLE_FILL

37.7ms

18.0ms

15.1ms

15.6ms

CIRCLE_FILL

387.3ms

63.5ms

23.5ms

25.6ms

ARC_FILL

212.5ms

121.0ms

108.4ms

108.1ms

ROUND_RECTANGLE_FILL

391.4ms

66.5ms

26.0ms

28.4ms

性能分析要点

从上面的数据可以提炼出几条结论:

  1. PFB 从 12x8 增大到 24x32 时,性能提升最明显。这是因为极小 PFB 会造成大量分块遍历和重复裁剪开销。

  2. PFB 从 24x32 增大到 240x32 后,边际收益开始递减。对很多项目来说,24x32 已经是不错的性价比平衡点。

  3. 圆形、圆角矩形这类需要逐像素判断的图形,对 PFB 尺寸最敏感。PFB 过小时,同一个图形会被拆成很多块重复参与运算。

  4. 图片绘制对 PFB 尺寸通常没那么敏感,因为图片往往以行扫描为主,裁剪效率较高。

更完整的数据和分析可以参考性能测试章节。

提示:性能测试应在 QEMU 或真实 MCU 上进行。PC 模拟器计时精度通常只有 1ms,不适合作为严肃性能基准。

PFB 形状对性能的影响

除了总面积,PFB 的形状也会显著影响性能。为量化这一点,项目对 240x240 屏幕做了多种 PFB 形状测试:

配置名

PFB 尺寸

总像素数

特点

small

15x15

225

小块,宽高均为屏幕的 1/16

middle

30x30

900

中等块,宽高均为屏幕的 1/8

fullwidth

240x1

240

全宽单行

fullheight

1x240

240

全高单列

fullscreen

240x240

57600

全屏缓冲,作为理论上限参考

完整热力图如下:

PFB Matrix Heatmap

结合 PFB 矩阵测试报告,可以得到如下结论。

1. 全高单列(1x240)通常是最差的形状

虽然 fullheight(1x240)fullwidth(240x1) 总像素数相同,但全高单列在绝大多数场景下都显著更慢。原因在于很多图形算法天然更适合按“行”处理,而不是按“列”处理。

测试用例

fullwidth (240x1)

fullheight (1x240)

性能差距

ELLIPSE

4.970 ms

177.934 ms

36 倍

ELLIPSE_FILL

2.198 ms

116.474 ms

53 倍

GRADIENT_ROUND_RECT_RING

8.147 ms

1351.166 ms

166 倍

POLYGON_FILL

2.730 ms

73.558 ms

27 倍

TRIANGLE_FILL

2.036 ms

42.931 ms

21 倍

TEXT

16.127 ms

14.931 ms

少数情况下接近

2. 中等方块(30x30)已接近全屏性能

30x30 的 PFB 仅占用约 1.8KB RAM,但多数图形操作已经能逼近全屏缓冲效率,是低 RAM 场景中很实用的折中。

测试用例

middle (30x30)

fullscreen (240x240)

倍数

RECTANGLE

0.943 ms

0.719 ms

1.3x

CIRCLE_FILL

1.960 ms

1.202 ms

1.6x

IMAGE_565

0.855 ms

0.613 ms

1.4x

TEXT

2.456 ms

0.385 ms

6.4x

ARC_FILL

5.491 ms

4.753 ms

1.2x

3. PFB 形状选择建议

  • 首选:宽度接近屏幕宽度的矮条,例如 240x32

  • 次选:接近正方形的小块,例如 30x30

  • 避免:全高单列或极窄高条,例如 1x2402x120

经验上,在总像素数相同的前提下,PFB 越“宽”通常越有利于性能。

总结

PFB 是 EmbeddedGUI 的核心设计之一,它通过以下机制让低 RAM 设备也能实现较完整的 GUI 能力:

  1. 支持任意大小 PFB,适配极低内存场景

  2. 对应用层隐藏 PFB 细节,统一通过 canvas 绘制

  3. 结合脏矩形与二次裁剪,减少无效计算

  4. 支持双缓冲和多缓冲,提升渲染流水线效率

  5. 在有限 RAM 下依然保留较好的图像、文字和动画表现

实际项目中,建议先从“屏幕宽度的 1/10 到 1/4”这一量级开始尝试 PFB 尺寸,再根据性能测试结果逐步调优。