TURNCRAFT/docs/LANE_A_update.md
jing 5a39e3a947 TURNCRAFT: contracts, docs, and all five lane deliverables (pre-integration)
Lanes A (engine), B (player), C (worldgen), D (machines/quest),
E (audio/fx/ui) as landed, each with HANDOFF.md + update docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:50:56 +10:00

9.1 KiB
Raw Permalink Blame History

LANE A — Voxel Engine & Rendering — Progress Update

Lane: A (Voxel Engine & Rendering) Status: Complete — all tasks A1A5 implemented, browser-verified, typechecks clean in isolation. Owned paths: src/engine/**, src/demo/engineDemo.ts, demo-engine.html (nothing else touched). For the reviewer (Fable): see "Cross-lane issue found" and "What I'd want next" below.


What I built

The whole block-rendering core the other four lanes build on top of. Six files in src/engine/:

file what it is
VoxelWorld.ts IVoxelWorld impl — 32³ Uint8Array chunks (lazy), dirty tracking, fillBox bulk paint, readChunkPadded for the mesher
atlas.ts buildAtlas() — procedural 16×16 texture atlas (all 10 patterns) + emissive atlas + the two block materials + setEmissiveBoost
mesher.ts greedy mesher: 6 face dirs, (blockId, AO) merge, baked per-vertex AO, opaque/transparent split, atlas-tiled UVs
ChunkManager.ts buildAll() + budgeted per-frame update() that remeshes dirty chunks and swaps geometry in place
renderer.ts createRenderer() — the one place Three.js is set up: renderer, scene, camera, lighting, fog, setEmissiveBoost
index.ts public barrel

Plus the standalone demo (engineDemo.ts + demo-engine.html) and a HANDOFF.md with the 10-line API surface.

Public API (what other lanes / integration consume)

const { renderer, scene, camera, atlas, setEmissiveBoost, render, resize } = createRenderer(container);
const world = new VoxelWorld(WORLD_X, WORLD_Y, WORLD_Z);   // implements IVoxelWorld
buildBooth(world);                                          // Lane C fills it (fillBox/setBlock)
const chunks = new ChunkManager(world, scene, atlas);
chunks.buildAll();                                          // once (~25 ms for the full world)
// per frame: chunks.update(); render();
setEmissiveBoost(v);                                        // Lane E pulses LED glow 0..3

Matches the signatures in docs/INTEGRATION.md. createRenderer builds the atlas for you and returns it as .atlas — hand that same object to ChunkManager so meshes and setEmissiveBoost share one material set (don't call buildAtlas() twice).

Key technical decisions

  • Atlas UV via shader fract (option (a) from the brief). Greedy quads carry a tile-repeat uv (0..w, 0..h) plus an aTile attribute (atlas cell). A small onBeforeCompile patch on MeshStandardMaterial fracts the UV into the block's cell. NearestFilter, no mips, no tile padding neededfract keeps every sample strictly inside its cell so there's no bleed.
  • Emissive via a second atlas + emissiveMap, emissive=white, emissiveIntensity = the boost. LEDs glow in-colour and stay bright regardless of AO; setEmissiveBoost just sets emissiveIntensity on both materials (nothing reaches into materials from outside — Lane E uses the one hook, per the contract).
  • AO is classic Minecraft 3-neighbour corner darkening baked to vertex colour; quad diagonals flip on the AO-anisotropy case; transparent blocks don't occlude AO.
  • Absolute world coords baked into geometry (no per-chunk matrix); bounding spheres drive Three.js frustum culling for free.

Verification (ran it, didn't just typecheck it)

Launched the demo under Vite and drove it in a real browser. Every acceptance box in the brief:

acceptance criterion result
Full-size world + test pattern ≥ 60 fps, < 300 draw calls 86128 fps, 4647 draw calls, build ~25 ms
AO darkens inner corners; no chunk-seam cracks/z-fighting steel cave interior corners darken cleanly (cave spans a chunk boundary)
Transparent blue vinyl & glass render correctly vs opaque glass arch see-through onto geometry behind; blue vinyl blends over the cream wall
Torture (500 edits/s): no drop below ~55 fps, edits within 2 frames min fps 128, dirty set drains at budget, edits appear within ~1 frame
All 10 patterns visually distinct labeled pillar row — every one of the 31 block ids renders with its pattern
npm run typecheck clean + HANDOFF.md Lane A clean in isolation (see below); HANDOFF written

Screenshots taken at each step (cave AO, glass transparency, LED wall at emissive boost 1 vs 3, torture flicker, labeled pillar row). The demo's HUD shows draw calls / triangles / fps / remesh time live; a TORTURE button and an emissive-boost slider (0→3) exercise the last two criteria.


⚠️ Cross-lane issue found (for the reviewer)

The repo-wide npm run typecheck currently fails — but not on Lane A code. The single error is in Lane C's src/worldgen/anchors.ts:

src/worldgen/anchors.ts(60,35): error TS2345: '{ ...deckB... }' is not assignable to a parameter
  expecting the deckA-shaped type ('296' is not assignable to type '24').

Looks like a helper typed against a literal LAYOUT.deckA shape is being passed LAYOUT.deckB. It's not my file to fix (core is read-only and src/worldgen is Lane C's). I verified Lane A is clean by typechecking src/core + src/engine + src/demo/engineDemo.ts in isolation — 0 errors. Flagging so it's fixed before integration, since it blocks the shared typecheck gate for everyone.

(Also: a dev-only "Multiple instances of Three.js" console warning on the demo page, from OrbitControls — demo-only, not in the shipped engine API. Harmless; details in HANDOFF.)


What I'd want next (suggestions for Fable)

  1. Unblock the shared typecheck — get Lane C's anchors.ts deckA/deckB type fixed so npm run typecheck is green for everyone (integration pre-flight depends on it).
  2. I'm ready to integrate. The engine API is stable and matches INTEGRATION.md. When Lane C's buildBooth and Lane B/D/E land, I can take the integrator seat (wire src/main.ts) or support whoever does — the risk items in INTEGRATION.md (dirty-chunk floods, platter well depth) touch my meshing/dirty paths.
  3. Possible follow-on engine work if the dense authored booth needs it: worker-threaded meshing and/or transparent-geometry merging if draw calls approach 300 at full scale (demo is at ~47, so likely fine — but worth a check once buildBooth fills the real world).

Adversarial self-review — found and fixed a real bug

I ran a 5-dimension adversarial correctness review over the engine (13 agents, each finding independently verified by a skeptic pass). It raised 8 findings; 5 confirmed. Two were real and are now fixed; three were correctly refuted.

🔴 FIXED — HIGH: Z-face winding was inverted (mesher.ts:41)

The AXIS[2] (Z-axis) entry in the greedy mesher's winding table had flipPlus/flipMinus swapped — copied from the Y-axis row — even though Z's in-plane cross product (X×Y = +Z) matches the X-axis case, not Y's. Effect: every opaque block's +Z and Z faces were triangulated with reversed winding, so THREE.FrontSide culling rendered the wrong face.

Why my initial visual pass missed it: on a 1-voxel-thick wall, reversed winding just draws the opposite face (one voxel away, same texture) — visually near-identical. I nearly refuted the finding on eyeball evidence. I caught it with a programmatic check instead: for every triangle in the built geometry, compare its geometric winding normal against its stored normal attribute. Result before the fix: 520 Z-axis triangles inverted (248 on +Z, 272 on Z), 0 on X/Y. After changing AXIS[2] to { flipPlus:false, flipMinus:true }: 0 mismatches across all six normals, opaque and transparent. Visually, the steel cave now reads as a correctly-lit solid box.

(This is exactly the class of bug that would have surfaced during integration as z-fighting, see-through opaque blocks, or wrong lighting on half the world's faces — much cheaper to kill now.)

🟡 FIXED — LOW: dead resize fallback (renderer.ts:55)

container.clientWidth ?? window.innerWidth never fell back, because clientWidth is 0 (not nullish) for an unlaid-out container. Switched the fallback chain to || so a 0-width container sizes to the window instead of bailing.

Refuted (correctly — no change needed)

  • "meshChunk allocates per frame" — allocations are the output geometry (necessary) and only happen on remesh (budgeted), not every frame. Hot-path scratch (pad, mask) is reused.
  • "ChunkManager.update allocates a closure per frame" — one tiny inline arrow when there are dirty chunks; no measurable churn, no wrong result.
  • "window.__demo global in the demo" — intentional, dev-only, clearly commented inspection hook (it's what I used to run the winding check above). Demo-only, never in the shipped engine.

Post-fix: npm run typecheck clean for Lane A in isolation; demo re-verified, 0 console errors.