[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:
parent
4ccf5edcc0
commit
f0982e70c3
BIN
docs/shots/laneA/round2_L2_hiatus.png
Normal file
BIN
docs/shots/laneA/round2_L2_hiatus.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
BIN
docs/shots/laneA/round2_L2_tbn_wall.png
Normal file
BIN
docs/shots/laneA/round2_L2_tbn_wall.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
@ -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(); },
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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`);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user