[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>
This commit is contained in:
jing 2026-07-16 16:26:01 +10:00
parent 4ccf5edcc0
commit f0982e70c3
7 changed files with 240 additions and 83 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

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

View File

@ -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;

View File

@ -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 ~4060 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`);

View File

@ -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

View File

@ -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
// <colorspace_fragment> 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 <colorspace_fragment>` 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 <colorspace_fragment>
}`,
});
// WebGL1 needs the derivatives extension for perturb(); harmless on WebGL2 where it's core.
mat.extensions = { derivatives: true };
return mat;
}