Skip to content

GL frontend experiment: hardware-rendered xsofy + level-2 fall into first person - #140

Draft
nnunley wants to merge 16 commits into
mainfrom
gl-frontend
Draft

GL frontend experiment: hardware-rendered xsofy + level-2 fall into first person#140
nnunley wants to merge 16 commits into
mainfrom
gl-frontend

Conversation

@nnunley

@nnunley nnunley commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Experiment branch (draft): the whole game rendered through a minimal OpenGL platform layer, with entering depth 2 tipping the playfield into the floor of a 3D world and continuing in first person.

Runs on the let-go glplat branch build (nooga/let-go#392; async parity in nooga/let-go#391):

XSOFY_SEED=42 <let-go>/lg main_gl.lg

What's in the branch

  • main_gl.lg on the unified poll-based event loop (xsofy.ui, Responsive UI event loop: composable, replay-safe input + cosmetic animation #56): glplat input source + clock source, pure step fold, render subscriber — same tick-vs-input contract as the terminal, so cosmetic animation never touches the deterministic (seed, action-log) stream.
  • Presentation state machine: :title (pulsing rune particles, any key) → :top-down → :falling (2.5s camera descent + ortho→perspective morph on real stairs descent) → :fp (grid-step, turn-based, billboarded entities).
  • xsofy/gl/: mat4, grid adapter (floor quads + extruded walls tinted from the lit tile palette with per-face shading), bridge (FOV/lighting/stains/gases), fall morph, fpview, vfx overlays (projectiles/bolts/flashes), HUD (status line, HP bar, log tail; spell-rune glyphs render 2-cells-wide with 1-cell advance so inscriptions overlap into composite sigils, matching the native-terminal look).
  • Atlas pipeline in lg: tools/bake_atlas.lg + assets/gl/fonts.edn bake Fairfax HD (the web shell's single face) at its native 12x24 grid with binarized alpha; ui/chan-source added for push-based producers now that let-go has poll!.
  • Env hooks for headless verification: XSOFY_GL_AUTOEXIT, XSOFY_GL_SHOT[_AT] (frame → PNG), XSOFY_GL_FORCE_FALL, XSOFY_GL_SKIP_TITLE, XSOFY_GL_DUMP.

Suite green throughout (416 tests, 2988 pass). Known gaps: no browser/WebGL backend yet, screenfx tint flash stub, no smooth FP camera lerp, no FOV culling of submitted geometry.

nnunley added 16 commits July 3, 2026 16:06
…cale/translation, look-at z sign; reinstate + strengthen tests
Implement grid.lg: the adapter that converts xsofy's rendered glyph grid
(cells with character, fg/bg colors) into vertex batches for GL rendering.

Public API:
- cell-quad: Convert a glyph cell to textured vertex quad (54 floats)
- cell-bg-quad: Convert cell background to untextured quad
- grid-verts: Batch cells into bg and glyph vertex vectors
- wall-extrude-verts: Extrude a cell into 4 CCW-wound wall faces (216 floats)

Coordinate system: cell (col, row) at world x = col*cell-w, y = -row*cell-h, z = 0.
Cell width defaults to 0.5, height to 1.0; walls extrude upward (+z).
Colors normalized from [0-255] to [0,1]; glyphs fallback to "?" if missing.

Tests (grid_test.lg): 15 tests covering float counts, vertex positions/UVs/colors,
alpha-zero filtering, fallback logic, batch separation, and wall geometry.
…able

GLYPH INVENTORY: collected from terrain, entities, items, runes (140 total glyphs).
- assets/gl/runes.txt: UTF-8 string of all glyphs (ASCII 0x20-0x7E + Unicode runes)
- Baked with lgatlas (Monaco.ttf): 128x256 texture, 16x16 grid (cell 8x16)
- assets/gl/atlas.{png,edn}: texture and UV lookup table

RENDER BRIDGE (xsofy/gl/bridge.lg):
- Reuses render.lg terrain/entity composition logic without modification
- Extracts grid cells as {:col :row :glyph :fg :bg} per cell
- Applies: lighting (light/apply-light), stains (fx/stain-tint-*), gases (fx/gas-tint)
- FOV/memory/unseen cells handled: visible cells lit, remembered cells dimmed, unseen black

MAIN LOOP (main_gl.lg):
- Init GL window via glplat, load atlas texture
- Build game state via build-game (skips title screen)
- Frame loop: PollInputEvents → parse glplat strings → dispatch actions → bridge → grid-verts → submit
- Input mapping: "key:w" → "w" → input/parse-key → :unknown | "key:up" → "[A" → :up
- Ortho projection: 0–39.5 (79*0.5) on x, 0–30 on z; draw bg (tex 0) then glyphs (atlas tex)
- Auto-exit via XSOFY_GL_AUTOEXIT env var (seconds); XSOFY_GL_DUMP flag logs first frame vertex counts

VERIFICATION:
- Test suite: 362 tests, 2880 assertions, all PASS ✓
- GL version: 2-second run, 2370 cells rendered, 14220 verts/batch, lighting visible ✓
- Batch output: bg=[0 0 0 1.0] (untextured), glyph=[uvs] in atlas

NOTES FOR NEXT WAVE (fall+first-person):
- Screenfx tint flash (if used on level 2) will be a full-screen quad post-submit
- no audio calls stubbed; sound system may need wrapping at loop level
- Wall extrusion ready in grid.lg; fall.lg morph parametrization ready
- Determinism preserved: no graphics side effects on seed/action-log
…lyph stub), read-string number parsing (env parsers silently failed -> infinite loop), os/exit after -main (GLFW keeps process alive), print instead of io flush! on *out*
Implements the centerpiece of GL frontend experiment: level-2 entry triggers
playfield-to-floor transition (2.5s morph), followed by first-person mode.

LEVEL-CHANGE DETECTION:
- Track depth (:depth in world state) before/after dispatch
- On depth increase (detected in advance-pres-state), trigger fall animation
- Presentation-side state machine: :top-down → :falling → :fp (game state untouched)
- XSOFY_GL_FORCE_FALL env hook: start animation N seconds after boot for testing

MORPH RENDERING (:falling mode, 2.5s duration):
- fall/morph provides: plane-tilt-rad, wall-height, camera {eye/target/up}, projection-blend
- Component-wise MVP lerp: ortho → perspective as projection-blend (t: 0→1)
- Plane tilt: rotate-x about player pivot, composed into model matrix
- Wall extrusion: wall-extrude-verts for all wall cells (tiles 8, 9) with dynamic height

FIRST-PERSON MODE (:fp):
- Perspective projection: ~70° FOV, camera 0.5 units above player, facing heading
- Dungeon-crawler input remapping: (up/k=forward, down/j=back, left/h=turn-left,
  right/l=turn-right in place, turn changes presentation-side facing only)
- Billboards: entity glyphs as upright quads facing camera (using yaw from facing)
- Wall boxes: same extrusion as morph but fixed 1.0 unit height
- Geometry culling: only render visible cells (FOV + memory + non-black cells)

DUMP METRICS (:falling only for now; can extend to :fp):
- Per-frame at t≈0, t≈0.5, t≈1: mode, t, camera eye/target, batch vertex counts
- Sanity checks: wall-batch non-empty on maps with walls; camera eye y descends
- Exit message includes final mode

fpview.lg: new namespace for FP view logic:
- remap-fp-input: input → action + facing change (turn-only flag for presentation)
- facing-to-angle: yaw from cardinal direction (for billboards)
- fp-camera-pose: camera {eye/target/up} from player pos + heading
- entity-billboard: upright glyph quad at entity cell, colored from entity visual
- Wall tile predicate: tiles 8, 9 (can extend if needed)

Test suite: 362 tests, 2880 assertions, all pass. Game logic untouched.
…op double model-tilt, 70-degree fovy (was 7), fall.lg camera in world units, force-fall no longer re-arms t=0 every frame
…eal descend fall trigger

- main_gl: advance-pres-state args were swapped (compared old>new depth) so a
  real stairs descent never triggered the fall; only XSOFY_GL_FORCE_FALL worked
- main_gl: auto-modes (autoexplore/rest/run/auto-target) now step every 60ms
  between inputs, mirroring play.lg's tick loop; keypress interrupts
- gl/vfx.lg: tick + overlay quads for :projectile/:bolt/:flash world vfx,
  lifted y=0.02 over the floor; wired into all three render modes
- gl/hud.lg: screen-space HUD (status line, HP bar, last 3 log messages) drawn
  in a second pass with near=0 ortho so it lands at NDC depth -1 (always on
  top under LEQUAL); fixed nested-vector corruption in hud-verts solids batch
- title.lg: show-descending-screen no-ops under XSOFY_GL_FRONTEND (set by
  main_gl) — it painted runes into the launching terminal mid-descent
Tests: 409 tests, 2968 pass, 0 fail
…sigil overlap

- assets/gl: atlas rebaked from Fairfax HD (the web shell's one true face,
  tools/fonts/.cache) — 159 glyphs, everything the sources emit incl. the
  box-drawing projectile glyphs (─│╱╲) whose absence made projectiles
  invisible, plus full runic coverage (Monaco lacked 33 glyphs; terminal
  only looked right via macOS font fallback)
- tools/bake_atlas.lg: baking policy now lives in lg (layout, glyph list,
  EDN/PNG writes) over four minimal glplat primitives (FontLoad,
  FontHasGlyph, FontRasterizeCell, SaveGlyphAtlasPNG); assets/gl/fonts.edn
  is the font manifest; replaces let-go's cmd/lgatlas Go CLI
- gl/hud.lg: spell-rune glyphs render 2 cells wide with 1-cell advance so
  consecutive runes overlap into a composite sigil — reproduces the native
  terminal look (halfwidth grid + wide fallback glyphs), which Fairfax
  (strictly halfwidth, measured) does not produce on its own
- tools/xsofy-shell.html: regenerated by gen-terminal-font.py (font fetch)
Tests: 410 tests, 2972 pass, 0 fail
- glplat/Screenshot (glReadPixels → PNG, pre-swap) + XSOFY_GL_SHOT /
  XSOFY_GL_SHOT_AT env hooks in main_gl so frames can be inspected headlessly
- fonts.edn cell 8x16 → 12x24: Fairfax HD is a bitmap-style face with a
  native 12x24 grid; at 16px every stroke straddled pixels and antialiased
  to faint hairlines (screenshot-diagnosed unreadable HUD)
- bake_atlas.lg: binarize alpha (>=96 → solid) for terminal-crisp glyphs;
  atlas now 256x512, zero partial-alpha pixels
Verified by screenshots: top-down and mid-fall frames, HUD/messages legible
wall-extrude-verts takes :fg (0-255) and shades per face (south 1.0,
north 0.8, x-faces 0.65); build-wall-batch feeds it the bridge cells'
lit style colors, so extruded walls follow the palette and lighting
instead of rendering fullbright white. Screenshot-verified mid-fall.
Tests: 411 tests, 2980 pass, 0 fail
Unset XSOFY_GL_SHOT read back as "" (truthy), so 1s in the loop called
Screenshot("") which threw; with no catch, the process exited 0 silently
— looked like a clean quit. getenv-str helper (unset/empty → nil) now
guards all env reads; the frame loop prints exceptions instead of dying
silently; title.lg's XSOFY_GL_FRONTEND guard tests count, not nil (it was
always-on, wrongly suppressing the terminal descending screen too).
Verified: plain run alive 15s+; AUTOEXIT/SHOT/DUMP paths still work.
Tests: 411, 2980 pass, 0 fail
- main loop now drives xsofy.ui/run-loop: glplat-input-source ([:key ev],
  pumps GLFW) + ui/clock-source, pure gl-step fold, gl-render subscriber.
  Same tick-vs-input contract as the terminal: sources polled input-first
  so a keypress cancels auto-mode before the tick's auto-step; cosmetic
  ticks never enter the deterministic action stream. frame-ms 0 — vsync
  paces via EndFrame.
- :title presentation mode (default entry): scattered scheme-tinted runes
  pulsing via screenfx/rune-brightness at the terminal's 120ms frame pace,
  title fade-in, 'Press any key', seed line; any key enters gameplay.
  XSOFY_GL_SKIP_TITLE=1 for scripted runs.
- fall trigger also checked at key-dispatch time (depth increase), not
  just per-tick
Screenshot-verified title; force-fall runs reach :fp and exit on time.
Tests: 411, 2980 pass, 0 fail
let-go now ships poll!/offer!/alts! (nooga/let-go#194), so the chan-source
adapter deferred in the #56 spec is buildable: producer goroutines push
events into a channel; the source drains it non-blockingly via async/poll!
(capped 16/tick like input-source). The loop contract is unchanged —
sources still never block, so WASM's single thread never parks the frame
loop; goroutines under Go-wasm park/wake through the JS event loop.
Stale 'deferred until #194' notes updated. Tests cover empty/order/splice/
flood-cap and a real (go …) producer.
Tests: 416, 2988 pass, 0 fail
@mparrett

Copy link
Copy Markdown
Collaborator

We rebased this branch onto current main for a downstream experiment and profiled the top-down path along the way. The results are in #180, based on this branch's tip (cc43ffb) rather than a rebase, so it merges with a button and nothing of yours moves.

The headline: culling cells that are neither in FOV nor remembered takes the top-down frame from ~400ms to ~18ms at seed 42, and the captured frame is byte-identical before and after. Also included are frame timing behind XSOFY_GL_PERF and two input fixes — every keypress was moving two tiles, and the arrow keys were dead entirely.

Independent of that PR: the test suite hangs on this branch's current base. It reaches decode . encode = identity on (seed, actions) in replay-test and then allocates without bound, 12.4 GB before we killed it. Clean cc43ffb reproduces it with none of our commits applied, and current main runs the same suite in 60 seconds with 444 tests and 3,066 assertions green, so it appears to be fixed upstream already. That may be worth a rebase on its own.

@mparrett
mparrett self-requested a review July 27, 2026 05:48
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.

2 participants