perf(render): skip frame-to-frame unchanged cells (appearance diff) - #146
Conversation
1240940 to
976b8c9
Compare
a5d05d3 to
0c0d060
Compare
|
Reauthored additively on top of the appearance-diff stack (#146), replacing the earlier #149 which was branched from main and rewrote render.lg back to direct-emit — that would have reverted the pen + appearance buffer. This keeps both wins: fewer escapes (pen/diff) AND atomic frames (sync). Wraps the frames that repaint in succession — render-full, render-dirty, and animate-vfx! (one BSU/ESU pair per animation frame; render-vfx-frame no longer flushes, the loop presents). One-shot modal screens stay on a plain flush: nothing streams after them, so there is nothing to tear. Render-only, so determinism and replay are untouched; the golden tests drive render-map-tile and don't see the frame-level sync bytes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Reviews complete:
All three PR heads are mergeable and their CI test runs are green. The stack wiring is correct: #145 → #146 → #149. After merging each lower PR, retarget/rebase the next PR onto |
render-dirty re-emits the entire visible FOV every move. But xsofy's lights are
static terrain sources (torches/lava/fire, collected once), so with the camera
held most visible cells are byte-identical to last frame — we were repainting
them for nothing. On a movement burst this is the dominant render-side cost.
Record each world cell's last drawn appearance [ch fr fg fb br bg bb] in a
buffer keyed by world coords; render-map-tile computes the cell's appearance and
skips emitting when it matches. Animated tiles (dancing water/lava, fire) differ
each frame by design and still redraw; static stone/floor/wall away from the
move drop out. Measured ~36% fewer set-fg/set-bg and ~21% fewer output bytes
over a movement burst on a water-heavy map (drier maps do better; WASM more,
since each skipped emit is a Go→JS→xterm.js crossing avoided). Visible output is
unchanged (golden comparison over a fixed input sequence).
Two invalidations keep the buffer honest — the coherence cost of the technique:
- render-map (the full-repaint path: render-full, camera shift, floor change,
resize) clears the buffer, since it draws over a freshly blanked/scrolled
screen. The FOV-delta path never calls it, so there the diff stays live.
- Entities are drawn over their tile by render-map-entities, not through the
buffer, so a moved monster would strand a ghost glyph on the vacated cell;
every current-or-former entity cell is invalidated each frame.
- vfx overlays paint raw without touching the buffer, so restore-dirty-cells!
invalidates the cells it restores or the overlay tint would stick.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cover the render-perf invariants directly, driving render-map-tile through
with-out-str and asserting on the captured escape stream plus the appearance
buffer:
- a seeded render is byte-for-byte reproducible on animated tiles (the
property that makes the FOV-delta iteration order safe to reason about);
- a static tile's color is independent of rand state (so reordering can't
touch static terrain);
- the appearance diff skips an unchanged cell and re-emits a changed one;
- the pen emits one set-fg for two same-color cells.
Closes the coverage gap noted in review: the earlier check was glyph-only and
would not have caught a color-order regression. These do.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7bd4e08 to
933504c
Compare
What
Skips redrawing map cells whose appearance hasn't changed since the last frame.
render-map-tilenow computes a cell's[glyph fg bg], compares it against aper-cell buffer of what was last drawn there, and emits only on a difference.
Visible output is unchanged — a skipped cell is one already showing the right
thing.
Stacked on the SGR-elision PR (
perf/render-sgr-elision); review/merge thatfirst.
Why
render-dirtyre-emits the entire visible FOV on every move. But the game'slights are static terrain sources (torches, lava, fire — collected once), so with
the camera held, most visible cells are byte-identical to the previous frame; we
were repainting them for nothing. On a movement burst this is the dominant
render-side cost.
Measured over a fixed movement sequence on a water-heavy map: ~35% fewer
set-fg/set-bgand ~21% fewer output bytes. Drier maps do better (lessanimation); the WASM build should benefit more, since each skipped emit is a
crossing into the JS host avoided. Animated tiles (dancing water/lava, fire)
differ every frame by design and still redraw; what drops out is the static
stone, floor, and wall away from the move.
Keeping the buffer honest
A cell's appearance is only part of what's on screen, so three cases would
otherwise leave stale pixels; each is handled:
render-map(used byrender-full, camerashift, floor change, resize) draws over a freshly blanked or scrolled screen,
so the buffer is dropped and everything redraws. The FOV-delta path never calls
it, so there the diff stays live.
through the buffer, so a monster that moved would leave a ghost glyph on the
vacated cell (the tile "didn't change"). Every current-or-former entity cell is
invalidated each frame so its tile repaints beneath the entities.
restore step invalidates the cells it redraws — otherwise the overlay tint
would stick.
This buffer-coherence bookkeeping is the real cost of the approach. It also makes
the case for a longer-term move to a full back-buffer (paint-to-buffer, diff on
flush) where that coherence is structural rather than a set of targeted
invalidations — a bigger change, noted here as the natural next step, not part of
this PR.
Safety
Render-only — no effect on the seed, action log, or world state, so determinism
and replay are unaffected. Verified by comparing the displayed cells (colors
included) over a fixed input sequence: static terrain and glyphs are identical
before and after. Animated tiles already vary run to run via the unseeded
randstream, unaffected either way.
This PR also adds golden tests that lock the losslessness down: driving
render-map-tilethroughwith-out-str, they assert a seeded render isreproducible, a static tile's color is independent of
randstate, the diffskips an unchanged cell and re-emits a changed one, and the pen emits one
set-fgfor two same-color cells. They close a coverage gap a review flagged —the earlier check was glyph-only and would not have caught a color-order
regression.