[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 {THREE.Material} material a wall material built for this arena's biome
|
||||||
* @param {function} rng
|
* @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
|
// 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
|
// 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
|
// 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 uv = new Float32Array(n * 2);
|
||||||
const aPhase = new Float32Array(n);
|
const aPhase = new Float32Array(n);
|
||||||
const aK = 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
|
// 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.
|
// 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 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 bin = new THREE.Vector3(f.bin.x, f.bin.y, f.bin.z);
|
||||||
const amp = spec.radius * 0.09;
|
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();
|
const v = new THREE.Vector3();
|
||||||
|
|
||||||
for (let i = 0; i < n; i++) {
|
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
|
uv[i * 2 + 1] = spec.at + along; // keep uv.y in canal-s units, like the tube
|
||||||
aPhase[i] = k * (spec.at + along);
|
aPhase[i] = k * (spec.at + along);
|
||||||
aK[i] = k;
|
aK[i] = k;
|
||||||
|
aWaveA[i] = waveAmp;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seam repair: uv.x comes from atan2, so a triangle straddling the -X axis interpolates it
|
// 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('uv', new THREE.BufferAttribute(uv, 2));
|
||||||
g.setAttribute('aPhase', new THREE.BufferAttribute(aPhase, 1));
|
g.setAttribute('aPhase', new THREE.BufferAttribute(aPhase, 1));
|
||||||
g.setAttribute('aK', new THREE.BufferAttribute(aK, 1));
|
g.setAttribute('aK', new THREE.BufferAttribute(aK, 1));
|
||||||
|
g.setAttribute('aWaveA', new THREE.BufferAttribute(aWaveA, 1));
|
||||||
g.computeBoundingSphere();
|
g.computeBoundingSphere();
|
||||||
geo.dispose(); // the source icosphere was scaffolding
|
geo.dispose(); // the source icosphere was scaffolding
|
||||||
|
|
||||||
@ -112,7 +118,7 @@ export function createArena({ spec, spline, material, rng, quality = 'high' }) {
|
|||||||
center,
|
center,
|
||||||
radius: spec.radius,
|
radius: spec.radius,
|
||||||
/** Conservative inner surface: shell minus displacement peak minus the shader's wave. */
|
/** 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,
|
covers: (s) => Math.abs(s - spec.at) <= spec.radius,
|
||||||
dispose() { g.dispose(); },
|
dispose() { g.dispose(); },
|
||||||
};
|
};
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { createRng } from '../core/rng.js';
|
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 { biome as biomeOf } from './biomes.js';
|
||||||
import { createWallMaterial } from './wall_material.js';
|
import { createWallMaterial } from './wall_material.js';
|
||||||
import { createTube } from './tube.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
|
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 } = {}) {
|
export async function createWorld(levelData, { rng, quality = 'high', assets = null } = {}) {
|
||||||
const R = rng || createRng((levelData?.seed ?? 0) >>> 0);
|
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 : [];
|
const arenaSpecs = Array.isArray(levelData.arenas) ? levelData.arenas : [];
|
||||||
|
|
||||||
// --- assets: optional, always (TECH.md §Asset manifest contract) -----------------------
|
// --- 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
|
// 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.
|
// 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).
|
// -> Lane D: proposing we standardize on the biome ids in round 2 (LANE_A_NOTES §-> Lane D).
|
||||||
const TEXTURE_SLUG = {
|
// TEMPORARY (ruling #3): D's round-1 keys aren't the biome ids — `small_intestine` ships as
|
||||||
oral: 'oral', esophagus: 'esophagus', stomach: 'stomach',
|
// `wall_smallint_a`. D renames to biome ids early this round and pings in NOTES; **delete
|
||||||
small_intestine: 'smallint', large_intestine: 'colon', appendix: 'appendix',
|
// 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
|
||||||
const TEXTURE_FOR = (biomeId) => `wall_${TEXTURE_SLUG[biomeId] ?? biomeId}_a`;
|
// forever — which is what made this dangerous in round 1.
|
||||||
function detailFor(biomeId) {
|
const TEXTURE_SLUG = { small_intestine: 'smallint', large_intestine: 'colon' };
|
||||||
if (!assets) return { detail: null, tile: null };
|
const slug = (biomeId) => TEXTURE_SLUG[biomeId] ?? biomeId;
|
||||||
const name = TEXTURE_FOR(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 {
|
try {
|
||||||
if (typeof assets.texture === 'function') {
|
const a = assets.texture(`wall_${slug(biomeId)}_a`);
|
||||||
const t = assets.texture(name);
|
if (a?.map) { out.detail = a.map; out.repeat = a.repeat ?? null; out.normalMap = a.normalMap ?? null; }
|
||||||
if (t && t.map) return { detail: t.map, tile: Array.isArray(t.tile) ? t.tile : null };
|
const b = assets.texture(`wall_${slug(biomeId)}_b`);
|
||||||
}
|
if (b?.map) { out.detailB = b.map; out.repeatB = b.repeat ?? null; }
|
||||||
if (typeof assets.get === 'function') {
|
if (typeof assets.matcap === 'function') out.matcap = assets.matcap('tissue_wet') ?? null;
|
||||||
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 };
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} 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 -------------------------------------------------------------------------
|
// --- materials -------------------------------------------------------------------------
|
||||||
@ -80,10 +95,10 @@ export async function createWorld(levelData, { rng, quality = 'high', assets = n
|
|||||||
};
|
};
|
||||||
|
|
||||||
function makeMaterial(biomeId, side, fog) {
|
function makeMaterial(biomeId, side, fog) {
|
||||||
const b = biomeOf(biomeId);
|
|
||||||
const { detail, tile } = detailFor(biomeId);
|
|
||||||
const m = createWallMaterial({
|
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);
|
owned.push(m);
|
||||||
return m;
|
return m;
|
||||||
@ -125,6 +140,7 @@ export async function createWorld(levelData, { rng, quality = 'high', assets = n
|
|||||||
const a = createArena({
|
const a = createArena({
|
||||||
spec, spline, rng: R, quality,
|
spec, spline, rng: R, quality,
|
||||||
material: arenaMaterialFor(spec), // shell viewed from inside
|
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);
|
group.add(a.mesh);
|
||||||
return a;
|
return a;
|
||||||
@ -136,20 +152,30 @@ export async function createWorld(levelData, { rng, quality = 'high', assets = n
|
|||||||
let time = 0;
|
let time = 0;
|
||||||
|
|
||||||
const biomeIdAt = (s) => spline.segmentAt(s).biome;
|
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;
|
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);
|
const f = spline.frameAt(s);
|
||||||
return {
|
const o = out || { pos: new THREE.Vector3(), tan: new THREE.Vector3(), nor: new THREE.Vector3(), bin: new THREE.Vector3() };
|
||||||
pos: new THREE.Vector3(f.pos.x, f.pos.y, f.pos.z),
|
o.pos.set(f.pos.x, f.pos.y, f.pos.z);
|
||||||
tan: new THREE.Vector3(f.tan.x, f.tan.y, f.tan.z),
|
o.tan.set(f.tan.x, f.tan.y, f.tan.z);
|
||||||
nor: new THREE.Vector3(f.nor.x, f.nor.y, f.nor.z),
|
o.nor.set(f.nor.x, f.nor.y, f.nor.z);
|
||||||
bin: new THREE.Vector3(f.bin.x, f.bin.y, f.bin.z),
|
o.bin.set(f.bin.x, f.bin.y, f.bin.z);
|
||||||
radius: f.radius,
|
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 = {
|
const world = {
|
||||||
level: levelData,
|
level: levelData,
|
||||||
length: spline.length,
|
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;
|
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
|
/** JS mirror of the vertex shader's wave, exact (0 = trough, 1 = crest). E: pulse the mix. */
|
||||||
* ship riding a crest is riding the current. E: pulse the mix with it. */
|
|
||||||
flowPulse: (s, t = time) => Math.pow(Math.max(0, Math.sin(spline.phaseAt(s) - OMEGA * t)), 3),
|
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) {
|
update(dt, playerS = 0) {
|
||||||
time += dt;
|
time += dt;
|
||||||
for (const m of owned) m.uniforms.uTime.value = time;
|
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 -----------------------------------------------------------------------------
|
// --- tuning -----------------------------------------------------------------------------
|
||||||
// OMEGA is the peristaltic contraction *rate*, global to the whole canal and constant in
|
// 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
|
// 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
|
// crossed into a wider pipe. The wavenumber is what varies: k(s) = OMEGA / crestSpeed(s), so
|
||||||
// wave crest travels at exactly the local flow speed and Lane B can surf one. Phase is the
|
// phase is the integral K(s) = ∫k ds (phaseAt), which stays continuous across flow changes —
|
||||||
// integral K(s) = ∫k ds (phaseAt), which stays continuous across flow changes — plain
|
// plain `k*s - w*t` does not, and tears the wave at every segment join.
|
||||||
// `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.
|
||||||
// 3.08 = 0.22 rad/unit x 14 units/s, i.e. the stub's esophagus wave, preserved exactly.
|
|
||||||
export const OMEGA = 3.08;
|
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 DU = 0.5; // march step in curve parameter
|
||||||
const DS = 0.5; // frame/phase LUT spacing in arclength
|
const DS = 0.5; // frame/phase LUT spacing in arclength
|
||||||
const NOISE_TAB = 1024;
|
const NOISE_TAB = 1024;
|
||||||
const RADIUS_WAVELENGTH = 55; // units per radius-fbm octave-0 cycle
|
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)
|
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
|
// Blend widths (units) for smoothstep-crossfading segment params across a join. Nothing ever
|
||||||
// flow are local scalars — a tight 12u transition reads as a sphincter. Curviness feeds
|
// steps: a hard radius change would read as a level seam, and C's constrictions are supposed
|
||||||
// centreline *amplitude*, which has a long lever arm: ramping it over a short span is itself a
|
// to feel like the body narrowing.
|
||||||
// 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
|
// `base`/`wobble` at ±25 => a 50-unit taper, which is what C's L2 was authored against
|
||||||
// budget, so a 12u step in it would swerve the centreline just as hard as curviness would.
|
// (LANE_C_NOTES → A #4: "assumes a smooth blend over ~40–60 units", their biggest open
|
||||||
const BLEND = { curviness: 60, curvBase: 60, base: 12, wobble: 12, flow: 12 };
|
// 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
|
// [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
|
// 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),
|
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),
|
wobble: (g) => (g.radius && typeof g.radius.wobble === 'number' ? g.radius.wobble : 0.2),
|
||||||
flow: (g) => (typeof g.flow === 'number' ? g.flow : 10),
|
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;
|
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]);
|
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 ----------------------
|
// --- 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);
|
const phaseArr = new Float64Array(M);
|
||||||
for (let i = 1; i < M; i++)
|
for (let i = 1; i < M; i++)
|
||||||
phaseArr[i] = phaseArr[i - 1] + 0.5 * (kAt((i - 1) * DS) + kAt(i * DS)) * DS;
|
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,
|
length: L, spans, uMax,
|
||||||
centre, sOfU, uOfS, ampAtU,
|
centre, sOfU, uOfS, ampAtU,
|
||||||
radiusAt, frameAt, project, phaseAt, kAt, paramAt,
|
radiusAt, frameAt, project, phaseAt, kAt, paramAt,
|
||||||
|
crestSpeedAt, waveAmpAt,
|
||||||
omega: OMEGA,
|
omega: OMEGA,
|
||||||
hash, stats,
|
hash, stats,
|
||||||
segmentAt: (s) => spans[idxAt(clamp(s, 0, L))].seg,
|
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 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)}`);
|
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;
|
let phaseMono = true;
|
||||||
for (let s = 1; s <= a.length; s += 1) if (a.phaseAt(s) <= a.phaseAt(s - 1)) phaseMono = false;
|
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);
|
ok('wave: phase K(s) strictly increasing', phaseMono);
|
||||||
const kEso = a.kAt(50), kSto = a.kAt(a.length - 20);
|
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: k == OMEGA/(CREST_FACTOR*flow)', Math.abs(kEso - OMEGA / (CREST_FACTOR * 14)) < 1e-6, `k=${kEso.toFixed(4)}`);
|
||||||
ok('wave: crest speed == local flow in each biome',
|
ok('wave: crestSpeed == 1.6 x local flow, in every biome',
|
||||||
Math.abs(OMEGA / kEso - 14) < 0.01 && Math.abs(OMEGA / kSto - 4) < 0.01,
|
Math.abs(a.crestSpeedAt(50) - 1.6 * 14) < 0.01 && Math.abs(a.crestSpeedAt(a.length - 20) - 1.6 * 4) < 0.01,
|
||||||
`${(OMEGA / kEso).toFixed(2)} u/s eso, ${(OMEGA / kSto).toFixed(2)} u/s stomach`);
|
`${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(` 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`);
|
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 uv = new Float32Array(n * 2);
|
||||||
const aPhase = new Float32Array(n);
|
const aPhase = new Float32Array(n);
|
||||||
const aK = new Float32Array(n);
|
const aK = new Float32Array(n);
|
||||||
|
const aWaveA = new Float32Array(n);
|
||||||
|
|
||||||
let p = 0, q = 0, w = 0;
|
let p = 0, q = 0, w = 0;
|
||||||
for (let r = 0; r < rings; r++) {
|
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 f = spline.frameAt(s);
|
||||||
const phase = spline.phaseAt(s);
|
const phase = spline.phaseAt(s);
|
||||||
const k = spline.kAt(s);
|
const k = spline.kAt(s);
|
||||||
|
const waveA = spline.waveAmpAt(s);
|
||||||
for (let j = 0; j < cols; j++) {
|
for (let j = 0; j < cols; j++) {
|
||||||
const th = (j / Q.radial) * TAU;
|
const th = (j / Q.radial) * TAU;
|
||||||
const ct = Math.cos(th), st = Math.sin(th);
|
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;
|
aTangent[p] = f.tan.x; aTangent[p + 1] = f.tan.y; aTangent[p + 2] = f.tan.z;
|
||||||
p += 3;
|
p += 3;
|
||||||
uv[q++] = j / Q.radial; uv[q++] = s;
|
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('uv', new THREE.BufferAttribute(uv, 2));
|
||||||
geo.setAttribute('aPhase', new THREE.BufferAttribute(aPhase, 1));
|
geo.setAttribute('aPhase', new THREE.BufferAttribute(aPhase, 1));
|
||||||
geo.setAttribute('aK', new THREE.BufferAttribute(aK, 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.setIndex(new THREE.BufferAttribute(idx, 1));
|
||||||
geo.computeBoundingSphere();
|
geo.computeBoundingSphere();
|
||||||
geo.boundingSphere.radius += 2; // the vertex shader displaces inward; keep culling honest
|
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
|
// axis and the tiling axis, and it must stay continuous across chunks
|
||||||
// aPhase float K(s) = ∫k ds, baked (see spline.js)
|
// aPhase float K(s) = ∫k ds, baked (see spline.js)
|
||||||
// aK float local wavenumber, for the analytic d(pulse)/ds
|
// 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: `#include <colorspace_fragment>` at the end of main() is LAW (TECH §Shader law).
|
||||||
// <colorspace_fragment> conversion. That's a whole-game decision (it moves every color at
|
// three converts THREE.Color inputs to linear but does not convert a raw shader's output back,
|
||||||
// once) so it stays matched to F's round-0 look until F rules on it — see LANE_A_NOTES.
|
// 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';
|
import * as THREE from 'three';
|
||||||
|
|
||||||
@ -21,43 +24,63 @@ export function createWallMaterial({
|
|||||||
biome,
|
biome,
|
||||||
omega,
|
omega,
|
||||||
fog = biome.fog, // overridable: arenas size their own fog to the room (see index.js)
|
fog = biome.fog, // overridable: arenas size their own fog to the room (see index.js)
|
||||||
waveAmp = biome.wave.amp,
|
|
||||||
breatheAmp = biome.wave.breathe,
|
breatheAmp = biome.wave.breathe,
|
||||||
detail = null, // THREE.Texture | null — Lane D's grayscale detail map
|
detail = null, // THREE.Texture | null — Lane D's grayscale detail/AO map
|
||||||
tile = null, // [repeatsAroundCircumference, unitsOfSPerRepeat]
|
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
|
radiusHint = 10, // used only to pick a square-ish default tiling
|
||||||
side = THREE.FrontSide,
|
side = THREE.FrontSide,
|
||||||
}) {
|
}) {
|
||||||
if (detail) { // defensive: we consume D's texture, so we set what we depend on
|
for (const t of [detail, detailB, normalMap]) { // we consume D's textures; set what we rely on
|
||||||
detail.wrapS = detail.wrapT = THREE.RepeatWrapping;
|
if (t) { t.wrapS = t.wrapT = THREE.RepeatWrapping; t.needsUpdate = true; }
|
||||||
detail.needsUpdate = true;
|
|
||||||
}
|
}
|
||||||
const tileAround = tile ? tile[0] : 3;
|
// Default tiling if D has no `tile` hint: ~square texels for this biome's radius.
|
||||||
const tileAlong = tile ? tile[1] : Math.max(4, (2 * Math.PI * radiusHint) / tileAround);
|
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,
|
side,
|
||||||
defines: detail ? { USE_DETAIL: '' } : {},
|
defines,
|
||||||
uniforms: {
|
uniforms: {
|
||||||
uTime: { value: 0 },
|
uTime: { value: 0 },
|
||||||
uTint: { value: new THREE.Color(biome.palette.tint) },
|
uTint: { value: new THREE.Color(biome.palette.tint) },
|
||||||
uRim: { value: new THREE.Color(biome.palette.rim) },
|
uRim: { value: new THREE.Color(biome.palette.rim) },
|
||||||
uVoid: { value: new THREE.Color(biome.palette.void) },
|
uVoid: { value: new THREE.Color(biome.palette.void) },
|
||||||
uFog: { value: fog },
|
uFog: { value: fog },
|
||||||
uWaveA: { value: waveAmp },
|
|
||||||
uBreatheA: { value: breatheAmp },
|
uBreatheA: { value: breatheAmp },
|
||||||
uOmega: { value: omega },
|
uOmega: { value: omega },
|
||||||
uRimPow: { value: 2.2 },
|
uRimPow: { value: 2.2 },
|
||||||
uRimGain: { value: 0.9 },
|
uRimGain: { value: 0.9 },
|
||||||
uDetail: { value: detail },
|
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 */`
|
vertexShader: /* glsl */`
|
||||||
attribute vec3 aInward;
|
attribute vec3 aInward;
|
||||||
attribute vec3 aTangent;
|
attribute vec3 aTangent;
|
||||||
attribute float aPhase;
|
attribute float aPhase;
|
||||||
attribute float aK;
|
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;
|
varying vec2 vUv; varying vec3 vN; varying vec3 vView; varying float vPulse;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@ -66,13 +89,13 @@ export function createWallMaterial({
|
|||||||
float sn = max(0.0, sin(phi));
|
float sn = max(0.0, sin(phi));
|
||||||
float pulse = sn * sn * sn; // sharp crest, long trough: a muscle, not a sine
|
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 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
|
// 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
|
// 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.
|
// 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;
|
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);
|
vec4 mv = modelViewMatrix * vec4(position + aInward * disp, 1.0);
|
||||||
vN = normalize(normalMatrix * nIn);
|
vN = normalize(normalMatrix * nIn);
|
||||||
@ -82,33 +105,85 @@ export function createWallMaterial({
|
|||||||
}`,
|
}`,
|
||||||
fragmentShader: /* glsl */`
|
fragmentShader: /* glsl */`
|
||||||
uniform vec3 uTint, uRim, uVoid;
|
uniform vec3 uTint, uRim, uVoid;
|
||||||
uniform float uFog, uRimPow, uRimGain;
|
uniform float uFog, uRimPow, uRimGain, uNormalScale, uMatcapGain;
|
||||||
|
uniform vec2 uRepeat, uRepeatB;
|
||||||
#ifdef USE_DETAIL
|
#ifdef USE_DETAIL
|
||||||
uniform sampler2D uDetail;
|
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
|
#endif
|
||||||
varying vec2 vUv; varying vec3 vN; varying vec3 vView; varying float vPulse;
|
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() {
|
void main() {
|
||||||
|
vec2 duv = vUv * uRepeat;
|
||||||
|
|
||||||
#ifdef USE_DETAIL
|
#ifdef USE_DETAIL
|
||||||
// Lane D authors grayscale; the biome tint is applied here, so one texture can serve
|
// Lane D authors grayscale luminance/AO; the biome tint is applied here, so one
|
||||||
// two biomes at different tints (ART_BIBLE §FLUX prompt kit).
|
// texture serves two biomes at different tints (ART_BIBLE §FLUX prompt kit).
|
||||||
float detail = texture2D(uDetail, vec2(vUv.x * uTile.x, vUv.y / uTile.y)).r;
|
float detail = texture2D(uDetail, duv).r;
|
||||||
detail = mix(0.5, 1.2, detail);
|
#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
|
#else
|
||||||
// Assets-optional law: no texture is the *shipping* look until D lands, not an error
|
// 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.
|
// 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 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));
|
float detail = folds * (0.85 + 0.15 * sin(vUv.y * 2.2));
|
||||||
|
vec3 base = uTint * detail * 0.8;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
vec3 base = uTint * detail * 0.8;
|
vec3 N = normalize(vN);
|
||||||
float fres = pow(1.0 - abs(dot(normalize(vN), normalize(vView))), uRimPow);
|
#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
|
// 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
|
// help beyond what its own geometry earns from the rim term
|
||||||
vec3 col = base + uRim * fres * uRimGain + uRim * vPulse * 0.10;
|
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);
|
float d = length(vView);
|
||||||
gl_FragColor = vec4(mix(col, uVoid, 1.0 - exp(-uFog * d * 0.55)), 1.0);
|
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