Vector / 3D Wireframe Engine — Prototype & Perf Characterisation (GWP-657)
(synthesis-atari-graphics.md §3, book 4 — “ST 3-D
Graphic Programming”). Gated on — and now consuming — GWP-654’s keystone line
primitive render/half-block-line (PR #81).
Artifacts: engine + demo runtime/programs/wirelab/wirelab.lsp; test
Serves: attract / boot (assigned by Josh; see cart map)
runtime/tests/test_wirelab.c; GIF runtime/tools/kn86rec/demos/wirelab.gif.
1. What landed
Section titled “1. What landed”A mesh-agnostic low-poly 3D wireframe engine (m3d/…) written in pure Fe,
plus a demo screen (wirelab) that tumbles a cube through it and draws it with
only the keystone line FFI — the Elite / Star Raiders / Tron amber-vector
look, on the real 1024×600 surface.
The pipeline, book 4 translated to KN-86:
| Stage | book 4 | KN-86 implementation |
|---|---|---|
| Rotate / translate | matrices + trig | rotate about Y then X; per-frame sin/cos hoisted out of the per-vertex loop (the sin/cos-table principle on a host where trig is a cheap C builtin) |
| Perspective project | x' = x·d/z | cx = 64 + focx·x/z, cy = 75 − focy·y/z; focy = 2·focx for the 8×4-px half-block aspect (a unit projects to 2× the half-blocks vertically) |
| Backface cull | cross-product normals | n = (p1−p0)×(p2−p0) (outward, faces wound CCW-from-outside); visible iff n·p0 < 0 (eye at origin). Exact for a planar convex face — no centroid walk |
| Hidden-line | — | draw only visible faces’ edges → on a convex solid, back-only edges are never drawn; silhouette edges are. Clean hidden-line cube, no extra machinery |
| Painter’s depth-sort | depth-sorted fill | m3d/face-depth (mean view-z) exposed for the shaded path; wireframe doesn’t need ordering |
| Draw | line routine | render/half-block-line (GWP-654) over the 128×150 half-block canvas — no new C seam |
Everything rides the GWP-644 idle-timer (run-with-timer), armed lazily and
substrate-guarded, exactly like fireplace / animlab / linelab. Rows 0 + 74
stay firmware-owned.
The cull is visible on-glass: the HUD reads FACES n/6 VISIBLE, which holds at
1 face-on and 3 through a generic tumble — the convex-cube silhouette.
test_wirelab.c pins the math headlessly: 8 verts / 6 faces; perspective shrinks
with depth; the named near face is visible and the far face culled at rot 0 (the
winding-sign anchor); a generic rotation shows exactly 3 of 6; m3d/draw-mesh
draws exactly the visible count (3 culled, 6 unculled).
2. Perf characterisation
Section titled “2. Perf characterisation”Measured in the live wirelab program context (the real arena + GC), Apple
Silicon dev host, Release, 40 000-iteration batches (≈0.8 s each; consistent to
the 0.1 µs across runs).
| What | µs / frame (dev host) |
|---|---|
| Transform + cull only (8 verts, 6 face tests) | ~14 µs |
+ the ~12 visible-edge render/half-block-line calls | ~20 µs |
| Cull off (all 6 faces, ~24 edges, no cull math) | ~19 µs |
| → the line-FFI draw portion | ~6 µs |
The math dominates (~14 µs); the C Bresenham draw is cheap (~6 µs for 12 edges).
Methodology note. The monotonic clock (now, CLOCK_MONOTONIC) quantises at
~15 µs here — coarser than a single frame. A single-frame or small-batch timing
is therefore unreliable (it floors to 0 or jumps a whole tick); only large-batch
means are trustworthy. This is why there is no per-frame µs on the HUD (an
on-glass calibration micro-bench both fought the clock and stressed the arena for
a cosmetic number) — the characterisation lives here instead.
Pi Zero 2 W extrapolation. A Cortex-A53 @ 1 GHz (in-order) vs an Apple performance core on a branchy, pointer-chasing tree-walker runs ~20–30× slower single-thread. So the cube costs an estimated ~0.4–0.6 ms/frame on-device. Against the idle-timer’s 16 fps cadence (62.5 ms/frame budget), that is well under ~1 % of the frame budget. Even a ~30-vertex ship (~3–4× the cube) lands near ~1.5–2 ms → ~3 % of budget. This is an extrapolation pending on-device validation at bring-up; the device isn’t available in the dev loop.
3. The design fork — where the transform math lives
Section titled “3. The design fork — where the transform math lives”The roadmap flagged this thread “Highest risk… transform math wants C; pre-bake frames if needed.” The task asked to characterise before committing. Done:
Recommendation: keep the transform math in pure Fe for low-poly wireframe.
The measured headroom is decisive — a tumbling cube is ~1 % of the on-device frame budget in the interpreter, with no new C surface, no host-CMake src-list duplication to keep in lockstep, and the whole thing stays a reversible, in-tree Lisp program. The roadmap’s “wants C” was a conservative guess for the ST’s FPU-less 68000; on a Pi Zero with cheap host trig it does not hold for low-poly wireframe. No C transform core is warranted by this prototype, and pre-baked frames are unnecessary for the cube/ship attract use — live is far cheaper than a frame cache.
Where C would earn its keep — the documented escape hatches, not built here:
- Dense meshes (hundreds of vertices). The ~14 µs math scales ~linearly in
vertices; a 300-vertex model ≈ 0.5 ms × ~25 ≈ ~12 ms on-device — still inside
budget, but the margin narrows. A C
vec3transform batch behind the samem3d/draw-meshorchestration is the lever if a launch mesh ever needs it. - Dither-shaded filled faces (the book-4 “solid” look). This is the real
cost cliff: there is no polygon-fill FFI, so a shaded face would paint each
lit half-block as its own
render/half-block-linepoint — O(area), ~hundreds of FFI calls per face per frame. That wants a C span/scanline-fill primitive (or a dither-fill FFI), and is the natural next ADR if shaded 3D is wanted. The engine already exposesm3d/face-depthfor the painter’s order it needs.
So the fork resolves to: pure Fe now; C is a targeted, later, optional lever for (1) density or (2) shading — Josh’s call on whether either is on the roadmap.
4. Reuse — this is the foundation, not a one-off
Section titled “4. Reuse — this is the foundation, not a one-off”The sprint’s other two threads draw vector shapes: the generative attract pipeline
(GWP-655) and the arcade skeletons (GWP-658). The engine is built mesh-agnostic
for them: m3d/draw-mesh takes any (verts . faces), any rotation, any camera
distance, with cull on/off. The cube is just the first caller.
Extraction path: when a second caller lands, lift the m3d/… block
(constants + transform / transform-mesh / visible? / face-depth /
draw-mesh) verbatim into a shared system-image/lib/render/mesh3d.lsp and add
it to the lean program dep set — it depends only on render/half-block-line,
sin/cos, and the vector builtins. It is kept self-contained in wirelab.lsp
for now to avoid loading 3D math into every program context before anyone needs
it (the per-program arena-scaling discipline — each lib loaded into the shared
program context costs resident arena).
5. Honest limits (prototype scope)
Section titled “5. Honest limits (prototype scope)”- Convex-only hidden-line. “Draw visible faces’ edges” is correct hidden-line for convex solids (cube, ship hull). A concave mesh needs true per-edge / z-buffer hidden-line — out of scope.
- Flat-face backface test.
n·p0 < 0is the standard planar-face test; with strong perspective on a near silhouette it can disagree with a per-pixel test by a face. At the demo’s camera distance (5.5, radius √3) it is stable at 3. - No near-plane clipping of edges. The transform clamps
zto a near guard so projection never divides by ~0, but an edge crossing the near plane isn’t split. Fine for a centred tumbling solid; a fly-through would need segment clipping. - Perf is a dev-host measurement + extrapolation, not on-device — see §2.
6. Record the demo
Section titled “6. Record the demo”tools/sync-kec-lisp.shcmake -S runtime -B runtime/build -DCMAKE_BUILD_TYPE=Release && cmake --build runtime/build -jtools/record-demo.sh --screen wirelab \ --script runtime/tools/kn86rec/demos/wirelab.rec \ --out runtime/tools/kn86rec/demos/wirelab.gif --fps 14 --tick 60