diff --git a/docs/LANES/LANE_A_NOTES.md b/docs/LANES/LANE_A_NOTES.md new file mode 100644 index 0000000..7451a7a --- /dev/null +++ b/docs/LANES/LANE_A_NOTES.md @@ -0,0 +1,193 @@ +# LANE A — NOTES (round 1) + +World v0 is in and **the real game boots it**: `http://localhost:8140/?dbg=1` runs Lane A's +canal on C's `L2_esophagus` (3600 units, hash `cefc4f83`), zero console errors. No boot.js +change was needed — C's `levels/index.js` landing is what unblocked `pickWorld()`. + +## What landed + +`web/js/world/` — nothing outside it was touched. + +| file | what | +|---|---| +| `spline.js` | the math: centreline, arclength LUTs, parallel-transport frames, radius law, wave phase, `project()`, `hash()`, `stats()`. **Imports no three.js** → `node web/js/world/spline.js --selfcheck` runs headless (14 assertions). | +| `biomes.js` | palette/param registry (ART_BIBLE values). C ratified this as the biome-id source of truth. | +| `wall_material.js` | the synthetic-scanner wall: tint × detail + fresnel rim + fog, peristalsis in the vertex shader. | +| `tube.js` | chunked extrusion + streaming window. | +| `arena.js` | displaced-icosphere shells (the stretch goal), v0. | +| `flycam.js` | `?fly=1` noclip. **Not wired** — snippet for F below. | +| `index.js` | `createWorld()` — THE WORLD CONTRACT, drop-in for the stub. | +| `dev.html` | my harness. Dev-only; delete when boot covers it. | +| `_fixture.js` | dev fixture level. Delete when C's levels cover every case (arenas: L3 does now). | + +Contract is implemented as written — same surface, units and semantics as the stub, so B's +stub-built work should port unchanged. Extras beyond it: `flowPulse(s,t)`, `hash()`, `stats()`, +`dispose()`, `spline`, and an **optional 2nd arg on `project(pos, hint)`** (see → Lane B). + +## Measured (say how you measured it — so: how) + +All from `dev.html` on C's real `L2_esophagus` with D's real pack, 1920×1080, via +`renderer.info` (`DBG.pose()` / the sweep in `DBG.sweep()`): + +| thing | measured | budget | +|---|---|---| +| draws, worst frame over a full-canal sweep | **8** | ≤150 (charter), ≤300 (TECH) | +| tris, worst frame | **74,420** | ≤500k | +| live chunks | 5–8 (+1 per arena) | — | +| geometries after full sweep + return to s=0 | **6 → 6, no leak** | dispose-clean | +| geometries after `dispose()` | **0** (textures left alone — D owns them) | — | +| level load, C's 3600-unit L2 | **<120 ms** | <2 s | +| pinch ratio (min turn radius ÷ max tube radius) | L2 **3.32**, L3 **1.89**, fixture 2.37 | >1.5 | + +**fps is NOT measured and I'm not claiming it.** Screenshot tooling drives the tab hidden, +which pauses rAF; `gl.finish()` didn't sync either (it reported 0.12 ms/frame = 8219 fps, +which is obviously junk). Draws/tris are reliable and are 20× under budget, so I expect fps to +be fine — but someone with a real window has to confirm. → Lane F, at round end. + +Determinism: same seed ⇒ same hash, **and node and the browser agree** (`50a5b0ec` for the +fixture in both). Different seed ⇒ different hash. Both asserted in the selfcheck. + +## Decisions (mine to make; flagging the ones that touch you) + +1. **`curviness` = fraction of the tightest bend this pipe can survive**, not an amplitude. + Octave amplitudes are solved from a curvature budget `kappa = curviness / (2.2 × baseRadius)`. + Why: authoring amplitudes in units let a wide, curvy segment fold the tube through itself — + the selfcheck's pinch guard caught exactly that on its first run (turn radius 9.9 vs tube + radius 17.2). Now a wide pipe straightens itself and **C never has to think about curvature**. + C's 0.15–0.95 range works as documented; nothing to change. +2. **Peristalsis: global `OMEGA = 3.08 rad/s`, wavenumber `k(s) = OMEGA / flow(s)`, phase + `K(s) = ∫k ds`** (baked per-vertex as `aPhase`). Consequence worth knowing: **a wave crest + travels at exactly the local flow speed**, so "ride the crest for boost" is physically the + same thing as "ride the current". Plain `k·s − ω·t` tears the wave at every flow change; + `K(s)` doesn't. For a flow-14 esophagus this reproduces the stub's wave exactly (k = 0.22). +3. **Esophagus `wave.amp` 0.9 → 1.4** (`biomes.js`). The stub's 0.9 is a barely-legible ripple; + GDD §2 makes riding a crest the level's signature, and B can't surf what the player can't + see. 1.4 reads as distinct rings and still leaves ~78% of a radius-10 tube flyable. Measured + in the harness against 0.9 and 3.2. **This moves `wallRho`** → Lane B. +4. **Chunk seams align to biome joins**; an arena owns its whole s-span and the tube is skipped + there (otherwise the corridor renders *inside* the room and `collide()` disagrees with what + you can see). Seam is chunk-quantised ±20u — fine until sphincter geometry exists. +5. **Arena fog is sized to the room**, not the biome: `min(biome.fog, 1.09/radius)`. Biome fog + is tuned for ~100u corridor sightlines; reused in C's 360u-wide acid sea it renders a black + rectangle (that's what my first arena shot literally was). Small lairs keep the murk. +6. **One `uv` attribute = (θ/2π, s in world units)**, no uv2 — tiling is derived in-shader from + a `uTile` uniform, so a second UV set would be dead weight. Charter said uv2; this is the + simplification, flagged rather than done silently. +7. **Colorspace: matched to the stub** — the wall writes its colour with no + `` conversion. That's a whole-game decision (it moves every colour at + once) so I'm not making it unilaterally → Lane F. + +## Known limitations (v0, stated plainly) + +- **Arena pole pinch.** Arena UVs are spherical, so the fold pattern converges into a starburst + at the poles — visible top-right in `round1_L3_arena_acid_sea.png`. A real texture will pinch + there too. Round 2: triplanar projection for arena shells. +- **Arena shape.** A sphere is the wrong shape for a stomach and C says so in their own + `L3` note. Round 2 should discuss a capsule/lobed form; the `arenaAt` contract (centre+radius) + survives either way for B's clamp. +- **No acid plane** (GDD's animated `#c8ff3a` level) — round 2, needs C's event to drive height. +- **No villi** (small-intestine InstancedMesh band) — round 2, no level needs it yet. +- **No matcap / normal map.** D ships `normal` maps already; using them needs a TBN in the wall + shader. Round 2. Untested code paths are worse than absent ones. +- **`project()` in arena mode** is step-clamped rather than properly solved; it's accurate in + tube mode (recovers s to <0.25, θ to <0.02 rad — asserted). Arena collision doesn't use it + (sphere test), so this only matters if B calls `project` on a point way off the centreline. +- Segment param blending lags the march by one 0.5u step. Deterministic, sub-step, harmless. + +--- + +## → Lane F + +1. **Pass `assets` into `createWorld`.** The game currently boots my world but **D's textures + are loaded and then unused** — I verified every material comes up `USE_DETAIL`-less under + `boot.js`. In `pickWorld()`: + ```js + const mod = await import('./world/index.js'); + const level = await loadLevel(flags.lvl); + const assets = await (await import('./core/assets.js')).createAssets({ flags, renderer }); + return mod.createWorld(level, { rng: createRng(flags.seed ?? level.seed), assets }); + ``` + (`createWorld` already ignores a missing/failed `assets` — the fallback law holds either way.) +2. **Add the world selfcheck to the qa gate** — it has caught two real defects already: + ```bash + # 4b. Lane A world math (headless; no three.js needed) + node web/js/world/spline.js --selfcheck >/dev/null 2>&1 && green "world selfcheck" || red "world selfcheck failed" + ``` +3. **Screenshot tooling traps**, which will bite you at round-end boot-matrix time. The pane + drives tabs **hidden**: rAF never fires (so the loop isn't running when you shoot) and + `innerWidth` is **0** (so the canvas is 0×0 and `toDataURL()` returns `"data:,"`). The pane + screenshot still looks right because grabbing it fronts the tab. `DBG.shot()`'s anchor-click + download also silently no-ops here. My harness works around all three in `DBG.size/pose/poseAt` + — worth lifting into `boot.js`'s DBG. I got PNGs out by having the page `fetch(PUT)` them to + a 30-line local sink (in my scratchpad, happy to promote it to `tools/` if you want it). +4. **Dev port**: 8140 was already held by another session's server, and `launch.json` is yours, + so I left it alone and reused the running one. If lanes are meant to run concurrently on one + box this needs a call. +5. Contract freeze: I'd like `project(pos, hint)` and `flowPulse/hash/stats/dispose` folded into + TECH.md's world contract, and `assets.texture()` added to the asset contract (see → Lane D). + +## → Lane B + +- **`wallRho` shrank**: it subtracts `wave.amp + wave.breathe + 0.6` and esophagus `wave.amp` + is now 1.4 (was 0.9). Playable radius in a base-10 tube ≈ **7.85**. Re-check your graze + distances. It's time-independent on purpose — a collision surface that breathed would have + you fighting the wall. +- **`project(pos, hint)`** — pass your previous `s` as `hint` and it's near-free and exact. With + no hint it seeds from `-pos.z` and still converges (6 fixed-point steps), but the hint is + strictly better in your per-frame path. Both are asserted in the selfcheck. +- **Crest speed == `biomeAt(s).flow`**, exactly, by construction. `world.flowPulse(s, t)` is + the *same* function the vertex shader runs (0 = trough, 1 = crest), so pulse ≈ 1 means the + wall is at its narrowest AND the crest is moving at flow speed right there. Boost window. +- `biomeAt(s).flow` is **blended across segment joins**, not stepped — safe to read per-frame. +- Arena mode: `modeAt(s)` / `arenaAt(s)` → `{center, radius}` for your 6DOF clamp. `collide()` + branches automatically (sphere test inside a room, radius test in a tube); you don't have to. +- `sample()` allocates fresh Vector3s per call, same as the stub. If that shows up in your + profile, ask and I'll add `sampleInto(s, out)`. + +## → Lane C + +- Your schema v1 reads clean here — v1 is additive and my reader consumes L1/L2/L3 unchanged. + Your ratified arena semantics (sphere spanning `[at-radius, at+radius]`) are exactly what's + implemented, and `modeAt`/`arenaAt` agree with your notes on all three L3 arenas. +- **Biome ids are the registry in `world/biomes.js`**: `oral · esophagus · stomach · + small_intestine · large_intestine · appendix`. Unknown id ⇒ neutral fallback + one warning, + never a crash. Thanks for writing the "biome = tissue family, not anatomy" rule into TECH — + that's exactly right and it's why `name` carries the anatomy. +- `curviness` behaves as you documented (0 straight … 1 writhing). Under the hood 1.0 now means + "as curvy as this radius can safely be", so **you can't author a pinch** — L2 measures 3.32× + clear, L3 1.89×. Author freely. +- Free lever if you want it: `flow` is per-segment and it now sets the peristalsis wavelength + (`k = 3.08/flow`). Low flow ⇒ tighter, slower rings. A "sluggish" segment reads sluggish. +- Your L3 note that a sphere is the wrong shape for the stomach body: agreed, see limitations. + +## → Lane D + +- **Naming mismatch, silent by construction.** Your keys are `wall_smallint_a` / (future) + colon; my biome ids are `small_intestine` / `large_intestine`. `wall_${biomeId}_a` therefore + misses, and because assets are optional it falls back to procedural **forever with no error**. + I've added an explicit slug map in `index.js` so it works today. Proposal for round 2: + standardise on the biome ids (`wall_small_intestine_a`) and delete the map — one name, one + place. Your call, just let's not leave it implicit. +- **Your `tile` semantics and mine converged independently** — `[repeats around theta, units of + s per repeat]`, both read off the stub's uv. Ratified from my side; `[4,16]` looks right in + engine (`round1_L2_textured.png`). +- **`texture()` is the useful entry point, not `get()`.** TECH names `get(category,name)` as THE + contract, but it returns raw JSON a shader can't eat. I prefer `assets.texture(name)` and keep + `get` duck-typed as the documented floor. Suggest F promotes `texture()` into TECH. +- FYI: tiling is applied **in-shader** from `tile`. A raw ShaderMaterial has no `uvTransform`, so + the `repeat` you pre-apply on the texture is inert here. Not a problem — just don't count on it. +- `wall_esophagus_a` looks genuinely good in engine. The `_b` variants and matcap aren't wired + yet (round 2, with the TBN work). + +## → Lane E + +- `world.flowPulse(s, t)` (0..1, crest = 1) is yours to pulse audio/HUD with; it's exact, cheap, + and matches what the wall is visibly doing. +- `world.biomeAt(s)` → `{id, palette, fog, flow, coatDrain}`. `palette.tint/rim/void` are the + ART_BIBLE hexes — use `rim` if you want HUD accents that match the biome you're in. + +## Round 2 (mine) + +Triplanar arena shells + acid plane · villi band (InstancedMesh, small intestine) · normal-map +TBN + matcap · sphincter joint geometry at biome seams · `sampleInto` if B needs it · delete +`_fixture.js`/`dev.html` once boot + C's levels cover them. diff --git a/docs/progress/a-progress.md b/docs/progress/a-progress.md new file mode 100644 index 0000000..6126807 --- /dev/null +++ b/docs/progress/a-progress.md @@ -0,0 +1,46 @@ +# Lane A — progress log + +## 2026-07-16 · Round 1 — the canal, v0 + +Built `web/js/world/`: spline + parallel-transport frames + arclength LUTs, chunked streaming +tube, synthetic-scanner wall shader with vertex peristalsis, biome registry, arena shells v0, +noclip flycam, headless selfcheck, dev harness. Contract per TECH.md §THE WORLD CONTRACT, +drop-in for the stub. + +**The game boots it.** `/?dbg=1` (no `?stub`) runs Lane A's world on C's `L2_esophagus`, +3600 units, hash `cefc4f83`, zero console errors. C's registry landing mid-round is what +unblocked `boot.js`'s `pickWorld()`; no shell change was needed. + +Measured (dev.html, C's real L2 + D's real pack, 1920×1080, `renderer.info`): +draws **8** worst-frame over a full-canal sweep (budget ≤150) · tris **74,420** (≤500k) · +geometries 6 → 6 across a full sweep and back, **no leak**, 0 after `dispose()` · +load **<120 ms** (<2 s) · pinch ratio L2 3.32 / L3 1.89 (>1.5). fps deliberately **not** +claimed — the screenshot pane runs tabs hidden, which pauses rAF, and `gl.finish()` didn't +sync; needs a real window. Determinism: node and browser agree on the hash. + +Three defects the checks caught that eyeballing would not have: +- **Tube folded through itself** on a wide+curvy join (turn radius 9.9 vs tube radius 17.2). + Root cause: centreline amplitude authored in units. Fixed by solving amplitudes from a + curvature budget, so `curviness` means "fraction of the pinch limit" and C can't author one. +- **Arena at 720 tris** — three's polyhedron `detail` is `20*(detail+1)^2`, not `20*4^detail`. + Now solved for ~3u spacing from the arena's own radius. +- **Acid sea rendered as a black rectangle** — biome fog is tuned for 100u corridors, C's arena + is 360u across. Arena fog is now sized to the room. + +Also caught, and both would have rotted silently: the browser served a **stale cached +`biomes.js`**, so the first evidence set was shot at the old wave amplitude (re-shot); and D's +texture keys (`wall_smallint_a`) don't match my biome ids (`small_intestine`), which under the +assets-optional law fails to procedural forever with no error (slug map added, standardisation +proposed to D). + +Decisions: peristalsis phase `K(s) = ∫k ds` with `k = OMEGA/flow(s)` ⇒ crest speed == local flow +(so "surf the crest" == "ride the current") · esophagus wave amp 0.9 → 1.4, measured, moves +`wallRho` for B · chunk seams align to biome joins, arenas own their span · one uv attribute, +tiling derived in-shader · colorspace left matching the stub, F to rule. + +Evidence → `docs/shots/laneA/`: `round1_real_game_boot` (boot.js running Lane A's world), +`round1_L2_textured` / `round1_L2_procedural_fallback` (same pose, with and without D's pack — +the assets-optional law), `round1_L2_curve`, `round1_L2_wave_crest`, `round1_L3_arena_acid_sea`. + +Open for round 2: triplanar arena shells + acid plane, villi band, normal/matcap TBN, sphincter +seam geometry. Full detail + per-lane asks in `LANE_A_NOTES.md`. diff --git a/docs/shots/laneA/round1_L2_curve.png b/docs/shots/laneA/round1_L2_curve.png new file mode 100644 index 0000000..1974dc1 Binary files /dev/null and b/docs/shots/laneA/round1_L2_curve.png differ diff --git a/docs/shots/laneA/round1_L2_procedural_fallback.png b/docs/shots/laneA/round1_L2_procedural_fallback.png new file mode 100644 index 0000000..42ca823 Binary files /dev/null and b/docs/shots/laneA/round1_L2_procedural_fallback.png differ diff --git a/docs/shots/laneA/round1_L2_textured.png b/docs/shots/laneA/round1_L2_textured.png new file mode 100644 index 0000000..fea460c Binary files /dev/null and b/docs/shots/laneA/round1_L2_textured.png differ diff --git a/docs/shots/laneA/round1_L2_wave_crest.png b/docs/shots/laneA/round1_L2_wave_crest.png new file mode 100644 index 0000000..e00dc79 Binary files /dev/null and b/docs/shots/laneA/round1_L2_wave_crest.png differ diff --git a/docs/shots/laneA/round1_L3_arena_acid_sea.png b/docs/shots/laneA/round1_L3_arena_acid_sea.png new file mode 100644 index 0000000..0a3ba6b Binary files /dev/null and b/docs/shots/laneA/round1_L3_arena_acid_sea.png differ diff --git a/docs/shots/laneA/round1_real_game_boot.png b/docs/shots/laneA/round1_real_game_boot.png new file mode 100644 index 0000000..0d06397 Binary files /dev/null and b/docs/shots/laneA/round1_real_game_boot.png differ diff --git a/web/js/world/_fixture.js b/web/js/world/_fixture.js new file mode 100644 index 0000000..015f646 --- /dev/null +++ b/web/js/world/_fixture.js @@ -0,0 +1,17 @@ +// world/_fixture.js (Lane A) — DEV FIXTURE, not shipping content. +// Lane C owns real levels (js/levels/*.json). This exists so spline.js's headless selfcheck +// and dev.html can exercise the world before/without C's data: multi-segment, curviness +// sweep, a biome change, and an arena. Delete the day C's L2 + registry land. + +export const FIXTURE = { + id: 'A_FIXTURE', + name: 'Lane A fixture canal', + seed: 20260716, + segments: [ + { biome: 'esophagus', length: 300, radius: { base: 10, wobble: 0.25 }, curviness: 0.55, flow: 14 }, + { biome: 'esophagus', length: 240, radius: { base: 12, wobble: 0.35 }, curviness: 0.95, flow: 11 }, + { biome: 'stomach', length: 120, radius: { base: 16, wobble: 0.30 }, curviness: 0.20, flow: 4 }, + ], + arenas: [{ at: 620, radius: 55, biome: 'stomach' }], + events: [], +}; diff --git a/web/js/world/arena.js b/web/js/world/arena.js new file mode 100644 index 0000000..ad30385 --- /dev/null +++ b/web/js/world/arena.js @@ -0,0 +1,119 @@ +// world/arena.js (Lane A) — arena shells, v0. A displaced icosphere wearing the same wall +// shader as the tube, so the mouth/stomach/boss lairs are the same material world as the +// corridors (ART_BIBLE) and Lane B's 6DOF clamp has a real `arenaAt` to clamp against. +// +// v0 scope, stated plainly: the shell + its bounds. The stomach's animated acid plane +// (GDD §3, emissive #c8ff3a, height driven by C's events) is round 2 — see LANE_A_NOTES. + +import * as THREE from 'three'; + +/** Seeded 3D value noise. Same lattice trick as spline.js: a table, not a hash function. */ +function makeNoise3(rng) { + const N = 64, tab = new Float32Array(N * N * N); + const r = rng('world.arena'); + for (let i = 0; i < tab.length; i++) tab[i] = r() * 2 - 1; + const at = (x, y, z) => tab[(((x & 63) * N + (y & 63)) * N + (z & 63))]; + const fade = (t) => t * t * (3 - 2 * t); + const lerp = (a, b, t) => a + (b - a) * t; + function vnoise(x, y, z) { + const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z); + const xf = fade(x - xi), yf = fade(y - yi), zf = fade(z - zi); + const c00 = lerp(at(xi, yi, zi), at(xi + 1, yi, zi), xf); + const c10 = lerp(at(xi, yi + 1, zi), at(xi + 1, yi + 1, zi), xf); + const c01 = lerp(at(xi, yi, zi + 1), at(xi + 1, yi, zi + 1), xf); + const c11 = lerp(at(xi, yi + 1, zi + 1), at(xi + 1, yi + 1, zi + 1), xf); + return lerp(lerp(c00, c10, yf), lerp(c01, c11, yf), zf); + } + return (x, y, z) => { + let sum = 0, amp = 1, f = 1, n = 0; + for (let o = 0; o < 3; o++) { sum += amp * vnoise(x * f, y * f, z * f); n += amp; amp *= 0.5; f *= 2.07; } + return sum / n; + }; +} + +/** + * @param {object} spec level `arenas[]` entry: { at, radius, biome } + * @param {object} spline + * @param {THREE.Material} material a wall material built for this arena's biome + * @param {function} rng + */ +export function createArena({ spec, spline, material, rng, quality = 'high' }) { + // three's polyhedron `detail` splits each edge into (detail+1) segments, so face count is + // 20*(detail+1)^2 — NOT 20*4^detail. detail:5 is 720 tris, which on a 55-unit room is a + // 10-unit facet and the fbm displacement has nothing to displace. Solve for ~3u spacing + // instead (icosahedron edge ~ 1.05r), so arena cost tracks arena size. + const spacing = quality === 'low' ? 6 : 3; + const detail = Math.max(3, Math.min(24, Math.round((1.05 * spec.radius) / spacing) - 1)); + const noise = makeNoise3(rng); + const f = spline.frameAt(spec.at); + const center = new THREE.Vector3(f.pos.x, f.pos.y, f.pos.z); + + const geo = new THREE.IcosahedronGeometry(spec.radius, detail); // non-indexed + const pos = geo.attributes.position; + const n = pos.count; + const position = new Float32Array(n * 3); + const aInward = new Float32Array(n * 3); + const aTangent = new Float32Array(n * 3); + const uv = new Float32Array(n * 2); + const aPhase = new Float32Array(n); + const aK = new Float32Array(n); + + // The churn wave crosses the room along the canal's own axis, slowly enough to read as a + // room breathing rather than a corridor's transit wave. + const k = 3.08 / Math.max(4, spec.radius / 5); + const axis = new THREE.Vector3(f.tan.x, f.tan.y, f.tan.z); + const ref = new THREE.Vector3(f.nor.x, f.nor.y, f.nor.z); + const bin = new THREE.Vector3(f.bin.x, f.bin.y, f.bin.z); + const amp = spec.radius * 0.09; + const v = new THREE.Vector3(); + + for (let i = 0; i < n; i++) { + v.fromBufferAttribute(pos, i); + const dir = v.clone().normalize(); + const r = spec.radius + amp * noise(dir.x * 2.3 + 11, dir.y * 2.3 + 5, dir.z * 2.3 + 3); + const p = dir.clone().multiplyScalar(r); + position[i * 3] = p.x; position[i * 3 + 1] = p.y; position[i * 3 + 2] = p.z; + aInward[i * 3] = -dir.x; aInward[i * 3 + 1] = -dir.y; aInward[i * 3 + 2] = -dir.z; + aTangent[i * 3] = axis.x; aTangent[i * 3 + 1] = axis.y; aTangent[i * 3 + 2] = axis.z; + const along = p.dot(axis); + uv[i * 2] = (Math.atan2(p.dot(bin), p.dot(ref)) / (Math.PI * 2)) + 0.5; + uv[i * 2 + 1] = spec.at + along; // keep uv.y in canal-s units, like the tube + aPhase[i] = k * (spec.at + along); + aK[i] = k; + } + + // Seam repair: uv.x comes from atan2, so a triangle straddling the -X axis interpolates it + // from ~1 back to ~0 and the wall shader's fold pattern crams a full cycle into that one + // triangle — a zigzag scar down the room. The geometry is non-indexed, so each triangle owns + // its three vertices and we can just push the low ones past the wrap. + for (let t = 0; t < n; t += 3) { + let lo = Infinity, hi = -Infinity; + for (let j = 0; j < 3; j++) { const x = uv[(t + j) * 2]; lo = Math.min(lo, x); hi = Math.max(hi, x); } + if (hi - lo > 0.5) for (let j = 0; j < 3; j++) if (uv[(t + j) * 2] < 0.5) uv[(t + j) * 2] += 1; + } + + const g = new THREE.BufferGeometry(); + g.setAttribute('position', new THREE.BufferAttribute(position, 3)); + g.setAttribute('aInward', new THREE.BufferAttribute(aInward, 3)); + g.setAttribute('aTangent', new THREE.BufferAttribute(aTangent, 3)); + g.setAttribute('uv', new THREE.BufferAttribute(uv, 2)); + g.setAttribute('aPhase', new THREE.BufferAttribute(aPhase, 1)); + g.setAttribute('aK', new THREE.BufferAttribute(aK, 1)); + g.computeBoundingSphere(); + geo.dispose(); // the source icosphere was scaffolding + + const mesh = new THREE.Mesh(g, material); // material built with side: BackSide + mesh.position.copy(center); + mesh.name = `arena ${spec.biome} @${spec.at}`; + + return { + spec, + mesh, + center, + radius: spec.radius, + /** Conservative inner surface: shell minus displacement peak minus the shader's wave. */ + innerRadius: spec.radius - amp - (material.uniforms?.uWaveA?.value ?? 0) - 0.6, + covers: (s) => Math.abs(s - spec.at) <= spec.radius, + dispose() { g.dispose(); }, + }; +} diff --git a/web/js/world/biomes.js b/web/js/world/biomes.js new file mode 100644 index 0000000..930149d --- /dev/null +++ b/web/js/world/biomes.js @@ -0,0 +1,85 @@ +// world/biomes.js (Lane A) — the biome registry. Palette/fog values are transcribed from +// ART_BIBLE.md §Biome palettes; gameplay defaults (flow, coatDrain) are sane seeds that +// Lane C overrides per-segment in level JSON (`segments[].flow`). C owns the tuning, this +// owns the look. +// +// Canonical ids — level JSON `segments[].biome` must be one of these: +// oral · esophagus · stomach · small_intestine · large_intestine · appendix +// Unknown id => NEUTRAL fallback + one console warning, never a crash. +// +// `wave.amp` is displacement in units, inward, at the crest — and it is a GAMEPLAY number, not +// just a look: world.wallRho() subtracts (amp + breathe) as its conservative margin, so raising +// it narrows Lane B's flight envelope. Esophagus 1.4 was measured in the harness, not guessed: +// the stub's 0.9 renders as a barely-legible ripple, and GDD §2 makes riding a crest for boost +// the level's signature mechanic — B cannot surf a crest the player can't see. 1.4 reads as +// distinct rings and still leaves ~78% of a radius-10 tube flyable. The other biomes are scaled +// in proportion and are UNVERIFIED until each one is actually built and eyeballed. + +export const BIOMES = { + oral: { + id: 'oral', + palette: { tint: 0xa8c4d8, rim: 0xdff2ff, void: 0x06090e }, + fog: 0.016, // clinical, bright, tutorial-readable => you can see across the cavity + flow: 5, + coatDrain: 0.15, + wave: { amp: 0.5, breathe: 0.10 }, // saliva tides, not real peristalsis + }, + esophagus: { + id: 'esophagus', + palette: { tint: 0x1e6e64, rim: 0x5affd2, void: 0x02100d }, + fog: 0.028, + flow: 14, + coatDrain: 0.5, + wave: { amp: 1.4, breathe: 0.15 }, // the signature: ribbed rings rushing past (measured) + }, + stomach: { + id: 'stomach', + palette: { tint: 0xb0571e, rim: 0xffb13a, void: 0x120801 }, + fog: 0.020, + flow: 4, + coatDrain: 2.2, // the acid sea: brutal in open acid (GDD §Player systems) + wave: { amp: 0.7, breathe: 0.25 }, // churn, not transit + }, + small_intestine: { + id: 'small_intestine', + palette: { tint: 0x7a2a6e, rim: 0xff6ad5, void: 0x0d0312 }, + fog: 0.050, // dense: villi forest occludes sightlines + flow: 9, + coatDrain: 0.8, + wave: { amp: 1.5, breathe: 0.18 }, + }, + large_intestine: { + id: 'large_intestine', + palette: { tint: 0x3a4a26, rim: 0x9dff4a, void: 0x050702 }, + fog: 0.075, // darkness is the mechanic here; scanner light is the safety bubble + flow: 5, + coatDrain: 0.6, + wave: { amp: 1.2, breathe: 0.2 }, + }, + appendix: { + id: 'appendix', + palette: { tint: 0x8a7020, rim: 0xffe066, void: 0x0a0800 }, + fog: 0.030, + flow: 2, + coatDrain: 0.3, + wave: { amp: 0.5, breathe: 0.12 }, + }, +}; + +const NEUTRAL = { + id: 'neutral', + palette: { tint: 0x557066, rim: 0x9fd8c8, void: 0x050a08 }, + fog: 0.03, flow: 10, coatDrain: 0.4, wave: { amp: 0.9, breathe: 0.15 }, +}; + +const warned = new Set(); + +export function biome(id) { + const b = BIOMES[id]; + if (b) return b; + if (!warned.has(id)) { + warned.add(id); + console.warn(`[world] unknown biome "${id}" — falling back to neutral. Valid: ${Object.keys(BIOMES).join(', ')}`); + } + return NEUTRAL; +} diff --git a/web/js/world/dev.html b/web/js/world/dev.html new file mode 100644 index 0000000..10f5963 --- /dev/null +++ b/web/js/world/dev.html @@ -0,0 +1,226 @@ + + + + +GUTS — Lane A world harness + + + + + +
+ + + diff --git a/web/js/world/flycam.js b/web/js/world/flycam.js new file mode 100644 index 0000000..9c3df0a --- /dev/null +++ b/web/js/world/flycam.js @@ -0,0 +1,70 @@ +// world/flycam.js (Lane A) — `?fly=1` noclip inspection camera. +// Owed to the other lanes per LANE_A_WORLD.md: a way to look at the canal without Lane B's +// player existing. True noclip (free 6DOF, ignores world.collide) — it is a debug tool, not +// a preview of arena-mode flight; Lane B owns how the ship actually feels. +// +// Not wired into boot.js yet: boot owns the camera and `?fly=1` currently drives a rail cam. +// The exact patch is offered in LANE_A_NOTES.md §-> Lane F. +// +// W/S forward/back · A/D strafe · Q/E down/up · Shift boost · drag to look · R reset to start + +import * as THREE from 'three'; + +export function createFlyCam({ camera, dom = window, world, speed = 35 }) { + const keys = new Set(); + let yaw = 0, pitch = 0, dragging = false; + + function reset() { + const f = world.sample(4); + camera.position.copy(f.pos).addScaledVector(f.tan, -6); + yaw = Math.atan2(-f.tan.x, -f.tan.z); + pitch = Math.asin(THREE.MathUtils.clamp(f.tan.y, -1, 1)); + } + + const onDown = (e) => { keys.add(e.code); if (e.code === 'KeyR') reset(); }; + const onUp = (e) => keys.delete(e.code); + const onBlur = () => keys.clear(); + const onMouseDown = () => { dragging = true; }; + const onMouseUp = () => { dragging = false; }; + const onMouseMove = (e) => { + if (!dragging) return; + yaw -= e.movementX * 0.003; + pitch = THREE.MathUtils.clamp(pitch - e.movementY * 0.003, -1.5, 1.5); + }; + + addEventListener('keydown', onDown); + addEventListener('keyup', onUp); + addEventListener('blur', onBlur); + dom.addEventListener('mousedown', onMouseDown); + addEventListener('mouseup', onMouseUp); + addEventListener('mousemove', onMouseMove); + reset(); + + const fwd = new THREE.Vector3(), right = new THREE.Vector3(), up = new THREE.Vector3(0, 1, 0); + + return { + get position() { return camera.position; }, + reset, + update(dt) { + camera.up.copy(up); // noclip stays world-up: no frame roll to fight + camera.rotation.set(pitch, yaw, 0, 'YXZ'); + fwd.set(0, 0, -1).applyEuler(camera.rotation); + right.set(1, 0, 0).applyEuler(camera.rotation); + const v = speed * (keys.has('ShiftLeft') || keys.has('ShiftRight') ? 3 : 1) * dt; + if (keys.has('KeyW')) camera.position.addScaledVector(fwd, v); + if (keys.has('KeyS')) camera.position.addScaledVector(fwd, -v); + if (keys.has('KeyD')) camera.position.addScaledVector(right, v); + if (keys.has('KeyA')) camera.position.addScaledVector(right, -v); + if (keys.has('KeyE')) camera.position.addScaledVector(up, v); + if (keys.has('KeyQ')) camera.position.addScaledVector(up, -v); + }, + dispose() { + removeEventListener('keydown', onDown); + removeEventListener('keyup', onUp); + removeEventListener('blur', onBlur); + dom.removeEventListener('mousedown', onMouseDown); + removeEventListener('mouseup', onMouseUp); + removeEventListener('mousemove', onMouseMove); + }, + }; +} diff --git a/web/js/world/index.js b/web/js/world/index.js new file mode 100644 index 0000000..5ef94c9 --- /dev/null +++ b/web/js/world/index.js @@ -0,0 +1,229 @@ +// world/index.js (Lane A) — createWorld(): THE WORLD CONTRACT (docs/TECH.md §THE WORLD +// CONTRACT), implemented for real. Drop-in replacement for js/stub/world_stub.js: same +// surface, same units, same semantics, so anything Lane B built against the stub keeps +// working. Structure: +// +// spline.js the math (no three.js — headless-testable, runs in qa.sh) +// biomes.js palette/param registry (ART_BIBLE values) +// wall_material.js the synthetic-scanner shader (tube AND arena wear it) +// tube.js chunked extrusion + streaming window +// arena.js displaced icosphere shells, v0 +// flycam.js ?fly=1 noclip inspection camera (offered to F for boot wiring) + +import * as THREE from 'three'; +import { createRng } from '../core/rng.js'; +import { buildSpline, OMEGA } from './spline.js'; +import { biome as biomeOf } from './biomes.js'; +import { createWallMaterial } from './wall_material.js'; +import { createTube } from './tube.js'; +import { createArena } from './arena.js'; + +const SKIN = 0.6; // collision safety margin (units) — matches the stub + +export async function createWorld(levelData, { rng, quality = 'high', assets = null } = {}) { + const R = rng || createRng((levelData?.seed ?? 0) >>> 0); + const spline = buildSpline(levelData, R); + const arenaSpecs = Array.isArray(levelData.arenas) ? levelData.arenas : []; + + // --- assets: optional, always (TECH.md §Asset manifest contract) ----------------------- + // A miss is not an error: the wall shader's procedural SEM path is the fallback, and it's + // the look we ship until D's pack lands. + // + // Two entry points, deliberately. TECH.md names `get(category, name)` as THE contract, but + // it returns the raw manifest entry — JSON, no THREE.Texture — so a shader can't eat it + // without re-implementing D's loader. `assets.texture(name)` is the one that returns + // {map, normalMap, tile}. We prefer it and keep `get` duck-typed as the documented floor. + // (D and I independently landed on the same `tile` semantics — [repeats around theta, units + // of s per repeat] — both read off the stub's uv. Noted for F to fold into TECH.md.) + // + // NB: tiling is applied in-shader from `tile`, NOT via texture.repeat — a raw ShaderMaterial + // has no uvTransform, so D's pre-applied repeat is inert here. Don't "fix" it by removing + // the uTile math. + // Texture naming. D's round-1 manifest keys are wall__a, and the slugs are NOT the + // biome ids: `small_intestine` ships as `wall_smallint_a`. Because assets are optional, a + // wrong key doesn't throw — it silently falls back to the procedural wall forever, which is + // the worst kind of bug. Explicit map, so a mismatch is visible in one place. + // -> Lane D: proposing we standardize on the biome ids in round 2 (LANE_A_NOTES §-> Lane D). + const TEXTURE_SLUG = { + oral: 'oral', esophagus: 'esophagus', stomach: 'stomach', + small_intestine: 'smallint', large_intestine: 'colon', appendix: 'appendix', + }; + const TEXTURE_FOR = (biomeId) => `wall_${TEXTURE_SLUG[biomeId] ?? biomeId}_a`; + function detailFor(biomeId) { + if (!assets) return { detail: null, tile: null }; + const name = TEXTURE_FOR(biomeId); + try { + if (typeof assets.texture === 'function') { + const t = assets.texture(name); + if (t && t.map) return { detail: t.map, tile: Array.isArray(t.tile) ? t.tile : null }; + } + if (typeof assets.get === 'function') { + const e = assets.get('textures', name); + if (!e) return { detail: null, tile: null }; + const tex = e.isTexture ? e : (e.texture ?? e.map ?? null); + return { detail: tex, tile: Array.isArray(e.tile) ? e.tile : null }; + } + } catch (err) { + console.warn(`[world] assets lookup failed for ${name}, using procedural wall —`, err.message); + } + return { detail: null, tile: null }; + } + + // --- materials ------------------------------------------------------------------------- + // Tube: one material per biome, cached. Chunks never straddle a biome join, so live chunks + // share a material and batch cleanly — this is why the draw count is ~7 and not ~700. + const owned = []; // every material we create, for update() + dispose() + const tubeMaterials = new Map(); + const radiusHintFor = (biomeId) => { + const span = spline.spans.find((sp) => sp.seg.biome === biomeId); + return span ? spline.paramAt((span.s0 + span.s1) / 2, 'base') : 10; + }; + + function makeMaterial(biomeId, side, fog) { + const b = biomeOf(biomeId); + const { detail, tile } = detailFor(biomeId); + const m = createWallMaterial({ + biome: b, omega: OMEGA, detail, tile, radiusHint: radiusHintFor(biomeId), side, fog, + }); + owned.push(m); + return m; + } + + function tubeMaterialFor(biomeId) { + if (!tubeMaterials.has(biomeId)) tubeMaterials.set(biomeId, makeMaterial(biomeId, THREE.FrontSide)); + return tubeMaterials.get(biomeId); + } + + /** + * Arenas get their OWN material per room, because fog has to be sized to the room. + * Biome fog is tuned for a corridor (legible to ~100 units, and it hides the streaming + * edge). Reuse it in C's 360-unit-wide acid sea and the far wall sits at 86% fog — the room + * renders as a black rectangle. Measured, not theorised: that's what the first arena shot + * was. So: fade the far wall (2*radius) to ~70% void, and never fog *more* than the biome + * would. Small lairs keep the biome's murk; big rooms open up. + */ + const arenaFog = (radius) => 1.09 / Math.max(1, radius); // ~70% void at the far wall + function arenaMaterialFor(spec) { + return makeMaterial(spec.biome, THREE.BackSide, Math.min(biomeOf(spec.biome).fog, arenaFog(spec.radius))); + } + + // --- geometry ------------------------------------------------------------------------- + const group = new THREE.Group(); + group.name = 'world'; + + const tube = createTube({ + spline, + materialFor: tubeMaterialFor, + quality, + // An arena owns its whole s-span: skip the tube there or the corridor renders *inside* the + // room and collide() would disagree with what you can see. + skipSpans: arenaSpecs.map((a) => [a.at - a.radius, a.at + a.radius]), + }); + group.add(tube.group); + + const arenas = arenaSpecs.map((spec) => { + const a = createArena({ + spec, spline, rng: R, quality, + material: arenaMaterialFor(spec), // shell viewed from inside + }); + group.add(a.mesh); + return a; + }); + + tube.prime(0); + + // --- contract ------------------------------------------------------------------------- + let time = 0; + + const biomeIdAt = (s) => spline.segmentAt(s).biome; + const waveMaxAt = (s) => { const b = biomeOf(biomeIdAt(s)); return b.wave.amp + b.wave.breathe; }; + const arenaSpatial = (v) => arenas.find((a) => v.distanceTo(a.center) <= a.radius) || null; + + function sample(s) { + const f = spline.frameAt(s); + return { + pos: new THREE.Vector3(f.pos.x, f.pos.y, f.pos.z), + tan: new THREE.Vector3(f.tan.x, f.tan.y, f.tan.z), + nor: new THREE.Vector3(f.nor.x, f.nor.y, f.nor.z), + bin: new THREE.Vector3(f.bin.x, f.bin.y, f.bin.z), + radius: f.radius, + }; + } + + const world = { + level: levelData, + length: spline.length, + group, + + sample, + + /** @param {THREE.Vector3} v @param {number} [hint] previous s — free accuracy for B */ + project: (v, hint) => spline.project(v, hint), + + /** Conservative: geometry radius minus the wave's full amplitude. Time-independent on + * purpose — a collision surface that breathed would have B's ship fighting the wall. */ + wallRho: (s, _theta) => spline.radiusAt(s) - waveMaxAt(s) - SKIN, + + collide(v, r = 0) { + const a = arenaSpatial(v); + if (a) { + const rel = v.clone().sub(a.center); + const d = rel.length(); + const max = a.innerRadius - r; + if (d <= max) return null; + return { push: rel.normalize().multiplyScalar(max - d), kind: 'wall', biome: a.spec.biome }; + } + const { s, theta, rho } = spline.project(v); + const max = world.wallRho(s, theta) - r; + if (rho <= max) return null; + const f = spline.frameAt(s); + const ct = Math.cos(theta), st = Math.sin(theta); + const dir = new THREE.Vector3( + f.nor.x * ct + f.bin.x * st, + f.nor.y * ct + f.bin.y * st, + f.nor.z * ct + f.bin.z * st, + ); + return { push: dir.multiplyScalar(max - rho), kind: 'wall', biome: biomeIdAt(s) }; + }, + + biomeAt(s) { + const b = biomeOf(biomeIdAt(s)); + // flow is read through the blended segment schedule, not the biome default: C tunes it + // per segment and B's controller wants it continuous across joins. + return { id: b.id, palette: b.palette, fog: b.fog, flow: spline.paramAt(s, 'flow'), coatDrain: b.coatDrain }; + }, + + modeAt: (s) => (arenas.some((a) => a.covers(s)) ? 'arena' : 'tube'), + arenaAt(s) { + const a = arenas.find((x) => x.covers(s)); + return a ? { center: a.center, radius: a.radius } : null; + }, + + /** JS mirror of the vertex shader's wave, exact. B: crest speed == biomeAt(s).flow, so a + * ship riding a crest is riding the current. E: pulse the mix with it. */ + flowPulse: (s, t = time) => Math.pow(Math.max(0, Math.sin(spline.phaseAt(s) - OMEGA * t)), 3), + + update(dt, playerS = 0) { + time += dt; + for (const m of owned) m.uniforms.uTime.value = time; + tube.update(playerS); + }, + + hash: () => spline.hash(), + stats: () => ({ ...spline.stats(), chunks: tube.stats.live, built: tube.stats.built, disposed: tube.stats.disposed }), + + dispose() { + tube.dispose(); + for (const a of arenas) { group.remove(a.mesh); a.dispose(); } + for (const m of owned) m.dispose(); // D owns their textures; not ours to free + owned.length = 0; + tubeMaterials.clear(); + }, + + // not contract, but handy and cheap to expose + spline, + get time() { return time; }, + }; + + return world; +} diff --git a/web/js/world/spline.js b/web/js/world/spline.js new file mode 100644 index 0000000..ddc1ff7 --- /dev/null +++ b/web/js/world/spline.js @@ -0,0 +1,427 @@ +// world/spline.js (Lane A) — the math core of the canal. Deliberately imports NOTHING but +// core/rng.js: no three.js, so `node web/js/world/spline.js --selfcheck` runs it headless in +// qa.sh. index.js wraps these plain {x,y,z} numbers into THREE objects at the contract +// boundary (docs/TECH.md §THE WORLD CONTRACT). +// +// Model +// ----- +// The centreline is a graph over the -Z axis: P(u) = (X(u), Y(u), -u). Two consequences we +// rely on everywhere: it can never self-intersect, and `u ≈ -pos.z` is a free, close seed for +// project(). X/Y are sums of three seeded low-frequency sines, smoothstep-blended across +// segment joins so the tangent never kinks. +// +// Amplitudes are NOT authored in units — they're solved from a curvature budget. A tube bends +// through itself when the centreline's turn radius drops below the tube radius, so `curviness` +// means "fraction of the tightest bend this pipe's radius can survive": +// kappa_budget(s) = curviness(s) / (PINCH_SAFETY * baseRadius(s)) +// split across axes and octaves by fixed weights, then inverted per octave (A = w*kappa/f^2). +// A wide pipe therefore straightens itself automatically and Lane C never has to think about +// curvature. selfcheck measures the result (`pinchRatio`) rather than trusting this argument — +// it caught the first version of this file, where amplitude was authored in units and a +// curviness-0.95 join into a radius-16 segment folded the tube shut. +// +// u is NOT arclength (|dP/du| >= 1). Everything public is in arclength `s`, mapped through +// LUTs built by one forward march. + +import { createRng } from '../core/rng.js'; + +// --- tiny vec3 (plain objects; index.js converts) --------------------------------------- +const V = (x = 0, y = 0, z = 0) => ({ x, y, z }); +const sub = (a, b) => V(a.x - b.x, a.y - b.y, a.z - b.z); +const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z; +const len = (a) => Math.sqrt(dot(a, a)); +const norm = (a) => { const l = len(a) || 1; return V(a.x / l, a.y / l, a.z / l); }; +const cross = (a, b) => V(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x); +const lerpV = (a, b, t) => V(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t); +const clamp = (x, lo, hi) => (x < lo ? lo : x > hi ? hi : x); +const lerp = (a, b, t) => a + (b - a) * t; +const smoothstep = (e0, e1, x) => { const t = clamp((x - e0) / (e1 - e0), 0, 1); return t * t * (3 - 2 * t); }; + +// --- tuning ----------------------------------------------------------------------------- +// OMEGA is the peristaltic contraction *rate*, global to the whole canal and constant in +// time — physiologically it's how often the muscle fires, which doesn't change because you +// crossed into a wider pipe. The wavenumber is what varies: k(s) = OMEGA / flow(s), so a +// wave crest travels at exactly the local flow speed and Lane B can surf one. Phase is the +// integral K(s) = ∫k ds (phaseAt), which stays continuous across flow changes — plain +// `k*s - w*t` does not, and tears the wave at every segment join. +// 3.08 = 0.22 rad/unit x 14 units/s, i.e. the stub's esophagus wave, preserved exactly. +export const OMEGA = 3.08; + +const DU = 0.5; // march step in curve parameter +const DS = 0.5; // frame/phase LUT spacing in arclength +const NOISE_TAB = 1024; +const RADIUS_WAVELENGTH = 55; // units per radius-fbm octave-0 cycle +const PINCH_SAFETY = 2.2; // min turn radius = this x base radius (>2 covers wobble peaks) + +// Blend widths (units) for smoothstep-crossfading segment params across a join. Radius and +// flow are local scalars — a tight 12u transition reads as a sphincter. Curviness feeds +// centreline *amplitude*, which has a long lever arm: ramping it over a short span is itself a +// hard lateral swerve, i.e. curvature. It gets a wide, gentle ramp. +// `curvBase` is baseRadius again, but read through the wide ramp: it divides the curvature +// budget, so a 12u step in it would swerve the centreline just as hard as curviness would. +const BLEND = { curviness: 60, curvBase: 60, base: 12, wobble: 12, flow: 12 }; + +// [wavelength, share of the axis curvature budget]. Y is tamer than X on purpose: a canal that +// writhes vertically as hard as it does laterally would swing the parallel-transport frame +// around and make Lane B's chase cam seasick. Axis shares are set so that even with every +// octave peaking together, sqrt(X^2 + Y^2) lands exactly on the budget. +const OCT_X = [[240, 0.5], [110, 0.3], [60, 0.2]]; +const OCT_Y = [[190, 0.5], [85, 0.3], [45, 0.2]]; +const AXIS_X = 0.8, AXIS_Y = 0.6; + +/** Solve each octave's amplitude from its share of a unit curvature budget: kappa ~ A*f^2. */ +function octaveCoefs(octaves, axisShare) { + return octaves.map(([wavelength, weight]) => { + const f = (2 * Math.PI) / wavelength; + return { f, coef: (weight * axisShare) / (f * f) }; + }); +} + +const GET = { + curviness: (g) => (typeof g.curviness === 'number' ? g.curviness : 0.5), + base: (g) => (g.radius && typeof g.radius.base === 'number' ? g.radius.base : 10), + wobble: (g) => (g.radius && typeof g.radius.wobble === 'number' ? g.radius.wobble : 0.2), + flow: (g) => (typeof g.flow === 'number' ? g.flow : 10), +}; +GET.curvBase = GET.base; + +/** + * Build the canal math for a level. Deterministic: same `level.seed` (or injected rng) => + * byte-identical output, forever. See world.hash(). + * @param {object} level level JSON (TECH.md §Level data schema v0) + * @param {function} [rng] core/rng.js createRng() instance; defaults to level.seed + */ +export function buildSpline(level, rng) { + if (!level || !Array.isArray(level.segments) || level.segments.length === 0) + throw new Error('[world] level.segments is required and must be non-empty'); + const R = rng || createRng((level.seed ?? 0) >>> 0); + + // --- segment table (declared arclength boundaries; C's events index this same s) ------- + const spans = []; + let acc = 0; + for (const seg of level.segments) { + const length = typeof seg.length === 'number' && seg.length > 0 ? seg.length : 100; + spans.push({ s0: acc, s1: acc + length, seg }); + acc += length; + } + const L = acc; + + function idxAt(s) { + // linear scan: levels have a handful of segments, and a branchy binary search here would + // cost more than it saves. + for (let i = 0; i < spans.length; i++) if (s < spans[i].s1) return i; + return spans.length - 1; + } + + /** Segment param at s, smoothstep-blended across joins so nothing steps discontinuously. */ + function paramAt(s, key) { + const get = GET[key], w = BLEND[key]; + const i = idxAt(s); + const sp = spans[i]; + const v = get(sp.seg); + if (i > 0 && s < sp.s0 + w) + return lerp(get(spans[i - 1].seg), v, smoothstep(sp.s0 - w, sp.s0 + w, s)); + if (i < spans.length - 1 && s > sp.s1 - w) + return lerp(v, get(spans[i + 1].seg), smoothstep(sp.s1 - w, sp.s1 + w, s)); + return v; + } + + // --- seeded noise --------------------------------------------------------------------- + const tab = new Float32Array(NOISE_TAB); + { const r = R('world.radius'); for (let i = 0; i < NOISE_TAB; i++) tab[i] = r() * 2 - 1; } + function vnoise(x) { + const i = Math.floor(x), f = x - i; + const a = tab[i & (NOISE_TAB - 1)], b = tab[(i + 1) & (NOISE_TAB - 1)]; + return lerp(a, b, f * f * (3 - 2 * f)); + } + function fbm1(x) { + let sum = 0, amp = 1, freq = 1, normSum = 0; + for (let o = 0; o < 3; o++) { sum += amp * vnoise(x * freq + o * 17.3); normSum += amp; amp *= 0.5; freq *= 2.03; } + return sum / normSum; // [-1, 1] + } + const radiusAt = (s) => paramAt(s, 'base') * (1 + paramAt(s, 'wobble') * fbm1(s / RADIUS_WAVELENGTH)); + + // --- centreline ----------------------------------------------------------------------- + const cx = octaveCoefs(OCT_X, AXIS_X), cy = octaveCoefs(OCT_Y, AXIS_Y); + const phX = cx.map(() => R('world.spline')() * Math.PI * 2); + const phY = cy.map(() => R('world.spline')() * Math.PI * 2); + + /** How hard the canal is allowed to bend at s: curviness as a fraction of the pinch limit. */ + const curvBudgetAt = (s) => paramAt(s, 'curviness') / (PINCH_SAFETY * Math.max(1, paramAt(s, 'curvBase'))); + + /** `budget` is kappa (1/units), not a length — octave coefs turn it into lateral offsets. */ + function centreWith(u, budget) { + let x = 0, y = 0; + for (let i = 0; i < cx.length; i++) x += budget * cx[i].coef * Math.sin(u * cx[i].f + phX[i]); + for (let i = 0; i < cy.length; i++) y += budget * cy[i].coef * Math.sin(u * cy[i].f + phY[i]); + return V(x, y, -u); + } + + // --- the march: u -> s, and the curvature budget sampled along it ---------------------- + // The budget is a function of arclength (that's where segments are declared), but the curve + // is evaluated in u — so we resolve it during the march, where s is already known, and + // cache the schedule per u-step. One pass, no circularity. + const uToS = [0]; + const ampArr = [curvBudgetAt(0)]; + { + let s = 0, prev = centreWith(0, ampArr[0]), n = 0; + const MAXN = Math.ceil((L / DU) * 1.5) + 16; + while (s < L && n < MAXN) { + n++; + const amp = curvBudgetAt(s); // s lags one step: deterministic, sub-step error + const p = centreWith(n * DU, amp); + s += len(sub(p, prev)); + uToS.push(s); ampArr.push(amp); + prev = p; + } + } + const uMax = (uToS.length - 1) * DU; + const ampAtU = (u) => { + const t = clamp(u / DU, 0, ampArr.length - 1), i = Math.min(Math.floor(t), ampArr.length - 2); + return lerp(ampArr[i], ampArr[i + 1], t - i); + }; + const centre = (u) => centreWith(u, ampAtU(u)); + const sOfU = (u) => { + const t = clamp(u / DU, 0, uToS.length - 1), i = Math.min(Math.floor(t), uToS.length - 2); + return lerp(uToS[i], uToS[i + 1], t - i); + }; + + // uniform s -> u table, inverted by a monotone merge-walk (uToS is strictly increasing) + const M = Math.ceil(L / DS) + 1; + const sToU = new Float64Array(M); + { + let j = 0; + for (let i = 0; i < M; i++) { + const target = Math.min(i * DS, L); + while (j < uToS.length - 2 && uToS[j + 1] < target) j++; + const s0 = uToS[j], s1 = uToS[j + 1]; + const f = s1 > s0 ? (target - s0) / (s1 - s0) : 0; + sToU[i] = (j + f) * DU; + } + } + const uOfS = (s) => { + const t = clamp(s, 0, L) / DS, i = Math.min(Math.floor(t), M - 2); + return lerp(sToU[i], sToU[i + 1], t - i); + }; + + // --- frames: parallel transport, NOT Frenet ------------------------------------------- + // Frenet's normal flips through inflection points; on a canal that writhes that shows up as + // the camera snapping upside-down mid-corridor (LANE_A_WORLD.md calls this out by name). + // Parallel transport carries the previous normal forward by the minimal rotation, so it's + // history-dependent => must be integrated once here and looked up, never recomputed ad hoc. + const posArr = new Float64Array(M * 3); + const tanArr = new Float64Array(M * 3); + const norArr = new Float64Array(M * 3); + { + const H = 0.25; + const tangentAtU = (u) => { + const a = centre(Math.max(0, u - H)), b = centre(Math.min(uMax, u + H)); + return norm(sub(b, a)); + }; + const put = (arr, i, v) => { arr[i * 3] = v.x; arr[i * 3 + 1] = v.y; arr[i * 3 + 2] = v.z; }; + + let prevT = tangentAtU(uOfS(0)); + // seed the frame from world up; the canal is a graph over -Z so it can never be vertical + let n = norm(sub(V(0, 1, 0), V(prevT.x * prevT.y, prevT.y * prevT.y, prevT.z * prevT.y))); + put(posArr, 0, centre(uOfS(0))); put(tanArr, 0, prevT); put(norArr, 0, n); + + for (let i = 1; i < M; i++) { + const u = uOfS(Math.min(i * DS, L)); + const p = centre(u), t = tangentAtU(u); + const axis = cross(prevT, t); + const sinA = len(axis), cosA = dot(prevT, t); + if (sinA > 1e-9) { // Rodrigues: rotate n by the frame's own turn + const k = norm(axis), angle = Math.atan2(sinA, cosA); + const c = Math.cos(angle), sN = Math.sin(angle), kv = cross(k, n), kd = dot(k, n); + n = V(n.x * c + kv.x * sN + k.x * kd * (1 - c), + n.y * c + kv.y * sN + k.y * kd * (1 - c), + n.z * c + kv.z * sN + k.z * kd * (1 - c)); + } + n = norm(V(n.x - t.x * dot(n, t), n.y - t.y * dot(n, t), n.z - t.z * dot(n, t))); // re-orthogonalize + put(posArr, i, p); put(tanArr, i, t); put(norArr, i, n); + prevT = t; + } + } + const at = (arr, i) => V(arr[i * 3], arr[i * 3 + 1], arr[i * 3 + 2]); + + // --- peristalsis phase: K(s) = ∫ k dx, trapezoid on the same grid ---------------------- + const kAt = (s) => OMEGA / Math.max(1, paramAt(s, 'flow')); + const phaseArr = new Float64Array(M); + for (let i = 1; i < M; i++) + phaseArr[i] = phaseArr[i - 1] + 0.5 * (kAt((i - 1) * DS) + kAt(i * DS)) * DS; + const phaseAt = (s) => { + const t = clamp(s, 0, L) / DS, i = Math.min(Math.floor(t), M - 2); + return lerp(phaseArr[i], phaseArr[i + 1], t - i); + }; + + /** Frame at arclength s. pos/tan/nor/bin + geometric radius. */ + function frameAt(s) { + const t = clamp(s, 0, L) / DS, i = Math.min(Math.floor(t), M - 2), f = t - i; + const pos = lerpV(at(posArr, i), at(posArr, i + 1), f); + const tan = norm(lerpV(at(tanArr, i), at(tanArr, i + 1), f)); + let nor = lerpV(at(norArr, i), at(norArr, i + 1), f); + nor = norm(V(nor.x - tan.x * dot(nor, tan), nor.y - tan.y * dot(nor, tan), nor.z - tan.z * dot(nor, tan))); + return { pos, tan, nor, bin: cross(tan, nor), radius: radiusAt(s) }; + } + + /** + * World -> spline space. Seeded from u = -pos.z (exact for the centreline's own axis, so + * the seed error is only the lateral tilt), then fixed-point: s += (p - C(s))·T(s). + * Converges at rate ~ curvature*rho (<0.5 in tube mode). Step-clamped so a point far off + * the centreline (arena mode) walks instead of exploding. + */ + function project(p, hint) { + let s = typeof hint === 'number' ? clamp(hint, 0, L) : sOfU(clamp(-p.z, 0, uMax)); + for (let i = 0; i < 6; i++) { + const f = frameAt(s); + const d = dot(sub(p, f.pos), f.tan); + const next = clamp(s + clamp(d, -8, 8), 0, L); + if (Math.abs(next - s) < 1e-4) { s = next; break; } + s = next; + } + const f = frameAt(s); + const rel = sub(p, f.pos); + const along = dot(rel, f.tan); + return { + s, + theta: Math.atan2(dot(rel, f.bin), dot(rel, f.nor)), + rho: Math.sqrt(Math.max(0, dot(rel, rel) - along * along)), // true cross-section radius + }; + } + + /** Golden determinism hash over sampled frames + radius + wave phase. qa.sh compares. */ + function hash() { + let h = 2166136261 >>> 0; + const push = (x) => { h ^= Math.round(x * 1000) & 0xffff; h = Math.imul(h, 16777619); }; + for (let s = 0; s <= L; s += 10) { + const f = frameAt(s); + push(f.pos.x); push(f.pos.y); push(f.pos.z); push(f.radius); + push(f.nor.x); push(f.nor.y); push(f.nor.z); push(phaseAt(s)); + } + return (h >>> 0).toString(16); + } + + /** Measured geometry health — the pinch guard is the one that bites. */ + function stats() { + let maxCurvature = 0, sAtMax = 0, maxRadius = 0, minRadius = Infinity; + for (let i = 1; i < M - 1; i++) { + const k = len(sub(at(tanArr, i + 1), at(tanArr, i - 1))) / (2 * DS); + if (k > maxCurvature) { maxCurvature = k; sAtMax = i * DS; } + } + for (let s = 0; s <= L; s += 1) { + const r = radiusAt(s); + if (r > maxRadius) maxRadius = r; + if (r < minRadius) minRadius = r; + } + const minTurnRadius = maxCurvature > 0 ? 1 / maxCurvature : Infinity; + return { + length: L, gridPoints: M, uMax, + maxCurvature, sAtMaxCurvature: sAtMax, minTurnRadius, + minRadius, maxRadius, + pinchRatio: minTurnRadius / maxRadius, // <1 => tube folds through itself on a bend + }; + } + + return { + length: L, spans, uMax, + centre, sOfU, uOfS, ampAtU, + radiusAt, frameAt, project, phaseAt, kAt, paramAt, + omega: OMEGA, + hash, stats, + segmentAt: (s) => spans[idxAt(clamp(s, 0, L))].seg, + }; +} + +// --------------------------------------------------------------------------------------- +// headless selfcheck: `node web/js/world/spline.js --selfcheck` (qa.sh gate) +// Browsers never reach this (no `process`). +// --------------------------------------------------------------------------------------- +async function selfcheck() { + const { FIXTURE } = await import('./_fixture.js'); + let fail = 0; + const ok = (name, cond, detail = '') => { + if (cond) console.log(` \x1b[32m✓\x1b[0m ${name}${detail ? ' — ' + detail : ''}`); + else { fail++; console.log(` \x1b[31m✗\x1b[0m ${name}${detail ? ' — ' + detail : ''}`); } + }; + console.log('world/spline selfcheck (fixture: %s)', FIXTURE.id); + + const a = buildSpline(FIXTURE); + const b = buildSpline(FIXTURE); + const c = buildSpline({ ...FIXTURE, seed: FIXTURE.seed + 1 }); + const st = a.stats(); + + ok('determinism: same seed => same hash', a.hash() === b.hash(), a.hash()); + ok('determinism: different seed => different hash', a.hash() !== c.hash(), `${a.hash()} vs ${c.hash()}`); + + const declared = FIXTURE.segments.reduce((n, s) => n + s.length, 0); + ok('arclength: length == declared total', Math.abs(a.length - declared) < 1e-6, `${a.length}`); + + let mono = true, prev = -1; + for (let s = 0; s <= a.length; s += 1) { const u = a.uOfS(s); if (u < prev) mono = false; prev = u; } + ok('arclength: s -> u monotone', mono); + + let maxRt = 0; + for (let s = 0; s <= a.length; s += 3) maxRt = Math.max(maxRt, Math.abs(a.sOfU(a.uOfS(s)) - s)); + ok('arclength: s -> u -> s round-trip < 0.05', maxRt < 0.05, `max err ${maxRt.toExponential(2)}`); + + let orth = 0, unit = 0; + for (let s = 0; s <= a.length; s += 2) { + const f = a.frameAt(s); + orth = Math.max(orth, Math.abs(f.tan.x * f.nor.x + f.tan.y * f.nor.y + f.tan.z * f.nor.z)); + unit = Math.max(unit, Math.abs(Math.hypot(f.nor.x, f.nor.y, f.nor.z) - 1), Math.abs(Math.hypot(f.bin.x, f.bin.y, f.bin.z) - 1)); + } + ok('frames: orthonormal (tan·nor ~ 0, |nor| = |bin| = 1)', orth < 1e-9 && unit < 1e-9, + `tan·nor ${orth.toExponential(1)}, unit err ${unit.toExponential(1)}`); + + // parallel transport must not spin the frame up: total twist stays bounded & smooth + let maxTwist = 0; + for (let s = 1; s <= a.length; s += 1) { + const p = a.frameAt(s - 1).nor, q = a.frameAt(s).nor; + maxTwist = Math.max(maxTwist, Math.acos(Math.min(1, Math.abs(p.x * q.x + p.y * q.y + p.z * q.z)))); + } + ok('frames: no roll snap (max per-unit normal turn < 0.2 rad)', maxTwist < 0.2, `${maxTwist.toFixed(4)} rad`); + + ok('geometry: tube does not pinch (minTurnRadius / maxRadius > 1.5)', st.pinchRatio > 1.5, + `turn R ${st.minTurnRadius.toFixed(1)} vs tube R ${st.maxRadius.toFixed(1)} => ${st.pinchRatio.toFixed(2)}x`); + + // project() round-trip: place a point at a known (s, theta, rho), recover it + let maxSerr = 0, maxTerr = 0, maxRerr = 0; + for (let s = 5; s < a.length - 5; s += 7) { + for (const theta of [0, 1.3, -2.4, 3.0]) { + const rho = a.radiusAt(s) * 0.6; + const f = a.frameAt(s); + const p = { + x: f.pos.x + (f.nor.x * Math.cos(theta) + f.bin.x * Math.sin(theta)) * rho, + y: f.pos.y + (f.nor.y * Math.cos(theta) + f.bin.y * Math.sin(theta)) * rho, + z: f.pos.z + (f.nor.z * Math.cos(theta) + f.bin.z * Math.sin(theta)) * rho, + }; + const q = a.project(p); + maxSerr = Math.max(maxSerr, Math.abs(q.s - s)); + let dt = Math.abs(q.theta - theta) % (Math.PI * 2); + if (dt > Math.PI) dt = Math.PI * 2 - dt; + maxTerr = Math.max(maxTerr, dt); + maxRerr = Math.max(maxRerr, Math.abs(q.rho - rho)); + } + } + ok('project: recovers s within 0.25', maxSerr < 0.25, `max ${maxSerr.toFixed(4)}`); + ok('project: recovers theta within 0.02 rad', maxTerr < 0.02, `max ${maxTerr.toFixed(5)}`); + ok('project: recovers rho within 0.05', maxRerr < 0.05, `max ${maxRerr.toFixed(4)}`); + + // wave: phase monotone, and crest speed == local flow (the whole point of K(s)) + let phaseMono = true; + for (let s = 1; s <= a.length; s += 1) if (a.phaseAt(s) <= a.phaseAt(s - 1)) phaseMono = false; + ok('wave: phase K(s) strictly increasing', phaseMono); + const kEso = a.kAt(50), kSto = a.kAt(a.length - 20); + ok('wave: k == OMEGA/flow (esophagus flow 14 => k 0.22, matches stub)', Math.abs(kEso - 0.22) < 0.001, `k=${kEso.toFixed(4)}`); + ok('wave: crest speed == local flow in each biome', + Math.abs(OMEGA / kEso - 14) < 0.01 && Math.abs(OMEGA / kSto - 4) < 0.01, + `${(OMEGA / kEso).toFixed(2)} u/s eso, ${(OMEGA / kSto).toFixed(2)} u/s stomach`); + + console.log(` stats: L=${st.length} grid=${st.gridPoints} maxK=${st.maxCurvature.toFixed(4)} (s=${st.sAtMaxCurvature}) radius=${st.minRadius.toFixed(1)}..${st.maxRadius.toFixed(1)} hash=${a.hash()}`); + console.log(fail === 0 ? '\x1b[32mworld/spline: OK\x1b[0m' : `\x1b[31mworld/spline: ${fail} FAILED\x1b[0m`); + return fail; +} + +if (typeof process !== 'undefined' && process.argv && process.argv.includes('--selfcheck')) { + selfcheck().then((f) => process.exit(f === 0 ? 0 : 1)); +} diff --git a/web/js/world/tube.js b/web/js/world/tube.js new file mode 100644 index 0000000..5bf227d --- /dev/null +++ b/web/js/world/tube.js @@ -0,0 +1,149 @@ +// world/tube.js (Lane A) — chunked tube geometry + the streaming window. +// +// The canal is extruded as rings along the parallel-transport frames and cut into chunks of +// ~CHUNK_LEN. Chunks are built/disposed around playerS so a 3000-unit level costs the same as +// a 300-unit one. Chunk seams are aligned to segment (biome) boundaries so a chunk never spans +// two biomes: each chunk gets exactly one material, and the tint changes exactly at the +// anatomical join — which is where a sphincter is anyway (GDD §structure). + +import * as THREE from 'three'; + +const TAU = Math.PI * 2; + +export function createTube({ spline, materialFor, quality = 'high', skipSpans = [] }) { + const Q = quality === 'low' + ? { radial: 48, step: 0.75, ahead: 120, behind: 45 } + : { radial: 64, step: 0.5, ahead: 180, behind: 60 }; + const CHUNK_LEN = 40; + const BUILD_BUDGET = 2; // chunks per update: a boost must not stall the frame + + const group = new THREE.Group(); + group.name = 'tube'; + + // --- chunk plan: subdivide each segment span, never straddle a biome join -------------- + // Chunks whose midpoint falls inside an arena are dropped: the arena shell is the wall + // there. The seam is therefore chunk-quantized (+/-CHUNK_LEN/2) — good enough for v0, and + // round 2 replaces it with real sphincter joint geometry anyway (LANE_A_NOTES). + const skipped = (s) => skipSpans.some(([a, b]) => s >= a && s <= b); + const plan = []; + for (const span of spline.spans) { + const len = span.s1 - span.s0; + const n = Math.max(1, Math.round(len / CHUNK_LEN)); + for (let i = 0; i < n; i++) { + const s0 = span.s0 + (len * i) / n; + const s1 = span.s0 + (len * (i + 1)) / n; + if (skipped((s0 + s1) / 2)) continue; + plan.push({ s0, s1, biomeId: span.seg.biome }); + } + } + + const live = new Map(); // plan index -> THREE.Mesh + const stats = { built: 0, disposed: 0, get live() { return live.size; } }; + + function buildChunk(i) { + const c = plan[i]; + const rings = Math.max(2, Math.round((c.s1 - c.s0) / Q.step) + 1); + const cols = Q.radial + 1; // duplicate the seam column so uv.x runs 0..1 without + // wrapping backwards across the last quad (visible streak) + const n = rings * cols; + const position = new Float32Array(n * 3); + const aInward = new Float32Array(n * 3); + const aTangent = new Float32Array(n * 3); + const uv = new Float32Array(n * 2); + const aPhase = new Float32Array(n); + const aK = new Float32Array(n); + + let p = 0, q = 0, w = 0; + for (let r = 0; r < rings; r++) { + const s = c.s0 + ((c.s1 - c.s0) * r) / (rings - 1); + const f = spline.frameAt(s); + const phase = spline.phaseAt(s); + const k = spline.kAt(s); + for (let j = 0; j < cols; j++) { + const th = (j / Q.radial) * TAU; + const ct = Math.cos(th), st = Math.sin(th); + const dx = f.nor.x * ct + f.bin.x * st; + const dy = f.nor.y * ct + f.bin.y * st; + const dz = f.nor.z * ct + f.bin.z * st; + position[p] = f.pos.x + dx * f.radius; + position[p + 1] = f.pos.y + dy * f.radius; + position[p + 2] = f.pos.z + dz * f.radius; + aInward[p] = -dx; aInward[p + 1] = -dy; aInward[p + 2] = -dz; + aTangent[p] = f.tan.x; aTangent[p + 1] = f.tan.y; aTangent[p + 2] = f.tan.z; + p += 3; + uv[q++] = j / Q.radial; uv[q++] = s; + aPhase[w] = phase; aK[w] = k; w++; + } + } + + const idx = new (n > 65535 ? Uint32Array : Uint16Array)((rings - 1) * Q.radial * 6); + let t = 0; + for (let r = 0; r < rings - 1; r++) { + for (let j = 0; j < Q.radial; j++) { + const a = r * cols + j, b = a + 1, cc = a + cols, d = b + cols; + idx[t++] = a; idx[t++] = cc; idx[t++] = b; // wound to face inward + idx[t++] = b; idx[t++] = cc; idx[t++] = d; + } + } + + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(position, 3)); + geo.setAttribute('aInward', new THREE.BufferAttribute(aInward, 3)); + geo.setAttribute('aTangent', new THREE.BufferAttribute(aTangent, 3)); + geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2)); + geo.setAttribute('aPhase', new THREE.BufferAttribute(aPhase, 1)); + geo.setAttribute('aK', new THREE.BufferAttribute(aK, 1)); + geo.setIndex(new THREE.BufferAttribute(idx, 1)); + geo.computeBoundingSphere(); + geo.boundingSphere.radius += 2; // the vertex shader displaces inward; keep culling honest + + const mesh = new THREE.Mesh(geo, materialFor(c.biomeId)); + mesh.name = `tube[${i}] ${c.biomeId} ${c.s0.toFixed(0)}..${c.s1.toFixed(0)}`; + group.add(mesh); + live.set(i, mesh); + stats.built++; + } + + function disposeChunk(i) { + const mesh = live.get(i); + if (!mesh) return; + group.remove(mesh); + mesh.geometry.dispose(); // material is shared per biome; index.js owns it + live.delete(i); + stats.disposed++; + } + + function wanted(playerS) { + const lo = playerS - Q.behind, hi = playerS + Q.ahead; + const set = new Set(); + for (let i = 0; i < plan.length; i++) if (plan[i].s1 >= lo && plan[i].s0 <= hi) set.add(i); + return set; + } + + return { + group, + stats, + chunkCount: plan.length, + quality: Q, + + /** Synchronous full fill of the window — used once at load, inside the <2s budget. */ + prime(playerS = 0) { + for (const i of wanted(playerS)) if (!live.has(i)) buildChunk(i); + }, + + update(playerS) { + const want = wanted(playerS); + for (const i of live.keys()) if (!want.has(i)) disposeChunk(i); + let budget = BUILD_BUDGET; + for (const i of want) { + if (live.has(i)) continue; + buildChunk(i); + if (--budget <= 0) break; + } + }, + + dispose() { + for (const i of [...live.keys()]) disposeChunk(i); + }, + }; +} diff --git a/web/js/world/wall_material.js b/web/js/world/wall_material.js new file mode 100644 index 0000000..ae5ea45 --- /dev/null +++ b/web/js/world/wall_material.js @@ -0,0 +1,114 @@ +// world/wall_material.js (Lane A) — the synthetic-scanner wall (ART_BIBLE.md §Rendering recipe). +// No real-time lights anywhere in GUTS: the wall is biome tint x detail luminance + a fresnel +// rim (the SEM edge glow) + exponential fog to the biome void. Peristalsis is vertex work. +// +// Attributes the geometry must supply (tube.js and arena.js both do): +// position vec3 baked ring/shell vertex +// aInward vec3 unit normal pointing into the playable space +// aTangent vec3 unit centreline tangent at this vertex (for the wave's normal tilt) +// uv vec2 (theta/2pi, s) — s in world units, NOT normalized: it's the wave's phase +// axis and the tiling axis, and it must stay continuous across chunks +// aPhase float K(s) = ∫k ds, baked (see spline.js) +// aK float local wavenumber, for the analytic d(pulse)/ds +// +// Colorspace: like the stub, this writes its computed color straight out with no +// conversion. That's a whole-game decision (it moves every color at +// once) so it stays matched to F's round-0 look until F rules on it — see LANE_A_NOTES. + +import * as THREE from 'three'; + +export function createWallMaterial({ + biome, + omega, + fog = biome.fog, // overridable: arenas size their own fog to the room (see index.js) + waveAmp = biome.wave.amp, + breatheAmp = biome.wave.breathe, + detail = null, // THREE.Texture | null — Lane D's grayscale detail map + tile = null, // [repeatsAroundCircumference, unitsOfSPerRepeat] + radiusHint = 10, // used only to pick a square-ish default tiling + side = THREE.FrontSide, +}) { + if (detail) { // defensive: we consume D's texture, so we set what we depend on + detail.wrapS = detail.wrapT = THREE.RepeatWrapping; + detail.needsUpdate = true; + } + const tileAround = tile ? tile[0] : 3; + const tileAlong = tile ? tile[1] : Math.max(4, (2 * Math.PI * radiusHint) / tileAround); + + return new THREE.ShaderMaterial({ + side, + defines: detail ? { USE_DETAIL: '' } : {}, + uniforms: { + uTime: { value: 0 }, + uTint: { value: new THREE.Color(biome.palette.tint) }, + uRim: { value: new THREE.Color(biome.palette.rim) }, + uVoid: { value: new THREE.Color(biome.palette.void) }, + uFog: { value: fog }, + uWaveA: { value: waveAmp }, + uBreatheA: { value: breatheAmp }, + uOmega: { value: omega }, + uRimPow: { value: 2.2 }, + uRimGain: { value: 0.9 }, + uDetail: { value: detail }, + uTile: { value: new THREE.Vector2(tileAround, tileAlong) }, + }, + vertexShader: /* glsl */` + attribute vec3 aInward; + attribute vec3 aTangent; + attribute float aPhase; + attribute float aK; + uniform float uTime, uWaveA, uBreatheA, uOmega; + varying vec2 vUv; varying vec3 vN; varying vec3 vView; varying float vPulse; + + void main() { + vUv = uv; + float phi = aPhase - uOmega * uTime; + float sn = max(0.0, sin(phi)); + float pulse = sn * sn * sn; // sharp crest, long trough: a muscle, not a sine + float breathe = uBreatheA * sin(uv.y * 0.7 + uTime * 0.8) * sin(uv.x * 6.2831853 * 3.0); + float disp = uWaveA * pulse + breathe; + + // Tilt the normal with the wave. Without this the crests are silhouette-only and the + // rim light slides over them as if the wall were flat — the effective wall radius is + // rho(s) = radius - disp, so the inward normal leans along the tangent by d(rho)/ds. + float dPulse_ds = 3.0 * sn * sn * cos(phi) * aK; + vec3 nIn = normalize(aInward - aTangent * (uWaveA * dPulse_ds)); + + vec4 mv = modelViewMatrix * vec4(position + aInward * disp, 1.0); + vN = normalize(normalMatrix * nIn); + vView = -mv.xyz; + vPulse = pulse; + gl_Position = projectionMatrix * mv; + }`, + fragmentShader: /* glsl */` + uniform vec3 uTint, uRim, uVoid; + uniform float uFog, uRimPow, uRimGain; + #ifdef USE_DETAIL + uniform sampler2D uDetail; + uniform vec2 uTile; + #endif + varying vec2 vUv; varying vec3 vN; varying vec3 vView; varying float vPulse; + + void main() { + #ifdef USE_DETAIL + // Lane D authors grayscale; the biome tint is applied here, so one texture can serve + // two biomes at different tints (ART_BIBLE §FLUX prompt kit). + float detail = texture2D(uDetail, vec2(vUv.x * uTile.x, vUv.y / uTile.y)).r; + detail = mix(0.5, 1.2, detail); + #else + // Assets-optional law: no texture is the *shipping* look until D lands, not an error + // state. Ridged folds around theta + striation along s = a passable SEM stand-in. + float folds = 0.55 + 0.45 * sin(vUv.x * 6.2831853 * 9.0 + sin(vUv.y * 0.9) * 2.0); + float detail = folds * (0.85 + 0.15 * sin(vUv.y * 2.2)); + #endif + + vec3 base = uTint * detail * 0.8; + float fres = pow(1.0 - abs(dot(normalize(vN), normalize(vView))), uRimPow); + // crest sheen: the wave is a gameplay tell (ride it for boost), so it gets a little + // help beyond what its own geometry earns from the rim term + vec3 col = base + uRim * fres * uRimGain + uRim * vPulse * 0.10; + float d = length(vView); + gl_FragColor = vec4(mix(col, uVoid, 1.0 - exp(-uFog * d * 0.55)), 1.0); + }`, + }); +}