Finishes the lane A round-2 list that f0982e7 (the rescued first half) left open:
- Biome-tint re-eyeball under the colorspace law (#4 second half). Verdict: all
six ART_BIBLE tints stand, biomes.js untouched — the wash was wall_material's
round-1 constants, tuned on the raw framebuffer. Retuned: rim pow 2.2->3.0,
rim gain 0.9->0.35, textured curve (0.35+d*.95)*.9 -> 0.12+d*0.60, procedural
d*.8 -> d^2*.65, sheen .10->.04, matcap .22->.14. Rationale documented
in-shader. Verified on a new six-biome contact sheet (?lvl=biomes, SIX_BIOMES
fixture — kept as permanent kit), the real L2 hiatus, and an arena interior.
Evidence: round2_tint_*.png x6 + two *_retuned frames. qa GREEN.
- Stomach-arena shape read for C (#7): recommend the spline-swept room (arena =
"open" mode over an s-span; fundus dome stays a welded icosphere; chained
spheres rejected — crease rings + union collision for a worse look). Full
argument NOTES §-> Lane C + round2_stomach_arena_sketch.svg. Triplanar
deliberately deferred: the swept room obsoletes it where it mattered.
- Ruling #5: harness -> web/dev/laneA_world.html (+DBG.post to the house shot
sink); _fixture.js stays — the spline selfcheck asserts against it, now
documented in-file. launch.json: guts-a on 8145.
- NOTES + progress written for both halves; ROUND2 mid-round box updated: lane
A's round-2 list is closed, only round-3 items remain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
202 lines
9.8 KiB
JavaScript
202 lines
9.8 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
|
|
// 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 <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';
|
|
|
|
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;
|
|
// 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 <colorspace_fragment> 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]) },
|
|
},
|
|
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
|
|
// 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 <colorspace_fragment>
|
|
}`,
|
|
});
|
|
// WebGL1 needs the derivatives extension for perturb(); harmless on WebGL2 where it's core.
|
|
mat.extensions = { derivatives: true };
|
|
return mat;
|
|
}
|