guts/web/js/world/tube.js
jing 689c478a7f [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>
2026-07-16 08:59:19 +10:00

150 lines
5.7 KiB
JavaScript

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