Repository navigation
Conversation
…uffer flush) DIRECT mode (#877) is tear-free but cannot rotate. The back-buffer flush keeps LVGL's partial rendering into draw buffers and lands every area in the frame buffer the panel is not scanning out: unrotated areas through the DMA2D engine (ESP-IDF 6.1's esp_async_color_convert window copies, the engine the DPI driver uses itself; PPA at angle 0 on older ESP-IDF), rotated areas through the PPA as before. The DPI driver's draw_bitmap cannot target the back buffer (it copies into the buffer on screen and treats a frame-buffer pointer as a switch request). At the frame's last area the flush brings the back buffer up to date from the buffer on screen - the region it has missed since it was last shown minus what this frame redrew, nothing for a full-screen redraw - then switches the panel to it with present_frame() at the frame boundary (one row of cache write-back: only DMA engines write these buffers). The panel never shows a half-drawn frame whatever the draw-buffer size, so strips are viable again: 128-row draw buffers free 1.5 MB of PSRAM against the stock example's two whole-frame ones. - DisplayConfig::flush_to_back_buffer_enabled (needs NUM_FBS >= 2 and the PPA; ignored with render_to_frame_buffers_enabled). - flush_to_back_buffer(), queue_block_copy() (DMA2D, PPA fallback), queue_ppa_block() (the PPA copy/rotation; the existing rotation path goes through it too), subtract_area(), per-buffer stale regions. - present_frame() private cache_sync_rows variant. - CMake: esp_driver_dma required on ESP-IDF >= 6.1; __has_include guard. - Example: CONFIG_M5STACK_TAB5_EXAMPLE_FLUSH_TO_BACK_BUFFER (default off) and CONFIG_M5STACK_TAB5_EXAMPLE_DRAW_BUFFER_LINES (panel rows; default 1280 = whole frame as before); GUI pacing as in DIRECT mode. - README section with the measured numbers (Tab5 ST71xx, ESP-IDF v6.1): small updates and the sweeping bar locked to the panel's 58 Hz with no tearing, full-screen redraw 28.9 fps (two panel periods; stock 38.2), 90-degree full redraw 14.4 with strips (stock 16.2). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
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
frame_dirty_area_is updated before confirming the area copy is actually queued. If… · New The comment describes that the “region this frame changed is copied into the new back buffer …… · New This log message drops the underlying ESP-IDF error reason, which makes diagnosing PPA issues… · New The100timeout is a magic number here. Introduce a named constant (or reuse an existing timeout… · New
What changed in this PR
Adds a new “back-buffer flush” LVGL rendering mode for M5Stack Tab5 to achieve tear-free updates while preserving display rotation (unlike DIRECT mode).
Changes:
- Introduces
DisplayConfig::flush_to_back_buffer_enabledand implements a back-buffer flush path with stale-region bookkeeping and optional DMA2D window-copy acceleration. - Extends
present_frame()with a private variant to limit cache sync to a configurable number of rows for DMA-written framebuffers. - Updates the example, Kconfig, README, and component requirements to support/describe the new mode.
| File | Description |
|---|---|
| components/m5stack-tab5/src/video.cpp | Implements back-buffer flush logic, DMA2D/PPA copy queuing, and present_frame() cache-sync refinement. |
| components/m5stack-tab5/include/m5stack-tab5.hpp | Exposes config flag, adds bookkeeping members, and declares new helper APIs/callbacks. |
| components/m5stack-tab5/example/main/m5stack_tab5_example.cpp | Adds example configuration for draw-buffer strip sizing and optional back-buffer flushing. |
| components/m5stack-tab5/example/main/gui.cpp | Adjusts GUI task pacing when flush mode already waits on frame-done events. |
| components/m5stack-tab5/example/main/Kconfig.projbuild | Adds menuconfig options for back-buffer flush and draw-buffer height. |
| components/m5stack-tab5/README.md | Documents the new tear-free back-buffer flush mode and benchmark results. |
| components/m5stack-tab5/CMakeLists.txt | Adds conditional dependency on esp_driver_dma for ESP-IDF ≥ 6.1. |
Review effort: Lite
| const bool last = lv_display_flush_is_last(disp); | ||
| join_area(frame_dirty_area_, frame_dirty_valid_, dst); | ||
| { | ||
| std::lock_guard<std::mutex> lock(panel_op_mutex_); | ||
| if (!wait_for_transfer_idle()) { | ||
| lv_display_flush_ready(disp); // wedged: drop this area, keep LVGL alive | ||
| return; | ||
| } | ||
| // An area that is not the frame's last releases LVGL from the PPA's | ||
| // completion ISR, like the plain flush. The last one is waited for below | ||
| // (the switch must not be queued before it has landed) and releases LVGL | ||
| // after the switch. | ||
| lvgl_flush_disp_ = disp; | ||
| lvgl_flush_pending_.store(!last, std::memory_order_release); | ||
| const bool queued = | ||
| angle == PPA_SRM_ROTATION_ANGLE_0 | ||
| ? queue_block_copy(px_map, ww, hh, 0, 0, ww, hh, back_fb, dst.x1, dst.y1) | ||
| : queue_ppa_block(px_map, ww, hh, 0, 0, ww, hh, back_fb, dst.x1, dst.y1, angle); | ||
| if (!queued) { | ||
| lvgl_flush_pending_.store(false, std::memory_order_relaxed); | ||
| lv_display_flush_ready(disp); | ||
| return; | ||
| } |
| /// Every flushed area is copied - rotated when needed - by the PPA into | ||
| /// the back frame buffer; when LVGL's last area of a frame has landed, | ||
| /// present_frame() switches the panel to that buffer at the frame | ||
| /// boundary, and the region this frame changed is copied into the new | ||
| /// back buffer (by the PPA, overlapping LVGL's next render) so it is | ||
| /// current for the next frame. Partial frames are never on screen, so | ||
| /// the draw buffers can be strips (pixel_buffer_size) instead of whole | ||
| /// frames: a full-screen redraw is assembled off screen and shown at | ||
| /// once. Needs initialize_lcd() first, CONFIG_M5STACK_TAB5_DPI_NUM_FBS >= 2 | ||
| /// and a working PPA; ignored with render_to_frame_buffers_enabled. | ||
| /// \note Costs: the flush of a frame's last area waits for the panel's | ||
| /// frame-done event (one refresh, like DIRECT mode), and the PPA | ||
| /// does an extra copy of the changed region per frame. Pixels | ||
| /// written with write_lcd_lines() land in the buffer on screen and | ||
| /// are not carried over to the next frame: draw through LVGL. |
| } | ||
| logger_.warn_rate_limited("PPA rotation failed ({}); rotating in software", | ||
| esp_err_to_name(ppa_err)); | ||
| logger_.warn_rate_limited("PPA rotation failed; rotating in software"); |
| // Switch at the frame boundary. The return value is deliberately not acted | ||
| // on: on a timeout the switch is still queued and LVGL carries on (that | ||
| // frame may tear); after a few consecutive timeouts present_frame() | ||
| // disables its wait itself. One row of cache write-back: only DMA engines | ||
| // write these buffers. | ||
| present_frame(back_fb, 100, 1); |
|
✅Static analysis result - no issues found! ✅ |
…_M5STACK_TAB5_BACK_BUFFER_FLUSH Default off: without it nothing of the mode is built (no members, no DMA2D driver include, the flush path is what it was) and initialize_display() refuses DisplayConfig::flush_to_back_buffer_enabled with an error naming the option. With it on, the per-display flag still defaults to off and nothing is allocated or installed until it is set. The example's menuconfig option depends on it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
|
Closing for now. On hardware the rotated output in this mode shows garbled strips (the benchmark only measured frame rates and never checked the rotated image), and the design's ceiling is inherent: tear-free means waiting for frame-done, so full-screen work is capped at two panel periods, which DIRECT mode (#877) already offers for apps that do not rotate. The memory win needs strip draw buffers and is 1.5 MB, against real added complexity (DMA2D plus PPA, per-buffer stale-region bookkeeping, an ESP-IDF-version-dependent component requirement). Kept for reference: the branch 🤖 Generated with Claude Code |



Summary
Follow-up to #877. DIRECT mode is tear-free but cannot rotate; this adds the other way to be tear-free, which keeps rotation:
initialize_display({.flush_to_back_buffer_enabled = true}).LVGL keeps its normal partial rendering into draw buffers. Every flushed area is copied into the frame buffer the panel is not scanning out: unrotated areas by the DMA2D engine (ESP-IDF 6.1's
esp_async_color_convertwindow-copy driver, the same engine the DPI driver uses for its own copies; the PPA at angle 0 on older ESP-IDF), rotated areas by the PPA as today. The DPI driver's owndraw_bitmapcannot target the back buffer: it always copies into the one on screen, and treats a frame-buffer pointer as a switch request. When LVGL's last area of a frame has landed, the flush brings the back buffer up to date - it copies, from the buffer on screen, the region the back buffer has missed since it was last shown MINUS what this frame just redrew (nothing for a full-screen redraw, the previous frame's small region for a small update) - then switches the panel to it withpresent_frame()at the frame boundary. With three buffers each one is brought up to date the same way when it becomes the back buffer.The panel never shows a half-drawn frame, whatever the draw-buffer size. That is what makes small draw buffers viable: with one frame buffer a full-screen redraw flushes strip by strip into the buffer on screen (a visible progressive wipe), which is why the example used two whole-frame draw buffers; with the back-buffer flush the strips are assembled off screen and shown at once.
CONFIG_M5STACK_TAB5_BACK_BUFFER_FLUSH(default off) compiles the mode in at all - without it nothing of it is built and the flush path is byte-for-byte what it was - andDisplayConfig::flush_to_back_buffer_enabled(default off) turns it on per display; nothing is allocated or installed until both are set. NeedsCONFIG_M5STACK_TAB5_DPI_NUM_FBS >= 2and the PPA; ignored withrender_to_frame_buffers_enabled; refused when anything is missing.flush_to_back_buffer(): the mode's flush;queue_block_copy()(DMA2D, PPA fallback) andqueue_ppa_block()(the PPA block copy/rotation, which the existing rotation path now also goes through, same parameters); per-buffer stale-region bookkeeping on the LVGL task,subtract_area()for the remainder.components/m5stack-tab5/CMakeLists.txtrequiresesp_driver_dmaon ESP-IDF >= 6.1 (where the window-copy driver lives); the header detects it with__has_include.present_frame()gained a privatecache_sync_rowsvariant: the driver writes back the cache for the rows of the switching draw, and these frame buffers are only ever written by DMA engines, so the back-buffer flush syncs one row instead of the whole 1.8 MB.CONFIG_M5STACK_TAB5_EXAMPLE_FLUSH_TO_BACK_BUFFER(default off) andCONFIG_M5STACK_TAB5_EXAMPLE_DRAW_BUFFER_LINES(panel rows of 720 pixels; default 1280 = the previous whole-frame buffers; 128 is the BSP's usual strip and the configuration measured below); the GUI task paces itself like DIRECT mode when the flush waits for frame-done. Default build unchanged.Costs: the flush of a frame's last area waits for the frame-done event (one refresh, like DIRECT mode), and the sync copies whatever the previous frame changed that this frame did not (a first version synced the whole changed region after the switch, through the PPA: full-screen redraws fell to 8 fps; the remainder-only sync and the DMA2D copies brought them to 29).
write_lcd_lines()draws land in the buffer on screen and are not carried over to the next frame (documented).Measurements
Measured with the example's display benchmark (a local test aid, not in the
repository: four phases of 8 s at 0 and at 90 degrees, same unit as the earlier
tables) on a Tab5 (ST71xx, ESP-IDF v6.1). "Stock" is one frame buffer with two
whole-frame draw buffers (the example's default); the back-buffer flush uses two
frame buffers, with whole-frame or 128-row (184 KB) draw buffers:
A full-screen redraw at 0 degrees takes two panel periods (34.5 ms): render plus
copy is about 26 ms, which misses one frame-done event and waits for the next -
the price of never showing a half-drawn frame, as in DIRECT mode. At 90 degrees
the strips are faster than whole-frame draw buffers because the PPA rotation of
one strip overlaps the rendering of the next. The 128-row configuration frees
1.5 MB of PSRAM against the stock example (two frame buffers plus two 184 KB
draw buffers, 4.1 MB, against one frame buffer plus two whole-frame draw
buffers, 5.5 MB).
Test plan
components/m5stack-tab5/examplebuilds for esp32p4 (ESP-IDF v6.1, component manager off): defaults (1 FB, whole-frame draw buffers),NUM_FBS=2+ back-buffer flush with whole-frame and with 128-row draw buffers; no warnings in the touched files, clang-format clean🤖 Generated with Claude Code
https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU