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

14 KiB
Raw Blame History

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.

  • Tilingseam_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(): agreedget() 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:

// 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:

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:

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.