Implements THE WORLD CONTRACT (TECH.md) in web/js/world/; drop-in for the stub. boot.js now runs this on C's L2_esophagus (3600u, hash cefc4f83), zero console errors. - spline.js: centreline + arclength LUTs + parallel-transport frames + radius law + peristalsis phase + project() + hash(). No three.js import, so qa can run its 14-assertion selfcheck headless. - curviness is solved from a curvature budget, not authored as an amplitude: the pinch guard caught the tube folding through itself on a wide+curvy join (turn radius 9.9 vs tube radius 17.2). C can no longer author a pinch. - peristalsis: global OMEGA, k(s) = OMEGA/flow(s), phase K(s) = integral k ds. A crest therefore travels at exactly the local flow speed, so "surf the crest" == "ride the current". Esophagus wave amp 0.9 -> 1.4 (measured): moves wallRho for Lane B. - tube.js: chunked streaming, seams aligned to biome joins, arenas own their s-span. - arena.js: displaced icosphere shells v0 (three's polyhedron detail is 20*(detail+1)^2, not 20*4^detail — 720 tris was far too coarse); fog sized to the room, since biome fog tuned for 100u corridors renders C's 360u acid sea as a black rectangle. - index.js: prefers assets.texture() over get() (get returns raw JSON a shader can't eat), plus an explicit slug map — D's wall_smallint_a vs biome id small_intestine would miss silently forever under the assets-optional law. Measured (C's real L2 + D's real pack, 1920x1080, renderer.info): 8 draws worst frame (budget 150), 74k tris (500k), no geometry leak across a full sweep, <120ms load, pinch ratio 3.32. fps not claimed — the screenshot pane runs tabs hidden, pausing rAF. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
115 lines
5.3 KiB
JavaScript
115 lines
5.3 KiB
JavaScript
// world/wall_material.js (Lane A) — the synthetic-scanner wall (ART_BIBLE.md §Rendering recipe).
|
|
// No real-time lights anywhere in GUTS: the wall is biome tint x detail luminance + a fresnel
|
|
// rim (the SEM edge glow) + exponential fog to the biome void. Peristalsis is vertex work.
|
|
//
|
|
// Attributes the geometry must supply (tube.js and arena.js both do):
|
|
// position vec3 baked ring/shell vertex
|
|
// aInward vec3 unit normal pointing into the playable space
|
|
// aTangent vec3 unit centreline tangent at this vertex (for the wave's normal tilt)
|
|
// uv vec2 (theta/2pi, s) — s in world units, NOT normalized: it's the wave's phase
|
|
// 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
|
|
//
|
|
// 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.
|
|
|
|
import * as THREE from 'three';
|
|
|
|
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]
|
|
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;
|
|
}
|
|
const tileAround = tile ? tile[0] : 3;
|
|
const tileAlong = tile ? tile[1] : Math.max(4, (2 * Math.PI * radiusHint) / tileAround);
|
|
|
|
return new THREE.ShaderMaterial({
|
|
side,
|
|
defines: detail ? { USE_DETAIL: '' } : {},
|
|
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) },
|
|
},
|
|
vertexShader: /* glsl */`
|
|
attribute vec3 aInward;
|
|
attribute vec3 aTangent;
|
|
attribute float aPhase;
|
|
attribute float aK;
|
|
uniform float uTime, uWaveA, uBreatheA, uOmega;
|
|
varying vec2 vUv; varying vec3 vN; varying vec3 vView; varying float vPulse;
|
|
|
|
void main() {
|
|
vUv = uv;
|
|
float phi = aPhase - uOmega * uTime;
|
|
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;
|
|
|
|
// 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));
|
|
|
|
vec4 mv = modelViewMatrix * vec4(position + aInward * disp, 1.0);
|
|
vN = normalize(normalMatrix * nIn);
|
|
vView = -mv.xyz;
|
|
vPulse = pulse;
|
|
gl_Position = projectionMatrix * mv;
|
|
}`,
|
|
fragmentShader: /* glsl */`
|
|
uniform vec3 uTint, uRim, uVoid;
|
|
uniform float uFog, uRimPow, uRimGain;
|
|
#ifdef USE_DETAIL
|
|
uniform sampler2D uDetail;
|
|
uniform vec2 uTile;
|
|
#endif
|
|
varying vec2 vUv; varying vec3 vN; varying vec3 vView; varying float vPulse;
|
|
|
|
void main() {
|
|
#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);
|
|
#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));
|
|
#endif
|
|
|
|
vec3 base = uTint * detail * 0.8;
|
|
float fres = pow(1.0 - abs(dot(normalize(vN), normalize(vView))), 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;
|
|
float d = length(vView);
|
|
gl_FragColor = vec4(mix(col, uVoid, 1.0 - exp(-uFog * d * 0.55)), 1.0);
|
|
}`,
|
|
});
|
|
}
|