[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>
This commit is contained in:
jing 2026-07-16 14:17:17 +10:00
parent 689c478a7f
commit efea7d98f3
44 changed files with 1001 additions and 25 deletions

227
docs/LANES/LANE_D_NOTES.md Normal file
View 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.09.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.717.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.180.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.50.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.

View 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.09.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.717.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.871.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.180.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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

48
pipeline/batch_audio.json Normal file
View 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
}
}

View 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"
}
}

View File

@ -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():

View File

@ -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 23 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 200900 band, breathing at 0.25 Hz
air = bandpass(noise(dur, r, "pink"), 200, 900) * 0.30
# cavern air — pink noise in the 200900 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
816 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), "40Hz16kHz log · 80dB", fill=(90, 110, 120))
p = os.path.join(SHOTS, "audio_spectrograms.png")
sheet.save(p, "PNG")
return p

View File

@ -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
View 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()

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

99
web/assets/manifest.json Normal file
View 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
View 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>

View File

@ -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];
},
/**