Skip to content

perf(render): elide redundant SGR escapes in the map render - #145

Merged
mparrett merged 1 commit into
mainfrom
perf/render-sgr-elision
Jul 20, 2026
Merged

perf(render): elide redundant SGR escapes in the map render#145
mparrett merged 1 commit into
mainfrom
perf/render-sgr-elision

Conversation

@mparrett

@mparrett mparrett commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

What

Adds a small render-only "pen" to the map render that skips re-emitting a set-fg/set-bg whose color already matches the last one emitted. move-cursor and write are untouched, and iteration order is unchanged, so the output is the same — same glyphs, same colors, fewer escape sequences.

Why

term's set-fg/set-bg write an SGR escape on every call, and the map loop calls them for every cell unconditionally. SGR attributes persist across cursor moves, so any cell whose color equals the previous one's is re-sending bytes for no visual change. On native that's wasted output; on the WASM build each call is a crossing into the JS host and xterm.js, so the waste is larger there.

How it works

  • Eliding across cursor moves is safe. SGR state is sticky: moving the cursor doesn't reset the pen color, so skipping a redundant set-fg is correct as long as nothing reset the terminal's color in between.
  • Reset discipline. The pen mirrors the terminal's current color, valid only while every styled write goes through it. Panels, vfx overlays, and the full-clear emit raw and leave it stale, so each contiguous penned batch resets the pen before its first cell.

Where it helps

Elision fires where same-color cells are already adjacent in emit order, so it does most of its work on the full-screen repaints — floor change, camera pan, resize — which walk the map row-major and are the biggest single render spikes. The per-move FOV-delta path iterates in set order and elides little; that's left as-is on purpose. Sorting it into runs would raise the elision rate slightly, but fire/water/lava tiles draw their color from the global rand stream, so reordering iteration would change which cell consumes which draw. Harmless in itself (that stream is unseeded, so the shimmer already varies run to run) but a needless behavior change for ~1% fewer escapes, so base iteration order stands.

Scope

Modest on its own — most frames are per-move FOV-delta renders, where there's little to elide. Its value is cutting the full-repaint spikes, and serving as the foundation for a frame-to-frame appearance diff (a follow-up PR stacked on this one), which is where the bulk of the render savings come from.

Safety

Rendering is a pure side effect — it doesn't touch the seed, action log, or world state — so this cannot affect determinism or replay. Static terrain and glyphs render identically before and after (verified by comparing the displayed cells, colors included, over a fixed input sequence); animated tiles already vary run to run via the unseeded rand stream, unaffected either way. Existing test suite passes unchanged.

let-go's `term` is a raw-emit primitive — set-fg/set-bg write an escape on
every call — so the map loop re-sends identical fg/bg for every cell, even
across same-color runs. SGR attributes persist across cursor moves, so those
repeats are pure waste: bytes on native, and a Go→JS→xterm.js crossing per
call on WASM.

Add a render-only "pen" that tracks the last emitted fg/bg and skips a
set-fg/set-bg whose color already matches. move-cursor and write are untouched,
so it's lossless — identical glyphs and colors, fewer escapes. Each contiguous
penned batch (render-map, the render-dirty delta loop, restore-dirty-cells!)
resets the pen first, because panels, vfx overlays, and the full-clear emit raw
and leave it stale.

Elision fires where same-color cells are already adjacent in emit order — most
of all on the full-screen repaints (floor change, camera pan, resize), which
walk the map row-major and are the biggest single render spikes. The per-move
FOV-delta path iterates in set order and elides little; it is deliberately left
unsorted, since some tiles draw their color from the global rand stream and
reordering would reshuffle which cell consumes which draw for no real gain.

Modest overall, and also the scaffolding the frame-to-frame appearance diff
(next) builds on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mparrett
mparrett force-pushed the perf/render-sgr-elision branch from 1240940 to 976b8c9 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 marked this pull request as ready for review July 8, 2026 13:44
@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 18:36 UTC

mparrett added a commit that referenced this pull request Jul 8, 2026
…itter

Review feedback on #148:
- Merge :demo?/:demo-mode into one :demo-mode :none|:liss|:bounce; p cycles
  all three, drop P.
- Replace :naive? bool with :render-mode :delta|:sgr-off|:naive; e cycles.
  :sgr-off keeps the damage buffer + cursor elision on and only re-emits SGR
  per drawn cell, isolating the #145 SGR-elision win; :naive is the full
  repaint-everything baseline.
- Fixed-width HUD slots (lpad numbers, pad enums) so coords/counters no longer
  push the rest of the line sideways frame-to-frame.
- Repaint one frame when demo toggles off, so the HUD shows demo:off
  immediately instead of leaving a stale animated frame up until the next key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@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 added a commit that referenced this pull request Jul 20, 2026
* tool: lighttest — interactive lighting + render-perf testbed

A standalone dev tool (like tools/mapgen.lg): generate a dungeon with
xsofy.terrain, drop a movable light, and render the lit result live — no game
loop, monsters, or turns. Reuses only the pure terrain/lighting/fov functions
plus terminal I/O, so it isolates how the lighting reads without the rest of the
game in the way.

Run it: `lg tools/lighttest.lg` (or with config: `lg tools/lighttest.lg '{:seed 42}'`).

Drive the light with the keyboard (arrows/hjkl/yubn) or let a screensaver demo
fly it on a Lissajous or edge-bounce path; toggle wall occlusion, terrain
lights, radius, and color. It doubles as the probe behind the render-perf lane:
a live delta-vs-naive render A/B with a drawn/sgr readout, a color-depth
posterize knob, and a synchronized-output (DEC 2026) toggle, all shown in the
HUD. Frame model is a poll loop (key-pending? + sleep) that blocks when idle and
animates when the demo is on, with resize handled by a full repaint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* tool(lighttest): collapse demo-mode enum, cycle render A/B, fix HUD jitter

Review feedback on #148:
- Merge :demo?/:demo-mode into one :demo-mode :none|:liss|:bounce; p cycles
  all three, drop P.
- Replace :naive? bool with :render-mode :delta|:sgr-off|:naive; e cycles.
  :sgr-off keeps the damage buffer + cursor elision on and only re-emits SGR
  per drawn cell, isolating the #145 SGR-elision win; :naive is the full
  repaint-everything baseline.
- Fixed-width HUD slots (lpad numbers, pad enums) so coords/counters no longer
  push the rest of the line sideways frame-to-frame.
- Repaint one frame when demo toggles off, so the HUD shows demo:off
  immediately instead of leaving a stale animated frame up until the next key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* tool(lighttest): add :mapshift demo mode for the tearing A/B

A single moving light barely dirties the frame, so synchronized-output
tearing is hard to judge. :mapshift scrolls the whole terrain under a fixed
light (toroidal wrap, one cell/frame diagonal), changing every non-void cell
every frame — coherent full-screen motion, the canonical tearing stressor.
Cycles in via p (off/liss/bounce/mapshift); leaving the mode resets the
scroll offset. Terrain is sampled at the shifted coord; lighting/FOV stay at
screen coords so the light pool holds still while the map flows through it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@mparrett
mparrett merged commit 57f90ad into main Jul 20, 2026
10 checks passed
@mparrett
mparrett deleted the perf/render-sgr-elision 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) review-priority/medium Review after bases land / once rebased

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants