guts/web/js/core/assets.js
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

177 lines
7.1 KiB
JavaScript

// core/assets.js (Lane D) — the manifest loader. Implements the asset contract in docs/TECH.md.
//
// THE LAW THIS FILE EXISTS TO ENFORCE: every asset is optional at runtime. Nothing in here
// throws, ever. A missing manifest, a 404, a corrupt WebP, a box with no network — all resolve
// to `null`, and the consumer draws its procedural fallback. GUTS must be fully playable with
// an empty assets/gen/, and `?localassets=0` exists so you can prove it on demand.
//
// Usage (boot constructs it once and passes it to every lane factory):
// const assets = await createAssets({ flags, renderer });
// const e = assets.get('textures', 'wall_esophagus_a'); // raw manifest entry | null
// const t = assets.texture('wall_esophagus_a'); // {map, normalMap, tile} | null
// if (t) mat.uniforms.uDetail.value = t.map; // else: keep the procedural path
import * as THREE from 'three';
const MANIFEST_URL = new URL('../../assets/manifest.json', import.meta.url);
const ASSETS_BASE = new URL('../../assets/', import.meta.url);
// The 3GOD depot (house shared mesh store). `depot:foo.glb` in a manifest resolves here.
// Nothing SHIPPED may use it — the game must run offline (README law) — but it lets Lane D
// point at depot meshes while prototyping without copying them into the repo.
const DEPOT_BASE = 'https://digalot.fyi/3god/';
const EMPTY = { textures: {}, matcaps: {}, models: {}, audio: { beds: {}, sfx: {} } };
/** `depot:x` -> depot URL; anything else -> resolved against web/assets/. */
function resolve(url) {
if (typeof url !== 'string' || !url) return null;
if (url.startsWith('depot:')) return DEPOT_BASE + url.slice(6);
if (/^https?:\/\//.test(url)) return url;
return new URL(url, ASSETS_BASE).href;
}
export async function createAssets({ flags = {}, renderer = null } = {}) {
const loader = new THREE.TextureLoader();
const cache = new Map(); // url -> THREE.Texture (one GPU upload per file)
const maxAniso = renderer?.capabilities?.getMaxAnisotropy?.() ?? 1;
let manifest = EMPTY;
// Miss ledger. The optional-asset law has one nasty edge, found by Lane A in round 1: ask
// for a name that doesn't exist (a typo, or a slug that drifted from a biome id) and you
// get the procedural fallback FOREVER, with no error — the game looks fine and the texture
// is simply never seen. So every miss is recorded, and each distinct one says so once.
// `assets.misses()` lists them; noisy-by-design under ?localassets=0, so stay quiet there.
const missed = new Set();
const noteMiss = (key) => {
if (missed.has(key)) return;
missed.add(key);
if (flags.localassets !== false) console.info(`[assets] miss: ${key} — using fallback`);
};
// ?localassets=0 => pretend the manifest is empty. This is the fallback-path test switch:
// if the game looks broken with it on, someone has violated the optional-asset law.
if (flags.localassets === false) {
console.info('[assets] ?localassets=0 — empty manifest, procedural fallbacks only');
} else {
try {
const r = await fetch(MANIFEST_URL, { cache: 'no-cache' });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
manifest = { ...EMPTY, ...(await r.json()) };
} catch (e) {
// Not an error condition — an asset-free checkout is a supported way to run GUTS.
console.info('[assets] no manifest, running asset-free —', e.message);
manifest = EMPTY;
}
}
function loadTexture(url, { srgb = true, repeat = null } = {}) {
const abs = resolve(url);
if (!abs) return null;
if (cache.has(abs)) return cache.get(abs);
let tex;
try {
// TextureLoader is async under the hood; a decode failure lands in onError, where we
// just mark the texture dead rather than letting it reject into nothing.
tex = loader.load(abs, undefined, undefined, () => {
console.info(`[assets] failed to decode ${url} — consumer should fall back`);
tex.userData.failed = true;
});
} catch (e) {
console.info(`[assets] failed to load ${url}`, e.message);
return null;
}
tex.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace;
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.anisotropy = maxAniso;
if (repeat) tex.repeat.set(repeat[0], repeat[1]);
cache.set(abs, tex);
return tex;
}
const api = {
manifest,
/** THE contract (TECH.md): raw manifest entry, or null on any miss. Never throws. */
get(category, name) {
const cat = manifest?.[category];
if (!cat) return null;
if (category === 'audio') return null; // audio is nested; use audioUrl()
const e = cat[name] ?? null;
if (!e) noteMiss(`${category}/${name}`);
return e;
},
/** Names asked for that the manifest didn't have. Check this if art "isn't showing up". */
misses() {
return [...missed];
},
/**
* Wall texture ready to plug into a material, or null.
* `tile` = [repeats around theta, units of s per repeat] (LANE_D_NOTES §tiling).
* The stub/world UV is (theta/2pi, s-in-units), so repeat = (tile[0], 1/tile[1])
* is pre-applied here — Lane A shouldn't have to rediscover the convention.
*/
texture(name) {
const e = api.get('textures', name);
if (!e) return null;
const tile = Array.isArray(e.tile) && e.tile.length === 2 ? e.tile : [4, 16];
const repeat = [tile[0], 1 / tile[1]];
const map = loadTexture(e.url, { srgb: true, repeat });
if (!map) return null;
return {
map,
normalMap: e.normal ? loadTexture(e.normal, { srgb: false, repeat }) : null,
tile,
repeat,
};
},
/** Matcap texture (clamped, non-tiling), or null. */
matcap(name) {
const e = api.get('matcaps', name);
if (!e) return null;
const t = loadTexture(e.url, { srgb: true });
if (t) t.wrapS = t.wrapT = THREE.ClampToEdgeWrapping;
return t;
},
/** Model manifest entry (url resolved), or null. Lane D ships GLBs from round 2. */
model(name) {
const e = api.get('models', name);
if (!e) return null;
return { ...e, url: resolve(e.url) };
},
/**
* Best playable audio URL for this browser, or null.
* cat: 'beds' | 'sfx'. Safari can't do Opus-in-Ogg, so we dual-ship and probe.
*/
audioUrl(cat, name) {
const e = manifest?.audio?.[cat]?.[name];
if (!e) return null;
const probe = document.createElement('audio');
const canOgg = !!probe.canPlayType('audio/ogg; codecs="opus"');
const pick = (canOgg && e.ogg) ? e.ogg : (e.m4a || e.ogg);
return pick ? resolve(pick) : null;
},
/** Raw audio entry ({ogg, m4a, loop, gain, seconds}) or null — for Lane E's engine. */
audio(cat, name) {
return manifest?.audio?.[cat]?.[name] ?? null;
},
/** Names present in a category — lets a lane iterate what actually shipped. */
list(category) {
if (category === 'audio') return Object.keys(manifest?.audio ?? {});
return Object.keys(manifest?.[category] ?? {});
},
dispose() {
for (const t of cache.values()) t.dispose();
cache.clear();
},
};
return api;
}