diff --git a/web/js/world/biomes.js b/web/js/world/biomes.js index 930149d..f6da248 100644 --- a/web/js/world/biomes.js +++ b/web/js/world/biomes.js @@ -17,6 +17,7 @@ export const BIOMES = { oral: { + cells: 0.5, cellScale: 6.0, // squamous epithelium IS a cobblestone pavement — the strongest case in the body id: 'oral', palette: { tint: 0xa8c4d8, rim: 0xdff2ff, void: 0x06090e }, fog: 0.016, // clinical, bright, tutorial-readable => you can see across the cavity @@ -25,6 +26,7 @@ export const BIOMES = { wave: { amp: 0.5, breathe: 0.10 }, // saliva tides, not real peristalsis }, esophagus: { + cells: 0.18, cellScale: 7.0, // ribbed longitudinal folds, not a pavement: barely a whisper, just to break the repeat id: 'esophagus', palette: { tint: 0x1e6e64, rim: 0x5affd2, void: 0x02100d }, fog: 0.028, @@ -33,6 +35,7 @@ export const BIOMES = { wave: { amp: 1.4, breathe: 0.15 }, // the signature: ribbed rings rushing past (measured) }, stomach: { + cells: 0.38, cellScale: 6.5, // gastric pits — a pitted field reads as cells at this scale id: 'stomach', palette: { tint: 0xb0571e, rim: 0xffb13a, void: 0x120801 }, fog: 0.020, @@ -41,6 +44,7 @@ export const BIOMES = { wave: { amp: 0.7, breathe: 0.25 }, // churn, not transit }, small_intestine: { + cells: 0.2, cellScale: 7.5, // villi are PROJECTIONS, not a pavement; keep it under D's texture id: 'small_intestine', palette: { tint: 0x7a2a6e, rim: 0xff6ad5, void: 0x0d0312 }, fog: 0.050, // dense: villi forest occludes sightlines @@ -49,6 +53,7 @@ export const BIOMES = { wave: { amp: 1.5, breathe: 0.18 }, }, large_intestine: { + cells: 0.42, cellScale: 6.0, // smooth mucosa under biofilm — the cells are the visible structure here id: 'large_intestine', palette: { tint: 0x3a4a26, rim: 0x9dff4a, void: 0x050702 }, fog: 0.075, // darkness is the mechanic here; scanner light is the safety bubble @@ -57,6 +62,7 @@ export const BIOMES = { wave: { amp: 1.2, breathe: 0.2 }, }, appendix: { + cells: 0.48, cellScale: 5.5, // the biofilm reservoir: mats of cells are literally the subject id: 'appendix', palette: { tint: 0x8a7020, rim: 0xffe066, void: 0x0a0800 }, fog: 0.030, diff --git a/web/js/world/wall_material.js b/web/js/world/wall_material.js index 79d2678..e765cec 100644 --- a/web/js/world/wall_material.js +++ b/web/js/world/wall_material.js @@ -37,6 +37,19 @@ export function createWallMaterial({ 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; } @@ -65,6 +78,9 @@ export function createWallMaterial({ 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 = ''; @@ -98,6 +114,8 @@ export function createWallMaterial({ 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; @@ -169,6 +187,33 @@ export function createWallMaterial({ #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 @@ -189,6 +234,21 @@ export function createWallMaterial({ // .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). @@ -201,7 +261,7 @@ export function createWallMaterial({ // 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); + 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. @@ -210,7 +270,7 @@ export function createWallMaterial({ // 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; + vec3 base = uTint * detail * detail * cellMod * 0.65; #endif vec3 N = normalize(vN);