feat: underpass overpasses carry the chapter transitions
@@ -44,3 +44,14 @@ npm run test:browser -- tests/browser/visual-regression.spec.ts
|
|||||||
- [ ] Review on the physical iPhone 13 and Pixel 6 during `physical-drive.v1`.
|
- [ ] Review on the physical iPhone 13 and Pixel 6 during `physical-drive.v1`.
|
||||||
|
|
||||||
No unsupported engine, physical device, or unpublished project-media result is represented as passed.
|
No unsupported engine, physical device, or unpublished project-media result is represented as passed.
|
||||||
|
|
||||||
|
## Underpass shade (chapter boundaries)
|
||||||
|
|
||||||
|
Every chapter boundary is an overpass deck the car drives beneath (`src/scene/world/underpass.ts`, pure rules in `src/journey/underpass.ts`). While the deck is overhead it dims the sky, the fog and the sky-lit road and terrain terms. By construction:
|
||||||
|
|
||||||
|
- **One dark-light cycle per boundary.** The shade follows the deck's distance from the camera: it rises once as the deck arrives and falls once as it leaves. Scrolling back reverses the same single cycle.
|
||||||
|
- **Maximum dip 35%.** The shade multiplier never goes below 0.65 (`SHADE_PEAK = 0.35`). The headlight throw, lamps and HUD are not dimmed.
|
||||||
|
- **Edges of at least 300 ms.** The shade is slew-limited: 0 to the 35% peak, or back, takes at least 300 ms (`SHADE_EDGE_SECONDS`), however fast the scroll.
|
||||||
|
- **A latch of at least 1 s.** Whether a pass shades at all is decided once, as the deck enters the band, and a pass starting within 1 s of the last shaded pass gets no shade (`SHADE_SPACING_MS`). Brisk scrolling (drive speed above 0.5, the exit sign's own gate) and explicit navigation never shade: the deck goes by without dimming or sound.
|
||||||
|
|
||||||
|
The worst case is therefore one shade cycle per second, at most **1 Hz**, well under the three-per-second limit, and each cycle is a 35% dip with soft edges, below the general-flash luminance pair the automated method counts at large area. The low tier draws the deck without any shade. Static, reduced-motion and data-saver visits get no scene, so nothing is added there.
|
||||||
|
|||||||
@@ -71,3 +71,11 @@ Run: `npm run build:preview` then serve `dist` (dev server shows a blank page).
|
|||||||
- **Verdict: SHIP** (Fable, hardware GL 1440x900, fresh context): beats land on the spec clock (lamps +0.60, rolling +1.00, sweep +1.05, attribute removed +4.5 s); the dissolve is one exposure pull (the poster darkens with the canvas fade, frame luminance 47.7 → 28 with no step over 15 per 200 ms); rAF p50 16.7 / p95 16.8 ms after the first frame. Fixes made during review: (1) the ignition clock no longer counts the first-frame shader stall, so the cold hold is a real 0.6 s; (2) the poster dims with the dissolve instead of a bright image giving way to a dark one; (3) the hero name is unlit until the sweep (the lamp's idle drift pre-lit letters the hold then darkened); (4) every program is compiled in frame 0 behind the poster by a warm render of the whole scene (hidden objects visible, culling off) through the real post-processing pipeline. renderer.compile() keys programs for the screen and never matched the composer's linear targets, so the car, the sign and the streaks each compiled on first sight: 230-280 ms stalls on the first scroll, which tripped the 250 ms catastrophic-frame downgrade and rebuilt the scene mid-opening (a 350 ms freeze and a brightness pop). After the fix no long task follows the first frame in any scenario; the first scroll cuts cleanly in 0.4 s.
|
- **Verdict: SHIP** (Fable, hardware GL 1440x900, fresh context): beats land on the spec clock (lamps +0.60, rolling +1.00, sweep +1.05, attribute removed +4.5 s); the dissolve is one exposure pull (the poster darkens with the canvas fade, frame luminance 47.7 → 28 with no step over 15 per 200 ms); rAF p50 16.7 / p95 16.8 ms after the first frame. Fixes made during review: (1) the ignition clock no longer counts the first-frame shader stall, so the cold hold is a real 0.6 s; (2) the poster dims with the dissolve instead of a bright image giving way to a dark one; (3) the hero name is unlit until the sweep (the lamp's idle drift pre-lit letters the hold then darkened); (4) every program is compiled in frame 0 behind the poster by a warm render of the whole scene (hidden objects visible, culling off) through the real post-processing pipeline. renderer.compile() keys programs for the screen and never matched the composer's linear targets, so the car, the sign and the streaks each compiled on first sight: 230-280 ms stalls on the first scroll, which tripped the 250 ms catastrophic-frame downgrade and rebuilt the scene mid-opening (a 350 ms freeze and a brightness pop). After the fix no long task follows the first frame in any scenario; the first scroll cuts cleanly in 0.4 s.
|
||||||
- **Known weaknesses:** the first frame now lands ~1.7-1.9 s after navigation on a cold GPU (all ~60 programs compile behind the poster in one 1.2 s task; the poster holds, nothing is blank); clicking a nav link inside the opening fires the pre-existing exit-sign flare, one bright frame (mean 80/255) on arrival; the phone opening is quieter (no hero sweep on coarse pointers); the short form's road start is a small 5-point dip in luminance before cruise.
|
- **Known weaknesses:** the first frame now lands ~1.7-1.9 s after navigation on a cold GPU (all ~60 programs compile behind the poster in one 1.2 s task; the poster holds, nothing is blank); clicking a nav link inside the opening fires the pre-existing exit-sign flare, one bright frame (mean 80/255) on arrival; the phone opening is quieter (no hero sweep on coarse pointers); the short form's road start is a small 5-point dip in luminance before cruise.
|
||||||
- Screenshots: docs/overnight/round8/ (01 long contact sheet 1.5-6.0 s, 02 cold frame, 03 lamps on, 04 rolling + sweep, 05 short form, 06 early-scroll cut sheet, 07 phone 390x844).
|
- Screenshots: docs/overnight/round8/ (01 long contact sheet 1.5-6.0 s, 02 cold frame, 03 lamps on, 04 rolling + sweep, 05 short form, 06 early-scroll cut sheet, 07 phone 390x844).
|
||||||
|
|
||||||
|
## Round 9: UNDERPASS (chapter transitions)
|
||||||
|
- **What:** every chapter boundary is an overpass deck (exits 02-08, 'EXIT 0N' face plate in the exit accent). It rises out of the haze once the exit sign has fired, holds behind the sign while it comes in, and passes over the camera as the next heading crosses the line; the cut happens in its shadow. The sky, fog and the sky-lit road/terrain terms dip at most 35% (edges >= 300 ms, latched once per pass, >= 1 s apart), three sodium strips and a road pool wash amber, the palette settles x4 under the deck, html[data-underpass='under'] tints the HUD label, and a 600 ms noise swell plays if the soundtrack is audible. Scrolling back re-emerges under the same deck. Brisk scrolls and nav long-scrolls get the structure only. Low tier: deck and strips.
|
||||||
|
- **Build:** journey/underpass.ts (pure), scene/world/underpass.ts, audio/underpass-pass.ts (lazy); environment.ts, palette.ts (dampPalette moved here, rate arg), road.ts, terrain.ts, world.css, bundle-budgets.mjs (lazy chunk class), flash-analysis.md. 872 tests (75 files), initial JS unchanged (+6 B gzip hash noise), CSS +50 B gz, scene chunk +2.8 KB gz.
|
||||||
|
- **Deviation:** deck clearance 22, not 14: the docked exit sign's top edge stands 14-20.4 m above the road at 1440x900.
|
||||||
|
- **Verdict: SHIP** (Fable, hardware GL 1440x900 high tier, wheel-driven passes, frames every ~100 ms): the deck is big, reads as a concrete overpass (parapet, soffit, piers, three strips) and the plate is legible from mid-distance; the sign/deck hand-off is clean (the deck holds in the haze until the sign has passed, and on a brisk approach it sits behind the sign, never through it); the shade beat is one slow dip and release with no step over 22/255 in frame mean (sky band <= 45) at any of Start->Profile, Profile->Experience, Infra->Builds, Builds->Additional, Toolkit->Contact; reverse scroll re-emerges under the same deck (shade latches again, 1 s apart); brisk and nav-jump passes get the structure and no shade/sound; reduced motion renders the static poster with nothing; 1024x700 (medium) shades, 390x844 (low) shows deck+strips only; rAF p50 16.7 / p95 16.7-16.8 ms in every serial run, 0-1 frames over 34 ms, no console errors. Fixes made during review: (1) DECK_PASS_Z 30 -> 25 (the chase camera's z): the deck had gone overhead ~5% of the gap (~1 s at a slow scroll) before the HUD cut, now the cut lands ~0.5 s after 'under' sets, in the deck's shadow; (2) STRIP_GLOW 2.4 -> 1.3 and POOL_GAIN 1.6 -> 1.1: the strips crossed the 0.85 bloom threshold and washed the frame then dropped (sky band 146 -> 32 in one step at Contact), now the strips read as sodium tubes without a flash; (3) plate 11x2.75 -> 16x4.2 over the deck face + parapet, 168 px type, 14 px accent bars: 'EXIT 0N' is readable at z ~ -100 instead of only in the last 30 m; (4) pier shader: 2.6 m form lines, grime at the base and the sodium pool bouncing up the lower faces, so the close pier is poured concrete, not a flat slab.
|
||||||
|
- **Known weaknesses:** the pier at x 16 crosses the left edge of the Selected-builds billboard (stilt at x 17) in front of it for ~6 m of travel, and one expressway lamp head pokes past the right pier at Infra->Builds (both brief, both read as foreground structure); the Contact deck is lit by the dawn sun and reads paler than the night decks (intentional, but less massive); a moderate approach walk can set data-underpass for ~50 ms once before the real pass (hidden by the 0.3 s HUD colour transition, no visible effect); the low tier never shades, so phones get the structure without the beat; `portfolio-underpass-frame` events only exist in test builds, so the review read html[data-underpass] and screencast frames instead.
|
||||||
|
- Screenshots: docs/overnight/round9/ (01 EXIT 02 rising behind the docked sign, 02 under the deck with strips and piers, 03 EXIT 03 overhead at the cut with the amber HUD label, 04 EXIT 08 at dawn into Contact, 05 EXIT 05 pier against the Selected-builds billboard, 06 phone 390x844 low tier, 07 contact sheet of the Profile->Experience pass 20.5-25.5 s).
|
||||||
|
|||||||
BIN
docs/overnight/round9/01-exit02-rising-behind-sign.webp
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
docs/overnight/round9/02-exit02-under-deck-strips.webp
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
docs/overnight/round9/03-exit03-overhead-at-cut-amber-hud.webp
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
docs/overnight/round9/04-exit08-dawn-contact.webp
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
docs/overnight/round9/05-exit05-pier-and-billboard.webp
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
docs/overnight/round9/06-phone-390-low-tier-deck-only.webp
Normal file
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 201 KiB |
@@ -42,6 +42,8 @@ export const classifyArtifact = (path, source = '') => {
|
|||||||
if (/^assets\/DashcamFrame-[^/]+\.(?:js|css)$/u.test(path)) return 'deferred-non-video';
|
if (/^assets\/DashcamFrame-[^/]+\.(?:js|css)$/u.test(path)) return 'deferred-non-video';
|
||||||
// RELAY STATION: the mast chirp, loaded when the first mast comes online with sound on.
|
// RELAY STATION: the mast chirp, loaded when the first mast comes online with sound on.
|
||||||
if (/^assets\/relay-tone-[^/]+\.js$/u.test(path)) return 'deferred-non-video';
|
if (/^assets\/relay-tone-[^/]+\.js$/u.test(path)) return 'deferred-non-video';
|
||||||
|
// UNDERPASS: the pass swell, loaded when the first deck shades the road with sound on.
|
||||||
|
if (/^assets\/underpass-pass-[^/]+\.js$/u.test(path)) return 'deferred-non-video';
|
||||||
if (/^assets\/nightdrive-(?:grand-tourer|districts)-[^/]+\.glb$/u.test(path)) return 'first-live-scene';
|
if (/^assets\/nightdrive-(?:grand-tourer|districts)-[^/]+\.glb$/u.test(path)) return 'first-live-scene';
|
||||||
if (/\.(?:woff2?|ttf|otf)$/u.test(path)) return 'initial-fonts';
|
if (/\.(?:woff2?|ttf|otf)$/u.test(path)) return 'initial-fonts';
|
||||||
if (/^assets\/hero-[^/]+\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'hero-poster';
|
if (/^assets\/hero-[^/]+\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'hero-poster';
|
||||||
|
|||||||
60
src/audio/underpass-pass.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { readSwitchboard } from '@/audio/music-pulse';
|
||||||
|
|
||||||
|
/** Swell length, seconds. */
|
||||||
|
export const UNDERPASS_PASS_SECONDS = 0.6;
|
||||||
|
/** Peak gain, ~-18 dB. */
|
||||||
|
export const UNDERPASS_PASS_GAIN = 0.125;
|
||||||
|
/** One swell per second at most (the shade latch already spaces passes). */
|
||||||
|
export const UNDERPASS_PASS_GAP_SECONDS = 1;
|
||||||
|
|
||||||
|
let lastStart = Number.NEGATIVE_INFINITY;
|
||||||
|
let noise: AudioBuffer | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UNDERPASS: driving beneath a deck, a ~600 ms swell of band-passed noise
|
||||||
|
* (the road's roar folding back off the concrete) through the switchboard so
|
||||||
|
* mute and volume apply. Lazy-loaded by the scene; plays only while the
|
||||||
|
* soundtrack is running and audible; never creates an AudioContext and never
|
||||||
|
* throws.
|
||||||
|
*/
|
||||||
|
export function playUnderpassPass(): boolean {
|
||||||
|
try {
|
||||||
|
const input = readSwitchboard();
|
||||||
|
if (input === null || input.context.state !== 'running') return false;
|
||||||
|
const context = input.context;
|
||||||
|
const start = context.currentTime + 0.005;
|
||||||
|
if (start - lastStart < UNDERPASS_PASS_GAP_SECONDS) return false;
|
||||||
|
lastStart = start;
|
||||||
|
if (noise === null || noise.sampleRate !== context.sampleRate) {
|
||||||
|
const length = Math.ceil(context.sampleRate * UNDERPASS_PASS_SECONDS);
|
||||||
|
noise = context.createBuffer(1, length, context.sampleRate);
|
||||||
|
const data = noise.getChannelData(0);
|
||||||
|
for (let i = 0; i < length; i += 1) data[i] = Math.random() * 2 - 1;
|
||||||
|
}
|
||||||
|
const source = context.createBufferSource();
|
||||||
|
source.buffer = noise;
|
||||||
|
const filter = context.createBiquadFilter();
|
||||||
|
filter.type = 'bandpass';
|
||||||
|
filter.Q.setValueAtTime(0.9, start);
|
||||||
|
filter.frequency.setValueAtTime(260, start);
|
||||||
|
filter.frequency.exponentialRampToValueAtTime(720, start + UNDERPASS_PASS_SECONDS * 0.5);
|
||||||
|
filter.frequency.exponentialRampToValueAtTime(240, start + UNDERPASS_PASS_SECONDS);
|
||||||
|
const envelope = context.createGain();
|
||||||
|
envelope.gain.setValueAtTime(0.0002, start);
|
||||||
|
envelope.gain.exponentialRampToValueAtTime(UNDERPASS_PASS_GAIN, start + UNDERPASS_PASS_SECONDS * 0.45);
|
||||||
|
envelope.gain.exponentialRampToValueAtTime(0.0002, start + UNDERPASS_PASS_SECONDS);
|
||||||
|
source.connect(filter);
|
||||||
|
filter.connect(envelope);
|
||||||
|
envelope.connect(input);
|
||||||
|
source.onended = () => {
|
||||||
|
source.disconnect();
|
||||||
|
filter.disconnect();
|
||||||
|
envelope.disconnect();
|
||||||
|
};
|
||||||
|
source.start(start);
|
||||||
|
source.stop(start + UNDERPASS_PASS_SECONDS + 0.01);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
185
src/journey/underpass.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import type { SectionId } from '@/content/types';
|
||||||
|
import { SIGN_ACCENTS, SIGN_ROUTE_ORDER, signRouteFor } from './sign-routes';
|
||||||
|
import type { JourneySnapshot } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UNDERPASS: every chapter boundary is an overpass the car drives beneath.
|
||||||
|
* Pure helpers shared by the scene chunk only (src/scene/world/underpass.ts,
|
||||||
|
* environment.ts): nothing in the initial bundle imports this module.
|
||||||
|
*
|
||||||
|
* As the next heading nears the activation line a deck slides in from the
|
||||||
|
* horizon; it passes over the camera exactly as the heading crosses the line,
|
||||||
|
* so the cut to the next chapter happens in its shadow. The deck's place is a
|
||||||
|
* pure function of scroll, so scrolling back brings you out under the same
|
||||||
|
* deck. The shade it casts is capped at 35%, eased over at least 300 ms and
|
||||||
|
* latched at most once a second (docs/accessibility/flash-analysis.md).
|
||||||
|
*/
|
||||||
|
type GapInput = Pick<JourneySnapshot, 'activeSectionId' | 'sectionActivation'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the deck rises out of the haze, and where it is overhead: the chase
|
||||||
|
* camera's z (CHAPTER_COMPOSITIONS, 23-26), so the deck is directly over the
|
||||||
|
* car as the heading crosses the line, not a beat before it (round 9: at 30
|
||||||
|
* the deck had gone over ~5% of the gap ahead of the cut).
|
||||||
|
*/
|
||||||
|
export const DECK_FAR_Z = -190;
|
||||||
|
export const DECK_PASS_Z = 25;
|
||||||
|
/** Earliest gap progress at which the deck leaves the horizon. */
|
||||||
|
export const DECK_FROM_P = 0.45;
|
||||||
|
/**
|
||||||
|
* The deck starts no earlier than the exit sign's trigger (the next heading
|
||||||
|
* 0.55 vh below the line on wide screens), so the sign's 3D approach always
|
||||||
|
* begins in front of the deck: the deck is still in the haze when it fires.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_LEAD_VH = 0.55;
|
||||||
|
/** After the cut the deck carries on behind the camera over this much scroll (vh). */
|
||||||
|
export const TAIL_VH = 0.25;
|
||||||
|
export const TAIL_Z = 25;
|
||||||
|
/** Peak shade (35%), the band around the camera it is cast in and its full-shade plateau (m). */
|
||||||
|
export const SHADE_PEAK = 0.35;
|
||||||
|
export const SHADE_BAND = 18;
|
||||||
|
export const SHADE_PLATEAU = 6;
|
||||||
|
/** No shade edge is faster than this: 0 -> peak takes at least 300 ms. */
|
||||||
|
export const SHADE_EDGE_SECONDS = 0.3;
|
||||||
|
/** A pass above this scroll drive speed gets the structure but no shade (sign.ts's gate). */
|
||||||
|
export const BRISK_DRIVE_SPEED = 0.5;
|
||||||
|
/** At most one full shade per second, however the visitor scrolls. */
|
||||||
|
export const SHADE_SPACING_MS = 1_000;
|
||||||
|
|
||||||
|
const clamp01 = (value: number): number => (Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0);
|
||||||
|
const smooth = (from: number, to: number, value: number): number => {
|
||||||
|
const t = clamp01((value - from) / (to - from));
|
||||||
|
return t * t * (3 - 2 * t);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gap progress through the active chapter: 0 with its heading on the
|
||||||
|
* activation line, 1 as the next heading reaches it. Same math as
|
||||||
|
* roadOpenness (headlights.ts), but 'start' is measured too (its boundary is
|
||||||
|
* exit 02) instead of always reading as open road. Null when there is no
|
||||||
|
* next heading, the offsets are not finite, or the span is not positive.
|
||||||
|
*/
|
||||||
|
export function gapProgress(snapshot: GapInput): number | null {
|
||||||
|
const { activeHeadingOffsetVh: active, nextHeadingOffsetVh: next } = snapshot.sectionActivation;
|
||||||
|
if (next === null || !Number.isFinite(active) || !Number.isFinite(next)) return null;
|
||||||
|
const span = next - active;
|
||||||
|
if (!(span > 0)) return null;
|
||||||
|
return clamp01(-active / span);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deck z for gap progress p: the horizon at p 0.45, overhead at p 1 (smoothstep, so it slows as it arrives). */
|
||||||
|
export function deckZ(p: number): number {
|
||||||
|
return DECK_FAR_Z + (DECK_PASS_Z - DECK_FAR_Z) * smooth(DECK_FROM_P, 1, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Past the cut (0..1 of TAIL_VH): the deck eases on behind the camera. */
|
||||||
|
export function tailZ(t: number): number {
|
||||||
|
const k = clamp01(t);
|
||||||
|
return DECK_PASS_Z + TAIL_Z * k * k;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shade cast at the camera by a deck at z: peak 0.35 within 6 m, 0 beyond 18 m. */
|
||||||
|
export function shadeFor(z: number, cameraZ = 25): number {
|
||||||
|
if (!Number.isFinite(z) || !Number.isFinite(cameraZ)) return 0;
|
||||||
|
const d = Math.abs(z - cameraZ);
|
||||||
|
if (d >= SHADE_BAND) return 0;
|
||||||
|
return SHADE_PEAK * (1 - smooth(SHADE_PLATEAU, SHADE_BAND, d));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shade as a share of its peak (0..1): the thresholds below read this. */
|
||||||
|
export const shadeDepth = (shade: number): number => clamp01(shade / SHADE_PEAK);
|
||||||
|
|
||||||
|
/** Moves the shade toward its target no faster than 0 -> peak in 300 ms. */
|
||||||
|
export function slewShade(current: number, target: number, deltaSeconds: number): number {
|
||||||
|
const step = (SHADE_PEAK / SHADE_EDGE_SECONDS) * Math.max(0, deltaSeconds);
|
||||||
|
const goal = Math.min(SHADE_PEAK, Math.max(0, Number.isFinite(target) ? target : 0));
|
||||||
|
return current < goal ? Math.min(goal, current + step) : Math.max(goal, current - step);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnderpassExit {
|
||||||
|
/** The chapter the deck leads into. */
|
||||||
|
id: SectionId;
|
||||||
|
marker: string;
|
||||||
|
label: string;
|
||||||
|
accent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seven decks, exits 02-08, each wearing its exit's sign accent. */
|
||||||
|
export const UNDERPASS_EXITS: readonly UnderpassExit[] = SIGN_ROUTE_ORDER.slice(1).map((id) => {
|
||||||
|
const marker = signRouteFor(id).marker;
|
||||||
|
return { id, marker, label: `EXIT ${marker}`, accent: SIGN_ACCENTS[id] };
|
||||||
|
});
|
||||||
|
|
||||||
|
export function nextSectionId(id: SectionId): SectionId | null {
|
||||||
|
const index = SIGN_ROUTE_ORDER.indexOf(id);
|
||||||
|
return index < 0 ? null : SIGN_ROUTE_ORDER[index + 1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnderpassTravel {
|
||||||
|
/** The chapter beyond the deck (its exit number is on the face plate). */
|
||||||
|
exit: SectionId;
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which deck is on the road and where, or null between decks. Approaching:
|
||||||
|
* gap progress from max(0.45, the sign's trigger) to 1 maps onto deckZ's
|
||||||
|
* 0.45..1. Past the cut: the first TAIL_VH of the new chapter carries the
|
||||||
|
* same deck on behind the camera, so the shade eases out after the cut.
|
||||||
|
*/
|
||||||
|
export function underpassTravel(snapshot: GapInput, leadVh = DEFAULT_LEAD_VH): UnderpassTravel | null {
|
||||||
|
const p = gapProgress(snapshot);
|
||||||
|
const following = nextSectionId(snapshot.activeSectionId);
|
||||||
|
const { activeHeadingOffsetVh: active, nextHeadingOffsetVh: next } = snapshot.sectionActivation;
|
||||||
|
if (p !== null && following !== null && next !== null) {
|
||||||
|
const span = next - active;
|
||||||
|
const lead = leadVh > 0 && Number.isFinite(leadVh) ? leadVh : DEFAULT_LEAD_VH;
|
||||||
|
const start = Math.max(DECK_FROM_P, 1 - lead / span);
|
||||||
|
if (p >= start) return { exit: following, z: deckZ(DECK_FROM_P + ((1 - DECK_FROM_P) * (p - start)) / (1 - start)) };
|
||||||
|
}
|
||||||
|
if (snapshot.activeSectionId !== 'start' && Number.isFinite(active)) {
|
||||||
|
const past = -active;
|
||||||
|
if (past >= 0 && past < TAIL_VH) return { exit: snapshot.activeSectionId, z: tailZ(past / TAIL_VH) };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShadeLatch {
|
||||||
|
/**
|
||||||
|
* inBand: the deck is close enough to shade (shadeFor > 0). The decision is
|
||||||
|
* taken once as the deck enters the band and held until it leaves: a brisk
|
||||||
|
* scroll (driveSpeed > 0.5), an explicit navigation, or a pass starting
|
||||||
|
* within 1 s of the last full shade gets no shade. Returns the decision.
|
||||||
|
*/
|
||||||
|
update(inBand: boolean, driveSpeed: number, explicit: boolean, now: number): boolean;
|
||||||
|
/** Passes that were allowed to shade (each also requests the pass sound). */
|
||||||
|
readonly passes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createShadeLatch(): ShadeLatch {
|
||||||
|
let open = false;
|
||||||
|
let allowed = false;
|
||||||
|
let lastFull = Number.NEGATIVE_INFINITY;
|
||||||
|
let passes = 0;
|
||||||
|
return {
|
||||||
|
get passes() {
|
||||||
|
return passes;
|
||||||
|
},
|
||||||
|
update(inBand, driveSpeed, explicit, now) {
|
||||||
|
if (!inBand) {
|
||||||
|
open = false;
|
||||||
|
allowed = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!open) {
|
||||||
|
open = true;
|
||||||
|
allowed = !explicit && !(driveSpeed > BRISK_DRIVE_SPEED) && now - lastFull >= SHADE_SPACING_MS;
|
||||||
|
if (allowed) {
|
||||||
|
lastFull = now;
|
||||||
|
passes += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allowed;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -26,12 +26,13 @@ import { createCoachworkEnvironment } from '@/scene/world/coachwork-light';
|
|||||||
import { createTravelDistance } from '@/scene/world/travel-distance';
|
import { createTravelDistance } from '@/scene/world/travel-distance';
|
||||||
import {
|
import {
|
||||||
blendDaybreak,
|
blendDaybreak,
|
||||||
|
dampPalette,
|
||||||
engineOffStarScale,
|
engineOffStarScale,
|
||||||
|
paletteDampRate,
|
||||||
createMutableChapterPalette,
|
createMutableChapterPalette,
|
||||||
STORM_STAR_SCALE,
|
STORM_STAR_SCALE,
|
||||||
STORM_TINT,
|
STORM_TINT,
|
||||||
writeChapterPalette,
|
writeChapterPalette,
|
||||||
type MutableChapterPalette,
|
|
||||||
} from '@/scene/world/palette';
|
} from '@/scene/world/palette';
|
||||||
import { createMaintenanceWindow } from '@/scene/world/maintenance-window';
|
import { createMaintenanceWindow } from '@/scene/world/maintenance-window';
|
||||||
import { createHeadlights } from '@/scene/world/headlights';
|
import { createHeadlights } from '@/scene/world/headlights';
|
||||||
@@ -48,6 +49,9 @@ import { readDashcam, takeDashcamCapture } from '@/journey/dashcam-feed';
|
|||||||
import { createDashcamLens } from '@/scene/world/dashcam-lens';
|
import { createDashcamLens } from '@/scene/world/dashcam-lens';
|
||||||
import { createIgnition, IGNITION } from '@/scene/world/ignition';
|
import { createIgnition, IGNITION } from '@/scene/world/ignition';
|
||||||
import { readIgnition } from '@/journey/ignition-feed';
|
import { readIgnition } from '@/journey/ignition-feed';
|
||||||
|
import { createUnderpass, underpassUniforms } from '@/scene/world/underpass';
|
||||||
|
import { DEFAULT_LEAD_VH, nextSectionId, shadeDepth } from '@/journey/underpass';
|
||||||
|
import { signRouteFor, signTriggerMaximumVhFor } from '@/journey/sign-routes';
|
||||||
|
|
||||||
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
||||||
const TEST_BUILD =
|
const TEST_BUILD =
|
||||||
@@ -195,7 +199,19 @@ export async function createProceduralSceneGeneration(
|
|||||||
maintenanceTier === 'full'
|
maintenanceTier === 'full'
|
||||||
&& (globalThis.navigator as { connection?: { saveData?: boolean } } | undefined)?.connection?.saveData !== true,
|
&& (globalThis.navigator as { connection?: { saveData?: boolean } } | undefined)?.connection?.saveData !== true,
|
||||||
);
|
);
|
||||||
|
// UNDERPASS: every chapter boundary is a deck the car drives beneath.
|
||||||
|
// High/medium: shade, sodium pool, palette boost and the pass swell (the
|
||||||
|
// swell only with the soundtrack audible). Low tier: deck and strips only.
|
||||||
|
const underpass = createUnderpass(context.resources, {
|
||||||
|
full: context.quality !== 'low'
|
||||||
|
&& (globalThis.navigator as { connection?: { saveData?: boolean } } | undefined)?.connection?.saveData !== true,
|
||||||
|
fogColor: scene.fog.color,
|
||||||
|
});
|
||||||
|
let underpassShade = 0;
|
||||||
|
let underpassKey = '';
|
||||||
|
let underpassAttr = false;
|
||||||
scene.add(
|
scene.add(
|
||||||
|
underpass.group,
|
||||||
relay.group,
|
relay.group,
|
||||||
billboard.mesh,
|
billboard.mesh,
|
||||||
maintenance.group,
|
maintenance.group,
|
||||||
@@ -447,7 +463,9 @@ export async function createProceduralSceneGeneration(
|
|||||||
targetPalette.starVisibility *= engineOffStarScale(engineOff, dawn);
|
targetPalette.starVisibility *= engineOffStarScale(engineOff, dawn);
|
||||||
}
|
}
|
||||||
dawnLightUniforms.uDawnLight.value = dawnRims ? dawn : 0;
|
dawnLightUniforms.uDawnLight.value = dawnRims ? dawn : 0;
|
||||||
dampPalette(currentPalette, targetPalette, deltaSeconds);
|
// UNDERPASS: deep in the deck's shade the palette settles x4 (last
|
||||||
|
// frame's shade: the deck moves after the camera below).
|
||||||
|
dampPalette(currentPalette, targetPalette, deltaSeconds, paletteDampRate(shadeDepth(underpassShade), service.storm));
|
||||||
const driveDistance = travelDistance.advance(
|
const driveDistance = travelDistance.advance(
|
||||||
deltaSeconds * (1 - engineOff) * roll,
|
deltaSeconds * (1 - engineOff) * roll,
|
||||||
snapshot.driveSpeed,
|
snapshot.driveSpeed,
|
||||||
@@ -540,6 +558,11 @@ export async function createProceduralSceneGeneration(
|
|||||||
);
|
);
|
||||||
background.copy(currentPalette.sky);
|
background.copy(currentPalette.sky);
|
||||||
scene.fog.color.copy(currentPalette.fog);
|
scene.fog.color.copy(currentPalette.fog);
|
||||||
|
if (underpassShade > 0) {
|
||||||
|
// The deck occludes the sky: at most 35% darker for a beat.
|
||||||
|
background.lerp(ENGINE_OFF_NIGHT, underpassShade);
|
||||||
|
scene.fog.color.lerp(ENGINE_OFF_NIGHT, underpassShade);
|
||||||
|
}
|
||||||
ambient.color
|
ambient.color
|
||||||
.copy(currentPalette.haze)
|
.copy(currentPalette.haze)
|
||||||
.lerp(currentPalette.terrainHigh, 0.26);
|
.lerp(currentPalette.terrainHigh, 0.26);
|
||||||
@@ -731,6 +754,29 @@ export async function createProceduralSceneGeneration(
|
|||||||
const stage = signStage.update(width, height);
|
const stage = signStage.update(width, height);
|
||||||
sign.compose(camera, width, height, stage.dock, now, stage.tilt);
|
sign.compose(camera, width, height, stage.dock, now, stage.tilt);
|
||||||
const gantry = sign.dockState();
|
const gantry = sign.dockState();
|
||||||
|
// UNDERPASS: after the sign so the deck holds behind it while it comes in.
|
||||||
|
const nextRoute = nextSectionId(snapshot.activeSectionId);
|
||||||
|
const passage = underpass.update(
|
||||||
|
snapshot.activeSectionId,
|
||||||
|
null,
|
||||||
|
snapshot.driveSpeed,
|
||||||
|
deltaSeconds,
|
||||||
|
now,
|
||||||
|
{
|
||||||
|
activation: snapshot.sectionActivation,
|
||||||
|
leadVh: nextRoute === null ? DEFAULT_LEAD_VH : signTriggerMaximumVhFor(signRouteFor(nextRoute), width, height),
|
||||||
|
cameraZ: camera.position.z,
|
||||||
|
signZ: gantry === 'incoming' ? sign.group.position.z : null,
|
||||||
|
explicit: snapshot.sign?.navigationArrival === true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
underpassShade = passage.shade;
|
||||||
|
const under = shadeDepth(passage.shade) > 0.2;
|
||||||
|
if (under !== underpassAttr) {
|
||||||
|
underpassAttr = under;
|
||||||
|
if (under) document.documentElement.dataset.underpass = 'under';
|
||||||
|
else delete document.documentElement.dataset.underpass;
|
||||||
|
}
|
||||||
if (gantry !== gantryState) {
|
if (gantry !== gantryState) {
|
||||||
gantryState = gantry;
|
gantryState = gantry;
|
||||||
document.documentElement.dataset.gantry = gantry;
|
document.documentElement.dataset.gantry = gantry;
|
||||||
@@ -765,6 +811,16 @@ export async function createProceduralSceneGeneration(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (TEST_BUILD) {
|
||||||
|
const deck = underpass.inspect();
|
||||||
|
const key = `${deck.exit}|${deck.z === null ? '' : Math.round(deck.z / 5)}|${Math.round(deck.shade * 100)}|${deck.passes}|${deck.held}|${deck.visible}`;
|
||||||
|
if (key !== underpassKey) {
|
||||||
|
underpassKey = key;
|
||||||
|
window.dispatchEvent(new CustomEvent('portfolio-underpass-frame', {
|
||||||
|
detail: { now, activeSectionId: snapshot.activeSectionId, direction: snapshot.direction, driveSpeed: snapshot.driveSpeed, ...deck },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
if (TEST_BUILD && snapshot.sign !== null) {
|
if (TEST_BUILD && snapshot.sign !== null) {
|
||||||
const presentation = sign.inspect(camera, width, height);
|
const presentation = sign.inspect(camera, width, height);
|
||||||
if (presentation !== null) {
|
if (presentation !== null) {
|
||||||
@@ -849,6 +905,8 @@ export async function createProceduralSceneGeneration(
|
|||||||
delete document.documentElement.dataset.interlude;
|
delete document.documentElement.dataset.interlude;
|
||||||
delete document.documentElement.dataset.interludeArmed;
|
delete document.documentElement.dataset.interludeArmed;
|
||||||
delete document.documentElement.dataset.dawn;
|
delete document.documentElement.dataset.dawn;
|
||||||
|
delete document.documentElement.dataset.underpass;
|
||||||
|
underpassUniforms.uShade.value = 0;
|
||||||
ignition.dispose();
|
ignition.dispose();
|
||||||
dawnLightUniforms.uDawnLight.value = 0;
|
dawnLightUniforms.uDawnLight.value = 0;
|
||||||
// Quality changes reuse the canvas context. Leave texture upload flags at
|
// Quality changes reuse the canvas context. Leave texture upload flags at
|
||||||
@@ -876,33 +934,3 @@ const INTERLUDE_RISE_MS = 800;
|
|||||||
function easeInOutCubic(t: number): number {
|
function easeInOutCubic(t: number): number {
|
||||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dampPalette(
|
|
||||||
current: MutableChapterPalette,
|
|
||||||
target: Readonly<MutableChapterPalette>,
|
|
||||||
deltaSeconds: number,
|
|
||||||
): void {
|
|
||||||
const mix = 1 - Math.exp(-Math.max(0, deltaSeconds) * 2.8);
|
|
||||||
current.sky.lerp(target.sky, mix);
|
|
||||||
current.fog.lerp(target.fog, mix);
|
|
||||||
current.terrainLow.lerp(target.terrainLow, mix);
|
|
||||||
current.terrainHigh.lerp(target.terrainHigh, mix);
|
|
||||||
current.sun.lerp(target.sun, mix);
|
|
||||||
current.haze.lerp(target.haze, mix);
|
|
||||||
current.warmth = THREE.MathUtils.lerp(current.warmth, target.warmth, mix);
|
|
||||||
current.terrainEnergy = THREE.MathUtils.lerp(
|
|
||||||
current.terrainEnergy,
|
|
||||||
target.terrainEnergy,
|
|
||||||
mix,
|
|
||||||
);
|
|
||||||
current.starVisibility = THREE.MathUtils.lerp(
|
|
||||||
current.starVisibility,
|
|
||||||
target.starVisibility,
|
|
||||||
mix,
|
|
||||||
);
|
|
||||||
current.streakEnergy = THREE.MathUtils.lerp(
|
|
||||||
current.streakEnergy,
|
|
||||||
target.streakEnergy,
|
|
||||||
mix,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -239,3 +239,40 @@ function lerp(from: number, to: number, progress: number): number {
|
|||||||
function clamp01(value: number): number {
|
function clamp01(value: number): number {
|
||||||
return Number.isFinite(value) ? THREE.MathUtils.clamp(value, 0, 1) : 0;
|
return Number.isFinite(value) ? THREE.MathUtils.clamp(value, 0, 1) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The palette's settling rate (1/s): about 1 s to cross most of a chapter change. */
|
||||||
|
export const PALETTE_DAMP_RATE = 2.8;
|
||||||
|
/** UNDERPASS: under the deck the world settles four times faster. */
|
||||||
|
export const UNDERPASS_PALETTE_BOOST = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate multiplier for dampPalette: x4 while the underpass shade is past half
|
||||||
|
* its depth, so the world emerges already in the next chapter's palette. A
|
||||||
|
* storm (the 03:12 window) scales the boost back to x1, so the storm tint
|
||||||
|
* never snaps in under a deck on top of its own ramp.
|
||||||
|
*/
|
||||||
|
export function paletteDampRate(shadeDepth: number, storm = 0): number {
|
||||||
|
if (!(shadeDepth > 0.5)) return 1;
|
||||||
|
return 1 + (UNDERPASS_PALETTE_BOOST - 1) * (1 - clamp01(storm));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eases the drawn palette toward its target; rate multiplies the settling speed. */
|
||||||
|
export function dampPalette(
|
||||||
|
current: MutableChapterPalette,
|
||||||
|
target: Readonly<MutableChapterPalette>,
|
||||||
|
deltaSeconds: number,
|
||||||
|
rate = 1,
|
||||||
|
): void {
|
||||||
|
const k = Number.isFinite(rate) && rate > 0 ? rate : 1;
|
||||||
|
const mix = 1 - Math.exp(-Math.max(0, deltaSeconds) * PALETTE_DAMP_RATE * k);
|
||||||
|
current.sky.lerp(target.sky, mix);
|
||||||
|
current.fog.lerp(target.fog, mix);
|
||||||
|
current.terrainLow.lerp(target.terrainLow, mix);
|
||||||
|
current.terrainHigh.lerp(target.terrainHigh, mix);
|
||||||
|
current.sun.lerp(target.sun, mix);
|
||||||
|
current.haze.lerp(target.haze, mix);
|
||||||
|
current.warmth = lerp(current.warmth, target.warmth, mix);
|
||||||
|
current.terrainEnergy = lerp(current.terrainEnergy, target.terrainEnergy, mix);
|
||||||
|
current.starVisibility = lerp(current.starVisibility, target.starVisibility, mix);
|
||||||
|
current.streakEnergy = lerp(current.streakEnergy, target.streakEnergy, mix);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { SceneResourceBag } from '@/scene/resources';
|
|||||||
import type { MutableChapterPalette } from '@/scene/world/palette';
|
import type { MutableChapterPalette } from '@/scene/world/palette';
|
||||||
import { CHAPTER_PALETTES } from '@/scene/world/palette';
|
import { CHAPTER_PALETTES } from '@/scene/world/palette';
|
||||||
import { DAWN_PARS, dawnLightUniforms } from '@/scene/world/dawn-light';
|
import { DAWN_PARS, dawnLightUniforms } from '@/scene/world/dawn-light';
|
||||||
|
import { UNDERPASS_PARS, underpassUniforms } from '@/scene/world/underpass';
|
||||||
import {
|
import {
|
||||||
HORIZON_FOG_END,
|
HORIZON_FOG_END,
|
||||||
HORIZON_FOG_START,
|
HORIZON_FOG_START,
|
||||||
@@ -64,6 +65,7 @@ export function createRoad(
|
|||||||
uBeamLift: { value: 0 },
|
uBeamLift: { value: 0 },
|
||||||
uThrow: { value: 1 },
|
uThrow: { value: 1 },
|
||||||
...dawnLightUniforms,
|
...dawnLightUniforms,
|
||||||
|
...underpassUniforms,
|
||||||
};
|
};
|
||||||
const material = resources.own(
|
const material = resources.own(
|
||||||
'material',
|
'material',
|
||||||
@@ -106,6 +108,7 @@ export function createRoad(
|
|||||||
varying float vWorldZ;
|
varying float vWorldZ;
|
||||||
varying float vNearFade;
|
varying float vNearFade;
|
||||||
${DAWN_PARS}
|
${DAWN_PARS}
|
||||||
|
${UNDERPASS_PARS}
|
||||||
|
|
||||||
float hash21(vec2 point) {
|
float hash21(vec2 point) {
|
||||||
point = fract(point * vec2(123.34, 456.21));
|
point = fract(point * vec2(123.34, 456.21));
|
||||||
@@ -199,6 +202,9 @@ export function createRoad(
|
|||||||
color += vec3(1.0, 0.6, 0.36) * uDawnLight * asphaltMask * film
|
color += vec3(1.0, 0.6, 0.36) * uDawnLight * asphaltMask * film
|
||||||
* (0.025 + grazing * 0.06 + glare * 0.32);
|
* (0.025 + grazing * 0.06 + glare * 0.32);
|
||||||
}
|
}
|
||||||
|
// UNDERPASS: the deck takes the sky off the asphalt (at most 35%);
|
||||||
|
// the headlights and lamps below are not sky-lit and keep their throw.
|
||||||
|
color *= underpassShade(vWorldZ);
|
||||||
|
|
||||||
// Headlight throw: two warm cones ahead of the car merging into a pool.
|
// Headlight throw: two warm cones ahead of the car merging into a pool.
|
||||||
float ahead = -vWorldZ - 5.0;
|
float ahead = -vWorldZ - 5.0;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { BEAM_PARS, beamUniforms } from './headlights';
|
import { BEAM_PARS, beamUniforms } from './headlights';
|
||||||
import { DAWN_PARS, dawnLightUniforms } from './dawn-light';
|
import { DAWN_PARS, dawnLightUniforms } from './dawn-light';
|
||||||
|
import { UNDERPASS_PARS, underpassUniforms } from './underpass';
|
||||||
import type { EnvironmentChapter } from '@/journey/types';
|
import type { EnvironmentChapter } from '@/journey/types';
|
||||||
import type { RenderQualitySettings } from '@/scene/render-quality';
|
import type { RenderQualitySettings } from '@/scene/render-quality';
|
||||||
import type { SceneResourceBag } from '@/scene/resources';
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
@@ -72,6 +73,7 @@ export function createTerrain(
|
|||||||
uHighColor: { value: highColor },
|
uHighColor: { value: highColor },
|
||||||
uFogColor: { value: new THREE.Color(initialPalette.fog) },
|
uFogColor: { value: new THREE.Color(initialPalette.fog) },
|
||||||
uHazeColor: { value: new THREE.Color(initialPalette.haze) },
|
uHazeColor: { value: new THREE.Color(initialPalette.haze) },
|
||||||
|
...underpassUniforms,
|
||||||
};
|
};
|
||||||
const material = resources.own(
|
const material = resources.own(
|
||||||
'material',
|
'material',
|
||||||
@@ -195,6 +197,7 @@ export function createTerrain(
|
|||||||
varying vec3 vTerrainPosition;
|
varying vec3 vTerrainPosition;
|
||||||
varying vec3 vGridColor;
|
varying vec3 vGridColor;
|
||||||
varying float vCrest;
|
varying float vCrest;
|
||||||
|
${UNDERPASS_PARS}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
float ridgeMix = smoothstep(0.025, 0.29, vHeight);
|
float ridgeMix = smoothstep(0.025, 0.29, vHeight);
|
||||||
@@ -207,7 +210,7 @@ export function createTerrain(
|
|||||||
float apron = 1.0 - smoothstep(12.0, 32.0, abs(vTerrainPosition.x));
|
float apron = 1.0 - smoothstep(12.0, 32.0, abs(vTerrainPosition.x));
|
||||||
float surfaceStrength = mix(0.72, 0.12 + uOverdrive * 0.3, road);
|
float surfaceStrength = mix(0.72, 0.12 + uOverdrive * 0.3, road);
|
||||||
float alpha = vDepthFade * pulse * routeCalm * mix(surfaceStrength, 0.91, ridgeMix)
|
float alpha = vDepthFade * pulse * routeCalm * mix(surfaceStrength, 0.91, ridgeMix)
|
||||||
* (1.0 - uEngineOff * 0.72);
|
* (1.0 - uEngineOff * 0.72) * underpassShade(vTerrainPosition.z);
|
||||||
float charge = uOverdrive * (0.35 + vFlow * 0.8) * (0.5 + apron * 0.5);
|
float charge = uOverdrive * (0.35 + vFlow * 0.8) * (0.5 + apron * 0.5);
|
||||||
charge += uSurge * (0.28 + vCrest * 0.5) * (1.0 - road);
|
charge += uSurge * (0.28 + vCrest * 0.5) * (1.0 - road);
|
||||||
gl_FragColor = vec4(color * (1.46 + ridgeMix * 0.54 + charge), alpha);
|
gl_FragColor = vec4(color * (1.46 + ridgeMix * 0.54 + charge), alpha);
|
||||||
@@ -253,6 +256,7 @@ export function createTerrain(
|
|||||||
varying vec3 vGridColor;
|
varying vec3 vGridColor;
|
||||||
${BEAM_PARS}
|
${BEAM_PARS}
|
||||||
${DAWN_PARS}
|
${DAWN_PARS}
|
||||||
|
${UNDERPASS_PARS}
|
||||||
void main() {
|
void main() {
|
||||||
vec3 normal = normalize(cross(dFdx(vTerrainPosition), dFdy(vTerrainPosition)));
|
vec3 normal = normalize(cross(dFdx(vTerrainPosition), dFdy(vTerrainPosition)));
|
||||||
float light = abs(dot(normal, normalize(vec3(-0.5, 0.8, -0.3))));
|
float light = abs(dot(normal, normalize(vec3(-0.5, 0.8, -0.3))));
|
||||||
@@ -260,6 +264,8 @@ export function createTerrain(
|
|||||||
color += uHighColor * pow(vHeight, 2.0) * light * 0.06;
|
color += uHighColor * pow(vHeight, 2.0) * light * 0.06;
|
||||||
// HIGH BEAM: the ridges beside the road catch the headlights.
|
// HIGH BEAM: the ridges beside the road catch the headlights.
|
||||||
float beam = headlightBeam(vTerrainPosition);
|
float beam = headlightBeam(vTerrainPosition);
|
||||||
|
// UNDERPASS: the deck takes the sky off the ridges beside it (at most 35%).
|
||||||
|
color *= underpassShade(vTerrainPosition.z);
|
||||||
color += vec3(1.0, 0.84, 0.66) * beam * (0.3 + 0.7 * abs(dot(normal, normalize(uBeamOrigin - vTerrainPosition)))) * 0.28;
|
color += vec3(1.0, 0.84, 0.66) * beam * (0.3 + 0.7 * abs(dot(normal, normalize(uBeamOrigin - vTerrainPosition)))) * 0.28;
|
||||||
// END OF SHIFT: the ridges catch first light from the low sun ahead.
|
// END OF SHIFT: the ridges catch first light from the low sun ahead.
|
||||||
color += dawnRim(normal, normalize(cameraPosition - vTerrainPosition));
|
color += dawnRim(normal, normalize(cameraPosition - vTerrainPosition));
|
||||||
|
|||||||
422
src/scene/world/underpass.ts
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { SectionId } from '@/content/types';
|
||||||
|
import type { JourneySnapshot } from '@/journey/types';
|
||||||
|
import {
|
||||||
|
createShadeLatch,
|
||||||
|
DEFAULT_LEAD_VH,
|
||||||
|
shadeFor,
|
||||||
|
slewShade,
|
||||||
|
underpassTravel,
|
||||||
|
UNDERPASS_EXITS,
|
||||||
|
} from '@/journey/underpass';
|
||||||
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UNDERPASS, scene side. One overpass deck, reused for every chapter
|
||||||
|
* boundary: it rises out of the haze as the next heading nears the line and
|
||||||
|
* passes over the camera as the heading crosses it, so the chapter cut
|
||||||
|
* happens in its shadow. The sky dims (at most 35%), three sodium strips
|
||||||
|
* under the deck wash the road amber, and the world comes out the other side
|
||||||
|
* already easing into the next chapter's palette. Its face plate reads the
|
||||||
|
* exit number in sign typography and the exit's accent.
|
||||||
|
*
|
||||||
|
* The world is drawn ~2.3x life size (lanes at +-5.7, a 2.6 m car
|
||||||
|
* half-width, 6.4 m kilometre posts). The deck clears the road by 22 m, not
|
||||||
|
* 14: the exit sign docks at the top of the frame in the same gap, and its
|
||||||
|
* top edge stands 14-20.4 m above the road at 1440x900 (contact to
|
||||||
|
* infrastructure), so a lower deck would cut through it as it went over.
|
||||||
|
*/
|
||||||
|
export const UNDERPASS_GEOMETRY = Object.freeze({
|
||||||
|
span: 70,
|
||||||
|
depth: 10,
|
||||||
|
clearance: 22,
|
||||||
|
thickness: 3.2,
|
||||||
|
parapet: 1.4,
|
||||||
|
pierX: 16,
|
||||||
|
pierWidth: 2.4,
|
||||||
|
pierDepth: 6,
|
||||||
|
/** The pass-by car, scaled to the world: roof ~3 m, half-width 2.6 m. */
|
||||||
|
carHeight: 3,
|
||||||
|
carHalfWidth: 2.6,
|
||||||
|
});
|
||||||
|
/**
|
||||||
|
* Face plate, on the deck's camera-facing side (world metres): it spans the
|
||||||
|
* deck face and the parapet above it, so 'EXIT 0N' still reads at z -100
|
||||||
|
* (round 9: an 11 x 2.75 plate on the face alone was ~40 px there).
|
||||||
|
*/
|
||||||
|
const PLATE_W = 16;
|
||||||
|
const PLATE_H = 4.2;
|
||||||
|
/** Three sodium strips under the deck, across the road. */
|
||||||
|
const STRIP_ZS = [-3, 0, 3] as const;
|
||||||
|
const STRIP_LENGTH = 40;
|
||||||
|
/**
|
||||||
|
* Amber just above the bloom threshold (0.85), so the strips halo softly: at
|
||||||
|
* 2.4 they washed the top third of the frame out as they went over (round 9
|
||||||
|
* measured the sky band at 146/255 then 32 a frame later).
|
||||||
|
*/
|
||||||
|
const STRIP_GLOW = 1.3;
|
||||||
|
/** The sodium wash on the road under the deck (x across, z along). */
|
||||||
|
const POOL_W = 26;
|
||||||
|
const POOL_L = 34;
|
||||||
|
const POOL_GAIN = 1.1;
|
||||||
|
/** Deck z eases toward its scroll position with this time constant (s). */
|
||||||
|
const EASE_SECONDS = 0.2;
|
||||||
|
/** While the exit sign comes in, the deck stays this far behind it (m). */
|
||||||
|
const SIGN_GAP = 10;
|
||||||
|
|
||||||
|
const clamp01 = (value: number): number => (Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0);
|
||||||
|
const smooth = (from: number, to: number, value: number): number => {
|
||||||
|
const t = clamp01((value - from) / (to - from));
|
||||||
|
return t * t * (3 - 2 * t);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The deck rises out of the haze far up the road and fades as it passes over the camera. */
|
||||||
|
export const underpassPresence = (z: number, cameraZ = 25): number =>
|
||||||
|
smooth(-190, -130, z) * (1 - smooth(cameraZ + 6, cameraZ + 20, z));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shade the road and terrain take under the deck: shared by every
|
||||||
|
* generation (like dawnLightUniforms). uShade is 0 on the low tier and
|
||||||
|
* between decks, at most 0.35; uShadeZ is the deck's z.
|
||||||
|
*/
|
||||||
|
export const underpassUniforms = {
|
||||||
|
uShade: { value: 0 },
|
||||||
|
uShadeZ: { value: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Darkens a sky-lit term in a band around the deck: 1 - uShade at its heart, 1 outside. */
|
||||||
|
export const UNDERPASS_PARS = /* glsl */ `
|
||||||
|
uniform float uShade;
|
||||||
|
uniform float uShadeZ;
|
||||||
|
float underpassShade(float z) {
|
||||||
|
return 1.0 - uShade * (1.0 - smoothstep(12.0, 44.0, abs(z - (uShadeZ - 8.0))));
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type Activation = Pick<JourneySnapshot['sectionActivation'], 'activeHeadingOffsetVh' | 'nextHeadingOffsetVh'>;
|
||||||
|
|
||||||
|
export interface UnderpassFrame {
|
||||||
|
/** The live heading offsets (vh); without them p stands in (active -p, next 1-p). */
|
||||||
|
activation?: Activation;
|
||||||
|
/** How far below the line the next heading is when the exit sign fires (vh). */
|
||||||
|
leadVh?: number;
|
||||||
|
cameraZ?: number;
|
||||||
|
/** The exit sign's z while it comes in: the deck holds behind it. */
|
||||||
|
signZ?: number | null;
|
||||||
|
/** An explicit navigation: the structure goes by, with no shade or sound. */
|
||||||
|
explicit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnderpassState {
|
||||||
|
shade: number;
|
||||||
|
/** Deck z, or null with no deck on the road. */
|
||||||
|
z: number | null;
|
||||||
|
exit: SectionId | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnderpassInspection extends UnderpassState {
|
||||||
|
visible: boolean;
|
||||||
|
presence: number;
|
||||||
|
label: string | null;
|
||||||
|
pool: boolean;
|
||||||
|
passes: number;
|
||||||
|
held: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnderpassWorld {
|
||||||
|
readonly group: { visible: boolean };
|
||||||
|
update(
|
||||||
|
sectionId: SectionId,
|
||||||
|
p: number | null,
|
||||||
|
driveSpeed: number,
|
||||||
|
deltaSeconds: number,
|
||||||
|
now: number,
|
||||||
|
frame?: UnderpassFrame,
|
||||||
|
): UnderpassState;
|
||||||
|
inspect(): UnderpassInspection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnderpassOptions {
|
||||||
|
/** high/medium: shade, pool, palette boost and sound. Low: deck and strips only. */
|
||||||
|
full: boolean;
|
||||||
|
/** The scene fog colour (a live THREE.Color): the deck fades into it with distance. */
|
||||||
|
fogColor?: unknown;
|
||||||
|
/** Injectable for tests; defaults to the lazy pass swell. */
|
||||||
|
sound?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultSound = () => {
|
||||||
|
void import('@/audio/underpass-pass').then((pass) => pass.playUnderpassPass(), () => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createUnderpass(resources: SceneResourceBag, options: UnderpassOptions): UnderpassWorld {
|
||||||
|
const { full } = options;
|
||||||
|
const sound = options.sound ?? defaultSound;
|
||||||
|
const G = UNDERPASS_GEOMETRY;
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.name = 'underpass';
|
||||||
|
group.visible = false;
|
||||||
|
|
||||||
|
const presenceUniform = { value: 0 };
|
||||||
|
const fogColor = options.fogColor ?? new THREE.Color(0x0a0716);
|
||||||
|
const bodyMaterial = resources.own('material', new THREE.ShaderMaterial({
|
||||||
|
uniforms: { uPresence: presenceUniform, uFogColor: { value: fogColor } },
|
||||||
|
vertexShader: /* glsl */ `
|
||||||
|
attribute float aPart;
|
||||||
|
varying float vPart;
|
||||||
|
varying vec3 vWorld;
|
||||||
|
varying vec3 vNormal;
|
||||||
|
void main() {
|
||||||
|
vec4 world = modelMatrix * vec4(position, 1.0);
|
||||||
|
vWorld = world.xyz;
|
||||||
|
vNormal = normalize(mat3(modelMatrix) * normal);
|
||||||
|
vPart = aPart;
|
||||||
|
gl_Position = projectionMatrix * viewMatrix * world;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
fragmentShader: /* glsl */ `
|
||||||
|
uniform float uPresence;
|
||||||
|
uniform vec3 uFogColor;
|
||||||
|
varying float vPart;
|
||||||
|
varying vec3 vWorld;
|
||||||
|
varying vec3 vNormal;
|
||||||
|
void main() {
|
||||||
|
// Screen-door fade, like the mile markers: never a translucent sheet.
|
||||||
|
vec2 cell = mod(floor(gl_FragCoord.xy), 4.0);
|
||||||
|
float threshold = (mod(cell.x + cell.y * 2.0, 4.0) * 4.0 + mod(cell.y, 2.0) * 2.0 + mod(cell.x, 2.0) + 0.5) / 16.0;
|
||||||
|
if (uPresence < threshold) discard;
|
||||||
|
vec3 color;
|
||||||
|
if (vPart > 0.5) {
|
||||||
|
color = vec3(1.0, 0.62, 0.24) * ${STRIP_GLOW.toFixed(2)};
|
||||||
|
} else {
|
||||||
|
// Matte concrete: a dim key from the sky, an amber bounce on the soffit.
|
||||||
|
vec3 n = normalize(vNormal);
|
||||||
|
float key = max(0.0, dot(n, normalize(vec3(-0.35, 0.8, 0.45))));
|
||||||
|
float soffit = max(0.0, -n.y);
|
||||||
|
color = vec3(0.045, 0.04, 0.062) + vec3(0.07, 0.065, 0.095) * key + vec3(0.2, 0.11, 0.04) * soffit;
|
||||||
|
// Poured in lifts: a form line every 2.6 m, the base darker with grime,
|
||||||
|
// and the sodium pool on the road bouncing up the lower pier faces.
|
||||||
|
float lift = fract(vWorld.y / 2.6);
|
||||||
|
float vertical = 1.0 - abs(n.y);
|
||||||
|
color *= 1.0 - 0.3 * vertical * (1.0 - smoothstep(0.0, 0.08, lift));
|
||||||
|
color *= 1.0 - 0.3 * vertical * (1.0 - smoothstep(0.0, 6.0, vWorld.y));
|
||||||
|
color += vec3(0.22, 0.12, 0.04) * vertical * (1.0 - smoothstep(1.0, 11.0, vWorld.y)) * uPresence;
|
||||||
|
// Far up the road the deck is part of the haze it rises from.
|
||||||
|
float haze = smoothstep(50.0, 220.0, distance(cameraPosition, vWorld));
|
||||||
|
color = mix(color, uFogColor, haze * 0.85);
|
||||||
|
}
|
||||||
|
gl_FragColor = vec4(color, 1.0);
|
||||||
|
#include <tonemapping_fragment>
|
||||||
|
#include <colorspace_fragment>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
fog: false,
|
||||||
|
}));
|
||||||
|
const body = new THREE.Mesh(resources.own('geometry', deckGeometry()), bodyMaterial);
|
||||||
|
body.name = 'underpass-deck';
|
||||||
|
body.frustumCulled = false;
|
||||||
|
group.add(body);
|
||||||
|
|
||||||
|
// The sodium wash: an additive quad on the road under the deck.
|
||||||
|
const poolGain = { value: 0 };
|
||||||
|
const pool = new THREE.Mesh(
|
||||||
|
resources.own('geometry', new THREE.PlaneGeometry(POOL_W, POOL_L).rotateX(-Math.PI / 2)),
|
||||||
|
resources.own('material', new THREE.ShaderMaterial({
|
||||||
|
uniforms: { uGain: poolGain },
|
||||||
|
vertexShader: /* glsl */ `
|
||||||
|
varying vec2 vUv;
|
||||||
|
void main() {
|
||||||
|
vUv = uv;
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
fragmentShader: /* glsl */ `
|
||||||
|
uniform float uGain;
|
||||||
|
varying vec2 vUv;
|
||||||
|
void main() {
|
||||||
|
vec2 d = (vUv - 0.5) * 2.0;
|
||||||
|
float pool = pow(max(0.0, 1.0 - length(d * vec2(1.0, 0.85))), 1.6);
|
||||||
|
gl_FragColor = vec4(vec3(1.0, 0.6, 0.22) * pool * uGain, 1.0);
|
||||||
|
#include <tonemapping_fragment>
|
||||||
|
#include <colorspace_fragment>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
transparent: true,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
depthWrite: false,
|
||||||
|
fog: false,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
pool.name = 'underpass-pool';
|
||||||
|
pool.position.set(0, 0.06, 2);
|
||||||
|
pool.frustumCulled = false;
|
||||||
|
pool.visible = false;
|
||||||
|
group.add(pool);
|
||||||
|
|
||||||
|
// The face plate: 'EXIT 0N' in the exit's accent, redrawn as the exit changes.
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 1024;
|
||||||
|
canvas.height = 256;
|
||||||
|
const context = canvas.getContext('2d') as CanvasRenderingContext2D | null;
|
||||||
|
const texture = resources.own('texture', new THREE.CanvasTexture(canvas));
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
const plateMaterial = resources.own('material', new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xdddddd,
|
||||||
|
map: texture,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0,
|
||||||
|
depthWrite: false,
|
||||||
|
polygonOffset: true,
|
||||||
|
polygonOffsetFactor: -1,
|
||||||
|
polygonOffsetUnits: -1,
|
||||||
|
fog: false,
|
||||||
|
}));
|
||||||
|
const plate = new THREE.Mesh(resources.own('geometry', new THREE.PlaneGeometry(PLATE_W, PLATE_H)), plateMaterial);
|
||||||
|
plate.name = 'underpass-plate';
|
||||||
|
// Flush with the deck face and the parapet above it (both end at z depth/2).
|
||||||
|
plate.position.set(0, G.clearance + (G.thickness + G.parapet) / 2, G.depth / 2 + 0.02);
|
||||||
|
plate.frustumCulled = false;
|
||||||
|
group.add(plate);
|
||||||
|
|
||||||
|
const latch = createShadeLatch();
|
||||||
|
let exit: SectionId | null = null;
|
||||||
|
let label: string | null = null;
|
||||||
|
let z: number | null = null;
|
||||||
|
let shade = 0;
|
||||||
|
let presence = 0;
|
||||||
|
let held = false;
|
||||||
|
let passes = 0;
|
||||||
|
|
||||||
|
const drawPlate = (id: SectionId) => {
|
||||||
|
const spec = UNDERPASS_EXITS.find((entry) => entry.id === id);
|
||||||
|
label = spec?.label ?? null;
|
||||||
|
if (context === null || spec === undefined) return;
|
||||||
|
const { width: w, height: h } = canvas;
|
||||||
|
context.clearRect(0, 0, w, h);
|
||||||
|
context.fillStyle = '#0d0916';
|
||||||
|
context.fillRect(0, 0, w, h);
|
||||||
|
context.fillStyle = spec.accent;
|
||||||
|
context.fillRect(0, 0, w, 14);
|
||||||
|
context.fillRect(0, h - 14, w, 14);
|
||||||
|
context.font = '760 168px "Geist Mono Variable", ui-monospace, monospace';
|
||||||
|
context.textAlign = 'center';
|
||||||
|
context.textBaseline = 'middle';
|
||||||
|
context.fillText(spec.label, w / 2, h / 2 + 6);
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hide = (deltaSeconds: number): UnderpassState => {
|
||||||
|
latch.update(false, 0, false, 0);
|
||||||
|
shade = slewShade(shade, 0, deltaSeconds);
|
||||||
|
presence = 0;
|
||||||
|
held = false;
|
||||||
|
group.visible = false;
|
||||||
|
pool.visible = false;
|
||||||
|
// The shade keeps easing out where the deck was last seen.
|
||||||
|
underpassUniforms.uShade.value = full ? shade : 0;
|
||||||
|
if (shade === 0) z = null;
|
||||||
|
return { shade: full ? shade : 0, z, exit: shade > 0 ? exit : null };
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
inspect: () => ({
|
||||||
|
shade: full ? shade : 0,
|
||||||
|
z,
|
||||||
|
exit,
|
||||||
|
visible: group.visible,
|
||||||
|
presence,
|
||||||
|
label,
|
||||||
|
pool: pool.visible,
|
||||||
|
passes,
|
||||||
|
held,
|
||||||
|
}),
|
||||||
|
update(sectionId, p, driveSpeed, deltaSeconds, now, frame = {}) {
|
||||||
|
const activation: Activation | null = frame.activation
|
||||||
|
?? (p === null || !Number.isFinite(p) ? null : { activeHeadingOffsetVh: -p, nextHeadingOffsetVh: 1 - p });
|
||||||
|
const travel = activation === null
|
||||||
|
? null
|
||||||
|
: underpassTravel(
|
||||||
|
{ activeSectionId: sectionId, sectionActivation: { layoutRevision: 0, ...activation } },
|
||||||
|
frame.leadVh ?? DEFAULT_LEAD_VH,
|
||||||
|
);
|
||||||
|
if (travel === null) return hide(deltaSeconds);
|
||||||
|
const cameraZ = Number.isFinite(frame.cameraZ) ? frame.cameraZ! : 25;
|
||||||
|
let target = travel.z;
|
||||||
|
if (travel.exit !== exit || z === null) {
|
||||||
|
// A new deck (or one that just appeared): no easing from the last one.
|
||||||
|
exit = travel.exit;
|
||||||
|
drawPlate(exit);
|
||||||
|
z = target;
|
||||||
|
held = false;
|
||||||
|
}
|
||||||
|
// Never through the exit sign: while it comes in, the deck waits behind it.
|
||||||
|
const signZ = frame.signZ;
|
||||||
|
if (signZ !== null && signZ !== undefined && Number.isFinite(signZ) && z <= signZ - SIGN_GAP + 0.5) {
|
||||||
|
target = Math.min(target, signZ - SIGN_GAP);
|
||||||
|
held = target < travel.z;
|
||||||
|
} else {
|
||||||
|
held = false;
|
||||||
|
}
|
||||||
|
z += (target - z) * (1 - Math.exp(-Math.max(0, deltaSeconds) / EASE_SECONDS));
|
||||||
|
presence = underpassPresence(z, cameraZ);
|
||||||
|
group.position.set(0, 0, z);
|
||||||
|
group.visible = presence > 0;
|
||||||
|
presenceUniform.value = presence;
|
||||||
|
plateMaterial.opacity = presence;
|
||||||
|
|
||||||
|
const cast = shadeFor(z, cameraZ);
|
||||||
|
const before = latch.passes;
|
||||||
|
const allowed = latch.update(cast > 0, driveSpeed, frame.explicit === true, now);
|
||||||
|
if (full && latch.passes > before) {
|
||||||
|
passes += 1;
|
||||||
|
sound();
|
||||||
|
}
|
||||||
|
shade = slewShade(shade, full && allowed ? cast : 0, deltaSeconds);
|
||||||
|
pool.visible = full && presence > 0;
|
||||||
|
poolGain.value = full ? POOL_GAIN * presence * (0.35 + 0.65 * smooth(-120, 10, z)) : 0;
|
||||||
|
underpassUniforms.uShade.value = full ? shade : 0;
|
||||||
|
underpassUniforms.uShadeZ.value = z;
|
||||||
|
return { shade: full ? shade : 0, z, exit };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function box(width: number, height: number, depth: number, x: number, y: number, z: number, part: number) {
|
||||||
|
const geometry = new THREE.BoxGeometry(width, height, depth);
|
||||||
|
geometry.translate(x, y, z);
|
||||||
|
return { geometry, part };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deck, parapets, piers and sodium strips, merged into one geometry with a part id. */
|
||||||
|
function deckGeometry() {
|
||||||
|
const G = UNDERPASS_GEOMETRY;
|
||||||
|
const top = G.clearance + G.thickness;
|
||||||
|
const pieces = [
|
||||||
|
box(G.span, G.thickness, G.depth, 0, G.clearance + G.thickness / 2, 0, 0),
|
||||||
|
box(G.span, G.parapet, 0.6, 0, top + G.parapet / 2, G.depth / 2 - 0.3, 0),
|
||||||
|
box(G.span, G.parapet, 0.6, 0, top + G.parapet / 2, -G.depth / 2 + 0.3, 0),
|
||||||
|
box(G.pierWidth, G.clearance, G.pierDepth, -G.pierX, G.clearance / 2, 0, 0),
|
||||||
|
box(G.pierWidth, G.clearance, G.pierDepth, G.pierX, G.clearance / 2, 0, 0),
|
||||||
|
...STRIP_ZS.map((stripZ) => box(STRIP_LENGTH, 0.22, 0.5, 0, G.clearance - 0.11, stripZ, 1)),
|
||||||
|
];
|
||||||
|
const positions: number[] = [];
|
||||||
|
const normals: number[] = [];
|
||||||
|
const parts: number[] = [];
|
||||||
|
const indices: number[] = [];
|
||||||
|
for (const { geometry, part } of pieces) {
|
||||||
|
const offset = positions.length / 3;
|
||||||
|
const position = geometry.getAttribute('position');
|
||||||
|
positions.push(...position.array);
|
||||||
|
normals.push(...geometry.getAttribute('normal').array);
|
||||||
|
for (let i = 0; i < position.count; i += 1) parts.push(part);
|
||||||
|
indices.push(...Array.from(geometry.getIndex().array as ArrayLike<number>, (i) => i + offset));
|
||||||
|
geometry.dispose();
|
||||||
|
}
|
||||||
|
const merged = new THREE.BufferGeometry();
|
||||||
|
merged.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
||||||
|
merged.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
|
||||||
|
merged.setAttribute('aPart', new THREE.Float32BufferAttribute(parts, 1));
|
||||||
|
merged.setIndex(indices);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
@@ -704,3 +704,9 @@ html:is([data-dawn='sunrise'], [data-dawn='day']) .journey-world::after { backgr
|
|||||||
will-change: auto;
|
will-change: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* UNDERPASS: under a deck the section label takes the sodium light. */
|
||||||
|
html[data-underpass='under'] .journey-hud__section { color: #ffb45c; }
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
html .journey-hud__section { transition: color 0.3s ease; }
|
||||||
|
}
|
||||||
|
|||||||
280
tests/unit/underpass.test.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import type { SectionId } from '@/content/types';
|
||||||
|
import { SIGN_ACCENTS } from '@/journey/sign-routes';
|
||||||
|
import {
|
||||||
|
createShadeLatch,
|
||||||
|
DECK_FAR_Z,
|
||||||
|
DECK_PASS_Z,
|
||||||
|
deckZ,
|
||||||
|
gapProgress,
|
||||||
|
SHADE_PEAK,
|
||||||
|
shadeFor,
|
||||||
|
slewShade,
|
||||||
|
underpassTravel,
|
||||||
|
UNDERPASS_EXITS,
|
||||||
|
} from '@/journey/underpass';
|
||||||
|
import { roadOpenness } from '@/scene/world/headlights';
|
||||||
|
import { SceneResourceBag } from '@/scene/resources';
|
||||||
|
import { createUnderpass, UNDERPASS_GEOMETRY, underpassUniforms } from '@/scene/world/underpass';
|
||||||
|
import { dampPalette, createMutableChapterPalette, paletteDampRate, writeChapterPalette } from '@/scene/world/palette';
|
||||||
|
import { readSwitchboard } from '@/audio/music-pulse';
|
||||||
|
import { playUnderpassPass } from '@/audio/underpass-pass';
|
||||||
|
|
||||||
|
const activation = (active: number, next: number | null) => ({
|
||||||
|
layoutRevision: 1,
|
||||||
|
activeHeadingOffsetVh: active,
|
||||||
|
nextHeadingOffsetVh: next,
|
||||||
|
});
|
||||||
|
const gap = (id: SectionId, active: number, next: number | null) => ({
|
||||||
|
activeSectionId: id,
|
||||||
|
sectionActivation: activation(active, next),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('underpass gap progress', () => {
|
||||||
|
it('measures the gap exactly as the road openness does', () => {
|
||||||
|
for (const [active, next] of [[0, 1], [-0.4, 0.6], [-0.95, 0.05], [-1.3, 0.4], [0.2, 1.2]] as const) {
|
||||||
|
const p = gapProgress(gap('experience', active, next));
|
||||||
|
expect(p).not.toBeNull();
|
||||||
|
const expected = THREE.MathUtils.clamp(-active / (next - active), 0, 1);
|
||||||
|
expect(p).toBeCloseTo(expected, 10);
|
||||||
|
// Same p, same openness curve.
|
||||||
|
const open = 1 - THREE.MathUtils.smoothstep(p!, 0.02, 0.12) * (1 - THREE.MathUtils.smoothstep(p!, 0.8, 0.92));
|
||||||
|
expect(roadOpenness(gap('experience', -p!, 1 - p!))).toBeCloseTo(open, 10);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is null with no next heading, a non-positive span or non-finite offsets', () => {
|
||||||
|
expect(gapProgress(gap('contact', -0.2, null))).toBeNull();
|
||||||
|
expect(gapProgress(gap('profile', 0.3, 0.3))).toBeNull();
|
||||||
|
expect(gapProgress(gap('profile', 0.5, 0.2))).toBeNull();
|
||||||
|
expect(gapProgress(gap('profile', Number.NaN, 0.2))).toBeNull();
|
||||||
|
expect(gapProgress(gap('profile', 0, Number.POSITIVE_INFINITY))).toBeNull();
|
||||||
|
expect(roadOpenness(gap('contact', -0.2, null))).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("measures 'start' too (its boundary is exit 02), where the road openness is always open", () => {
|
||||||
|
expect(roadOpenness(gap('start', -0.5, 0.5))).toBe(1);
|
||||||
|
expect(gapProgress(gap('start', -0.5, 0.5))).toBeCloseTo(0.5, 10);
|
||||||
|
expect(underpassTravel(gap('start', -0.98, 0.02))?.exit).toBe('profile');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('underpass deck travel', () => {
|
||||||
|
it('slides from the horizon at p 0.45 to over the camera at p 1, monotonic and reversible', () => {
|
||||||
|
expect(deckZ(0)).toBe(DECK_FAR_Z);
|
||||||
|
expect(deckZ(0.45)).toBe(DECK_FAR_Z);
|
||||||
|
expect(deckZ(1)).toBeCloseTo(DECK_PASS_Z, 10);
|
||||||
|
expect(DECK_FAR_Z).toBe(-190);
|
||||||
|
// Overhead means the chase camera's z (23-26 across the chapters).
|
||||||
|
expect(DECK_PASS_Z).toBe(25);
|
||||||
|
const forward: number[] = [];
|
||||||
|
for (let i = 0; i <= 200; i += 1) forward.push(deckZ(i / 200));
|
||||||
|
for (let i = 1; i < forward.length; i += 1) expect(forward[i]!).toBeGreaterThanOrEqual(forward[i - 1]!);
|
||||||
|
const back: number[] = [];
|
||||||
|
for (let i = 200; i >= 0; i -= 1) back.push(deckZ(i / 200));
|
||||||
|
expect(back.reverse()).toEqual(forward);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not leave the horizon before the exit sign fires, then passes over at the line', () => {
|
||||||
|
// A long chapter: the sign fires with the next heading 0.55 vh below the line.
|
||||||
|
const span = 3;
|
||||||
|
expect(underpassTravel(gap('experience', -(span - 0.6), 0.6))).toBeNull();
|
||||||
|
const fired = underpassTravel(gap('experience', -(span - 0.55), 0.55));
|
||||||
|
expect(fired?.exit).toBe('infrastructure');
|
||||||
|
expect(fired?.z).toBeCloseTo(DECK_FAR_Z, 6);
|
||||||
|
expect(underpassTravel(gap('experience', -span, 0))?.z).toBeCloseTo(DECK_PASS_Z, 6);
|
||||||
|
// Narrower trigger (a tall screen): the deck waits longer.
|
||||||
|
expect(underpassTravel(gap('experience', -(span - 0.4), 0.4), 0.23)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the same deck on behind the camera past the cut, and scrolls back under it', () => {
|
||||||
|
const before = underpassTravel(gap('profile', -0.999, 0.001));
|
||||||
|
const after = underpassTravel(gap('experience', -0.001, 1.5));
|
||||||
|
expect(before?.exit).toBe('experience');
|
||||||
|
expect(after?.exit).toBe('experience');
|
||||||
|
expect(Math.abs(after!.z - before!.z)).toBeLessThan(1);
|
||||||
|
expect(underpassTravel(gap('experience', -0.24, 1.26))!.z).toBeGreaterThan(after!.z);
|
||||||
|
expect(underpassTravel(gap('experience', -0.3, 1.2))).toBeNull();
|
||||||
|
// Contact has no next heading, but its own deck still trails out.
|
||||||
|
expect(underpassTravel(gap('contact', -0.1, null))?.exit).toBe('contact');
|
||||||
|
// Start has no deck behind it.
|
||||||
|
expect(underpassTravel(gap('start', 0, 1))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('underpass shade', () => {
|
||||||
|
it('peaks at no more than 35% over the camera and is zero outside the 18 m band', () => {
|
||||||
|
let peak = 0;
|
||||||
|
for (let z = -200; z <= 80; z += 0.25) {
|
||||||
|
const shade = shadeFor(z, 25);
|
||||||
|
peak = Math.max(peak, shade);
|
||||||
|
expect(shade).toBeLessThanOrEqual(0.35);
|
||||||
|
if (Math.abs(z - 25) >= 18) expect(shade).toBe(0);
|
||||||
|
}
|
||||||
|
expect(peak).toBeCloseTo(SHADE_PEAK, 10);
|
||||||
|
expect(SHADE_PEAK).toBeLessThanOrEqual(0.35);
|
||||||
|
expect(shadeFor(Number.NaN)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never moves faster than 0 to peak in 300 ms', () => {
|
||||||
|
let shade = 0;
|
||||||
|
let steps = 0;
|
||||||
|
while (shade < SHADE_PEAK) {
|
||||||
|
shade = slewShade(shade, 1, 1 / 120);
|
||||||
|
steps += 1;
|
||||||
|
}
|
||||||
|
expect(steps / 120).toBeGreaterThanOrEqual(0.3 - 1e-9);
|
||||||
|
expect(slewShade(SHADE_PEAK, 0, 0.1)).toBeCloseTo(SHADE_PEAK * (2 / 3), 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('latches once per pass: brisk scrolls and explicit navigation get none, and full shades are 1 s apart', () => {
|
||||||
|
const latch = createShadeLatch();
|
||||||
|
expect(latch.update(true, 0.3, false, 0)).toBe(true);
|
||||||
|
// Held for the whole pass, even if the drive speeds up.
|
||||||
|
expect(latch.update(true, 0.9, false, 200)).toBe(true);
|
||||||
|
expect(latch.update(false, 0.3, false, 400)).toBe(false);
|
||||||
|
// The next pass within 1 s of the last full shade: none.
|
||||||
|
expect(latch.update(true, 0.3, false, 900)).toBe(false);
|
||||||
|
expect(latch.update(true, 0.3, false, 1_200)).toBe(false);
|
||||||
|
latch.update(false, 0.3, false, 1_300);
|
||||||
|
expect(latch.update(true, 0.3, false, 1_400)).toBe(true);
|
||||||
|
latch.update(false, 0.3, false, 1_500);
|
||||||
|
// Brisk: the sign's gate.
|
||||||
|
expect(latch.update(true, 0.51, false, 5_000)).toBe(false);
|
||||||
|
latch.update(false, 0.3, false, 5_100);
|
||||||
|
expect(latch.update(true, 0.5, false, 5_200)).toBe(true);
|
||||||
|
latch.update(false, 0.3, false, 5_300);
|
||||||
|
// An explicit navigation long-scroll.
|
||||||
|
expect(latch.update(true, 0.2, true, 9_000)).toBe(false);
|
||||||
|
expect(latch.passes).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('underpass exits', () => {
|
||||||
|
it('has seven decks, exits 02-08, each in its sign accent', () => {
|
||||||
|
expect(UNDERPASS_EXITS.map((exit) => exit.label)).toEqual([
|
||||||
|
'EXIT 02', 'EXIT 03', 'EXIT 04', 'EXIT 05', 'EXIT 06', 'EXIT 07', 'EXIT 08',
|
||||||
|
]);
|
||||||
|
for (const exit of UNDERPASS_EXITS) expect(exit.accent).toBe(SIGN_ACCENTS[exit.id]);
|
||||||
|
expect(UNDERPASS_EXITS[0]!.id).toBe('profile');
|
||||||
|
expect(UNDERPASS_EXITS[6]!.id).toBe('contact');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('underpass deck', () => {
|
||||||
|
it('clears the scaled car and the docked exit sign, and spans the road and its shoulders', () => {
|
||||||
|
const G = UNDERPASS_GEOMETRY;
|
||||||
|
expect(G.clearance).toBeGreaterThan(G.carHeight * 2);
|
||||||
|
// The docked sign's top edge reaches 20.4 m (Infrastructure at 1440x900).
|
||||||
|
expect(G.clearance).toBeGreaterThan(20.4);
|
||||||
|
// Road 20.4 m wide, shoulders to +-12; piers stand clear of both.
|
||||||
|
expect(G.span / 2).toBeGreaterThan(12);
|
||||||
|
expect(G.pierX - G.pierWidth / 2).toBeGreaterThan(12);
|
||||||
|
expect(G.pierX + G.pierWidth / 2).toBeLessThan(G.span / 2);
|
||||||
|
expect(G.depth).toBe(10);
|
||||||
|
expect(G.span).toBe(70);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns shade 0 with no gap, and casts it only for a calm scroll on the full tier', () => {
|
||||||
|
const sound = vi.fn();
|
||||||
|
const world = createUnderpass(new SceneResourceBag(), { full: true, sound });
|
||||||
|
expect(world.update('experience', null, 0.2, 1 / 60, 0)).toEqual({ shade: 0, z: null, exit: null });
|
||||||
|
expect(world.update('experience', 0.3, 0.2, 1 / 60, 16)).toEqual({ shade: 0, z: null, exit: null });
|
||||||
|
expect(world.group.visible).toBe(false);
|
||||||
|
// Slow scroll to the line.
|
||||||
|
let now = 16;
|
||||||
|
let peak = 0;
|
||||||
|
for (let p = 0.5; p <= 1; p += 0.002) {
|
||||||
|
now += 33;
|
||||||
|
const state = world.update('experience', p, 0.2, 0.033, now, { cameraZ: 25 });
|
||||||
|
peak = Math.max(peak, state.shade);
|
||||||
|
expect(state.shade).toBeLessThanOrEqual(0.35);
|
||||||
|
}
|
||||||
|
expect(peak).toBeGreaterThan(0.3);
|
||||||
|
expect(world.inspect().exit).toBe('infrastructure');
|
||||||
|
expect(world.inspect().label).toBe('EXIT 04');
|
||||||
|
expect(world.inspect().pool).toBe(true);
|
||||||
|
expect(sound).toHaveBeenCalledTimes(1);
|
||||||
|
expect(underpassUniforms.uShade.value).toBeGreaterThan(0.3);
|
||||||
|
// Past the deck: the shade eases out over >= 300 ms.
|
||||||
|
const out = world.update('infrastructure', null, 0.2, 0.1, now + 100);
|
||||||
|
expect(out.shade).toBeGreaterThan(0.2);
|
||||||
|
for (let i = 0; i < 10; i += 1) world.update('infrastructure', null, 0.2, 0.1, now + 200 + i * 100);
|
||||||
|
expect(world.inspect().shade).toBe(0);
|
||||||
|
expect(underpassUniforms.uShade.value).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a brisk pass or an explicit navigation gets the structure but no shade or sound', () => {
|
||||||
|
for (const [speed, explicit] of [[0.8, false], [0.2, true]] as const) {
|
||||||
|
const sound = vi.fn();
|
||||||
|
const world = createUnderpass(new SceneResourceBag(), { full: true, sound });
|
||||||
|
let now = 0;
|
||||||
|
for (let p = 0.5; p <= 1; p += 0.01) {
|
||||||
|
now += 16;
|
||||||
|
const state = world.update('profile', p, speed, 0.016, now, { explicit });
|
||||||
|
expect(state.shade).toBe(0);
|
||||||
|
}
|
||||||
|
expect(world.inspect().visible).toBe(true);
|
||||||
|
expect(sound).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the low tier draws the deck and strips only', () => {
|
||||||
|
const sound = vi.fn();
|
||||||
|
const world = createUnderpass(new SceneResourceBag(), { full: false, sound });
|
||||||
|
let now = 0;
|
||||||
|
for (let p = 0.5; p <= 1; p += 0.002) {
|
||||||
|
now += 33;
|
||||||
|
expect(world.update('toolkit', p, 0.2, 0.033, now).shade).toBe(0);
|
||||||
|
}
|
||||||
|
expect(world.inspect().visible).toBe(true);
|
||||||
|
expect(world.inspect().pool).toBe(false);
|
||||||
|
expect(sound).not.toHaveBeenCalled();
|
||||||
|
expect(underpassUniforms.uShade.value).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds behind the exit sign while it comes in', () => {
|
||||||
|
const world = createUnderpass(new SceneResourceBag(), { full: true, sound: vi.fn() });
|
||||||
|
let now = 0;
|
||||||
|
for (let p = 0.6; p <= 0.95; p += 0.01) {
|
||||||
|
now += 16;
|
||||||
|
world.update('profile', p, 0.2, 0.016, now, { signZ: -60 });
|
||||||
|
expect(world.inspect().z!).toBeLessThanOrEqual(-70 + 1e-6);
|
||||||
|
}
|
||||||
|
expect(world.inspect().held).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('underpass palette boost', () => {
|
||||||
|
it('settles x4 deep in the shade, and not on top of the storm', () => {
|
||||||
|
expect(paletteDampRate(0)).toBe(1);
|
||||||
|
expect(paletteDampRate(0.5)).toBe(1);
|
||||||
|
expect(paletteDampRate(0.8)).toBe(4);
|
||||||
|
expect(paletteDampRate(0.8, 1)).toBe(1);
|
||||||
|
expect(paletteDampRate(0.8, 0.5)).toBeCloseTo(2.5, 10);
|
||||||
|
const slow = createMutableChapterPalette('deep-night');
|
||||||
|
const fast = createMutableChapterPalette('deep-night');
|
||||||
|
const target = writeChapterPalette(createMutableChapterPalette(), 'deep-night', 'infrastructure', 1);
|
||||||
|
dampPalette(slow, target, 0.1);
|
||||||
|
dampPalette(fast, target, 0.1, 4);
|
||||||
|
const distance = (palette: typeof slow) => Math.abs(palette.warmth - target.warmth);
|
||||||
|
expect(distance(fast)).toBeLessThan(distance(slow));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('underpass pass swell', () => {
|
||||||
|
it('is silent with no audible soundtrack', () => {
|
||||||
|
expect(readSwitchboard()).toBeNull();
|
||||||
|
expect(playUnderpassPass()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays out of the initial bundle: only the scene chunk imports the helper', () => {
|
||||||
|
const environment = readFileSync(resolve(process.cwd(), 'src/scene/world/environment.ts'), 'utf8');
|
||||||
|
expect(environment).toContain("from '@/scene/world/underpass'");
|
||||||
|
const css = readFileSync(resolve(process.cwd(), 'src/styles/world.css'), 'utf8');
|
||||||
|
expect(css).toContain("html[data-underpass='under'] .journey-hud__section");
|
||||||
|
});
|
||||||
|
});
|
||||||