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>
232 lines
10 KiB
HTML
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>
|