[lane A] Round 1: the canal v0 — spline, streaming tube, scanner wall, arenas
Implements THE WORLD CONTRACT (TECH.md) in web/js/world/; drop-in for the stub. boot.js now runs this on C's L2_esophagus (3600u, hash cefc4f83), zero console errors. - spline.js: centreline + arclength LUTs + parallel-transport frames + radius law + peristalsis phase + project() + hash(). No three.js import, so qa can run its 14-assertion selfcheck headless. - curviness is solved from a curvature budget, not authored as an amplitude: the pinch guard caught the tube folding through itself on a wide+curvy join (turn radius 9.9 vs tube radius 17.2). C can no longer author a pinch. - peristalsis: global OMEGA, k(s) = OMEGA/flow(s), phase K(s) = integral k ds. A crest therefore travels at exactly the local flow speed, so "surf the crest" == "ride the current". Esophagus wave amp 0.9 -> 1.4 (measured): moves wallRho for Lane B. - tube.js: chunked streaming, seams aligned to biome joins, arenas own their s-span. - arena.js: displaced icosphere shells v0 (three's polyhedron detail is 20*(detail+1)^2, not 20*4^detail — 720 tris was far too coarse); fog sized to the room, since biome fog tuned for 100u corridors renders C's 360u acid sea as a black rectangle. - index.js: prefers assets.texture() over get() (get returns raw JSON a shader can't eat), plus an explicit slug map — D's wall_smallint_a vs biome id small_intestine would miss silently forever under the assets-optional law. Measured (C's real L2 + D's real pack, 1920x1080, renderer.info): 8 draws worst frame (budget 150), 74k tris (500k), no geometry leak across a full sweep, <120ms load, pinch ratio 3.32. fps not claimed — the screenshot pane runs tabs hidden, pausing rAF. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0a038582a7
commit
689c478a7f
193
docs/LANES/LANE_A_NOTES.md
Normal file
193
docs/LANES/LANE_A_NOTES.md
Normal file
@ -0,0 +1,193 @@
|
||||
# LANE A — NOTES (round 1)
|
||||
|
||||
World v0 is in and **the real game boots it**: `http://localhost:8140/?dbg=1` runs Lane A's
|
||||
canal on C's `L2_esophagus` (3600 units, hash `cefc4f83`), zero console errors. No boot.js
|
||||
change was needed — C's `levels/index.js` landing is what unblocked `pickWorld()`.
|
||||
|
||||
## What landed
|
||||
|
||||
`web/js/world/` — nothing outside it was touched.
|
||||
|
||||
| file | what |
|
||||
|---|---|
|
||||
| `spline.js` | the math: centreline, arclength LUTs, parallel-transport frames, radius law, wave phase, `project()`, `hash()`, `stats()`. **Imports no three.js** → `node web/js/world/spline.js --selfcheck` runs headless (14 assertions). |
|
||||
| `biomes.js` | palette/param registry (ART_BIBLE values). C ratified this as the biome-id source of truth. |
|
||||
| `wall_material.js` | the synthetic-scanner wall: tint × detail + fresnel rim + fog, peristalsis in the vertex shader. |
|
||||
| `tube.js` | chunked extrusion + streaming window. |
|
||||
| `arena.js` | displaced-icosphere shells (the stretch goal), v0. |
|
||||
| `flycam.js` | `?fly=1` noclip. **Not wired** — snippet for F below. |
|
||||
| `index.js` | `createWorld()` — THE WORLD CONTRACT, drop-in for the stub. |
|
||||
| `dev.html` | my harness. Dev-only; delete when boot covers it. |
|
||||
| `_fixture.js` | dev fixture level. Delete when C's levels cover every case (arenas: L3 does now). |
|
||||
|
||||
Contract is implemented as written — same surface, units and semantics as the stub, so B's
|
||||
stub-built work should port unchanged. Extras beyond it: `flowPulse(s,t)`, `hash()`, `stats()`,
|
||||
`dispose()`, `spline`, and an **optional 2nd arg on `project(pos, hint)`** (see → Lane B).
|
||||
|
||||
## Measured (say how you measured it — so: how)
|
||||
|
||||
All from `dev.html` on C's real `L2_esophagus` with D's real pack, 1920×1080, via
|
||||
`renderer.info` (`DBG.pose()` / the sweep in `DBG.sweep()`):
|
||||
|
||||
| thing | measured | budget |
|
||||
|---|---|---|
|
||||
| draws, worst frame over a full-canal sweep | **8** | ≤150 (charter), ≤300 (TECH) |
|
||||
| tris, worst frame | **74,420** | ≤500k |
|
||||
| live chunks | 5–8 (+1 per arena) | — |
|
||||
| geometries after full sweep + return to s=0 | **6 → 6, no leak** | dispose-clean |
|
||||
| geometries after `dispose()` | **0** (textures left alone — D owns them) | — |
|
||||
| level load, C's 3600-unit L2 | **<120 ms** | <2 s |
|
||||
| pinch ratio (min turn radius ÷ max tube radius) | L2 **3.32**, L3 **1.89**, fixture 2.37 | >1.5 |
|
||||
|
||||
**fps is NOT measured and I'm not claiming it.** Screenshot tooling drives the tab hidden,
|
||||
which pauses rAF; `gl.finish()` didn't sync either (it reported 0.12 ms/frame = 8219 fps,
|
||||
which is obviously junk). Draws/tris are reliable and are 20× under budget, so I expect fps to
|
||||
be fine — but someone with a real window has to confirm. → Lane F, at round end.
|
||||
|
||||
Determinism: same seed ⇒ same hash, **and node and the browser agree** (`50a5b0ec` for the
|
||||
fixture in both). Different seed ⇒ different hash. Both asserted in the selfcheck.
|
||||
|
||||
## Decisions (mine to make; flagging the ones that touch you)
|
||||
|
||||
1. **`curviness` = fraction of the tightest bend this pipe can survive**, not an amplitude.
|
||||
Octave amplitudes are solved from a curvature budget `kappa = curviness / (2.2 × baseRadius)`.
|
||||
Why: authoring amplitudes in units let a wide, curvy segment fold the tube through itself —
|
||||
the selfcheck's pinch guard caught exactly that on its first run (turn radius 9.9 vs tube
|
||||
radius 17.2). Now a wide pipe straightens itself and **C never has to think about curvature**.
|
||||
C's 0.15–0.95 range works as documented; nothing to change.
|
||||
2. **Peristalsis: global `OMEGA = 3.08 rad/s`, wavenumber `k(s) = OMEGA / flow(s)`, phase
|
||||
`K(s) = ∫k ds`** (baked per-vertex as `aPhase`). Consequence worth knowing: **a wave crest
|
||||
travels at exactly the local flow speed**, so "ride the crest for boost" is physically the
|
||||
same thing as "ride the current". Plain `k·s − ω·t` tears the wave at every flow change;
|
||||
`K(s)` doesn't. For a flow-14 esophagus this reproduces the stub's wave exactly (k = 0.22).
|
||||
3. **Esophagus `wave.amp` 0.9 → 1.4** (`biomes.js`). The stub's 0.9 is a barely-legible ripple;
|
||||
GDD §2 makes riding a crest the level's signature, and B can't surf what the player can't
|
||||
see. 1.4 reads as distinct rings and still leaves ~78% of a radius-10 tube flyable. Measured
|
||||
in the harness against 0.9 and 3.2. **This moves `wallRho`** → Lane B.
|
||||
4. **Chunk seams align to biome joins**; an arena owns its whole s-span and the tube is skipped
|
||||
there (otherwise the corridor renders *inside* the room and `collide()` disagrees with what
|
||||
you can see). Seam is chunk-quantised ±20u — fine until sphincter geometry exists.
|
||||
5. **Arena fog is sized to the room**, not the biome: `min(biome.fog, 1.09/radius)`. Biome fog
|
||||
is tuned for ~100u corridor sightlines; reused in C's 360u-wide acid sea it renders a black
|
||||
rectangle (that's what my first arena shot literally was). Small lairs keep the murk.
|
||||
6. **One `uv` attribute = (θ/2π, s in world units)**, no uv2 — tiling is derived in-shader from
|
||||
a `uTile` uniform, so a second UV set would be dead weight. Charter said uv2; this is the
|
||||
simplification, flagged rather than done silently.
|
||||
7. **Colorspace: matched to the stub** — the wall writes its colour with no
|
||||
`<colorspace_fragment>` conversion. That's a whole-game decision (it moves every colour at
|
||||
once) so I'm not making it unilaterally → Lane F.
|
||||
|
||||
## Known limitations (v0, stated plainly)
|
||||
|
||||
- **Arena pole pinch.** Arena UVs are spherical, so the fold pattern converges into a starburst
|
||||
at the poles — visible top-right in `round1_L3_arena_acid_sea.png`. A real texture will pinch
|
||||
there too. Round 2: triplanar projection for arena shells.
|
||||
- **Arena shape.** A sphere is the wrong shape for a stomach and C says so in their own
|
||||
`L3` note. Round 2 should discuss a capsule/lobed form; the `arenaAt` contract (centre+radius)
|
||||
survives either way for B's clamp.
|
||||
- **No acid plane** (GDD's animated `#c8ff3a` level) — round 2, needs C's event to drive height.
|
||||
- **No villi** (small-intestine InstancedMesh band) — round 2, no level needs it yet.
|
||||
- **No matcap / normal map.** D ships `normal` maps already; using them needs a TBN in the wall
|
||||
shader. Round 2. Untested code paths are worse than absent ones.
|
||||
- **`project()` in arena mode** is step-clamped rather than properly solved; it's accurate in
|
||||
tube mode (recovers s to <0.25, θ to <0.02 rad — asserted). Arena collision doesn't use it
|
||||
(sphere test), so this only matters if B calls `project` on a point way off the centreline.
|
||||
- Segment param blending lags the march by one 0.5u step. Deterministic, sub-step, harmless.
|
||||
|
||||
---
|
||||
|
||||
## → Lane F
|
||||
|
||||
1. **Pass `assets` into `createWorld`.** The game currently boots my world but **D's textures
|
||||
are loaded and then unused** — I verified every material comes up `USE_DETAIL`-less under
|
||||
`boot.js`. In `pickWorld()`:
|
||||
```js
|
||||
const mod = await import('./world/index.js');
|
||||
const level = await loadLevel(flags.lvl);
|
||||
const assets = await (await import('./core/assets.js')).createAssets({ flags, renderer });
|
||||
return mod.createWorld(level, { rng: createRng(flags.seed ?? level.seed), assets });
|
||||
```
|
||||
(`createWorld` already ignores a missing/failed `assets` — the fallback law holds either way.)
|
||||
2. **Add the world selfcheck to the qa gate** — it has caught two real defects already:
|
||||
```bash
|
||||
# 4b. Lane A world math (headless; no three.js needed)
|
||||
node web/js/world/spline.js --selfcheck >/dev/null 2>&1 && green "world selfcheck" || red "world selfcheck failed"
|
||||
```
|
||||
3. **Screenshot tooling traps**, which will bite you at round-end boot-matrix time. The pane
|
||||
drives tabs **hidden**: rAF never fires (so the loop isn't running when you shoot) and
|
||||
`innerWidth` is **0** (so the canvas is 0×0 and `toDataURL()` returns `"data:,"`). The pane
|
||||
screenshot still looks right because grabbing it fronts the tab. `DBG.shot()`'s anchor-click
|
||||
download also silently no-ops here. My harness works around all three in `DBG.size/pose/poseAt`
|
||||
— worth lifting into `boot.js`'s DBG. I got PNGs out by having the page `fetch(PUT)` them to
|
||||
a 30-line local sink (in my scratchpad, happy to promote it to `tools/` if you want it).
|
||||
4. **Dev port**: 8140 was already held by another session's server, and `launch.json` is yours,
|
||||
so I left it alone and reused the running one. If lanes are meant to run concurrently on one
|
||||
box this needs a call.
|
||||
5. Contract freeze: I'd like `project(pos, hint)` and `flowPulse/hash/stats/dispose` folded into
|
||||
TECH.md's world contract, and `assets.texture()` added to the asset contract (see → Lane D).
|
||||
|
||||
## → Lane B
|
||||
|
||||
- **`wallRho` shrank**: it subtracts `wave.amp + wave.breathe + 0.6` and esophagus `wave.amp`
|
||||
is now 1.4 (was 0.9). Playable radius in a base-10 tube ≈ **7.85**. Re-check your graze
|
||||
distances. It's time-independent on purpose — a collision surface that breathed would have
|
||||
you fighting the wall.
|
||||
- **`project(pos, hint)`** — pass your previous `s` as `hint` and it's near-free and exact. With
|
||||
no hint it seeds from `-pos.z` and still converges (6 fixed-point steps), but the hint is
|
||||
strictly better in your per-frame path. Both are asserted in the selfcheck.
|
||||
- **Crest speed == `biomeAt(s).flow`**, exactly, by construction. `world.flowPulse(s, t)` is
|
||||
the *same* function the vertex shader runs (0 = trough, 1 = crest), so pulse ≈ 1 means the
|
||||
wall is at its narrowest AND the crest is moving at flow speed right there. Boost window.
|
||||
- `biomeAt(s).flow` is **blended across segment joins**, not stepped — safe to read per-frame.
|
||||
- Arena mode: `modeAt(s)` / `arenaAt(s)` → `{center, radius}` for your 6DOF clamp. `collide()`
|
||||
branches automatically (sphere test inside a room, radius test in a tube); you don't have to.
|
||||
- `sample()` allocates fresh Vector3s per call, same as the stub. If that shows up in your
|
||||
profile, ask and I'll add `sampleInto(s, out)`.
|
||||
|
||||
## → Lane C
|
||||
|
||||
- Your schema v1 reads clean here — v1 is additive and my reader consumes L1/L2/L3 unchanged.
|
||||
Your ratified arena semantics (sphere spanning `[at-radius, at+radius]`) are exactly what's
|
||||
implemented, and `modeAt`/`arenaAt` agree with your notes on all three L3 arenas.
|
||||
- **Biome ids are the registry in `world/biomes.js`**: `oral · esophagus · stomach ·
|
||||
small_intestine · large_intestine · appendix`. Unknown id ⇒ neutral fallback + one warning,
|
||||
never a crash. Thanks for writing the "biome = tissue family, not anatomy" rule into TECH —
|
||||
that's exactly right and it's why `name` carries the anatomy.
|
||||
- `curviness` behaves as you documented (0 straight … 1 writhing). Under the hood 1.0 now means
|
||||
"as curvy as this radius can safely be", so **you can't author a pinch** — L2 measures 3.32×
|
||||
clear, L3 1.89×. Author freely.
|
||||
- Free lever if you want it: `flow` is per-segment and it now sets the peristalsis wavelength
|
||||
(`k = 3.08/flow`). Low flow ⇒ tighter, slower rings. A "sluggish" segment reads sluggish.
|
||||
- Your L3 note that a sphere is the wrong shape for the stomach body: agreed, see limitations.
|
||||
|
||||
## → Lane D
|
||||
|
||||
- **Naming mismatch, silent by construction.** Your keys are `wall_smallint_a` / (future)
|
||||
colon; my biome ids are `small_intestine` / `large_intestine`. `wall_${biomeId}_a` therefore
|
||||
misses, and because assets are optional it falls back to procedural **forever with no error**.
|
||||
I've added an explicit slug map in `index.js` so it works today. Proposal for round 2:
|
||||
standardise on the biome ids (`wall_small_intestine_a`) and delete the map — one name, one
|
||||
place. Your call, just let's not leave it implicit.
|
||||
- **Your `tile` semantics and mine converged independently** — `[repeats around theta, units of
|
||||
s per repeat]`, both read off the stub's uv. Ratified from my side; `[4,16]` looks right in
|
||||
engine (`round1_L2_textured.png`).
|
||||
- **`texture()` is the useful entry point, not `get()`.** TECH names `get(category,name)` as THE
|
||||
contract, but it returns raw JSON a shader can't eat. I prefer `assets.texture(name)` and keep
|
||||
`get` duck-typed as the documented floor. Suggest F promotes `texture()` into TECH.
|
||||
- FYI: tiling is applied **in-shader** from `tile`. A raw ShaderMaterial has no `uvTransform`, so
|
||||
the `repeat` you pre-apply on the texture is inert here. Not a problem — just don't count on it.
|
||||
- `wall_esophagus_a` looks genuinely good in engine. The `_b` variants and matcap aren't wired
|
||||
yet (round 2, with the TBN work).
|
||||
|
||||
## → Lane E
|
||||
|
||||
- `world.flowPulse(s, t)` (0..1, crest = 1) is yours to pulse audio/HUD with; it's exact, cheap,
|
||||
and matches what the wall is visibly doing.
|
||||
- `world.biomeAt(s)` → `{id, palette, fog, flow, coatDrain}`. `palette.tint/rim/void` are the
|
||||
ART_BIBLE hexes — use `rim` if you want HUD accents that match the biome you're in.
|
||||
|
||||
## Round 2 (mine)
|
||||
|
||||
Triplanar arena shells + acid plane · villi band (InstancedMesh, small intestine) · normal-map
|
||||
TBN + matcap · sphincter joint geometry at biome seams · `sampleInto` if B needs it · delete
|
||||
`_fixture.js`/`dev.html` once boot + C's levels cover them.
|
||||
46
docs/progress/a-progress.md
Normal file
46
docs/progress/a-progress.md
Normal file
@ -0,0 +1,46 @@
|
||||
# Lane A — progress log
|
||||
|
||||
## 2026-07-16 · Round 1 — the canal, v0
|
||||
|
||||
Built `web/js/world/`: spline + parallel-transport frames + arclength LUTs, chunked streaming
|
||||
tube, synthetic-scanner wall shader with vertex peristalsis, biome registry, arena shells v0,
|
||||
noclip flycam, headless selfcheck, dev harness. Contract per TECH.md §THE WORLD CONTRACT,
|
||||
drop-in for the stub.
|
||||
|
||||
**The game boots it.** `/?dbg=1` (no `?stub`) runs Lane A's world on C's `L2_esophagus`,
|
||||
3600 units, hash `cefc4f83`, zero console errors. C's registry landing mid-round is what
|
||||
unblocked `boot.js`'s `pickWorld()`; no shell change was needed.
|
||||
|
||||
Measured (dev.html, C's real L2 + D's real pack, 1920×1080, `renderer.info`):
|
||||
draws **8** worst-frame over a full-canal sweep (budget ≤150) · tris **74,420** (≤500k) ·
|
||||
geometries 6 → 6 across a full sweep and back, **no leak**, 0 after `dispose()` ·
|
||||
load **<120 ms** (<2 s) · pinch ratio L2 3.32 / L3 1.89 (>1.5). fps deliberately **not**
|
||||
claimed — the screenshot pane runs tabs hidden, which pauses rAF, and `gl.finish()` didn't
|
||||
sync; needs a real window. Determinism: node and browser agree on the hash.
|
||||
|
||||
Three defects the checks caught that eyeballing would not have:
|
||||
- **Tube folded through itself** on a wide+curvy join (turn radius 9.9 vs tube radius 17.2).
|
||||
Root cause: centreline amplitude authored in units. Fixed by solving amplitudes from a
|
||||
curvature budget, so `curviness` means "fraction of the pinch limit" and C can't author one.
|
||||
- **Arena at 720 tris** — three's polyhedron `detail` is `20*(detail+1)^2`, not `20*4^detail`.
|
||||
Now solved for ~3u spacing from the arena's own radius.
|
||||
- **Acid sea rendered as a black rectangle** — biome fog is tuned for 100u corridors, C's arena
|
||||
is 360u across. Arena fog is now sized to the room.
|
||||
|
||||
Also caught, and both would have rotted silently: the browser served a **stale cached
|
||||
`biomes.js`**, so the first evidence set was shot at the old wave amplitude (re-shot); and D's
|
||||
texture keys (`wall_smallint_a`) don't match my biome ids (`small_intestine`), which under the
|
||||
assets-optional law fails to procedural forever with no error (slug map added, standardisation
|
||||
proposed to D).
|
||||
|
||||
Decisions: peristalsis phase `K(s) = ∫k ds` with `k = OMEGA/flow(s)` ⇒ crest speed == local flow
|
||||
(so "surf the crest" == "ride the current") · esophagus wave amp 0.9 → 1.4, measured, moves
|
||||
`wallRho` for B · chunk seams align to biome joins, arenas own their span · one uv attribute,
|
||||
tiling derived in-shader · colorspace left matching the stub, F to rule.
|
||||
|
||||
Evidence → `docs/shots/laneA/`: `round1_real_game_boot` (boot.js running Lane A's world),
|
||||
`round1_L2_textured` / `round1_L2_procedural_fallback` (same pose, with and without D's pack —
|
||||
the assets-optional law), `round1_L2_curve`, `round1_L2_wave_crest`, `round1_L3_arena_acid_sea`.
|
||||
|
||||
Open for round 2: triplanar arena shells + acid plane, villi band, normal/matcap TBN, sphincter
|
||||
seam geometry. Full detail + per-lane asks in `LANE_A_NOTES.md`.
|
||||
BIN
docs/shots/laneA/round1_L2_curve.png
Normal file
BIN
docs/shots/laneA/round1_L2_curve.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
docs/shots/laneA/round1_L2_procedural_fallback.png
Normal file
BIN
docs/shots/laneA/round1_L2_procedural_fallback.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
BIN
docs/shots/laneA/round1_L2_textured.png
Normal file
BIN
docs/shots/laneA/round1_L2_textured.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
BIN
docs/shots/laneA/round1_L2_wave_crest.png
Normal file
BIN
docs/shots/laneA/round1_L2_wave_crest.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
BIN
docs/shots/laneA/round1_L3_arena_acid_sea.png
Normal file
BIN
docs/shots/laneA/round1_L3_arena_acid_sea.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/shots/laneA/round1_real_game_boot.png
Normal file
BIN
docs/shots/laneA/round1_real_game_boot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
17
web/js/world/_fixture.js
Normal file
17
web/js/world/_fixture.js
Normal file
@ -0,0 +1,17 @@
|
||||
// world/_fixture.js (Lane A) — DEV FIXTURE, not shipping content.
|
||||
// Lane C owns real levels (js/levels/*.json). This exists so spline.js's headless selfcheck
|
||||
// and dev.html can exercise the world before/without C's data: multi-segment, curviness
|
||||
// sweep, a biome change, and an arena. Delete the day C's L2 + registry land.
|
||||
|
||||
export const FIXTURE = {
|
||||
id: 'A_FIXTURE',
|
||||
name: 'Lane A fixture canal',
|
||||
seed: 20260716,
|
||||
segments: [
|
||||
{ biome: 'esophagus', length: 300, radius: { base: 10, wobble: 0.25 }, curviness: 0.55, flow: 14 },
|
||||
{ biome: 'esophagus', length: 240, radius: { base: 12, wobble: 0.35 }, curviness: 0.95, flow: 11 },
|
||||
{ biome: 'stomach', length: 120, radius: { base: 16, wobble: 0.30 }, curviness: 0.20, flow: 4 },
|
||||
],
|
||||
arenas: [{ at: 620, radius: 55, biome: 'stomach' }],
|
||||
events: [],
|
||||
};
|
||||
119
web/js/world/arena.js
Normal file
119
web/js/world/arena.js
Normal file
@ -0,0 +1,119 @@
|
||||
// world/arena.js (Lane A) — arena shells, v0. A displaced icosphere wearing the same wall
|
||||
// shader as the tube, so the mouth/stomach/boss lairs are the same material world as the
|
||||
// corridors (ART_BIBLE) and Lane B's 6DOF clamp has a real `arenaAt` to clamp against.
|
||||
//
|
||||
// v0 scope, stated plainly: the shell + its bounds. The stomach's animated acid plane
|
||||
// (GDD §3, emissive #c8ff3a, height driven by C's events) is round 2 — see LANE_A_NOTES.
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
/** Seeded 3D value noise. Same lattice trick as spline.js: a table, not a hash function. */
|
||||
function makeNoise3(rng) {
|
||||
const N = 64, tab = new Float32Array(N * N * N);
|
||||
const r = rng('world.arena');
|
||||
for (let i = 0; i < tab.length; i++) tab[i] = r() * 2 - 1;
|
||||
const at = (x, y, z) => tab[(((x & 63) * N + (y & 63)) * N + (z & 63))];
|
||||
const fade = (t) => t * t * (3 - 2 * t);
|
||||
const lerp = (a, b, t) => a + (b - a) * t;
|
||||
function vnoise(x, y, z) {
|
||||
const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
|
||||
const xf = fade(x - xi), yf = fade(y - yi), zf = fade(z - zi);
|
||||
const c00 = lerp(at(xi, yi, zi), at(xi + 1, yi, zi), xf);
|
||||
const c10 = lerp(at(xi, yi + 1, zi), at(xi + 1, yi + 1, zi), xf);
|
||||
const c01 = lerp(at(xi, yi, zi + 1), at(xi + 1, yi, zi + 1), xf);
|
||||
const c11 = lerp(at(xi, yi + 1, zi + 1), at(xi + 1, yi + 1, zi + 1), xf);
|
||||
return lerp(lerp(c00, c10, yf), lerp(c01, c11, yf), zf);
|
||||
}
|
||||
return (x, y, z) => {
|
||||
let sum = 0, amp = 1, f = 1, n = 0;
|
||||
for (let o = 0; o < 3; o++) { sum += amp * vnoise(x * f, y * f, z * f); n += amp; amp *= 0.5; f *= 2.07; }
|
||||
return sum / n;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} spec level `arenas[]` entry: { at, radius, biome }
|
||||
* @param {object} spline
|
||||
* @param {THREE.Material} material a wall material built for this arena's biome
|
||||
* @param {function} rng
|
||||
*/
|
||||
export function createArena({ spec, spline, material, rng, quality = 'high' }) {
|
||||
// three's polyhedron `detail` splits each edge into (detail+1) segments, so face count is
|
||||
// 20*(detail+1)^2 — NOT 20*4^detail. detail:5 is 720 tris, which on a 55-unit room is a
|
||||
// 10-unit facet and the fbm displacement has nothing to displace. Solve for ~3u spacing
|
||||
// instead (icosahedron edge ~ 1.05r), so arena cost tracks arena size.
|
||||
const spacing = quality === 'low' ? 6 : 3;
|
||||
const detail = Math.max(3, Math.min(24, Math.round((1.05 * spec.radius) / spacing) - 1));
|
||||
const noise = makeNoise3(rng);
|
||||
const f = spline.frameAt(spec.at);
|
||||
const center = new THREE.Vector3(f.pos.x, f.pos.y, f.pos.z);
|
||||
|
||||
const geo = new THREE.IcosahedronGeometry(spec.radius, detail); // non-indexed
|
||||
const pos = geo.attributes.position;
|
||||
const n = pos.count;
|
||||
const position = new Float32Array(n * 3);
|
||||
const aInward = new Float32Array(n * 3);
|
||||
const aTangent = new Float32Array(n * 3);
|
||||
const uv = new Float32Array(n * 2);
|
||||
const aPhase = new Float32Array(n);
|
||||
const aK = 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.
|
||||
const k = 3.08 / Math.max(4, spec.radius / 5);
|
||||
const axis = new THREE.Vector3(f.tan.x, f.tan.y, f.tan.z);
|
||||
const ref = new THREE.Vector3(f.nor.x, f.nor.y, f.nor.z);
|
||||
const bin = new THREE.Vector3(f.bin.x, f.bin.y, f.bin.z);
|
||||
const amp = spec.radius * 0.09;
|
||||
const v = new THREE.Vector3();
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
v.fromBufferAttribute(pos, i);
|
||||
const dir = v.clone().normalize();
|
||||
const r = spec.radius + amp * noise(dir.x * 2.3 + 11, dir.y * 2.3 + 5, dir.z * 2.3 + 3);
|
||||
const p = dir.clone().multiplyScalar(r);
|
||||
position[i * 3] = p.x; position[i * 3 + 1] = p.y; position[i * 3 + 2] = p.z;
|
||||
aInward[i * 3] = -dir.x; aInward[i * 3 + 1] = -dir.y; aInward[i * 3 + 2] = -dir.z;
|
||||
aTangent[i * 3] = axis.x; aTangent[i * 3 + 1] = axis.y; aTangent[i * 3 + 2] = axis.z;
|
||||
const along = p.dot(axis);
|
||||
uv[i * 2] = (Math.atan2(p.dot(bin), p.dot(ref)) / (Math.PI * 2)) + 0.5;
|
||||
uv[i * 2 + 1] = spec.at + along; // keep uv.y in canal-s units, like the tube
|
||||
aPhase[i] = k * (spec.at + along);
|
||||
aK[i] = k;
|
||||
}
|
||||
|
||||
// Seam repair: uv.x comes from atan2, so a triangle straddling the -X axis interpolates it
|
||||
// from ~1 back to ~0 and the wall shader's fold pattern crams a full cycle into that one
|
||||
// triangle — a zigzag scar down the room. The geometry is non-indexed, so each triangle owns
|
||||
// its three vertices and we can just push the low ones past the wrap.
|
||||
for (let t = 0; t < n; t += 3) {
|
||||
let lo = Infinity, hi = -Infinity;
|
||||
for (let j = 0; j < 3; j++) { const x = uv[(t + j) * 2]; lo = Math.min(lo, x); hi = Math.max(hi, x); }
|
||||
if (hi - lo > 0.5) for (let j = 0; j < 3; j++) if (uv[(t + j) * 2] < 0.5) uv[(t + j) * 2] += 1;
|
||||
}
|
||||
|
||||
const g = new THREE.BufferGeometry();
|
||||
g.setAttribute('position', new THREE.BufferAttribute(position, 3));
|
||||
g.setAttribute('aInward', new THREE.BufferAttribute(aInward, 3));
|
||||
g.setAttribute('aTangent', new THREE.BufferAttribute(aTangent, 3));
|
||||
g.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
||||
g.setAttribute('aPhase', new THREE.BufferAttribute(aPhase, 1));
|
||||
g.setAttribute('aK', new THREE.BufferAttribute(aK, 1));
|
||||
g.computeBoundingSphere();
|
||||
geo.dispose(); // the source icosphere was scaffolding
|
||||
|
||||
const mesh = new THREE.Mesh(g, material); // material built with side: BackSide
|
||||
mesh.position.copy(center);
|
||||
mesh.name = `arena ${spec.biome} @${spec.at}`;
|
||||
|
||||
return {
|
||||
spec,
|
||||
mesh,
|
||||
center,
|
||||
radius: spec.radius,
|
||||
/** Conservative inner surface: shell minus displacement peak minus the shader's wave. */
|
||||
innerRadius: spec.radius - amp - (material.uniforms?.uWaveA?.value ?? 0) - 0.6,
|
||||
covers: (s) => Math.abs(s - spec.at) <= spec.radius,
|
||||
dispose() { g.dispose(); },
|
||||
};
|
||||
}
|
||||
85
web/js/world/biomes.js
Normal file
85
web/js/world/biomes.js
Normal file
@ -0,0 +1,85 @@
|
||||
// world/biomes.js (Lane A) — the biome registry. Palette/fog values are transcribed from
|
||||
// ART_BIBLE.md §Biome palettes; gameplay defaults (flow, coatDrain) are sane seeds that
|
||||
// Lane C overrides per-segment in level JSON (`segments[].flow`). C owns the tuning, this
|
||||
// owns the look.
|
||||
//
|
||||
// Canonical ids — level JSON `segments[].biome` must be one of these:
|
||||
// oral · esophagus · stomach · small_intestine · large_intestine · appendix
|
||||
// Unknown id => NEUTRAL fallback + one console warning, never a crash.
|
||||
//
|
||||
// `wave.amp` is displacement in units, inward, at the crest — and it is a GAMEPLAY number, not
|
||||
// just a look: world.wallRho() subtracts (amp + breathe) as its conservative margin, so raising
|
||||
// it narrows Lane B's flight envelope. Esophagus 1.4 was measured in the harness, not guessed:
|
||||
// the stub's 0.9 renders as a barely-legible ripple, and GDD §2 makes riding a crest for boost
|
||||
// the level's signature mechanic — B cannot surf a crest the player can't see. 1.4 reads as
|
||||
// distinct rings and still leaves ~78% of a radius-10 tube flyable. The other biomes are scaled
|
||||
// in proportion and are UNVERIFIED until each one is actually built and eyeballed.
|
||||
|
||||
export const BIOMES = {
|
||||
oral: {
|
||||
id: 'oral',
|
||||
palette: { tint: 0xa8c4d8, rim: 0xdff2ff, void: 0x06090e },
|
||||
fog: 0.016, // clinical, bright, tutorial-readable => you can see across the cavity
|
||||
flow: 5,
|
||||
coatDrain: 0.15,
|
||||
wave: { amp: 0.5, breathe: 0.10 }, // saliva tides, not real peristalsis
|
||||
},
|
||||
esophagus: {
|
||||
id: 'esophagus',
|
||||
palette: { tint: 0x1e6e64, rim: 0x5affd2, void: 0x02100d },
|
||||
fog: 0.028,
|
||||
flow: 14,
|
||||
coatDrain: 0.5,
|
||||
wave: { amp: 1.4, breathe: 0.15 }, // the signature: ribbed rings rushing past (measured)
|
||||
},
|
||||
stomach: {
|
||||
id: 'stomach',
|
||||
palette: { tint: 0xb0571e, rim: 0xffb13a, void: 0x120801 },
|
||||
fog: 0.020,
|
||||
flow: 4,
|
||||
coatDrain: 2.2, // the acid sea: brutal in open acid (GDD §Player systems)
|
||||
wave: { amp: 0.7, breathe: 0.25 }, // churn, not transit
|
||||
},
|
||||
small_intestine: {
|
||||
id: 'small_intestine',
|
||||
palette: { tint: 0x7a2a6e, rim: 0xff6ad5, void: 0x0d0312 },
|
||||
fog: 0.050, // dense: villi forest occludes sightlines
|
||||
flow: 9,
|
||||
coatDrain: 0.8,
|
||||
wave: { amp: 1.5, breathe: 0.18 },
|
||||
},
|
||||
large_intestine: {
|
||||
id: 'large_intestine',
|
||||
palette: { tint: 0x3a4a26, rim: 0x9dff4a, void: 0x050702 },
|
||||
fog: 0.075, // darkness is the mechanic here; scanner light is the safety bubble
|
||||
flow: 5,
|
||||
coatDrain: 0.6,
|
||||
wave: { amp: 1.2, breathe: 0.2 },
|
||||
},
|
||||
appendix: {
|
||||
id: 'appendix',
|
||||
palette: { tint: 0x8a7020, rim: 0xffe066, void: 0x0a0800 },
|
||||
fog: 0.030,
|
||||
flow: 2,
|
||||
coatDrain: 0.3,
|
||||
wave: { amp: 0.5, breathe: 0.12 },
|
||||
},
|
||||
};
|
||||
|
||||
const NEUTRAL = {
|
||||
id: 'neutral',
|
||||
palette: { tint: 0x557066, rim: 0x9fd8c8, void: 0x050a08 },
|
||||
fog: 0.03, flow: 10, coatDrain: 0.4, wave: { amp: 0.9, breathe: 0.15 },
|
||||
};
|
||||
|
||||
const warned = new Set();
|
||||
|
||||
export function biome(id) {
|
||||
const b = BIOMES[id];
|
||||
if (b) return b;
|
||||
if (!warned.has(id)) {
|
||||
warned.add(id);
|
||||
console.warn(`[world] unknown biome "${id}" — falling back to neutral. Valid: ${Object.keys(BIOMES).join(', ')}`);
|
||||
}
|
||||
return NEUTRAL;
|
||||
}
|
||||
226
web/js/world/dev.html
Normal file
226
web/js/world/dev.html
Normal file
@ -0,0 +1,226 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>GUTS — Lane A world harness</title>
|
||||
<!--
|
||||
Lane A's own harness. NOT the game: js/boot.js is the game, F owns it.
|
||||
|
||||
This exists because boot.js can't reach js/world/index.js yet — pickWorld() imports Lane C's
|
||||
js/levels/index.js in the same try block, and that module doesn't exist, so every non-stub
|
||||
boot falls back to the stub regardless of my work landing. Patch offered in LANE_A_NOTES.md
|
||||
§-> Lane F. Until it's applied, this is how the canal gets eyeballed. Delete it once boot can
|
||||
show the real world.
|
||||
|
||||
http://localhost:8140/js/world/dev.html?dbg=1 rail cam down the fixture canal
|
||||
...?dbg=1&fly=1 noclip (WASD/QE, drag to look)
|
||||
...?dbg=1&s=430 park the rail cam at s=430
|
||||
...?dbg=1&fakeassets=1 exercise the Lane D texture path
|
||||
...?lvl=L2_esophagus C's level, when it lands
|
||||
...?shots=1 hide the overlay
|
||||
-->
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; overflow: hidden; background: #02100d; }
|
||||
canvas { display: block; }
|
||||
#dbg { position: fixed; left: 10px; bottom: 10px; color: #39e6ff;
|
||||
font: 12px/1.5 ui-monospace, monospace; text-shadow: 0 0 4px #000;
|
||||
pointer-events: none; white-space: pre; }
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{ "imports": { "three": "../../vendor/three.module.js",
|
||||
"three/addons/": "../../vendor/addons/" } }
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="dbg"></div>
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { createRng } from '../core/rng.js';
|
||||
import { createWorld } from './index.js';
|
||||
import { createFlyCam } from './flycam.js';
|
||||
import { FIXTURE } from './_fixture.js';
|
||||
|
||||
const p = new URLSearchParams(location.search);
|
||||
const on = (k) => p.has(k) && p.get(k) !== '0';
|
||||
const num = (k, d) => (p.has(k) ? parseFloat(p.get(k)) : d);
|
||||
|
||||
// --- level: C's if it's landed, fixture otherwise ---------------------------------------
|
||||
let level = FIXTURE;
|
||||
if (p.has('lvl')) {
|
||||
try {
|
||||
const mod = await import('../levels/index.js');
|
||||
level = await mod.getLevel(p.get('lvl'));
|
||||
} catch (e) {
|
||||
console.warn('[devharness] no Lane C registry yet, using fixture —', e.message);
|
||||
}
|
||||
}
|
||||
const seed = p.has('seed') ? (parseInt(p.get('seed'), 10) >>> 0) : level.seed;
|
||||
|
||||
// --- fake assets (?fakeassets=1) ---------------------------------------------------------
|
||||
// Stands in for Lane D so the USE_DETAIL branch of the wall shader is exercised before their
|
||||
// pack exists: a seamless grayscale fold pattern, same duck-type index.js expects from
|
||||
// assets.get('textures', 'wall_<biome>_a'). Proves the integration, not the art.
|
||||
function fakeAssets() {
|
||||
const S = 256, cv = document.createElement('canvas');
|
||||
cv.width = cv.height = S;
|
||||
const ctx = cv.getContext('2d'), img = ctx.createImageData(S, S), r = createRng(seed)('assets.fake');
|
||||
for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) {
|
||||
const u = x / S, v = y / S; // periodic in both axes => tiles cleanly
|
||||
let g = 0.5 + 0.22 * Math.sin(Math.PI * 2 * 3 * u + 2 * Math.sin(Math.PI * 2 * 2 * v))
|
||||
+ 0.16 * Math.sin(Math.PI * 2 * 5 * v)
|
||||
+ 0.10 * Math.sin(Math.PI * 2 * 11 * u + 1.7);
|
||||
g = Math.max(0, Math.min(1, g + (r() - 0.5) * 0.10));
|
||||
const i = (y * S + x) * 4, b = (g * 255) | 0;
|
||||
img.data[i] = img.data[i + 1] = img.data[i + 2] = b; img.data[i + 3] = 255;
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
const tex = new THREE.CanvasTexture(cv);
|
||||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||
return { get: (kind, id) => (kind === 'textures' && /^wall_/.test(id) ? { isTexture: false, texture: tex, tile: [3, 14] } : null) };
|
||||
}
|
||||
|
||||
// --- shell -------------------------------------------------------------------------------
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
document.body.appendChild(renderer.domElement);
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 600);
|
||||
|
||||
const t0 = performance.now();
|
||||
// ?assets=1 uses Lane D's real loader + pack; ?fakeassets=1 uses the synthetic stand-in above.
|
||||
let assets = null;
|
||||
if (on('fakeassets')) assets = fakeAssets();
|
||||
else if (on('assets')) {
|
||||
try {
|
||||
const m = await import('../core/assets.js');
|
||||
assets = await m.createAssets({ flags: { localassets: p.get('localassets') !== '0' } });
|
||||
} catch (e) {
|
||||
console.warn('[devharness] Lane D assets.js unavailable, procedural walls —', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const world = await createWorld(level, { rng: createRng(seed), quality: p.get('quality') || 'high', assets });
|
||||
const loadMs = performance.now() - t0;
|
||||
scene.add(world.group);
|
||||
scene.background = new THREE.Color(world.biomeAt(0).palette.void);
|
||||
|
||||
const fly = on('fly') ? createFlyCam({ camera, dom: renderer.domElement, world }) : null;
|
||||
let sCam = num('s', 5);
|
||||
const railSpeed = num('speed', 1) * (p.has('s') ? 0 : 1); // ?s= parks the cam for clean shots
|
||||
|
||||
function rail(dt) {
|
||||
sCam = (sCam + world.biomeAt(sCam).flow * 0.6 * railSpeed * dt) % world.length;
|
||||
const f = world.sample(sCam), ahead = world.sample(Math.min(sCam + 12, world.length));
|
||||
camera.position.copy(f.pos).addScaledVector(f.nor, -f.radius * 0.25);
|
||||
camera.up.copy(f.nor);
|
||||
camera.lookAt(ahead.pos);
|
||||
}
|
||||
|
||||
addEventListener('resize', () => {
|
||||
camera.aspect = innerWidth / innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
});
|
||||
|
||||
const dbgEl = document.getElementById('dbg');
|
||||
if (!on('dbg') || on('shots')) dbgEl.style.display = 'none';
|
||||
renderer.info.autoReset = false;
|
||||
let frames = 0, fpsT = 0, fps = 0, draws = 0, tris = 0;
|
||||
|
||||
window.DBG = {
|
||||
world, scene, camera, renderer,
|
||||
get draws() { return draws; }, get tris() { return tris; }, get fps() { return fps; },
|
||||
shot(name = 'shot') {
|
||||
const a = document.createElement('a');
|
||||
a.download = `${name}.png`;
|
||||
a.href = renderer.domElement.toDataURL('image/png');
|
||||
a.click();
|
||||
},
|
||||
/**
|
||||
* Fix the drawing buffer to an explicit size for evidence frames. Two traps, both learned
|
||||
* the hard way, both of which will bite anyone shooting boot.js (see LANE_A_NOTES §-> Lane F):
|
||||
* 1. Screenshot tooling drives the tab HIDDEN. A hidden tab gets no requestAnimationFrame,
|
||||
* so the render loop simply is not running when you shoot.
|
||||
* 2. A hidden tab also reports innerWidth/innerHeight 0 — so the canvas is 0x0 and
|
||||
* toDataURL() returns the literal string "data:,". The pane screenshot still looks fine
|
||||
* because grabbing it fronts the tab; JS-side capture does not.
|
||||
* Sizing explicitly also makes every committed shot the same resolution regardless of window.
|
||||
*/
|
||||
size(w = 1920, h = 1080) {
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setSize(w, h, false);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
return [renderer.domElement.width, renderer.domElement.height];
|
||||
},
|
||||
|
||||
/** Deterministic evidence frame: park the rail cam at s, set the wave to exactly t, render. */
|
||||
pose(s = 5, t = 0, w = 1920, h = 1080) {
|
||||
this.size(w, h);
|
||||
world.update(t - world.time, s); // land the wave phase exactly on t
|
||||
for (let i = 0; i < 12; i++) world.update(0, s); // let the 2-chunks/update budget fill
|
||||
sCam = s;
|
||||
rail(0);
|
||||
renderer.render(scene, camera);
|
||||
const r = { s, t, draws: renderer.info.render.calls, tris: renderer.info.render.triangles,
|
||||
pulse: world.flowPulse(s, t), ...world.stats() };
|
||||
renderer.info.reset();
|
||||
return r;
|
||||
},
|
||||
|
||||
/** Park the noclip cam explicitly (arena interiors, wall close-ups), then render. */
|
||||
poseAt(pos, lookAt, t = 0, w = 1920, h = 1080) {
|
||||
this.size(w, h);
|
||||
const p = new THREE.Vector3(...pos), l = new THREE.Vector3(...lookAt);
|
||||
const s = world.project(p).s;
|
||||
world.update(t - world.time, s);
|
||||
for (let i = 0; i < 12; i++) world.update(0, s);
|
||||
camera.position.copy(p);
|
||||
camera.up.set(0, 1, 0);
|
||||
camera.lookAt(l);
|
||||
renderer.render(scene, camera);
|
||||
const r = { s, draws: renderer.info.render.calls, tris: renderer.info.render.triangles };
|
||||
renderer.info.reset();
|
||||
return r;
|
||||
},
|
||||
|
||||
/** Streaming leak probe: fly the whole canal, report renderer.info deltas. */
|
||||
sweep(step = 20) {
|
||||
const g0 = renderer.info.memory.geometries;
|
||||
for (let s = 0; s <= world.length; s += step) world.update(0.016, s);
|
||||
world.update(0.016, 0);
|
||||
const g1 = renderer.info.memory.geometries;
|
||||
return { geometriesBefore: g0, geometriesAfter: g1, delta: g1 - g0, ...world.stats() };
|
||||
},
|
||||
};
|
||||
|
||||
let last = performance.now();
|
||||
function frame(now) {
|
||||
const dt = Math.min((now - last) / 1000, 0.05);
|
||||
last = now;
|
||||
if (fly) fly.update(dt); else rail(dt);
|
||||
const camS = fly ? world.project(camera.position).s : sCam;
|
||||
world.update(dt, camS);
|
||||
renderer.render(scene, camera);
|
||||
draws = renderer.info.render.calls;
|
||||
tris = renderer.info.render.triangles;
|
||||
renderer.info.reset();
|
||||
frames++; fpsT += dt;
|
||||
if (fpsT >= 0.5) {
|
||||
fps = Math.round(frames / fpsT); frames = 0; fpsT = 0;
|
||||
if (on('dbg') && !on('shots')) {
|
||||
const st = world.stats();
|
||||
dbgEl.textContent =
|
||||
`${fps} fps · ${draws} draws · ${(tris / 1000) | 0}k tris · load ${loadMs.toFixed(0)}ms\n` +
|
||||
`${level.id} seed=${seed} hash=${world.hash()} L=${world.length}\n` +
|
||||
`s=${camS.toFixed(0)} ${world.biomeAt(camS).id} ${world.modeAt(camS)} · flow ${world.biomeAt(camS).flow.toFixed(1)} · pulse ${world.flowPulse(camS).toFixed(2)}\n` +
|
||||
`chunks ${st.chunks} live / ${st.built} built / ${st.disposed} disposed · turnR ${st.minTurnRadius.toFixed(0)} vs R ${st.maxRadius.toFixed(1)}`;
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
70
web/js/world/flycam.js
Normal file
70
web/js/world/flycam.js
Normal file
@ -0,0 +1,70 @@
|
||||
// world/flycam.js (Lane A) — `?fly=1` noclip inspection camera.
|
||||
// Owed to the other lanes per LANE_A_WORLD.md: a way to look at the canal without Lane B's
|
||||
// player existing. True noclip (free 6DOF, ignores world.collide) — it is a debug tool, not
|
||||
// a preview of arena-mode flight; Lane B owns how the ship actually feels.
|
||||
//
|
||||
// Not wired into boot.js yet: boot owns the camera and `?fly=1` currently drives a rail cam.
|
||||
// The exact patch is offered in LANE_A_NOTES.md §-> Lane F.
|
||||
//
|
||||
// W/S forward/back · A/D strafe · Q/E down/up · Shift boost · drag to look · R reset to start
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
export function createFlyCam({ camera, dom = window, world, speed = 35 }) {
|
||||
const keys = new Set();
|
||||
let yaw = 0, pitch = 0, dragging = false;
|
||||
|
||||
function reset() {
|
||||
const f = world.sample(4);
|
||||
camera.position.copy(f.pos).addScaledVector(f.tan, -6);
|
||||
yaw = Math.atan2(-f.tan.x, -f.tan.z);
|
||||
pitch = Math.asin(THREE.MathUtils.clamp(f.tan.y, -1, 1));
|
||||
}
|
||||
|
||||
const onDown = (e) => { keys.add(e.code); if (e.code === 'KeyR') reset(); };
|
||||
const onUp = (e) => keys.delete(e.code);
|
||||
const onBlur = () => keys.clear();
|
||||
const onMouseDown = () => { dragging = true; };
|
||||
const onMouseUp = () => { dragging = false; };
|
||||
const onMouseMove = (e) => {
|
||||
if (!dragging) return;
|
||||
yaw -= e.movementX * 0.003;
|
||||
pitch = THREE.MathUtils.clamp(pitch - e.movementY * 0.003, -1.5, 1.5);
|
||||
};
|
||||
|
||||
addEventListener('keydown', onDown);
|
||||
addEventListener('keyup', onUp);
|
||||
addEventListener('blur', onBlur);
|
||||
dom.addEventListener('mousedown', onMouseDown);
|
||||
addEventListener('mouseup', onMouseUp);
|
||||
addEventListener('mousemove', onMouseMove);
|
||||
reset();
|
||||
|
||||
const fwd = new THREE.Vector3(), right = new THREE.Vector3(), up = new THREE.Vector3(0, 1, 0);
|
||||
|
||||
return {
|
||||
get position() { return camera.position; },
|
||||
reset,
|
||||
update(dt) {
|
||||
camera.up.copy(up); // noclip stays world-up: no frame roll to fight
|
||||
camera.rotation.set(pitch, yaw, 0, 'YXZ');
|
||||
fwd.set(0, 0, -1).applyEuler(camera.rotation);
|
||||
right.set(1, 0, 0).applyEuler(camera.rotation);
|
||||
const v = speed * (keys.has('ShiftLeft') || keys.has('ShiftRight') ? 3 : 1) * dt;
|
||||
if (keys.has('KeyW')) camera.position.addScaledVector(fwd, v);
|
||||
if (keys.has('KeyS')) camera.position.addScaledVector(fwd, -v);
|
||||
if (keys.has('KeyD')) camera.position.addScaledVector(right, v);
|
||||
if (keys.has('KeyA')) camera.position.addScaledVector(right, -v);
|
||||
if (keys.has('KeyE')) camera.position.addScaledVector(up, v);
|
||||
if (keys.has('KeyQ')) camera.position.addScaledVector(up, -v);
|
||||
},
|
||||
dispose() {
|
||||
removeEventListener('keydown', onDown);
|
||||
removeEventListener('keyup', onUp);
|
||||
removeEventListener('blur', onBlur);
|
||||
dom.removeEventListener('mousedown', onMouseDown);
|
||||
removeEventListener('mouseup', onMouseUp);
|
||||
removeEventListener('mousemove', onMouseMove);
|
||||
},
|
||||
};
|
||||
}
|
||||
229
web/js/world/index.js
Normal file
229
web/js/world/index.js
Normal file
@ -0,0 +1,229 @@
|
||||
// world/index.js (Lane A) — createWorld(): THE WORLD CONTRACT (docs/TECH.md §THE WORLD
|
||||
// CONTRACT), implemented for real. Drop-in replacement for js/stub/world_stub.js: same
|
||||
// surface, same units, same semantics, so anything Lane B built against the stub keeps
|
||||
// working. Structure:
|
||||
//
|
||||
// spline.js the math (no three.js — headless-testable, runs in qa.sh)
|
||||
// biomes.js palette/param registry (ART_BIBLE values)
|
||||
// wall_material.js the synthetic-scanner shader (tube AND arena wear it)
|
||||
// tube.js chunked extrusion + streaming window
|
||||
// arena.js displaced icosphere shells, v0
|
||||
// flycam.js ?fly=1 noclip inspection camera (offered to F for boot wiring)
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { createRng } from '../core/rng.js';
|
||||
import { buildSpline, OMEGA } from './spline.js';
|
||||
import { biome as biomeOf } from './biomes.js';
|
||||
import { createWallMaterial } from './wall_material.js';
|
||||
import { createTube } from './tube.js';
|
||||
import { createArena } from './arena.js';
|
||||
|
||||
const SKIN = 0.6; // collision safety margin (units) — matches the stub
|
||||
|
||||
export async function createWorld(levelData, { rng, quality = 'high', assets = null } = {}) {
|
||||
const R = rng || createRng((levelData?.seed ?? 0) >>> 0);
|
||||
const spline = buildSpline(levelData, R);
|
||||
const arenaSpecs = Array.isArray(levelData.arenas) ? levelData.arenas : [];
|
||||
|
||||
// --- assets: optional, always (TECH.md §Asset manifest contract) -----------------------
|
||||
// A miss is not an error: the wall shader's procedural SEM path is the fallback, and it's
|
||||
// the look we ship until D's pack lands.
|
||||
//
|
||||
// Two entry points, deliberately. TECH.md names `get(category, name)` as THE contract, but
|
||||
// it returns the raw manifest entry — JSON, no THREE.Texture — so a shader can't eat it
|
||||
// without re-implementing D's loader. `assets.texture(name)` is the one that returns
|
||||
// {map, normalMap, tile}. We prefer it and keep `get` duck-typed as the documented floor.
|
||||
// (D and I independently landed on the same `tile` semantics — [repeats around theta, units
|
||||
// of s per repeat] — both read off the stub's uv. Noted for F to fold into TECH.md.)
|
||||
//
|
||||
// NB: tiling is applied in-shader from `tile`, NOT via texture.repeat — a raw ShaderMaterial
|
||||
// has no uvTransform, so D's pre-applied repeat is inert here. Don't "fix" it by removing
|
||||
// the uTile math.
|
||||
// Texture naming. D's round-1 manifest keys are wall_<slug>_a, and the slugs are NOT the
|
||||
// biome ids: `small_intestine` ships as `wall_smallint_a`. Because assets are optional, a
|
||||
// wrong key doesn't throw — it silently falls back to the procedural wall forever, which is
|
||||
// the worst kind of bug. Explicit map, so a mismatch is visible in one place.
|
||||
// -> Lane D: proposing we standardize on the biome ids in round 2 (LANE_A_NOTES §-> Lane D).
|
||||
const TEXTURE_SLUG = {
|
||||
oral: 'oral', esophagus: 'esophagus', stomach: 'stomach',
|
||||
small_intestine: 'smallint', large_intestine: 'colon', appendix: 'appendix',
|
||||
};
|
||||
const TEXTURE_FOR = (biomeId) => `wall_${TEXTURE_SLUG[biomeId] ?? biomeId}_a`;
|
||||
function detailFor(biomeId) {
|
||||
if (!assets) return { detail: null, tile: null };
|
||||
const name = TEXTURE_FOR(biomeId);
|
||||
try {
|
||||
if (typeof assets.texture === 'function') {
|
||||
const t = assets.texture(name);
|
||||
if (t && t.map) return { detail: t.map, tile: Array.isArray(t.tile) ? t.tile : null };
|
||||
}
|
||||
if (typeof assets.get === 'function') {
|
||||
const e = assets.get('textures', name);
|
||||
if (!e) return { detail: null, tile: null };
|
||||
const tex = e.isTexture ? e : (e.texture ?? e.map ?? null);
|
||||
return { detail: tex, tile: Array.isArray(e.tile) ? e.tile : null };
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[world] assets lookup failed for ${name}, using procedural wall —`, err.message);
|
||||
}
|
||||
return { detail: null, tile: null };
|
||||
}
|
||||
|
||||
// --- materials -------------------------------------------------------------------------
|
||||
// Tube: one material per biome, cached. Chunks never straddle a biome join, so live chunks
|
||||
// share a material and batch cleanly — this is why the draw count is ~7 and not ~700.
|
||||
const owned = []; // every material we create, for update() + dispose()
|
||||
const tubeMaterials = new Map();
|
||||
const radiusHintFor = (biomeId) => {
|
||||
const span = spline.spans.find((sp) => sp.seg.biome === biomeId);
|
||||
return span ? spline.paramAt((span.s0 + span.s1) / 2, 'base') : 10;
|
||||
};
|
||||
|
||||
function makeMaterial(biomeId, side, fog) {
|
||||
const b = biomeOf(biomeId);
|
||||
const { detail, tile } = detailFor(biomeId);
|
||||
const m = createWallMaterial({
|
||||
biome: b, omega: OMEGA, detail, tile, radiusHint: radiusHintFor(biomeId), side, fog,
|
||||
});
|
||||
owned.push(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
function tubeMaterialFor(biomeId) {
|
||||
if (!tubeMaterials.has(biomeId)) tubeMaterials.set(biomeId, makeMaterial(biomeId, THREE.FrontSide));
|
||||
return tubeMaterials.get(biomeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Arenas get their OWN material per room, because fog has to be sized to the room.
|
||||
* Biome fog is tuned for a corridor (legible to ~100 units, and it hides the streaming
|
||||
* edge). Reuse it in C's 360-unit-wide acid sea and the far wall sits at 86% fog — the room
|
||||
* renders as a black rectangle. Measured, not theorised: that's what the first arena shot
|
||||
* was. So: fade the far wall (2*radius) to ~70% void, and never fog *more* than the biome
|
||||
* would. Small lairs keep the biome's murk; big rooms open up.
|
||||
*/
|
||||
const arenaFog = (radius) => 1.09 / Math.max(1, radius); // ~70% void at the far wall
|
||||
function arenaMaterialFor(spec) {
|
||||
return makeMaterial(spec.biome, THREE.BackSide, Math.min(biomeOf(spec.biome).fog, arenaFog(spec.radius)));
|
||||
}
|
||||
|
||||
// --- geometry -------------------------------------------------------------------------
|
||||
const group = new THREE.Group();
|
||||
group.name = 'world';
|
||||
|
||||
const tube = createTube({
|
||||
spline,
|
||||
materialFor: tubeMaterialFor,
|
||||
quality,
|
||||
// An arena owns its whole s-span: skip the tube there or the corridor renders *inside* the
|
||||
// room and collide() would disagree with what you can see.
|
||||
skipSpans: arenaSpecs.map((a) => [a.at - a.radius, a.at + a.radius]),
|
||||
});
|
||||
group.add(tube.group);
|
||||
|
||||
const arenas = arenaSpecs.map((spec) => {
|
||||
const a = createArena({
|
||||
spec, spline, rng: R, quality,
|
||||
material: arenaMaterialFor(spec), // shell viewed from inside
|
||||
});
|
||||
group.add(a.mesh);
|
||||
return a;
|
||||
});
|
||||
|
||||
tube.prime(0);
|
||||
|
||||
// --- contract -------------------------------------------------------------------------
|
||||
let time = 0;
|
||||
|
||||
const biomeIdAt = (s) => spline.segmentAt(s).biome;
|
||||
const waveMaxAt = (s) => { const b = biomeOf(biomeIdAt(s)); return b.wave.amp + b.wave.breathe; };
|
||||
const arenaSpatial = (v) => arenas.find((a) => v.distanceTo(a.center) <= a.radius) || null;
|
||||
|
||||
function sample(s) {
|
||||
const f = spline.frameAt(s);
|
||||
return {
|
||||
pos: new THREE.Vector3(f.pos.x, f.pos.y, f.pos.z),
|
||||
tan: new THREE.Vector3(f.tan.x, f.tan.y, f.tan.z),
|
||||
nor: new THREE.Vector3(f.nor.x, f.nor.y, f.nor.z),
|
||||
bin: new THREE.Vector3(f.bin.x, f.bin.y, f.bin.z),
|
||||
radius: f.radius,
|
||||
};
|
||||
}
|
||||
|
||||
const world = {
|
||||
level: levelData,
|
||||
length: spline.length,
|
||||
group,
|
||||
|
||||
sample,
|
||||
|
||||
/** @param {THREE.Vector3} v @param {number} [hint] previous s — free accuracy for B */
|
||||
project: (v, hint) => spline.project(v, hint),
|
||||
|
||||
/** Conservative: geometry radius minus the wave's full amplitude. Time-independent on
|
||||
* purpose — a collision surface that breathed would have B's ship fighting the wall. */
|
||||
wallRho: (s, _theta) => spline.radiusAt(s) - waveMaxAt(s) - SKIN,
|
||||
|
||||
collide(v, r = 0) {
|
||||
const a = arenaSpatial(v);
|
||||
if (a) {
|
||||
const rel = v.clone().sub(a.center);
|
||||
const d = rel.length();
|
||||
const max = a.innerRadius - r;
|
||||
if (d <= max) return null;
|
||||
return { push: rel.normalize().multiplyScalar(max - d), kind: 'wall', biome: a.spec.biome };
|
||||
}
|
||||
const { s, theta, rho } = spline.project(v);
|
||||
const max = world.wallRho(s, theta) - r;
|
||||
if (rho <= max) return null;
|
||||
const f = spline.frameAt(s);
|
||||
const ct = Math.cos(theta), st = Math.sin(theta);
|
||||
const dir = new THREE.Vector3(
|
||||
f.nor.x * ct + f.bin.x * st,
|
||||
f.nor.y * ct + f.bin.y * st,
|
||||
f.nor.z * ct + f.bin.z * st,
|
||||
);
|
||||
return { push: dir.multiplyScalar(max - rho), kind: 'wall', biome: biomeIdAt(s) };
|
||||
},
|
||||
|
||||
biomeAt(s) {
|
||||
const b = biomeOf(biomeIdAt(s));
|
||||
// flow is read through the blended segment schedule, not the biome default: C tunes it
|
||||
// per segment and B's controller wants it continuous across joins.
|
||||
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'),
|
||||
arenaAt(s) {
|
||||
const a = arenas.find((x) => x.covers(s));
|
||||
return a ? { center: a.center, radius: a.radius } : null;
|
||||
},
|
||||
|
||||
/** JS mirror of the vertex shader's wave, exact. B: crest speed == biomeAt(s).flow, so a
|
||||
* ship riding a crest is riding the current. E: pulse the mix with it. */
|
||||
flowPulse: (s, t = time) => Math.pow(Math.max(0, Math.sin(spline.phaseAt(s) - OMEGA * t)), 3),
|
||||
|
||||
update(dt, playerS = 0) {
|
||||
time += dt;
|
||||
for (const m of owned) m.uniforms.uTime.value = time;
|
||||
tube.update(playerS);
|
||||
},
|
||||
|
||||
hash: () => spline.hash(),
|
||||
stats: () => ({ ...spline.stats(), chunks: tube.stats.live, built: tube.stats.built, disposed: tube.stats.disposed }),
|
||||
|
||||
dispose() {
|
||||
tube.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;
|
||||
tubeMaterials.clear();
|
||||
},
|
||||
|
||||
// not contract, but handy and cheap to expose
|
||||
spline,
|
||||
get time() { return time; },
|
||||
};
|
||||
|
||||
return world;
|
||||
}
|
||||
427
web/js/world/spline.js
Normal file
427
web/js/world/spline.js
Normal file
@ -0,0 +1,427 @@
|
||||
// world/spline.js (Lane A) — the math core of the canal. Deliberately imports NOTHING but
|
||||
// core/rng.js: no three.js, so `node web/js/world/spline.js --selfcheck` runs it headless in
|
||||
// qa.sh. index.js wraps these plain {x,y,z} numbers into THREE objects at the contract
|
||||
// boundary (docs/TECH.md §THE WORLD CONTRACT).
|
||||
//
|
||||
// Model
|
||||
// -----
|
||||
// The centreline is a graph over the -Z axis: P(u) = (X(u), Y(u), -u). Two consequences we
|
||||
// rely on everywhere: it can never self-intersect, and `u ≈ -pos.z` is a free, close seed for
|
||||
// project(). X/Y are sums of three seeded low-frequency sines, smoothstep-blended across
|
||||
// segment joins so the tangent never kinks.
|
||||
//
|
||||
// Amplitudes are NOT authored in units — they're solved from a curvature budget. A tube bends
|
||||
// through itself when the centreline's turn radius drops below the tube radius, so `curviness`
|
||||
// means "fraction of the tightest bend this pipe's radius can survive":
|
||||
// kappa_budget(s) = curviness(s) / (PINCH_SAFETY * baseRadius(s))
|
||||
// split across axes and octaves by fixed weights, then inverted per octave (A = w*kappa/f^2).
|
||||
// A wide pipe therefore straightens itself automatically and Lane C never has to think about
|
||||
// curvature. selfcheck measures the result (`pinchRatio`) rather than trusting this argument —
|
||||
// it caught the first version of this file, where amplitude was authored in units and a
|
||||
// curviness-0.95 join into a radius-16 segment folded the tube shut.
|
||||
//
|
||||
// u is NOT arclength (|dP/du| >= 1). Everything public is in arclength `s`, mapped through
|
||||
// LUTs built by one forward march.
|
||||
|
||||
import { createRng } from '../core/rng.js';
|
||||
|
||||
// --- tiny vec3 (plain objects; index.js converts) ---------------------------------------
|
||||
const V = (x = 0, y = 0, z = 0) => ({ x, y, z });
|
||||
const sub = (a, b) => V(a.x - b.x, a.y - b.y, a.z - b.z);
|
||||
const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
|
||||
const len = (a) => Math.sqrt(dot(a, a));
|
||||
const norm = (a) => { const l = len(a) || 1; return V(a.x / l, a.y / l, a.z / l); };
|
||||
const cross = (a, b) => V(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x);
|
||||
const lerpV = (a, b, t) => V(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t);
|
||||
const clamp = (x, lo, hi) => (x < lo ? lo : x > hi ? hi : x);
|
||||
const lerp = (a, b, t) => a + (b - a) * t;
|
||||
const smoothstep = (e0, e1, x) => { const t = clamp((x - e0) / (e1 - e0), 0, 1); return t * t * (3 - 2 * t); };
|
||||
|
||||
// --- tuning -----------------------------------------------------------------------------
|
||||
// OMEGA is the peristaltic contraction *rate*, global to the whole canal and constant in
|
||||
// time — physiologically it's how often the muscle fires, which doesn't change because you
|
||||
// crossed into a wider pipe. The wavenumber is what varies: k(s) = OMEGA / flow(s), so a
|
||||
// wave crest travels at exactly the local flow speed and Lane B can surf one. Phase is the
|
||||
// integral K(s) = ∫k ds (phaseAt), which stays continuous across flow changes — plain
|
||||
// `k*s - w*t` does not, and tears the wave at every segment join.
|
||||
// 3.08 = 0.22 rad/unit x 14 units/s, i.e. the stub's esophagus wave, preserved exactly.
|
||||
export const OMEGA = 3.08;
|
||||
|
||||
const DU = 0.5; // march step in curve parameter
|
||||
const DS = 0.5; // frame/phase LUT spacing in arclength
|
||||
const NOISE_TAB = 1024;
|
||||
const RADIUS_WAVELENGTH = 55; // units per radius-fbm octave-0 cycle
|
||||
const PINCH_SAFETY = 2.2; // min turn radius = this x base radius (>2 covers wobble peaks)
|
||||
|
||||
// Blend widths (units) for smoothstep-crossfading segment params across a join. Radius and
|
||||
// flow are local scalars — a tight 12u transition reads as a sphincter. Curviness feeds
|
||||
// centreline *amplitude*, which has a long lever arm: ramping it over a short span is itself a
|
||||
// hard lateral swerve, i.e. curvature. It gets a wide, gentle ramp.
|
||||
// `curvBase` is baseRadius again, but read through the wide ramp: it divides the curvature
|
||||
// budget, so a 12u step in it would swerve the centreline just as hard as curviness would.
|
||||
const BLEND = { curviness: 60, curvBase: 60, base: 12, wobble: 12, flow: 12 };
|
||||
|
||||
// [wavelength, share of the axis curvature budget]. Y is tamer than X on purpose: a canal that
|
||||
// writhes vertically as hard as it does laterally would swing the parallel-transport frame
|
||||
// around and make Lane B's chase cam seasick. Axis shares are set so that even with every
|
||||
// octave peaking together, sqrt(X^2 + Y^2) lands exactly on the budget.
|
||||
const OCT_X = [[240, 0.5], [110, 0.3], [60, 0.2]];
|
||||
const OCT_Y = [[190, 0.5], [85, 0.3], [45, 0.2]];
|
||||
const AXIS_X = 0.8, AXIS_Y = 0.6;
|
||||
|
||||
/** Solve each octave's amplitude from its share of a unit curvature budget: kappa ~ A*f^2. */
|
||||
function octaveCoefs(octaves, axisShare) {
|
||||
return octaves.map(([wavelength, weight]) => {
|
||||
const f = (2 * Math.PI) / wavelength;
|
||||
return { f, coef: (weight * axisShare) / (f * f) };
|
||||
});
|
||||
}
|
||||
|
||||
const GET = {
|
||||
curviness: (g) => (typeof g.curviness === 'number' ? g.curviness : 0.5),
|
||||
base: (g) => (g.radius && typeof g.radius.base === 'number' ? g.radius.base : 10),
|
||||
wobble: (g) => (g.radius && typeof g.radius.wobble === 'number' ? g.radius.wobble : 0.2),
|
||||
flow: (g) => (typeof g.flow === 'number' ? g.flow : 10),
|
||||
};
|
||||
GET.curvBase = GET.base;
|
||||
|
||||
/**
|
||||
* Build the canal math for a level. Deterministic: same `level.seed` (or injected rng) =>
|
||||
* byte-identical output, forever. See world.hash().
|
||||
* @param {object} level level JSON (TECH.md §Level data schema v0)
|
||||
* @param {function} [rng] core/rng.js createRng() instance; defaults to level.seed
|
||||
*/
|
||||
export function buildSpline(level, rng) {
|
||||
if (!level || !Array.isArray(level.segments) || level.segments.length === 0)
|
||||
throw new Error('[world] level.segments is required and must be non-empty');
|
||||
const R = rng || createRng((level.seed ?? 0) >>> 0);
|
||||
|
||||
// --- segment table (declared arclength boundaries; C's events index this same s) -------
|
||||
const spans = [];
|
||||
let acc = 0;
|
||||
for (const seg of level.segments) {
|
||||
const length = typeof seg.length === 'number' && seg.length > 0 ? seg.length : 100;
|
||||
spans.push({ s0: acc, s1: acc + length, seg });
|
||||
acc += length;
|
||||
}
|
||||
const L = acc;
|
||||
|
||||
function idxAt(s) {
|
||||
// linear scan: levels have a handful of segments, and a branchy binary search here would
|
||||
// cost more than it saves.
|
||||
for (let i = 0; i < spans.length; i++) if (s < spans[i].s1) return i;
|
||||
return spans.length - 1;
|
||||
}
|
||||
|
||||
/** Segment param at s, smoothstep-blended across joins so nothing steps discontinuously. */
|
||||
function paramAt(s, key) {
|
||||
const get = GET[key], w = BLEND[key];
|
||||
const i = idxAt(s);
|
||||
const sp = spans[i];
|
||||
const v = get(sp.seg);
|
||||
if (i > 0 && s < sp.s0 + w)
|
||||
return lerp(get(spans[i - 1].seg), v, smoothstep(sp.s0 - w, sp.s0 + w, s));
|
||||
if (i < spans.length - 1 && s > sp.s1 - w)
|
||||
return lerp(v, get(spans[i + 1].seg), smoothstep(sp.s1 - w, sp.s1 + w, s));
|
||||
return v;
|
||||
}
|
||||
|
||||
// --- seeded noise ---------------------------------------------------------------------
|
||||
const tab = new Float32Array(NOISE_TAB);
|
||||
{ const r = R('world.radius'); for (let i = 0; i < NOISE_TAB; i++) tab[i] = r() * 2 - 1; }
|
||||
function vnoise(x) {
|
||||
const i = Math.floor(x), f = x - i;
|
||||
const a = tab[i & (NOISE_TAB - 1)], b = tab[(i + 1) & (NOISE_TAB - 1)];
|
||||
return lerp(a, b, f * f * (3 - 2 * f));
|
||||
}
|
||||
function fbm1(x) {
|
||||
let sum = 0, amp = 1, freq = 1, normSum = 0;
|
||||
for (let o = 0; o < 3; o++) { sum += amp * vnoise(x * freq + o * 17.3); normSum += amp; amp *= 0.5; freq *= 2.03; }
|
||||
return sum / normSum; // [-1, 1]
|
||||
}
|
||||
const radiusAt = (s) => paramAt(s, 'base') * (1 + paramAt(s, 'wobble') * fbm1(s / RADIUS_WAVELENGTH));
|
||||
|
||||
// --- centreline -----------------------------------------------------------------------
|
||||
const cx = octaveCoefs(OCT_X, AXIS_X), cy = octaveCoefs(OCT_Y, AXIS_Y);
|
||||
const phX = cx.map(() => R('world.spline')() * Math.PI * 2);
|
||||
const phY = cy.map(() => R('world.spline')() * Math.PI * 2);
|
||||
|
||||
/** How hard the canal is allowed to bend at s: curviness as a fraction of the pinch limit. */
|
||||
const curvBudgetAt = (s) => paramAt(s, 'curviness') / (PINCH_SAFETY * Math.max(1, paramAt(s, 'curvBase')));
|
||||
|
||||
/** `budget` is kappa (1/units), not a length — octave coefs turn it into lateral offsets. */
|
||||
function centreWith(u, budget) {
|
||||
let x = 0, y = 0;
|
||||
for (let i = 0; i < cx.length; i++) x += budget * cx[i].coef * Math.sin(u * cx[i].f + phX[i]);
|
||||
for (let i = 0; i < cy.length; i++) y += budget * cy[i].coef * Math.sin(u * cy[i].f + phY[i]);
|
||||
return V(x, y, -u);
|
||||
}
|
||||
|
||||
// --- the march: u -> s, and the curvature budget sampled along it ----------------------
|
||||
// The budget is a function of arclength (that's where segments are declared), but the curve
|
||||
// is evaluated in u — so we resolve it during the march, where s is already known, and
|
||||
// cache the schedule per u-step. One pass, no circularity.
|
||||
const uToS = [0];
|
||||
const ampArr = [curvBudgetAt(0)];
|
||||
{
|
||||
let s = 0, prev = centreWith(0, ampArr[0]), n = 0;
|
||||
const MAXN = Math.ceil((L / DU) * 1.5) + 16;
|
||||
while (s < L && n < MAXN) {
|
||||
n++;
|
||||
const amp = curvBudgetAt(s); // s lags one step: deterministic, sub-step error
|
||||
const p = centreWith(n * DU, amp);
|
||||
s += len(sub(p, prev));
|
||||
uToS.push(s); ampArr.push(amp);
|
||||
prev = p;
|
||||
}
|
||||
}
|
||||
const uMax = (uToS.length - 1) * DU;
|
||||
const ampAtU = (u) => {
|
||||
const t = clamp(u / DU, 0, ampArr.length - 1), i = Math.min(Math.floor(t), ampArr.length - 2);
|
||||
return lerp(ampArr[i], ampArr[i + 1], t - i);
|
||||
};
|
||||
const centre = (u) => centreWith(u, ampAtU(u));
|
||||
const sOfU = (u) => {
|
||||
const t = clamp(u / DU, 0, uToS.length - 1), i = Math.min(Math.floor(t), uToS.length - 2);
|
||||
return lerp(uToS[i], uToS[i + 1], t - i);
|
||||
};
|
||||
|
||||
// uniform s -> u table, inverted by a monotone merge-walk (uToS is strictly increasing)
|
||||
const M = Math.ceil(L / DS) + 1;
|
||||
const sToU = new Float64Array(M);
|
||||
{
|
||||
let j = 0;
|
||||
for (let i = 0; i < M; i++) {
|
||||
const target = Math.min(i * DS, L);
|
||||
while (j < uToS.length - 2 && uToS[j + 1] < target) j++;
|
||||
const s0 = uToS[j], s1 = uToS[j + 1];
|
||||
const f = s1 > s0 ? (target - s0) / (s1 - s0) : 0;
|
||||
sToU[i] = (j + f) * DU;
|
||||
}
|
||||
}
|
||||
const uOfS = (s) => {
|
||||
const t = clamp(s, 0, L) / DS, i = Math.min(Math.floor(t), M - 2);
|
||||
return lerp(sToU[i], sToU[i + 1], t - i);
|
||||
};
|
||||
|
||||
// --- frames: parallel transport, NOT Frenet -------------------------------------------
|
||||
// Frenet's normal flips through inflection points; on a canal that writhes that shows up as
|
||||
// the camera snapping upside-down mid-corridor (LANE_A_WORLD.md calls this out by name).
|
||||
// Parallel transport carries the previous normal forward by the minimal rotation, so it's
|
||||
// history-dependent => must be integrated once here and looked up, never recomputed ad hoc.
|
||||
const posArr = new Float64Array(M * 3);
|
||||
const tanArr = new Float64Array(M * 3);
|
||||
const norArr = new Float64Array(M * 3);
|
||||
{
|
||||
const H = 0.25;
|
||||
const tangentAtU = (u) => {
|
||||
const a = centre(Math.max(0, u - H)), b = centre(Math.min(uMax, u + H));
|
||||
return norm(sub(b, a));
|
||||
};
|
||||
const put = (arr, i, v) => { arr[i * 3] = v.x; arr[i * 3 + 1] = v.y; arr[i * 3 + 2] = v.z; };
|
||||
|
||||
let prevT = tangentAtU(uOfS(0));
|
||||
// seed the frame from world up; the canal is a graph over -Z so it can never be vertical
|
||||
let n = norm(sub(V(0, 1, 0), V(prevT.x * prevT.y, prevT.y * prevT.y, prevT.z * prevT.y)));
|
||||
put(posArr, 0, centre(uOfS(0))); put(tanArr, 0, prevT); put(norArr, 0, n);
|
||||
|
||||
for (let i = 1; i < M; i++) {
|
||||
const u = uOfS(Math.min(i * DS, L));
|
||||
const p = centre(u), t = tangentAtU(u);
|
||||
const axis = cross(prevT, t);
|
||||
const sinA = len(axis), cosA = dot(prevT, t);
|
||||
if (sinA > 1e-9) { // Rodrigues: rotate n by the frame's own turn
|
||||
const k = norm(axis), angle = Math.atan2(sinA, cosA);
|
||||
const c = Math.cos(angle), sN = Math.sin(angle), kv = cross(k, n), kd = dot(k, n);
|
||||
n = V(n.x * c + kv.x * sN + k.x * kd * (1 - c),
|
||||
n.y * c + kv.y * sN + k.y * kd * (1 - c),
|
||||
n.z * c + kv.z * sN + k.z * kd * (1 - c));
|
||||
}
|
||||
n = norm(V(n.x - t.x * dot(n, t), n.y - t.y * dot(n, t), n.z - t.z * dot(n, t))); // re-orthogonalize
|
||||
put(posArr, i, p); put(tanArr, i, t); put(norArr, i, n);
|
||||
prevT = t;
|
||||
}
|
||||
}
|
||||
const at = (arr, i) => V(arr[i * 3], arr[i * 3 + 1], arr[i * 3 + 2]);
|
||||
|
||||
// --- peristalsis phase: K(s) = ∫ k dx, trapezoid on the same grid ----------------------
|
||||
const kAt = (s) => OMEGA / Math.max(1, paramAt(s, 'flow'));
|
||||
const phaseArr = new Float64Array(M);
|
||||
for (let i = 1; i < M; i++)
|
||||
phaseArr[i] = phaseArr[i - 1] + 0.5 * (kAt((i - 1) * DS) + kAt(i * DS)) * DS;
|
||||
const phaseAt = (s) => {
|
||||
const t = clamp(s, 0, L) / DS, i = Math.min(Math.floor(t), M - 2);
|
||||
return lerp(phaseArr[i], phaseArr[i + 1], t - i);
|
||||
};
|
||||
|
||||
/** Frame at arclength s. pos/tan/nor/bin + geometric radius. */
|
||||
function frameAt(s) {
|
||||
const t = clamp(s, 0, L) / DS, i = Math.min(Math.floor(t), M - 2), f = t - i;
|
||||
const pos = lerpV(at(posArr, i), at(posArr, i + 1), f);
|
||||
const tan = norm(lerpV(at(tanArr, i), at(tanArr, i + 1), f));
|
||||
let nor = lerpV(at(norArr, i), at(norArr, i + 1), f);
|
||||
nor = norm(V(nor.x - tan.x * dot(nor, tan), nor.y - tan.y * dot(nor, tan), nor.z - tan.z * dot(nor, tan)));
|
||||
return { pos, tan, nor, bin: cross(tan, nor), radius: radiusAt(s) };
|
||||
}
|
||||
|
||||
/**
|
||||
* World -> spline space. Seeded from u = -pos.z (exact for the centreline's own axis, so
|
||||
* the seed error is only the lateral tilt), then fixed-point: s += (p - C(s))·T(s).
|
||||
* Converges at rate ~ curvature*rho (<0.5 in tube mode). Step-clamped so a point far off
|
||||
* the centreline (arena mode) walks instead of exploding.
|
||||
*/
|
||||
function project(p, hint) {
|
||||
let s = typeof hint === 'number' ? clamp(hint, 0, L) : sOfU(clamp(-p.z, 0, uMax));
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const f = frameAt(s);
|
||||
const d = dot(sub(p, f.pos), f.tan);
|
||||
const next = clamp(s + clamp(d, -8, 8), 0, L);
|
||||
if (Math.abs(next - s) < 1e-4) { s = next; break; }
|
||||
s = next;
|
||||
}
|
||||
const f = frameAt(s);
|
||||
const rel = sub(p, f.pos);
|
||||
const along = dot(rel, f.tan);
|
||||
return {
|
||||
s,
|
||||
theta: Math.atan2(dot(rel, f.bin), dot(rel, f.nor)),
|
||||
rho: Math.sqrt(Math.max(0, dot(rel, rel) - along * along)), // true cross-section radius
|
||||
};
|
||||
}
|
||||
|
||||
/** Golden determinism hash over sampled frames + radius + wave phase. qa.sh compares. */
|
||||
function hash() {
|
||||
let h = 2166136261 >>> 0;
|
||||
const push = (x) => { h ^= Math.round(x * 1000) & 0xffff; h = Math.imul(h, 16777619); };
|
||||
for (let s = 0; s <= L; s += 10) {
|
||||
const f = frameAt(s);
|
||||
push(f.pos.x); push(f.pos.y); push(f.pos.z); push(f.radius);
|
||||
push(f.nor.x); push(f.nor.y); push(f.nor.z); push(phaseAt(s));
|
||||
}
|
||||
return (h >>> 0).toString(16);
|
||||
}
|
||||
|
||||
/** Measured geometry health — the pinch guard is the one that bites. */
|
||||
function stats() {
|
||||
let maxCurvature = 0, sAtMax = 0, maxRadius = 0, minRadius = Infinity;
|
||||
for (let i = 1; i < M - 1; i++) {
|
||||
const k = len(sub(at(tanArr, i + 1), at(tanArr, i - 1))) / (2 * DS);
|
||||
if (k > maxCurvature) { maxCurvature = k; sAtMax = i * DS; }
|
||||
}
|
||||
for (let s = 0; s <= L; s += 1) {
|
||||
const r = radiusAt(s);
|
||||
if (r > maxRadius) maxRadius = r;
|
||||
if (r < minRadius) minRadius = r;
|
||||
}
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
length: L, spans, uMax,
|
||||
centre, sOfU, uOfS, ampAtU,
|
||||
radiusAt, frameAt, project, phaseAt, kAt, paramAt,
|
||||
omega: OMEGA,
|
||||
hash, stats,
|
||||
segmentAt: (s) => spans[idxAt(clamp(s, 0, L))].seg,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// headless selfcheck: `node web/js/world/spline.js --selfcheck` (qa.sh gate)
|
||||
// Browsers never reach this (no `process`).
|
||||
// ---------------------------------------------------------------------------------------
|
||||
async function selfcheck() {
|
||||
const { FIXTURE } = await import('./_fixture.js');
|
||||
let fail = 0;
|
||||
const ok = (name, cond, detail = '') => {
|
||||
if (cond) console.log(` \x1b[32m✓\x1b[0m ${name}${detail ? ' — ' + detail : ''}`);
|
||||
else { fail++; console.log(` \x1b[31m✗\x1b[0m ${name}${detail ? ' — ' + detail : ''}`); }
|
||||
};
|
||||
console.log('world/spline selfcheck (fixture: %s)', FIXTURE.id);
|
||||
|
||||
const a = buildSpline(FIXTURE);
|
||||
const b = buildSpline(FIXTURE);
|
||||
const c = buildSpline({ ...FIXTURE, seed: FIXTURE.seed + 1 });
|
||||
const st = a.stats();
|
||||
|
||||
ok('determinism: same seed => same hash', a.hash() === b.hash(), a.hash());
|
||||
ok('determinism: different seed => different hash', a.hash() !== c.hash(), `${a.hash()} vs ${c.hash()}`);
|
||||
|
||||
const declared = FIXTURE.segments.reduce((n, s) => n + s.length, 0);
|
||||
ok('arclength: length == declared total', Math.abs(a.length - declared) < 1e-6, `${a.length}`);
|
||||
|
||||
let mono = true, prev = -1;
|
||||
for (let s = 0; s <= a.length; s += 1) { const u = a.uOfS(s); if (u < prev) mono = false; prev = u; }
|
||||
ok('arclength: s -> u monotone', mono);
|
||||
|
||||
let maxRt = 0;
|
||||
for (let s = 0; s <= a.length; s += 3) maxRt = Math.max(maxRt, Math.abs(a.sOfU(a.uOfS(s)) - s));
|
||||
ok('arclength: s -> u -> s round-trip < 0.05', maxRt < 0.05, `max err ${maxRt.toExponential(2)}`);
|
||||
|
||||
let orth = 0, unit = 0;
|
||||
for (let s = 0; s <= a.length; s += 2) {
|
||||
const f = a.frameAt(s);
|
||||
orth = Math.max(orth, Math.abs(f.tan.x * f.nor.x + f.tan.y * f.nor.y + f.tan.z * f.nor.z));
|
||||
unit = Math.max(unit, Math.abs(Math.hypot(f.nor.x, f.nor.y, f.nor.z) - 1), Math.abs(Math.hypot(f.bin.x, f.bin.y, f.bin.z) - 1));
|
||||
}
|
||||
ok('frames: orthonormal (tan·nor ~ 0, |nor| = |bin| = 1)', orth < 1e-9 && unit < 1e-9,
|
||||
`tan·nor ${orth.toExponential(1)}, unit err ${unit.toExponential(1)}`);
|
||||
|
||||
// parallel transport must not spin the frame up: total twist stays bounded & smooth
|
||||
let maxTwist = 0;
|
||||
for (let s = 1; s <= a.length; s += 1) {
|
||||
const p = a.frameAt(s - 1).nor, q = a.frameAt(s).nor;
|
||||
maxTwist = Math.max(maxTwist, Math.acos(Math.min(1, Math.abs(p.x * q.x + p.y * q.y + p.z * q.z))));
|
||||
}
|
||||
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`);
|
||||
|
||||
// project() round-trip: place a point at a known (s, theta, rho), recover it
|
||||
let maxSerr = 0, maxTerr = 0, maxRerr = 0;
|
||||
for (let s = 5; s < a.length - 5; s += 7) {
|
||||
for (const theta of [0, 1.3, -2.4, 3.0]) {
|
||||
const rho = a.radiusAt(s) * 0.6;
|
||||
const f = a.frameAt(s);
|
||||
const p = {
|
||||
x: f.pos.x + (f.nor.x * Math.cos(theta) + f.bin.x * Math.sin(theta)) * rho,
|
||||
y: f.pos.y + (f.nor.y * Math.cos(theta) + f.bin.y * Math.sin(theta)) * rho,
|
||||
z: f.pos.z + (f.nor.z * Math.cos(theta) + f.bin.z * Math.sin(theta)) * rho,
|
||||
};
|
||||
const q = a.project(p);
|
||||
maxSerr = Math.max(maxSerr, Math.abs(q.s - s));
|
||||
let dt = Math.abs(q.theta - theta) % (Math.PI * 2);
|
||||
if (dt > Math.PI) dt = Math.PI * 2 - dt;
|
||||
maxTerr = Math.max(maxTerr, dt);
|
||||
maxRerr = Math.max(maxRerr, Math.abs(q.rho - rho));
|
||||
}
|
||||
}
|
||||
ok('project: recovers s within 0.25', maxSerr < 0.25, `max ${maxSerr.toFixed(4)}`);
|
||||
ok('project: recovers theta within 0.02 rad', maxTerr < 0.02, `max ${maxTerr.toFixed(5)}`);
|
||||
ok('project: recovers rho within 0.05', maxRerr < 0.05, `max ${maxRerr.toFixed(4)}`);
|
||||
|
||||
// wave: phase monotone, and crest speed == local flow (the whole point of K(s))
|
||||
let phaseMono = true;
|
||||
for (let s = 1; s <= a.length; s += 1) if (a.phaseAt(s) <= a.phaseAt(s - 1)) phaseMono = false;
|
||||
ok('wave: phase K(s) strictly increasing', phaseMono);
|
||||
const kEso = a.kAt(50), kSto = a.kAt(a.length - 20);
|
||||
ok('wave: k == OMEGA/flow (esophagus flow 14 => k 0.22, matches stub)', Math.abs(kEso - 0.22) < 0.001, `k=${kEso.toFixed(4)}`);
|
||||
ok('wave: crest speed == local flow in each biome',
|
||||
Math.abs(OMEGA / kEso - 14) < 0.01 && Math.abs(OMEGA / kSto - 4) < 0.01,
|
||||
`${(OMEGA / kEso).toFixed(2)} u/s eso, ${(OMEGA / kSto).toFixed(2)} u/s stomach`);
|
||||
|
||||
console.log(` stats: L=${st.length} grid=${st.gridPoints} maxK=${st.maxCurvature.toFixed(4)} (s=${st.sAtMaxCurvature}) radius=${st.minRadius.toFixed(1)}..${st.maxRadius.toFixed(1)} hash=${a.hash()}`);
|
||||
console.log(fail === 0 ? '\x1b[32mworld/spline: OK\x1b[0m' : `\x1b[31mworld/spline: ${fail} FAILED\x1b[0m`);
|
||||
return fail;
|
||||
}
|
||||
|
||||
if (typeof process !== 'undefined' && process.argv && process.argv.includes('--selfcheck')) {
|
||||
selfcheck().then((f) => process.exit(f === 0 ? 0 : 1));
|
||||
}
|
||||
149
web/js/world/tube.js
Normal file
149
web/js/world/tube.js
Normal file
@ -0,0 +1,149 @@
|
||||
// world/tube.js (Lane A) — chunked tube geometry + the streaming window.
|
||||
//
|
||||
// The canal is extruded as rings along the parallel-transport frames and cut into chunks of
|
||||
// ~CHUNK_LEN. Chunks are built/disposed around playerS so a 3000-unit level costs the same as
|
||||
// a 300-unit one. Chunk seams are aligned to segment (biome) boundaries so a chunk never spans
|
||||
// two biomes: each chunk gets exactly one material, and the tint changes exactly at the
|
||||
// anatomical join — which is where a sphincter is anyway (GDD §structure).
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
export function createTube({ spline, materialFor, quality = 'high', skipSpans = [] }) {
|
||||
const Q = quality === 'low'
|
||||
? { radial: 48, step: 0.75, ahead: 120, behind: 45 }
|
||||
: { radial: 64, step: 0.5, ahead: 180, behind: 60 };
|
||||
const CHUNK_LEN = 40;
|
||||
const BUILD_BUDGET = 2; // chunks per update: a boost must not stall the frame
|
||||
|
||||
const group = new THREE.Group();
|
||||
group.name = 'tube';
|
||||
|
||||
// --- chunk plan: subdivide each segment span, never straddle a biome join --------------
|
||||
// Chunks whose midpoint falls inside an arena are dropped: the arena shell is the wall
|
||||
// there. The seam is therefore chunk-quantized (+/-CHUNK_LEN/2) — good enough for v0, and
|
||||
// round 2 replaces it with real sphincter joint geometry anyway (LANE_A_NOTES).
|
||||
const skipped = (s) => skipSpans.some(([a, b]) => s >= a && s <= b);
|
||||
const plan = [];
|
||||
for (const span of spline.spans) {
|
||||
const len = span.s1 - span.s0;
|
||||
const n = Math.max(1, Math.round(len / CHUNK_LEN));
|
||||
for (let i = 0; i < n; i++) {
|
||||
const s0 = span.s0 + (len * i) / n;
|
||||
const s1 = span.s0 + (len * (i + 1)) / n;
|
||||
if (skipped((s0 + s1) / 2)) continue;
|
||||
plan.push({ s0, s1, biomeId: span.seg.biome });
|
||||
}
|
||||
}
|
||||
|
||||
const live = new Map(); // plan index -> THREE.Mesh
|
||||
const stats = { built: 0, disposed: 0, get live() { return live.size; } };
|
||||
|
||||
function buildChunk(i) {
|
||||
const c = plan[i];
|
||||
const rings = Math.max(2, Math.round((c.s1 - c.s0) / Q.step) + 1);
|
||||
const cols = Q.radial + 1; // duplicate the seam column so uv.x runs 0..1 without
|
||||
// wrapping backwards across the last quad (visible streak)
|
||||
const n = rings * cols;
|
||||
const position = new Float32Array(n * 3);
|
||||
const aInward = new Float32Array(n * 3);
|
||||
const aTangent = new Float32Array(n * 3);
|
||||
const uv = new Float32Array(n * 2);
|
||||
const aPhase = new Float32Array(n);
|
||||
const aK = new Float32Array(n);
|
||||
|
||||
let p = 0, q = 0, w = 0;
|
||||
for (let r = 0; r < rings; r++) {
|
||||
const s = c.s0 + ((c.s1 - c.s0) * r) / (rings - 1);
|
||||
const f = spline.frameAt(s);
|
||||
const phase = spline.phaseAt(s);
|
||||
const k = spline.kAt(s);
|
||||
for (let j = 0; j < cols; j++) {
|
||||
const th = (j / Q.radial) * TAU;
|
||||
const ct = Math.cos(th), st = Math.sin(th);
|
||||
const dx = f.nor.x * ct + f.bin.x * st;
|
||||
const dy = f.nor.y * ct + f.bin.y * st;
|
||||
const dz = f.nor.z * ct + f.bin.z * st;
|
||||
position[p] = f.pos.x + dx * f.radius;
|
||||
position[p + 1] = f.pos.y + dy * f.radius;
|
||||
position[p + 2] = f.pos.z + dz * f.radius;
|
||||
aInward[p] = -dx; aInward[p + 1] = -dy; aInward[p + 2] = -dz;
|
||||
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; w++;
|
||||
}
|
||||
}
|
||||
|
||||
const idx = new (n > 65535 ? Uint32Array : Uint16Array)((rings - 1) * Q.radial * 6);
|
||||
let t = 0;
|
||||
for (let r = 0; r < rings - 1; r++) {
|
||||
for (let j = 0; j < Q.radial; j++) {
|
||||
const a = r * cols + j, b = a + 1, cc = a + cols, d = b + cols;
|
||||
idx[t++] = a; idx[t++] = cc; idx[t++] = b; // wound to face inward
|
||||
idx[t++] = b; idx[t++] = cc; idx[t++] = d;
|
||||
}
|
||||
}
|
||||
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(position, 3));
|
||||
geo.setAttribute('aInward', new THREE.BufferAttribute(aInward, 3));
|
||||
geo.setAttribute('aTangent', new THREE.BufferAttribute(aTangent, 3));
|
||||
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
||||
geo.setAttribute('aPhase', new THREE.BufferAttribute(aPhase, 1));
|
||||
geo.setAttribute('aK', new THREE.BufferAttribute(aK, 1));
|
||||
geo.setIndex(new THREE.BufferAttribute(idx, 1));
|
||||
geo.computeBoundingSphere();
|
||||
geo.boundingSphere.radius += 2; // the vertex shader displaces inward; keep culling honest
|
||||
|
||||
const mesh = new THREE.Mesh(geo, materialFor(c.biomeId));
|
||||
mesh.name = `tube[${i}] ${c.biomeId} ${c.s0.toFixed(0)}..${c.s1.toFixed(0)}`;
|
||||
group.add(mesh);
|
||||
live.set(i, mesh);
|
||||
stats.built++;
|
||||
}
|
||||
|
||||
function disposeChunk(i) {
|
||||
const mesh = live.get(i);
|
||||
if (!mesh) return;
|
||||
group.remove(mesh);
|
||||
mesh.geometry.dispose(); // material is shared per biome; index.js owns it
|
||||
live.delete(i);
|
||||
stats.disposed++;
|
||||
}
|
||||
|
||||
function wanted(playerS) {
|
||||
const lo = playerS - Q.behind, hi = playerS + Q.ahead;
|
||||
const set = new Set();
|
||||
for (let i = 0; i < plan.length; i++) if (plan[i].s1 >= lo && plan[i].s0 <= hi) set.add(i);
|
||||
return set;
|
||||
}
|
||||
|
||||
return {
|
||||
group,
|
||||
stats,
|
||||
chunkCount: plan.length,
|
||||
quality: Q,
|
||||
|
||||
/** Synchronous full fill of the window — used once at load, inside the <2s budget. */
|
||||
prime(playerS = 0) {
|
||||
for (const i of wanted(playerS)) if (!live.has(i)) buildChunk(i);
|
||||
},
|
||||
|
||||
update(playerS) {
|
||||
const want = wanted(playerS);
|
||||
for (const i of live.keys()) if (!want.has(i)) disposeChunk(i);
|
||||
let budget = BUILD_BUDGET;
|
||||
for (const i of want) {
|
||||
if (live.has(i)) continue;
|
||||
buildChunk(i);
|
||||
if (--budget <= 0) break;
|
||||
}
|
||||
},
|
||||
|
||||
dispose() {
|
||||
for (const i of [...live.keys()]) disposeChunk(i);
|
||||
},
|
||||
};
|
||||
}
|
||||
114
web/js/world/wall_material.js
Normal file
114
web/js/world/wall_material.js
Normal file
@ -0,0 +1,114 @@
|
||||
// world/wall_material.js (Lane A) — the synthetic-scanner wall (ART_BIBLE.md §Rendering recipe).
|
||||
// No real-time lights anywhere in GUTS: the wall is biome tint x detail luminance + a fresnel
|
||||
// rim (the SEM edge glow) + exponential fog to the biome void. Peristalsis is vertex work.
|
||||
//
|
||||
// Attributes the geometry must supply (tube.js and arena.js both do):
|
||||
// position vec3 baked ring/shell vertex
|
||||
// aInward vec3 unit normal pointing into the playable space
|
||||
// aTangent vec3 unit centreline tangent at this vertex (for the wave's normal tilt)
|
||||
// uv vec2 (theta/2pi, s) — s in world units, NOT normalized: it's the wave's phase
|
||||
// axis and the tiling axis, and it must stay continuous across chunks
|
||||
// aPhase float K(s) = ∫k ds, baked (see spline.js)
|
||||
// aK float local wavenumber, for the analytic d(pulse)/ds
|
||||
//
|
||||
// Colorspace: like the stub, this writes its computed color straight out with no
|
||||
// <colorspace_fragment> conversion. That's a whole-game decision (it moves every color at
|
||||
// once) so it stays matched to F's round-0 look until F rules on it — see LANE_A_NOTES.
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
export function createWallMaterial({
|
||||
biome,
|
||||
omega,
|
||||
fog = biome.fog, // overridable: arenas size their own fog to the room (see index.js)
|
||||
waveAmp = biome.wave.amp,
|
||||
breatheAmp = biome.wave.breathe,
|
||||
detail = null, // THREE.Texture | null — Lane D's grayscale detail map
|
||||
tile = null, // [repeatsAroundCircumference, unitsOfSPerRepeat]
|
||||
radiusHint = 10, // used only to pick a square-ish default tiling
|
||||
side = THREE.FrontSide,
|
||||
}) {
|
||||
if (detail) { // defensive: we consume D's texture, so we set what we depend on
|
||||
detail.wrapS = detail.wrapT = THREE.RepeatWrapping;
|
||||
detail.needsUpdate = true;
|
||||
}
|
||||
const tileAround = tile ? tile[0] : 3;
|
||||
const tileAlong = tile ? tile[1] : Math.max(4, (2 * Math.PI * radiusHint) / tileAround);
|
||||
|
||||
return new THREE.ShaderMaterial({
|
||||
side,
|
||||
defines: detail ? { USE_DETAIL: '' } : {},
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uTint: { value: new THREE.Color(biome.palette.tint) },
|
||||
uRim: { value: new THREE.Color(biome.palette.rim) },
|
||||
uVoid: { value: new THREE.Color(biome.palette.void) },
|
||||
uFog: { value: fog },
|
||||
uWaveA: { value: waveAmp },
|
||||
uBreatheA: { value: breatheAmp },
|
||||
uOmega: { value: omega },
|
||||
uRimPow: { value: 2.2 },
|
||||
uRimGain: { value: 0.9 },
|
||||
uDetail: { value: detail },
|
||||
uTile: { value: new THREE.Vector2(tileAround, tileAlong) },
|
||||
},
|
||||
vertexShader: /* glsl */`
|
||||
attribute vec3 aInward;
|
||||
attribute vec3 aTangent;
|
||||
attribute float aPhase;
|
||||
attribute float aK;
|
||||
uniform float uTime, uWaveA, uBreatheA, uOmega;
|
||||
varying vec2 vUv; varying vec3 vN; varying vec3 vView; varying float vPulse;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
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
|
||||
float breathe = uBreatheA * sin(uv.y * 0.7 + uTime * 0.8) * sin(uv.x * 6.2831853 * 3.0);
|
||||
float disp = uWaveA * pulse + breathe;
|
||||
|
||||
// Tilt the normal with the wave. Without this the crests are silhouette-only and the
|
||||
// rim light slides over them as if the wall were flat — the effective wall radius is
|
||||
// rho(s) = radius - disp, so the inward normal leans along the tangent by d(rho)/ds.
|
||||
float dPulse_ds = 3.0 * sn * sn * cos(phi) * aK;
|
||||
vec3 nIn = normalize(aInward - aTangent * (uWaveA * dPulse_ds));
|
||||
|
||||
vec4 mv = modelViewMatrix * vec4(position + aInward * disp, 1.0);
|
||||
vN = normalize(normalMatrix * nIn);
|
||||
vView = -mv.xyz;
|
||||
vPulse = pulse;
|
||||
gl_Position = projectionMatrix * mv;
|
||||
}`,
|
||||
fragmentShader: /* glsl */`
|
||||
uniform vec3 uTint, uRim, uVoid;
|
||||
uniform float uFog, uRimPow, uRimGain;
|
||||
#ifdef USE_DETAIL
|
||||
uniform sampler2D uDetail;
|
||||
uniform vec2 uTile;
|
||||
#endif
|
||||
varying vec2 vUv; varying vec3 vN; varying vec3 vView; varying float vPulse;
|
||||
|
||||
void main() {
|
||||
#ifdef USE_DETAIL
|
||||
// Lane D authors grayscale; the biome tint is applied here, so one texture can serve
|
||||
// two biomes at different tints (ART_BIBLE §FLUX prompt kit).
|
||||
float detail = texture2D(uDetail, vec2(vUv.x * uTile.x, vUv.y / uTile.y)).r;
|
||||
detail = mix(0.5, 1.2, detail);
|
||||
#else
|
||||
// Assets-optional law: no texture is the *shipping* look until D lands, not an error
|
||||
// state. Ridged folds around theta + striation along s = a passable SEM stand-in.
|
||||
float folds = 0.55 + 0.45 * sin(vUv.x * 6.2831853 * 9.0 + sin(vUv.y * 0.9) * 2.0);
|
||||
float detail = folds * (0.85 + 0.15 * sin(vUv.y * 2.2));
|
||||
#endif
|
||||
|
||||
vec3 base = uTint * detail * 0.8;
|
||||
float fres = pow(1.0 - abs(dot(normalize(vN), normalize(vView))), uRimPow);
|
||||
// crest sheen: the wave is a gameplay tell (ride it for boost), so it gets a little
|
||||
// help beyond what its own geometry earns from the rim term
|
||||
vec3 col = base + uRim * fres * uRimGain + uRim * vPulse * 0.10;
|
||||
float d = length(vView);
|
||||
gl_FragColor = vec4(mix(col, uVoid, 1.0 - exp(-uFog * d * 0.55)), 1.0);
|
||||
}`,
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user