[lane A] Round 1: the canal v0 — spline, streaming tube, scanner wall, arenas

Implements THE WORLD CONTRACT (TECH.md) in web/js/world/; drop-in for the stub.
boot.js now runs this on C's L2_esophagus (3600u, hash cefc4f83), zero console errors.

- spline.js: centreline + arclength LUTs + parallel-transport frames + radius law +
  peristalsis phase + project() + hash(). No three.js import, so qa can run its
  14-assertion selfcheck headless.
- curviness is solved from a curvature budget, not authored as an amplitude: the pinch
  guard caught the tube folding through itself on a wide+curvy join (turn radius 9.9 vs
  tube radius 17.2). C can no longer author a pinch.
- peristalsis: global OMEGA, k(s) = OMEGA/flow(s), phase K(s) = integral k ds. A crest
  therefore travels at exactly the local flow speed, so "surf the crest" == "ride the
  current". Esophagus wave amp 0.9 -> 1.4 (measured): moves wallRho for Lane B.
- tube.js: chunked streaming, seams aligned to biome joins, arenas own their s-span.
- arena.js: displaced icosphere shells v0 (three's polyhedron detail is 20*(detail+1)^2,
  not 20*4^detail — 720 tris was far too coarse); fog sized to the room, since biome fog
  tuned for 100u corridors renders C's 360u acid sea as a black rectangle.
