From b42d8216cca7d685e8af40a712e0b0720619ad21 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 15 Jul 2026 18:02:59 +0200 Subject: [PATCH] Render matrix rain as pooled internal-RAM sprites The rain was 40 live text labels; moving a label re-rasterizes its katakana glyphs every frame, reading the font from PSRAM and blending against the framebuffer. That per-frame load starved the DSI's framebuffer read and flashed the panel blue while a voice reply downloaded (confirmed: with the rain removed the flash vanished). Pre-render a small pool of streak sprites once into internal RAM and blit them; movement is now a cheap opaque copy that never touches the PSRAM bus. Also drain the rain during audio (listening/speaking) rather than freezing it. This clears the last of the load flicker while keeping the dense look. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu --- CHANGELOG.md | 11 +++- firmware/main/face.c | 135 +++++++++++++++++++++++++++++++------------ 2 files changed, 107 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94fe4a4..207b326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ until the first tagged release. ### Fixed -- Reduced the blue-screen flicker during voice activity — lowered the DSI pixel - clock so the display's continuous PSRAM reads aren't starved by concurrent - Wi-Fi and audio DMA on the same bus. +- Fixed the blue-screen flicker during voice activity — the matrix rain now + renders as cheap pre-rendered sprites, and the DSI clocks/FIFO are tuned, so + the display's memory reads are no longer starved by Wi-Fi and audio DMA. + +### Changed + +- Matrix rain is drawn from a reused pool of pre-rendered streak sprites instead + of live text labels, and eases off while the device is listening or speaking. diff --git a/firmware/main/face.c b/firmware/main/face.c index 93495b6..cd741df 100644 --- a/firmware/main/face.c +++ b/firmware/main/face.c @@ -8,6 +8,7 @@ #include #include "esp_random.h" +#include "esp_heap_caps.h" #include "bsp/esp-bsp.h" #include "lvgl.h" @@ -20,6 +21,18 @@ LV_FONT_DECLARE(font_rage_64); #define SCREEN 800 #define RAIN_MAX 40 #define RAIN_CELL 20 + +/* Rain is drawn as a small pool of pre-rendered "streak" sprites in INTERNAL RAM, + * reused across the on-screen streams. Moving a stream is then a cheap opaque blit + * (write-only, no per-frame glyph rasterization and no read-back-to-blend), and the + * sprite pixels are read from internal RAM instead of PSRAM — so the rain stops + * competing with the DSI's framebuffer read on the PSRAM bus (the "thinking" + * underrun). One sprite is regenerated at a time to keep visual variance. */ +#define CELL_H 26 /* px per glyph row */ +#define SPRITE_GLYPHS 12 /* streak length in a sprite */ +#define SPRITE_W 24 /* sprite width (glyph ~22px) */ +#define SPRITE_H (SPRITE_GLYPHS * CELL_H) +#define SPRITE_POOL 5 /* distinct pre-rendered streaks */ #define RAIN_COLS (SCREEN / RAIN_CELL) #define TICK_MS 33 @@ -74,12 +87,16 @@ static const char *GLYPHS[] = { #define NGLYPHS (sizeof(GLYPHS) / sizeof(GLYPHS[0])) typedef struct { - lv_obj_t *tail; - lv_obj_t *head; - int x, y, len, speed; + lv_obj_t *img; /* lv_image showing one of the pool sprites */ + int sprite; /* which pool sprite (index into sprite_dsc) */ + int x, y, speed; bool alive; } stream_t; +/* Pre-rendered streak sprite pool (RGB565, opaque, black background baked in). */ +static lv_color16_t *sprite_buf[SPRITE_POOL]; +static lv_image_dsc_t sprite_dsc[SPRITE_POOL]; + static struct { face_state_t state; lv_obj_t *rain_layer; @@ -109,44 +126,68 @@ static struct { static uint32_t now_ms(void) { return lv_tick_get(); } static const char *rand_glyph(void) { return GLYPHS[esp_random() % NGLYPHS]; } -static void stream_fill(stream_t *s) +/* Render a fresh random streak into pool sprite k: a katakana column, dim green + * fading up from a bright leading glyph, on an opaque black background. Done once + * per sprite (not per frame) via an off-screen canvas. */ +static void sprite_render(int k) { - char buf[16 * 4 + 16]; - int pos = 0; - for (int i = 0; i < s->len; i++) { - pos += snprintf(buf + pos, sizeof(buf) - pos, "%s%s", i ? "\n" : "", rand_glyph()); + if (sprite_buf[k] == NULL) { + return; } - lv_label_set_text(s->tail, buf); - lv_label_set_text(s->head, rand_glyph()); + lv_obj_t *cv = lv_canvas_create(F.rain_layer); + lv_canvas_set_buffer(cv, sprite_buf[k], SPRITE_W, SPRITE_H, LV_COLOR_FORMAT_RGB565); + lv_canvas_fill_bg(cv, lv_color_black(), LV_OPA_COVER); + lv_layer_t layer; + lv_canvas_init_layer(cv, &layer); + for (int i = 0; i < SPRITE_GLYPHS; i++) { + int from_head = SPRITE_GLYPHS - 1 - i; /* 0 = bright leading glyph */ + lv_draw_label_dsc_t dsc; + lv_draw_label_dsc_init(&dsc); + dsc.font = &font_rain_22; + dsc.align = LV_TEXT_ALIGN_CENTER; + dsc.text = rand_glyph(); + if (from_head == 0) { + dsc.color = lv_color_hex(0xC3FFD7); /* bright head */ + } else { + int g = 0xA0 - from_head * 9; /* fade green up the tail */ + if (g < 0x28) g = 0x28; + dsc.color = lv_color_make(0x00, g, 0x40); + } + lv_area_t a = { 0, i * CELL_H, SPRITE_W - 1, i * CELL_H + CELL_H - 1 }; + lv_draw_label(&layer, &dsc, &a); + } + lv_canvas_finish_layer(cv, &layer); + lv_obj_delete(cv); /* keep sprite_buf[k]; drop the canvas object */ + + sprite_dsc[k].header.magic = LV_IMAGE_HEADER_MAGIC; + sprite_dsc[k].header.cf = LV_COLOR_FORMAT_RGB565; + sprite_dsc[k].header.w = SPRITE_W; + sprite_dsc[k].header.h = SPRITE_H; + sprite_dsc[k].header.stride = SPRITE_W * 2; + sprite_dsc[k].data = (const uint8_t *)sprite_buf[k]; + sprite_dsc[k].data_size = SPRITE_W * SPRITE_H * 2; } static void stream_spawn(stream_t *s, int speed) { s->x = (esp_random() % RAIN_COLS) * RAIN_CELL; - s->len = 6 + esp_random() % 9; s->speed = speed + esp_random() % 4; - s->y = -(s->len + 1) * 26; - if (s->tail == NULL) { - s->tail = lv_label_create(F.rain_layer); - lv_obj_set_style_text_font(s->tail, &font_rain_22, 0); - lv_obj_set_style_text_color(s->tail, lv_color_hex(0x00A050), 0); - lv_obj_set_style_text_opa(s->tail, 170, 0); - s->head = lv_label_create(F.rain_layer); - lv_obj_set_style_text_font(s->head, &font_rain_22, 0); - lv_obj_set_style_text_color(s->head, lv_color_hex(0xC3FFD7), 0); + s->y = -SPRITE_H; + s->sprite = esp_random() % SPRITE_POOL; + if (s->img == NULL) { + s->img = lv_image_create(F.rain_layer); } - lv_obj_clear_flag(s->tail, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(s->head, LV_OBJ_FLAG_HIDDEN); - stream_fill(s); + lv_image_set_src(s->img, &sprite_dsc[s->sprite]); + lv_obj_set_pos(s->img, s->x, s->y); + lv_obj_clear_flag(s->img, LV_OBJ_FLAG_HIDDEN); s->alive = true; } static void stream_hide(stream_t *s) { s->alive = false; - if (s->tail != NULL) { - lv_obj_add_flag(s->tail, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(s->head, LV_OBJ_FLAG_HIDDEN); + if (s->img != NULL) { + lv_obj_add_flag(s->img, LV_OBJ_FLAG_HIDDEN); } } @@ -163,8 +204,11 @@ static void rain_tick(void) return; } /* Rain is the reachability signal: it only falls when the gateway is live. - * A disconnect drains it gracefully (streams finish falling, none respawn). */ - int target = (F.power == 2 || !gw_connected()) ? 0 : d->rain_streams; + * A disconnect drains it gracefully (streams finish falling, none respawn). + * Also drain it while audio is active (listening or the butler speaking): + * the streams fall away rather than freezing (less jarring). */ + int target = (F.power == 2 || !gw_connected() + || audio_is_playing() || audio_capture_active()) ? 0 : d->rain_streams; int alive = 0; for (int i = 0; i < RAIN_MAX; i++) { stream_t *s = &F.streams[i]; @@ -172,13 +216,9 @@ static void rain_tick(void) continue; } alive++; - s->y += s->speed * 2; - lv_obj_set_pos(s->tail, s->x, s->y); - lv_obj_set_pos(s->head, s->x, s->y + s->len * 26); - if (F.mutate == 0 && (esp_random() % 3) == 0) { - lv_label_set_text(s->head, rand_glyph()); - } - if (s->y > SCREEN + 26) { + s->y += s->speed * 2; /* cheap blit of a pre-rendered sprite */ + lv_obj_set_pos(s->img, s->x, s->y); + if (s->y > SCREEN) { if (alive > target) { stream_hide(s); alive--; @@ -193,7 +233,13 @@ static void rain_tick(void) alive++; } } - F.mutate = (F.mutate + 1) % 4; + /* Variance: regenerate one pool sprite periodically so the reused streaks vary + * over time instead of looking tiled. */ + static uint16_t regen; + if (++regen >= 90) { + regen = 0; + sprite_render(esp_random() % SPRITE_POOL); + } } static void apply_power(int level) @@ -368,6 +414,23 @@ void face_init(void) F.rain_layer = lv_obj_create(scr); lv_obj_remove_style_all(F.rain_layer); lv_obj_set_size(F.rain_layer, SCREEN, SCREEN); + lv_obj_set_style_bg_opa(F.rain_layer, LV_OPA_TRANSP, 0); + lv_obj_clear_flag(F.rain_layer, LV_OBJ_FLAG_SCROLLABLE); + + /* Allocate the pre-rendered streak sprites in INTERNAL RAM (so their per-frame + * reads stay off the PSRAM bus the DSI is reading the framebuffer from) and + * render the initial pool. ~15 KB each. */ + for (int k = 0; k < SPRITE_POOL; k++) { + sprite_buf[k] = heap_caps_malloc(SPRITE_W * SPRITE_H * sizeof(lv_color16_t), + MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + if (sprite_buf[k] == NULL) { + /* internal RAM famine: fall back to PSRAM (still cheaper than per-frame + * rasterization, just without the bus-relief benefit) */ + sprite_buf[k] = heap_caps_malloc(SPRITE_W * SPRITE_H * sizeof(lv_color16_t), + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + } + sprite_render(k); + } /* radial-ish shade so rain doesn't run through the face */ F.shade = lv_obj_create(scr);