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>
6.1 KiB
Lane A — Voxel Engine & Rendering — HANDOFF
Status: complete. All of A1–A5 implemented, browser-verified, and typechecking clean in isolation. No stubs.
Public API (10 lines)
import { createRenderer, VoxelWorld, ChunkManager, buildAtlas, meshChunk } from './engine';
const { renderer, scene, camera, atlas, setEmissiveBoost, render, resize, dispose } = createRenderer(container);
const world = new VoxelWorld(WORLD_X, WORLD_Y, WORLD_Z); // implements IVoxelWorld
world.fillBox(x0,y0,z0,x1,y1,z1,id); // bulk paint (worldgen)
world.setBlock(x,y,z,id); world.getBlock(x,y,z); world.isSolid(x,y,z);
const chunks = new ChunkManager(world, scene, atlas, /*remeshBudget*/ 4);
chunks.buildAll(); // once, after worldgen fills the world (< 3 s; ~25 ms here)
chunks.update(); // once per frame — drains dirty chunks, budgeted
setEmissiveBoost(v); // Lane E pulses LED glow 0..3 (default 1)
atlas also carries opaqueMaterial / transparentMaterial (ChunkManager uses them).
meshChunk(world,cx,cy,cz) is exported for anyone who wants raw geometry.
What's done
- VoxelWorld — 32³ Uint8Array chunks, lazily allocated; oob reads AIR for y≥0 and a
solid sentinel for y<0;
setBlockmarks the chunk + any bordering neighbour dirty;fillBoxwrites raw with a chunk-resolve cache and dirties the touched range +1;readChunkPaddedgives the mesher a 1-voxel-padded id buffer (fast interior copy, getBlock only on the border shell). Dirty API:forEachDirtyChunk/clearDirty/dirtyCount. - Atlas — procedural 8×4 grid of 16×16 tiles, one per block id, all 10 patterns
painted deterministically; a parallel emissive atlas (black except LED/strobe). Two
MeshStandardMaterials (opaque, transparent) with anonBeforeCompilepatch that fracts the per-quad tile-repeat UV into the block's atlas cell.NearestFilter, no mips, no padding needed (fract keeps the sample strictly inside a cell → no bleed). - Greedy mesher — per-chunk, 6 face directions, cells keyed by
(blockId, 4×AO), coplanar equal cells merged into one quad; classic 3-neighbour AO baked to vertex colour; quad diagonal flips on AO anisotropy; correct outward winding per direction; opaque and transparent faces split into separate geometries; absolute world coords baked in (bounding sphere → Three.js frustum culling per chunk). - ChunkManager —
buildAll()meshes every non-empty chunk once;update()drains the dirty set atremeshBudgetchunks/frame, disposing+swapping geometry in place; removes emptied meshes;meshCount/lastRemeshMs/lastRemeshCountfor HUDs. - Renderer — WebGLRenderer (AA off, pixelRatio ≤2), sRGB output, ACES tone mapping,
75° camera, warm key
DirectionalLight+ coolHemisphereLight+ small ambient, warm plywood fog, near-black warm background; self-wiringResizeObserver; the singlesetEmissiveBoosthook Lane E drives.
Verified in demo-engine.html (all acceptance boxes)
Full 448×160×256 world + test pattern: build ~25 ms, 46–47 draw calls, ~1.5 k tris, 86–128 fps. AO visibly darkens the steel cave's inner corners with no chunk-seam cracks. Glass arch is see-through onto the geometry behind it; blue vinyl blends over the cream wall. All 31 block ids render with distinct patterns (labeled pillar row); LEDs glow and the emissive slider scales them 0→3. Torture (500 edits/s): min fps 128, dirty set drains, edits appear within ~1 frame.
Post-build adversarial review (done)
A 5-dimension correctness review + programmatic verification caught and fixed two bugs:
- HIGH (fixed): Z-axis winding table (
AXIS[2]) had flip flags swapped → every opaque block's +Z/−Z faces were wound backwards and back-face-culled. Verified via a per-triangle geometric-vs- stored-normal check: 520 Z tris inverted before, 0 mismatches after (all six normals, opaque and transparent). If you ever touch the mesher, re-run that check (seeLANE_A_update.md). - LOW (fixed):
renderer.tsresize fallback used??whereclientWidthreturns0(not nullish); switched to||so a 0-width container falls back to the window.
Contract friction / notes for the integrator
- Repo-wide
npm run typecheckcurrently FAILS — but not on Lane A code. The only error is in Lane C'ssrc/worldgen/anchors.ts(aLAYOUT.deckBassigned where adeckA-typed value is expected). Lane A compiles clean on its own; verified with an isolated tsconfig oversrc/core+src/engine+src/demo/engineDemo.ts. Not my file to touch — flagging for whoever owns worldgen / integration. - Dev-only console warning "Multiple instances of Three.js" appears on the demo page
because
OrbitControls(demo convenience only, not in the shipped engine API) pullsthreethrough a second Vite resolution. Harmless; it will not appear in the integrated game (no OrbitControls there). If desired, addresolve.dedupe: ['three']tovite.config— but that's a shared config file, so I left it to the integrator. - Draw-call budget at full booth scale. The demo (empty-ish world) is ~47 calls, far under 300. The dense authored booth could push non-empty-chunks × 2 (opaque+transparent) upward; if it ever nears 300, the cheap wins are merging tiny transparent geometries or worker-threaded meshing. No web workers in v1 (per brief); meshing is fully synchronous and comfortably inside the per-frame budget so far.
- Wiring reminders (match
docs/INTEGRATION.md): callchunks.buildAll()once afterbuildBooth(world), thenchunks.update()every frame in the loop.createRendererbuilds the atlas for you and exposes it as.atlas; hand that same object toChunkManagersosetEmissiveBoostand the meshes share one material set. Do not callbuildAtlas()a second time — you'd get materialssetEmissiveBoostcan't reach.
Owned paths (no writes outside these)
src/engine/**, src/demo/engineDemo.ts, demo-engine.html. Core untouched.
src/demo/engineDemo.ts exposes a tiny dev-only window.__demo.look() camera hook,
clearly commented — used for inspection, harmless in normal play.