Skip to content

feat(m5stack-tab5): tear-free rendering with two DPI frame buffers - #847

Draft
Alaining wants to merge 5 commits into
esp-cpp:feat/tab5-st7121-rotationfrom
Alaining:feat/m5stack-tab5-multi-framebuffer
Draft

Alaining wants to merge 5 commits into
esp-cpp:feat/tab5-st7121-rotationfrom
Alaining:feat/m5stack-tab5-multi-framebuffer

Conversation

@Alaining

@Alaining Alaining commented Oct 16, 2026 •

Copy link
Copy Markdown

Why is this PR needed: with one frame buffer, every Tab5 redraw is copied into the image the panel is sending, so the UI tears. The current code can't fix this:

  • the BSP hard-codes num_fbs = 1;
  • it doesn't expose the frame buffers;
  • espp's Display only renders in PARTIAL mode, copying from its own draw buffers.

Description

Optional tear-free LVGL rendering on the Tab5, for the cost of one extra frame buffer. Off by
default:
the default build behaves exactly as before.

Stacked on #738.

  • CONFIG_M5STACK_TAB5_DPI_NUM_FBS (1-3, default 1).
  • initialize_display({.render_to_frame_buffers = true}) (default false): LVGL draws into the
    frame buffer that is not on screen. The flush switches the panel to it, then waits for the panel's
    frame-done event before LVGL draws again.
  • frame_buffers() for applications with their own renderer.
  • Example: a menuconfig option (default off) turns it on.
  • Not available in this mode: display rotation. On ESP-IDF v6.0.0, DMA2D is off with 2+ buffers.

Motivation and Context

Measured with the m5stack-tab5 example on a Tab5 (ST7123, ESP-IDF v6.0.0), BSP default draw
buffers, with the same LVGL loop in both builds:

main this PR (enabled)
Frame buffers 1 2
Tearing (moving-bar test) yes ✅ none
Free PSRAM 19.0 MB ⚠️ 17.8 MB (-1.2 MB)
Small updates 57 FPS ✅ 56 FPS (same)
Full-screen redraw 10 FPS ✅ 25 FPS
Continuous animation 52 FPS ⚠️ 29 FPS
  • Cost: one extra frame buffer (1.8 MB), partly offset because LVGL's separate draw buffers are no
    longer needed. Net: 1.2 MB.
  • Trade-off: a frame that takes longer than one panel refresh (17 ms) to draw waits for the next
    refresh. Heavy animations therefore run at half the panel rate (29 FPS) instead of tearing.

How has this been tested?

  • The example builds for esp32p4 with ESP-IDF v6.0.0, default and enabled: no new warnings,
    clang-format clean.
  • On a Tab5 (ST7123): tearing check, PSRAM, FPS per phase, touch. Default config behaves the same
    as the base.
  • Our application, rendering into 2 buffers with its own flush, passes its hardware bench.
  • Not tested: the ILI9881 / ST7121 panels, ESP-IDF v6.0.3 / v6.1.

Types of changes

  • New feature (non-breaking change which adds functionality)
  • Software change

🤖 Generated with Claude Code

Alaining and others added 3 commits October 15, 2026 04:10
… the panel's frame buffers

The DPI panel was always created with one frame buffer, private to the BSP,
so LVGL had to render into separate PSRAM draw buffers and every flush copied
the area into the frame buffer the panel scans out.

- Kconfig M5STACK_TAB5_DPI_NUM_FBS (1..3, default 1) sets num_fbs for all
  three panel variants (ILI9881, ST7123, ST7121).
- frame_buffers() returns the panel's frame buffers (cached by
  initialize_lcd()), frame_buffer_size() their size, so an application can
  render straight into them; esp_lcd_panel_draw_bitmap() on a frame buffer
  switches the panel to it instead of copying.
- initialize_display(const DisplayConfig &): with render_to_frame_buffers the
  BSP sets LVGL up in DIRECT mode on two of the frame buffers and its flush
  switches buffers once the frame's last area is rendered. No draw buffers
  and no rotation scratch buffer are allocated. initialize_display(size_t)
  is unchanged.
- The example gains an opt-in menuconfig option for that mode; README and
  example README document it, with the render time measured on a Tab5
  (full screen 75.6 -> 48.6 ms) and the PSRAM it saves.

Defaults are unchanged: one frame buffer, draw buffers, copying flush.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…_flush_ready() itself

The DPI transfer-done interrupt (notify_lvgl_flush_ready) only gives
draw_done_sem_ now; the BSP's own flush() calls lv_display_flush_ready().
An application that installs its own flush callback on the frame buffers
(frame_buffers(), DisplayConfig::render_to_frame_buffers) and relies on
the interrupt to finish the flush hangs on its first frame. Say so in both
doc comments.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…, or use a flush wait callback

