Skip to content

perf(render): skip frame-to-frame unchanged cells (appearance diff) - #146

Merged
mparrett merged 2 commits into
mainfrom
perf/render-appearance-diff
Jul 20, 2026
Merged

perf(render): skip frame-to-frame unchanged cells (appearance diff)#146
mparrett merged 2 commits into
mainfrom
perf/render-appearance-diff

Conversation

@mparrett

@mparrett mparrett commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

What

Skips redrawing map cells whose appearance hasn't changed since the last frame.
render-map-tile now computes a cell's [glyph fg bg], compares it against a
per-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 that
first.

Why

render-dirty re-emits the entire visible FOV on every move. But the game's
lights 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-bg and ~21% fewer output bytes.
Drier maps do better (less
animation); 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:

  • Full repaints clear the buffer. render-map (used by render-full, camera
    shift, 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.
  • Moved entities. Entities are drawn over their tile by a separate pass, not
    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.
  • VFX overlays paint raw over cells without updating the buffer, so the
    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 rand
stream, unaffected either way.

This PR also adds golden tests that lock the losslessness down: driving
render-map-tile through with-out-str, they assert a seeded render is
reproducible, a static tile's color is independent of rand state, the diff
skips an unchanged cell and re-emits a changed one, and the pen emits one
set-fg for 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.

@mparrett
mparrett force-pushed the perf/render-sgr-elision branch from 1240940 to 976b8c9 Compare July 8, 2026 13:08
@mparrett
mparrett force-pushed the perf/render-appearance-diff branch from a5d05d3 to 0c0d060 Compare July 8, 2026 13:08
@mparrett
mparrett marked this pull request as draft July 8, 2026 13:23
@mparrett mparrett added the perf-render Render emit performance (render.lg) label Jul 8, 2026
@mparrett mparrett mentioned this pull request Jul 8, 2026
6 tasks
@mparrett mparrett added the deploy-preview Publish a gh-pages WASM preview for this PR label Jul 8, 2026
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-20 20:45 UTC

mparrett added a commit that referenced this pull request Jul 8, 2026
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>
@mparrett
mparrett marked this pull request as ready for review July 9, 2026 02:19
@mparrett

mparrett commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

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 main before merging it.

mparrett and others added 2 commits July 20, 2026 11:44
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>
@mparrett
mparrett force-pushed the perf/render-appearance-diff branch from 7bd4e08 to 933504c Compare July 20, 2026 18:45
@mparrett
mparrett changed the base branch from perf/render-sgr-elision to main July 20, 2026 18:45
@mparrett
mparrett merged commit 1f0ec33 into main Jul 20, 2026
3 of 4 checks passed
@mparrett
mparrett deleted the perf/render-appearance-diff branch July 28, 2026 13:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

deploy-preview Publish a gh-pages WASM preview for this PR perf-render Render emit performance (render.lg)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants