[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>
227
docs/LANES/LANE_D_NOTES.md
Normal file
@ -0,0 +1,227 @@
|
||||
# LANE D — Assets — NOTES
|
||||
|
||||
## Round 1 (2026-07-16) — pipeline online, first texture pack, audio proof
|
||||
|
||||
Everything below was generated **free and local** on the m3ultra box (`flux_local`, MPS).
|
||||
Total spend this round: **$0.00**. `FAL_LIST.md` does not exist yet — nothing needed it.
|
||||
|
||||
### What landed
|
||||
|
||||
| thing | where | note |
|
||||
|---|---|---|
|
||||
| 6 wall textures + normals | `web/assets/gen/wall_*.webp` | grayscale SEM, exact-tiling, 1024² |
|
||||
| 1 wet-tissue matcap | `web/assets/gen/matcap_tissue_wet.webp` | 512², cropped to the ball |
|
||||
| 1 bed + 4 sfx | `web/assets/audio/*.{ogg,m4a}` | procedural numpy synth, dual-shipped |
|
||||
| manifest + loader | `web/assets/manifest.json`, `js/core/assets.js` | null-on-miss, `?localassets=0` |
|
||||
| pipeline scripts | `pipeline/*.py` | ported from PROCITY, `--dry-run` first |
|
||||
| dev viewer | `web/dev/laneD_texview.html` | in-engine eyeball harness (see §ownership) |
|
||||
| shot sink | `pipeline/shot_sink.py` | writes canvas frames into `docs/shots/laneD/` |
|
||||
|
||||
**Shipped asset weight: 2.3 MB** (1.7 MB textures+normals, 0.61 MB audio). Audio budget 0.61/10 MB.
|
||||
|
||||
### Measured (not estimated)
|
||||
|
||||
- **Generation:** 9.0–9.6 s per 1024² image on the m3ultra MPS GPU (`flux2-klein-4b`, 4 steps,
|
||||
guidance 3.5, quantize 8), one GPU job at a time. Whole 7-image pack ≈ 64 s. Audio: **4.2 s**
|
||||
for the entire pack (numpy synth + ffmpeg opus/aac), measured with `time`.
|
||||
- **Tiling** — `seam_error` = mean |Δ| across the wrap seam ÷ mean |Δ| inside the image.
|
||||
**~1.0 means the seam is indistinguishable from ordinary interior detail.** FLUX is *told*
|
||||
"seamless tiling texture" and flatly ignores it — every raw image scored 3.7–17.9:
|
||||
|
||||
| texture | seam before | seam after | mean before → after |
|
||||
|---|---|---|---|
|
||||
| wall_esophagus_a | 3.70 | **1.14** | 0.22 → 0.50 |
|
||||
| wall_esophagus_b | 11.12 | **2.39** | 0.17 → 0.49 |
|
||||
| wall_smallint_a | 8.60 | **1.04** | 0.18 → 0.49 |
|
||||
| wall_smallint_b | 10.50 | **1.32** | 0.24 → 0.50 |
|
||||
| wall_stomach_a | 12.16 | **0.87** | 0.22 → 0.50 |
|
||||
| wall_stomach_b | 17.85 | **1.08** | 0.25 → 0.50 |
|
||||
|
||||
`wall_esophagus_b` (2.39) is the pack's weakest: it's near-pure parallel lines, so the blend
|
||||
has no noise to hide in. It reads clean in the 2×2 check and in engine — flagging it honestly
|
||||
rather than claiming 1.0.
|
||||
- **Loop seam** (`bed-esophagus`) = **0.32**: the wrap step is 3× *smaller* than the average
|
||||
sample-to-sample step, i.e. inaudible. Numeric, in `pipeline/batch_audio.json`.
|
||||
- **Audio streams verified with ffprobe:** every asset ships opus/ogg 48 kHz stereo **and**
|
||||
aac/m4a 44.1 kHz stereo. Final ear-check is John's (PIPELINE.md).
|
||||
|
||||
Evidence: `docs/shots/laneD/` — `contact_walls.png` (source | 2×2 tile check | normal, per
|
||||
texture), `contact_matcaps.png`, `audio_spectrograms.png` (log-freq, 80 dB, loop seam printed),
|
||||
and 7 in-engine 1280×720 frames `round1_wall_*.png` + `round1_fallback_localassets0.png`.
|
||||
|
||||
### Two things that were wrong and are now right
|
||||
|
||||
1. **The pack was two stops apart.** FLUX gives every prompt its own exposure — source means
|
||||
ranged 0.18–0.32. These are DETAIL maps *multiplied into a biome tint*, so a dark texture
|
||||
doesn't read as "dark tissue", it reads as "this biome's tint is broken". `derive_maps.py`
|
||||
now normalises every wall to mean 0.50 / std 0.19 (soft-clipped). The tint, not the texture,
|
||||
decides brightness.
|
||||
2. **The prompt kit needed framing, not uniformity** — see §prompt-kit below.
|
||||
|
||||
### §prompt-kit — an ART_BIBLE amendment, tried and REJECTED
|
||||
|
||||
The ART_BIBLE stem nails the *look* but lets FLUX compose a single centred hero subject: the
|
||||
first `esophagus_b` was one sphincter donut (tiled → polka dots) and `smallint_a` was one
|
||||
urchin of villi (tiled → mush). PROCITY hit this exact problem and solved it by ending every
|
||||
template with *"filling the entire frame edge to edge, nothing else visible"*.
|
||||
|
||||
I ported that into the STEM — and **it made the pack worse**: `esophagus_a` lost its ribbed
|
||||
drama for bland wood-grain, and stomach/smallint collapsed into near-identical honeycomb.
|
||||
**Uniformity is not the goal; variety across biomes is.** Rejected and reverted.
|
||||
|
||||
What shipped instead: the **stem stays ART_BIBLE-verbatim**, and only the two prompts that
|
||||
actually drew a hero subject carry framing words in their own subject text. Four winners were
|
||||
kept untouched rather than churned. This is recorded in `gen_textures.py` so nobody re-runs the
|
||||
experiment. **No ART_BIBLE edit is requested** — the file is right as written.
|
||||
|
||||
### §tiling — convention (ratified with Lane A)
|
||||
|
||||
`tile: [repeats_around_theta, units_of_s_per_repeat]`, read off the stub's
|
||||
`uv = (theta/2pi, s-in-world-units)`. Lane A converged on the identical semantics independently
|
||||
(LANE_A_NOTES §→ Lane D) — **ratified from my side too, it's settled.** `assets.texture(n)`
|
||||
returns `repeat = [tile[0], 1/tile[1]]` precomputed.
|
||||
|
||||
Per-texture values live in `pipeline/batch_textures.json` and flow into the manifest.
|
||||
|
||||
## → Lane A
|
||||
|
||||
Thanks for the review — answering each point:
|
||||
|
||||
- **Naming (`wall_smallint_a` vs biome id `small_intestine`): agreed, your proposal, round 2.**
|
||||
You're right that it's the dangerous kind of bug. I did **not** rename this round on purpose:
|
||||
your `index.js` slug map is live and a unilateral rename would silently break it mid-round —
|
||||
exactly the failure you described. Round 2, in one step: I rename to biome ids
|
||||
(`wall_small_intestine_a`, `wall_large_intestine_a`), you delete the map. F to sequence.
|
||||
- **Meanwhile the silent-miss class is fixed at my end.** `assets.js` now keeps a miss ledger:
|
||||
the first time any name misses it logs `[assets] miss: textures/x — using fallback`, and
|
||||
`assets.misses()` returns every name asked for that wasn't there. Verified against your exact
|
||||
case: `assets.texture('wall_small_intestine_a')` → null + one log + listed in `misses()`.
|
||||
So a drifted slug now announces itself instead of falling back forever in silence.
|
||||
- **`texture()` vs `get()`: agreed** — `get()` is the documented floor (raw JSON, per TECH),
|
||||
`texture()` is what a shader can eat. Seconding your suggestion that F promotes `texture()`
|
||||
into TECH as the contract. I'll keep both, and `get()` keeps working exactly as specced.
|
||||
- **"the `repeat` you pre-apply is inert in a raw ShaderMaterial" — correct, and it's deliberate.**
|
||||
`texture.repeat` only feeds `uvTransform` in three's built-in materials. I set it anyway so
|
||||
the value is right if anyone ever uses a built-in material, and I expose the same numbers as
|
||||
`.repeat`/`.tile` for shader use. Multiply it in yourself: `vec2 duv = vUv * uRepeat;`.
|
||||
|
||||
### The TBN gotcha — you said round 2, here's the answer now
|
||||
|
||||
You noted the `_b` variants and matcap "aren't wired yet (round 2, with the TBN work)". I hit
|
||||
the normal-map integration in the viewer and it cost me a while, so take the result:
|
||||
|
||||
**Do NOT add the normal-map sample to the vertex normal.** I first wrote
|
||||
`n = normalize(vN + tn * 0.55)`. 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** → the tube renders
|
||||
near-black. The rim *is* the biome's main light; this mistake looks like "Lane D's textures are
|
||||
too dark" and it is not. Diagnosis: flip to the procedural fallback — if that's bright and the
|
||||
textured path is black, it's this.
|
||||
|
||||
The tube geometry carries no tangent attribute, so rebuild the TBN per-pixel from screen
|
||||
derivatives (three's `perturbNormal2Arb`). Working, verified in
|
||||
`web/dev/laneD_texview.html` — lift it:
|
||||
|
||||
```glsl
|
||||
// needs: new THREE.ShaderMaterial({ extensions: { derivatives: true }, ... })
|
||||
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);
|
||||
}
|
||||
// usage, in the fragment shader:
|
||||
vec2 duv = vUv * uRepeat; // uRepeat = assets.texture(n).repeat
|
||||
vec3 tn = texture2D(uNormal, duv).xyz * 2.0 - 1.0;
|
||||
tn.xy *= uNormalScale; // 0.6 looks right; 0 = off, 1 = full relief
|
||||
vec3 n = perturb(normalize(vN), -vView, duv, normalize(tn));
|
||||
float fres = pow(1.0 - abs(dot(n, normalize(vView))), 2.2);
|
||||
```
|
||||
|
||||
With this, the rim light traces every fold edge and the walls finally look like the ART_BIBLE
|
||||
line ("SEM tissue + artificial rim light"): `round1_wall_esophagus_a.png`. It's the single
|
||||
biggest visual win of my round and it's yours to take.
|
||||
|
||||
- **Detail sampling**: the wall webp is a luminance/AO map. `float detail = texture2D(uDetail,
|
||||
duv).r;` then `base = uTint * (0.35 + detail * 0.95) * 0.9` is what the shots use. Note
|
||||
three uploads these as `SRGB8_ALPHA8` (I set `colorSpace = SRGBColorSpace`), so the GPU
|
||||
decodes to linear on sample — that's intended. Normals are `NoColorSpace`, don't change that.
|
||||
- **Biome tints beyond esophagus**: the viewer carries the ART_BIBLE palettes for stomach and
|
||||
small intestine, so each wall was judged under its own tint, not teal.
|
||||
|
||||
## → Lane F
|
||||
|
||||
**1. Wiring `assets` into boot.** `assets.js` is done and `flags.localassets` already parses
|
||||
(thank you). Nothing constructs it yet. Exact snippet to paste into `js/boot.js`:
|
||||
|
||||
```js
|
||||
import { createAssets } from './core/assets.js'; // with the other core imports
|
||||
|
||||
const assets = await createAssets({ flags, renderer }); // after `renderer` exists,
|
||||
// before pickWorld()
|
||||
```
|
||||
then pass it into the lane factories (`createWorld(level, { rng, assets })`, and B/E likewise),
|
||||
and add to the DBG export if you like: `assets` (has `.misses()` — see → Lane A).
|
||||
It never throws and never blocks: no manifest ⇒ empty ⇒ every lane's fallback path. Safe to
|
||||
wire before any lane consumes it. Lane A currently reaches assets via its own path; once boot
|
||||
passes it in, that can collapse.
|
||||
|
||||
**2. Ownership: I created `web/dev/`** — `laneD_texview.html`, a dev-only harness (nothing
|
||||
imports it, not shipped). The eyeball law needs my textures on the real stub tube, but boot
|
||||
doesn't construct `assets` yet, so it imports `world_stub.js` **read-only** and puts a textured
|
||||
material on its geometry. Claiming `web/dev/laneD_texview.html` as Lane D; **please ratify or
|
||||
relocate** in ROUND2. It doubles as the reference implementation Lane A is welcome to lift.
|
||||
|
||||
**3. TECH suggestion (seconding Lane A):** promote `assets.texture(name)` into the asset
|
||||
contract next to `get()`. `get()` returns raw JSON; `texture()` returns
|
||||
`{map, normalMap, tile, repeat}` and is what consumers actually want. Also worth adding
|
||||
`?sink=<port>` nowhere near the flags table — it's viewer-local, not a game flag.
|
||||
|
||||
**4. `tools/qa.sh` gate 5 already calls `validate_manifest.py`** and it's green: 6 textures,
|
||||
1 matcap, 1 bed, 4 sfx, audio 0.61/10 MB. It enforces urls-exist, WebP ≤1024, matcap square,
|
||||
sane `tile`, ogg+m4a dual-ship, and warns on orphans in `gen/`. An absent manifest is treated
|
||||
as **legal** (asset-free boot), not an error.
|
||||
|
||||
## → Lane E
|
||||
|
||||
The audio pack is live and the loader is ready for your engine:
|
||||
|
||||
```js
|
||||
const url = assets.audioUrl('beds', 'esophagus'); // best format for THIS browser, or null
|
||||
const e = assets.audio('sfx', 'pellet'); // {ogg, m4a, loop, gain, seconds} | null
|
||||
```
|
||||
- **Keys** (not filenames): beds → `esophagus`. sfx → `pellet`, `hit_squelch`, `boost`,
|
||||
`pickup`. `assets.list('textures')` etc. enumerates what actually shipped.
|
||||
- **`audioUrl` probes the browser** and returns m4a where Opus-in-Ogg isn't playable (Safari).
|
||||
Both formats ship for every asset — always. Don't hardcode `.ogg`.
|
||||
- Each entry carries **`gain`** (bed 0.5; sfx 0.5–0.7) and **`loop`**. Please respect `gain` —
|
||||
beds are loudnorm'd to −16 LUFS, sfx are peak-normalised to ≈−3.5 dBFS, so raw playback has
|
||||
sfx much hotter than the bed by design.
|
||||
- **`bed-esophagus` is exactly 24.000 s and loops seamlessly** (tail crossfaded over the head,
|
||||
seam 0.32). Loop it with `audio.loop = true` / a looping AudioBufferSourceNode — no gap
|
||||
logic, no fade needed. The heartbeat is 50 bpm and divides the loop exactly.
|
||||
- Your procedural fallback beeps stay necessary: `?localassets=0` must still make noise.
|
||||
- Round 2 = full pack (beds per biome, torpedo/gate/alarm, boss stingers). Tell me what names
|
||||
you want and I'll match your keys rather than you adapting to mine.
|
||||
|
||||
## → Lane C
|
||||
|
||||
Got your round-2 list (`bolus_chunk`, `eosinophil_swarm`, `candida_bloom`) and the boss specs —
|
||||
they're enough to concept from, thank you. Your esophagus wall pack shipped this round.
|
||||
Early flag: **`candida_bloom` is the right call as a decal/plane, not a GLB** — a flat colony
|
||||
on a curved wall is the shape TRELLIS is worst at, and a wall-hugging plaque wants to follow
|
||||
`(s, theta)` anyway. I'll concept it as a sprite/decal sheet unless you object.
|
||||
The tapeworm (long, thin, segmented) is my expected **fal.ai candidate** — PIPELINE flags thin
|
||||
geometry as TRELLIS's known weakness. It'd go on `FAL_LIST.md` for John's approval, ~30¢.
|
||||
|
||||
## Open questions / round 2
|
||||
|
||||
1. **Slug rename** (§→ Lane A) — needs F to sequence it with A's map deletion. One step, both files.
|
||||
2. **Hero meshes** — the round-2 headline: ENDO-1 ship + C's three enemies. concept → sf3d
|
||||
draft → trellis_mac → normalize → thumb. Blobby organics are TRELLIS's sweet spot; the
|
||||
tapeworm probably isn't.
|
||||
3. **Colon + mouth walls** (2 each) to finish the biome set, + acid/decal sprites.
|
||||
4. **Full audio pack** — pending E's key list.
|
||||
5. `normalize.py` + `glb_stat.py` still need porting from PROCITY (round 2, with the meshes) —
|
||||
the house GLB law (metres, +Y up, ≤5k tris, no Draco) is unenforced until they exist.
|
||||
`validate_manifest.py` already fails any model over 5k tris that declares its count.
|
||||
58
docs/progress/d-progress.md
Normal file
@ -0,0 +1,58 @@
|
||||
# Lane D — progress log
|
||||
|
||||
## 2026-07-16 — Round 1: pipeline online, first texture pack, audio proof
|
||||
|
||||
Shipped all four round-1 items. **$0.00 spent** — everything generated locally on the m3ultra
|
||||
box; no fal.ai candidates arose, so `FAL_LIST.md` still doesn't exist.
|
||||
|
||||
**1. Scripts ported from PROCITY** (`--dry-run` first, idempotent, resumable):
|
||||
`gen_textures.py` (flux_local backend, provenance JSON), `derive_maps.py` (tiling + Sobel
|
||||
normals + WebP), `build_manifest.py`, `validate_manifest.py` (qa gate 5, stdlib-only so it runs
|
||||
on any box — hand-parses the WebP header rather than needing PIL), `gen_audio.py`.
|
||||
Plus `shot_sink.py`, a small addition of my own: `DBG.shot()`'s `<a download>` lands as
|
||||
unnamed half-written temp blobs in this browser pane, and naming evidence by guessing at file
|
||||
sizes isn't evidence — so the page POSTs frames and they land correctly named.
|
||||
|
||||
**2. First texture pack** — 6 walls (2 each: esophagus / stomach / small intestine) + normals
|
||||
+ 1 wet-tissue matcap. 9.0–9.6 s per 1024² image, ~64 s for the pack. 1.7 MB shipped.
|
||||
- Every raw FLUX image had a hard wrap seam (seam_error 3.7–17.9) despite the prompt asking for
|
||||
"seamless tiling texture". A 4-way cosine partition-of-unity blend of the four half-rolls
|
||||
fixes it *exactly* (weights sum to 1, are N-periodic, and each copy's seam sits where its own
|
||||
weight is 0) → **0.87–1.32**, i.e. the seam is now indistinguishable from interior detail.
|
||||
- Levels-normalised the pack to a common exposure: FLUX gave each prompt its own brightness
|
||||
(means 0.18–0.32), which under a multiplied biome tint reads as a broken tint, not dark tissue.
|
||||
- Prompt-kit experiment **rejected**: adding PROCITY's "uniform coverage / edge to edge" clause
|
||||
to the ART_BIBLE stem cured the centred-subject failures but flattened the whole pack and
|
||||
collapsed three biomes into the same honeycomb. Reverted; framing words now live only in the
|
||||
two subject prompts that needed them. Recorded in `gen_textures.py` so it isn't re-tried.
|
||||
|
||||
**3. `js/core/assets.js`** — manifest loader per the TECH contract: null-on-miss, never throws,
|
||||
`?localassets=0` empty boot, `depot:` resolution, dual-format audio probing. Added a **miss
|
||||
ledger** after Lane A identified the silent-forever-fallback failure mode: each distinct miss
|
||||
logs once and `assets.misses()` lists them.
|
||||
|
||||
**4. Audio proof** — `bed-esophagus` (24.000 s, loop seam **0.32** = inaudible) + 4 sfx
|
||||
(pellet, hit-squelch, boost, pickup). Whole pack renders in **4.2 s**. 0.61/10 MB budget,
|
||||
ogg+m4a dual-shipped, verified with ffprobe.
|
||||
- First spectrogram sheet was a saturated cream rectangle (fixed `(db+60)/70` normalisation) —
|
||||
evidence you can't read is not evidence. Rewrote against each clip's own peak over 80 dB with
|
||||
a log frequency axis and Hz gridlines.
|
||||
- Caught by looking at it: the bed had strong energy to 16 kHz — my `lp1` is one-pole (6 dB/oct),
|
||||
so a 900 Hz lowpass left 16 kHz only ~25 dB down. That's hiss, not a wet gut. Added cascaded
|
||||
poles; the bed is now properly muffled below ~1 kHz.
|
||||
|
||||
**Eyeballed in engine** (`web/dev/laneD_texview.html`, a dev harness that imports the stub world
|
||||
read-only): all 6 walls under their own ART_BIBLE biome tints + the `?localassets=0` fallback →
|
||||
`docs/shots/laneD/round1_*.png`.
|
||||
- **The find of the round:** normal maps were rendering the tube near-black. I'd added the
|
||||
tangent-space sample to the view-space normal, which tilts every normal at the camera and
|
||||
kills the fresnel rim — and the rim is the biome's only real light. Rebuilt the TBN per-pixel
|
||||
from screen derivatives; the rim now traces every fold edge and the walls finally look like
|
||||
the ART_BIBLE's "SEM tissue + artificial rim light". Handed the working code to Lane A in
|
||||
NOTES, since they'd scheduled TBN work for round 2.
|
||||
|
||||
QA green. Committed only Lane D paths.
|
||||
|
||||
**Next round:** slug rename to biome ids (with A deleting their map — F to sequence), hero
|
||||
meshes (ENDO-1 + C's three enemies), colon/mouth walls, full audio pack, port `normalize.py` +
|
||||
`glb_stat.py` so the house GLB law is actually enforced.
|
||||
BIN
docs/shots/laneD/audio_spectrograms.png
Normal file
|
After Width: | Height: | Size: 246 KiB |
BIN
docs/shots/laneD/contact_matcaps.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
docs/shots/laneD/contact_walls.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/shots/laneD/round1_fallback_localassets0.png
Normal file
|
After Width: | Height: | Size: 565 KiB |
BIN
docs/shots/laneD/round1_wall_esophagus_a.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
docs/shots/laneD/round1_wall_esophagus_b.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
docs/shots/laneD/round1_wall_smallint_a.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
docs/shots/laneD/round1_wall_smallint_b.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/shots/laneD/round1_wall_stomach_a.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
docs/shots/laneD/round1_wall_stomach_b.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
48
pipeline/batch_audio.json
Normal file
@ -0,0 +1,48 @@
|
||||
{
|
||||
"bed-esophagus": {
|
||||
"category": "bed",
|
||||
"gain": 0.5,
|
||||
"key": "esophagus",
|
||||
"loop": true,
|
||||
"loop_seam": 0.319,
|
||||
"m4a_kb": 380,
|
||||
"ogg_kb": 164,
|
||||
"seconds": 24.0
|
||||
},
|
||||
"sfx-boost": {
|
||||
"category": "sfx",
|
||||
"gain": 0.6,
|
||||
"key": "boost",
|
||||
"loop": false,
|
||||
"m4a_kb": 14,
|
||||
"ogg_kb": 6,
|
||||
"seconds": 0.85
|
||||
},
|
||||
"sfx-hit-squelch": {
|
||||
"category": "sfx",
|
||||
"gain": 0.7,
|
||||
"key": "hit_squelch",
|
||||
"loop": false,
|
||||
"m4a_kb": 6,
|
||||
"ogg_kb": 2,
|
||||
"seconds": 0.34
|
||||
},
|
||||
"sfx-pellet": {
|
||||
"category": "sfx",
|
||||
"gain": 0.5,
|
||||
"key": "pellet",
|
||||
"loop": false,
|
||||
"m4a_kb": 4,
|
||||
"ogg_kb": 2,
|
||||
"seconds": 0.2
|
||||
},
|
||||
"sfx-pickup": {
|
||||
"category": "sfx",
|
||||
"gain": 0.5,
|
||||
"key": "pickup",
|
||||
"loop": false,
|
||||
"m4a_kb": 9,
|
||||
"ogg_kb": 6,
|
||||
"seconds": 0.5
|
||||
}
|
||||
}
|
||||
141
pipeline/batch_textures.json
Normal file
@ -0,0 +1,141 @@
|
||||
{
|
||||
"matcap_tissue_wet": {
|
||||
"gen_seconds": 8.9,
|
||||
"guidance": 3.5,
|
||||
"kb": 19,
|
||||
"kind": "matcap",
|
||||
"model": "flux2-klein-4b",
|
||||
"prompt": "spherical material study ball, wet translucent organic tissue, subsurface glow, studio black background",
|
||||
"seam": null,
|
||||
"seed": 1401,
|
||||
"size": 512,
|
||||
"steps": 4,
|
||||
"tile": null
|
||||
},
|
||||
"wall_esophagus_a": {
|
||||
"gen_seconds": 9.1,
|
||||
"guidance": 3.5,
|
||||
"kb": 92,
|
||||
"kb_n": 220,
|
||||
"kind": "wall",
|
||||
"mean_after": 0.495,
|
||||
"mean_before": 0.216,
|
||||
"model": "flux2-klein-4b",
|
||||
"prompt": "scanning electron microscope micrograph, monochrome, high detail organic tissue, dark background, dramatic rim lighting, seamless tiling texture, flat top-down macro swatch filling the entire frame edge to edge, uniform repeating coverage, no single central subject, nothing else visible, human esophageal mucosa, longitudinal ribbed folds, wet ridges",
|
||||
"seam_after": 1.14,
|
||||
"seam_before": 3.7,
|
||||
"seed": 1101,
|
||||
"size": 1024,
|
||||
"steps": 4,
|
||||
"tile": [
|
||||
4,
|
||||
16
|
||||
],
|
||||
"tile_mode": "blend"
|
||||
},
|
||||
"wall_esophagus_b": {
|
||||
"gen_seconds": 9.2,
|
||||
"guidance": 3.5,
|
||||
"kb": 47,
|
||||
"kb_n": 159,
|
||||
"kind": "wall",
|
||||
"mean_after": 0.492,
|
||||
"mean_before": 0.173,
|
||||
"model": "flux2-klein-4b",
|
||||
"prompt": "scanning electron microscope micrograph, monochrome, high detail organic tissue, dark background, dramatic rim lighting, seamless tiling texture, human esophageal mucosa, rows of transverse circular ridges repeating across the whole frame, wet muscular bands, filling the entire frame edge to edge",
|
||||
"seam_after": 2.39,
|
||||
"seam_before": 11.12,
|
||||
"seed": 1102,
|
||||
"size": 1024,
|
||||
"steps": 4,
|
||||
"tile": [
|
||||
3,
|
||||
12
|
||||
],
|
||||
"tile_mode": "blend"
|
||||
},
|
||||
"wall_smallint_a": {
|
||||
"gen_seconds": 9.2,
|
||||
"guidance": 3.5,
|
||||
"kb": 90,
|
||||
"kb_n": 204,
|
||||
"kind": "wall",
|
||||
"mean_after": 0.495,
|
||||
"mean_before": 0.185,
|
||||
"model": "flux2-klein-4b",
|
||||
"prompt": "scanning electron microscope micrograph, monochrome, high detail organic tissue, dark background, dramatic rim lighting, seamless tiling texture, dense field of intestinal villi, soft finger-like projections packed across the whole frame, filling the entire frame edge to edge",
|
||||
"seam_after": 1.04,
|
||||
"seam_before": 8.6,
|
||||
"seed": 1301,
|
||||
"size": 1024,
|
||||
"steps": 4,
|
||||
"tile": [
|
||||
5,
|
||||
14
|
||||
],
|
||||
"tile_mode": "blend"
|
||||
},
|
||||
"wall_smallint_b": {
|
||||
"gen_seconds": 9.2,
|
||||
"guidance": 3.5,
|
||||
"kb": 106,
|
||||
"kb_n": 216,
|
||||
"kind": "wall",
|
||||
"mean_after": 0.496,
|
||||
"mean_before": 0.24,
|
||||
"model": "flux2-klein-4b",
|
||||
"prompt": "scanning electron microscope micrograph, monochrome, high detail organic tissue, dark background, dramatic rim lighting, seamless tiling texture, flat top-down macro swatch filling the entire frame edge to edge, uniform repeating coverage, no single central subject, nothing else visible, intestinal crypts of Lieberkuhn, honeycomb of round pits, packed glandular openings",
|
||||
"seam_after": 1.32,
|
||||
"seam_before": 10.5,
|
||||
"seed": 1302,
|
||||
"size": 1024,
|
||||
"steps": 4,
|
||||
"tile": [
|
||||
6,
|
||||
10
|
||||
],
|
||||
"tile_mode": "blend"
|
||||
},
|
||||
"wall_stomach_a": {
|
||||
"gen_seconds": 9.1,
|
||||
"guidance": 3.5,
|
||||
"kb": 73,
|
||||
"kb_n": 193,
|
||||
"kind": "wall",
|
||||
"mean_after": 0.496,
|
||||
"mean_before": 0.216,
|
||||
"model": "flux2-klein-4b",
|
||||
"prompt": "scanning electron microscope micrograph, monochrome, high detail organic tissue, dark background, dramatic rim lighting, seamless tiling texture, flat top-down macro swatch filling the entire frame edge to edge, uniform repeating coverage, no single central subject, nothing else visible, gastric mucosa, pitted craters, gastric pits, glistening mucus",
|
||||
"seam_after": 0.87,
|
||||
"seam_before": 12.16,
|
||||
"seed": 1201,
|
||||
"size": 1024,
|
||||
"steps": 4,
|
||||
"tile": [
|
||||
4,
|
||||
18
|
||||
],
|
||||
"tile_mode": "blend"
|
||||
},
|
||||
"wall_stomach_b": {
|
||||
"gen_seconds": 9.2,
|
||||
"guidance": 3.5,
|
||||
"kb": 62,
|
||||
"kb_n": 179,
|
||||
"kind": "wall",
|
||||
"mean_after": 0.497,
|
||||
"mean_before": 0.25,
|
||||
"model": "flux2-klein-4b",
|
||||
"prompt": "scanning electron microscope micrograph, monochrome, high detail organic tissue, dark background, dramatic rim lighting, seamless tiling texture, flat top-down macro swatch filling the entire frame edge to edge, uniform repeating coverage, no single central subject, nothing else visible, stomach rugae, thick heavy folded ridges, deep valleys between folds",
|
||||
"seam_after": 1.08,
|
||||
"seam_before": 17.85,
|
||||
"seed": 1202,
|
||||
"size": 1024,
|
||||
"steps": 4,
|
||||
"tile": [
|
||||
3,
|
||||
22
|
||||
],
|
||||
"tile_mode": "blend"
|
||||
}
|
||||
}
|
||||
@ -37,6 +37,14 @@ NORMAL_STRENGTH = 2.4
|
||||
OUT_SIZE = 1024 # house law: WebP <= 1024 (TECH.md)
|
||||
MATCAP_SIZE = 512
|
||||
|
||||
# Pack coherence: FLUX gives every prompt its own exposure (the esophagus came back ~2 stops
|
||||
# darker than the villi). These are DETAIL maps that get MULTIPLIED into a biome tint by Lane
|
||||
# A's shader, so a dark texture doesn't read as "dark tissue", it reads as "this biome's tint
|
||||
# is broken". Normalising every wall to the same mean/spread makes the tint mean the same
|
||||
# thing in every biome; the shader, not the texture, decides brightness.
|
||||
LEVELS_MEAN = 0.50
|
||||
LEVELS_STD = 0.19
|
||||
|
||||
|
||||
# ── image <-> array ─────────────────────────────────────────────────────────────────────
|
||||
def load_gray(path):
|
||||
@ -95,6 +103,14 @@ def match_stats(x, ref):
|
||||
return np.clip((x - x.mean()) * (ref.std() / sx) + ref.mean(), 0.0, 1.0)
|
||||
|
||||
|
||||
def normalize_levels(a, mean=LEVELS_MEAN, std=LEVELS_STD):
|
||||
"""Bring a wall to the pack's common exposure. Linear (shape-preserving) — soft-clipped
|
||||
at the ends so the SEM speckle highlights roll off instead of blowing out to flat white."""
|
||||
sa = a.std() or 1.0
|
||||
x = (a - a.mean()) * (std / sa) + mean
|
||||
return np.clip(0.5 + np.tanh((x - 0.5) * 1.9) * 0.5, 0.0, 1.0)
|
||||
|
||||
|
||||
def seam_error(a):
|
||||
"""Mean |delta| across the wrap seams vs mean |delta| inside the image. ~1.0 = tiles
|
||||
perfectly (the seam is as smooth as ordinary interior detail); >>1 = visible seam.
|
||||
@ -191,7 +207,9 @@ def harvest(only="", tile_mode="blend", contact_only=False):
|
||||
continue
|
||||
|
||||
before = seam_error(a)
|
||||
mean_before = float(a.mean())
|
||||
tiled = {"blend": tile_blend, "mirror": tile_mirror, "none": lambda z: z}[tile_mode](a)
|
||||
tiled = normalize_levels(tiled)
|
||||
if tiled.shape[0] != OUT_SIZE:
|
||||
tiled = resize(tiled, OUT_SIZE)
|
||||
after = seam_error(tiled)
|
||||
@ -203,8 +221,10 @@ def harvest(only="", tile_mode="blend", contact_only=False):
|
||||
wall_rows.append((slug, a, tiled, nrm))
|
||||
results[slug] = dict(kind="wall", seam_before=round(float(before), 2),
|
||||
seam_after=round(float(after), 2), kb=kb_a, kb_n=kb_n,
|
||||
mean_before=round(mean_before, 3), mean_after=round(float(tiled.mean()), 3),
|
||||
size=OUT_SIZE, tile_mode=tile_mode)
|
||||
print(f" {slug:22s} seam {before:5.2f} -> {after:4.2f} "
|
||||
print(f" {slug:22s} seam {before:5.2f} -> {after:4.2f} "
|
||||
f"mean {mean_before:.2f} -> {tiled.mean():.2f} "
|
||||
f"{kb_a:>4}KB +{kb_n:>4}KB normal [{tile_mode}]")
|
||||
|
||||
for slug, r in results.items():
|
||||
|
||||
@ -101,12 +101,21 @@ def lp1(x, cutoff):
|
||||
return lp_pole(x * (1 - a), a)
|
||||
|
||||
|
||||
def lp(x, cutoff, poles=1):
|
||||
"""Cascaded one-poles: 6 dB/oct each. ONE pole is not "muffled" — at cutoff 900 it still
|
||||
leaves 16 kHz only ~25 dB down, which reads as hiss, not as a wet cavern. Anything that
|
||||
is supposed to sound muffled wants 2–3 poles."""
|
||||
for _ in range(poles):
|
||||
x = lp1(x, cutoff)
|
||||
return x
|
||||
|
||||
|
||||
def hp1(x, cutoff):
|
||||
return x - lp1(x, cutoff)
|
||||
|
||||
|
||||
def bandpass(x, lo, hi):
|
||||
return hp1(lp1(x, hi), lo)
|
||||
def bandpass(x, lo, hi, poles=1):
|
||||
return hp1(lp(x, hi, poles), lo)
|
||||
|
||||
|
||||
def adsr(dur, a=0.01, d=0.1, s=0.7, r=0.1):
|
||||
@ -211,10 +220,11 @@ def bed_esophagus(name):
|
||||
|
||||
# low wet drone — two detuned saws, heavily lowpassed, slow swell
|
||||
d1 = osc(55.0, dur, "saw") + osc(55.0 * 1.006, dur, "saw") * 0.8
|
||||
drone = lp1(d1 * 0.25, 190) * (0.75 + 0.25 * np.sin(2 * np.pi * tarr(dur) / 12.0))
|
||||
drone = lp(d1 * 0.25, 190, poles=2) * (0.75 + 0.25 * np.sin(2 * np.pi * tarr(dur) / 12.0))
|
||||
|
||||
# cavern air — pink noise in the 200–900 band, breathing at 0.25 Hz
|
||||
air = bandpass(noise(dur, r, "pink"), 200, 900) * 0.30
|
||||
# cavern air — pink noise in the 200–900 band, breathing at 0.25 Hz. 3 poles: you are
|
||||
# inside a body, everything above ~1 kHz is behind wet tissue.
|
||||
air = bandpass(noise(dur, r, "pink"), 200, 900, poles=3) * 0.30
|
||||
air *= 0.6 + 0.4 * np.sin(2 * np.pi * tarr(dur) / 8.0)
|
||||
|
||||
# muffled heartbeat (lub-dub), 50 bpm
|
||||
@ -238,8 +248,7 @@ def bed_esophagus(name):
|
||||
st = int(i * 4.0 * SR)
|
||||
wd = 1.8
|
||||
seg = noise(wd, np.random.default_rng(seed_of(name) + i))
|
||||
sweep = np.linspace(300, 1600, int(wd * SR))
|
||||
seg = bandpass(seg, 200, 2000) * np.sin(np.pi * np.linspace(0, 1, len(seg))) ** 2
|
||||
seg = bandpass(seg, 200, 1400, poles=2) * np.sin(np.pi * np.linspace(0, 1, len(seg))) ** 2
|
||||
seg *= 0.18 * (0.7 + 0.3 * math.sin(i))
|
||||
n = min(len(seg), len(wh) - st)
|
||||
if n > 0:
|
||||
@ -343,23 +352,39 @@ def loop_seam(x):
|
||||
return float(wrap / inner)
|
||||
|
||||
|
||||
SPEC_TICKS = (50, 100, 500, 1000, 5000, 10000) # Hz gridlines drawn on the sheet
|
||||
SPEC_FMIN, SPEC_FMAX, SPEC_DB = 40.0, 16000.0, 80.0
|
||||
|
||||
|
||||
def _log_rows(nbins, h):
|
||||
"""Row -> FFT-bin map for a LOG frequency axis. A linear axis spends 3/4 of the image on
|
||||
8–16 kHz where these sounds have nothing; log puts the drone, the heartbeat and the
|
||||
squelch where you can actually see them."""
|
||||
freqs = np.geomspace(SPEC_FMIN, SPEC_FMAX, h)
|
||||
return np.clip((freqs / (SR / 2) * (nbins - 1)).astype(int), 0, nbins - 1)
|
||||
|
||||
|
||||
def spectrogram(x, w=420, h=180):
|
||||
"""|STFT| -> log-magnitude image column-per-frame. numpy only (no matplotlib on the box)."""
|
||||
"""|STFT| -> log-magnitude, log-frequency image. numpy only (no matplotlib on the box).
|
||||
|
||||
Levels are normalised to EACH clip's own peak over an 80 dB range. (First cut used a
|
||||
fixed `(db+60)/70` and every sheet came out a saturated cream rectangle — evidence you
|
||||
can't read is not evidence.)"""
|
||||
m = x.mean(axis=1) if x.ndim == 2 else x
|
||||
n = 1024
|
||||
hop = max(1, (len(m) - n) // w)
|
||||
win = np.hanning(n)
|
||||
cols = []
|
||||
for i in range(w):
|
||||
st = i * hop
|
||||
seg = m[st:st + n]
|
||||
seg = m[i * hop:i * hop + n]
|
||||
if len(seg) < n:
|
||||
seg = np.pad(seg, (0, n - len(seg)))
|
||||
S = np.abs(np.fft.rfft(seg * win))
|
||||
cols.append(20 * np.log10(S + 1e-6))
|
||||
img = np.array(cols).T[:h * 2:2][::-1] # low freqs at the bottom
|
||||
img = np.clip((img + 60) / 70, 0, 1)
|
||||
return img
|
||||
cols.append(np.abs(np.fft.rfft(seg * win)))
|
||||
S = np.array(cols).T # (bins, frames)
|
||||
db = 20 * np.log10(S + 1e-9)
|
||||
db -= db.max() # 0 dB = this clip's loudest bin
|
||||
img = db[_log_rows(S.shape[0], h)][::-1] # low freqs at the bottom
|
||||
return np.clip((img + SPEC_DB) / SPEC_DB, 0, 1)
|
||||
|
||||
|
||||
def evidence(rendered):
|
||||
@ -368,19 +393,25 @@ def evidence(rendered):
|
||||
from PIL import Image, ImageDraw
|
||||
rows = len(rendered)
|
||||
W, H, PADL = 420, 180, 150
|
||||
sheet = Image.new("RGB", (W + PADL + 20, rows * (H + 14) + 14), (10, 12, 14))
|
||||
sheet = Image.new("RGB", (W + PADL + 56, rows * (H + 16) + 16), (10, 12, 14))
|
||||
d = ImageDraw.Draw(sheet)
|
||||
freqs = np.geomspace(SPEC_FMIN, SPEC_FMAX, H)
|
||||
for i, (name, x, meta) in enumerate(rendered):
|
||||
y = 8 + i * (H + 14)
|
||||
y = 8 + i * (H + 16)
|
||||
img = spectrogram(x, W, H)
|
||||
g = (img * 255).astype(np.uint8)
|
||||
rgb = np.stack([g, (g * 0.85).astype(np.uint8), (g * 0.55).astype(np.uint8)], axis=-1)
|
||||
sheet.paste(Image.fromarray(rgb, "RGB"), (PADL, y))
|
||||
for f in SPEC_TICKS: # log-axis gridlines + Hz labels
|
||||
row = H - 1 - int(np.argmin(np.abs(freqs - f)))
|
||||
d.line([(PADL, y + row), (PADL + W, y + row)], fill=(60, 90, 100))
|
||||
d.text((PADL + W + 4, y + row - 5),
|
||||
f"{f//1000}k" if f >= 1000 else str(f), fill=(110, 140, 150))
|
||||
d.text((8, y + 6), name, fill=(90, 255, 210))
|
||||
d.text((8, y + 22), f"{meta['seconds']:.2f}s", fill=(150, 170, 180))
|
||||
d.text((8, y + 24), f"{meta['seconds']:.2f}s {meta['ogg_kb']}KB ogg", fill=(150, 170, 180))
|
||||
if meta.get("loop"):
|
||||
d.text((8, y + 38), f"seam {meta['loop_seam']:.2f}", fill=(150, 170, 180))
|
||||
d.text((8, y + 54 if meta.get("loop") else y + 38), f"{meta['ogg_kb']}KB ogg", fill=(150, 170, 180))
|
||||
d.text((8, y + 40), f"loop seam {meta['loop_seam']:.2f}", fill=(150, 170, 180))
|
||||
d.text((8, y + H - 14), "40Hz–16kHz log · 80dB", fill=(90, 110, 120))
|
||||
p = os.path.join(SHOTS, "audio_spectrograms.png")
|
||||
sheet.save(p, "PNG")
|
||||
return p
|
||||
|
||||
@ -29,7 +29,13 @@ MB = os.environ.get("MB_HOME", os.path.expanduser("~/Documents/MODELBEAST"))
|
||||
FLUX_PY = os.path.join(MB, "venvs/mflux/bin/python")
|
||||
FLUX_RUN = os.path.join(MB, "server/operators/flux_local/run.py")
|
||||
|
||||
# ── the style stem — verbatim from ART_BIBLE §FLUX prompt kit ────────────────────────────
|
||||
# ── the style stem — verbatim from ART_BIBLE §FLUX prompt kit. DO NOT add framing words ──
|
||||
# Tried and rejected (round 1, Lane D — see LANE_D_NOTES §prompt-kit): appending PROCITY's
|
||||
# "uniform coverage, no single central subject, filling the frame edge to edge" to the STEM.
|
||||
# It cured the centred-subject failures but flattened the whole pack — esophagus_a lost its
|
||||
# ribbed drama for bland wood-grain, and stomach/smallint collapsed into the same honeycomb.
|
||||
# Uniformity is not the goal; VARIETY across biomes is. So the stem stays pure style, and the
|
||||
# two prompts that actually drew a hero subject carry their own framing words instead.
|
||||
STEM = ("scanning electron microscope micrograph, monochrome, high detail organic tissue, "
|
||||
"dark background, dramatic rim lighting, seamless tiling texture")
|
||||
|
||||
@ -40,8 +46,11 @@ WALLS = {
|
||||
"wall_esophagus_a": dict(
|
||||
p="human esophageal mucosa, longitudinal ribbed folds, wet ridges",
|
||||
seed=1101, tile=[4, 16]),
|
||||
# "circular muscular rings" drew ONE ring, centred -> polka dots when tiled. Ask for the
|
||||
# FIELD, not the feature, and carry the framing clause in the subject.
|
||||
"wall_esophagus_b": dict(
|
||||
p="human esophagus inner lining, tight circular muscular rings, contracted sphincter folds",
|
||||
p="human esophageal mucosa, rows of transverse circular ridges repeating across the "
|
||||
"whole frame, wet muscular bands, filling the entire frame edge to edge",
|
||||
seed=1102, tile=[3, 12]),
|
||||
"wall_stomach_a": dict(
|
||||
p="gastric mucosa, pitted craters, gastric pits, glistening mucus",
|
||||
@ -49,8 +58,11 @@ WALLS = {
|
||||
"wall_stomach_b": dict(
|
||||
p="stomach rugae, thick heavy folded ridges, deep valleys between folds",
|
||||
seed=1202, tile=[3, 22]),
|
||||
# "forest of fronds" drew one radial urchin burst. Same fix; keep "finger-like" so this
|
||||
# stays VILLI and doesn't drift into smallint_b's honeycomb crypts.
|
||||
"wall_smallint_a": dict(
|
||||
p="intestinal villi, dense finger-like projections, forest of fronds",
|
||||
p="dense field of intestinal villi, soft finger-like projections packed across the "
|
||||
"whole frame, filling the entire frame edge to edge",
|
||||
seed=1301, tile=[5, 14]),
|
||||
"wall_smallint_b": dict(
|
||||
p="intestinal crypts of Lieberkuhn, honeycomb of round pits, packed glandular openings",
|
||||
|
||||
90
pipeline/shot_sink.py
Normal file
@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lane D shot sink — write canvas frames from a dev page straight into docs/shots/laneD/.
|
||||
|
||||
Why: the eyeball law wants in-engine PNGs committed as evidence every round, but `DBG.shot()`
|
||||
relies on an <a download> click, and in a headless/embedded browser pane those land as unnamed
|
||||
half-written temp blobs in ~/Downloads. Naming evidence by guessing at file sizes is not
|
||||
evidence. So: the page POSTs the canvas here and the file lands with the right name, first try.
|
||||
|
||||
python3 pipeline/shot_sink.py & # listens on :8141, writes docs/shots/laneD/
|
||||
python3 pipeline/shot_sink.py --dir docs/shots/laneD --port 8141
|
||||
|
||||
From the page (any origin — CORS is wide open, it's a localhost dev tool):
|
||||
|
||||
canvas.toBlob(b => fetch('http://localhost:8141/shot/my_name.png', {method:'POST', body:b}));
|
||||
|
||||
Stdlib only. Refuses anything that isn't a PNG, and sanitises the name to a bare basename so a
|
||||
page can't post its way out of the shots directory.
|
||||
"""
|
||||
import argparse, os, re, sys
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
|
||||
OUTDIR = os.path.join(ROOT, "docs", "shots", "laneD")
|
||||
|
||||
|
||||
class Sink(BaseHTTPRequestHandler):
|
||||
def _cors(self):
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "*")
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
self._cors()
|
||||
self.end_headers()
|
||||
|
||||
def _read_body(self):
|
||||
"""Content-Length when present, else chunked. fetch(body: Blob) normally sends a
|
||||
Content-Length, but some embedded browsers stream it chunked instead — and
|
||||
read(Content-Length or 0) silently returns b"" for those, which looks exactly like
|
||||
"not a PNG". Handle both rather than rejecting a perfectly good frame."""
|
||||
cl = self.headers.get("Content-Length")
|
||||
if cl is not None:
|
||||
return self.rfile.read(int(cl))
|
||||
if (self.headers.get("Transfer-Encoding") or "").lower() != "chunked":
|
||||
return b""
|
||||
buf = bytearray()
|
||||
while True:
|
||||
n = int(self.rfile.readline().split(b";")[0] or b"0", 16)
|
||||
if n == 0:
|
||||
self.rfile.readline() # trailing CRLF
|
||||
return bytes(buf)
|
||||
buf += self.rfile.read(n)
|
||||
self.rfile.readline() # CRLF after each chunk
|
||||
|
||||
def do_POST(self):
|
||||
m = re.match(r"^/shot/([\w.-]+)$", self.path)
|
||||
if not m:
|
||||
self.send_response(404); self._cors(); self.end_headers(); return
|
||||
name = os.path.basename(m.group(1))
|
||||
if not name.endswith(".png"):
|
||||
name += ".png"
|
||||
body = self._read_body()
|
||||
if not body.startswith(PNG_MAGIC): # only ever write real PNGs
|
||||
print(f" reject {name}: {len(body)}B, head={body[:8]!r}, "
|
||||
f"cl={self.headers.get('Content-Length')!r}, "
|
||||
f"te={self.headers.get('Transfer-Encoding')!r}", flush=True)
|
||||
self.send_response(415); self._cors(); self.end_headers()
|
||||
self.wfile.write(b"not a png"); return
|
||||
path = os.path.join(OUTDIR, name)
|
||||
with open(path, "wb") as f:
|
||||
f.write(body)
|
||||
print(f" shot {name} {len(body)//1024}KB -> {os.path.relpath(path, ROOT)}", flush=True)
|
||||
self.send_response(200); self._cors(); self.end_headers()
|
||||
self.wfile.write(b"ok")
|
||||
|
||||
def log_message(self, *a): # the prints above are the only log we want
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--port", type=int, default=8141)
|
||||
ap.add_argument("--dir", default=OUTDIR)
|
||||
a = ap.parse_args()
|
||||
OUTDIR = os.path.abspath(a.dir)
|
||||
os.makedirs(OUTDIR, exist_ok=True)
|
||||
print(f"shot sink: POST http://localhost:{a.port}/shot/<name>.png -> {OUTDIR}", flush=True)
|
||||
ThreadingHTTPServer(("127.0.0.1", a.port), Sink).serve_forever()
|
||||
BIN
web/assets/audio/bed-esophagus.m4a
Normal file
BIN
web/assets/audio/bed-esophagus.ogg
Normal file
BIN
web/assets/audio/sfx-boost.m4a
Normal file
BIN
web/assets/audio/sfx-boost.ogg
Normal file
BIN
web/assets/audio/sfx-hit-squelch.m4a
Normal file
BIN
web/assets/audio/sfx-hit-squelch.ogg
Normal file
BIN
web/assets/audio/sfx-pellet.m4a
Normal file
BIN
web/assets/audio/sfx-pellet.ogg
Normal file
BIN
web/assets/audio/sfx-pickup.m4a
Normal file
BIN
web/assets/audio/sfx-pickup.ogg
Normal file
BIN
web/assets/gen/matcap_tissue_wet.webp
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
web/assets/gen/wall_esophagus_a.webp
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
web/assets/gen/wall_esophagus_a_n.webp
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
web/assets/gen/wall_esophagus_b.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
web/assets/gen/wall_esophagus_b_n.webp
Normal file
|
After Width: | Height: | Size: 159 KiB |
BIN
web/assets/gen/wall_smallint_a.webp
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
web/assets/gen/wall_smallint_a_n.webp
Normal file
|
After Width: | Height: | Size: 205 KiB |
BIN
web/assets/gen/wall_smallint_b.webp
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
web/assets/gen/wall_smallint_b_n.webp
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
web/assets/gen/wall_stomach_a.webp
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
web/assets/gen/wall_stomach_a_n.webp
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
web/assets/gen/wall_stomach_b.webp
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
web/assets/gen/wall_stomach_b_n.webp
Normal file
|
After Width: | Height: | Size: 179 KiB |
99
web/assets/manifest.json
Normal file
@ -0,0 +1,99 @@
|
||||
{
|
||||
"audio": {
|
||||
"beds": {
|
||||
"esophagus": {
|
||||
"gain": 0.5,
|
||||
"loop": true,
|
||||
"m4a": "audio/bed-esophagus.m4a",
|
||||
"ogg": "audio/bed-esophagus.ogg",
|
||||
"seconds": 24.0
|
||||
}
|
||||
},
|
||||
"sfx": {
|
||||
"boost": {
|
||||
"gain": 0.6,
|
||||
"loop": false,
|
||||
"m4a": "audio/sfx-boost.m4a",
|
||||
"ogg": "audio/sfx-boost.ogg",
|
||||
"seconds": 0.85
|
||||
},
|
||||
"hit_squelch": {
|
||||
"gain": 0.7,
|
||||
"loop": false,
|
||||
"m4a": "audio/sfx-hit-squelch.m4a",
|
||||
"ogg": "audio/sfx-hit-squelch.ogg",
|
||||
"seconds": 0.34
|
||||
},
|
||||
"pellet": {
|
||||
"gain": 0.5,
|
||||
"loop": false,
|
||||
"m4a": "audio/sfx-pellet.m4a",
|
||||
"ogg": "audio/sfx-pellet.ogg",
|
||||
"seconds": 0.2
|
||||
},
|
||||
"pickup": {
|
||||
"gain": 0.5,
|
||||
"loop": false,
|
||||
"m4a": "audio/sfx-pickup.m4a",
|
||||
"ogg": "audio/sfx-pickup.ogg",
|
||||
"seconds": 0.5
|
||||
}
|
||||
}
|
||||
},
|
||||
"matcaps": {
|
||||
"tissue_wet": {
|
||||
"url": "gen/matcap_tissue_wet.webp"
|
||||
}
|
||||
},
|
||||
"models": {},
|
||||
"textures": {
|
||||
"wall_esophagus_a": {
|
||||
"normal": "gen/wall_esophagus_a_n.webp",
|
||||
"tile": [
|
||||
4,
|
||||
16
|
||||
],
|
||||
"url": "gen/wall_esophagus_a.webp"
|
||||
},
|
||||
"wall_esophagus_b": {
|
||||
"normal": "gen/wall_esophagus_b_n.webp",
|
||||
"tile": [
|
||||
3,
|
||||
12
|
||||
],
|
||||
"url": "gen/wall_esophagus_b.webp"
|
||||
},
|
||||
"wall_smallint_a": {
|
||||
"normal": "gen/wall_smallint_a_n.webp",
|
||||
"tile": [
|
||||
5,
|
||||
14
|
||||
],
|
||||
"url": "gen/wall_smallint_a.webp"
|
||||
},
|
||||
"wall_smallint_b": {
|
||||
"normal": "gen/wall_smallint_b_n.webp",
|
||||
"tile": [
|
||||
6,
|
||||
10
|
||||
],
|
||||
"url": "gen/wall_smallint_b.webp"
|
||||
},
|
||||
"wall_stomach_a": {
|
||||
"normal": "gen/wall_stomach_a_n.webp",
|
||||
"tile": [
|
||||
4,
|
||||
18
|
||||
],
|
||||
"url": "gen/wall_stomach_a.webp"
|
||||
},
|
||||
"wall_stomach_b": {
|
||||
"normal": "gen/wall_stomach_b_n.webp",
|
||||
"tile": [
|
||||
3,
|
||||
22
|
||||
],
|
||||
"url": "gen/wall_stomach_b.webp"
|
||||
}
|
||||
}
|
||||
}
|
||||
231
web/dev/laneD_texview.html
Normal file
@ -0,0 +1,231 @@
|
||||
<!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>
|
||||
@ -36,6 +36,18 @@ export async function createAssets({ flags = {}, renderer = null } = {}) {
|
||||
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) {
|
||||
@ -84,7 +96,14 @@ export async function createAssets({ flags = {}, renderer = null } = {}) {
|
||||
const cat = manifest?.[category];
|
||||
if (!cat) return null;
|
||||
if (category === 'audio') return null; // audio is nested; use audioUrl()
|
||||
return cat[name] ?? null;
|
||||
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];
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||