[lane A] The swept room + the acid sea, built — and it corrected me four times

The shape read in LANE_A_NOTES was written from reading code, so I built it to
find out if it was true. Flyable: web/dev/laneA_world.html?dbg=1&assets=1&lvl=swept
Nothing here is imposed on anyone: every addition is additive and a level that
opts out behaves exactly as today. C can bin the whole proposal and lose nothing.

WHAT THE PROTOTYPE DISPROVED (my own cost estimates, all four):
- "radial resolution must scale with radius" — wrong. 64 segments is a 0.05u
  circle error at r=70. The arena needs it because it fbm-displaces; the tube's
  wave is smooth in theta. No change made.
- "cap geometry at the span ends" — a thinko. The canal is continuous; a room is
  a fat part of it. Only the fundus dome caps, and it stays an icosphere.
- cost — cheaper than feared: 9 draws / 98k tris worst-frame for a radius-70
  cathedral vs 8 / 74k for the L2 corridor. No leak over a full sweep.
- "one schema flag" — held. `segments[].mode: "open"` is the entire ask.

THE TWO REAL COSTS, NEITHER PREDICTED:
1. D's `tile[0]` is a COUNT, not a density — only a texel size if radius never
   changes. Measured 6.1:1 smear at r=70. The wall now derives the count from
   each ring's own arc length (aRadius attr + uThetaSpan); D's authored numbers
   keep their exact meaning at their reference radius, no re-authoring. That fix
   then laid a seam down the canal (a fractional 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.
2. MY OWN PINCH GUARD WAS WRONG and would have blocked C. The fixture scored
   1.30 and "failed" the >1.5 law; it was never bent. stats() divided the
   tightest turn ANYWHERE by the widest radius ANYWHERE — 996 units apart, a
   bend in the radius-13 pylorus against the radius of the sea. Locally its
   worst point is 6.4. pinchRatio is now min over s of turnRadius(s)/radius(s).
   Provably one-directional (global <= local by construction) so nothing that
   passed can fail; 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 the round-1 promise
   that C never has to think about curvature.

THE ACID SEA (world/acid.js, `level.acid {from,to,height,biome}`):
Flat emissive #c8ff3a, level and NOT tube-following — that's the mechanic. The
best find of the session: `height` is ONE NUMBER that tunes the level. Measured:
-18 -> 0% of the sea floor is dry shallow, -55 -> 14%, -62 -> 48%. So C's "position
IS the resource" falls out of the radius wobble they already author — the mucus
shallows are just "where the wall rises above the waterline", nothing to place —
and rising acid literally drowns them: free escalation, same scalar their event
pump drives. Also disproved my own claim: the centreline stays level (+/-1u over
700u), so shallows come from radius wobble, NOT the canal's bends. C must author
vertical bends deliberately if they want depth by anatomy.

Stated plainly, not papered over: the waterline is prototype quality (the plane
meets faceted rings and the shoreline steps). Acid does not drain the coat —
depthAt(pos) is the hook and the cost is Lane B's call. Nothing drives height but
DBG until C's events do.

qa GREEN incl. the new provenance gate; spline selfcheck green; L2 corridor
re-eyeballed for regression. Rulings requested from F in NOTES §-> Lane F.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jing 2026-07-16 18:04:16 +10:00
parent 3a6dbc30a3
commit 111ade7d12
15 changed files with 547 additions and 20 deletions

3
.gitignore vendored
View File

@ -6,3 +6,6 @@ pipeline/.genaudio/
pipeline/_normalized/
docs/shots/**/*.mov
docs/shots/**/*.mp4
# python bytecode from pipeline/ scripts (Lane A, round 2)
__pycache__/

View File

@ -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
`<colorspace_fragment>` 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.
---

View File

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

View File

@ -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.741.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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -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');

View File

@ -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

134
web/js/world/acid.js Normal file
View File

@ -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 <colorspace_fragment>` 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 <colorspace_fragment>
}`,
});
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(); },
};
}

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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 <colorspace_fragment>` 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.