- index.js: prefers assets.texture() over get() (get returns raw JSON a shader can't eat),
  plus an explicit slug map — D's wall_smallint_a vs biome id small_intestine would miss
  silently forever under the assets-optional law.

Measured (C's real L2 + D's real pack, 1920x1080, renderer.info): 8 draws worst frame
(budget 150), 74k tris (500k), no geometry leak across a full sweep, <120ms load,
pinch ratio 3.32. fps not claimed — the screenshot pane runs tabs hidden, pausing rAF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jing 2026-07-16 08:59:19 +10:00
parent 0a038582a7
commit 689c478a7f
17 changed files with 1675 additions and 0 deletions

193
docs/LANES/LANE_A_NOTES.md Normal file
View File

@ -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 | 58 (+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.150.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
`<colorspace_fragment>` 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.

View File

@ -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`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

17
web/js/world/_fixture.js Normal file
View File

@ -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: [],
};

119
web/js/world/arena.js Normal file
View File

@ -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(); },
};
}

85
web/js/world/biomes.js Normal file
View File

@ -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;
}

226
web/js/world/dev.html Normal file
View File

@ -0,0 +1,226 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>GUTS — Lane A world harness</title>
<!--
Lane A's own harness. NOT the game: js/boot.js is the game, F owns it.
This exists because boot.js can't reach js/world/index.js yet — pickWorld() imports Lane C's
js/levels/index.js in the same try block, and that module doesn't exist, so every non-stub
boot falls back to the stub regardless of my work landing. Patch offered in LANE_A_NOTES.md
§-> Lane F. Until it's applied, this is how the canal gets eyeballed. Delete it once boot can
show the real world.
http://localhost:8140/js/world/dev.html?dbg=1 rail cam down the fixture canal
...?dbg=1&fly=1 noclip (WASD/QE, drag to look)
...?dbg=1&s=430 park the rail cam at s=430
...?dbg=1&fakeassets=1 exercise the Lane D texture path
...?lvl=L2_esophagus C's level, when it lands
...?shots=1 hide the overlay
-->
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #02100d; }
canvas { display: block; }
#dbg { position: fixed; left: 10px; bottom: 10px; color: #39e6ff;
font: 12px/1.5 ui-monospace, monospace; text-shadow: 0 0 4px #000;
pointer-events: none; white-space: pre; }
</style>
<script type="importmap">
{ "imports": { "three": "../../vendor/three.module.js",
"three/addons/": "../../vendor/addons/" } }
</script>
</head>
<body>
<div id="dbg"></div>
<script type="module">
import * as THREE from 'three';
import { createRng } from '../core/rng.js';
import { createWorld } from './index.js';
import { createFlyCam } from './flycam.js';
import { FIXTURE } from './_fixture.js';
const p = new URLSearchParams(location.search);
const on = (k) => p.has(k) && p.get(k) !== '0';
const num = (k, d) => (p.has(k) ? parseFloat(p.get(k)) : d);
// --- level: C's if it's landed, fixture otherwise ---------------------------------------
let level = FIXTURE;
if (p.has('lvl')) {
try {
const mod = await import('../levels/index.js');
level = await mod.getLevel(p.get('lvl'));
} catch (e) {
console.warn('[devharness] no Lane C registry yet, using fixture —', e.message);
}
}
const seed = p.has('seed') ? (parseInt(p.get('seed'), 10) >>> 0) : level.seed;
// --- fake assets (?fakeassets=1) ---------------------------------------------------------
// Stands in for Lane D so the USE_DETAIL branch of the wall shader is exercised before their
// pack exists: a seamless grayscale fold pattern, same duck-type index.js expects from
// assets.get('textures', 'wall_<biome>_a'). Proves the integration, not the art.
function fakeAssets() {
const S = 256, cv = document.createElement('canvas');
cv.width = cv.height = S;
const ctx = cv.getContext('2d'), img = ctx.createImageData(S, S), r = createRng(seed)('assets.fake');
for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) {
const u = x / S, v = y / S; // periodic in both axes => tiles cleanly
let g = 0.5 + 0.22 * Math.sin(Math.PI * 2 * 3 * u + 2 * Math.sin(Math.PI * 2 * 2 * v))
+ 0.16 * Math.sin(Math.PI * 2 * 5 * v)
+ 0.10 * Math.sin(Math.PI * 2 * 11 * u + 1.7);
g = Math.max(0, Math.min(1, g + (r() - 0.5) * 0.10));
const i = (y * S + x) * 4, b = (g * 255) | 0;
img.data[i] = img.data[i + 1] = img.data[i + 2] = b; img.data[i + 3] = 255;
}
ctx.putImageData(img, 0, 0);
const tex = new THREE.CanvasTexture(cv);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
return { get: (kind, id) => (kind === 'textures' && /^wall_/.test(id) ? { isTexture: false, texture: tex, tile: [3, 14] } : null) };
}
// --- shell -------------------------------------------------------------------------------
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 600);
const t0 = performance.now();
// ?assets=1 uses Lane D's real loader + pack; ?fakeassets=1 uses the synthetic stand-in above.
let assets = null;
if (on('fakeassets')) assets = fakeAssets();
else if (on('assets')) {
try {
const m = await import('../core/assets.js');
assets = await m.createAssets({ flags: { localassets: p.get('localassets') !== '0' } });
} catch (e) {
console.warn('[devharness] Lane D assets.js unavailable, procedural walls —', e.message);
}
}
const world = await createWorld(level, { rng: createRng(seed), quality: p.get('quality') || 'high', assets });
const loadMs = performance.now() - t0;
scene.add(world.group);
scene.background = new THREE.Color(world.biomeAt(0).palette.void);
const fly = on('fly') ? createFlyCam({ camera, dom: renderer.domElement, world }) : null;
let sCam = num('s', 5);
const railSpeed = num('speed', 1) * (p.has('s') ? 0 : 1); // ?s= parks the cam for clean shots
function rail(dt) {
sCam = (sCam + world.biomeAt(sCam).flow * 0.6 * railSpeed * dt) % world.length;
const f = world.sample(sCam), ahead = world.sample(Math.min(sCam + 12, world.length));
camera.position.copy(f.pos).addScaledVector(f.nor, -f.radius * 0.25);
camera.up.copy(f.nor);
camera.lookAt(ahead.pos);
}
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
const dbgEl = document.getElementById('dbg');
if (!on('dbg') || on('shots')) dbgEl.style.display = 'none';
renderer.info.autoReset = false;
let frames = 0, fpsT = 0, fps = 0, draws = 0, tris = 0;
window.DBG = {
world, scene, camera, renderer,
get draws() { return draws; }, get tris() { return tris; }, get fps() { return fps; },
shot(name = 'shot') {
const a = document.createElement('a');
a.download = `${name}.png`;
a.href = renderer.domElement.toDataURL('image/png');
a.click();
},
/**
* Fix the drawing buffer to an explicit size for evidence frames. Two traps, both learned
* the hard way, both of which will bite anyone shooting boot.js (see LANE_A_NOTES §-> Lane F):
* 1. Screenshot tooling drives the tab HIDDEN. A hidden tab gets no requestAnimationFrame,
* so the render loop simply is not running when you shoot.
* 2. A hidden tab also reports innerWidth/innerHeight 0 — so the canvas is 0x0 and
* toDataURL() returns the literal string "data:,". The pane screenshot still looks fine
* because grabbing it fronts the tab; JS-side capture does not.
* Sizing explicitly also makes every committed shot the same resolution regardless of window.
*/
size(w = 1920, h = 1080) {
renderer.setPixelRatio(1);
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
return [renderer.domElement.width, renderer.domElement.height];
},
/** Deterministic evidence frame: park the rail cam at s, set the wave to exactly t, render. */
pose(s = 5, t = 0, w = 1920, h = 1080) {
this.size(w, h);
world.update(t - world.time, s); // land the wave phase exactly on t
for (let i = 0; i < 12; i++) world.update(0, s); // let the 2-chunks/update budget fill
sCam = s;
rail(0);
renderer.render(scene, camera);
const r = { s, t, draws: renderer.info.render.calls, tris: renderer.info.render.triangles,
pulse: world.flowPulse(s, t), ...world.stats() };
renderer.info.reset();
return r;
},
/** Park the noclip cam explicitly (arena interiors, wall close-ups), then render. */
poseAt(pos, lookAt, t = 0, w = 1920, h = 1080) {
this.size(w, h);
const p = new THREE.Vector3(...pos), l = new THREE.Vector3(...lookAt);
const s = world.project(p).s;
world.update(t - world.time, s);
for (let i = 0; i < 12; i++) world.update(0, s);
camera.position.copy(p);
camera.up.set(0, 1, 0);
camera.lookAt(l);
renderer.render(scene, camera);
const r = { s, draws: renderer.info.render.calls, tris: renderer.info.render.triangles };
renderer.info.reset();
return r;
},
/** Streaming leak probe: fly the whole canal, report renderer.info deltas. */
sweep(step = 20) {
const g0 = renderer.info.memory.geometries;
for (let s = 0; s <= world.length; s += step) world.update(0.016, s);
world.update(0.016, 0);
const g1 = renderer.info.memory.geometries;
return { geometriesBefore: g0, geometriesAfter: g1, delta: g1 - g0, ...world.stats() };
},
};
let last = performance.now();
function frame(now) {
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
if (fly) fly.update(dt); else rail(dt);
const camS = fly ? world.project(camera.position).s : sCam;
world.update(dt, camS);
renderer.render(scene, camera);
draws = renderer.info.render.calls;
tris = renderer.info.render.triangles;
renderer.info.reset();
frames++; fpsT += dt;
if (fpsT >= 0.5) {
fps = Math.round(frames / fpsT); frames = 0; fpsT = 0;
if (on('dbg') && !on('shots')) {
const st = world.stats();
dbgEl.textContent =
`${fps} fps · ${draws} draws · ${(tris / 1000) | 0}k tris · load ${loadMs.toFixed(0)}ms\n` +
`${level.id} seed=${seed} hash=${world.hash()} L=${world.length}\n` +
`s=${camS.toFixed(0)} ${world.biomeAt(camS).id} ${world.modeAt(camS)} · flow ${world.biomeAt(camS).flow.toFixed(1)} · pulse ${world.flowPulse(camS).toFixed(2)}\n` +
`chunks ${st.chunks} live / ${st.built} built / ${st.disposed} disposed · turnR ${st.minTurnRadius.toFixed(0)} vs R ${st.maxRadius.toFixed(1)}`;
}
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>

70
web/js/world/flycam.js Normal file
View File

@ -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);
},
};
}

229
web/js/world/index.js Normal file
View File

@ -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_<slug>_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;
}

427
web/js/world/spline.js Normal file
View File

@ -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));
}

149
web/js/world/tube.js Normal file
View File

@ -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);
},
};
}

View File

@ -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
// <colorspace_fragment> 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);
}`,
});
}