guts/web/js/world/wall_material.js
jing 111ade7d12 [lane A] The swept room + the acid sea, built — and it corrected me four times
The shape read in LANE_A_NOTES was written from reading code, so I built it to
find out if it was true. Flyable: web/dev/laneA_world.html?dbg=1&assets=1&lvl=swept
Nothing here is imposed on anyone: every addition is additive and a level that
opts out behaves exactly as today. C can bin the whole proposal and lose nothing.

WHAT THE PROTOTYPE DISPROVED (my own cost estimates, all four):
- "radial resolution must scale with radius" — wrong. 64 segments is a 0.05u
  circle error at r=70. The arena needs it because it fbm-displaces; the tube's
  wave is smooth in theta. No change made.
- "cap geometry at the span ends" — a thinko. The canal is continuous; a room is
  a fat part of it. Only the fundus dome caps, and it stays an icosphere.
- cost — cheaper than feared: 9 draws / 98k tris worst-frame for a radius-70
  cathedral vs 8 / 74k for the L2 corridor. No leak over a full sweep.
- "one schema flag" — held. `segments[].mode: "open"` is the entire ask.

THE TWO REAL COSTS, NEITHER PREDICTED:
1. D's `tile[0]` is a COUNT, not a density — only a texel size if radius never
   changes. Measured 6.1:1 smear at r=70. The wall now derives the count from
   each ring's own arc length (aRadius attr + uThetaSpan); D's authored numbers
   keep their exact meaning at their reference radius, no re-authoring. That fix
   then laid a seam down the canal (a fractional count can't wrap) -> rounded to
   an integer, seam gone. Not a no-op on corridors: L2's hiatus takes 3 repeats
   where it took 4, which is the texel size correctly holding still as the pipe
   narrows. Eyeballed.
2. MY OWN PINCH GUARD WAS WRONG and would have blocked C. The fixture scored
   1.30 and "failed" the >1.5 law; it was never bent. stats() divided the
   tightest turn ANYWHERE by the widest radius ANYWHERE — 996 units apart, a
   bend in the radius-13 pylorus against the radius of the sea. Locally its
   worst point is 6.4. pinchRatio is now min over s of turnRadius(s)/radius(s).
   Provably one-directional (global <= local by construction) so nothing that
   passed can fail; real levels GAINED headroom: L2 3.32->4.16, L3 1.91->6.99,
   L1 2.11->6.06. This mattered — the guard is what backs the round-1 promise
   that C never has to think about curvature.

THE ACID SEA (world/acid.js, `level.acid {from,to,height,biome}`):
Flat emissive #c8ff3a, level and NOT tube-following — that's the mechanic. The
best find of the session: `height` is ONE NUMBER that tunes the level. Measured:
-18 -> 0% of the sea floor is dry shallow, -55 -> 14%, -62 -> 48%. So C's "position
IS the resource" falls out of the radius wobble they already author — the mucus
shallows are just "where the wall rises above the waterline", nothing to place —
and rising acid literally drowns them: free escalation, same scalar their event
pump drives. Also disproved my own claim: the centreline stays level (+/-1u over
700u), so shallows come from radius wobble, NOT the canal's bends. C must author
vertical bends deliberately if they want depth by anatomy.

Stated plainly, not papered over: the waterline is prototype quality (the plane
meets faceted rings and the shoreline steps). Acid does not drain the coat —
depthAt(pos) is the hook and the cost is Lane B's call. Nothing drives height but
DBG until C's events do.

qa GREEN incl. the new provenance gate; spline selfcheck green; L2 corridor
re-eyeballed for regression. Rulings requested from F in NOTES §-> Lane F.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:04:16 +10:00

245 lines
13 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.
// 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 <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;
// 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 <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]) },
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 <colorspace_fragment>
}`,
});
// WebGL1 needs the derivatives extension for perturb(); harmless on WebGL2 where it's core.
mat.extensions = { derivatives: true };
return mat;
}