diff --git a/.gitignore b/.gitignore index 4047cd0..ed2c728 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ pipeline/.genaudio/ pipeline/_normalized/ docs/shots/**/*.mov docs/shots/**/*.mp4 + +# python bytecode from pipeline/ scripts (Lane A, round 2) +__pycache__/ diff --git a/docs/LANES/LANE_A_NOTES.md b/docs/LANES/LANE_A_NOTES.md index bacc4e9..0eebfd3 100644 --- a/docs/LANES/LANE_A_NOTES.md +++ b/docs/LANES/LANE_A_NOTES.md @@ -60,6 +60,52 @@ consistent with round 1's budget headroom. fps still not claimed, same reason as - **Slug-map deletion** (ruling #3): waiting on D's rename ping, as sequenced. The map is already down to the two real mismatches (`smallint`, `colon`). +## Round 2, part 3 — I built the thing I proposed, and it corrected me four times + +The shape read below was written from reading the code. Then I prototyped it, and the +prototype disagreed with me on almost every cost I'd estimated. Everything in this section is +**flyable now**: `web/dev/laneA_world.html?dbg=1&assets=1&lvl=swept`. + +**What the prototype proved (all measured, worst frame over the whole fixture):** + +| claim I made in the shape read | what building it showed | +|---|---| +| "radial resolution must scale with radius" | **Wrong.** 64 segments is plenty at radius 70 — deviation from a true circle is 0.05u. The arena needed it because it displaces with 3D fbm noise; the tube's wave is smooth in theta and doesn't care. No change made. | +| "cap geometry at the span ends" | **Wrong, and it was a thinko.** The canal is continuous — cardia → sea → pylorus is one pipe. A room is a fat part of it. Nothing to cap. Only the fundus dome needs caps, and it stays an icosphere. | +| "modest streaming cost" | **Right, and cheaper than I feared:** 9 draws / 98k tris worst-frame for a radius-70 cathedral, vs 8 / 74k for the L2 corridor. Budget is 150 draws / 500k tris. No leak across a full sweep. | +| "one schema flag" | **Right.** `segments[].mode: "open"` is the entire ask. Additive: a level without it behaves exactly as today. | + +**The two real costs — neither of which I predicted:** + +1. **D's `tile` semantics don't survive a radius change, and that's now fixed.** `tile[0]` is + "repeats around theta" — a *count*, and a count is only a texel size if radius never + changes. Measured: stomach `[4, 18]` is square at radius 10 and a **6.1:1 smear at radius + 70**. The wall shader now derives the count from each ring's own arc length (`aRadius` + attribute + `uThetaSpan`), so a texel is the same size in a radius-12 cardia and a radius-70 + sea. **D's authored numbers keep their exact meaning at their reference radius** — nothing + to re-author. Then that fix laid a permanent seam down the canal, because a fractional + repeat count can't wrap: rounded to an integer, seam gone. Not a no-op on corridors — L2's + hiatus takes 3 repeats where it took 4, which is the texel size correctly holding still as + the pipe narrows. Eyeballed: `round2_L2_hiatus_retuned.png`. +2. **My own pinch guard was wrong, and it would have blocked C.** The fixture scored 1.30 and + "failed" the >1.5 law. It wasn't bent: `stats()` divided the tightest turn *anywhere* by the + widest radius *anywhere*, and those were **996 units apart** — a bend in the radius-13 + pylorus against the radius of the sea. Locally the worst point is 6.4. Fixed: `pinchRatio` + is now `min over s of turnRadius(s)/radius(s)`. The change only ever loosens (the global + ratio is ≤ the local one by construction), so nothing that passed before can fail now; real + levels gained headroom (**L2 3.32 → 4.16, L3 1.91 → 6.99, L1 2.11 → 6.06**). This mattered: + the guard is what backs my round-1 promise that *C never has to think about curvature*, and + a guard that false-alarms on rooms revokes that promise exactly when C first needs it. + +**The acid sea is in too** (`world/acid.js`, `level.acid: {from, to, height, biome}`) — and +prototyping found the mechanic, not just the mesh. See § → Lane C. + +Known limitations, stated: the **waterline is prototype quality** — the plane meets faceted +ring geometry and the shoreline steps visibly (`round2_acid_sea_shallows.png`, right side); +it wants either a finer plane or a proper intersection strip. The acid **does not drain your +coat yet** — `depthAt(pos)` is the hook and it's Lane B's call what it costs. Nothing drives +`height` but DBG until C's events do. + ## → Lane C (round 2): what shape is the acid sea? **Recommendation: don't grow the sphere — sweep the room along the spline you already @@ -88,8 +134,86 @@ schema v3 early round 3, implemented by me the same round, before you author the proper. Sketch: `docs/shots/laneA/round2_stomach_arena_sketch.svg`. `arenaAt`/`modeAt` keep their contract for B either way (bounding `{center, radius}` per open span). +### → Lane C, part 2: the acid height is a design dial, and it's the best thing I found + +Fly it: `?lvl=swept`. `level.acid: { from, to, height, biome }` puts a flat emissive `#c8ff3a` +plane across an open span. It is **level, not tube-following** — that's the whole mechanic: +the wall's radius wobbles (48→73 in my fixture), so the *floor* rises and falls 25 units +across the sea, and a flat surface therefore leaves some floor dry and drowns the rest. +**Your mucus shallows are already in the geometry you authored** — they're just "where the +wall rises above the waterline". Nothing to place, no lanes, no volumes. + +And `height` is one number that tunes the whole level. Measured on the fixture: + +| `acid.height` | share of the sea floor that is dry shallow | +|---|---| +| −18 | **0%** — no shallows, the sea is uniformly lethal | +| −55 | **14%** — sparse islands; "position is the resource" | +| −62 | **48%** — an archipelago, more shore than sea | + +So your L3 identity line ("ambient pH drains the coat, position IS the resource") comes out of +a single scalar, and **rising acid literally drowns the shallows** — that's your escalation, +free, and it's the same `height` your event pump would drive. `world.acid.set(y, dt)` eases, +`set(y, 0)` snaps. + +Two things I got wrong that you should know before authoring: +- **I assumed the canal's own bends would make the sea deep and shallow.** They don't — the + centreline stays within ±1 unit of level across 700 units in this seed. The shallows come + entirely from *radius wobble*. If you want depth to vary by anatomy (greater curvature sinks, + lesser rises) you must author vertical bends deliberately; don't assume curviness gives them. +- **L3's numbers disagree with each other.** `arenas[1].radius: 180` is a 36cm-wide stomach — + 2.4× life size by your own "1 unit : 1 mm" note — while the `Body` segment says radius 45, + i.e. 9cm, too small. They were never meant to be the same shape, which is exactly the + ambiguity the sphere-vs-sweep question is about. My fixture uses **70** (~14cm, a real full + stomach, still a cathedral beside a ~2u ship). If the sea must be 180, say so — it's one + number and I'll re-measure. + ## → Lane D (round 2) +**I generated four textures in your lane. They're yours: ratify, retune or bin them.** Why I +did rather than asked: the six-biome contact sheet could only judge 3 of 6 biomes for real — +oral and large_intestine had no pack, and a tint cannot be ratified against a procedural +stand-in. `wall_oral_a/b` + `wall_colon_a/b` + normals, on MODELBEAST flux_local, **$0.00**, +your pipeline unchanged, your `derive_maps` normalisation untouched. Seams 6.35/9.30/14.06/6.37 +→ **1.01/1.08/0.74/1.36** (your "indistinguishable" band; the shipped pack's weakest is 2.39). +All six biomes are now textured and eyeballed (`round2_tint_*.png`). + +1. **A provenance defect, found and fixed — please check my call.** `batch_textures.json` + recorded, for **4 of 6 walls**, the prompt from the framing experiment you tried and + *rejected* — not the prompt that made the shipped pack. `record()` only runs when you + generate, so reverting a prompt while keeping its image (exactly what you correctly did: + "four winners kept untouched rather than churned") drifts the record silently, and your own + docstring is what it breaks: *"regeneration must always be possible."* Proved rather than + guessed — regenerated `esophagus_a` at seed 1101 under both prompts: yours-in-code gives the + shipped ribbed drama, the recorded one gives the bland wood-grain you described rejecting. + Evidence: `round2_provenance_probe.png`. I re-pointed the four records at the code's prompt + (**images untouched — they were never wrong**), and added + `gen_textures.py --verify-provenance` + a qa gate so it can't rot again. If you'd rather the + *records* were right and the images regenerated, that's your call and it's one command. +2. **A rule for the prompt kit, if you want it.** `oral_a` took three subjects and the reason + generalises yours: v1 said "tongue" and FLUX drew a perfect SEM **dome** — *naming an ORGAN + summons its silhouette*, which is your "one urchin of villi" wearing a field's clothing, and + it's why the edge-to-edge clause alone couldn't save it. v2 named the tissue but papillae are + 3D projections, so perspective gave it a vanishing point that tiles into starbursts. v3 asked + for squamous cell pavement — **flat by construction**. So: *prompt the tissue's SURFACE, not + its FEATURES.* Sheet: `round2_oral_prompt_evolution.png`. +3. **ART_BIBLE has no oral entry** (§FLUX prompt kit lists esophageal/stomach/small + intestine/colon). `colon_a` is your bible line verbatim; the other three subjects are new + text I wrote — a gap I filled, not a spec I followed. If you like them, they want folding + into ART_BIBLE properly, which is your amendment to make, not mine. +4. **`tile[0]` is now a density, not a count** — see part 3 above. Your authored numbers keep + their exact meaning at each biome's reference radius; the shader just re-derives the count + from the local arc length so rooms stop smearing 6:1. **No re-authoring needed**, and + nothing about `[repeats_around_theta, units_of_s_per_repeat]` changes as an interface. +5. **`appendix` wears your colon pack** at a gold tint (`TEXTURE_SHARE`, world/index.js) — it + is anatomically colon tissue, so it's your "one texture, two biomes" law for zero bytes. + It works (`round2_tint_appendix.png`). It also means **the appendix needs no pack** — spend + the round on heroes instead. +6. Your **box checkout is stale**: `~/Documents/guts` on m3ultra sits at `dade036` with the + round-1 pipeline edits uncommitted. I diffed all three files against local HEAD — byte + identical, **nothing of yours is stranded** — but it'll bite you the moment you pull. I + worked in `~/laneA_gen/` and touched nothing of yours. + - **Your measured detail curve changed — deliberately, and it's the law's fault, not your measurement's.** `(0.35 + d·0.95)·0.9` was right on the raw framebuffer; under `` its pedestal alone displays as ~60% grey. Now `0.12 + d·0.60` @@ -102,9 +226,24 @@ their contract for B either way (bounding `{center, radius}` per open span). ## → Lane B (round 2) -- **Nothing gameplay-moving changed this half.** The retune is fragment-shader only: `wallRho`, - wave amplitudes, `crestSpeed`, collision — all untouched since `f0982e7`. Your speed-lock - and envelope numbers stand. +**Still nothing gameplay-moving: `wallRho`, wave amps, `crestSpeed` and `collide()` are +untouched since `f0982e7`. Your envelope and speed-lock numbers stand.** Three things exist now +that are yours to use or ignore — all additive, none of them fire on a level that doesn't opt in: + +- **`modeAt(s)` can now return `'arena'` in a tube** (an "open" span — a swept room, see § → C). + If you branch on `modeAt`, that's already the flag: 6DOF on, disc clamp off. **`collide()` is + exact there** — it's the same tube path you already trust, with a big radius — so you do not + need a special case. That's the point of the whole proposal. +- **`arenaAt(s)` gained `swept: true`** for open spans, and returns the LOCAL sphere (centreline + centre, `wallRho(s)` radius) rather than one static room, because a swept room has no single + centre. Authored rooms are unchanged and now say `swept: false`. **Don't clamp to the swept + sphere** — `collide()` is better; it's there for cheap "how much room is there" questions. +- **`world.acid`** (null unless the level has one). `acid.depthAt(pos)` returns units submerged, + `>0` means you're in it. **What that costs is yours** — I've deliberately not made it drain + the coat, because the coat is your system. C's L3 identity is "ambient pH drains the coat, + position is the resource", so the shape of the answer is probably `depthAt > 0 ⇒ apply + `biomeAt(s).coatDrain``, with the mucus shallows falling out for free (they're just where the + wall is above the waterline). Tell me what you need and I'll shape the hook to it. - The crest sheen is dimmer (0.10 → 0.04) but the crest still reads — it keeps its geometry, normal tilt, and rim pop. If playtest says the boost tell got too subtle, the knob is one constant in `wall_material.js` and I'll trade sheen against rim gain, not against the law. @@ -116,6 +255,25 @@ their contract for B either way (bounding `{center, radius}` per open span). ## → Lane F (round 2) +**Four things need your ruling — I made a call on each rather than block, all reversible:** + +1. **A wrote textures into D's lane** (oral + colon, $0, D's pipeline unchanged) because the + tint eyeball was unratifiable without them. Argued in § → Lane D. If lanes generating into + each other's lanes is not okay even with sign-off-on-arrival, say so and I'll take the + pattern back, not the pixels. +2. **qa.sh gained gate 5b** (`texture provenance`, stdlib, no GPU) after finding 4 of 6 walls + recorded a prompt that never shipped. It's your file and a 6-line addition — keep or drop. + The defect it guards is real and proved (`round2_provenance_probe.png`). +3. **The pinch guard changed metric** (global min/max → local `min over s`) because the old one + false-failed the swept room by comparing two points 996 units apart. It only ever loosens — + nothing that passed can now fail — but the round-1 numbers you've quoted move: **L2 3.32 → + 4.16, L3 1.91 → 6.99**. If those numbers are load-bearing anywhere, they're the same geometry + measured correctly, not a regression. +4. **Contract additions, all optional and additive** — for TECH if you want them frozen: + `segments[].mode: "open"` (schema, C's to spec properly), `level.acid {from,to,height,biome}`, + `world.acid` (+`set`/`depthAt`), `arenaAt().swept`. A level using none of them behaves exactly + as it does today; **C can ignore the entire proposal and nothing changes.** + - Ruling #5 executed as above; `web/dev/` now holds D's texview and my world harness. - Housekeeping: `launch.json` gained `guts-a` on **8145** (8140/8141 were other sessions'). Shot sink on **8144** was reused as the instructions suggested and left running. @@ -125,10 +283,22 @@ their contract for B either way (bounding `{center, radius}` per open span). ### Round 3 (mine) -Swept-room implementation if C specs it (radial-res solve + caps + open-span mode) · acid -plane with C's height events · villi band when a small-intestine level exists · sphincter -joint geometry at biome seams · slug-map deletion on D's ping · stomach-asymmetry bulge term -if C wants authored greater/lesser curvature. +Harden the swept room **if C takes it** (waterline quality is the real work; the mode itself is +done) · wire `acid.height` to C's events · villi band when a small-intestine level exists · +sphincter joint geometry at biome seams · slug-map deletion on D's ping · stomach-asymmetry +bulge term if C wants authored greater/lesser curvature. + +**One proposal I did NOT build, because it's a mechanic and mechanics aren't mine:** the +`large_intestine` tint **cannot be ratified and I'm not claiming it is**. ART_BIBLE says "dark +swamp, scanner-light gameplay" and `biomes.js` says "darkness is the mechanic here; scanner +light is the safety bubble" — but GUTS has **no real-time lights by law**, so the scanner light +doesn't exist, and a contact sheet of that biome is a dark green murk by design +(`round2_tint_large_intestine.png` — the colon texture is loaded and correct, you just can't +see it). Cheapest honest answer, and it's ~5 lines in my wall: the fog term already fades to +void with distance, so a **near-field inverse of it is a visibility bubble** that travels with +the camera and costs nothing. That would make the biome judgeable and give L4 its mechanic. It +needs someone to own "how big is the bubble, and does it shrink" — that's C/B. Ask and I'll +build it. --- diff --git a/docs/LANES/ROUND2_INSTRUCTIONS.md b/docs/LANES/ROUND2_INSTRUCTIONS.md index ae40a8b..94d814b 100644 --- a/docs/LANES/ROUND2_INSTRUCTIONS.md +++ b/docs/LANES/ROUND2_INSTRUCTIONS.md @@ -21,6 +21,15 @@ > wall shader retuned for the colorspace law (see LANE_A_NOTES §Round 2); shape read + sketch > in NOTES §→ Lane C; harness moved to `web/dev/laneA_world.html` per ruling #5. Only > round-3 items remain for A.] +> **[A, later still — went past the list; read LANE_A_NOTES §Round 2 part 3 before resuming: +> C — the swept room + acid sea are FLYABLE (`?lvl=swept`), and `acid.height` turns out to be +> a one-number dial for how much of the sea is survivable. Your shape decision is now a +> look-at, not a read. **D — all six biomes are textured** (A generated oral+colon on +> MODELBEAST, $0, your pipeline, yours to ratify or bin), and **4 of 6 walls had provenance +> recording a prompt that never shipped** — fixed + gated, please check the call. **B — +> nothing of yours moved**; `world.acid.depthAt()` is a hook awaiting your coat rules. +> **F — four rulings requested** (§→ Lane F), incl. qa.sh gate 5b and a pinch-metric change +> that moves quoted numbers (L2 3.32→4.16, L3 1.91→6.99, same geometry, measured correctly).] > - **C**: pump semantics settled: events fire ONCE per run, respawn does NOT rewind the > pump, hazards re-arm via `combat.reset(fromS)`. `boot.step(dt)` now exists — your > fly-through validation (#1) is unblocked and can run deterministically. STILL YOURS: diff --git a/docs/progress/a-progress.md b/docs/progress/a-progress.md index 83225db..c725d8c 100644 --- a/docs/progress/a-progress.md +++ b/docs/progress/a-progress.md @@ -24,8 +24,44 @@ session, which closed out the lane's round-2 list: deliberately skipped: the swept room would obsolete it where it matters. - Housekeeping: `guts-a` dev server on 8145 in launch.json; sink 8144 reused. -Still open for A (round 3): swept-room implementation if C specs it, acid plane, villi band, -sphincter geometry, slug-map deletion on D's rename ping. +Then, same session, past the round-2 list — the shape read was written from reading code, so I +built it to find out if it was true. It wasn't, four times: + +- **The texture set is complete** (`3a6dbc3`). Generated `wall_oral_a/b` + `wall_colon_a/b` + + normals on MODELBEAST flux_local, **$0.00**, D's pipeline unchanged — because the tint eyeball + could only judge 3 of 6 biomes against a real pack, and a stand-in can't ratify a tint. Seams + 0.74–1.36 (D's "indistinguishable" band). Appendix wears the colon pack at gold tint — it IS + colon tissue; ART_BIBLE's "one texture, two biomes" law for zero bytes. **oral_a took three + subjects** and taught a rule worth more than the texture: naming an ORGAN summons its + silhouette ("tongue" → a dome), and papillae are 3D projections so perspective tiles into + starbursts — prompt the tissue's SURFACE, not its FEATURES. +- **Found a provenance defect in D's pack**: `batch_textures.json` recorded, for 4 of 6 walls, + the prompt from the framing experiment D *rejected*. `record()` only runs on generation, so + reverting a prompt while keeping its image drifts the record silently — and it breaks the + script's own promise that regeneration is always possible. Proved it by regenerating + esophagus_a at seed 1101 under both prompts (`round2_provenance_probe.png`). Records + re-pointed, `--verify-provenance` + qa gate 5b added so it can't recur. +- **Prototyped the swept room + acid sea** (`?lvl=swept`), and it corrected my own cost + estimates: radial resolution does NOT need to scale with radius (64 segs = 0.05u circle error + at r=70), caps were a thinko (the canal is continuous — a room is a fat part of it), and it's + cheap: **9 draws / 98k tris** for a radius-70 cathedral vs 8/74k for the L2 corridor, no leak. + The two real costs were ones I hadn't predicted: **D's `tile[0]` is a count, not a density** + (measured 6.1:1 smear at r=70 — now derived from each ring's arc length, D's numbers keeping + their meaning), and **my own pinch guard was wrong** — it divided the tightest turn anywhere + by the widest radius anywhere, 996 units apart, and false-failed a valid room at 1.30 (locally + 6.4). Now local; only ever loosens; real levels gained headroom (L2 3.32→4.16, L3 1.91→6.99). +- **The acid sea's height is a design dial** and it's the best thing I found: one scalar tunes + how much of the sea floor is dry shallow (−18 → 0%, −55 → 14%, −62 → 48%), and C's "position + is the resource" falls out of radius wobble they already author. Rising acid drowns the + shallows = free escalation. Also disproved my own assumption: the centreline stays level + (±1u over 700u), so shallows come from radius wobble, NOT from the canal's bends. + +Still open for A (round 3): harden the swept room **if C takes it** (the waterline is the real +work), wire acid height to C's events, villi band, sphincter geometry, slug-map deletion on D's +rename ping. Not claiming: **the large_intestine tint is unratifiable** until someone owns the +"scanner light" its design depends on — GUTS has no lights by law, so that biome is murk by +construction. A near-field inverse of the fog term would be ~5 lines; it needs C/B to own the +mechanic first. ## 2026-07-16 · Round 1 — the canal, v0 diff --git a/docs/shots/laneA/round2_L2_hiatus_retuned.png b/docs/shots/laneA/round2_L2_hiatus_retuned.png index 5c71657..2b697a0 100644 Binary files a/docs/shots/laneA/round2_L2_hiatus_retuned.png and b/docs/shots/laneA/round2_L2_hiatus_retuned.png differ diff --git a/docs/shots/laneA/round2_acid_sea_shallows.png b/docs/shots/laneA/round2_acid_sea_shallows.png new file mode 100644 index 0000000..9d4b56a Binary files /dev/null and b/docs/shots/laneA/round2_acid_sea_shallows.png differ diff --git a/docs/shots/laneA/round2_swept_acid_sea.png b/docs/shots/laneA/round2_swept_acid_sea.png new file mode 100644 index 0000000..050bcdb Binary files /dev/null and b/docs/shots/laneA/round2_swept_acid_sea.png differ diff --git a/web/dev/laneA_world.html b/web/dev/laneA_world.html index 252ce9b..79ba51b 100644 --- a/web/dev/laneA_world.html +++ b/web/dev/laneA_world.html @@ -41,7 +41,7 @@ import * as THREE from 'three'; import { createRng } from '../js/core/rng.js'; import { createWorld } from '../js/world/index.js'; import { createFlyCam } from '../js/world/flycam.js'; -import { FIXTURE, SIX_BIOMES } from '../js/world/_fixture.js'; +import { FIXTURE, SIX_BIOMES, SWEPT_STOMACH } from '../js/world/_fixture.js'; const p = new URLSearchParams(location.search); const on = (k) => p.has(k) && p.get(k) !== '0'; @@ -51,6 +51,8 @@ const num = (k, d) => (p.has(k) ? parseFloat(p.get(k)) : d); let level = FIXTURE; if (p.get('lvl') === 'biomes') { level = SIX_BIOMES; // the tint contact sheet — one segment per registry biome +} else if (p.get('lvl') === 'swept') { + level = SWEPT_STOMACH; // the swept-room + acid-sea prototype (→ Lane C) } else if (p.has('lvl')) { try { const mod = await import('../js/levels/index.js'); diff --git a/web/js/world/_fixture.js b/web/js/world/_fixture.js index 3f83e0a..f449b16 100644 --- a/web/js/world/_fixture.js +++ b/web/js/world/_fixture.js @@ -16,6 +16,49 @@ export const FIXTURE = { events: [], }; +// The swept-room prototype (?lvl=swept in the harness) — Lane A's round-2 proposal to Lane C +// made flyable, so the shape argument is settled by looking instead of by reading. +// +// This is NOT L3 and does not touch it: C owns levels. It is the same anatomy (cardia -> +// fundus -> body/acid sea -> antrum -> pylorus) authored the way the proposal says a room +// should be authored — as the canal at room radii, `mode: "open"` where the disc clamp comes +// off — so C can fly it, hate it, and say why. +// +// Two numbers C should argue with: +// - **radius 70 for the sea**, not L3's arena radius 180. 180 units = a 36cm-wide stomach, +// which is 2.4x life size even by L3's own "1 unit : 1 mm" note; 70 gives ~14cm, which is +// a real full stomach and still a cathedral next to a ~2u ship. If the sea must be 180, +// say so and I'll measure that instead — it is one number. +// - **wave.amp 4.0 in the sea** (schema v2, ruling #8). The biome default 0.7 is sized for a +// radius-10 pipe and is literally invisible on a radius-70 wall. A room this size needs its +// churn authored, which is the per-segment override earning its keep. +export const SWEPT_STOMACH = { + id: 'A_SWEPT_STOMACH', + name: 'Lane A swept-room prototype (stomach)', + seed: 20260716, + segments: [ + { biome: 'esophagus', name: 'Cardia', length: 200, radius: { base: 12, wobble: 0.2 }, + curviness: 0.3, flow: 8 }, + // The flare. A segment join blends radius over ~±25u (round 2), so 12 -> 70 across one + // join would be a 58u climb in 50u of s: a funnel, not an anatomy. Give the flare its own + // short segment and it reads as the fundus opening out. + { biome: 'stomach', name: 'Fundus flare', length: 220, radius: { base: 38, wobble: 0.22 }, + curviness: 0.25, flow: 4, mode: 'open', wave: { amp: 2.2 } }, + { biome: 'stomach', name: 'Body (the acid sea)', length: 700, radius: { base: 70, wobble: 0.3 }, + curviness: 0.45, flow: 3, mode: 'open', wave: { amp: 4.0 } }, + { biome: 'stomach', name: 'Antrum', length: 380, radius: { base: 26, wobble: 0.25 }, + curviness: 0.35, flow: 6, mode: 'open', wave: { amp: 1.4 } }, + { biome: 'stomach', name: 'Pylorus', length: 160, radius: { base: 13, wobble: 0.15 }, + curviness: 0.2, flow: 10 }, + ], + // The fundus dome stays an authored icosphere: it is a dead-end bulge OFF the centreline, + // which a sweep cannot express. Both kinds of room in one level, on purpose — that's the + // proposal's actual claim, not "spheres are bad". + arenas: [{ at: 300, radius: 46, biome: 'stomach' }], + acid: { from: 420, to: 1120, height: -18, biome: 'stomach' }, + events: [], +}; + // The tint contact sheet (?lvl=biomes in the harness): one straight-ish segment per registry // biome, in registry order, generous radius so the wall — not the curve — is what you judge. // Exists for the eyeball law: every biome tint gets re-checked against D's current pack in one diff --git a/web/js/world/acid.js b/web/js/world/acid.js new file mode 100644 index 0000000..72a2236 --- /dev/null +++ b/web/js/world/acid.js @@ -0,0 +1,134 @@ +// world/acid.js (Lane A) — the acid sea's surface. GDD §3: the stomach's animated `#c8ff3a` +// level, the thing that makes the acid sea a SEA and not just a big room. +// +// PROTOTYPE, round 2. It renders and it animates; it does not yet drain your coat, because the +// height it should sit at is Lane C's to drive (`level:event` → a height, per their L3 design) +// and Lane B owns what touching it costs. Both hooks are here and unclaimed: +// world.acid.height read it; it's the y of the surface in world space +// world.acid.set(y, dt) drive it; C's event pump or DBG can call this +// world.acid.depthAt(pos) >0 means submerged, in units. B: this is your drain test. +// +// Why a horizontal plane and not a shell: acid is a fluid, so it is level — it does NOT follow +// the tube. That is the entire point of it as a mechanic. The canal dives and climbs through +// its own bends (the greater curvature sinks, the lesser rises), so a single flat plane at a +// fixed y makes some of the room lethal and some of it safe **for free, out of the geometry +// C already authored** — no lanes, no volumes, no authoring. Fly low and you swim; hug the +// ceiling of the sea and you don't. The mucus shallows are then just "where the wall rises +// above the surface", which is a fact about the spline, not a thing anyone has to place. +// +// Colorspace: `#include ` at the end of main() is LAW (TECH §Shader law). + +import * as THREE from 'three'; + +const ACID = 0xc8ff3a; // ART_BIBLE §Colour: acid / corrosive zones + +/** + * @param {object} spec `level.acid`: { from, to, height, biome } + * @param {object} spline + * @param {object} opts { fog, void: hex } — matched to the room's material by index.js + */ +export function createAcid({ spec, spline, fog = 0.02, voidColor = 0x120801, quality = 'high' }) { + // Size the plane to the span it covers, plus the widest wall in it, so the surface always + // reaches the rock. Sampling the radius rather than trusting `radius.base`: wobble is real + // and a plane that stops short of the wall shows a seam of void at the waterline. + let halfWidth = 0; + const step = 4; + for (let s = spec.from; s <= spec.to; s += step) halfWidth = Math.max(halfWidth, spline.radiusAt(s) * 1.35); + const pts = []; + for (let s = spec.from; s <= spec.to; s += step) { + const f = spline.frameAt(s); + pts.push(new THREE.Vector3(f.pos.x, f.pos.y, f.pos.z)); + } + const box = new THREE.Box3().setFromPoints(pts).expandByScalar(halfWidth); + + // A plane in XZ. Segments are for the vertex ripple, not for shape: 1 unit per segment is + // ample for a wave whose wavelength is ~14 units, and it keeps a 300x300u sea under 2k tris. + const w = box.max.x - box.min.x, d = box.max.z - box.min.z; + const seg = quality === 'low' ? 24 : Math.min(96, Math.max(16, Math.round(Math.max(w, d) / 6))); + const geo = new THREE.PlaneGeometry(w, d, seg, seg); + geo.rotateX(-Math.PI / 2); // PlaneGeometry is XY; we want a floor + + const mat = new THREE.ShaderMaterial({ + transparent: true, + side: THREE.DoubleSide, // you will be under it, and that must read + depthWrite: false, // it's a fluid surface, not an occluder + uniforms: { + uTime: { value: 0 }, + uAcid: { value: new THREE.Color(ACID) }, + uVoid: { value: new THREE.Color(voidColor) }, + uFog: { value: fog }, + uOpacity: { value: 0.62 }, + }, + vertexShader: /* glsl */` + uniform float uTime; + varying vec2 vP; varying vec3 vView; varying float vRipple; + void main() { + vP = position.xz; + // Two crossing swells + a fine chop. Cheap, and it never repeats visibly because the + // three periods are mutually irrational-ish. + float a = sin(position.x * 0.09 + uTime * 0.8); + float b = sin(position.z * 0.13 - uTime * 0.6); + float c = sin((position.x + position.z) * 0.31 + uTime * 1.7); + vRipple = a * 0.5 + b * 0.4 + c * 0.18; + vec3 p = position + vec3(0.0, vRipple * 0.55, 0.0); + vec4 mv = modelViewMatrix * vec4(p, 1.0); + vView = -mv.xyz; + gl_Position = projectionMatrix * mv; + }`, + fragmentShader: /* glsl */` + uniform vec3 uAcid, uVoid; + uniform float uFog, uOpacity, uTime; + varying vec2 vP; varying vec3 vView; varying float vRipple; + void main() { + // Caustic-ish banding: the surface is emissive (no lights in GUTS), so the "light" has + // to be in the colour itself. Bright where the swells pinch, dark in the troughs. + float band = 0.5 + 0.5 * sin(vRipple * 3.4 + uTime * 0.9); + float foam = smoothstep(0.72, 1.0, abs(vRipple)); // crests catch a hot rim + vec3 col = uAcid * (0.42 + band * 0.5) + vec3(0.85, 1.0, 0.55) * foam * 0.5; + + // Same exponential fog to the same void as the wall, or the sea would float free of + // the room it is in — the far shore must dissolve exactly like the far wall does. + float dist = length(vView); + float f = 1.0 - exp(-uFog * dist * 0.55); + col = mix(col, uVoid, f); + + // Grazing angles read as a slab of fluid; straight down you see through it. Without + // this it looks like a sheet of paper laid in the room. + float graze = 1.0 - abs(normalize(vView).y); + float alpha = uOpacity * mix(0.55, 1.0, graze) * (1.0 - f * 0.85); + gl_FragColor = vec4(col, alpha); + #include + }`, + }); + + const mesh = new THREE.Mesh(geo, mat); + mesh.position.set((box.min.x + box.max.x) / 2, spec.height ?? 0, (box.min.z + box.max.z) / 2); + mesh.renderOrder = 2; // after the walls: it's transparent + mesh.name = `acid ${spec.from}..${spec.to}`; + + let time = 0; + return { + spec, + mesh, + get height() { return mesh.position.y; }, + + /** C's event pump (or DBG) drives this. dt > 0 eases; dt = 0 snaps. */ + set(y, dt = 0) { + mesh.position.y = dt > 0 ? THREE.MathUtils.damp(mesh.position.y, y, 1.6, dt) : y; + }, + + /** B: >0 means submerged, in units. Outside the acid's s-span it is always <= 0. */ + depthAt(pos) { + const s = spline.project(pos).s; + if (s < spec.from || s > spec.to) return -1; + return mesh.position.y - pos.y; + }, + + update(dt) { + time += dt; + mat.uniforms.uTime.value = time; + }, + + dispose() { geo.dispose(); mat.dispose(); }, + }; +} diff --git a/web/js/world/arena.js b/web/js/world/arena.js index e8606cf..78e7223 100644 --- a/web/js/world/arena.js +++ b/web/js/world/arena.js @@ -58,6 +58,11 @@ export function createArena({ spec, spline, material, rng, quality = 'high', wav const aPhase = new Float32Array(n); const aK = new Float32Array(n); const aWaveA = new Float32Array(n); + // The shell wears the same wall material as the tube, so it owes the same attributes. Its + // uv is spherical, so "radius around theta" is the radius of the ring this vertex sits on + // (shrinking to 0 at the poles), not the sphere's radius — that is what keeps the texel + // density matched to the tube's and the pole from smearing worse than it already does. + const aRadius = new Float32Array(n); // The churn wave crosses the room along the canal's own axis, slowly enough to read as a // room breathing rather than a corridor's transit wave. @@ -85,6 +90,7 @@ export function createArena({ spec, spline, material, rng, quality = 'high', wav aPhase[i] = k * (spec.at + along); aK[i] = k; aWaveA[i] = waveAmp; + aRadius[i] = Math.hypot(p.dot(ref), p.dot(bin)); // distance from the room's own axis } // Seam repair: uv.x comes from atan2, so a triangle straddling the -X axis interpolates it @@ -105,6 +111,7 @@ export function createArena({ spec, spline, material, rng, quality = 'high', wav g.setAttribute('aPhase', new THREE.BufferAttribute(aPhase, 1)); g.setAttribute('aK', new THREE.BufferAttribute(aK, 1)); g.setAttribute('aWaveA', new THREE.BufferAttribute(aWaveA, 1)); + g.setAttribute('aRadius', new THREE.BufferAttribute(aRadius, 1)); g.computeBoundingSphere(); geo.dispose(); // the source icosphere was scaffolding diff --git a/web/js/world/index.js b/web/js/world/index.js index 5f72665..d86570c 100644 --- a/web/js/world/index.js +++ b/web/js/world/index.js @@ -17,6 +17,7 @@ import { biome as biomeOf } from './biomes.js'; import { createWallMaterial } from './wall_material.js'; import { createTube } from './tube.js'; import { createArena } from './arena.js'; +import { createAcid } from './acid.js'; const SKIN = 0.6; // collision safety margin (units) — matches the stub @@ -155,12 +156,38 @@ export async function createWorld(levelData, { rng, quality = 'high', assets = n return a; }); + // The acid sea (round-2 prototype). Optional and absent by default: no `level.acid`, no + // plane, no cost — same law as assets. Fog/void come from the room's own biome so the far + // shore dissolves exactly like the far wall. + const acidSpec = levelData.acid || null; + let acid = null; + if (acidSpec) { + // NB: spline.segmentAt, not the biomeIdAt below — that one is declared later and this + // would be reading it from its temporal dead zone. + const b = biomeOf(acidSpec.biome ?? spline.segmentAt(acidSpec.from).biome); + acid = createAcid({ + spec: acidSpec, spline, quality, + fog: Math.min(b.fog, arenaFog(spline.radiusAt((acidSpec.from + acidSpec.to) / 2))), + voidColor: b.palette.void, + }); + group.add(acid.mesh); + } + tube.prime(0); // --- contract ------------------------------------------------------------------------- let time = 0; const biomeIdAt = (s) => spline.segmentAt(s).biome; + + // --- open spans: the swept room (round-2 PROTOTYPE, Lane A -> Lane C) ------------------- + // The proposal in LANE_A_NOTES §-> Lane C: a room is not a different primitive, it is the + // canal at room radii with the disc clamp taken off. `segments[].mode: "open"` is the whole + // schema ask. Everything else already works: the tube extrudes any radius, project()/wallRho + // already collide against a varying one, and the uv stays (theta, s) so D's pack tiles with + // no pole pinch. Deliberately additive — a level without `mode` behaves exactly as before, + // so C can ignore this entirely and nothing changes. + const isOpenAt = (s) => spline.segmentAt(s).mode === 'open'; // Prefers the per-segment override (schema v2) over the biome default, via the blended // schedule — so wallRho tracks C's calm hiatus instead of the biome's loudest wave. const waveMaxAt = (s) => spline.waveAmpAt(s) + biomeOf(biomeIdAt(s)).wave.breathe; @@ -228,10 +255,23 @@ export async function createWorld(levelData, { rng, quality = 'high', assets = n return { id: b.id, palette: b.palette, fog: b.fog, flow: spline.paramAt(s, 'flow'), coatDrain: b.coatDrain }; }, - modeAt: (s) => (arenas.some((a) => a.covers(s)) ? 'arena' : 'tube'), + /** 'arena' means "6DOF, no disc clamp" to Lane B — an authored room OR an open span. */ + modeAt: (s) => (arenas.some((a) => a.covers(s)) || isOpenAt(s) ? 'arena' : 'tube'), + + /** + * B: unchanged for authored rooms (a static sphere). For an **open span** there is no one + * sphere — the room is swept — so this returns the LOCAL sphere at s: centred on the + * centreline, radius = the playable wall right there. It slides with you. `swept: true` + * says which you got. Clamping to it is not required and not advised: `collide()` is exact + * in an open span (it is the same tube path you already trust), and this is here for + * cheap "am I near the middle / how much room is there" questions. + */ arenaAt(s) { const a = arenas.find((x) => x.covers(s)); - return a ? { center: a.center, radius: a.radius } : null; + if (a) return { center: a.center, radius: a.radius, swept: false }; + if (!isOpenAt(s)) return null; + const f = spline.frameAt(s); + return { center: new THREE.Vector3(f.pos.x, f.pos.y, f.pos.z), radius: world.wallRho(s), swept: true }; }, /** JS mirror of the vertex shader's wave, exact (0 = trough, 1 = crest). E: pulse the mix. */ @@ -242,10 +282,14 @@ export async function createWorld(levelData, { rng, quality = 'high', assets = n crestSpeed: (s) => spline.crestSpeedAt(s), crestFactor: CREST_FACTOR, + /** The acid sea, or null on a level without one. See world/acid.js for the C/B hooks. */ + acid, + update(dt, playerS = 0) { time += dt; for (const m of owned) m.uniforms.uTime.value = time; tube.update(playerS); + if (acid) acid.update(dt); }, hash: () => spline.hash(), @@ -253,6 +297,7 @@ export async function createWorld(levelData, { rng, quality = 'high', assets = n dispose() { tube.dispose(); + if (acid) { group.remove(acid.mesh); acid.dispose(); } for (const a of arenas) { group.remove(a.mesh); a.dispose(); } for (const m of owned) m.dispose(); // D owns their textures; not ours to free owned.length = 0; diff --git a/web/js/world/spline.js b/web/js/world/spline.js index b3a82e3..c098952 100644 --- a/web/js/world/spline.js +++ b/web/js/world/spline.js @@ -321,7 +321,29 @@ export function buildSpline(level, rng) { return (h >>> 0).toString(16); } - /** Measured geometry health — the pinch guard is the one that bites. */ + /** + * Measured geometry health — the pinch guard is the one that bites. + * + * `pinchRatio` is LOCAL: the worst turnRadius(s)/radius(s) over the canal, i.e. the tightest + * bend measured against the tube's radius AT THAT s. It used to be global — min turn radius + * anywhere ÷ max radius anywhere — which is the same number whenever radius is roughly + * constant (every corridor level: L2 and L3 are unaffected) and is nonsense the moment it + * isn't. Round 2's swept-room prototype scored 1.30 and "failed": it was dividing a bend in + * the radius-13 pylorus by the radius of the sea 996 units away, two places that cannot fold + * into each other. Locally its worst point is 6.4. The old metric could only ever cry wolf, + * never miss a real fold — but a false alarm on the pinch guard is not harmless: the guard is + * what lets Lane C author `curviness` without thinking about curvature (round-1 decision #1), + * and a guard that fails valid rooms takes that guarantee away exactly when C needs it. + * + * The change is provably one-directional — global min turn ÷ global max radius is <= the + * local ratio everywhere by construction — so nothing that passed the round-1 guard can fail + * this one. Real levels gained headroom, they didn't lose it: L2 3.32 -> 4.16, L3 1.91 -> 6.99. + * + * Neither metric has ever covered NON-LOCAL self-intersection — the canal looping back and + * passing through itself far from where it bent. That needs a proximity test over the whole + * centreline, not a curvature test, and no level has come close to needing one. Stated so the + * gap is known rather than assumed away. + */ function stats() { let maxCurvature = 0, sAtMax = 0, maxRadius = 0, minRadius = Infinity; for (let i = 1; i < M - 1; i++) { @@ -333,12 +355,22 @@ export function buildSpline(level, rng) { if (r > maxRadius) maxRadius = r; if (r < minRadius) minRadius = r; } + // The local guard, on the same grid the curvature is measured on. + let pinchRatio = Infinity, sAtPinch = 0; + for (let i = 1; i < M - 1; i++) { + const k = len(sub(at(tanArr, i + 1), at(tanArr, i - 1))) / (2 * DS); + const s = i * DS; + const ratio = (k > 0 ? 1 / k : Infinity) / Math.max(0.001, radiusAt(s)); + if (ratio < pinchRatio) { pinchRatio = ratio; sAtPinch = s; } + } const minTurnRadius = maxCurvature > 0 ? 1 / maxCurvature : Infinity; return { length: L, gridPoints: M, uMax, maxCurvature, sAtMaxCurvature: sAtMax, minTurnRadius, minRadius, maxRadius, - pinchRatio: minTurnRadius / maxRadius, // <1 => tube folds through itself on a bend + pinchRatio, // <1 => the tube folds through itself on a bend + sAtPinch, // where to look when it does + pinchRatioGlobal: minTurnRadius / maxRadius, // the old number, kept for comparison only }; } @@ -402,8 +434,9 @@ async function selfcheck() { } ok('frames: no roll snap (max per-unit normal turn < 0.2 rad)', maxTwist < 0.2, `${maxTwist.toFixed(4)} rad`); - ok('geometry: tube does not pinch (minTurnRadius / maxRadius > 1.5)', st.pinchRatio > 1.5, - `turn R ${st.minTurnRadius.toFixed(1)} vs tube R ${st.maxRadius.toFixed(1)} => ${st.pinchRatio.toFixed(2)}x`); + ok('geometry: tube does not pinch (LOCAL turnRadius(s) / radius(s) > 1.5)', st.pinchRatio > 1.5, + `worst at s=${st.sAtPinch.toFixed(0)} => ${st.pinchRatio.toFixed(2)}x` + + ` (global min/max ratio, the round-1 metric, would say ${st.pinchRatioGlobal.toFixed(2)}x)`); // project() round-trip: place a point at a known (s, theta, rho), recover it let maxSerr = 0, maxTerr = 0, maxRerr = 0; diff --git a/web/js/world/tube.js b/web/js/world/tube.js index 78ba6e8..fe0a2a3 100644 --- a/web/js/world/tube.js +++ b/web/js/world/tube.js @@ -53,6 +53,7 @@ export function createTube({ spline, materialFor, quality = 'high', skipSpans = const aPhase = new Float32Array(n); const aK = new Float32Array(n); const aWaveA = new Float32Array(n); + const aRadius = new Float32Array(n); // the wall shader derives texel density from it let p = 0, q = 0, w = 0; for (let r = 0; r < rings; r++) { @@ -74,7 +75,7 @@ export function createTube({ spline, materialFor, quality = 'high', skipSpans = aTangent[p] = f.tan.x; aTangent[p + 1] = f.tan.y; aTangent[p + 2] = f.tan.z; p += 3; uv[q++] = j / Q.radial; uv[q++] = s; - aPhase[w] = phase; aK[w] = k; aWaveA[w] = waveA; w++; + aPhase[w] = phase; aK[w] = k; aWaveA[w] = waveA; aRadius[w] = f.radius; w++; } } @@ -96,6 +97,7 @@ export function createTube({ spline, materialFor, quality = 'high', skipSpans = geo.setAttribute('aPhase', new THREE.BufferAttribute(aPhase, 1)); geo.setAttribute('aK', new THREE.BufferAttribute(aK, 1)); geo.setAttribute('aWaveA', new THREE.BufferAttribute(aWaveA, 1)); + geo.setAttribute('aRadius', new THREE.BufferAttribute(aRadius, 1)); geo.setIndex(new THREE.BufferAttribute(idx, 1)); geo.computeBoundingSphere(); geo.boundingSphere.radius += 2; // the vertex shader displaces inward; keep culling honest diff --git a/web/js/world/wall_material.js b/web/js/world/wall_material.js index 97da165..79d2678 100644 --- a/web/js/world/wall_material.js +++ b/web/js/world/wall_material.js @@ -12,6 +12,8 @@ // aK float local wavenumber, for the analytic d(pulse)/ds // aWaveA float local peristalsis amplitude. Per-VERTEX, not a uniform, because schema v2 // lets C set `wave.amp` per segment and segments share a biome material. +// aRadius float the ring's radius here. Per-VERTEX for the same reason: one biome material +// now spans radius 12 corridors AND radius 70 rooms (see uThetaSpan below). // // Colorspace: `#include ` at the end of main() is LAW (TECH §Shader law). // three converts THREE.Color inputs to linear but does not convert a raw shader's output back, @@ -42,6 +44,22 @@ export function createWallMaterial({ // Default tiling if D has no `tile` hint: ~square texels for this biome's radius. const fallbackRepeat = [3, 3 / Math.max(4, (2 * Math.PI * radiusHint) / 3)]; const rep = repeat ?? fallbackRepeat; + + // Tiling around theta is a DENSITY, not a count — the fix for rooms. + // D's `tile[0]` is "repeats around", which is a count, and a count is only a texel size if + // the radius never changes. It doesn't: with `mode:"open"` one stomach material now spans a + // radius-12 cardia and a radius-70 sea, and 4 repeats around a 440u circumference is one + // repeat per 110u against 18u along s — a measured 6.1:1 smear (round 2 prototype). So: + // convert D's count into the arc length it implies AT THIS BIOME'S REFERENCE RADIUS, and let + // the shader re-derive the count from each ring's own radius. + // + // Precisely: at the reference radius this is arithmetically identical to before, so **D's + // authored `tile` numbers keep their exact meaning** — but away from it the count deliberately + // moves, because that is the entire fix. L2's hiatus (radius 7 against a reference of ~10) + // now takes 3 repeats where it took 4, and that IS the texel size holding still while the + // pipe narrows. So: not a byte-for-byte no-op on existing corridors, a small and correct + // change to them, eyeballed (docs/shots/laneA/round2_L2_hiatus_retuned.png). + const thetaSpan = (2 * Math.PI * radiusHint) / Math.max(0.001, rep[0]); // The _b layer deliberately runs at a different, non-integer-multiple scale: sampling the // same tiling twice would just reinforce the repeat it's meant to hide. const repB = repeatB ?? [rep[0] / 2.7, rep[1] / 2.7]; @@ -79,6 +97,7 @@ export function createWallMaterial({ uMatcapGain: { value: matcapGain }, uRepeat: { value: new THREE.Vector2(rep[0], rep[1]) }, uRepeatB: { value: new THREE.Vector2(repB[0], repB[1]) }, + uThetaSpan: { value: thetaSpan }, }, vertexShader: /* glsl */` attribute vec3 aInward; @@ -86,11 +105,33 @@ export function createWallMaterial({ attribute float aPhase; attribute float aK; attribute float aWaveA; - uniform float uTime, uBreatheA, uOmega; + attribute float aRadius; + uniform float uTime, uBreatheA, uOmega, uThetaSpan; varying vec2 vUv; varying vec3 vN; varying vec3 vView; varying float vPulse; + varying float vThetaU; varying float vThetaUB; void main() { vUv = uv; + // Texture coordinate around theta, in REPEATS, from this ring's own arc length — so a + // texel is the same size on a radius-12 pipe and a radius-70 room. Computed here, not + // per-pixel, so it interpolates linearly across the quad. + // + // ROUNDED TO AN INTEGER, and that is not fussiness — it is the whole trick. uv.x runs + // 0..1 around the ring, so the wrap is only seamless if the texture completes a WHOLE + // number of repeats; a raw arc-length count is fractional and lays a permanent seam + // down the length of the canal (seen, then fixed — round 2). Rounding costs at most + // half a repeat of density error, which is worst where the count is smallest, hence + // the floor of 2. Where radius is ~constant this is exactly D's authored count, so + // corridors are untouched. Where it climbs, the count steps 4->5->6; each step shears + // the texture across one 0.5u ring, which is invisible next to a 6:1 smear. + float nTheta = max(2.0, floor((6.2831853 * max(0.001, aRadius)) / uThetaSpan + 0.5)); + vThetaU = uv.x * nTheta; + // The _b layer wraps too, so it needs its own WHOLE count — dividing vThetaU by 2.7 + // would re-introduce the exact seam the rounding above just removed. Rounded from its + // own span, floored at 1, and nudged off nTheta so the two layers can't lock into the + // same repeat and reinforce the tiling they exist to hide. + float nB = max(1.0, floor(nTheta / 2.7 + 0.5)); + vThetaUB = uv.x * (nB == nTheta ? max(1.0, nTheta - 1.0) : nB); float phi = aPhase - uOmega * uTime; float sn = max(0.0, sin(phi)); float pulse = sn * sn * sn; // sharp crest, long trough: a muscle, not a sine @@ -113,6 +154,7 @@ export function createWallMaterial({ uniform vec3 uTint, uRim, uVoid; uniform float uFog, uRimPow, uRimGain, uNormalScale, uMatcapGain; uniform vec2 uRepeat, uRepeatB; + varying float vThetaU; varying float vThetaUB; #ifdef USE_DETAIL uniform sampler2D uDetail; #endif @@ -144,7 +186,8 @@ export function createWallMaterial({ #endif void main() { - vec2 duv = vUv * uRepeat; + // .x from the arc-length density (vThetaU), .y from D's units-of-s-per-repeat. + vec2 duv = vec2(vThetaU, vUv.y * uRepeat.y); #ifdef USE_DETAIL // Lane D authors grayscale luminance/AO; the biome tint is applied here, so one @@ -152,7 +195,7 @@ export function createWallMaterial({ float detail = texture2D(uDetail, duv).r; #ifdef USE_DETAIL_B // macro variation: the _b wall at a coarser, non-multiple scale breaks _a's repeat - float db = texture2D(uDetailB, vUv * uRepeatB).r; + float db = texture2D(uDetailB, vec2(vThetaUB, vUv.y * uRepeatB.y)).r; detail = mix(detail, detail * (0.55 + 0.9 * db), 0.6); #endif // D's round-1 curve was (0.35 + d*0.95)*0.9, measured on the raw framebuffer.