Stolen from the shader half of Sebastian Lague's graphics-library video (the Voronoi farm-plot trick), because it turns out to be an anatomy tool rather than a decoration: mucosa at SEM scale IS a Voronoi diagram. Lane D's own oral prompt asks FLUX for a "cobblestone pavement of flat polygonal squamous epithelial cells" — that is a Voronoi described in English, and generating it in the fragment shader instead is free, never repeats, and can do the one thing a baked texture never will: BREATHE. Membrane width rides vPulse, the same wave the vertex shader displaces with, so the pavement compresses exactly where the muscle contracts. - F2-F1 Voronoi (9-tap), multiplied into the detail LUMINANCE, never added — D's texture stays the author of the look and this is structure underneath it. Applied to both the textured and the assets-optional fallback path. - Rides D's own tiling (duv * scale), so cells hold their physical size on a radius-7 hiatus and a radius-70 sea — the upstream arc-length fix does that work for free. - Compiled out with a define, not branched out on a uniform: a biome that wants no pavement pays nothing for nine hashes a pixel. Per-biome, because a pavement is a CLAIM ABOUT TISSUE and not a filter: oral 0.50 (squamous epithelium is the strongest case in the body), appendix 0.48 and colon 0.42 (biofilm mats are literally cells), stomach 0.38 (gastric pits), small intestine 0.20 and esophagus 0.18 (villi are projections and folds are folds — there it is a whisper that only breaks the texture repeat). Verified: per-biome values reach the material (esophagus 0.18, not the 0.35 default), the effect reads on the wall at gameplay distance, and world.hash() is byte-identical (bea3807c) before and after — this is pixels only, the determinism law is untouched. Not measured: frame cost on real hardware. Nine hashes per wall pixel is not free and the budget is 60fps on mid laptops; if it bites, the honest lever is dropping the low-gain biomes to 0 (which compiles it out entirely) rather than cheapening the shader. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
305 lines
17 KiB
JavaScript
305 lines
17 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,
|
|
// --- procedural epithelium (round 3) ------------------------------------------------
|
|
// A Voronoi cell pavement multiplied into the detail luminance. This is not decoration for
|
|
// its own sake: mucosa at SEM scale IS a Voronoi diagram — Lane D's own oral prompt asks
|
|
// FLUX for a "cobblestone pavement of flat polygonal squamous epithelial cells", which is a
|
|
// Voronoi described in words. Generating it in the shader instead costs no texture memory,
|
|
// never repeats (the tiling it hides is the one thing a baked texture cannot fix), and —
|
|
// the part a texture can never do — it can BREATHE, because the peristalsis phase is already
|
|
// a varying right here.
|
|
//
|
|
// Per-biome, because the pavement is a claim about tissue: squamous epithelium (oral) is all
|
|
// cells, ribbed esophageal folds are not. `biome.cells` overrides; 0 compiles it out entirely.
|
|
cells = biome.cells ?? 0.35,
|
|
cellScale = biome.cellScale ?? 7.0, // cells per texture repeat
|
|
}) {
|
|
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 = {};
|
|
// Compiled out, not branched out: a 9-tap Voronoi is ~9 hashes per pixel over the whole wall,
|
|
// and a `uniform == 0` test would still pay for it. A biome that wants no pavement pays zero.
|
|
if (cells > 0) defines.USE_CELLS = '';
|
|
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 },
|
|
uCells: { value: cells },
|
|
uCellScale: { value: cellScale },
|
|
},
|
|
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_CELLS
|
|
uniform float uCells, uCellScale;
|
|
// Hash -> a stable per-cell offset. No trig-free cleverness needed: this is the standard
|
|
// sin-fract hash and it is deterministic on every GPU we ship to, which is what the house
|
|
// determinism law cares about (core/rng.js governs SIMULATION randomness; this is texture).
|
|
vec2 cellHash(vec2 p) {
|
|
p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
|
|
return fract(sin(p) * 43758.5453);
|
|
}
|
|
// F2 - F1 Voronoi: ~0 on a cell border, large in a cell's interior. That difference (not
|
|
// F1 alone) is what gives clean membrane lines of even width instead of round blobs.
|
|
float cellEdge(vec2 p) {
|
|
vec2 g = floor(p), f = fract(p);
|
|
float f1 = 8.0, f2 = 8.0;
|
|
for (int y = -1; y <= 1; y++) {
|
|
for (int x = -1; x <= 1; x++) {
|
|
vec2 o = vec2(float(x), float(y));
|
|
vec2 r = o + cellHash(g + o) - f;
|
|
float d = dot(r, r);
|
|
if (d < f1) { f2 = f1; f1 = d; }
|
|
else if (d < f2) { f2 = d; }
|
|
}
|
|
}
|
|
return sqrt(f2) - sqrt(f1);
|
|
}
|
|
#endif
|
|
|
|
#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);
|
|
|
|
// Procedural epithelium. Rides D's OWN tiling (duv * scale), so cells are the same
|
|
// physical size on a radius-7 hiatus and a radius-70 sea — the arc-length fix upstream
|
|
// does this work for free. Membranes tighten on the crest: vPulse is the same wave the
|
|
// vertex shader displaces with, so the pavement compresses exactly where the muscle
|
|
// does. That is the one thing a baked texture can never do, and it is why this exists.
|
|
float cellMod = 1.0;
|
|
#ifdef USE_CELLS
|
|
float edge = cellEdge(duv * uCellScale);
|
|
float w = 0.16 - 0.05 * vPulse; // membranes squeeze as it contracts
|
|
float pave = smoothstep(0.0, w, edge); // 0 = membrane line, 1 = cell body
|
|
// Multiplied into luminance, never added: D's texture stays the author of the look and
|
|
// this is structure underneath it. Cell bodies keep their value, membranes go dark.
|
|
cellMod = mix(1.0, 0.42 + 0.58 * pave, uCells);
|
|
#endif
|
|
|
|
#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 * cellMod * 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 * cellMod * 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;
|
|
}
|