Flipper Zero Unleashed Firmware 帧缓冲直访指南:用 canvas_get_buffer() 实现像素级碰撞与全屏渲染
【免费下载链接】unleashed-firmwareFlipper Zero Unleashed Firmware项目地址: https://gitcode.com/GitHub_Trending/un/unleashed-firmware
导读
本文以 Unleashed Firmware 官方示例 example_canvas_buffer 为切入点,深入讲解 Canvas 帧缓冲直访 APIcanvas_get_buffer()的设计动机、缓冲布局与读写路径。读者将掌握:如何从普通外部应用(FAP)绕过逐图元绘制接口,直接对 128×64 单色帧缓冲做像素级读回与写入,并理解为什么像素读回(pixel read-back)是实现在屏幕文字与图形之间做精确碰撞检测的唯一合规途径。
示例概述:一个「愤怒的像素」3D 立方体
example_canvas_buffer是一个演示型外部应用:一个线框立方体在屏幕上飞行,撞到屏幕边缘以及画面上已有的文字后反弹。它有两个关键特征:
- 文字由标准 Canvas API 绘制:示例中通过
canvas_draw_str()绘制了 "Angry pixels"、"run!"、"bonk!"、"ouch!"、"the floor is lava" 等文字; - 碰撞检测是像素级物理:立方体撞击后,每个发生碰撞的顶点会对自转轴施加一个力矩(r × F),三个轴的转动惯量略有差异,自转轴像不对称陀螺那样缓慢进动,因此翻滚始终是真实的三维运动,而不会退化成扁平的二维旋转。
该应用通过 application.fam 声明为外部应用(apptype=FlipperAppType.EXTERNAL),入口函数为example_canvas_buffer_app,仅依赖gui模块,栈大小为 2KB。
canvas_get_buffer():读与写的统一入口
API 声明与实现
在 canvas.h 中声明了两个配套接口:
/** Get canvas buffer. * * @param canvas Canvas instance * * @return pointer to buffer */ uint8_t* canvas_get_buffer(Canvas* canvas); /** Get canvas buffer size. * * @param canvas Canvas instance * * @return size of canvas in bytes */ size_t canvas_get_buffer_size(const Canvas* canvas);在 canvas.c 中,两者的实现直通 u8g2 渲染内核:
uint8_t* canvas_get_buffer(Canvas* canvas) { furi_check(canvas); return u8g2_GetBufferPtr(&canvas->fb); } size_t canvas_get_buffer_size(const Canvas* canvas) { furi_check(canvas); return u8g2_GetBufferTileWidth(&canvas->fb) * u8g2_GetBufferTileHeight(&canvas->fb) * 8; }即:返回的是 Canvas 背后 u8g2 帧缓冲(canvas->fb)的真实指针。也就是说,这一 API 把固件内部渲染器的帧缓冲直接暴露给了外部应用——返回的指针就是当前帧的活缓冲(live framebuffer),同帧内 Canvas API 先画好的内容已经光栅化在其中,外部应用既能在其上叠加绘制,也能把它读回来做像素判断。
缓冲布局:单字节覆盖垂直 8 像素条
返回的帧缓冲是 128×64 的 1-bit 帧缓冲,按 8 页(page)排列,每页是垂直的 8 像素条。寻址公式(示例源码 display.c 中有完整注释):
byte = (y / 8) * width + x bit = y % 8 // LSB 在最上面(bit 0 对应 y%8 == 0 的像素)以 128×64 屏幕为例,每行 128 个像素由 16 个字节覆盖(128 / 8),总字节数为(64 / 8) * 128 = 1024字节。外部应用完全可以根据此公式把(x, y)映射到缓冲中的字节与位,实现任意像素的读写。
读写两条路径的源码实现
写路径:Bresenham 直线光栅化
示例没有使用canvas_draw_line(),而是自己在帧缓冲上实现了 Bresenham 直线光栅化(display.c):
static void fb_draw_line(uint8_t* fb, int32_t x0, int32_t y0, int32_t x1, int32_t y1) { int32_t dx = abs(x1 - x0); int32_t dy = -abs(y1 - y0); int32_t step_x = x0 < x1 ? 1 : -1; int32_t step_y = y0 < y1 ? 1 : -1; int32_t err = dx + dy; for(;;) { fb_put_pixel(fb, x0, y0); if(x0 == x1 && y0 == y1) break; int32_t e2 = 2 * err; if(e2 >= dy) { err += dy; x0 += step_x; } if(e2 <= dx) { err += dx; y0 += step_y; } } }底层单像素写入fb_put_pixel直接按缓冲布局置位,越界坐标被忽略:
static inline void fb_put_pixel(uint8_t* fb, int32_t x, int32_t y) { if((uint32_t)x < SCREEN_WIDTH && (uint32_t)y < SCREEN_HEIGHT) { fb[((y >> 3) * SCREEN_WIDTH) + x] |= 1 << (y & 7); } }读路径:与 Canvas API 同帧渲染的文字做碰撞
读回函数fb_hit(display.c)的语义与写路径对称:先查边界,再把目标字节对应位取出。特别之处在于屏幕外的坐标一律返回 true,这使屏幕边缘天然成为碰撞用的「实心墙」:
static inline bool fb_hit(const uint8_t* fb, int32_t x, int32_t y) { if((uint32_t)x >= SCREEN_WIDTH || (uint32_t)y >= SCREEN_HEIGHT) return true; return (fb[((y >> 3) * SCREEN_WIDTH) + x] & (1 << (y & 7))) != 0; }碰撞流程位于绘制回调cube_draw_callback(display.c):
- 先用
canvas_draw_str()绘制文字(由 Canvas API 光栅化进同一帧缓冲); - 调用
canvas_get_buffer(canvas)拿到缓冲指针; - 读回阶段:分别对「X 方向推进后的位置」和「Y 方向推进后的位置」探测 8 个投影顶点的像素,确定立方体会在哪个轴向上撞上已绘制像素,同时根据碰撞顶点的位置向量与冲量方向(r × F)累加力矩;
- 力矩经各轴不同的逆转动惯量(
inv_inertia[3] = {18, 16, 13})换算后叠加到自转角速度上,并做阻尼衰减; - 写回阶段:把 12 条棱按投影坐标直接光栅化进缓冲,叠加在 Canvas API 本帧绘制的内容之上。
帧循环(display.c)则通过furi_message_queue_get等待输入、调用view_port_update请求重绘,Back 键按下即退出。
为什么没有缓冲访问就做不到:Canvas API 是只写的
这是示例文档反复强调的核心论点:公开的 Canvas API 严格只写(write-only)。
- 不存在
canvas_get_pixel()之类的像素读回接口——通读 canvas.h 可以看到,全部公开接口都是canvas_draw_*、canvas_set_*、canvas_width/height这类写侧或查询接口; - 字体光栅化发生在固件私有的 u8g2 内核中,外部应用无法通过其他途径获得字形位图(glyph bitmap)。
因此「撞上字母并反弹」这件事,必须回读帧缓冲;而读帧缓冲的唯一官方合规途径,就是canvas_get_buffer()。这正是示例文档所宣称的:这个示例离开了canvas_get_buffer()就无法复现。
演进史:全屏渲染的六种历史方案
示例文档按时间顺序记录了真实项目尝试过的全屏渲染方案(FPS 数据为作者在真实硬件上对全屏动画的测量值):
| # | 方案 | 结论 |
|---|---|---|
| 1 | 仅用 Canvas 图元 | 常常不够用:无像素读回,全屏软件渲染按图元逐条调用太慢 |
| 2 | 自有帧缓冲 +canvas_draw_xbm() | 可行、完全官方,但全屏动画位图路径最高约8 FPS(canvas_draw_xbm实现见 canvas.c) |
| 3 | gui_add_framebuffer_callback() | 快(实测67+ FPS)但根本性错误:回调是用于 RPC 屏幕流传输的「发送后捕获」钩子 |
| 4 | gui_direct_draw_acquire()/gui_direct_draw_release() | 合法解决了接管问题,但仍只暴露只写绘制图元,不给原始缓冲 |
| 5 | u8g2_GetBufferPtr(&canvas->fb)hack | 本地编译可过,但发布 FAP 不可能:API 检查拒绝该符号,只有用./fbt在树内构建才可达 |
| 6 | canvas_get_buffer()/canvas_get_buffer_size()公开 API | 本示例:普通 FAP 即可获得全速读写访问,且保留正常的 ViewPort 输入处理与合成 |
为什么回调方案在原理上就是错的
方案 3 的缺陷由 canvas.c 的实现直接坐实——canvas_commit()先u8g2_SendBuffer()把帧发给屏幕,然后才遍历执行提交回调:
void canvas_commit(Canvas* canvas) { furi_check(canvas); u8g2_SendBuffer(&canvas->fb); // Iterate over callbacks canvas_lock(canvas); for M_EACH(p, canvas->canvas_callback_pair, CanvasCallbackPair_t) { p->callback( canvas_get_buffer(canvas), canvas_get_buffer_size(canvas), canvas_get_orientation(canvas), p->context); } canvas_unlock(canvas); }因此:回调里的写入晚了一帧(下一次canvas_reset()会清掉),应用也从未真正持有 GUI 焦点(输入事件不被捕获),ViewPort 与桌面还会反复争夺屏幕。GUI 的合成与重绘流程gui_redraw(gui.c)也印证了这一点:正常路径是canvas_reset → 各层 ViewPort 绘制 → canvas_commit,外部回调没有参与任何一层的合成决策。
历史反例代码(仅供参考,勿用于新代码)
示例文档完整保留了当年 Arduboy 运行时早期构建中的提交回调写法,这里原样继承:
void rt_framebuffer_commit_callback( uint8_t* data, size_t size, CanvasOrientation orientation, void* context) { ArduboyRuntimeState* state = (ArduboyRuntimeState*)context; if(!state || !data) return; if(size < RuntimeBufferSize) return; (void)orientation; const uint8_t* src = state->screen_buffer; bool inverted = __atomic_load_n((bool*)&state->screen_inverted, __ATOMIC_ACQUIRE); for(size_t i = 0; i < RuntimeBufferSize; i++) { if(inverted) { data[i] = src[i]; } else { data[i] = (uint8_t)(src[i] ^ 0xFF); } } if(state->pending_clear) { memset(state->screen_buffer, 0x00, RuntimeBufferSize); state->pending_clear = false; } }它曾在那个时代的 GUI 内部实现上达到 67+ FPS,但在当前固件上无法正确工作:回调运行在帧已发送给显示屏之后、应用收不到输入、ViewPort 合成也会被打乱。示例文档将其记录为「历史上能跑、但逻辑不正确」的反例。
合法的中途方案:gui_direct_draw_acquire / release
方案 4 中的直接绘制接管接口至今仍存在于 gui.h 与 gui.c。其合法之处在于:GUI 暂停 ViewPort 合成、把 Canvas 交给应用,输入则通过RECORD_INPUT_EVENTSpubsub 获取。但它的局限同样明确——它只给出只写绘制图元,而非原始缓冲,因此无法满足像素读回的需求。gui_redraw中if(gui->direct_draw) break;的存在也说明直接绘制模式下 GUI 会跳过正常合成流程,与常规 ViewPort 应用的输入/合成语义不同。
构建与运行
示例作为外部应用(FAP)构建,在仓库根目录执行:
./fbt fap_example_canvas_buffer构建产物可拷贝到 SD 卡以 FAP 形式加载。应用清单(application.fam)中的关键字段:
appid="example_canvas_buffer",显示名[GUI] 3D cube;apptype=FlipperAppType.EXTERNAL,入口example_canvas_buffer_app;requires=["gui"],stack_size=2 * 1024;- 图标为
cube_10px.png,归类在Examples类别下。
运行后立方体会在全屏层(GuiLayerFullscreen,见 display.c)上飞行并反弹,按 Back 键退出;退出时应用会依次释放 ViewPort、关闭 GUI 与通知记录,并把背光恢复为自动模式(sequence_display_backlight_enforce_auto)。
对 FAP 开发者的实践结论
- 需要像素读回时:
canvas_get_buffer()是唯一官方途径,配合canvas_get_buffer_size()使用(后者返回缓冲字节数,示例中即为 1024 字节); - 缓冲只应在绘制回调内触碰:示例源码头部注释明确强调,绘制回调执行期间缓冲内容对当前帧有效(display.c);
- 读回与写回可以在同一帧内共存:先让 Canvas API 绘制文字/图形,再读回做碰撞/命中测试,最后直接写缓冲叠加自定义渲染——这正是本示例展示的标准范式;
- 不要使用提交回调或 u8g2 内部 hack:前者晚一帧且不持有焦点,后者无法通过 FAP 的 API 符号检查,无法以外部应用形态发布。
延伸阅读
- 示例文档:applications/examples/example_canvas_buffer/README.md
- 完整示例源码:applications/examples/example_canvas_buffer/display.c
- 应用清单:applications/examples/example_canvas_buffer/application.fam
- Canvas API 声明:applications/services/gui/canvas.h
- Canvas 实现(含
canvas_commit回调时序):applications/services/gui/canvas.c - GUI 合成与直接绘制接管:applications/services/gui/gui.c、applications/services/gui/gui.h
【免费下载链接】unleashed-firmwareFlipper Zero Unleashed Firmware项目地址: https://gitcode.com/GitHub_Trending/un/unleashed-firmware
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考