Skip to content

HUD/console: cache charset glyphs as blit-ready sprites - #30

Open
tannevaled wants to merge 1 commit into
mainfrom
hud-glyph-cache
Open

HUD/console: cache charset glyphs as blit-ready sprites#30
tannevaled wants to merge 1 commit into
mainfrom
hud-glyph-cache

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

What

The 2D overlay layer (console, menu, HUD text, centerprint) funnels every glyph through render.DrawCharacter, which per call re-derives the source origin in the 128×128 conchars sheet, walks the sheet with a strided (128-wide) read, and performs a per-pixel integer divide ((off+u)/scale) to project virtual pixels onto the HUDScale-upscaled framebuffer. That work is identical on every frame yet redone tens of thousands of times per second (a full 80×25 console screen is ~2000 glyphs × 64+ pixels).

This adds GlyphCache, a retained-mode fast path (the same idea proven in the go-news-reader UI): pre-expand each of the 256 conchars glyphs once, at the active scale, into a contiguous ready-to-blit tile. The per-frame draw then collapses to a contiguous-row copy with the same transparent-index skip — no row/col math, no strided sheet reads, no per-pixel divide, no per-call shape validation.

Technique

  • Cache key: (conchars sheet identity, scale). Tiles hold palette-indexed bytes exactly as the framebuffer does; the RGB palette is applied downstream by FrameBuffer.Expand, after the 2D layer is composited, so a palette swap (gun-flash / underwater tint) does not invalidate the cache. Only a charset reload (video-mode change) or a HUDScale change rebuilds — EnsureFor is the invalidation hook.
  • Additive only — no existing file is touched. New surface:
    • GlyphCache, NewGlyphCache(chars, scale), EnsureFor(chars, scale) (rebuilt, err)
    • GlyphCache.DrawCharacter / DrawString / DrawColorString / DrawCenteredString (drop-in cached counterparts of the free funcs)
    • Screen.DrawConsoleCached / DrawNotifyCached (cached console + notify draw)

Identical-output proof

TestGlyphCacheParityExhaustive diffs the cached blit against the free DrawCharacter byte-for-byte across scales {1,2,3} × all 256 glyphs × 7 clip positions (on-screen, top-left corner, partial top-left clip, left-only clip, bottom-right overrun, and two fully-off-screen cases). The console, notify, and string paths are each diffed against their free counterparts on identical framebuffers too. All pass → cached output is byte-identical to the existing renderer.

Benchmarks

Apple-silicon, go1.26.4, CGO_ENABLED=0, median of 4 (-benchmem):

Workload Scale Before After Speedup Allocs
Full 80×25 console screen 1 81.0 µs 66.7 µs 1.21× 0/op
2 291 µs 245 µs 1.19× 0/op
3 623 µs 517 µs 1.20× 0/op
HUD string (31 glyphs) 1 1048 ns 845 ns 1.24× 0/op
2 3585 ns 2864 ns 1.25× 0/op

Zero per-frame allocations. One-time cache build ≈ 39 µs / 72 KB at scale 2 (3 allocs), amortised over thousands of frames per video-mode change.

Tests

  • render package stays at 100.0% statement coverage (new files included).
  • Race-clean (go test -race ./render/).
  • go vet clean, gofmt clean.
  • New tests cover the cache, exhaustive parity, all error/no-op branches, invalidation (same / scale-change / sheet-change / error-leaves-untouched), and cached console/notify guards.

GPL-2.0-or-later (engine subtree), matching the files it sits beside.

🤖 Generated with Claude Code

…verlay

The 2D overlay layer (console, menu, HUD text, centerprint) funnels
every glyph through DrawCharacter, which per call re-derives the source
origin in the 128x128 conchars sheet, walks the sheet with a strided
read, and performs a PER-PIXEL integer divide ((off+u)/scale) to project
virtual pixels onto the HUDScale-upscaled framebuffer. That work is
identical every frame yet redone tens of thousands of times per second
(a full 80x25 console screen is ~2000 glyphs).

Add GlyphCache: a retained-mode fast path that pre-expands each of the
256 conchars glyphs ONCE, at the active scale, into a contiguous
ready-to-blit tile. The per-frame draw then collapses to a
contiguous-row copy with the same transparent-index skip -- no row/col
math, no strided sheet reads, no per-pixel divide, no per-call shape
validation. Output is BYTE-IDENTICAL to DrawCharacter.

Additive only: no existing file is touched. New API:
  - GlyphCache + NewGlyphCache(chars, scale) + EnsureFor (invalidation
    on charset/HUDScale change; a palette swap does NOT invalidate --
    the tiles hold palette-INDEXED bytes, the RGB palette is applied
    downstream by FrameBuffer.Expand).
  - GlyphCache.DrawCharacter / DrawString / DrawColorString /
    DrawCenteredString (drop-in cached counterparts).
  - Screen.DrawConsoleCached / DrawNotifyCached (cached console/notify).

Identical-output proof: TestGlyphCacheParityExhaustive diffs the cached
blit against the free DrawCharacter byte-for-byte across scales {1,2,3},
all 256 glyphs, and 7 clip positions (on-screen, partial top-left,
bottom-right overrun, fully off-screen). Console/notify/string paths
each diffed against their free counterparts too.

Benchmarks (Apple silicon, go1.26.4, CGO_ENABLED=0, median of 4):
  full 80x25 console screen   scale 1  81.0us -> 66.7us  (1.21x)
                              scale 2  291us  -> 245us    (1.19x)
                              scale 3  623us  -> 517us    (1.20x)
  HUD string (31 glyphs)      scale 1  1048ns -> 845ns    (1.24x)
                              scale 2  3585ns -> 2864ns   (1.25x)
Zero per-frame allocations. One-time build ~39us / 72KB at scale 2,
amortised over thousands of frames per video-mode change.

render package stays at 100% statement coverage; race-clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant