feat: underpass overpasses carry the chapter transitions
This commit is contained in:
60
src/audio/underpass-pass.ts
Normal file
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
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 {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
422
src/scene/world/underpass.ts
Normal file
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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user