guts/web/dev/laneD_texview.html
jing efea7d98f3 [lane D] Round 1: first texture pack + audio proof, exact-tiling pipeline
Textures (6 walls + normals + wet matcap, $0, ~64s on the m3ultra MPS):
- FLUX ignores "seamless tiling" (seam_error 3.7-17.9); a 4-way cosine
  partition-of-unity blend of the four half-rolls fixes it exactly -> 0.87-1.32
- levels-normalised the pack to one exposure (means were 0.18-0.32); these are
  detail maps multiplied into a biome tint, so per-prompt exposure read as a
  broken tint rather than as dark tissue
- prompt-kit experiment rejected + recorded: PROCITY's "uniform coverage" clause
  in the STEM cured centred subjects but flattened the pack; framing words now
  live only in the two subject prompts that needed them

Audio (1 bed + 4 sfx, 4.2s render, 0.61/10MB, ogg+m4a dual-ship):
- bed-esophagus 24.000s, loop seam 0.32 (wrap step 3x smaller than inner step)
- cascaded lowpass poles: one-pole at 900Hz left 16kHz only ~25dB down = hiss
- rewrote the spectrogram sheet (per-clip peak, log freq) — the first one was a
  saturated rectangle, and evidence you can't read is not evidence

assets.js: miss ledger + misses() — Lane A found that a drifted slug falls back
procedurally forever with no error. Each distinct miss now announces itself.

Also: shot_sink.py (canvas -> docs/shots/laneD, correctly named) and a dev
texture viewer that imports the stub world read-only for the eyeball law.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:17:17 +10:00

