TURNCRAFT/docs/briefs/LANE_A_ENGINE.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

5.5 KiB
Raw Blame History

LANE A — Voxel Engine & Rendering

Read first: docs/DESIGN.md, docs/CONTRACTS.md, everything in src/core/. You own: src/engine/**, src/demo/engineDemo.ts, demo-engine.html. You may not touch anything else.

Mission

Build the voxel core: chunked block storage, greedy meshing with a procedural texture atlas and per-vertex AO, and the Three.js renderer/scene/lighting rig. Everything the player sees that is made of blocks goes through you. Target: the full 448×160×256 world renders at 60 fps on a mid-range laptop.

Provides (consumed by other lanes)

  • VoxelWorld — implements IVoxelWorld from src/core/types.ts
  • createRenderer(container: HTMLElement){ renderer, scene, camera, setEmissiveBoost(v: number), render() } — the one place Three.js is set up
  • ChunkManager — builds/updates chunk meshes from a VoxelWorld, adds them to the scene, and remeshes dirty chunks with a per-frame budget
  • buildAtlas() — the procedural texture atlas (CanvasTexture)

Tasks

A1. VoxelWorld storage

  • One Uint8Array per 32³ chunk, allocated lazily (empty chunks read as AIR).
  • getBlock: out of bounds → AIR for y ≥ 0, a solid sentinel below y < 0 (per the IVoxelWorld doc comment). setBlock: ignore out-of-bounds.
  • setBlock marks the containing chunk dirty, plus each adjacent chunk when the voxel lies on a shared face (otherwise cross-chunk faces go stale).
  • Expose forEachDirtyChunk(cb) / clearDirty(cx,cy,cz) for the ChunkManager.
  • Bulk-fill fast path: fillBox(x0,y0,z0,x1,y1,z1,id) (worldgen will paint millions of voxels; per-voxel setBlock with dirty bookkeeping is too slow — fillBox writes raw and dirties each touched chunk once).

A2. Procedural texture atlas

  • One canvas atlas of 16×16 px tiles, one tile per block id (index = id), built from BLOCKS in src/core/blocks.ts: paint each tile with its pattern + tint. Implement all patterns in the Pattern union: solid (±6% value noise), brushed (fine horizontal streaks), plywood (wavy grain lines, slightly darker for ply_edge with laminate stripes), speckle, grooves (fine concentric-ish dark lines — straight parallel lines are fine at tile scale), pcb (traces + pads), mesh (dark hole grid), glass (faint diagonal streaks, low alpha), led (bright core, radial falloff), felt (soft high-frequency noise).
  • NearestFilter, no mips (or NearestMipmapLinear with generated mips if you handle bleeding via 1px tile padding — your call, document it).
  • Two materials: opaque (alphaTest off) and transparent pass (for glass, vinyl_blue) — both from the same atlas. Emissive: vertex-color or a second emissive atlas so emissive > 0 blocks glow; expose setEmissiveBoost(v) multiplying emissive intensity (Lane E pulses it to the beat; default 1.0).

A3. Greedy mesher

  • Per chunk: greedy meshing over the 6 face directions, merging coplanar faces with identical (blockId, AO tuple). Neighbor lookups read through VoxelWorld so chunk borders cull correctly.
  • Transparent blocks: don't occlude opaque neighbors; mesh them into the separate transparent geometry, don't merge across different transparent ids.
  • Per-vertex AO: classic 3-neighbor corner darkening (the Minecraft 03 levels), baked into vertex colors. Flip quad triangulation on the AO anisotropy case.
  • Output non-indexed or indexed BufferGeometry — your call — with position, uv (atlas tile + repeat handled by writing uvs per merged quad using a tiny shader patch or by splitting quads; greedy + atlas needs one of: (a) uv wrap via shader fract, or (b) cap merge length and tile uvs. Choose (a): patch onBeforeCompile to fract the uv within the tile. Document the approach in code).

A4. ChunkManager

  • Initial full-world mesh build must complete < 3 s (parallelize nothing — just be efficient; greedy over 560 mostly-empty chunks is fast).
  • Per-frame remesh budget (e.g. 4 chunks/frame) draining the dirty set; listens to nothing — poll forEachDirtyChunk from its update().
  • Frustum culling is Three.js default per chunk mesh; nothing fancier needed.

A5. Renderer rig

  • createRenderer(container): WebGLRenderer (antialias off, pixelRatio capped at 2), sRGB output, ACES tone mapping, PerspectiveCamera 75°.
  • Lighting per DESIGN §7: warm key DirectionalLight, cool ambient/hemi fill, fog colored warm plywood-brown, background near-black warm.
  • No OrbitControls in the delivered API (demo may use one).

Demo (demo-engine.html)

Standalone scene: build a VoxelWorld, fill a test pattern that exercises everything — a 60×60 plywood floor, stepped mesas of every block id in a labeled row (one pillar per id), a glass arch, an LED wall, a hollowed cave — OrbitControls to fly around, on-screen text: draw calls, triangles, remesh time. A button "torture" edits 500 random blocks/sec to prove dirty remeshing keeps 60 fps. A slider drives setEmissiveBoost 0→3.

Acceptance

  • Full-size empty-ish world + test pattern ≥ 60 fps, < 300 draw calls
  • AO visibly darkens inner corners; no cracks/z-fighting at chunk borders
  • Transparent blue vinyl & glass render correctly against opaque blocks
  • Torture button: no frame drops below ~55 fps, edits appear within 2 frames
  • All 10 patterns visually distinct at demo-engine.html
  • npm run typecheck clean; HANDOFF.md written

Out of scope

Player, physics, worldgen content, machines, audio, UI. No web workers in v1 (note in HANDOFF if meshing budget suggests they're needed later).