Skip to content

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

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

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
Contributor

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
Contributor 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

@finger563
finger563 deleted the branch esp-cpp:feat/tab5-st7121-rotation November 2, 2026 00:30
@finger563 finger563 closed this Nov 2, 2026
finger563 added a commit that referenced this pull request Nov 3, 2026
…ng into the frame buffers (supersedes #847) (#877)

* feat(m5stack-tab5): configurable DPI frame-buffer count and access to 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>
(cherry picked from commit 0ebbcbd)

* docs(m5stack-tab5): a custom LVGL flush callback must call lv_display_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>
(cherry picked from commit 50a6a93)

* docs(m5stack-tab5): call lv_display_flush_ready() once per flush call, 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>
(cherry picked from commit c08e962)

* feat(m5stack-tab5): vsync-gated present_frame() for tear-free frame-buffer 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>
(cherry picked from commit 2602cdc)

* feat(m5stack-tab5): example sleeps per lv_timer_handler() when rendering 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>
(cherry picked from commit 6df3ece)

* fix(m5stack-tab5): present_frame() takes part in the transfer accounting

The merged flush path leaves its copy / rotation in flight and tracks it
by a token; every panel operation waits for it first. present_frame()
now does too (wait_for_transfer_idle() before its draw_bitmap, refusing
to switch on a wedged transfer), and the DIRECT-mode flush documents
why it never leaves a transfer in flight itself, plus the two costs
inherent to DIRECT mode with two buffers that are left as they are.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

* fix(m5stack-tab5): present_frame() waits for a second frame-done event where it is the bridge VSYNC

Before ESP-IDF v5.5.5 / v6.0.3 the P4 raises the DPI panel's frame-done
event (on_refresh_done) from the DSI bridge's VSYNC interrupt, which is
not synchronized with the DMA re-arm that actually switches buffers: the
DMA fetch of a frame completes a porch before that frame's VSYNC, so the
first VSYNC after a draw may belong to a frame that was re-armed on the
old buffer, and LVGL would start rendering into a buffer still being
scanned out (a porch-wide tearing window every frame). The DMA re-arms
exactly once between consecutive VSYNCs, so on those versions wait for
the second event; on_frame_buf_complete (later versions) is raised right
after the re-arm and one event suffices, as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

* fix(m5stack-tab5): refuse DIRECT mode when LVGL's stride or the rotation would break it

- LVGL's draw-buffer stride (LV_DRAW_BUF_STRIDE_ALIGN) must equal the
  panel's 1440-byte row for in-place rendering into the frame buffers;
  check it at initialize_display() and refuse instead of drawing skewed.
- A non-zero display rotation (the compile-time initial one or one LVGL
  asked for before init) is refused at initialize_display(); LVGL cannot
  rotate in DIRECT mode and the old first-flush warning came too late.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

* fix(m5stack-tab5): present_frame() accepts any pointer inside a frame buffer; one presenter at a time

- The buffer to show is found by range, as the DPI driver's draw_bitmap
  does, rather than requiring the exact start address.
- present_frame() is serialized end to end by a mutex: the frame-done
  semaphore has a single token and a single waiter per switch, so two
  callers (an application task and the LVGL flush, say) could otherwise
  steal each other's event.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

* feat(m5stack-tab5): keep the direct-to-framebuffer PPA rotation with several frame buffers

With CONFIG_M5STACK_TAB5_DPI_NUM_FBS > 1 the PPA path was disabled
(dpi_framebuffer_ left null) because a cached pointer could target a
buffer no longer scanned out, so rotation fell back to the slow software
path and the extra buffers bought nothing without DIRECT mode. Nothing
switches buffers except present_frame(): it now moves dpi_framebuffer_
to the buffer it switched to, so the PPA rotation always targets the
scanned-out buffer whatever the count. The pointer is atomic (written
under panel_op_mutex_, read by flush() on the LVGL task).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

* refactor(m5stack-tab5): espp bool naming for the DIRECT-mode flag; frame-buffer Kconfig help states the cost

- DisplayConfig::render_to_frame_buffers -> render_to_frame_buffers_enabled
  and direct_render_ -> direct_render_enabled_ (espp's convention for
  bool config fields and members), with the README / example updated.
- CONFIG_M5STACK_TAB5_DPI_NUM_FBS help now states the 1.8 MB PSRAM cost
  per extra buffer, that LVGL's DIRECT mode uses at most two, and that
  extra buffers without DIRECT mode buy nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

* fix(m5stack-tab5): run lv_init() before querying LVGL's stride in initialize_display()

lv_draw_buf_width_to_stride() goes through the draw-buffer handlers that
lv_init() installs; before the espp::Display (which calls lv_init()) is
constructed the query returned 0 and the DIRECT-mode stride check refused
every configuration. lv_init() is idempotent, so call it first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

* fix(m5stack-tab5): review follow-ups on the multi-framebuffer PR

- initialize_lcd() validates the frame-buffer pointers the DPI driver
  returns: each of the first kNumDpiFramebuffers must be non-null and
  distinct, else initialization fails with an error naming the index.
- present_frame() counts consecutive frame-done timeouts and after three
  switches its wait off (with a warning naming set_vsync_enabled(true) as
  the re-arm), so a frame-done event that never arrives degrades to the
  non-blocking path instead of holding the UI at one frame per timeout;
  a successful wait resets the count. The README, the header doc and the
  DIRECT-mode flush (which deliberately ignores the return value) describe
  what happens on a timeout: the switch stays queued, LVGL carries on,
  that frame may tear.
- Example: in DIRECT mode the GUI task sleeps exactly what LVGL asked for
  (floored at 1 ms), with LV_NO_TIMER_READY capped at LV_DEF_REFR_PERIOD,
  and the comment says so; next_rotation()'s DIRECT-mode early return is
  an #else branch now (cppcheck: statements after return).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

* fix(m5stack-tab5): present_frame() refuses a single frame buffer; re-arming vsync resets its warning

Review follow-ups on #877:
- present_frame() returned true with one frame buffer (nothing to switch,
  cache write-back only), which hid the tearing it promises to avoid. It
  now returns false with a rate-limited error naming
  CONFIG_M5STACK_TAB5_DPI_NUM_FBS; the DIRECT-mode flush never reaches it
  with one buffer (initialize_display() refuses that configuration).
- set_vsync_enabled(true) cleared the consecutive-timeout count but not the
  once-only timeout warning, so a second outage episode was never logged.
  Re-arming now resets both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

---------

Co-authored-by: Alaining <ee.master.alain@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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