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>
228 lines
14 KiB
Markdown
228 lines
14 KiB
Markdown
# 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.
|