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>
153 lines
5.9 KiB
JavaScript
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);
|
|
},
|
|
};
|
|
}
|