232 lines
10 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>GUTS — Lane D texture viewer (dev)</title>
<!--
Lane D's dev harness. NOT part of the game, not shipped, nothing imports it.
Why it exists: the eyeball law says a texture isn't done until it's been seen in-engine on
the stub tube, but boot.js doesn't construct `assets` yet (that wiring is Lane F's — the
snippet is in LANE_D_NOTES.md §-> Lane F). So this page imports the stub world READ-ONLY,
keeps its geometry (the real (theta/2pi, s) UVs), and puts a textured material on it.
It doubles as the reference implementation of how to sample a Lane D texture — Lane A can
lift the three marked lines straight into the real wall shader.
http://localhost:8140/dev/laneD_texview.html (cycles every 3 s)
http://localhost:8140/dev/laneD_texview.html?tex=wall_stomach_a&hold=1
...&localassets=0 (fallback-law proof: flat tint)
-->
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #02100d; }
canvas { display: block; width: 100vw; height: auto; } /* fixed buffer, fitted on screen */
#hud { position: fixed; left: 12px; bottom: 12px; color: #5affd2;
font: 12px/1.5 ui-monospace, monospace; text-shadow: 0 0 5px #000; pointer-events: none; }
#hud b { color: #fff; font-weight: 600; }
</style>
<script type="importmap">
{ "imports": { "three": "../vendor/three.module.js",
"three/addons/": "../vendor/addons/" } }
</script>
</head>
<body>
<div id="hud"></div>
<script type="module">
import * as THREE from 'three';
import { createWorld, STUB_LEVEL } from '../js/stub/world_stub.js';
import { createAssets } from '../js/core/assets.js';
import { parseFlags } from '../js/core/flags.js';
import { createRng } from '../js/core/rng.js';
const flags = parseFlags();
const p = new URLSearchParams(location.search);
const hold = p.has('hold');
// FIXED render size, deliberately not innerWidth/innerHeight. This pane reports 0x0 whenever
// it isn't being looked at, which sized the renderer to 0x0 and made every capture come back
// an empty blob. Evidence must not depend on whether a window happens to be visible — and a
// fixed size also means every round's shots are the same resolution and comparable.
const W = +(p.get('w') || 1280), H = +(p.get('h') || 720);
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(1);
renderer.setSize(W, H, false); // false: don't touch CSS size, the stylesheet fits it
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, W / H, 0.1, 600);
const world = await createWorld(STUB_LEVEL, { rng: createRng(STUB_LEVEL.seed) });
scene.add(world.group);
const BIOME = world.biomeAt(0);
scene.background = new THREE.Color(BIOME.palette.void);
const assets = await createAssets({ flags, renderer });
// The stub's mesh + geometry are reused verbatim: same rings, same aInward, same
// uv = (theta/2pi, s-in-units). Only the material is Lane D's.
const mesh = world.group.children[0];
const WAVE_A = 0.9, WAVE_K = 0.22, WAVE_W = 3.0; // mirrors world_stub.js's displacement
const mat = new THREE.ShaderMaterial({
side: THREE.FrontSide,
extensions: { derivatives: true }, // perturbNormal builds its TBN from dFdx/dFdy
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: BIOME.fog },
uDetail: { value: null },
uNormal: { value: null },
uRepeat: { value: new THREE.Vector2(4, 1 / 16) },
uHasDetail: { value: 0 },
uHasNormal: { value: 0 },
uNormalScale: { value: 0.6 },
},
vertexShader: /* glsl */`
attribute vec3 aInward;
varying vec2 vUv; varying vec3 vN; varying vec3 vView;
uniform float uTime;
void main() {
vUv = uv;
float pulse = pow(max(0.0, sin(${WAVE_K.toFixed(3)} * uv.y - ${WAVE_W.toFixed(2)} * uTime)), 3.0);
float breathe = 0.15 * sin(uv.y * 0.7 + uTime * 0.8) * sin(uv.x * 6.2831 * 3.0);
vec3 disp = position + aInward * (${WAVE_A.toFixed(2)} * pulse + breathe);
vec4 mv = modelViewMatrix * vec4(disp, 1.0);
vN = normalize(normalMatrix * aInward);
vView = -mv.xyz;
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: /* glsl */`
varying vec2 vUv; varying vec3 vN; varying vec3 vView;
uniform vec3 uTint; uniform vec3 uRim; uniform vec3 uVoid;
uniform float uFog; uniform float uTime;
uniform sampler2D uDetail; uniform sampler2D uNormal;
uniform vec2 uRepeat; uniform float uHasDetail; uniform float uHasNormal;
uniform float uNormalScale;
// Tangent-space normal -> view space, TBN rebuilt per-pixel from screen derivatives
// (three's perturbNormal2Arb). The tube geometry carries no tangent attribute, and
// deriving one is the ONLY correct way to apply these maps.
// Do NOT "just add" the map to the normal: a tangent-space sample is ~(0,0,1), so adding
// it tilts every normal toward the camera, dot(n,view) -> 1, and the fresnel rim (which
// is this biome's main light) dies. That mistake renders the tube near-black.
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);
}
void main() {
// ---- LANE A: this is the whole texture integration, three lines ------------------
// uv is (theta/2pi, s-in-units); uRepeat is assets.texture(n).repeat = [tileTheta, 1/sPerTile].
// RepeatWrapping means duv may run outside 0..1 freely. A raw ShaderMaterial does NOT
// apply texture.repeat for you (only built-in materials do) — multiply it in yourself.
vec2 duv = vUv * uRepeat;
float detail = uHasDetail > 0.5 ? texture2D(uDetail, duv).r : 0.5;
// ----------------------------------------------------------------------------------
// procedural fallback (the law: no texture => still a good-looking wall)
float folds = 0.55 + 0.45 * sin(vUv.x * 6.2831 * 9.0 + sin(vUv.y * 0.9) * 2.0);
float base_l = uHasDetail > 0.5 ? (0.35 + detail * 0.95) : (folds * (0.85 + 0.15 * sin(vUv.y * 2.2)));
vec3 base = uTint * base_l * 0.9;
vec3 n = normalize(vN);
if (uHasNormal > 0.5) { // perturb the fresnel with the derived normal
vec3 tn = texture2D(uNormal, duv).xyz * 2.0 - 1.0;
tn.xy *= uNormalScale; // 0 = off, 1 = full derived relief
n = perturb(n, -vView, duv, normalize(tn));
}
float fres = pow(1.0 - abs(dot(n, normalize(vView))), 2.2);
vec3 col = base + uRim * fres * 0.9;
float d = length(vView);
col = mix(col, uVoid, 1.0 - exp(-uFog * d * 0.55));
gl_FragColor = vec4(col, 1.0);
}`,
});
mesh.material = mat;
// ART_BIBLE §Biome palettes. The stub only knows esophagus, but a stomach texture judged
// under a teal tint tells you nothing — the tint is half the look. Keyed off the slug so
// each wall is eyeballed in the biome it was written for.
const PALETTES = {
esophagus: { tint: 0x1e6e64, rim: 0x5affd2, void: 0x02100d, fog: 0.028 },
stomach: { tint: 0xb0571e, rim: 0xffb13a, void: 0x120801, fog: 0.030 },
smallint: { tint: 0x7a2a6e, rim: 0xff6ad5, void: 0x0d0312, fog: 0.034 },
};
const paletteFor = (name) =>
PALETTES[Object.keys(PALETTES).find((k) => (name || '').includes(k))] ?? PALETTES.esophagus;
const names = assets.list('textures');
let idx = Math.max(0, names.indexOf(p.get('tex') || ''));
function apply(i) {
const name = names[i];
const t = name ? assets.texture(name) : null; // null => fallback path stays lit
mat.uniforms.uDetail.value = t?.map ?? null;
mat.uniforms.uNormal.value = t?.normalMap ?? null;
mat.uniforms.uHasDetail.value = t?.map ? 1 : 0;
mat.uniforms.uHasNormal.value = t?.normalMap ? 1 : 0;
if (t) mat.uniforms.uRepeat.value.set(t.repeat[0], t.repeat[1]);
const pal = paletteFor(name);
mat.uniforms.uTint.value.setHex(pal.tint);
mat.uniforms.uRim.value.setHex(pal.rim);
mat.uniforms.uVoid.value.setHex(pal.void);
mat.uniforms.uFog.value = pal.fog;
scene.background = new THREE.Color(pal.void);
hud.innerHTML = t
? `<b>${name}</b> · tile [${t.tile}] → repeat [${t.tile[0]}, 1/${t.tile[1]}]`
+ `${t.normalMap ? ' · +normal' : ''} · ${names.length} in manifest`
: `<b>no texture</b> — procedural fallback (manifest empty${flags.localassets === false ? ', ?localassets=0' : ''})`;
}
apply(idx);
addEventListener('keydown', (e) => {
if (e.code === 'Space') { idx = (idx + 1) % names.length; apply(idx); }
});
let t = 0, cycle = 0;
let sCam = 5;
renderer.setAnimationLoop(() => {
const dt = 1 / 60;
t += dt;
world.update(dt, sCam);
mat.uniforms.uTime.value = t;
sCam = (sCam + BIOME.flow * 0.45 * dt) % world.length;
const f = world.sample(sCam), ahead = world.sample(Math.min(sCam + 12, world.length));
camera.position.copy(f.pos).addScaledVector(f.nor, -f.radius * 0.25);
camera.up.copy(f.nor);
camera.lookAt(ahead.pos);
if (!hold && names.length) {
cycle += dt;
if (cycle > 3) { cycle = 0; idx = (idx + 1) % names.length; apply(idx); }
}
renderer.render(scene, camera);
});
// shot(name): POST the frame to pipeline/shot_sink.py if it's running (lands correctly named
// in docs/shots/laneD/), else fall back to a browser download.
window.DBG = {
shot(n = 'shot') {
renderer.render(scene, camera); // force a frame: rAF is paused while the pane is hidden
return new Promise((res) => renderer.domElement.toBlob(async (b) => {
if (!b) { res('FAILED: empty canvas'); return; }
try {
const port = p.get('sink') || '8141';
const r = await fetch(`http://localhost:${port}/shot/${n}.png`, { method: 'POST', body: b });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
res(`sink: ${n}.png`);
} catch (e) {
const a = document.createElement('a');
a.download = `${n}.png`; a.href = renderer.domElement.toDataURL('image/png'); a.click();
res(`download (sink offline: ${e.message}): ${n}.png`);
}
}, 'image/png'));
},
apply, names, assets, mat, world,
};
</script>
</body>
</html>