Repository navigation
feat(m5stack-tab5): multi-framebuffer DPI panel + LVGL DIRECT rendering into the frame buffers (supersedes #847) - #877
Conversation
|
✅Static analysis result - no issues found! ✅ |
There was a problem hiding this comment.
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
Afteresp_lcd_dpi_panel_get_frame_buffer()succeeds, the code assumes allkNumDpiFramebuffers… · New This section implies tear-free behavior unconditionally, butpresent_frame()can returnfalse… · New In DIRECT mode the comment says to sleep until LVGL’s next timer, but the code clampsnext_msto… · New If the frame-done event never arrives (e.g., callback not firing on a given IDF/panel combination),… · New
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 viaframe_buffers(). - Add
initialize_display(DisplayConfig)withrender_to_frame_buffers_enabledto enable LVGL DIRECT rendering into the panel frame buffers, pluspresent_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
|
CI note on the first run (41513437084): the The 🤖 Generated with Claude Code |
… 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
ea0adac to
2200535
Compare
There was a problem hiding this comment.
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
2 open issues
4 resolved since last review
Afteresp_lcd_dpi_panel_get_frame_buffer()succeeds, the code assumes allkNumDpiFramebuffers… If the frame-done event never arrives (e.g., callback not firing on a given IDF/panel combination),… In DIRECT mode the comment says to sleep until LVGL’s next timer, but the code clampsnext_msto… This section implies tear-free behavior unconditionally, butpresent_frame()can returnfalse…
Review effort: Lite
…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


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
mainsince (#738, #868, #869, #872) and apply the review findings from #847.Alaining's feature (quoted from #847)
(The flag is now
render_to_frame_buffers_enabled, see below.)Integration with
main(one commit each)Three cherry-picks conflicted (the
Kconfigmenu, and the completion-callback region ofvideo.cpp/m5stack-tab5.hppwhere #872 added the PPA callback andcomplete_transfer()); all three were additive and resolved by keeping both sides. Then: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 callswait_for_transfer_idle()before itsdraw_bitmapand 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).on_refresh_donefrom 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 versionspresent_frame()waits for two events (one refresh of extra latency there); withon_frame_buf_completeone event suffices as before.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.)present_frame()range check + one presenter at a time (findings 4, 6): any pointer inside a frame buffer selects it, as the driver'sdraw_bitmapdoes, and the call is serialized by a mutex because the frame-done semaphore has one token and one waiter per switch.NUM_FBS > 1(software rotation instead). Nothing switches buffers exceptpresent_frame(), so it now moves the PPA target (dpi_framebuffer_, atomic) to the buffer it switched to.render_to_frame_buffers_enabled/direct_render_enabled_per espp's bool convention; theCONFIG_M5STACK_TAB5_DPI_NUM_FBShelp 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
#error(from perf(m5stack-tab5): non-blocking PPA rotation in the LVGL flush #872) refusesCONFIG_LCD_DSI_ISR_CACHE_SAFE/CONFIG_DMA2D_ISR_IRAM_SAFE; unchanged here.Test plan
components/m5stack-tab5/examplebuilds for esp32p4 (ESP-IDF v6.1, component manager off) in three configurations: defaults (NUM_FBS=1),NUM_FBS=2without the example option,NUM_FBS=2+CONFIG_M5STACK_TAB5_EXAMPLE_RENDER_TO_FRAME_BUFFERS=y; no warnings in the touched files, clang-format cleanTab5 (ST71xx, ESP-IDF v6.1), default configuration (
NUM_FBS=1): boots and behaves asmain- no error lines, no draw timeouts, no DSI underruns in a 25 s captureTab5,
NUM_FBS=2+ DIRECT rendering: boots into DIRECT mode, no error lines, nopresent_frame()timeouts, no DSI underruns. The example's display benchmark (a local test aid, not in this PR; 0° phases, 8 s each, same unit as the fix(m5stack-tab5): rotation audit, experimental opt-in ST7121 panel rotation (default off), and DSI underrun fixes #738 / perf(m5stack-tab5): non-blocking PPA rotation in the LVGL flush #872 tables) with LVGL rendering into the frame buffers:main(1 FB, full-frame draw buffers, PPA/DMA2D copy)In DIRECT mode every frame is presented at the next panel refresh (about 58 Hz on this panel), so phases that previously ran faster than the panel now lock to it; a full-screen redraw goes from 38 to 58 fps, and a frame that takes longer than one refresh waits for the next (heavy: 14.4 vs 15.7). The rotation button is a no-op in this mode (DIRECT mode refuses rotation at init).
Rebased head (2200535, on top of refactor(m5stack-tab5): audio task on the shared I2S TX feed policy (follow-up to #869) #875 + fix(m5stack-tab5): only enable charging when a battery is fitted (periodic speaker pop) #876) on the Tab5 by the maintainer, default configuration: rotation, camera (no tearing), audio clean, charging behaving as fix(m5stack-tab5): only enable charging when a battery is fitted (periodic speaker pop) #876 intends
Rebased head, DIRECT configuration: tear-free rendering confirmed; the maintainer also heard the charger pop again on this build with a battery fitted (the pre-rebase DIRECT build with the charger simply left on was silent), so the interaction between fix(m5stack-tab5): only enable charging when a battery is fitted (periodic speaker pop) #876's pause-on-playback and DIRECT mode is an open observation, not a change in this PR
CI
🤖 Generated with Claude Code
https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU