src.nth.io/

summaryrefslogtreecommitdiff
path: root/main
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-07-15 19:10:57 -0500
committerLuke Hoersten <[email protected]>2026-07-15 19:10:57 -0500
commit659da7f36473a21494693f031a39fb6bb977b90a (patch)
treeb36783374bd8f32fe8c8c763337b01056c4565f6 /main
parent7620b935728ea964ddca09b91ac0be36069a8c64 (diff)
display: tear-free frame path via triple buffering + scan tracking
draw_bitmap on a direct fb pointer only updates the driver's cur_fb_index; the DPI DMA reloads that index at the END of the in-progress frame scan (~21ms period at ~47Hz). Under double buffering, flipping and immediately decoding the next frame into the other fb writes a buffer the DMA may still be scanning out — a torn frame. This regime is common now that the TCP window fix delivers frames back-to-back (decode starts ~6ms after flip). Fix with zero added latency: num_fbs 2 -> 3 (+1.15MB PSRAM of 25MB free), track the actually-scanning fb via on_refresh_done (fires in the DMA-done ISR exactly when the DMA reloads cur_fb_index), and pick the decode target as the fb that is neither pending display nor scanning. Three buffers minus at most two excluded roles = always a free one; no waiting on vsync anywhere. Instrumented: /state tear_guard_engaged counts back-buffer picks made while the previous fb was still mid-scan — each one is a frame that would have torn under double buffering.
Diffstat (limited to 'main')
-rw-r--r--main/display.c100
-rw-r--r--main/display.h19
-rw-r--r--main/http_api.c5
3 files changed, 101 insertions, 23 deletions
diff --git a/main/display.c b/main/display.c
index ca1690e..7441200 100644
--- a/main/display.c
+++ b/main/display.c
@@ -122,13 +122,30 @@ static uint8_t s_last_pwm;
// instead writes the JPEG decoder output directly into the panel's
// double-buffered framebuffer (see s_panel_fbs).
static uint8_t *s_rot_buf;
-// The DPI driver owns the two framebuffers when num_fbs = 2. We grab
-// pointers to both after panel_init and alternate which one we hand to
-// the JPEG decoder + the panel for each /frame. draw_bitmap with a
-// pointer that's inside one of these turns into a cache writeback +
-// index swap (microseconds), eliminating the previous ~24 ms memcpy.
-static uint8_t *s_panel_fbs[2];
-static int s_back_fb; // index of the fb the next paint will fill
+// The DPI driver owns the framebuffers when num_fbs > 1. We grab
+// pointers to all of them after panel_init and hand one to the JPEG
+// decoder + the panel for each frame. draw_bitmap with a pointer
+// that's inside one of these turns into a cache writeback + index
+// swap (microseconds), eliminating the previous ~24 ms memcpy.
+//
+// Triple buffering + scan tracking = tear-free with zero waiting.
+// draw_bitmap only updates the driver's cur_fb_index; the DMA reloads
+// that index at the END of the in-progress frame scan (~21 ms period
+// at ~47 Hz). So after a flip the just-retired fb keeps scanning out
+// until the next boundary — under double buffering, a back-to-back
+// decode would write into it mid-scan (tear). With three fbs the next
+// decode target is chosen to avoid BOTH the pending fb (flipped, will
+// display at the next boundary) and the scanning fb (tracked via the
+// driver's on_refresh_done, which fires exactly when the DMA reloads):
+// three buffers minus at most two excluded roles = always a free one.
+static uint8_t *s_panel_fbs[3];
+static int s_back_fb; // fb the next paint will fill
+static volatile uint8_t s_pending_fb; // last fb handed to draw_bitmap
+static volatile uint8_t s_scanning_fb; // fb the DMA is scanning out now
+static volatile uint32_t s_tear_guard_engaged; // frames where scanning !=
+ // pending at back-buffer pick
+ // (= frames double buffering
+ // would have torn)
// ============================================================================
// ATTINY88 I2C helpers
@@ -270,6 +287,17 @@ static esp_err_t bridge_init(void)
return ESP_OK;
}
+// Runs in the DPI driver's DMA-done ISR at every frame boundary — keep it
+// to the one assignment. See the comment at the registration site.
+static bool on_refresh_done_cb(esp_lcd_panel_handle_t panel,
+ esp_lcd_dpi_panel_event_data_t *edata,
+ void *user_ctx)
+{
+ (void)panel; (void)edata; (void)user_ctx;
+ s_scanning_fb = s_pending_fb;
+ return false;
+}
+
// ============================================================================
// DSI bring-up
// ============================================================================
@@ -314,10 +342,12 @@ static esp_err_t dsi_bring_up(void)
.pixel_format = LCD_COLOR_PIXEL_FORMAT_RGB888,
.in_color_format = LCD_COLOR_FMT_RGB888,
.out_color_format = LCD_COLOR_FMT_RGB888,
- // Double-buffer the panel: the DSI engine streams one fb while
- // we fill the other. esp_lcd_panel_draw_bitmap(fb) becomes a
- // cache writeback + index swap (~µs) instead of a ~24 ms memcpy.
- .num_fbs = 2,
+ // Triple-buffer the panel (see s_panel_fbs): one fb scanning
+ // out, one pending display at the next frame boundary, one
+ // free for the decoder. esp_lcd_panel_draw_bitmap(fb) stays a
+ // cache writeback + index swap (~µs), and the decoder never
+ // writes a buffer the DMA might still be reading.
+ .num_fbs = 3,
.video_timing = {
.h_size = PANEL_H_ACTIVE,
.v_size = PANEL_V_ACTIVE,
@@ -350,15 +380,30 @@ static esp_err_t dsi_bring_up(void)
ESP_RETURN_ON_ERROR(esp_lcd_panel_init(s_panel), TAG, "panel_init");
- // Grab pointers to both framebuffers so we can hand them to the
- // JPEG decoder as the direct decode destination — that triggers
+ // Grab pointers to all three framebuffers so we can hand them to
+ // the JPEG decoder as the direct decode destination — that triggers
// the IDF DPI driver's fast path in draw_bitmap (no memcpy).
- void *fb0 = NULL, *fb1 = NULL;
- ESP_RETURN_ON_ERROR(esp_lcd_dpi_panel_get_frame_buffer(s_panel, 2, &fb0, &fb1),
+ void *fb0 = NULL, *fb1 = NULL, *fb2 = NULL;
+ ESP_RETURN_ON_ERROR(esp_lcd_dpi_panel_get_frame_buffer(s_panel, 3, &fb0, &fb1, &fb2),
TAG, "get_frame_buffer");
s_panel_fbs[0] = fb0;
s_panel_fbs[1] = fb1;
- s_back_fb = 1; // fb0 is the one the driver shows first; we fill fb1 next
+ s_panel_fbs[2] = fb2;
+ s_pending_fb = 0; // driver scans fb0 first
+ s_scanning_fb = 0;
+ s_back_fb = 1;
+
+ // Track which fb the DMA is actually scanning. on_refresh_done fires
+ // from the driver's DMA-done ISR at each frame boundary, exactly when
+ // the DMA has just been reloaded with cur_fb_index — i.e. the pending
+ // fb becomes the scanning fb. (If a flip lands concurrently, the
+ // stale value only over-excludes an actually-free fb for one frame —
+ // conservative, never a tear.)
+ esp_lcd_dpi_panel_event_callbacks_t cbs = {
+ .on_refresh_done = on_refresh_done_cb,
+ };
+ ESP_RETURN_ON_ERROR(esp_lcd_dpi_panel_register_event_callbacks(s_panel, &cbs, NULL),
+ TAG, "register_event_callbacks");
// Continuous HS clock — TC358762's internal FLL needs a stable
// reference, and we still allow LP data-lane blanking so command
@@ -464,6 +509,22 @@ void *display_back_buffer(size_t *out_size)
{
if (!s_up) return NULL;
if (out_size) *out_size = (size_t)PANEL_H_ACTIVE * PANEL_V_ACTIVE * 3;
+
+ // Pick the fb that is neither pending display nor being scanned out.
+ // Snapshot the ISR-written value once so both the exclusion and the
+ // counter see the same fb. If scanning == pending (steady state: the
+ // boundary already passed), two fbs are free — pick the lowest.
+ // If they differ, the previous fb is still mid-scan and this pick is
+ // exactly the case where double buffering would have torn.
+ uint8_t scanning = s_scanning_fb;
+ uint8_t pending = s_pending_fb;
+ if (scanning != pending) s_tear_guard_engaged++;
+ for (int i = 0; i < 3; i++) {
+ if (i != scanning && i != pending) {
+ s_back_fb = i;
+ break;
+ }
+ }
return s_panel_fbs[s_back_fb];
}
@@ -474,10 +535,15 @@ esp_err_t display_flip_back_buffer(void)
esp_err_t err = esp_lcd_panel_draw_bitmap(s_panel, 0, 0,
PANEL_H_ACTIVE, PANEL_V_ACTIVE,
fb);
- if (err == ESP_OK) s_back_fb ^= 1; // next /frame fills the other one
+ if (err == ESP_OK) s_pending_fb = (uint8_t)s_back_fb;
return err;
}
+uint32_t display_tear_guard_engaged(void)
+{
+ return s_tear_guard_engaged;
+}
+
esp_err_t display_present_rgb565(const uint16_t *src,
uint16_t src_w,
uint16_t src_h)
diff --git a/main/display.h b/main/display.h
index 0ecff4d..679a408 100644
--- a/main/display.h
+++ b/main/display.h
@@ -48,13 +48,20 @@ esp_err_t display_present_rgb565(const uint16_t *src,
// driver skips the memcpy entirely; otherwise it copies via CPU.
esp_err_t display_present_bgr888(const void *bgr888);
-// Double-buffer accessors for the zero-memcpy /frame path. The DPI
-// panel owns two BGR888 framebuffers; `display_back_buffer` hands back
-// the one that is NOT currently being streamed by the DSI engine so
-// callers (the JPEG decoder) can fill it in place. When the buffer is
-// ready, `display_flip_back_buffer` swaps it in — turns into a cache
-// writeback + index swap inside the IDF driver (no memcpy).
+// Framebuffer accessors for the zero-memcpy frame path. The DPI panel
+// owns three BGR888 framebuffers (triple buffering): one scanning out,
+// one pending display at the next frame boundary, one free.
+// `display_back_buffer` hands back the free one — guaranteed not to be
+// touched by the DSI DMA — so callers (the JPEG decoder) can fill it in
+// place with no tear risk and no waiting. When the buffer is ready,
+// `display_flip_back_buffer` swaps it in — a cache writeback + index
+// swap inside the IDF driver (no memcpy).
// `out_size` (if non-null) is set to the buffer's byte size, useful
// for passing to jpeg_decoder_process as its output capacity.
void *display_back_buffer(size_t *out_size);
esp_err_t display_flip_back_buffer(void);
+
+// Count of back-buffer picks made while the previously-flipped fb was
+// still scanning out — i.e. frames that would have torn under the old
+// double-buffer scheme. Monotonic since boot; exposed via /state.
+uint32_t display_tear_guard_engaged(void);
diff --git a/main/http_api.c b/main/http_api.c
index e64ebc9..b9eade0 100644
--- a/main/http_api.c
+++ b/main/http_api.c
@@ -73,6 +73,11 @@ static esp_err_t state_get_handler(httpd_req_t *req)
(double)heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
cJSON_AddNumberToObject(root, "free_psram",
(double)heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
+ // Frames whose decode target was picked while the previous fb was
+ // still mid-scan — each one is a tear the triple-buffer guard
+ // prevented (would-have-torn count under double buffering).
+ cJSON_AddNumberToObject(root, "tear_guard_engaged",
+ (double)display_tear_guard_engaged());
viewport_state_unlock();