// 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 // 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: `#include ` at the end of main() is LAW (TECH §Shader law). // three converts THREE.Color inputs to linear but does not convert a raw shader's output back, // so without it every ART_BIBLE colour ships wrong (B measured amber #ff5a2a displaying as // pure red). Round 1 shipped without it; this is the fix. import * as THREE from 'three'; export function createWallMaterial({ biome, omega, fog = biome.fog, // overridable: arenas size their own fog to the room (see index.js) breatheAmp = biome.wave.breathe, 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, }) { 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; } } // 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]; 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, 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 }, uBreatheA: { value: breatheAmp }, uOmega: { value: omega }, uRimPow: { value: 2.2 }, uRimGain: { value: 0.9 }, uDetail: { value: detail }, 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; attribute float aWaveA; uniform float uTime, 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 = 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 * (aWaveA * 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, uNormalScale, uMatcapGain; uniform vec2 uRepeat, uRepeatB; #ifdef USE_DETAIL uniform sampler2D uDetail; #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 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 N = normalize(vN); #ifdef USE_NORMAL vec3 tn = texture2D(uNormal, duv).xyz * 2.0 - 1.0; tn.xy *= uNormalScale; N = perturb(N, -vView, duv, normalize(tn)); #endif vec3 V = normalize(vView); float fres = pow(1.0 - abs(dot(N, V)), uRimPow); // crest sheen: the wave is a gameplay tell (ride it for boost), so it gets a little // help beyond what its own geometry earns from the rim term vec3 col = base + uRim * fres * uRimGain + uRim * vPulse * 0.10; #ifdef USE_MATCAP // wet specular. Keyed off the perturbed normal so the sheen follows the folds, and // weighted toward grazing angles so it reads as a film of mucus, not a plastic gloss. vec2 muv = N.xy * 0.5 + 0.5; col += texture2D(uMatcap, muv).rgb * uMatcapGain * (0.25 + 0.75 * fres); #endif float d = length(vView); gl_FragColor = vec4(mix(col, uVoid, 1.0 - exp(-uFog * d * 0.55)), 1.0); #include }`, }); // WebGL1 needs the derivatives extension for perturb(); harmless on WebGL2 where it's core. mat.extensions = { derivatives: true }; return mat; }