Skip to content

feat(m5stack-tab5): tear-free LVGL flushes that keep rotation (back-buffer flush) - #879

Closed
finger563 wants to merge 2 commits into
mainfrom
feat/tab5-tear-free-flush
Closed

finger563 wants to merge 2 commits into
mainfrom
feat/tab5-tear-free-flush

Conversation

@finger563

@finger563 finger563 commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

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_convert window-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 own draw_bitmap cannot 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 with present_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.

  • Fully opt-in, twice over: 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 - and DisplayConfig::flush_to_back_buffer_enabled (default off) turns it on per display; nothing is allocated or installed until both are set. Needs CONFIG_M5STACK_TAB5_DPI_NUM_FBS >= 2 and the PPA; ignored with render_to_frame_buffers_enabled; refused when anything is missing.
  • flush_to_back_buffer(): the mode's flush; queue_block_copy() (DMA2D, PPA fallback) and queue_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.txt requires esp_driver_dma on ESP-IDF >= 6.1 (where the window-copy driver lives); the header detects it with __has_include.
  • present_frame() gained a private cache_sync_rows variant: 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.
  • Example: CONFIG_M5STACK_TAB5_EXAMPLE_FLUSH_TO_BACK_BUFFER (default off) and CONFIG_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.
  • README section.

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:

phase stock (tears) back-buffer, whole-frame draw buffers back-buffer, 128-row strips
small update, 0 deg 993 fps (unthrottled) 57.8 (locked to the 58 Hz panel) 57.8
sweeping bar, 0 deg 350 57.7, no tearing 57.5, no tearing
full-screen redraw, 0 deg 38.2 28.9 28.9
heavy animation, 0 deg 15.7 11.5 11.5
small update, 90 deg 589 57.8 57.8
sweeping bar, 90 deg 260 56.7 56.8
full-screen redraw, 90 deg 16.2 11.6 14.4
heavy animation, 90 deg 9.4 6.4 7.2
free PSRAM after display init 26.36 MB 24.52 MB 27.89 MB

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/example builds 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
  • Tab5 (ST71xx, ESP-IDF v6.1): benchmark above; by eye: no tearing on the sweeping bar, rotation works in the mode, camera preview works
  • CI

🤖 Generated with Claude Code

https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

…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
Copilot AI balanced review requested due to automatic review settings November 3, 2026 19:11

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 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_enabled and 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

Comment on lines +1754 to +1776
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;
}
Comment on lines +248 to +262
/// 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");
Comment on lines +1825 to +1830
// 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);
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown

✅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
@finger563

Copy link
Copy Markdown
Contributor Author

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 feat/tab5-tear-free-flush, the measurements in the description, and two engine facts worth having - the PPA's scale-rotate path moves only about 36 MB/s even at angle 0 (a full frame is about 50 ms), while ESP-IDF 6.1's DMA2D window-copy driver (esp_async_color_convert) moves about 180 MB/s; and the DPI driver's draw_bitmap always copies into the buffer on screen, so a back-buffer scheme must bring its own copy engine. The garbled strips are unexplained; the first place to look would be the rotated PPA block placement when the source area is a strip of the logical (rotated) screen rather than a whole frame.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

@finger563 finger563 closed this Nov 3, 2026
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