脏矩形机制

背景介绍

一般情况下,当屏幕内容绘制完毕后,实际应用通常需要更新屏幕中的一部分内容,而不是单纯显示一个静态图片在那。

如下图所示,屏幕中有一个图片控件(Img2)和一个文本控件(Change Text),图片控件是静态不变的,但是文本控件内容是需要变化的。从左边变到右边,文本从《Change Text》变成《New Text》,但是背景并没有变化,如果屏幕有内容变化就去更新整个屏幕,这个不仅会影响CPU性能,还会影响功耗。

对于下面的场景理论上只需要更新红色区域内容即可。只在需要的时候重绘画面中变化的部分。由于基于光栅的绘图技术在数据结构上总表现为一个矩形区域,且画面中变化的内容往往又被称为”弄脏了”的部分——“脏矩阵”故此得名。

脏矩阵技术的应用非常广泛,例如我们所熟悉的Windows系统,大部分时候就只会更新鼠标指针滑动所经过的那一小片矩形区域而已。

因为脏矩阵在降低传输带宽和CPU占用方面有着不可替代的优势,几乎所有的知名GUI协议栈都在默认情况下悄悄地使用各种各样的脏矩阵算法对系统帧率进行优化。

image-20241114205533910

了解脏矩阵基本背景后,脏矩阵主要要解决的问题是屏幕上有哪些内容需要重绘。引用【玩转Arm-2D】如何使用脏矩阵优化帧率(基础篇)-腾讯云开发者社区-腾讯云的一个极端场景。

如下图显示屏从空白到显示一条直线,大多数GUI框架都会重绘整个屏幕内容。

image-20241114211724243

理想的脏矩阵希望是只绘制线覆盖的这部分区域,但是因为脏矩阵都是矩形的,所以这个奇怪的多边形是无法构建的。

image-20241114211951830

那用一连串宽度(Width)和高度(Height)都为1个像素的脏矩阵来精确逼近整个斜线,也就是下图这种形式,理论上是可行,但如何精准的找到这个这些脏矩阵呢?这个需要一个特别聪明的算法,本项目是做不到的。

image-20241114212557053

所以,通过上面分析可以发现。脏矩阵本身虽然可以有效降低刷新区域面积——提高帧率,但生成脏矩阵的算法本身却可能消耗巨大。这里的矛盾在于:太简陋的算法会因为一条对角线就更新整个屏幕;而”想的太多”的算法也可能会因为时间成本的积累而把脏矩阵的带来的收益抵消了不少——难就难在一个度的把握上。

脏矩阵策略

从上面分析来看,脏矩阵不能太智能(嵌入式小设备带不动),也不能说不要,如何设计一个好的策略至关重要。和ARM-2D的设计理念一致,与其让GUI去智能分析哪些区域需要更新,不如让用户自己去告知GUI那些内容需要更新。

对于GUI而言,只需要将用户告知的脏矩阵合并即可。避免作为用户是知道哪些内容是需要更新的,更新的时机也完全由用户来决定,这样对嵌入式设备来说问题就简化了。

在本项目中,所有的控件都是用户构建的,用户需要更新哪个控件的内容,只需要调用egui_view_invalidate接口,就会通知GUI,当前控件需要更新,之后GUI绘制之前,会去计算所有控件的脏矩阵区域,并将重叠的脏矩阵区域合并在一起,最终构建一个脏矩阵列表,在进行屏幕绘制时,会选择PFB和脏矩阵重叠部分,进行绘制。

下面针对各个场景的脏矩阵合并策略进行说明。

场景1:无重叠脏矩阵

如下图所示,这些控件之间没有重合,egui_core_update_region_dirty这里判断控件和其他脏矩阵有没有重合,没有重合就从egui_core.region_dirty_arr找一个空的位置记录脏矩阵的坐标信息。

最终egui_core.region_dirty_arr会有4个脏矩阵信息。

image-20241114215148268

场景2:重叠脏矩阵合并

如下图所示,Img0和Img1有重叠区域,并且Img0和Img1都需要更新时,Img0对应的脏矩阵是Dirty0.0,Img1对应的脏矩阵是Dirty0.1egui_core_update_region_dirty这里会判断两个脏矩阵有重绘,会通过egui_region_union将2个矩阵合并为Dirty0,存入egui_core.region_dirty_arr中。

Dirty1Dirty0无重合,所以最终egui_core.region_dirty_arr中会有2个脏矩阵信息。

image-20241114215702160

场景3:部分控件更新

如下图所示,Img0和Img1有重叠区域,但是只有Img1需要更新时,虽然2个控件重叠了,但是egui_core_update_region_dirty只会保存Dirty0信息,存入egui_core.region_dirty_arr中。

Dirty1Dirty0无重合,所以最终egui_core.region_dirty_arr中会有2个脏矩阵信息。

image-20241114220221459

场景4:控件移动(无重叠)

如下图所示,考虑移动的场景,用户调用egui_view_scroll_by或者egui_view_scroll_to接口来移动控件,当新移动的位置和之前的位置没有重叠,在egui_view_layout中,会先将当前控件的脏矩阵信息调用egui_core_update_region_dirty更新到脏矩阵列表中,新的位置通过egui_view_invalidate更新,在下次layout时更新脏矩阵中。之后egui_core_update_region_dirty发现新的位置Dirty1Dirty0没重叠,两个信息都存入egui_core.region_dirty_arr中。

Dirty1Dirty0无重合,所以最终egui_core.region_dirty_arr中会有2个脏矩阵信息。

image-20241114221017435

场景5:控件移动(有重叠)

如下图所示,考虑移动的场景,用户调用egui_view_scroll_by或者egui_view_scroll_to接口来移动控件,当新移动的位置和之前的位置有重叠时,在egui_view_layout中,会先将当前控件的脏矩阵信息调用egui_core_update_region_dirty更新到脏矩阵列表中,新的位置通过egui_view_invalidate更新,在下次layout时更新脏矩阵中。之后egui_core_update_region_dirty发现新的位置Dirty1Dirty0有重叠,两个矩阵合并存入egui_core.region_dirty_arr中。

所以最终egui_core.region_dirty_arr中只有1个脏矩阵Dirty0信息。

image-20241114221420787

通过简单的处理,由用户来通知脏矩阵信息,相比于ARM-2D必须用户给出具体坐标信息,本项目用户只需要告知GUI哪些控件的内容有变化即可,GUI通过控件去构建脏矩阵列表。

脏矩阵与 PFB 的协作关系

脏矩形和 PFB 是两个独立但紧密协作的机制:

  1. 脏矩形决定”刷新什么区域”:通过 egui_view_invalidate() 标记需要重绘的控件,框架计算出需要更新的矩形区域列表

  2. PFB 决定”如何刷新”:对每个脏矩形区域,PFB 分块遍历机制逐块完成渲染和传输

具体流程:

egui_view_invalidate()
    -> egui_view_request_layout()         // 标记需要重新布局
    -> egui_core_update_region_dirty()    // 更新脏矩形列表
    -> egui_core_refresh_screen()         // 定时器触发刷新
        -> egui_check_need_refresh(core)      // 检查是否有脏矩形
        -> egui_polling_refresh_display(core) // 遍历脏矩形列表
            -> egui_core_draw_view_group(core) // 对每个脏矩形做 PFB 分块绘制

当 PFB 区域与脏矩形不重叠时,该 PFB 分块直接跳过,不进行任何绘制操作。当 PFB 区域被脏矩形部分包含时,框架会将 PFB 缩小到实际需要绘制的交叉区域,进一步减少绘制量。

egui_view_invalidate 调用链

egui_view_invalidate() 是用户触发脏矩形的核心入口。以下是常见的触发场景:

// 场景1:修改控件属性时自动触发
egui_view_label_set_text(view, "new text");
    // 内部调用 -> egui_view_invalidate(self)

// 场景2:修改控件位置
egui_view_set_position(view, new_x, new_y);
    // 内部调用 -> egui_view_invalidate(self)

// 场景3:修改可见性
egui_view_set_visible(view, 1);
    // 内部调用 -> egui_view_invalidate(self)

// 场景4:修改透明度
egui_view_set_alpha(view, new_alpha);
    // 内部调用 -> egui_view_invalidate(self)

// 场景5:用户手动触发(自定义绘制内容变化时)
egui_view_invalidate(my_custom_view);

egui_view_invalidate() 的实现会检查视图是否可见(包括检查所有父视图的可见性),只有在可见状态下才会触发重绘请求。

细粒度脏矩形扩展

上面的流程描述的是“整控件失效”的默认路径。为了进一步缩小局部刷新范围,框架又补充了细粒度能力:控件作者可以自己定义一组稳定的子区域,把它们当成控件内部的“脏矩形矩阵”来使用。

这里的“矩阵”本质上仍然是一组矩形,而不是让框架自动推导任意形状。框架负责做的事情依旧很简单:

  1. 接收控件上报的局部脏区

  2. 转换到屏幕坐标

  3. 裁剪到控件真实可见范围

  4. 合并到全局 dirty list

  5. 在 PFB 遍历阶段只绘制当前 tile 相关的内容

egui_view_invalidate_region()

egui_view_invalidate_region(egui_view_t *self, const egui_region_t *dirty_region) 用于标记控件内部的一个局部区域失效。

它与 egui_view_invalidate() 的区别在于:

  • dirty_region 使用控件本地坐标,而不是屏幕坐标

  • 框架会自动转换到 region_screen

  • 会自动裁剪到控件的屏幕区域内

  • 仅刷新内容变化,不额外请求布局

因此它特别适合:

  • 光标闪烁

  • 单个按钮/单个格子高亮

  • 局部数值变化

  • 焦点框、选中框、badge、icon 等稳定小区域

egui_view_invalidate_sub_region()

当控件内部存在固定分区时,可以把这些分区提前整理成表,通过 egui_view_invalidate_sub_region() 按索引触发失效。

这种模式适合:

  • number picker 的上/中/下三个区

  • calendar 的日期格子

  • segmented control / tab / grid 等固定槽位

相比每次动态计算坐标,预定义子区域表更容易复用,也更适合做成控件内部的长期优化策略。

egui_canvas_is_region_active()

细粒度 invalidate 只解决了“哪些区域会被加入 dirty list”,还需要解决“on_draw() 内部如何少算一点”。

egui_canvas_is_region_active() 用来判断某个屏幕区域是否与当前 PFB tile 相交:

  • 相交:说明当前子元素确实可能落在当前 tile 中,需要继续绘制

  • 不相交:可以直接跳过对应子元素的测量、格式化和绘制逻辑

这一步很适合放在:

  • 文本绘制前

  • 单元格循环内部

  • 多分区控件的每个子块前

  • 渐变、阴影、圆弧等高成本绘制前

自定义脏矩形矩阵的推荐模式

后续做性能优化时,推荐按下面的顺序思考:

  1. 先判断变化是不是“稳定的局部区域”

  2. 如果是,优先定义一个小而稳定的子区域表,而不是拆很多零碎矩形

  3. 状态切换时同时覆盖旧区域和新区域,避免残影

  4. on_draw() 内用 egui_canvas_is_region_active() 跳过无关子元素

  5. 一旦出现布局变化、文本重排、整页切换,就回退到 egui_view_invalidate()

这套模式已经在 textinputnumber_pickermini_calendar 上验证过,说明“控件自己定义脏矩形矩阵”是一条可持续扩展的优化路径。更完整的实战建议可参考脏矩形细粒度优化指南

调试与统计

为了判断细粒度优化是否真的缩小了脏区,框架增加了 EGUI_CONFIG_DEBUG_DIRTY_REGION_STATS 调试开关。打开后,每帧会输出:

  • regions:本帧脏矩形数量

  • dirty_area:本帧脏区面积

  • screen_area:屏幕总面积

  • pfb_tiles:本帧参与刷新的 PFB tile 数量

这些日志可以用 scripts/perf_analysis/dirty_region_stats_report.py 自动汇总为 Markdown 和 CSV 报告,方便横向对比多个控件或多个版本的优化结果。

需要注意的是:

  • 这些统计值反映的是脏区覆盖率和 tile 覆盖率

  • 它们适合证明“局部刷新面积是不是变小了”

  • 最终性能时间仍应以 QEMU 基准测试为准

脏矩阵对功耗的影响

脏矩形机制对嵌入式系统的功耗优化至关重要:

  1. 减少 CPU 计算量:只有脏矩形区域内的控件才需要参与绘制计算,静态控件的绘制开销为零

  2. 减少 SPI 传输量:只将脏矩形覆盖的 PFB 分块数据发送到显示屏,减少总线传输量

  3. 支持低功耗模式:当画面完全静止时(无脏矩形),egui_check_need_refresh(core) 返回 0,不会触发任何绘制和传输操作,CPU 可以进入睡眠状态

脏矩形数量通过 EGUI_CONFIG_DIRTY_AREA_COUNT 配置,默认值通常为 4~8。数量越多可以避免不必要的区域合并,但同时也增加了遍历开销。对于大多数应用,4 个脏矩形已经足够。

脏矩阵屏幕绘制

在构建好脏矩阵信息后,就需要完成屏幕绘制。由于项目绘制是按照PFB来进行的,下面给出几个场景来说明脏矩阵如何实现提升刷新率的目的。

假设按行扫描,下面是可能遇到的各个场景。

场景1

当PFB和脏矩阵不重叠时,这个情况下,这个区域无需绘制。这样就少了很多绘制动作。

image-20241114222128351

场景2

当PFB包含脏矩阵时,这个情况下,虚线是原本需要绘制的PFB区域,但是因为脏矩阵不大,只需要绘制实现部分的PFB就行。这样就少了很多绘制动作。

image-20241114222331905

场景3

当PFB部分包含脏矩阵时,这个情况下,虚线是原本需要绘制的PFB区域,但是因为脏矩阵不大,只需要绘制实现部分的PFB就行。这样就少了很多绘制动作。

image-20241114222434353

场景4

当PFB中包含多个脏矩阵时,这个情况下,虚线是原本需要绘制的PFB区域,但是因为两个脏矩阵不重叠,原本一次绘制,需要变成2次绘制,PFB0绘制1次,FPB1绘制1次。这样就少了很多绘制动作。

image-20241114222617197

脏矩阵无法解决的场景

开篇说的画线的极端场景是无法优化的。

此外在支持触控的场景中,显示帧率性能并不能通过脏矩阵方案提升,如下所示场景,有2个图片Img1和Img2,从右滑动到左边时,整个屏幕的内容还是需要重绘。

所以说脏矩阵并不是万能的,部分场景还是得一个个去挖掘CPU性能。

image-20241114211124771

接口设计与实战

上面这一页主要解释脏矩形机制本身。

如果需要继续看“控件作者应该如何设计 dirty-region 接口、何时该用整控件失效、何时该用局部区域失效、圆形控件如何复用 helper,以及运行截图效果”,可继续参考: