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

149 lines
9.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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](../src/engine/VoxelWorld.ts) | `IVoxelWorld` impl — 32³ `Uint8Array` chunks (lazy), dirty tracking, `fillBox` bulk paint, `readChunkPadded` for the mesher |
| [atlas.ts](../src/engine/atlas.ts) | `buildAtlas()` — procedural 16×16 texture atlas (all 10 patterns) + emissive atlas + the two block materials + `setEmissiveBoost` |
| [mesher.ts](../src/engine/mesher.ts) | greedy mesher: 6 face dirs, `(blockId, AO)` merge, baked per-vertex AO, opaque/transparent split, atlas-tiled UVs |
| [ChunkManager.ts](../src/engine/ChunkManager.ts) | `buildAll()` + budgeted per-frame `update()` that remeshes dirty chunks and swaps geometry in place |
| [renderer.ts](../src/engine/renderer.ts) | `createRenderer()` — the one place Three.js is set up: renderer, scene, camera, lighting, fog, `setEmissiveBoost` |
| [index.ts](../src/engine/index.ts) | public barrel |
Plus the standalone demo ([engineDemo.ts](../src/demo/engineDemo.ts) + [demo-engine.html](../demo-engine.html)) and a [HANDOFF.md](../src/engine/HANDOFF.md) with the 10-line API surface.
### Public API (what other lanes / integration consume)
```ts
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 needed** — `fract` 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 (03) 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](../src/engine/mesher.ts))
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](../src/engine/renderer.ts))
`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.