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