Skip to content

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.


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:

Stagebook 4KN-86 implementation
Rotate / translatematrices + trigrotate 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 projectx' = x·d/zcx = 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 cullcross-product normalsn = (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-linedraw 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-sortdepth-sorted fillm3d/face-depth (mean view-z) exposed for the shaded path; wireframe doesn’t need ordering
Drawline routinerender/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).


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:

  1. 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 vec3 transform batch behind the same m3d/draw-mesh orchestration is the lever if a launch mesh ever needs it.
  2. 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-line point — 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 exposes m3d/face-depth for 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).


  • 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 < 0 is 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 z to 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.

Terminal window
tools/sync-kec-lisp.sh
cmake -S runtime -B runtime/build -DCMAKE_BUILD_TYPE=Release && cmake --build runtime/build -j
tools/record-demo.sh --screen wirelab \
--script runtime/tools/kn86rec/demos/wirelab.rec \
--out runtime/tools/kn86rec/demos/wirelab.gif --fps 14 --tick 60