Make the custom-flush-callback note precise: LVGL waits for
lv_display_flush_ready() after every flush-callback call (per area), and
lv_display_set_flush_wait_cb() is the alternative that needs no call.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Alaining

Copy link
Copy Markdown
Author

@finger563 could you take a look when you have a moment? This adds multiple DPI frame buffers to the Tab5 BSP and is stacked on #738 (details in my comment there: #738 (comment)). It's a draft until #738 lands; the default config behaves exactly as #738 alone. Thanks!

Alaining and others added 2 commits October 16, 2026 18:36
…uffer swaps

With two DPI frame buffers, esp_lcd_panel_draw_bitmap() on a frame buffer
only queues the switch (cache write-back, cur_fb_index); the DMA takes the
new buffer up when it restarts at the end of the frame it is sending. A
renderer that starts on the previous buffer at once draws into the buffer
the panel is still scanning out, and the frame tears. on_color_trans_done
cannot tell when the switch lands: for a frame buffer the driver calls it
synchronously, before the switch.

- present_frame(frame_buffer, timeout_ms = 100): queues the switch, then
  waits for the DMA's frame-done event (on_frame_buf_complete; on_refresh_done
  before ESP-IDF v5.5.5 / v6.0.3, selected at compile time), after which the
  previous buffer is free. Events from before the switch was queued are
  dropped first. A timeout logs once and returns false instead of stalling.
- set_vsync_enabled() / vsync_enabled() turn the wait off and on.
- The frame-done callback and its semaphore are registered only with
  CONFIG_M5STACK_TAB5_DPI_NUM_FBS >= 2, so the default build is unchanged.
- DisplayConfig::render_to_frame_buffers now shows each frame with
  present_frame(), so the BSP's DIRECT mode does not tear. README updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ing into the frame buffers

The flush already waits for the panel's frame-done event, so the example's fixed
16 ms sleep after every pass would halve the frame rate. The default
configuration keeps the fixed sleep.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Alaining Alaining changed the title feat(m5stack-tab5): multiple DPI frame buffers (rotate at full FPS, optional zero-copy rendering) feat(m5stack-tab5): tear-free rendering with two DPI frame buffers Oct 17, 2026
@finger563

Copy link
Copy Markdown
Contributor

Hardware results on a Tab5 (ST71xx panel, ESP-IDF v6.1)

Measured with a small display benchmark added locally to the m5stack-tab5 example. It runs before the GUI starts, sets LVGL's refresh timer to 1 ms, and runs each phase for 8 s at 0° and again at 90°:

  • small: a counter label changes every frame;
  • full: the whole screen changes colour every frame;
  • bar: a full-height bar sweeps across the screen (tearing check);
  • heavy: 24 gradient boxes move every frame.

DSI underruns were counted from the driver's "underrun happens" log lines. These are single runs. Branches tested: espp main (base), #738, and #847, both PRs with main merged in locally (see the note below).

Phase base 0° base 90° #738 0° #738 90° #847 (1 FB) 0° #847 (1 FB) 90° #847 (2 FBs, direct) 0°
full 25.0 fps 10.1 fps 35.9 fps 13.1 fps 35.9 fps 13.1 fps 57.8 fps
heavy 14.8 fps 7.9 fps 18.2 fps 9.7 fps 18.2 fps 9.7 fps 19.2 fps
bar 181 fps 167 fps 283 fps 224 fps 283 fps 224 fps 57.7 fps
small 996 fps 1000 fps 1000 fps 1000 fps 1000 fps 1000 fps 57.7 fps
DSI underruns 0 0 0 0 0 0 0
PSRAM free at start 24.5 MB 24.5 MB 24.5 MB 28.3 MB

small and bar above about 58 fps only show how fast LVGL renders and flushes a small dirty area; the panel refreshes at about 58 Hz. In 2-FB direct mode each frame waits for a panel refresh, so those phases cap at the panel rate. The 2-FB mode can't rotate, so it has no 90° column. Its 3.7 MB of extra free PSRAM is because the example's full-frame LVGL draw buffers aren't needed in that mode.

Takeaways:

Build note: neither PR builds as-is on ESP-IDF v6.1. v6.1 added esp_lcd_dpi_panel_event_callbacks_t::on_vsync, so the designated initializer in video.cpp (cbs = {.on_color_trans_done = ..., .on_refresh_done = nullptr}) fails with -Werror=missing-field-initializers. main already zero-initializes the struct and assigns the callbacks. Rebasing onto main fixes it; the only conflict is that initializer, and keeping main's form plus the PRs' additions resolves it. The rebase also brings in BSP APIs that downstream users rely on (touch_state(), touch_state_convert(), sdcard_component()).

🤖 Generated with Claude Code

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants