diff --git a/docs/shots/laneA/round2_L2_hiatus.png b/docs/shots/laneA/round2_L2_hiatus.png new file mode 100644 index 0000000..51e1f70 Binary files /dev/null and b/docs/shots/laneA/round2_L2_hiatus.png differ diff --git a/docs/shots/laneA/round2_L2_tbn_wall.png b/docs/shots/laneA/round2_L2_tbn_wall.png new file mode 100644 index 0000000..eacbe9f Binary files /dev/null and b/docs/shots/laneA/round2_L2_tbn_wall.png differ diff --git a/web/js/world/arena.js b/web/js/world/arena.js index ad30385..e8606cf 100644 --- a/web/js/world/arena.js +++ b/web/js/world/arena.js @@ -37,7 +37,7 @@ function makeNoise3(rng) { * @param {THREE.Material} material a wall material built for this arena's biome * @param {function} rng */ -export function createArena({ spec, spline, material, rng, quality = 'high' }) { +export function createArena({ spec, spline, material, rng, quality = 'high', waveAmpDefault = 0.7 }) { // three's polyhedron `detail` splits each edge into (detail+1) segments, so face count is // 20*(detail+1)^2 — NOT 20*4^detail. detail:5 is 720 tris, which on a 55-unit room is a // 10-unit facet and the fbm displacement has nothing to displace. Solve for ~3u spacing @@ -57,6 +57,7 @@ export function createArena({ spec, spline, material, rng, quality = 'high' }) { const uv = new Float32Array(n * 2); const aPhase = new Float32Array(n); const aK = new Float32Array(n); + const aWaveA = new Float32Array(n); // The churn wave crosses the room along the canal's own axis, slowly enough to read as a // room breathing rather than a corridor's transit wave. @@ -65,6 +66,9 @@ export function createArena({ spec, spline, material, rng, quality = 'high' }) { const ref = new THREE.Vector3(f.nor.x, f.nor.y, f.nor.z); const bin = new THREE.Vector3(f.bin.x, f.bin.y, f.bin.z); const amp = spec.radius * 0.09; + // A room churns, it doesn't transit: the shell's wave amplitude comes from the arena's own + // biome (or C's per-arena override), never from whatever segment happens to span it. + const waveAmp = typeof spec.wave?.amp === 'number' ? spec.wave.amp : waveAmpDefault; const v = new THREE.Vector3(); for (let i = 0; i < n; i++) { @@ -80,6 +84,7 @@ export function createArena({ spec, spline, material, rng, quality = 'high' }) { uv[i * 2 + 1] = spec.at + along; // keep uv.y in canal-s units, like the tube aPhase[i] = k * (spec.at + along); aK[i] = k; + aWaveA[i] = waveAmp; } // Seam repair: uv.x comes from atan2, so a triangle straddling the -X axis interpolates it @@ -99,6 +104,7 @@ export function createArena({ spec, spline, material, rng, quality = 'high' }) { g.setAttribute('uv', new THREE.BufferAttribute(uv, 2)); g.setAttribute('aPhase', new THREE.BufferAttribute(aPhase, 1)); g.setAttribute('aK', new THREE.BufferAttribute(aK, 1)); + g.setAttribute('aWaveA', new THREE.BufferAttribute(aWaveA, 1)); g.computeBoundingSphere(); geo.dispose(); // the source icosphere was scaffolding @@ -112,7 +118,7 @@ export function createArena({ spec, spline, material, rng, quality = 'high' }) { center, radius: spec.radius, /** Conservative inner surface: shell minus displacement peak minus the shader's wave. */ - innerRadius: spec.radius - amp - (material.uniforms?.uWaveA?.value ?? 0) - 0.6, + innerRadius: spec.radius - amp - waveAmp - 0.6, covers: (s) => Math.abs(s - spec.at) <= spec.radius, dispose() { g.dispose(); }, }; diff --git a/web/js/world/index.js b/web/js/world/index.js index 5ef94c9..4118911 100644 --- a/web/js/world/index.js +++ b/web/js/world/index.js @@ -12,7 +12,7 @@ import * as THREE from 'three'; import { createRng } from '../core/rng.js'; -import { buildSpline, OMEGA } from './spline.js'; +import { buildSpline, OMEGA, CREST_FACTOR } from './spline.js'; import { biome as biomeOf } from './biomes.js'; import { createWallMaterial } from './wall_material.js'; import { createTube } from './tube.js'; @@ -20,9 +20,25 @@ import { createArena } from './arena.js'; const SKIN = 0.6; // collision safety margin (units) — matches the stub +/** + * Fill each segment's `wave` from its biome so the spline only ever sees numbers. C's schema v2 + * `segments[].wave: { amp }` overrides the biome (ruling #8) — the diaphragmatic hiatus is a + * fixed muscular ring: tight AND calm. Returns a shallow copy; `world.level` stays C's object. + */ +function normalizeLevel(levelData) { + return { + ...levelData, + segments: levelData.segments.map((seg) => { + const b = biomeOf(seg.biome); + return { ...seg, wave: { amp: seg.wave?.amp ?? b.wave.amp, breathe: seg.wave?.breathe ?? b.wave.breathe } }; + }), + }; +} + export async function createWorld(levelData, { rng, quality = 'high', assets = null } = {}) { const R = rng || createRng((levelData?.seed ?? 0) >>> 0); - const spline = buildSpline(levelData, R); + if (!levelData || !Array.isArray(levelData.segments)) throw new Error('[world] level.segments is required'); + const spline = buildSpline(normalizeLevel(levelData), R); const arenaSpecs = Array.isArray(levelData.arenas) ? levelData.arenas : []; // --- assets: optional, always (TECH.md §Asset manifest contract) ----------------------- @@ -44,29 +60,28 @@ export async function createWorld(levelData, { rng, quality = 'high', assets = n // wrong key doesn't throw — it silently falls back to the procedural wall forever, which is // the worst kind of bug. Explicit map, so a mismatch is visible in one place. // -> Lane D: proposing we standardize on the biome ids in round 2 (LANE_A_NOTES §-> Lane D). - const TEXTURE_SLUG = { - oral: 'oral', esophagus: 'esophagus', stomach: 'stomach', - small_intestine: 'smallint', large_intestine: 'colon', appendix: 'appendix', - }; - const TEXTURE_FOR = (biomeId) => `wall_${TEXTURE_SLUG[biomeId] ?? biomeId}_a`; - function detailFor(biomeId) { - if (!assets) return { detail: null, tile: null }; - const name = TEXTURE_FOR(biomeId); + // TEMPORARY (ruling #3): D's round-1 keys aren't the biome ids — `small_intestine` ships as + // `wall_smallint_a`. D renames to biome ids early this round and pings in NOTES; **delete + // this map and the ?? fallback the moment they do.** D's assets.js now has a miss ledger + // (`assets.misses()`), so a drifted slug announces itself instead of silently falling back + // forever — which is what made this dangerous in round 1. + const TEXTURE_SLUG = { small_intestine: 'smallint', large_intestine: 'colon' }; + const slug = (biomeId) => TEXTURE_SLUG[biomeId] ?? biomeId; + + /** One wall's full texture set, all optional and independently so. */ + function texturesFor(biomeId) { + const out = { detail: null, detailB: null, normalMap: null, matcap: null, repeat: null, repeatB: null }; + if (!assets || typeof assets.texture !== 'function') return out; try { - if (typeof assets.texture === 'function') { - const t = assets.texture(name); - if (t && t.map) return { detail: t.map, tile: Array.isArray(t.tile) ? t.tile : null }; - } - if (typeof assets.get === 'function') { - const e = assets.get('textures', name); - if (!e) return { detail: null, tile: null }; - const tex = e.isTexture ? e : (e.texture ?? e.map ?? null); - return { detail: tex, tile: Array.isArray(e.tile) ? e.tile : null }; - } + const a = assets.texture(`wall_${slug(biomeId)}_a`); + if (a?.map) { out.detail = a.map; out.repeat = a.repeat ?? null; out.normalMap = a.normalMap ?? null; } + const b = assets.texture(`wall_${slug(biomeId)}_b`); + if (b?.map) { out.detailB = b.map; out.repeatB = b.repeat ?? null; } + if (typeof assets.matcap === 'function') out.matcap = assets.matcap('tissue_wet') ?? null; } catch (err) { - console.warn(`[world] assets lookup failed for ${name}, using procedural wall —`, err.message); + console.warn(`[world] assets lookup failed for ${biomeId}, using procedural wall —`, err.message); } - return { detail: null, tile: null }; + return out; } // --- materials ------------------------------------------------------------------------- @@ -80,10 +95,10 @@ export async function createWorld(levelData, { rng, quality = 'high', assets = n }; function makeMaterial(biomeId, side, fog) { - const b = biomeOf(biomeId); - const { detail, tile } = detailFor(biomeId); const m = createWallMaterial({ - biome: b, omega: OMEGA, detail, tile, radiusHint: radiusHintFor(biomeId), side, fog, + biome: biomeOf(biomeId), omega: OMEGA, side, fog, + radiusHint: radiusHintFor(biomeId), + ...texturesFor(biomeId), }); owned.push(m); return m; @@ -125,6 +140,7 @@ export async function createWorld(levelData, { rng, quality = 'high', assets = n const a = createArena({ spec, spline, rng: R, quality, material: arenaMaterialFor(spec), // shell viewed from inside + waveAmpDefault: biomeOf(spec.biome).wave.amp, // the room's biome, not the segment's }); group.add(a.mesh); return a; @@ -136,20 +152,30 @@ export async function createWorld(levelData, { rng, quality = 'high', assets = n let time = 0; const biomeIdAt = (s) => spline.segmentAt(s).biome; - const waveMaxAt = (s) => { const b = biomeOf(biomeIdAt(s)); return b.wave.amp + b.wave.breathe; }; + // Prefers the per-segment override (schema v2) over the biome default, via the blended + // schedule — so wallRho tracks C's calm hiatus instead of the biome's loudest wave. + const waveMaxAt = (s) => spline.waveAmpAt(s) + biomeOf(biomeIdAt(s)).wave.breathe; const arenaSpatial = (v) => arenas.find((a) => v.distanceTo(a.center) <= a.radius) || null; - function sample(s) { + /** + * Contract v1.2: `sample(s)` allocates ~6 Vector3s and it's B's hot path (~60 calls/frame at + * 55 enemies = ~350 allocations/frame of GC churn — LANE_B_NOTES → A #3). Pass a caller-owned + * frame as `out` and nothing allocates. The allocating form stays as sugar. + */ + function sample(s, out) { const f = spline.frameAt(s); - return { - pos: new THREE.Vector3(f.pos.x, f.pos.y, f.pos.z), - tan: new THREE.Vector3(f.tan.x, f.tan.y, f.tan.z), - nor: new THREE.Vector3(f.nor.x, f.nor.y, f.nor.z), - bin: new THREE.Vector3(f.bin.x, f.bin.y, f.bin.z), - radius: f.radius, - }; + const o = out || { pos: new THREE.Vector3(), tan: new THREE.Vector3(), nor: new THREE.Vector3(), bin: new THREE.Vector3() }; + o.pos.set(f.pos.x, f.pos.y, f.pos.z); + o.tan.set(f.tan.x, f.tan.y, f.tan.z); + o.nor.set(f.nor.x, f.nor.y, f.nor.z); + o.bin.set(f.bin.x, f.bin.y, f.bin.z); + o.radius = f.radius; + return o; } + /** A reusable frame, for callers who want the fast path without owning the boilerplate. */ + sample.frame = () => ({ pos: new THREE.Vector3(), tan: new THREE.Vector3(), nor: new THREE.Vector3(), bin: new THREE.Vector3(), radius: 0 }); + const world = { level: levelData, length: spline.length, @@ -199,10 +225,14 @@ export async function createWorld(levelData, { rng, quality = 'high', assets = n return a ? { center: a.center, radius: a.radius } : null; }, - /** JS mirror of the vertex shader's wave, exact. B: crest speed == biomeAt(s).flow, so a - * ship riding a crest is riding the current. E: pulse the mix with it. */ + /** JS mirror of the vertex shader's wave, exact (0 = trough, 1 = crest). E: pulse the mix. */ flowPulse: (s, t = time) => Math.pow(Math.max(0, Math.sin(spline.phaseAt(s) - OMEGA * t)), 3), + /** Crest-speed law (TECH v1.1): u/s the crest travels == CREST_FACTOR × flow(s). B + * speed-locks to this while surfing; it outruns throttleMax by design (1.6 > 1.4). */ + crestSpeed: (s) => spline.crestSpeedAt(s), + crestFactor: CREST_FACTOR, + update(dt, playerS = 0) { time += dt; for (const m of owned) m.uniforms.uTime.value = time; diff --git a/web/js/world/spline.js b/web/js/world/spline.js index ddc1ff7..b3a82e3 100644 --- a/web/js/world/spline.js +++ b/web/js/world/spline.js @@ -40,26 +40,39 @@ const smoothstep = (e0, e1, x) => { const t = clamp((x - e0) / (e1 - e0), 0, 1); // --- tuning ----------------------------------------------------------------------------- // OMEGA is the peristaltic contraction *rate*, global to the whole canal and constant in // time — physiologically it's how often the muscle fires, which doesn't change because you -// crossed into a wider pipe. The wavenumber is what varies: k(s) = OMEGA / flow(s), so a -// wave crest travels at exactly the local flow speed and Lane B can surf one. Phase is the -// integral K(s) = ∫k ds (phaseAt), which stays continuous across flow changes — plain -// `k*s - w*t` does not, and tears the wave at every segment join. -// 3.08 = 0.22 rad/unit x 14 units/s, i.e. the stub's esophagus wave, preserved exactly. +// crossed into a wider pipe. The wavenumber is what varies: k(s) = OMEGA / crestSpeed(s), so +// phase is the integral K(s) = ∫k ds (phaseAt), which stays continuous across flow changes — +// plain `k*s - w*t` does not, and tears the wave at every segment join. +// 3.08 = 0.22 rad/unit x 14 units/s, i.e. the stub's round-0 esophagus wave. export const OMEGA = 3.08; +// Crest-speed law (TECH §FROZEN v1.1, round-2 ruling #1). Round 1 made a crest travel at +// exactly flow(s), which felt right and played wrong: B measured that surfing then loses to +// throttle-mashing (throttleMax 1.4 × flow > 1.0 × flow), so the level's signature mechanic +// was strictly dominated. The wave has to outrun the player. crestSpeed = 1.6 × flow beats +// 1.4 and stays "gameplay wave == visual wave" — the thing you see is the thing you ride. +// Consequence: crest spacing grows 1.6× (45.7u at flow 14, was 28.5u). Rings are further +// apart and move faster, which reads better on a speed level anyway. +export const CREST_FACTOR = 1.6; + const DU = 0.5; // march step in curve parameter const DS = 0.5; // frame/phase LUT spacing in arclength const NOISE_TAB = 1024; const RADIUS_WAVELENGTH = 55; // units per radius-fbm octave-0 cycle const PINCH_SAFETY = 2.2; // min turn radius = this x base radius (>2 covers wobble peaks) -// Blend widths (units) for smoothstep-crossfading segment params across a join. Radius and -// flow are local scalars — a tight 12u transition reads as a sphincter. Curviness feeds -// centreline *amplitude*, which has a long lever arm: ramping it over a short span is itself a -// hard lateral swerve, i.e. curvature. It gets a wide, gentle ramp. -// `curvBase` is baseRadius again, but read through the wide ramp: it divides the curvature -// budget, so a 12u step in it would swerve the centreline just as hard as curviness would. -const BLEND = { curviness: 60, curvBase: 60, base: 12, wobble: 12, flow: 12 }; +// Blend widths (units) for smoothstep-crossfading segment params across a join. Nothing ever +// steps: a hard radius change would read as a level seam, and C's constrictions are supposed +// to feel like the body narrowing. +// +// `base`/`wobble` at ±25 => a 50-unit taper, which is what C's L2 was authored against +// (LANE_C_NOTES → A #4: "assumes a smooth blend over ~40–60 units", their biggest open +// dependency on me). Round 1 was ±12; widened here to match the design. At L2's 12→9 step +// (s 1100) that's ~3.5s of narrowing at flow 16 — you feel the body close in, not a wall. +// `curviness`/`curvBase` feed centreline *amplitude*, which has a long lever arm: ramping it +// over a short span is itself a hard lateral swerve, i.e. curvature. They get a wider ramp. +// `waveAmp` tracks radius: a segment that is calmer is calmer over the same taper it narrows. +const BLEND = { curviness: 60, curvBase: 60, base: 25, wobble: 25, flow: 12, waveAmp: 25 }; // [wavelength, share of the axis curvature budget]. Y is tamer than X on purpose: a canal that // writhes vertically as hard as it does laterally would swing the parallel-transport frame @@ -82,6 +95,11 @@ const GET = { base: (g) => (g.radius && typeof g.radius.base === 'number' ? g.radius.base : 10), wobble: (g) => (g.radius && typeof g.radius.wobble === 'number' ? g.radius.wobble : 0.2), flow: (g) => (typeof g.flow === 'number' ? g.flow : 10), + // schema v2 `segments[].wave: { amp }` (round-2 ruling #8, C's request): the diaphragmatic + // hiatus is a fixed muscular ring and does NOT have big peristaltic waves, but it's also the + // level's tightest hole — so biome-wide amplitude put the biggest wave in the smallest gap. + // index.js fills this from the biome registry before we ever see it, so it's always a number. + waveAmp: (g) => (g.wave && typeof g.wave.amp === 'number' ? g.wave.amp : 1.0), }; GET.curvBase = GET.base; @@ -245,7 +263,9 @@ export function buildSpline(level, rng) { const at = (arr, i) => V(arr[i * 3], arr[i * 3 + 1], arr[i * 3 + 2]); // --- peristalsis phase: K(s) = ∫ k dx, trapezoid on the same grid ---------------------- - const kAt = (s) => OMEGA / Math.max(1, paramAt(s, 'flow')); + const crestSpeedAt = (s) => CREST_FACTOR * Math.max(1, paramAt(s, 'flow')); + const kAt = (s) => OMEGA / crestSpeedAt(s); + const waveAmpAt = (s) => paramAt(s, 'waveAmp'); const phaseArr = new Float64Array(M); for (let i = 1; i < M; i++) phaseArr[i] = phaseArr[i - 1] + 0.5 * (kAt((i - 1) * DS) + kAt(i * DS)) * DS; @@ -326,6 +346,7 @@ export function buildSpline(level, rng) { length: L, spans, uMax, centre, sOfU, uOfS, ampAtU, radiusAt, frameAt, project, phaseAt, kAt, paramAt, + crestSpeedAt, waveAmpAt, omega: OMEGA, hash, stats, segmentAt: (s) => spans[idxAt(clamp(s, 0, L))].seg, @@ -407,15 +428,37 @@ async function selfcheck() { ok('project: recovers theta within 0.02 rad', maxTerr < 0.02, `max ${maxTerr.toFixed(5)}`); ok('project: recovers rho within 0.05', maxRerr < 0.05, `max ${maxRerr.toFixed(4)}`); - // wave: phase monotone, and crest speed == local flow (the whole point of K(s)) + // wave: phase monotone, and the crest-speed law (TECH FROZEN v1.1) let phaseMono = true; for (let s = 1; s <= a.length; s += 1) if (a.phaseAt(s) <= a.phaseAt(s - 1)) phaseMono = false; ok('wave: phase K(s) strictly increasing', phaseMono); const kEso = a.kAt(50), kSto = a.kAt(a.length - 20); - ok('wave: k == OMEGA/flow (esophagus flow 14 => k 0.22, matches stub)', Math.abs(kEso - 0.22) < 0.001, `k=${kEso.toFixed(4)}`); - ok('wave: crest speed == local flow in each biome', - Math.abs(OMEGA / kEso - 14) < 0.01 && Math.abs(OMEGA / kSto - 4) < 0.01, - `${(OMEGA / kEso).toFixed(2)} u/s eso, ${(OMEGA / kSto).toFixed(2)} u/s stomach`); + ok('wave: k == OMEGA/(CREST_FACTOR*flow)', Math.abs(kEso - OMEGA / (CREST_FACTOR * 14)) < 1e-6, `k=${kEso.toFixed(4)}`); + ok('wave: crestSpeed == 1.6 x local flow, in every biome', + Math.abs(a.crestSpeedAt(50) - 1.6 * 14) < 0.01 && Math.abs(a.crestSpeedAt(a.length - 20) - 1.6 * 4) < 0.01, + `${a.crestSpeedAt(50).toFixed(2)} u/s eso (flow 14), ${a.crestSpeedAt(a.length - 20).toFixed(2)} u/s stomach (flow 4)`); + // the law exists to make surfing the fast line — assert the thing B actually depends on + ok('wave: crest outruns a throttle-mashing player (crestSpeed > 1.4 x flow)', + a.crestSpeedAt(50) > 1.4 * 14, `${a.crestSpeedAt(50).toFixed(1)} > ${(1.4 * 14).toFixed(1)} u/s`); + ok('wave: OMEGA/kAt(s) == crestSpeedAt(s) (phase and speed agree)', + Math.abs(OMEGA / a.kAt(300) - a.crestSpeedAt(300)) < 1e-9); + + // per-segment wave override (schema v2): fixture segment 3 declares wave.amp 0.55 + const segWave = buildSpline({ + ...FIXTURE, + segments: FIXTURE.segments.map((s, i) => (i === 2 ? { ...s, wave: { amp: 0.55 } } : { ...s, wave: { amp: 1.4 } })), + }); + ok('wave: per-segment amp override honoured', Math.abs(segWave.waveAmpAt(650) - 0.55) < 1e-6, + `amp at s=650 => ${segWave.waveAmpAt(650).toFixed(3)}`); + ok('wave: amp blends across the join (no step)', + Math.abs(segWave.waveAmpAt(540) - (1.4 + 0.55) / 2) < 0.02, + `amp at the join => ${segWave.waveAmpAt(540).toFixed(3)} (midpoint of 1.4 and 0.55)`); + + // C's biggest dependency: radius must taper, not cliff (LANE_C_NOTES → A #4) + let maxRadiusStep = 0; + for (let s = 1; s <= a.length; s += 0.5) maxRadiusStep = Math.max(maxRadiusStep, Math.abs(a.radiusAt(s) - a.radiusAt(s - 0.5))); + ok('radius: no cliff at segment joins (max step < 0.15 u per 0.5 u)', maxRadiusStep < 0.15, + `max ${maxRadiusStep.toFixed(4)} u/step`); console.log(` stats: L=${st.length} grid=${st.gridPoints} maxK=${st.maxCurvature.toFixed(4)} (s=${st.sAtMaxCurvature}) radius=${st.minRadius.toFixed(1)}..${st.maxRadius.toFixed(1)} hash=${a.hash()}`); console.log(fail === 0 ? '\x1b[32mworld/spline: OK\x1b[0m' : `\x1b[31mworld/spline: ${fail} FAILED\x1b[0m`); diff --git a/web/js/world/tube.js b/web/js/world/tube.js index 5bf227d..78ba6e8 100644 --- a/web/js/world/tube.js +++ b/web/js/world/tube.js @@ -52,6 +52,7 @@ export function createTube({ spline, materialFor, quality = 'high', skipSpans = 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++) { @@ -59,6 +60,7 @@ export function createTube({ spline, materialFor, quality = 'high', skipSpans = 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); @@ -72,7 +74,7 @@ export function createTube({ spline, materialFor, quality = 'high', skipSpans = 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++; + aPhase[w] = phase; aK[w] = k; aWaveA[w] = waveA; w++; } } @@ -93,6 +95,7 @@ export function createTube({ spline, materialFor, quality = 'high', skipSpans = 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 diff --git a/web/js/world/wall_material.js b/web/js/world/wall_material.js index ae5ea45..a5cd082 100644 --- a/web/js/world/wall_material.js +++ b/web/js/world/wall_material.js @@ -10,10 +10,13 @@ // axis and the tiling axis, and it must stay continuous across chunks // aPhase float K(s) = ∫k ds, baked (see spline.js) // aK float local wavenumber, for the analytic d(pulse)/ds +// aWaveA float local peristalsis amplitude. Per-VERTEX, not a uniform, because schema v2 +// lets C set `wave.amp` per segment and segments share a biome material. // -// Colorspace: like the stub, this writes its computed color straight out with no -// conversion. That's a whole-game decision (it moves every color at -// once) so it stays matched to F's round-0 look until F rules on it — see LANE_A_NOTES. +// Colorspace: `#include ` at the end of main() is LAW (TECH §Shader law). +// three converts THREE.Color inputs to linear but does not convert a raw shader's output back, +// so without it every ART_BIBLE colour ships wrong (B measured amber #ff5a2a displaying as +// pure red). Round 1 shipped without it; this is the fix. import * as THREE from 'three'; @@ -21,43 +24,63 @@ export function createWallMaterial({ biome, omega, fog = biome.fog, // overridable: arenas size their own fog to the room (see index.js) - waveAmp = biome.wave.amp, breatheAmp = biome.wave.breathe, - detail = null, // THREE.Texture | null — Lane D's grayscale detail map - tile = null, // [repeatsAroundCircumference, unitsOfSPerRepeat] + detail = null, // THREE.Texture | null — Lane D's grayscale detail/AO map + detailB = null, // THREE.Texture | null — the _b variant, macro variation + normalMap = null, // THREE.Texture | null — tangent-space normals + matcap = null, // THREE.Texture | null — wet-tissue specular ball + repeat = null, // [repeats around theta, repeats per unit of s] (= D's .repeat) + repeatB = null, + normalScale = 0.6, // D: "0.6 looks right; 0 = off, 1 = full relief" + matcapGain = 0.22, radiusHint = 10, // used only to pick a square-ish default tiling side = THREE.FrontSide, }) { - if (detail) { // defensive: we consume D's texture, so we set what we depend on - detail.wrapS = detail.wrapT = THREE.RepeatWrapping; - detail.needsUpdate = true; + for (const t of [detail, detailB, normalMap]) { // we consume D's textures; set what we rely on + if (t) { t.wrapS = t.wrapT = THREE.RepeatWrapping; t.needsUpdate = true; } } - const tileAround = tile ? tile[0] : 3; - const tileAlong = tile ? tile[1] : Math.max(4, (2 * Math.PI * radiusHint) / tileAround); + // Default tiling if D has no `tile` hint: ~square texels for this biome's radius. + const fallbackRepeat = [3, 3 / Math.max(4, (2 * Math.PI * radiusHint) / 3)]; + const rep = repeat ?? fallbackRepeat; + // The _b layer deliberately runs at a different, non-integer-multiple scale: sampling the + // same tiling twice would just reinforce the repeat it's meant to hide. + const repB = repeatB ?? [rep[0] / 2.7, rep[1] / 2.7]; - return new THREE.ShaderMaterial({ + const defines = {}; + if (detail) defines.USE_DETAIL = ''; + if (detail && detailB) defines.USE_DETAIL_B = ''; + if (normalMap) defines.USE_NORMAL = ''; + if (matcap) defines.USE_MATCAP = ''; + + const mat = new THREE.ShaderMaterial({ side, - defines: detail ? { USE_DETAIL: '' } : {}, + defines, uniforms: { uTime: { value: 0 }, uTint: { value: new THREE.Color(biome.palette.tint) }, uRim: { value: new THREE.Color(biome.palette.rim) }, uVoid: { value: new THREE.Color(biome.palette.void) }, uFog: { value: fog }, - uWaveA: { value: waveAmp }, uBreatheA: { value: breatheAmp }, uOmega: { value: omega }, uRimPow: { value: 2.2 }, uRimGain: { value: 0.9 }, uDetail: { value: detail }, - uTile: { value: new THREE.Vector2(tileAround, tileAlong) }, + uDetailB: { value: detailB }, + uNormal: { value: normalMap }, + uMatcap: { value: matcap }, + uNormalScale: { value: normalScale }, + uMatcapGain: { value: matcapGain }, + uRepeat: { value: new THREE.Vector2(rep[0], rep[1]) }, + uRepeatB: { value: new THREE.Vector2(repB[0], repB[1]) }, }, vertexShader: /* glsl */` attribute vec3 aInward; attribute vec3 aTangent; attribute float aPhase; attribute float aK; - uniform float uTime, uWaveA, uBreatheA, uOmega; + attribute float aWaveA; + uniform float uTime, uBreatheA, uOmega; varying vec2 vUv; varying vec3 vN; varying vec3 vView; varying float vPulse; void main() { @@ -66,13 +89,13 @@ export function createWallMaterial({ float sn = max(0.0, sin(phi)); float pulse = sn * sn * sn; // sharp crest, long trough: a muscle, not a sine float breathe = uBreatheA * sin(uv.y * 0.7 + uTime * 0.8) * sin(uv.x * 6.2831853 * 3.0); - float disp = uWaveA * pulse + breathe; + float disp = aWaveA * pulse + breathe; // Tilt the normal with the wave. Without this the crests are silhouette-only and the // rim light slides over them as if the wall were flat — the effective wall radius is // rho(s) = radius - disp, so the inward normal leans along the tangent by d(rho)/ds. float dPulse_ds = 3.0 * sn * sn * cos(phi) * aK; - vec3 nIn = normalize(aInward - aTangent * (uWaveA * dPulse_ds)); + vec3 nIn = normalize(aInward - aTangent * (aWaveA * dPulse_ds)); vec4 mv = modelViewMatrix * vec4(position + aInward * disp, 1.0); vN = normalize(normalMatrix * nIn); @@ -82,33 +105,85 @@ export function createWallMaterial({ }`, fragmentShader: /* glsl */` uniform vec3 uTint, uRim, uVoid; - uniform float uFog, uRimPow, uRimGain; + uniform float uFog, uRimPow, uRimGain, uNormalScale, uMatcapGain; + uniform vec2 uRepeat, uRepeatB; #ifdef USE_DETAIL uniform sampler2D uDetail; - uniform vec2 uTile; + #endif + #ifdef USE_DETAIL_B + uniform sampler2D uDetailB; + #endif + #ifdef USE_NORMAL + uniform sampler2D uNormal; + #endif + #ifdef USE_MATCAP + uniform sampler2D uMatcap; #endif varying vec2 vUv; varying vec3 vN; varying vec3 vView; varying float vPulse; + #ifdef USE_NORMAL + // three's perturbNormal2Arb, lifted from Lane D's web/dev/laneD_texview.html. + // The tube carries no tangent attribute, so rebuild the TBN per-pixel from screen + // derivatives. DO NOT "simplify" this to normalize(vN + tn * k): a tangent-space sample + // is ~(0,0,1), so adding it tilts every normal toward the camera, dot(n,view) -> 1, the + // fresnel rim dies and the tube renders near-black. The rim IS the biome's only light. + // That failure looks exactly like "Lane D's textures are too dark" and is not. + vec3 perturb(vec3 N, vec3 viewPos, vec2 st, vec3 mapN) { + vec3 q0 = dFdx(viewPos), q1 = dFdy(viewPos); + vec2 st0 = dFdx(st), st1 = dFdy(st); + vec3 S = normalize(q0 * st1.t - q1 * st0.t); + vec3 T = normalize(-q0 * st1.s + q1 * st0.s); + return normalize(mat3(S, T, N) * mapN); + } + #endif + void main() { + vec2 duv = vUv * uRepeat; + #ifdef USE_DETAIL - // Lane D authors grayscale; the biome tint is applied here, so one texture can serve - // two biomes at different tints (ART_BIBLE §FLUX prompt kit). - float detail = texture2D(uDetail, vec2(vUv.x * uTile.x, vUv.y / uTile.y)).r; - detail = mix(0.5, 1.2, detail); + // Lane D authors grayscale luminance/AO; the biome tint is applied here, so one + // texture serves two biomes at different tints (ART_BIBLE §FLUX prompt kit). + float detail = texture2D(uDetail, duv).r; + #ifdef USE_DETAIL_B + // macro variation: the _b wall at a coarser, non-multiple scale breaks _a's repeat + float db = texture2D(uDetailB, vUv * uRepeatB).r; + detail = mix(detail, detail * (0.55 + 0.9 * db), 0.6); + #endif + vec3 base = uTint * (0.35 + detail * 0.95) * 0.9; // D's measured curve #else // Assets-optional law: no texture is the *shipping* look until D lands, not an error // state. Ridged folds around theta + striation along s = a passable SEM stand-in. float folds = 0.55 + 0.45 * sin(vUv.x * 6.2831853 * 9.0 + sin(vUv.y * 0.9) * 2.0); float detail = folds * (0.85 + 0.15 * sin(vUv.y * 2.2)); + vec3 base = uTint * detail * 0.8; #endif - vec3 base = uTint * detail * 0.8; - float fres = pow(1.0 - abs(dot(normalize(vN), normalize(vView))), uRimPow); + vec3 N = normalize(vN); + #ifdef USE_NORMAL + vec3 tn = texture2D(uNormal, duv).xyz * 2.0 - 1.0; + tn.xy *= uNormalScale; + N = perturb(N, -vView, duv, normalize(tn)); + #endif + + vec3 V = normalize(vView); + float fres = pow(1.0 - abs(dot(N, V)), uRimPow); // crest sheen: the wave is a gameplay tell (ride it for boost), so it gets a little // help beyond what its own geometry earns from the rim term vec3 col = base + uRim * fres * uRimGain + uRim * vPulse * 0.10; + + #ifdef USE_MATCAP + // wet specular. Keyed off the perturbed normal so the sheen follows the folds, and + // weighted toward grazing angles so it reads as a film of mucus, not a plastic gloss. + vec2 muv = N.xy * 0.5 + 0.5; + col += texture2D(uMatcap, muv).rgb * uMatcapGain * (0.25 + 0.75 * fres); + #endif + float d = length(vView); gl_FragColor = vec4(mix(col, uVoid, 1.0 - exp(-uFog * d * 0.55)), 1.0); + #include }`, }); + // WebGL1 needs the derivatives extension for perturb(); harmless on WebGL2 where it's core. + mat.extensions = { derivatives: true }; + return mat; }