Render matrix rain as pooled internal-RAM sprites
Test, Build and Push / test-gateway (push) Successful in 12s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
This commit is contained in:
2026-07-15 18:02:59 +02:00
co-authored by Claude Opus 4.8
parent 728c460d36
commit b42d8216cc
2 changed files with 107 additions and 39 deletions
+8 -3
View File
@@ -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.
+99 -36
View File
@@ -8,6 +8,7 @@
#include <time.h>
#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);