// 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. // aRadius float the ring's radius here. Per-VERTEX for the same reason: one biome material // now spans radius 12 corridors AND radius 70 rooms (see uThetaSpan below). // // 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.14, 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; // Tiling around theta is a DENSITY, not a count — the fix for rooms. // D's `tile[0]` is "repeats around", which is a count, and a count is only a texel size if // the radius never changes. It doesn't: with `mode:"open"` one stomach material now spans a // radius-12 cardia and a radius-70 sea, and 4 repeats around a 440u circumference is one // repeat per 110u against 18u along s — a measured 6.1:1 smear (round 2 prototype). So: // convert D's count into the arc length it implies AT THIS BIOME'S REFERENCE RADIUS, and let // the shader re-derive the count from each ring's own radius. // // Precisely: at the reference radius this is arithmetically identical to before, so **D's // authored `tile` numbers keep their exact meaning** — but away from it the count deliberately // moves, because that is the entire fix. L2's hiatus (radius 7 against a reference of ~10) // now takes 3 repeats where it took 4, and that IS the texel size holding still while the // pipe narrows. So: not a byte-for-byte no-op on existing corridors, a small and correct // change to them, eyeballed (docs/shots/laneA/round2_L2_hiatus_retuned.png). const thetaSpan = (2 * Math.PI * radiusHint) / Math.max(0.001, rep[0]); // 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 }, // Retuned for the colorspace law (round 2). The round-1 values (pow 2.2, gain 0.9) // were eyeballed on the raw framebuffer; with encoding the // output, every midtone gained ~a stop and the rim — which the whole tube sees at // grazing angles — flooded the frame mint. Tighter pow confines it to fold edges // (where the TBN normals vary), lower gain returns the wall to "dark tissue, rim // light" (ART_BIBLE). Re-eyeballed on the six-biome contact sheet, ?lvl=biomes. uRimPow: { value: 3.0 }, uRimGain: { value: 0.35 }, 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]) }, uThetaSpan: { value: thetaSpan }, }, vertexShader: /* glsl */` attribute vec3 aInward; attribute vec3 aTangent; attribute float aPhase; attribute float aK; attribute float aWaveA; attribute float aRadius; uniform float uTime, uBreatheA, uOmega, uThetaSpan; varying vec2 vUv; varying vec3 vN; varying vec3 vView; varying float vPulse; varying float vThetaU; varying float vThetaUB; void main() { vUv = uv; // Texture coordinate around theta, in REPEATS, from this ring's own arc length — so a // texel is the same size on a radius-12 pipe and a radius-70 room. Computed here, not // per-pixel, so it interpolates linearly across the quad. // // ROUNDED TO AN INTEGER, and that is not fussiness — it is the whole trick. uv.x runs // 0..1 around the ring, so the wrap is only seamless if the texture completes a WHOLE // number of repeats; a raw arc-length count is fractional and lays a permanent seam // down the length of the canal (seen, then fixed — round 2). Rounding costs at most // half a repeat of density error, which is worst where the count is smallest, hence // the floor of 2. Where radius is ~constant this is exactly D's authored count, so // corridors are untouched. Where it climbs, the count steps 4->5->6; each step shears // the texture across one 0.5u ring, which is invisible next to a 6:1 smear. float nTheta = max(2.0, floor((6.2831853 * max(0.001, aRadius)) / uThetaSpan + 0.5)); vThetaU = uv.x * nTheta; // The _b layer wraps too, so it needs its own WHOLE count — dividing vThetaU by 2.7 // would re-introduce the exact seam the rounding above just removed. Rounded from its // own span, floored at 1, and nudged off nTheta so the two layers can't lock into the // same repeat and reinforce the tiling they exist to hide. float nB = max(1.0, floor(nTheta / 2.7 + 0.5)); vThetaUB = uv.x * (nB == nTheta ? max(1.0, nTheta - 1.0) : nB); 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; varying float vThetaU; varying float vThetaUB; #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() { // .x from the arc-length density (vThetaU), .y from D's units-of-s-per-repeat. vec2 duv = vec2(vThetaU, vUv.y * uRepeat.y); #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, vec2(vThetaUB, vUv.y * uRepeatB.y)).r; detail = mix(detail, detail * (0.55 + 0.9 * db), 0.6); #endif // D's round-1 curve was (0.35 + d*0.95)*0.9, measured on the raw framebuffer. // Post-colorspace that pedestal alone displays as ~60% grey; re-measured on the // contact sheet so a mid-grey texel lands near the tint's own luminance. vec3 base = uTint * (0.12 + detail * 0.60); #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)); // detail² not detail: the sine bands only span ~0.4..1.0, and post-colorspace that // narrow range reads as a flat bright wall (the oral biome was near white-out). // Squaring restores the dark troughs the SEM look needs; still fallback art. vec3 base = uTint * detail * detail * 0.65; #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.04; #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; }