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

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

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

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

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

228 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.