guts/web/js/world/tube.js
jing f0982e70c3 [lane A] Round 2 WIP (session cut off): crest-speed law, taper blending, schema-v2 wave, TBN walls
Landed before the cut: crestSpeed(s) + CREST_FACTOR 1.6 (ruling #1) with selfcheck
asserts; radius blend widened +/-12 -> +/-25 (C's #4 dependency) + no-cliff selfcheck;
per-segment wave.amp override as per-vertex aWaveA (ruling #8); colorspace law in the
wall shader (ruling #2); TBN normal maps + matcap + dual detail layers (D's perturb(),
trap documented in-shader); sample(s, out) v1.2; slug map shrunk to the two real
mismatches. Evidence: docs/shots/laneA/round2_L2_*.png.

Cut off before: NOTES/progress, stomach-arena shape read for C (task #7).
Committed by F to protect the shared tree; spline + qa selfchecks GREEN at commit time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 16:26:01 +10:00

153 lines
5.9 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);
const aWaveA = 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);
const waveA = spline.waveAmpAt(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; aWaveA[w] = waveA; 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.setAttribute('aWaveA', new THREE.BufferAttribute(aWaveA, 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);
},
};
}