feat: underpass overpasses carry the chapter transitions

This commit is contained in:
2026-10-11 10:48:31 +03:00
parent 7423f5d52e
commit 989928c5bb
19 changed files with 1084 additions and 33 deletions

View 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
View 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;
},
};
}

View File

@@ -26,12 +26,13 @@ import { createCoachworkEnvironment } from '@/scene/world/coachwork-light';
import { createTravelDistance } from '@/scene/world/travel-distance';
import {
blendDaybreak,
dampPalette,
engineOffStarScale,
paletteDampRate,
createMutableChapterPalette,
STORM_STAR_SCALE,
STORM_TINT,
writeChapterPalette,
type MutableChapterPalette,
} from '@/scene/world/palette';
import { createMaintenanceWindow } from '@/scene/world/maintenance-window';
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 { createIgnition, IGNITION } from '@/scene/world/ignition';
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;
const TEST_BUILD =
@@ -195,7 +199,19 @@ export async function createProceduralSceneGeneration(
maintenanceTier === 'full'
&& (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(
underpass.group,
relay.group,
billboard.mesh,
maintenance.group,
@@ -447,7 +463,9 @@ export async function createProceduralSceneGeneration(
targetPalette.starVisibility *= engineOffStarScale(engineOff, dawn);
}
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(
deltaSeconds * (1 - engineOff) * roll,
snapshot.driveSpeed,
@@ -540,6 +558,11 @@ export async function createProceduralSceneGeneration(
);
background.copy(currentPalette.sky);
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
.copy(currentPalette.haze)
.lerp(currentPalette.terrainHigh, 0.26);
@@ -731,6 +754,29 @@ export async function createProceduralSceneGeneration(
const stage = signStage.update(width, height);
sign.compose(camera, width, height, stage.dock, now, stage.tilt);
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) {
gantryState = 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) {
const presentation = sign.inspect(camera, width, height);
if (presentation !== null) {
@@ -849,6 +905,8 @@ export async function createProceduralSceneGeneration(
delete document.documentElement.dataset.interlude;
delete document.documentElement.dataset.interludeArmed;
delete document.documentElement.dataset.dawn;
delete document.documentElement.dataset.underpass;
underpassUniforms.uShade.value = 0;
ignition.dispose();
dawnLightUniforms.uDawnLight.value = 0;
// 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 {
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,
);
}

View File

@@ -239,3 +239,40 @@ function lerp(from: number, to: number, progress: number): number {
function clamp01(value: number): number {
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);
}

View File

@@ -4,6 +4,7 @@ import type { SceneResourceBag } from '@/scene/resources';
import type { MutableChapterPalette } from '@/scene/world/palette';
import { CHAPTER_PALETTES } from '@/scene/world/palette';
import { DAWN_PARS, dawnLightUniforms } from '@/scene/world/dawn-light';
import { UNDERPASS_PARS, underpassUniforms } from '@/scene/world/underpass';
import {
HORIZON_FOG_END,
HORIZON_FOG_START,
@@ -64,6 +65,7 @@ export function createRoad(
uBeamLift: { value: 0 },
uThrow: { value: 1 },
...dawnLightUniforms,
...underpassUniforms,
};
const material = resources.own(
'material',
@@ -106,6 +108,7 @@ export function createRoad(
varying float vWorldZ;
varying float vNearFade;
${DAWN_PARS}
${UNDERPASS_PARS}
float hash21(vec2 point) {
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
* (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.
float ahead = -vWorldZ - 5.0;

View File

@@ -1,6 +1,7 @@
import * as THREE from 'three';
import { BEAM_PARS, beamUniforms } from './headlights';
import { DAWN_PARS, dawnLightUniforms } from './dawn-light';
import { UNDERPASS_PARS, underpassUniforms } from './underpass';
import type { EnvironmentChapter } from '@/journey/types';
import type { RenderQualitySettings } from '@/scene/render-quality';
import type { SceneResourceBag } from '@/scene/resources';
@@ -72,6 +73,7 @@ export function createTerrain(
uHighColor: { value: highColor },
uFogColor: { value: new THREE.Color(initialPalette.fog) },
uHazeColor: { value: new THREE.Color(initialPalette.haze) },
...underpassUniforms,
};
const material = resources.own(
'material',
@@ -195,6 +197,7 @@ export function createTerrain(
varying vec3 vTerrainPosition;
varying vec3 vGridColor;
varying float vCrest;
${UNDERPASS_PARS}
void main() {
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 surfaceStrength = mix(0.72, 0.12 + uOverdrive * 0.3, road);
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);
charge += uSurge * (0.28 + vCrest * 0.5) * (1.0 - road);
gl_FragColor = vec4(color * (1.46 + ridgeMix * 0.54 + charge), alpha);
@@ -253,6 +256,7 @@ export function createTerrain(
varying vec3 vGridColor;
${BEAM_PARS}
${DAWN_PARS}
${UNDERPASS_PARS}
void main() {
vec3 normal = normalize(cross(dFdx(vTerrainPosition), dFdy(vTerrainPosition)));
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;
// HIGH BEAM: the ridges beside the road catch the headlights.
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;
// END OF SHIFT: the ridges catch first light from the low sun ahead.
color += dawnRim(normal, normalize(cameraPosition - vTerrainPosition));

View 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;
}

View File

@@ -704,3 +704,9 @@ html:is([data-dawn='sunrise'], [data-dawn='day']) .journey-world::after { backgr
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; }
}