pm-kit: small-tile incremental updates (sub-rect blit) to cut tearing

Validated against GeeekPi's lv_port_disp.c (vendor LVGL driver for this kit): it flushes
per-dirty-rectangle windowed writes at 62.5MHz, no TE — exactly this approach. Fix blit_rect
(1024-byte buffer; the 512-byte one was the black-screen bug), switch incremental updates from
full-width 144-row bands to changed 40x40 tiles. Full repaints still use the proven full blit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-06-01 13:52:07 -05:00
parent eef535f9ef
commit 1eca3ee0fe

View file

@ -38,6 +38,12 @@ const XTAL_FREQ_HZ: u32 = 12_000_000;
const WIDTH: u16 = 320; const WIDTH: u16 = 320;
const HEIGHT: u16 = 480; const HEIGHT: u16 = 480;
/// Dirty-tile grid for incremental updates. TILE wide is sub-screen-width so each tile blit writes
/// faster than the panel scan crosses it → minimal tearing. 320/40=8 cols, 480/40=12 rows = 96 tiles.
const TILE: u16 = 40;
const NX: usize = WIDTH as usize / TILE as usize;
const NY: usize = HEIGHT as usize / TILE as usize;
/// Direct ST7796 driver — a faithful port of the proven MicroPython driver in `pico/main.py`, /// Direct ST7796 driver — a faithful port of the proven MicroPython driver in `pico/main.py`,
/// which runs this exact panel on this exact board. Per-command CS framing (CS low → command → /// which runs this exact panel on this exact board. Per-command CS framing (CS low → command →
/// data → CS high), MADCTL 0x48, INVON, NO row/column offset, big-endian RGB565. This replaces /// data → CS high), MADCTL 0x48, INVON, NO row/column offset, big-endian RGB565. This replaces
@ -138,6 +144,40 @@ where
fn blit(&mut self, px: &[Rgb565]) { fn blit(&mut self, px: &[Rgb565]) {
self.blit_band(px, 0, HEIGHT); self.blit_band(px, 0, HEIGHT);
} }
/// Blit a sub-rectangle `(x,y,w,h)` gathered from the full framebuffer. Used for small tile
/// updates: sub-width writes finish a region faster than the panel scan crosses it (datasheet
/// §10.8 Example 1), so the visible tear is tiny vs a full-width band. Same streaming loop and
/// 1024-byte buffer as `blit_band` (the 512-byte buffer was what broke the earlier version).
fn blit_rect(&mut self, fb: &[Rgb565], x: u16, y: u16, w: u16, h: u16) {
let x1 = x + w - 1;
let y1 = y + h - 1;
self.cmd(0x2A, &[(x >> 8) as u8, (x & 0xFF) as u8, (x1 >> 8) as u8, (x1 & 0xFF) as u8]);
self.cmd(0x2B, &[(y >> 8) as u8, (y & 0xFF) as u8, (y1 >> 8) as u8, (y1 & 0xFF) as u8]);
let _ = self.cs.set_low();
let _ = self.dc.set_low();
let _ = self.spi.write(&[0x2C]); // RAMWR
let _ = self.dc.set_high();
let mut buf = [0u8; 1024];
let mut n = 0;
for r in 0..h as usize {
let row = (y as usize + r) * WIDTH as usize + x as usize;
for c in 0..w as usize {
let raw = fb[row + c].into_storage();
buf[n] = (raw >> 8) as u8;
buf[n + 1] = (raw & 0xFF) as u8;
n += 2;
if n == buf.len() {
let _ = self.spi.write(&buf);
n = 0;
}
}
}
if n > 0 {
let _ = self.spi.write(&buf[..n]);
}
let _ = self.cs.set_high();
}
} }
@ -275,7 +315,7 @@ fn main() -> ! {
// changed vs the last frame. Full-width windows (CASET 0..319) behave exactly like the proven // changed vs the last frame. Full-width windows (CASET 0..319) behave exactly like the proven
// full-screen blit — unlike sub-width tiles, which the panel's MX bit mirror-maps wrongly. // full-screen blit — unlike sub-width tiles, which the panel's MX bit mirror-maps wrongly.
let mut fb = FrameBuf { px: unsafe { &mut *core::ptr::addr_of_mut!(FRAMEBUF) } }; let mut fb = FrameBuf { px: unsafe { &mut *core::ptr::addr_of_mut!(FRAMEBUF) } };
let mut row_hash = [0u64; HEIGHT as usize]; // FNV-1a of each row at its last blit (0 → force first paint) let mut tile_hash = [0u64; NX * NY]; // FNV-1a of each tile at its last blit (0 → force first paint)
let mut bar_start = timer.get_counter().ticks(); let mut bar_start = timer.get_counter().ticks();
let mut last_step = usize::MAX; // audio: click on a new step let mut last_step = usize::MAX; // audio: click on a new step
@ -392,51 +432,46 @@ fn main() -> ! {
} else { } else {
pm_ui::draw_metronome(&mut fb, &screen).ok(); pm_ui::draw_metronome(&mut fb, &screen).ok();
} }
#[inline(always)] // FNV-1a of one TILE×TILE block at grid cell (tx, ty)
fn row_fnv(row: &[Rgb565]) -> u64 { fn tile_fnv(px: &[Rgb565], tx: usize, ty: usize) -> u64 {
let mut h = 0xcbf29ce484222325u64; // FNV-1a offset basis let mut h = 0xcbf29ce484222325u64; // FNV-1a offset basis
for &p in row { let x0 = tx * TILE as usize;
h ^= p.into_storage() as u64; let y0 = ty * TILE as usize;
for r in 0..TILE as usize {
let base = (y0 + r) * WIDTH as usize + x0;
for c in 0..TILE as usize {
h ^= px[base + c].into_storage() as u64;
h = h.wrapping_mul(0x100000001b3); h = h.wrapping_mul(0x100000001b3);
} }
}
h h
} }
let w = WIDTH as usize;
if force_full { if force_full {
// known-good full-screen blit; seed the row hashes so increments diff against it // known-good full-screen blit; seed the tile hashes so increments diff against it
st.blit(&fb.px[..]); st.blit(&fb.px[..]);
for yy in 0..HEIGHT as usize { for ty in 0..NY {
row_hash[yy] = row_fnv(&fb.px[yy * w..yy * w + w]); for tx in 0..NX {
tile_hash[ty * NX + tx] = tile_fnv(&fb.px[..], tx, ty);
}
} }
force_full = false; force_full = false;
info!("FULL blit (seeded {} rows)", HEIGHT as u32); info!("FULL blit (seeded {} tiles)", (NX * NY) as u32);
} else { } else {
// ---- dirty row-band blit: contiguous runs of changed rows, full-width ---- // ---- dirty-tile blit: push only the changed TILE×TILE blocks (sub-width = less tearing) ----
let mut pushed_rows = 0u32; let mut pushed = 0u32;
let mut y = 0usize; for ty in 0..NY {
while y < HEIGHT as usize { for tx in 0..NX {
let h = row_fnv(&fb.px[y * w..y * w + w]); let h = tile_fnv(&fb.px[..], tx, ty);
if h != row_hash[y] { let idx = ty * NX + tx;
let ylo = y; if h != tile_hash[idx] {
row_hash[y] = h; tile_hash[idx] = h;
y += 1; st.blit_rect(&fb.px[..], (tx * TILE as usize) as u16, (ty * TILE as usize) as u16, TILE, TILE);
while y < HEIGHT as usize { pushed += 1;
let hh = row_fnv(&fb.px[y * w..y * w + w]);
if hh == row_hash[y] {
break; // an unchanged row ends the dirty run
}
row_hash[y] = hh;
y += 1;
}
let band = (y - ylo) as u16;
st.blit_band(&fb.px[ylo * w..y * w], ylo as u16, band);
pushed_rows += band as u32;
} else {
y += 1;
} }
} }
if pushed_rows > 0 { }
info!("blit {} / {} rows (bands)", pushed_rows, HEIGHT as u32); if pushed > 0 {
info!("blit {} / {} tiles", pushed, (NX * NY) as u32);
} }
} }
drawn_step = cur_step; drawn_step = cur_step;