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

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
@nooga

nooga commented Sep 10, 2026

Copy link
Copy Markdown
Owner

let's undraft, we are doing it 🚀

@mparrett

Copy link
Copy Markdown
Collaborator

@nnunley #140 had fallen 46 commits behind main, so I rebased it for you to take or leave. It's your PR and your branch, so I won't push to gl-frontend; how it lands is your call.

The branch is mparrett/xsofy:experiment/gl-frontend-v2: your 16 commits, then #180's 4, cherry-picked onto main at 0c38395, plus two new commits at the end. Your commits keep you as author; the committer field shows me because the cherry-pick ran here.

Three ways it could go, in the order I'd suggest:

  1. Force-update GL frontend experiment: hardware-rendered xsofy + level-2 fall into first person #140 from it. One push, and Top-down perf: cull never-seen cells (22x), plus frame timing and two input fixes #180 closes as folded in:
git fetch https://github.com/mparrett/xsofy experiment/gl-frontend-v2
git push --force-with-lease=gl-frontend:cc43ffb origin FETCH_HEAD:gl-frontend
  1. Redo the rebase yourself and use that branch as the reference answer. Only two files conflicted:
    • xsofy/test/run.lg: main switched to run-all-tests for let-go 1.13's clojure.test port. I kept main's form and appended your five test namespaces.
    • xsofy/title.lg: main wrapped each frame of show-descending-screen in sfx/with-frame (refactor(render): (with-frame ...) — RAII-style synced frame boundaries #174). I kept main's body and grafted in only your XSOFY_GL_FRONTEND guard. Taking your side would have dropped both with-frame calls. The guard counts characters because os/getenv returns "" for an unset variable, not nil.
  2. A replacement PR from that branch, if you'd rather hand it off.

Verified on let-go main

Built let-go main (36d57e36) twice, with -tags glplat and with -tags glplat_ebiten, and ran the branch on each (macOS, arm64):

Check GLFW Ebitengine
Suite 475 tests, 0 failures same
Boot to gameplay, clean exit yes yes
Title, fall, first person yes renders; first person mis-sorts (no depth buffer)
Culled top-down frame byte-identical to pre-cull yes yes
Cells / cpu per frame, seed 42 2,370 → 40, ~365 → ~17 ms 2,370 → 40, ~382 → ~17 ms
One move per keypress, Shift+K runs, arrows move and repeat yes yes
Glyphs upright yes yes

The replay-test hang nooga saw in #180's latest CI leg on 2026-09-20 came from the old base; the suite finishes on the new one.

The two new commits

  • 2d57261 moves parse-glplat-event out of main_gl.lg into a pure xsofy.gl.events namespace, since main_gl.lg runs -main on load and nothing could require it from a test. events-test covers the Top-down perf: cull never-seen cells (22x), plus frame timing and two input fixes #180 input fixes, which nooga noted had no test. Putting the double-move back fails it.
  • c535ab3 ignores bare modifier keys. Both backends send key:lshift and the like, which dispatched as an unbound key and cleared :running, so pressing Shift mid-run stopped the run. Ebitengine also auto-repeats modifiers, so holding Shift there stopped it about 20 times a second.

Two questions

  • How does a player run it? The PR body points at the glplat branch build from let-go#392, which is closed. Today it takes let-go main built with -tags glplat (cgo, GLFW) or -tags glplat_ebiten, then lg main_gl.lg; released lg binaries register no backend. Do you want a short "running the GL frontend" section, and should it name one backend as the default?
  • First person on Ebitengine. C4 in let-go's backend contract makes painter's order the portable guarantee, and first person currently leans on the GLFW depth buffer. Would you ship first person as GLFW-only for now, or hold it for a back-to-front sort?

This branch has not been deployed

No deployments
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.

3 participants