From 0ebbcbd7c200356d009849f6b1c9d2ef6fe713eb Mon Sep 17 00:00:00 2001 From: Alaining Date: Wed, 14 Oct 2026 20:57:06 -0700 Subject: [PATCH 1/5] 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) --- components/m5stack-tab5/Kconfig | 13 ++ components/m5stack-tab5/README.md | 34 ++++ components/m5stack-tab5/example/README.md | 10 ++ .../example/main/Kconfig.projbuild | 19 +++ components/m5stack-tab5/example/main/gui.cpp | 6 + .../example/main/m5stack_tab5_example.cpp | 11 +- .../m5stack-tab5/include/m5stack-tab5.hpp | 60 +++++++ components/m5stack-tab5/src/video.cpp | 161 +++++++++++++----- 8 files changed, 274 insertions(+), 40 deletions(-) create mode 100644 components/m5stack-tab5/example/main/Kconfig.projbuild diff --git a/components/m5stack-tab5/Kconfig b/components/m5stack-tab5/Kconfig index fe8beb41a4..14bf3fc1cc 100644 --- a/components/m5stack-tab5/Kconfig +++ b/components/m5stack-tab5/Kconfig @@ -41,4 +41,17 @@ menu "M5Stack Tab5 Configuration" (the default) to keep the known-good PPA rotation for all orientations; enable only to experiment on your unit. + config M5STACK_TAB5_DPI_NUM_FBS + int "Number of DPI frame buffers" + default 1 + range 1 3 + help + How many frame buffers the MIPI-DSI DPI panel driver allocates in PSRAM + (esp_lcd_dpi_panel_config_t::num_fbs), 720 x 1280 RGB565 = 1.8 MB each. + With 1 (the default) LVGL renders into separate draw buffers and the BSP + copies each flushed area into the one frame buffer the panel scans out. + With 2 or 3 the application can render straight into the frame buffers + and switch whole frames instead of copying them: see + M5StackTab5::frame_buffers() and DisplayConfig::render_to_frame_buffers. + endmenu diff --git a/components/m5stack-tab5/README.md b/components/m5stack-tab5/README.md index 1be89d579a..05df04dbce 100644 --- a/components/m5stack-tab5/README.md +++ b/components/m5stack-tab5/README.md @@ -96,6 +96,40 @@ auto controller_type = tab5.get_display_controller(); const char* controller_name = tab5.get_display_controller_name(); ``` +## Rendering into the panel's frame buffers + +The DPI panel scans a frame buffer in PSRAM out to the display continuously. By +default the BSP creates one (`CONFIG_M5STACK_TAB5_DPI_NUM_FBS` = 1): LVGL +renders into separate draw buffers and every flush copies the area into that +frame buffer. With 2 (or 3) frame buffers an application can render straight +into them and show a finished frame by switching the panel to its buffer, with +no copy: + +- `frame_buffers()` returns the panel's frame buffers (after + `initialize_lcd()`), each `frame_buffer_size()` bytes, cache-line aligned. + `esp_lcd_panel_draw_bitmap()` with a pointer inside one of them makes the + panel switch to that buffer instead of copying it. +- `initialize_display({.render_to_frame_buffers = true})` sets LVGL up in DIRECT + mode on two of them, with the BSP's flush switching buffers at the end of each + frame. No draw buffers and no rotation scratch buffer are allocated. The + display rotation must stay at 0 (LVGL cannot rotate in DIRECT mode), and the + buffer switch is not synchronized to the panel refresh, so a frame can tear. + +```cpp +// menuconfig: M5Stack Tab5 Configuration -> Number of DPI frame buffers = 2 +auto &tab5 = espp::M5StackTab5::get(); +tab5.initialize_lcd(); +tab5.initialize_display({.render_to_frame_buffers = true}); +``` + +Measured on a Tab5 in an LVGL application (full-screen redraw, 720 x 1280 +RGB565): 75.6 ms with full-frame double draw buffers copied into one frame buffer, +48.6 ms rendering into two frame buffers. Memory: two frame buffers take 3.7 MB of +PSRAM, against 5.5 MB for one frame buffer plus two full-frame draw buffers (and +the 1.8 MB rotation scratch buffer is not needed either). The frame buffers are +cache-line aligned, so they also satisfy a raised `LV_DRAW_BUF_ALIGN` (e.g. 128 +for the PPA draw unit), which a plain `heap_caps_malloc()` draw buffer may not. + ## Example The [example](./example) shows how to use the `espp::M5StackTab5` hardware abstraction component to initialize and use various subsystems of the Tab5. diff --git a/components/m5stack-tab5/example/README.md b/components/m5stack-tab5/example/README.md index a9a164f411..469c0193c1 100644 --- a/components/m5stack-tab5/example/README.md +++ b/components/m5stack-tab5/example/README.md @@ -63,6 +63,16 @@ those tasks than the defaults assume: your own project on this example (or reproduce its functionality), keep this override in your `sdkconfig` / `sdkconfig.defaults`. +### Rendering into the panel's frame buffers + +Set **M5Stack Tab5 Configuration -> Number of DPI frame buffers** to 2 and +enable **M5Stack Tab5 Example Configuration -> Render LVGL straight into the +panel's frame buffers** to run the example with +`initialize_display({.render_to_frame_buffers = true})`: LVGL renders in DIRECT +mode into the panel's frame buffers instead of into two full-frame PSRAM draw +buffers that are copied on every flush. The rotate button does nothing in this +mode (LVGL cannot rotate in DIRECT mode). See the component README. + ## Hardware Requirements - M5Stack Tab5 development board diff --git a/components/m5stack-tab5/example/main/Kconfig.projbuild b/components/m5stack-tab5/example/main/Kconfig.projbuild new file mode 100644 index 0000000000..61bf2b3da5 --- /dev/null +++ b/components/m5stack-tab5/example/main/Kconfig.projbuild @@ -0,0 +1,19 @@ +menu "M5Stack Tab5 Example Configuration" + + config M5STACK_TAB5_EXAMPLE_RENDER_TO_FRAME_BUFFERS + bool "Render LVGL straight into the panel's frame buffers" + depends on M5STACK_TAB5_DPI_NUM_FBS > 1 + default n + help + Initialize the display with + DisplayConfig::render_to_frame_buffers: LVGL renders in DIRECT + mode straight into the DPI panel's frame buffers and a finished + frame is shown by switching the panel to it, instead of copying + LVGL's draw buffers into the one frame buffer the panel scans + out. Saves the two draw buffers and the rotation scratch buffer + (PSRAM) and the copy on every flush. Needs + CONFIG_M5STACK_TAB5_DPI_NUM_FBS >= 2 (M5Stack Tab5 Configuration). + The rotate button does nothing in this mode: LVGL cannot rotate in + DIRECT mode. + +endmenu diff --git a/components/m5stack-tab5/example/main/gui.cpp b/components/m5stack-tab5/example/main/gui.cpp index 6cf1fe2036..c43ec80aa6 100644 --- a/components/m5stack-tab5/example/main/gui.cpp +++ b/components/m5stack-tab5/example/main/gui.cpp @@ -541,6 +541,12 @@ bool Gui::draw_page_active() { } void Gui::next_rotation() { +#if CONFIG_M5STACK_TAB5_EXAMPLE_RENDER_TO_FRAME_BUFFERS + // LVGL renders into the panel's frame buffers in DIRECT mode, which cannot + // rotate. + logger_.warn("Rotation is not available while rendering into the frame buffers"); + return; +#endif std::lock_guard lock(mutex_); clear_circles_impl(); auto rotation = lv_display_get_rotation(lv_display_get_default()); diff --git a/components/m5stack-tab5/example/main/m5stack_tab5_example.cpp b/components/m5stack-tab5/example/main/m5stack_tab5_example.cpp index 4d2f1bb852..0a3973900f 100644 --- a/components/m5stack-tab5/example/main/m5stack_tab5_example.cpp +++ b/components/m5stack-tab5/example/main/m5stack_tab5_example.cpp @@ -88,12 +88,21 @@ extern "C" void app_main(void) { // repaints at once rather than wiping progressively. Partial updates still // only render/flush their dirty area, so the larger buffer costs nothing // there (just PSRAM). - logger.info("Initializing display..."); + logger.info("Initializing display ({} DPI frame buffers)...", tab5.frame_buffers().size()); +#if CONFIG_M5STACK_TAB5_EXAMPLE_RENDER_TO_FRAME_BUFFERS + // LVGL renders straight into the panel's frame buffers (DIRECT mode): no + // draw buffers, no rotation scratch buffer, and no copy per flush. + if (!tab5.initialize_display({.render_to_frame_buffers = true})) { + logger.error("Failed to initialize display!"); + return; + } +#else auto pixel_buffer_size = tab5.display_width() * tab5.display_height(); if (!tab5.initialize_display(pixel_buffer_size)) { logger.error("Failed to initialize display!"); return; } +#endif // make the filter we'll use for the IMU to compute the orientation static constexpr float angle_noise = 0.001f; diff --git a/components/m5stack-tab5/include/m5stack-tab5.hpp b/components/m5stack-tab5/include/m5stack-tab5.hpp index 9aa3a247e8..f48f7b5891 100644 --- a/components/m5stack-tab5/include/m5stack-tab5.hpp +++ b/components/m5stack-tab5/include/m5stack-tab5.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -205,11 +206,35 @@ class M5StackTab5 : public BaseComponent { /// \return true if the LCD was successfully initialized, false otherwise bool initialize_lcd(); + /// How initialize_display() sets LVGL up + struct DisplayConfig { + /// Size of each of LVGL's two draw buffers, in pixels. They are allocated + /// in PSRAM, as is the rotation scratch buffer of the same size. Ignored + /// with render_to_frame_buffers. + size_t pixel_buffer_size{1280 * 720 / 10}; + /// Let LVGL render in DIRECT mode straight into the DPI panel's frame + /// buffers (see frame_buffers()) instead of into separate draw buffers. + /// No draw buffers and no rotation scratch buffer are allocated, and a + /// finished frame is shown by switching the panel to its frame buffer + /// instead of copying it. Needs initialize_lcd() first and + /// CONFIG_M5STACK_TAB5_DPI_NUM_FBS >= 2. + /// \note The display rotation must stay at 0: LVGL cannot rotate in DIRECT + /// mode. Pixels written with write_lcd_lines() are lost at the next + /// frame switch. The switch is not synchronized to the panel's + /// refresh, so a frame can tear. + bool render_to_frame_buffers{false}; + }; + /// Initialize the LVGL display /// \param pixel_buffer_size The size of the pixel buffer /// \return true if the display was successfully initialized, false otherwise bool initialize_display(size_t pixel_buffer_size = 1280 * 720 / 10); + /// Initialize the LVGL display + /// \param config How to set LVGL up (draw buffers, or the panel's frame buffers) + /// \return true if the display was successfully initialized, false otherwise + bool initialize_display(const DisplayConfig &config); + /// Initialize the GT911 multi-touch controller /// \param callback The touchpad callback /// \return true if the touchpad was successfully initialized, false otherwise @@ -276,6 +301,34 @@ class M5StackTab5 : public BaseComponent { /// \return A shared pointer to the display driver const std::shared_ptr &display_driver() const { return display_driver_; } + /// Most frame buffers the DPI panel driver supports (ESP-IDF's limit) + static constexpr size_t max_frame_buffers = 3; + + /// Get the DPI panel's frame buffers + /// \return The CONFIG_M5STACK_TAB5_DPI_NUM_FBS frame buffers initialize_lcd() + /// got from the panel driver, each frame_buffer_size() bytes + /// (display_width() x display_height() RGB565 pixels) in PSRAM and + /// cache-line aligned; empty until initialize_lcd() succeeded + /// \note The panel scans one of them out all the time. Passing a pointer + /// inside one of them to esp_lcd_panel_draw_bitmap() makes the panel + /// switch to that buffer (after a cache write-back) instead of copying + /// it. This lets an application render (e.g. LVGL in DIRECT mode) + /// straight into the frame buffers; see also + /// DisplayConfig::render_to_frame_buffers. + std::span frame_buffers() const { + // frame_buffers_ is written before initialize_lcd() opens the gate + if (!lcd_initialized_.load(std::memory_order_acquire)) { + return {}; + } + return {frame_buffers_.data(), num_frame_buffers_}; + } + + /// Get the size of one DPI frame buffer + /// \return The size of one frame buffer in bytes + static constexpr size_t frame_buffer_size() { + return display_width_ * display_height_ * sizeof(Pixel); + } + /// Write lines to the LCD /// \param xs The x start coordinate /// \param ys The y start coordinate @@ -987,6 +1040,13 @@ class M5StackTab5 : public BaseComponent { void *dpi_framebuffer_{nullptr}; size_t dpi_framebuffer_bytes_{0}; + // The DPI panel's frame buffers (frame_buffers()), filled by initialize_lcd() + std::array frame_buffers_{}; + size_t num_frame_buffers_{0}; + // initialize_display() set LVGL up in DIRECT mode on the frame buffers + // (DisplayConfig::render_to_frame_buffers) + bool direct_render_{false}; + // Display controller detection DisplayController display_controller_{DisplayController::UNKNOWN}; diff --git a/components/m5stack-tab5/src/video.cpp b/components/m5stack-tab5/src/video.cpp index afdb5d47b1..6b63258d86 100644 --- a/components/m5stack-tab5/src/video.cpp +++ b/components/m5stack-tab5/src/video.cpp @@ -57,18 +57,12 @@ static_assert(LV_COLOR_DEPTH == 16 && sizeof(M5StackTab5::Pixel) == sizeof(uint1 "The Tab5 video path (DPI panel config, PPA rotation color modes, buffer " "sizing) is RGB565-only; configure LVGL with LV_COLOR_DEPTH 16"); -// Number of framebuffers the DPI panel is created with (esp_lcd_dpi_panel_config_t::num_fbs, -// used for every controller variant below). The direct-to-framebuffer PPA -// rotation in flush() caches THE single framebuffer pointer at init and writes -// into it unconditionally; with more than one framebuffer the driver flips -// which buffer is scanned out, and rotating into a fixed one would -// intermittently update a non-visible buffer. That optimization is therefore -// only implemented for exactly one framebuffer: raising this value requires -// first teaching the direct-to-framebuffer path to track the active -// framebuffer (or removing it in favor of the always-correct scratch-buffer -// path), and a static_assert in initialize_lcd() enforces that at compile -// time rather than letting the stale-pointer bug ship. -static constexpr uint8_t kNumDpiFramebuffers = 1; +// Number of frame buffers the DPI panel is created with (num_fbs, the same for +// every controller variant below). More than one lets an application render +// straight into them and switch whole frames (see frame_buffers()). +static constexpr uint8_t kNumDpiFramebuffers = CONFIG_M5STACK_TAB5_DPI_NUM_FBS; +static_assert(kNumDpiFramebuffers >= 1 && kNumDpiFramebuffers <= M5StackTab5::max_frame_buffers, + "CONFIG_M5STACK_TAB5_DPI_NUM_FBS must be 1..3 (the DPI panel driver's limit)"); // Alignment the PPA requires for its output buffer in external (PSRAM) memory: // both the buffer pointer and the buffer size must be multiples of the data @@ -393,6 +387,21 @@ bool M5StackTab5::initialize_lcd() { } } + // Keep the panel's frame buffers for frame_buffers() and for + // DisplayConfig::render_to_frame_buffers. (The driver reads only the first + // kNumDpiFramebuffers of the pointers passed.) + num_frame_buffers_ = 0; + frame_buffers_ = {}; + ret = esp_lcd_dpi_panel_get_frame_buffer(lcd_handles_.panel, kNumDpiFramebuffers, + &frame_buffers_[0], &frame_buffers_[1], + &frame_buffers_[2]); + if (ret != ESP_OK) { + logger_.error("Failed to get the DPI panel frame buffers: {}", esp_err_to_name(ret)); + frame_buffers_ = {}; + return false; + } + num_frame_buffers_ = kNumDpiFramebuffers; + espp::display_drivers::Config display_config{ .panel_io = nullptr, .write_command = std::bind_front(&M5StackTab5::dsi_write_command, this), @@ -507,7 +516,21 @@ bool M5StackTab5::initialize_lcd() { // on which the underrun streaking this call addresses was reproduced, and // M5Stack's own Tab5 demo firmware ships with DMA2D enabled on production // units that are predominantly ST71xx. - if (display_controller_ != DisplayController::ILI9881) { + // + // ESP-IDF v6.0.0 only: its DPI draw_bitmap() checks the DMA2D hook before the + // "pointer is inside a frame buffer" test (fixed in v6.0.1), so with DMA2D on + // and 2+ frame buffers a frame-buffer switch would become a full-frame copy + // into the buffer on screen. Keep the CPU path there. +#if ESP_IDF_VERSION == ESP_IDF_VERSION_VAL(6, 0, 0) + constexpr bool dma2d_breaks_fb_switch = kNumDpiFramebuffers > 1; +#else + constexpr bool dma2d_breaks_fb_switch = false; +#endif + if constexpr (dma2d_breaks_fb_switch) { + logger_.info("ESP-IDF v6.0.0 with {} frame buffers: keeping CPU draw_bitmap copies (DMA2D " + "would turn a frame-buffer switch into a copy)", + kNumDpiFramebuffers); + } else if (display_controller_ != DisplayController::ILI9881) { ret = esp_lcd_dpi_panel_enable_dma2d(lcd_handles_.panel); if (ret != ESP_OK) { // Not fatal: draw_bitmap falls back to the (slower) CPU copy. @@ -527,19 +550,12 @@ bool M5StackTab5::initialize_lcd() { // This optimization is only valid with a single DPI framebuffer: caching one // fixed pointer assumes the panel scans that same buffer forever. With // num_fbs > 1 the driver flips between buffers and the PPA could rotate into - // one that is not being scanned out. Multi-framebuffer operation is - // intentionally unsupported until this path learns to track the active - // framebuffer, and the static_assert below turns raising - // kNumDpiFramebuffers without doing that work into a compile-time error - // instead of an intermittent visual glitch. - static_assert(kNumDpiFramebuffers == 1, - "The direct-to-framebuffer PPA rotation caches a single framebuffer pointer; " - "with multiple DPI framebuffers it must track the active one instead"); - { - void *fb = nullptr; - if (esp_lcd_dpi_panel_get_frame_buffer(lcd_handles_.panel, kNumDpiFramebuffers, &fb) == - ESP_OK && - fb != nullptr) { + // one that is not being scanned out, so dpi_framebuffer_ stays null and + // flush() takes its scratch-buffer path: draw_bitmap() then copies into + // whichever buffer is on screen, which is correct for any count. + if constexpr (kNumDpiFramebuffers == 1) { + void *fb = frame_buffers_[0]; + if (fb != nullptr) { const size_t fb_bytes = static_cast(display_width_) * display_height_ * sizeof(Pixel); // The PPA requires its output buffer pointer and size to be aligned to // the data cache line (see kPpaOutBufferAlignment). The esp_lcd driver @@ -635,22 +651,66 @@ static size_t third_buffer_bytes = 0; static ppa_client_handle_t g_ppa_client = nullptr; bool M5StackTab5::initialize_display(size_t pixel_buffer_size) { + return initialize_display(DisplayConfig{.pixel_buffer_size = pixel_buffer_size}); +} + +bool M5StackTab5::initialize_display(const DisplayConfig &config) { + if (display_ && direct_render_ != config.render_to_frame_buffers) { + logger_.error("The LVGL display is already initialized {} the frame buffers", + direct_render_ ? "on" : "without"); + return false; + } + const Display::LvglConfig lvgl_config{ + .width = display_width_, + .height = display_height_, + .flush_callback = std::bind_front(&M5StackTab5::flush, this), + // Forward LVGL rotation changes to the display driver so panels that can + // rotate in hardware (ST7121) do so via MADCTL instead of the PPA / + // software rotation in flush(). + .rotation_callback = std::bind_front(&M5StackTab5::on_display_rotation, this), + .rotation = rotation}; + const Display::OledConfig oled_config{ + .set_brightness_callback = + [this](float brightness) { this->brightness(brightness * 100.0f); }, + .get_brightness_callback = [this]() { return this->brightness() / 100.0f; }}; + + if (config.render_to_frame_buffers) { + if (num_frame_buffers_ < 2) { + logger_.error("render_to_frame_buffers needs initialize_lcd() first and " + "CONFIG_M5STACK_TAB5_DPI_NUM_FBS >= 2 ({} frame buffers)", + num_frame_buffers_); + return false; + } + logger_.info("Initializing LVGL display in DIRECT mode on the DPI frame buffers"); + if (!display_) { + // LVGL renders its first frame into vram0. The panel scans frame buffer 0 + // out from the start, so hand LVGL buffer 1 first: the first frame is + // drawn off screen like every later one. (With 3 frame buffers LVGL uses + // buffers 1 and 0.) + display_ = std::make_shared>( + lvgl_config, oled_config, + Display::StaticMemoryConfig{ + .pixel_buffer_size = display_width_ * display_height_, + .vram0 = static_cast(frame_buffers_[1]), + .vram1 = static_cast(frame_buffers_[0]), + }, + Logger::Verbosity::WARN); + // espp::Display sets the buffers up for PARTIAL rendering; render whole + // frames in place instead (nothing has been rendered yet). + lv_display_set_render_mode(display_->get_lvgl_display(), LV_DISPLAY_RENDER_MODE_DIRECT); + direct_render_ = true; + } + // No PPA client and no rotation scratch buffer: flush() does not rotate in + // DIRECT mode, and LVGL's buffers are the frame buffers themselves. + logger_.info("LVGL display initialized"); + return true; + } + + const size_t pixel_buffer_size = config.pixel_buffer_size; logger_.info("Initializing LVGL display with pixel buffer size: {} pixels", pixel_buffer_size); if (!display_) { display_ = std::make_shared>( - Display::LvglConfig{.width = display_width_, - .height = display_height_, - .flush_callback = std::bind_front(&M5StackTab5::flush, this), - // Forward LVGL rotation changes to the display driver so - // panels that can rotate in hardware (ST7121) do so via - // MADCTL instead of the PPA / software rotation in flush(). - .rotation_callback = - std::bind_front(&M5StackTab5::on_display_rotation, this), - .rotation = rotation}, - Display::OledConfig{ - .set_brightness_callback = - [this](float brightness) { this->brightness(brightness * 100.0f); }, - .get_brightness_callback = [this]() { return this->brightness() / 100.0f; }}, + lvgl_config, oled_config, Display::DynamicMemoryConfig{ .pixel_buffer_size = pixel_buffer_size, .double_buffered = true, @@ -946,6 +1006,29 @@ void M5StackTab5::flush(lv_display_t *disp, const lv_area_t *area, uint8_t *px_m // own thread. flush() runs on the LVGL (GUI) thread, so reading it here is // safe; the camera task reads the cached atomic instead. camera_display_rotation_.store(static_cast(rotation), std::memory_order_relaxed); + + if (direct_render_) { + // DIRECT mode (DisplayConfig::render_to_frame_buffers): LVGL has rendered + // the dirty areas in place, into the frame buffer px_map points to, and + // calls this once per area. When the last area is done, draw_bitmap() on + // the frame buffer writes the cache back and switches the panel to it (no + // copy, on_color_trans_done fires synchronously). LVGL then renders the + // next frame into the other buffer. + static bool rotation_warned = false; // flush() only runs on the LVGL task + if (rotation != LV_DISPLAY_ROTATION_0 && !rotation_warned) { + logger_.warn("Display rotation is not supported when rendering into the frame buffers"); + rotation_warned = true; + } + if (lv_display_flush_is_last(disp)) { + // Serialized with write_lcd_lines() like every other panel draw; for a + // frame buffer the completion is synchronous, so this does not block. + std::lock_guard lock(panel_op_mutex_); + draw_and_wait(0, 0, display_width_, display_height_, px_map); + } + lv_display_flush_ready(disp); + return; + } + // When the panel itself applies the rotation (ST7121, 180 degrees via the // display driver's MADCTL scan flip - see on_display_rotation()), the // logical frame is written to the framebuffer unrotated and at unrotated From 50a6a93eae4c586a19342bb14c509725899f0cdd Mon Sep 17 00:00:00 2001 From: Alaining Date: Thu, 15 Oct 2026 01:17:42 -0700 Subject: [PATCH 2/5] 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) --- components/m5stack-tab5/include/m5stack-tab5.hpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/components/m5stack-tab5/include/m5stack-tab5.hpp b/components/m5stack-tab5/include/m5stack-tab5.hpp index f48f7b5891..5849fe7396 100644 --- a/components/m5stack-tab5/include/m5stack-tab5.hpp +++ b/components/m5stack-tab5/include/m5stack-tab5.hpp @@ -222,6 +222,9 @@ class M5StackTab5 : public BaseComponent { /// mode. Pixels written with write_lcd_lines() are lost at the next /// frame switch. The switch is not synchronized to the panel's /// refresh, so a frame can tear. + /// \note If you install your own LVGL flush callback, call + /// lv_display_flush_ready() yourself; the transfer-done interrupt + /// does not. bool render_to_frame_buffers{false}; }; @@ -315,6 +318,9 @@ class M5StackTab5 : public BaseComponent { /// it. This lets an application render (e.g. LVGL in DIRECT mode) /// straight into the frame buffers; see also /// DisplayConfig::render_to_frame_buffers. + /// \note If you install your own LVGL flush callback, call + /// lv_display_flush_ready() yourself; the transfer-done interrupt + /// does not. std::span frame_buffers() const { // frame_buffers_ is written before initialize_lcd() opens the gate if (!lcd_initialized_.load(std::memory_order_acquire)) { From c08e9623ec7f48de1fd6e9a308912759ba195ced Mon Sep 17 00:00:00 2001 From: Alaining Date: Thu, 15 Oct 2026 16:47:15 -0700 Subject: [PATCH 3/5] 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) --- components/m5stack-tab5/include/m5stack-tab5.hpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/components/m5stack-tab5/include/m5stack-tab5.hpp b/components/m5stack-tab5/include/m5stack-tab5.hpp index 5849fe7396..fd6194c35d 100644 --- a/components/m5stack-tab5/include/m5stack-tab5.hpp +++ b/components/m5stack-tab5/include/m5stack-tab5.hpp @@ -223,8 +223,10 @@ class M5StackTab5 : public BaseComponent { /// frame switch. The switch is not synchronized to the panel's /// refresh, so a frame can tear. /// \note If you install your own LVGL flush callback, call - /// lv_display_flush_ready() yourself; the transfer-done interrupt - /// does not. + /// lv_display_flush_ready() once for every call of it (LVGL waits + /// for it before flushing the next area), or register a wait + /// callback with lv_display_set_flush_wait_cb(); the transfer-done + /// interrupt no longer does it. bool render_to_frame_buffers{false}; }; @@ -319,8 +321,10 @@ class M5StackTab5 : public BaseComponent { /// straight into the frame buffers; see also /// DisplayConfig::render_to_frame_buffers. /// \note If you install your own LVGL flush callback, call - /// lv_display_flush_ready() yourself; the transfer-done interrupt - /// does not. + /// lv_display_flush_ready() once for every call of it (LVGL waits + /// for it before flushing the next area), or register a wait + /// callback with lv_display_set_flush_wait_cb(); the transfer-done + /// interrupt no longer does it. std::span frame_buffers() const { // frame_buffers_ is written before initialize_lcd() opens the gate if (!lcd_initialized_.load(std::memory_order_acquire)) { From 2602cdcb61f20606c73e10061a078977f6ddf76f Mon Sep 17 00:00:00 2001 From: Alaining Date: Wed, 14 Oct 2026 21:00:50 -0700 Subject: [PATCH 4/5] 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) --- components/m5stack-tab5/README.md | 15 ++- .../m5stack-tab5/include/m5stack-tab5.hpp | 51 +++++++++- components/m5stack-tab5/src/video.cpp | 94 +++++++++++++++++-- 3 files changed, 146 insertions(+), 14 deletions(-) diff --git a/components/m5stack-tab5/README.md b/components/m5stack-tab5/README.md index 05df04dbce..e370a0598d 100644 --- a/components/m5stack-tab5/README.md +++ b/components/m5stack-tab5/README.md @@ -109,11 +109,18 @@ no copy: `initialize_lcd()`), each `frame_buffer_size()` bytes, cache-line aligned. `esp_lcd_panel_draw_bitmap()` with a pointer inside one of them makes the panel switch to that buffer instead of copying it. +- `present_frame(frame_buffer)` switches the panel to a finished frame and + waits until the panel scans it out (at most one refresh: about 17 ms, 21 ms + on the ILI9881 panel), so the previous buffer is free to draw into: + tear-free double buffering. The wait uses the DMA's frame-done event; + `on_color_trans_done` cannot serve, because for a frame buffer the driver + calls it before the switch. A timeout (default 100 ms) logs once and returns + `false` rather than stalling the UI; `set_vsync_enabled(false)` skips the + wait. - `initialize_display({.render_to_frame_buffers = true})` sets LVGL up in DIRECT - mode on two of them, with the BSP's flush switching buffers at the end of each - frame. No draw buffers and no rotation scratch buffer are allocated. The - display rotation must stay at 0 (LVGL cannot rotate in DIRECT mode), and the - buffer switch is not synchronized to the panel refresh, so a frame can tear. + mode on two of them, with the BSP's flush calling `present_frame()` at the end + of each frame. No draw buffers and no rotation scratch buffer are allocated. + The display rotation must stay at 0 (LVGL cannot rotate in DIRECT mode). ```cpp // menuconfig: M5Stack Tab5 Configuration -> Number of DPI frame buffers = 2 diff --git a/components/m5stack-tab5/include/m5stack-tab5.hpp b/components/m5stack-tab5/include/m5stack-tab5.hpp index fd6194c35d..d20dea60a7 100644 --- a/components/m5stack-tab5/include/m5stack-tab5.hpp +++ b/components/m5stack-tab5/include/m5stack-tab5.hpp @@ -218,10 +218,11 @@ class M5StackTab5 : public BaseComponent { /// finished frame is shown by switching the panel to its frame buffer /// instead of copying it. Needs initialize_lcd() first and /// CONFIG_M5STACK_TAB5_DPI_NUM_FBS >= 2. + /// The BSP's flush shows each frame with present_frame(), so LVGL never + /// draws into the buffer the panel is still scanning out (no tearing). /// \note The display rotation must stay at 0: LVGL cannot rotate in DIRECT /// mode. Pixels written with write_lcd_lines() are lost at the next - /// frame switch. The switch is not synchronized to the panel's - /// refresh, so a frame can tear. + /// frame switch. /// \note If you install your own LVGL flush callback, call /// lv_display_flush_ready() once for every call of it (LVGL waits /// for it before flushing the next area), or register a wait @@ -339,6 +340,41 @@ class M5StackTab5 : public BaseComponent { return display_width_ * display_height_ * sizeof(Pixel); } + /// Show a whole frame that is already in one of the panel's frame buffers, + /// and wait until the panel scans it out + /// + /// The tear-free way to switch frame buffers, e.g. at the end of an LVGL + /// DIRECT-mode frame. esp_lcd_panel_draw_bitmap() on a frame buffer only + /// writes the cache back and records the buffer as the next one to scan + /// out; the panel's DMA switches to it when it starts its next frame. + /// Until then it is still reading the previous buffer, so drawing into that + /// one at once would tear. This waits for the DMA's frame-done event (the + /// moment it restarts on the new buffer), after which the previous buffer + /// is free. (on_color_trans_done cannot be used for this: for a frame + /// buffer the driver calls it synchronously, before the switch.) + /// + /// \param frame_buffer Start of one of frame_buffers() + /// \param timeout_ms How long to wait for the switch + /// \return true once the panel scans the frame out, or at once if the wait + /// is disabled or unavailable (see set_vsync_enabled()); false if + /// \p frame_buffer is not one of frame_buffers() or the wait timed + /// out (the switch is still queued; carry on rather than stall) + /// \note Waits at most one panel refresh period (about 17 ms; 21 ms on the + /// ILI9881 panel) when the panel is running. Do not call it from an + /// ISR. The wait needs CONFIG_M5STACK_TAB5_DPI_NUM_FBS >= 2 (with one + /// frame buffer there is nothing to switch, and the call only writes + /// the cache back). + bool present_frame(const void *frame_buffer, uint32_t timeout_ms = 100); + + /// Enable or disable the wait in present_frame() + /// \param enabled false: present_frame() queues the switch and returns at + /// once (faster, but the next frame can tear) + void set_vsync_enabled(bool enabled) { vsync_enabled_.store(enabled, std::memory_order_relaxed); } + + /// Whether present_frame() waits for the frame-buffer switch + /// \return true if the wait is enabled (the default) + bool vsync_enabled() const { return vsync_enabled_.load(std::memory_order_relaxed); } + /// Write lines to the LCD /// \param xs The x start coordinate /// \param ys The y start coordinate @@ -1083,6 +1119,17 @@ class M5StackTab5 : public BaseComponent { bool panel_handles_rotation(lv_display_rotation_t rotation) const; static bool notify_lvgl_flush_ready(esp_lcd_panel_handle_t panel, esp_lcd_dpi_panel_event_data_t *edata, void *user_ctx); + // Frame-done ISR (on_frame_buf_complete, on_refresh_done before ESP-IDF + // v5.5.5 / v6.0.3): the DMA has restarted on the frame buffer queued last. + // Gives frame_done_sem_ for present_frame(). + static bool notify_frame_done(esp_lcd_panel_handle_t panel, esp_lcd_dpi_panel_event_data_t *edata, + void *user_ctx); + // Given once per panel refresh by notify_frame_done() (only with more than + // one frame buffer); present_frame() waits on it + SemaphoreHandle_t frame_done_sem_{nullptr}; + std::atomic vsync_enabled_{true}; + // A stalled panel logs one present_frame() timeout, not one per frame + std::atomic vsync_timeout_logged_{false}; // DSI write helpers void dsi_write_command(uint8_t cmd, std::span params, uint32_t flags); diff --git a/components/m5stack-tab5/src/video.cpp b/components/m5stack-tab5/src/video.cpp index 6b63258d86..ef3afa04f4 100644 --- a/components/m5stack-tab5/src/video.cpp +++ b/components/m5stack-tab5/src/video.cpp @@ -482,6 +482,29 @@ bool M5StackTab5::initialize_lcd() { return false; } } + if constexpr (kNumDpiFramebuffers > 1) { + // Frame-done event for present_frame(): the DMA has finished a frame and + // restarted on the frame buffer queued last (cur_fb_index), i.e. a queued + // switch has happened. ESP-IDF v5.5.5 / v6.0.3 / v6.1 renamed this + // callback from on_refresh_done (now deprecated) to on_frame_buf_complete + // and added a separate on_vsync; pick whichever this ESP-IDF has. + auto set_frame_done_callback = [](auto &callbacks, auto callback) { + if constexpr (requires { callbacks.on_frame_buf_complete; }) { + callbacks.on_frame_buf_complete = callback; + } else { + callbacks.on_refresh_done = callback; + } + }; + // Created before the callback is registered, so the ISR never sees null. + if (frame_done_sem_ == nullptr) { + frame_done_sem_ = xSemaphoreCreateBinary(); + } + if (frame_done_sem_ != nullptr) { + set_frame_done_callback(cbs, &M5StackTab5::notify_frame_done); + } else { + logger_.warn("Could not create the frame-done semaphore; present_frame() will not wait"); + } + } ret = esp_lcd_dpi_panel_register_event_callbacks(lcd_handles_.panel, &cbs, this); if (ret != ESP_OK) { logger_.error("Failed to register panel event callback: {}", esp_err_to_name(ret)); @@ -954,6 +977,47 @@ void M5StackTab5::write_lcd_lines(int xs, int ys, int xe, int ye, const uint8_t draw_and_wait(xs, ys, xe + 1, ye + 1, data); } +bool M5StackTab5::present_frame(const void *frame_buffer, uint32_t timeout_ms) { + // frame_buffers() is empty until the lcd_initialized_ gate is open + // (acquire), so this also refuses a panel that is not fully initialized. + const auto fbs = frame_buffers(); + if (frame_buffer == nullptr || std::find(fbs.begin(), fbs.end(), frame_buffer) == fbs.end()) { + logger_.error("present_frame() needs the start of one of the panel's frame buffers"); + return false; + } + { + // Queue the switch: for a frame buffer the driver writes the cache back, + // makes it the next one to scan out (cur_fb_index) and calls + // on_color_trans_done synchronously, so draw_and_wait() returns at once. + // The DMA takes the new buffer up when it restarts at the end of the + // frame it is sending. Serialized with write_lcd_lines() like every other + // panel draw; the frame-done wait below is outside the mutex, so a direct + // write is not held up for a refresh. + std::lock_guard lock(panel_op_mutex_); + if (!draw_and_wait(0, 0, display_width_, display_height_, frame_buffer)) { + logger_.error("present_frame(): draw_bitmap failed"); + return false; + } + } + if (!vsync_enabled_.load(std::memory_order_relaxed) || frame_done_sem_ == nullptr) { + return true; + } + // Drop a frame-done event that came before this point: it may have + // restarted the DMA on the previous buffer. Any later one restarts it on + // the new buffer. (If the switch itself landed between draw_bitmap() and + // here, this waits one refresh longer, which is safe.) + xSemaphoreTake(frame_done_sem_, 0); + if (xSemaphoreTake(frame_done_sem_, pdMS_TO_TICKS(timeout_ms)) == pdTRUE) { + return true; + } + // A panel that stopped refreshing must not freeze the caller: the switch + // stays queued and happens whenever the DMA restarts. + if (!vsync_timeout_logged_.exchange(true, std::memory_order_relaxed)) { + logger_.warn("present_frame(): no frame-done event within {} ms (logged once)", timeout_ms); + } + return false; +} + void M5StackTab5::brightness(float brightness) { brightness = std::clamp(brightness, 0.0f, 100.0f); if (backlight_) { @@ -1010,20 +1074,19 @@ void M5StackTab5::flush(lv_display_t *disp, const lv_area_t *area, uint8_t *px_m if (direct_render_) { // DIRECT mode (DisplayConfig::render_to_frame_buffers): LVGL has rendered // the dirty areas in place, into the frame buffer px_map points to, and - // calls this once per area. When the last area is done, draw_bitmap() on - // the frame buffer writes the cache back and switches the panel to it (no - // copy, on_color_trans_done fires synchronously). LVGL then renders the - // next frame into the other buffer. + // calls this once per area. When the last area is done, present_frame() + // switches the panel to that buffer (no copy). LVGL then renders the next + // frame into the other buffer. static bool rotation_warned = false; // flush() only runs on the LVGL task if (rotation != LV_DISPLAY_ROTATION_0 && !rotation_warned) { logger_.warn("Display rotation is not supported when rendering into the frame buffers"); rotation_warned = true; } if (lv_display_flush_is_last(disp)) { - // Serialized with write_lcd_lines() like every other panel draw; for a - // frame buffer the completion is synchronous, so this does not block. - std::lock_guard lock(panel_op_mutex_); - draw_and_wait(0, 0, display_width_, display_height_, px_map); + // Waits until the panel scans px_map out, so LVGL never renders the + // next frame into the buffer the panel is still reading (see + // present_frame()). + present_frame(px_map); } lv_display_flush_ready(disp); return; @@ -1166,6 +1229,21 @@ bool IRAM_ATTR M5StackTab5::notify_lvgl_flush_ready(esp_lcd_panel_handle_t panel return higher_priority_task_woken == pdTRUE; } +bool IRAM_ATTR M5StackTab5::notify_frame_done(esp_lcd_panel_handle_t panel, + esp_lcd_dpi_panel_event_data_t *edata, + void *user_ctx) { + auto *tab5 = static_cast(user_ctx); + if (tab5 == nullptr || tab5->frame_done_sem_ == nullptr) { + return false; + } + // ISR context (the DMA transfer-done interrupt, or on chips with a DSI + // bridge VSYNC interrupt and ESP-IDF < v6.0.3, that one): only give the + // semaphore. Returning true asks the driver to yield to the woken task. + BaseType_t higher_priority_task_woken = pdFALSE; + xSemaphoreGiveFromISR(tab5->frame_done_sem_, &higher_priority_task_woken); + return higher_priority_task_woken == pdTRUE; +} + void M5StackTab5::dsi_write_command(uint8_t cmd, std::span params, uint32_t /*flags*/) { if (!lcd_handles_.io) { From 6df3ece5c0ff369fd2353c219c5b0206d9b555c7 Mon Sep 17 00:00:00 2001 From: Alaining Date: Fri, 16 Oct 2026 19:56:51 -0700 Subject: [PATCH 5/5] 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) --- components/m5stack-tab5/example/main/gui.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/components/m5stack-tab5/example/main/gui.cpp b/components/m5stack-tab5/example/main/gui.cpp index c43ec80aa6..a046fc8a62 100644 --- a/components/m5stack-tab5/example/main/gui.cpp +++ b/components/m5stack-tab5/example/main/gui.cpp @@ -417,16 +417,22 @@ void Gui::init_circle_layer() { } bool Gui::update(std::mutex &m, std::condition_variable &cv) { + [[maybe_unused]] uint32_t next_ms = 16; { std::lock_guard lock(mutex_); - lv_task_handler(); + next_ms = lv_task_handler(); // keep the audio volume label in sync with the live BSP state, so the // first press of a volume button doesn't appear to jump from a stale // default (the values are set by app_main after the Gui is constructed) update_audio_label(); } std::unique_lock lock(m); +#if CONFIG_M5STACK_TAB5_EXAMPLE_RENDER_TO_FRAME_BUFFERS + // the flush already waits for the panel's frame-done event, so only sleep until LVGL's next timer + cv.wait_for(lock, std::chrono::milliseconds(std::clamp(next_ms, 1, 16))); +#else cv.wait_for(lock, std::chrono::milliseconds(16)); +#endif return false; // don't stop the task }