Stage 9 — colour on the wire, faces on the screen¶
HISTORICAL — the $FF40 record interface this stage built was RETIRED at stage 10b (STAGE10-DESIGN.md): the GL command port at $FF50 is the hardware 3D path now, reusing this stage's transform/clip/draw pipeline unchanged. Kept as the design record of that pipeline.
Stage 8b's engine draws white lines. This stage gives every primitive its own colour and adds the first FACE: a 3D triangle with a fill option — the polygon primitive (an N-gon is a fan of triangles, and the fan is the caller's one-liner; hardware gets the primitive everything tessellates to). Two sub-stages, each shipped end-to-end before the next begins:
- 9a — colour per record. The list format grows a header; every record carries its own RGB565 colour. LINE records only.
- 9b — the TRI record, outline or filled.
The list format: TYPED RECORDS (replaces the raw 12-byte edge)¶
byte 0 type 1 = LINE, 2 = TRI
byte 1 flags bit0 = FILL (TRI only)
bytes 2-3 colour RGB565, little-endian
then vertices int16 x,y,z per vertex, little-endian
LINE: 2 verts -> 16-byte record
TRI: 3 verts -> 22-byte record
Records are even-sized (halfword walker unchanged) and self-sizing (the walker advances by type). The count parameter becomes a RECORD count. There is no compatibility shim with the stage-8b format: bitstream and disk ship together, as ever — but the change is why cube.bin and the engine must move in one sync.
9a: colour per record¶
- lib_g3d grows
g3color(c)— the POOL pen, default white;g3linestamps it into the record.g3render's software walk sets the device pen per record; the engine writes GCOL/GCOLH per record before its LINE. The engine's erase is unchanged (pen 0), and it no longer forces white afterwards — the device pen is left holding the LAST record's colour (same "set your pen after" rule the image command documents). - cube.c colours its rings (bottom red, top green, verticals blue) — rotation becomes legible at a glance, and the demo doubles as the colour test.
9b: the TRI record¶
Outline (flags.FILL=0): after transform + near clip + projection + viewport map, each polygon edge is Cohen-Sutherland-clipped in SCREEN space against the viewport box, then drawn as a LINE. (LINE records keep their established window-space clip; both are exact — the outline clips post-map so it shares the fill's vertices.)
Filled is the new algorithm, and its clipping is the reason it is simple: a filled triangle needs NO Cohen-Sutherland. Pipeline:
- Transform the 3 vertices (matrix, as ever).
- Near clip the polygon against z = Z3NEAR: walk the edges v0v1, v1v2, v2v0; keep in-front vertices, insert the muldiv intersection where an edge crosses. Yields 0, 3 or 4 vertices; a quad (a,b,c,d) fans into (a,b,c) + (a,c,d). (This is the one place a triangle becomes two.)
- Project AND viewport-map each vertex — fill happens in SCREEN SPACE, so every implementation rounds the same three points the same way.
- Scanline fill, clamped to the viewport box (clamping against an axis-aligned rectangle is exact — that is what replaces CS here):
- sort the mapped vertices ascending by y (comparison-swap network; equal y keeps record order),
- for each y from y0 to y2 inclusive, skip y outside the viewport,
- span ends by muldiv interpolation, operand order fixed: long edge xa = px0 + muldiv(y - py0, px2 - px0, py2 - py0) split edge xb = against v0v1 while y < py1, else v1v2 (py2 == py0: the whole thing is one scanline; span = min..max x),
- clamp both ends to [vx0, vx1], skip empty spans,
- emit the span as the device's own BOXFILL with y0 = y1 = y — one register-poked command per scanline, so the SOFTWARE walk, the EMULATOR engine and the RTL all issue the identical device op and the pixels cannot disagree. The span machinery (gfx_span pair writes) makes it fast for free.
Cost per filled scanline: 2 muldivs + a BOXFILL issue (~40 + ~60 cycles) plus the device's pair-write fill — a 100-line triangle in the order of 20k cycles, engine-side. The CPU still writes ~30 pokes a frame.
API¶
g3color(c) pool pen for subsequent records (default white)
g3line(x0,y0,z0,...) unchanged signature, now stamps the pen
g3tri(p, fill) p = int[9]: three x,y,z vertices; fill 0/1
(an N-gon: fan g3tri calls, one line of C)
g3up/g3go/g3render/g3flags/g3flip/g3sync are unchanged. The software fallback renders both record types with the same math — the identity- matrix byte-compare (engine vs software, one framebuffer) remains the crown-jewel test and now covers colour and fill.
Verification ladder (per sub-stage, the usual order)¶
- Emulator ge_render walks typed records (golden model).
- lib_g3d + cube (9a: coloured rings; 9b: a g3tri demo face) — emulator suites: colour spot-asserts, tri-fill spot pixels from a host replica of THIS spec, engine-vs-software identity byte-compare over a mixed pool (lines + tris, filled + outline, clipped + not).
- tb_geom: directed records against the same replica (expected pen writes now checked per record; a TRI's expected span list).
- Bitstream (the walker FSM grows ~15 states; LUT budget has room), flash + disk TOGETHER (format change!), board POINT verification.
9c: parameter READBACK, persistent scenes, and rotate¶
The record list survives in SDRAM between commands and the parameter file survives in registers — the engine is already a SCENE STORE. What was missing: the parameter file was write-only, so no command could learn the current record count to APPEND. 9c adds the read side: reading GEVAL returns par[GESEL] low, GEVALH high (no auto-increment on reads; only the high WRITE commits-and-increments). That one readback makes stateful shell tools compose:
tri ... know APPENDS to the engine list (read count, upload at the persisted cursor without rewinding, count+1, render) — so triangles stack into a scene the ENGINE owns. Withoutk: rewind, count=1.rotate [x y z]— angles in brads (256 = a full turn, 64 = 90°) — composes RyRxRz about the WORLD ORIGIN into params 0-8 (translation 9-11 untouched), sets erase, and RENDERs the persisted list. No args: identity. Model scenes near the origin if they are to rotate nicely.- cube's 12 records persist too: run cube, stack a tri with
k, then rotate the whole ensemble.
9d: the look-at camera¶
camera [ex ey ez ax ay az] — the camera AT the eye point LOOKING AT
the aim point — closes the tool family, and it is pure software on the
8b matrix path, as always promised: lib_g3cam.c builds the normalized
look-at basis (forward = aim-eye; right = worldUp x forward; true up =
forward x right) with the library's second 32-bit primitive, i3sqrt (a
try-a-bit square root over m3mul), writes rows [right; up; forward] to
params 0-8 and T = -M*eye to 9-11, and RENDERs the persisted scene.
Bare camera is home (origin, +z). Coordinate budget +/-16383; aiming
straight up/down falls back to a world-+x horizon.
Two lessons paid for here: m3mul is UNSIGNED, so magnitudes must be taken before squaring (the identity view — all non-negative components — could never catch the sign bug; the oblique-view test found it at once), and the camera math lives in its OWN spliced lib because folding it into lib_g3d pushed every g3d client past the 64K address space.
Board-verified: the oblique view's interior pixel reads back exactly where the emulator's replica predicted.
Not in stage 9¶
Shading/interpolated colour, depth sorting or hidden faces (painter's order is the caller's job — the pool draws in record order), textures, and BASIC statements for triangles. Each is a later rung.