guts/web/js/world/tube.js
jing 111ade7d12 [lane A] The swept room + the acid sea, built — and it corrected me four times
The shape read in LANE_A_NOTES was written from reading code, so I built it to
find out if it was true. Flyable: web/dev/laneA_world.html?dbg=1&assets=1&lvl=swept
Nothing here is imposed on anyone: every addition is additive and a level that
opts out behaves exactly as today. C can bin the whole proposal and lose nothing.

WHAT THE PROTOTYPE DISPROVED (my own cost estimates, all four):
- "radial resolution must scale with radius" — wrong. 64 segments is a 0.05u
  circle error at r=70. The arena needs it because it fbm-displaces; the tube's
  wave is smooth in theta. No change made.
- "cap geometry at the span ends" — a thinko. The canal is continuous; a room is
  a fat part of it. Only the fundus dome caps, and it stays an icosphere.
- cost — cheaper than feared: 9 draws / 98k tris worst-frame for a radius-70
  cathedral vs 8 / 74k for the L2 corridor. No leak over a full sweep.
- "one schema flag" — held. `segments[].mode: "open"` is the entire ask.

THE TWO REAL COSTS, NEITHER PREDICTED:
1. D's `tile[0]` is a COUNT, not a density — only a texel size if radius never
   changes. Measured 6.1:1 smear at r=70. The wall now derives the count from
   each ring's own arc length (aRadius attr + uThetaSpan); D's authored numbers
   keep their exact meaning at their reference radius, no re-authoring. That fix
   then laid a seam down the canal (a fractional count can't wrap) -> rounded to
   an integer, seam gone. Not a no-op on corridors: L2's hiatus takes 3 repeats
   where it took 4, which is the texel size correctly holding still as the pipe
   narrows. Eyeballed.
2. MY OWN PINCH GUARD WAS WRONG and would have blocked C. The fixture scored
   1.30 and "failed" the >1.5 law; it was never bent. stats() divided the
   tightest turn ANYWHERE by the widest radius ANYWHERE — 996 units apart, a
   bend in the radius-13 pylorus against the radius of the sea. Locally its
   worst point is 6.4. pinchRatio is now min over s of turnRadius(s)/radius(s).
   Provably one-directional (global <= local by construction) so nothing that
   passed can fail; real levels GAINED headroom: L2 3.32->4.16, L3 1.91->6.99,
   L1 2.11->6.06. This mattered — the guard is what backs the round-1 promise
   that C never has to think about curvature.

THE ACID SEA (world/acid.js, `level.acid {from,to,height,biome}`):
Flat emissive #c8ff3a, level and NOT tube-following — that's the mechanic. The
best find of the session: `height` is ONE NUMBER that tunes the level. Measured:
-18 -> 0% of the sea floor is dry shallow, -55 -> 14%, -62 -> 48%. So C's "position
IS the resource" falls out of the radius wobble they already author — the mucus
shallows are just "where the wall rises above the waterline", nothing to place —
and rising acid literally drowns them: free escalation, same scalar their event
pump drives. Also disproved my own claim: the centreline stays level (+/-1u over
700u), so shallows come from radius wobble, NOT the canal's bends. C must author
vertical bends deliberately if they want depth by anatomy.

Stated plainly, not papered over: the waterline is prototype quality (the plane
meets faceted rings and the shoreline steps). Acid does not drain the coat —
depthAt(pos) is the hook and the cost is Lane B's call. Nothing drives height but
DBG until C's events do.

qa GREEN incl. the new provenance gate; spline selfcheck green; L2 corridor
re-eyeballed for regression. Rulings requested from F in NOTES §-> Lane F.

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

155 lines
6.1 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);
const aRadius = new Float32Array(n); // the wall shader derives texel density from it
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; aRadius[w] = f.radius; 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.setAttribute('aRadius', new THREE.BufferAttribute(aRadius, 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);
},
};
}