Skip to content

feat(m5stack-tab5): multi-framebuffer DPI panel + LVGL DIRECT rendering into the frame buffers (supersedes #847) - #877

Merged
finger563 merged 14 commits into
mainfrom
feat/tab5-multi-framebuffer
Nov 3, 2026
Merged

finger563 merged 14 commits into
mainfrom
feat/tab5-multi-framebuffer

Conversation

@finger563

@finger563 finger563 commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Supersedes #847 by @Alaining, which GitHub closed automatically when its base branch (#738's) was deleted on merge. Alaining's five commits are cherry-picked unchanged with their authorship; the commits after them integrate the feature with what landed on main since (#738, #868, #869, #872) and apply the review findings from #847.

Alaining's feature (quoted from #847)

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.

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.

  • 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.

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

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.

(The flag is now render_to_frame_buffers_enabled, see below.)

Integration with main (one commit each)

Three cherry-picks conflicted (the Kconfig menu, and the completion-callback region of video.cpp / m5stack-tab5.hpp where #872 added the PPA callback and complete_transfer()); all three were additive and resolved by keeping both sides. Then:

  1. present_frame() takes part in the transfer accounting. main's flush leaves its DMA2D copy / PPA rotation in flight and tracks it by a token; every panel operation waits for it first. present_frame() now calls wait_for_transfer_idle() before its draw_bitmap and refuses to switch on a wedged transfer. The DIRECT-mode flush documents that it never leaves a transfer in flight itself (it releases LVGL right after the switch) and the two costs inherent to DIRECT mode with two buffers that are left as they are: LVGL's CPU copy of the previous frame's dirty areas between the buffers before each render, and the full-frame cache write-back per switch (the sync copy dirties lines outside this frame's areas).
  2. Second frame-done event where the event is the bridge VSYNC (review finding 1). Before ESP-IDF v5.5.5 / v6.0.3 the P4 raises on_refresh_done from the DSI bridge's VSYNC interrupt, which is not synchronized with the DMA re-arm that switches buffers: the first VSYNC after a draw can belong to a frame re-armed on the old buffer, so LVGL would render into a buffer still being scanned out. The DMA re-arms exactly once between consecutive VSYNCs, so on those versions present_frame() waits for two events (one refresh of extra latency there); with on_frame_buf_complete one event suffices as before.
  3. Refusals at initialize_display() (findings 2, 3): LVGL's row stride must equal the panel's 1440-byte row (LV_DRAW_BUF_STRIDE_ALIGN; 1440 is a multiple of neither 64 nor 128, a raised alignment would skew every row), and the display rotation must be 0; both are checked up front instead of discovered at the first flush. (lv_init() is called first so the stride query is answered; it is idempotent.)
  4. present_frame() range check + one presenter at a time (findings 4, 6): any pointer inside a frame buffer selects it, as the driver's draw_bitmap does, and the call is serialized by a mutex because the frame-done semaphore has one token and one waiter per switch.
  5. PPA rotation kept with several frame buffers (finding 5). feat(m5stack-tab5): tear-free rendering with two DPI frame buffers #847 disabled the direct-to-framebuffer PPA rotation for NUM_FBS > 1 (software rotation instead). Nothing switches buffers except present_frame(), so it now moves the PPA target (dpi_framebuffer_, atomic) to the buffer it switched to.
  6. Naming and Kconfig help (findings 7, 8): render_to_frame_buffers_enabled / direct_render_enabled_ per espp's bool convention; the CONFIG_M5STACK_TAB5_DPI_NUM_FBS help 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.

Not changed, by design: the DIRECT-mode dirty-area sync copy and the full-frame cache write-back (documented in flush()).

Caveats

  • Hardware-tested by me on an ST71xx Tab5 with ESP-IDF v6.1 only (see below); Alaining tested an ST7123 on v6.0.0. The ILI9881 and ST7121 panels and ESP-IDF < 6.1 were not run by me; the pre-6.0.3 two-event wait is reasoned from the driver source, not measured.
  • A build-time #error (from perf(m5stack-tab5): non-blocking PPA rotation in the LVGL flush #872) refuses CONFIG_LCD_DSI_ISR_CACHE_SAFE / CONFIG_DMA2D_ISR_IRAM_SAFE; unchanged here.

Test plan

🤖 Generated with Claude Code

https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

Copilot AI balanced review requested due to automatic review settings November 2, 2026 19:49
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

✅Static analysis result - no issues found! ✅

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

4 open issues
What changed in this PR

Adds configurable multi-framebuffer support for the Tab5 DPI panel and an optional LVGL DIRECT-rendering path that draws straight into the panel frame buffers to reduce tearing and improve full-screen redraw performance.

Changes:

  • Make DPI framebuffer count configurable (CONFIG_M5STACK_TAB5_DPI_NUM_FBS) and expose panel frame buffers via frame_buffers().
  • Add initialize_display(DisplayConfig) with render_to_frame_buffers_enabled to enable LVGL DIRECT rendering into the panel frame buffers, plus present_frame() for tear-free buffer switching.
  • Update the example, Kconfig, and docs to demonstrate/describe the new DIRECT mode.
File Description
components/​m5stack-tab5/​src/​video.cpp Implements configurable multi-FB init, frame-done callback + semaphore, present_frame(), and LVGL DIRECT-mode flush path.
components/​m5stack-tab5/​include/​m5stack-tab5.hpp Exposes new DisplayConfig, frame_buffers(), present_frame(), and related state.
components/​m5stack-tab5/​example/​main/​m5stack_tab5_example.cpp Adds menuconfig-controlled initialization for DIRECT rendering into panel frame buffers.
components/​m5stack-tab5/​example/​main/​gui.cpp Adjusts LVGL loop timing and disables rotation when DIRECT mode is enabled.
components/​m5stack-tab5/​example/​main/​Kconfig.projbuild Adds example option to enable DIRECT rendering mode.
components/​m5stack-tab5/​example/​README.md Documents how to enable DIRECT rendering in the example.
components/​m5stack-tab5/​README.md Documents frame buffer access, present_frame(), and DIRECT-mode setup/perf notes.
components/​m5stack-tab5/​Kconfig Adds M5STACK_TAB5_DPI_NUM_FBS with help text explaining trade-offs.

Review effort: Lite

Comment thread components/m5stack-tab5/src/video.cpp Resolved
Comment thread components/m5stack-tab5/README.md Resolved
Comment thread components/m5stack-tab5/src/video.cpp Resolved
@finger563

Copy link
Copy Markdown
Contributor Author

CI note on the first run (41513437084): the Build wheels on macos-latest failure never executed anything. The job's annotation is "The job was not acquired by Runner of type hosted even after multiple attempts", with GitHub's capacity warnings for macOS arm64 runners, and it ended as cancelled after 28 minutes in the queue. The other wheel jobs passed, main's recent wheel runs and every other PR's wheel run that day succeeded, and this branch changes only components/m5stack-tab5/** (ESP-only, not part of the wheel), so it is a runner-capacity flake unrelated to the PR. The push of the review follow-ups triggers a fresh run.

The static_analysis failure was real (cppcheck: statements after return in the example's next_rotation() under the DIRECT-mode configuration) and is fixed in the same push.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

Alaining and others added 13 commits November 2, 2026 15:57
… 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)
…_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)
…, 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)
…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>
(cherry picked from commit 2602cdc)
…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>
(cherry picked from commit 6df3ece)
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
…t 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
…ion 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
… 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
…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
…ame-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
…tialize_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
- 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread components/m5stack-tab5/src/video.cpp Resolved
…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
@finger563
finger563 merged commit 70d1474 into main Nov 3, 2026
178 checks passed
@finger563
finger563 deleted the feat/tab5-multi-framebuffer branch November 3, 2026 15:08
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.

3 participants