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