Files
Portfolio/src/scene/world/environment.ts

961 lines
39 KiB
TypeScript

import * as THREE from 'three';
import type { JourneySnapshot } from '@/journey/types';
import { readMusicPulse } from '@/audio/music-pulse';
import type { SceneResourceBag } from '@/scene/resources';
import type { SceneBuildContext, SceneGeneration } from '@/scene/types';
import { renderQualityFor } from '@/scene/render-quality';
import { createHorizonAir } from '@/scene/world/horizon-air';
import { createExpressway } from '@/scene/world/expressway';
import { createDistricts } from '@/scene/world/districts';
import { createSignStage } from '@/scene/world/sign-stage';
import { createSpeedWake } from '@/scene/world/speed-wake';
import { createRoad } from '@/scene/world/road';
import { createTerrain } from '@/scene/world/terrain';
import { createSun } from '@/scene/world/sun';
import { createCar, loadGrandTourer } from '@/scene/world/car';
import { createSign, prepareSignTypography } from '@/scene/world/sign';
import { createAtmosphere } from '@/scene/world/atmosphere';
import { createPostProcessor } from '@/scene/post-processing';
import { installCinematicGrade } from '@/scene/cinematic-grade';
import { createSkyline } from '@/scene/world/skyline';
import { createTraffic } from '@/scene/world/traffic';
import { createDriveDynamics } from '@/scene/drive-dynamics';
import { HORIZON_FOG_END, HORIZON_FOG_START } from '@/scene/world/metrics';
import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition';
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,
} from '@/scene/world/palette';
import { createMaintenanceWindow } from '@/scene/world/maintenance-window';
import { createHeadlights } from '@/scene/world/headlights';
import { maintenanceProgress } from '@/journey/maintenance-progress';
import { createMilestones } from '@/scene/world/milestones';
import { billboardNear, createBillboard, textureSize } from '@/scene/world/billboard';
import { BILLBOARD_NUDGE, RELAY_NUDGE } from '@/scene/world/composition';
import { createRelayMasts } from '@/scene/world/relay-masts';
import { createYard } from '@/scene/world/yard';
import { isMilestoneSection, sectionLocalProgress } from '@/journey/route-log';
import { dawnStep, type DawnStep } from '@/journey/dawn';
import { dawnLightUniforms } from '@/scene/world/dawn-light';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
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 =
typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
export function createChaseCamera(aspect = 1) {
const c = CHAPTER_COMPOSITIONS.start;
const camera = new THREE.PerspectiveCamera(c.fov, aspect, 0.1, 1_200);
camera.position.set(c.x, c.y, c.z);
camera.lookAt(0, c.targetY, c.targetZ);
return camera;
}
export function createWorldFog(color: number) {
return new THREE.Fog(color, HORIZON_FOG_START, HORIZON_FOG_END);
}
export async function createProceduralSceneGeneration(
context: SceneBuildContext,
): Promise<SceneGeneration> {
if (TEST_BUILD && isTestFault('F1-shader')) {
recordTestFaultEvent('shader-rejected');
throw new Error('Injected shader failure');
}
if (TEST_BUILD && isTestFault('F1-texture')) {
recordTestFaultEvent('texture-rejected');
throw new Error('Injected texture failure');
}
if (TEST_BUILD && isTestFault('F1-car')) {
recordTestFaultEvent('car-rejected');
throw new Error('Injected car failure');
}
if (context.signal.aborted)
throw new DOMException('Scene build aborted', 'AbortError');
const canvas = context.canvas as HTMLCanvasElement;
if (typeof canvas.getContext !== 'function')
throw new Error('A canvas element is required');
const mobileOrTablet =
Math.max(canvas.clientWidth || 0, canvas.clientHeight || 0) < 1280;
const quality = renderQualityFor(
context.quality,
globalThis.devicePixelRatio ?? 1,
mobileOrTablet,
);
installCinematicGrade();
const renderer = context.resources.own(
'renderer',
new THREE.WebGLRenderer({
canvas,
alpha: false,
antialias: context.quality !== 'low',
powerPreference: 'high-performance',
}),
);
renderer.setPixelRatio(quality.pixelRatio);
renderer.outputColorSpace = THREE.SRGBColorSpace;
// ACES plus the film grade in cinematic-grade.ts, shared by every tier.
renderer.toneMapping = THREE.CustomToneMapping;
renderer.toneMappingExposure = IGNITION.exposure;
const scene = new THREE.Scene();
scene.name = 'world-root';
scene.environment = createCoachworkEnvironment(context.resources, renderer);
scene.environmentIntensity = 0.85;
const currentPalette = createMutableChapterPalette('deep-night');
const targetPalette = createMutableChapterPalette('deep-night');
const background = currentPalette.sky.clone();
scene.background = background;
scene.fog = createWorldFog(currentPalette.fog.getHex());
const camera = createChaseCamera();
camera.name = 'journey-camera';
scene.add(camera);
const ambient = new THREE.HemisphereLight(0x6aa9c5, 0x100812, 1.25);
scene.add(ambient);
const rearLight = new THREE.PointLight(0xff365f, 5, 18);
rearLight.position.set(0, 1.2, 7);
scene.add(rearLight);
const carKeyLight = new THREE.PointLight(0x72dce8, 18, 34, 2);
carKeyLight.name = 'car-key-light';
carKeyLight.position.set(-4.8, 5.4, 9.5);
scene.add(carKeyLight);
const carFillLight = new THREE.PointLight(0xb83f93, 11, 28, 2);
carFillLight.name = 'car-fill-light';
carFillLight.position.set(4.2, 3.4, 8);
scene.add(carFillLight);
const road = createRoad(context.resources, quality);
const terrain = createTerrain(context.resources, quality);
const speedWake = createSpeedWake(context.resources);
const expressway = createExpressway(context.resources, quality);
const districts = createDistricts(context.resources, quality, context.signal);
const signStage = createSignStage(context.resources);
const sun = createSun(context.resources);
const horizonAir = createHorizonAir(context.resources);
const skyline = createSkyline(context.resources);
const traffic = createTraffic(context.resources, quality, scene.fog.color);
// Phones and the low tier get the decimated car; it is about 200 px wide
// there and seen from behind.
const liteCar =
Math.min(canvas.clientWidth || 0, canvas.clientHeight || 0) < 600
|| context.quality === 'low';
const authoredCar = await loadGrandTourer(context.signal, liteCar);
const car = createCar(context.resources, authoredCar, { coachwork: !liteCar });
car.setDetail(mobileOrTablet ? 'reduced' : 'full');
await prepareSignTypography(context.signal);
const sign = createSign(context.resources, {
maximumAnisotropy: renderer.capabilities.getMaxAnisotropy(),
maximumTextureSize: renderer.capabilities.maxTextureSize,
});
const atmosphere = createAtmosphere(context.resources, quality);
// HIGH BEAM: the cursor steers the headlights (road cones on every tier).
const headlights = createHeadlights(
context.resources,
context.quality,
// A THREE.Group at runtime; CarGroup only types what car.ts uses.
car.group as unknown as Parameters<typeof createHeadlights>[2],
);
// The 03:12 window. Phones, coarse pointers and the low tier keep only the
// power-down, the re-light and the palette nudge: no rain, packets or lightning.
const maintenance = createMaintenanceWindow(context.resources);
const maintenanceTier =
context.quality === 'low'
|| (typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches)
? 'lite'
: 'full';
// END OF SHIFT: the low tier and touch keep the palette, the sun lift and
// the star drain (uniform writes); the warm rims and the bloom change are
// high/medium fine-pointer only.
const dawnRims = maintenanceTier === 'full';
// MILE MARKERS: kilometre posts roll past through Experience and Profile.
// Low tier and touch draw them without beam, pulse or flash.
const milestones = createMilestones(context.resources, maintenanceTier === 'full');
// DRIVE-IN: the Selected builds billboard. Low tier and touch: one steady
// flood, no pulse, flicker or hover zoom, 512 px screens.
const billboard = createBillboard(context.resources, {
mode: maintenanceTier,
textureSize: textureSize(context.quality),
});
let billboardKey = '';
// RELAY STATION: transmitter masts come online through Toolkit. Low tier,
// touch and data saver: all rungs lit, steady beacons, no beams, hover or tone.
const relay = createRelayMasts(
context.resources,
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,
});
// NIGHT FREIGHT: a gantry crane works the Additional work container yard.
// Low tier and touch: crane, stacks and plates; no floods, lamps or pulse.
const yard = createYard(context.resources, maintenanceTier === 'full');
let underpassShade = 0;
let underpassKey = '';
let underpassAttr = false;
let underpassSince = -1;
scene.add(
underpass.group,
relay.group,
yard.group,
billboard.mesh,
maintenance.group,
milestones.mesh,
milestones.pool,
atmosphere.group,
road.group,
expressway.group,
districts.group,
terrain.group,
sun.group,
horizonAir.group,
skyline.group,
traffic.group,
car.group,
speedWake.group,
sign.group,
sign.truss,
...headlights.lights,
);
const postProcessor = quality.postEffects
? createPostProcessor(context.resources, renderer)
: null;
const dynamics = createDriveDynamics();
const cameraState = {
x: camera.position.x,
y: camera.position.y,
z: camera.position.z,
targetX: 0,
targetY: 3.9,
targetZ: -90,
shift: CHAPTER_COMPOSITIONS.start.shift,
verticalShift: 0,
fov: camera.fov,
roll: 0,
};
let width = 0;
let height = 0;
const travelDistance = createTravelDistance();
let lastNow: number | null = null;
let disposed = false;
let renderedFrames = 0;
let atmosphericTime = 0;
let overdrive = 0;
let terrainSurge = 0;
let musicPulse = 0;
let terrainSpectrumPhase = 0;
let firstFrameSuppressionRecorded = false;
// Opening shot: a crane down from the sun to the chase position. It plays
// once, only when the visit starts at the top, and a scroll hurries it.
let introProgress: number | null = null;
let introFrames = 0;
let introSlowFrames = 0;
// The outro mirrors the intro: on arrival the camera cranes back up toward
// the sun on a long lens. Leaving the destination reverses it, faster.
let outroProgress = 0;
let interludeOn = false;
let riseUntil = 0;
let gantryState = '';
let dawnState: DawnStep | '' = '';
// DASHCAM: the pointer drifts the lens and the letterbox closes while open.
const dashcamLens = createDashcamLens();
// IGNITION: the first frame is cold; lamps, beam, roll and exposure come up
// over three seconds (src/scene/world/ignition.ts).
const ignition = createIgnition();
const resize = (): void => {
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
const nextHeight = Math.max(1, canvas.clientHeight || canvas.height || 1);
if (nextWidth === width && nextHeight === height) return;
width = nextWidth;
height = nextHeight;
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
if (lastNow === null) {
const wideComposition = camera.aspect > 1.18;
cameraState.x = wideComposition ? CHAPTER_COMPOSITIONS.start.x : 0;
cameraState.targetX = 0;
cameraState.shift = wideComposition
? CHAPTER_COMPOSITIONS.start.shift
: 0;
cameraState.verticalShift = wideComposition ? 0 : 0.16;
camera.position.set(cameraState.x, cameraState.y, cameraState.z);
camera.lookAt(
cameraState.targetX,
cameraState.targetY,
cameraState.targetZ,
);
}
car.setDetail(width < 1_100 || height < 620 ? 'reduced' : 'full');
car.group.visible = height >= 340;
sign.setViewportSize(width, height);
atmosphere.setViewportAspect(camera.aspect);
postProcessor?.resize(
width,
height,
quality.pixelRatio * quality.postScale,
);
if (TEST_BUILD && context.quality !== 'medium') {
window.__PORTFOLIO_PERFORMANCE__?.recordRuntimeAllocation(
context.resources.gpuBudgetSnapshot({
tier: context.quality,
defaultFramebuffer: {
width: Math.max(1, canvas.width),
height: Math.max(1, canvas.height),
bytesPerPixel: 4,
attachments: 2,
samples: context.quality === 'low' ? 1 : 4,
},
}),
);
}
};
if (context.signal.aborted) {
context.resources.dispose();
throw new DOMException('Scene build aborted', 'AbortError');
}
return {
render(snapshot: Readonly<JourneySnapshot>, now: number) {
if (disposed) return;
if (TEST_BUILD && isTestFault('F2-first-frame-timeout')) {
if (!firstFrameSuppressionRecorded) {
firstFrameSuppressionRecorded = true;
recordTestFaultEvent('first-frame-suppressed');
}
return false;
}
if (
TEST_BUILD &&
isTestFault('F3-post-first-frame-exception') &&
renderedFrames > 0
) {
recordTestFaultEvent('post-first-frame-exception');
throw new Error('Injected post-first-frame exception');
}
resize();
const wallSeconds = lastNow === null ? 0 : Math.max(0, (now - lastNow) / 1000);
const deltaSeconds = Math.min(0.1, wallSeconds);
lastNow = now;
dynamics.update(snapshot.acceleration, snapshot.direction, deltaSeconds);
const response = dynamics.snapshot();
// Read the existing speed contract; visual charge has its own slow release
// and cannot change the soundtrack or the canonical drive dynamics.
const chargeTarget = THREE.MathUtils.smoothstep(
snapshot.driveSpeed,
0.68,
0.9,
);
overdrive +=
(chargeTarget - overdrive) *
(1 -
Math.exp(-deltaSeconds / (chargeTarget > overdrive ? 0.28 : 1.05)));
// Terrain feedback reads acceleration directly, with its own fast attack
// and long release. Audio and the shared drive responses stay untouched.
const surgeTarget = Math.sqrt(
THREE.MathUtils.clamp(snapshot.acceleration, 0, 1),
);
// Soundtrack coupling: the bass the visitor hears lifts the terrain and
// the sun. One analyser read per frame; zero when nothing is audible.
const pulseTarget = readMusicPulse();
musicPulse +=
(pulseTarget - musicPulse) *
(1 - Math.exp(-deltaSeconds / (pulseTarget > musicPulse ? 0.05 : 0.32)));
const beat = THREE.MathUtils.smoothstep(musicPulse, 0.42, 0.92);
terrainSurge +=
(Math.max(surgeTarget, beat * 0.55) - terrainSurge) *
(1 -
Math.exp(-deltaSeconds / (Math.max(surgeTarget, beat * 0.55) > terrainSurge ? 0.11 : 1.15)));
// Integrate color travel instead of multiplying elapsed time by speed:
// lifting off slows the spectrum to rest without rewinding its phase.
terrainSpectrumPhase =
(terrainSpectrumPhase + deltaSeconds * overdrive * 0.38) % 1;
// IGNITION: the first three seconds. A scroll, a navigation, a sign, the
// dashcam or a stuttering start (the crane's rule) cuts to driving. The
// clock starts on the frame after the first: the first frame compiles
// the shaders (hundreds of ms on a cold GPU) while the canvas dissolves
// in on the compositor, and that stall must not eat the cold hold. A
// later hitch stretches the timeline instead of skipping a beat.
const ignited = ignition.update(renderedFrames <= 1 ? 0 : Math.min(0.1, wallSeconds), {
cut: snapshot.scrollY > 40
|| snapshot.activeSectionId !== 'start'
|| snapshot.sign !== null
|| readDashcam().open
|| introSlowFrames >= 3,
short: readIgnition().returning,
});
const roll = ignited.roll;
renderer.toneMappingExposure = ignited.exposure;
if (renderedFrames === 0) canvas.dataset.lit = '';
writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0);
// The 03:12 window: section-local scroll progress drives every beat.
const service = maintenance.update(
maintenanceProgress(snapshot),
snapshot.direction,
deltaSeconds,
maintenanceTier,
);
if (service.storm > 0) {
targetPalette.sky.lerp(STORM_SKY, 0.55 * service.storm);
targetPalette.fog.lerp(STORM_SKY, 0.4 * service.storm);
targetPalette.starVisibility *= 1 - (1 - STORM_STAR_SCALE) * service.storm;
}
districts.setServiceState(service.dim, service.relight);
milestones.update(
snapshot.activeSectionId,
isMilestoneSection(snapshot.activeSectionId)
? sectionLocalProgress(snapshot, snapshot.activeSectionId)
: null,
musicPulse,
deltaSeconds,
);
// The deck from the previous frame (underpass updates after the sign).
const yardDeck = underpass.inspect();
yard.update(
snapshot.activeSectionId,
sectionLocalProgress(snapshot, 'additional-work'),
musicPulse,
deltaSeconds,
yardDeck.visible ? yardDeck.z : null,
camera.aspect,
);
const relayNudge = relay.update(
snapshot.activeSectionId,
snapshot.activeSectionId === 'toolkit' ? sectionLocalProgress(snapshot, 'toolkit') : null,
musicPulse,
document.documentElement.dataset.relayFocus,
deltaSeconds,
now,
camera.aspect,
);
const billboardNudge = billboard.update({
section: snapshot.activeSectionId,
p: snapshot.activeSectionId === 'selected-builds'
? sectionLocalProgress(snapshot, 'selected-builds')
: null,
direction: snapshot.direction,
near: billboardNear(snapshot),
pulse: musicPulse,
energy: overdrive,
focus: document.documentElement.dataset.billboardFocus,
narrow: camera.aspect <= 1.18,
deltaSeconds,
});
// END OF SHIFT: parked at the destination, the night ends. One scalar
// drives it all; leaving rewinds it as fast as the outro returns.
const dawn = easeInOutCubic(outroProgress);
blendDaybreak(targetPalette, THREE.MathUtils.smoothstep(dawn, 0.15, 0.95));
// Engine off: parked at the destination the sky drops about 10%, the
// stars come out (then drain as the dawn rises), and the drive winds
// down to a stop.
const engineOff = THREE.MathUtils.smoothstep(outroProgress, 0.12, 0.7);
if (engineOff > 0) {
targetPalette.sky.lerp(ENGINE_OFF_NIGHT, 0.1 * engineOff);
targetPalette.fog.lerp(ENGINE_OFF_NIGHT, 0.1 * engineOff);
targetPalette.starVisibility *= engineOffStarScale(engineOff, dawn);
}
dawnLightUniforms.uDawnLight.value = dawnRims ? dawn : 0;
// 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,
response.roadEnergy,
);
const visualSpeed = THREE.MathUtils.clamp(
0.18 + response.roadEnergy * 0.82,
0.18,
1,
) * (1 - engineOff) * roll;
road.update(
driveDistance,
visualSpeed,
response.roadEnergy * roll,
currentPalette,
);
expressway.update(
driveDistance,
response.roadEnergy,
currentPalette.warmth,
snapshot.activeSectionId,
deltaSeconds,
);
districts.update(
driveDistance,
response.roadEnergy,
snapshot.activeSectionId,
atmosphericTime,
deltaSeconds,
);
terrain.update(
driveDistance,
visualSpeed,
snapshot.chapter,
snapshot.routeProgress,
currentPalette,
overdrive,
terrainSurge,
terrainSpectrumPhase,
engineOff,
);
atmosphericTime += deltaSeconds;
const elapsedSeconds = atmosphericTime;
horizonAir.update(currentPalette, elapsedSeconds, dawn);
skyline.update(currentPalette, elapsedSeconds);
traffic.update(
deltaSeconds,
(18 + snapshot.driveSpeed * 54 + response.roadEnergy * 48) * (1 - engineOff) * roll,
elapsedSeconds,
);
sun.update(
currentPalette,
snapshot.routeProgress,
elapsedSeconds,
Math.min(1, response.bloomEnergy + beat * 0.35),
snapshot.arrived,
dawn,
);
car.update(
visualSpeed,
response.suspensionEnergy,
elapsedSeconds,
driveDistance,
engineOff,
ignited.lamps,
);
speedWake.update(
overdrive * roll,
elapsedSeconds,
car.group.position,
width < 1100 || height < 620 ? 2.32 : 2.78,
);
sign.setResting(snapshot.activeSectionId);
sign.update(snapshot.sign, now, snapshot.driveSpeed);
// Distant lightning lifts the sky a little, toward a still-dark lilac.
if (service.flash > 0) {
flashRestore.sky.copy(currentPalette.sky);
flashRestore.fog.copy(currentPalette.fog);
currentPalette.sky.lerp(LIGHTNING_SKY, service.flash * 0.7);
currentPalette.fog.lerp(LIGHTNING_SKY, service.flash * 0.3);
}
atmosphere.update(
driveDistance,
visualSpeed,
response.streakEnergy * roll,
currentPalette,
atmosphericTime,
overdrive,
dawn,
);
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);
ambient.groundColor.copy(currentPalette.fog);
if (service.flash > 0) {
currentPalette.sky.copy(flashRestore.sky);
currentPalette.fog.copy(flashRestore.fog);
}
ambient.intensity = 1.05 + currentPalette.warmth * 0.18;
carKeyLight.color.setHex(0xb2d5ff);
carKeyLight.intensity = 32 + response.bloomEnergy * 3.6 + overdrive * 8;
carFillLight.color.setHex(0xff62ad);
carFillLight.intensity = 12.5 + currentPalette.warmth * 2.8;
rearLight.intensity = 4.2 + response.suspensionEnergy * 1.4;
const wideComposition = camera.aspect > 1.18;
const baseComposition = CHAPTER_COMPOSITIONS[snapshot.activeSectionId];
const upcoming =
snapshot.sign && snapshot.sign.phase !== 'depart'
? CHAPTER_COMPOSITIONS[snapshot.sign.targetId]
: baseComposition;
const anticipation =
snapshot.sign?.phase === 'approach'
? Math.min(0.55, snapshot.sign.progress * 0.55)
: snapshot.sign?.phase === 'dwell'
? 0.55
: 0;
const composition = {
x: THREE.MathUtils.lerp(baseComposition.x, upcoming.x, anticipation),
y: THREE.MathUtils.lerp(baseComposition.y, upcoming.y, anticipation),
z: THREE.MathUtils.lerp(baseComposition.z, upcoming.z, anticipation),
targetY: THREE.MathUtils.lerp(
baseComposition.targetY,
upcoming.targetY,
anticipation,
) + RELAY_NUDGE.targetY * relayNudge,
targetZ: THREE.MathUtils.lerp(
baseComposition.targetZ,
upcoming.targetZ,
anticipation,
) + BILLBOARD_NUDGE.targetZ * billboardNudge,
fov: THREE.MathUtils.lerp(
baseComposition.fov,
upcoming.fov,
anticipation,
),
shift: THREE.MathUtils.lerp(
baseComposition.shift,
upcoming.shift,
anticipation,
) + BILLBOARD_NUDGE.shift * billboardNudge,
};
// The document stays native. Only the rendered lens eases to the chapter's safe zone.
const cameraX = wideComposition ? composition.x : 0;
const targetX = 0;
const targetShift =
height < 560 && width >= 768
? 0.22
: wideComposition
? composition.shift
: 0;
const targetVerticalShift = wideComposition
? 0
: snapshot.activeSectionId === 'start'
? 0.16
: 0.07;
const roadVibration =
Math.sin(driveDistance * 0.105) * (0.012 + response.roadEnergy * 0.024);
const cameraMix =
1 -
Math.exp(
-deltaSeconds / (snapshot.activeSectionId === 'contact' ? 1.5 : 1.05),
);
cameraState.x = THREE.MathUtils.lerp(
cameraState.x,
cameraX - response.directionBias * 0.16,
cameraMix,
);
cameraState.y = THREE.MathUtils.lerp(
cameraState.y,
(wideComposition ? composition.y : 6.5) + roadVibration,
cameraMix,
);
cameraState.z = THREE.MathUtils.lerp(
cameraState.z,
(wideComposition ? composition.z : 31) + response.cameraEnergy * 0.9,
cameraMix,
);
cameraState.targetX = THREE.MathUtils.lerp(
cameraState.targetX,
targetX + response.directionBias * 0.32,
cameraMix,
);
cameraState.targetY = THREE.MathUtils.lerp(
cameraState.targetY,
composition.targetY + response.cameraEnergy * 0.32,
cameraMix,
);
cameraState.targetZ = THREE.MathUtils.lerp(
cameraState.targetZ,
composition.targetZ - response.cameraEnergy * 16,
cameraMix,
);
cameraState.fov = THREE.MathUtils.lerp(
cameraState.fov,
(wideComposition ? composition.fov : 61) + response.cameraEnergy * 5.5,
cameraMix,
);
cameraState.roll = THREE.MathUtils.lerp(
cameraState.roll,
-response.directionBias * 0.003 + roadVibration * 0.018,
cameraMix,
);
cameraState.shift = THREE.MathUtils.lerp(
cameraState.shift,
targetShift,
cameraMix,
);
cameraState.verticalShift = THREE.MathUtils.lerp(
cameraState.verticalShift,
targetVerticalShift,
cameraMix,
);
if (introProgress === null) {
introProgress =
snapshot.activeSectionId === 'start' && snapshot.scrollY < 40 ? 0 : 1;
}
if (introProgress < 1) {
// The shot belongs to a visitor who waits at the top. Scrolling,
// navigating or a sign on its way cuts straight to the chase framing:
// signs latch their placement against that camera.
if (
snapshot.scrollY > 40
|| snapshot.activeSectionId !== 'start'
|| snapshot.sign !== null
) introProgress = 1;
// A crane that stutters is worse than none. The first frames carry
// shader compilation and a lone hitch is forgiven; three slow frames
// end the shot.
introFrames += 1;
if (introFrames > 3 && wallSeconds > 0.1) introSlowFrames += 1;
if (introSlowFrames >= 3) introProgress = 1;
// IGNITION: the crane holds its top position until the main beam is on.
if (ignited.phase >= 2) introProgress = Math.min(1, introProgress + deltaSeconds / INTRO_SECONDS);
}
outroProgress = THREE.MathUtils.clamp(
outroProgress
+ (snapshot.arrived ? deltaSeconds / OUTRO_SECONDS : -deltaSeconds / OUTRO_RETURN_SECONDS),
0,
1,
);
// Part of the way back, not all: the end shot holds the sun, the
// skyline and the road running into them, like a film's last frame.
const outro = easeInOutCubic(outroProgress) * OUTRO_REACH;
const crane = easeInOutCubic(
THREE.MathUtils.clamp((introProgress - 0.06) / 0.94, 0, 1),
) * (1 - outro);
const craneShift = cameraState.shift * crane;
const dashcam = dashcamLens.update(readDashcam(), deltaSeconds, now);
camera.setViewOffset(
width,
height,
-width * craneShift,
-height * cameraState.verticalShift,
width,
height,
);
camera.position.set(
THREE.MathUtils.lerp(0, cameraState.x, crane) + dashcam.x,
THREE.MathUtils.lerp(46, cameraState.y, crane),
THREE.MathUtils.lerp(92, cameraState.z, crane),
);
camera.lookAt(
THREE.MathUtils.lerp(0, cameraState.targetX, crane),
THREE.MathUtils.lerp(64, cameraState.targetY, crane) + dashcam.targetY,
THREE.MathUtils.lerp(-720, cameraState.targetZ, crane),
);
camera.rotation.z += cameraState.roll * crane;
const lensFov =
THREE.MathUtils.lerp(30, cameraState.fov, crane);
if (Math.abs(camera.fov - lensFov) > 0.001) {
camera.fov = lensFov;
camera.updateProjectionMatrix();
}
// IGNITION: the dipped level lights the road throw; the steerable main
// beam (spot, haze cone, lift) only comes in above it.
const mainBeam = THREE.MathUtils.clamp((ignited.lamps - IGNITION.dipped) / (1 - IGNITION.dipped), 0, 1);
const beam = headlights.update(snapshot.beam, snapshot, camera, (1 - engineOff) * mainBeam, deltaSeconds);
road.setBeam(beam.yaw, beam.lift, ignited.lamps);
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;
// The HUD tint follows the shade, but only once it has held for
// UNDERPASS_ATTR_HOLD_MS: an approach walk can graze the threshold for a
// frame or three before the real pass. Leaving is immediate.
const shaded = shadeDepth(passage.shade) > 0.2;
if (!shaded) underpassSince = -1;
else if (underpassSince < 0) underpassSince = now;
const under = shaded && now - underpassSince >= UNDERPASS_ATTR_HOLD_MS;
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;
}
// The interlude: the reading plates step aside while the sign has the
// road to itself (CSS keys off this attribute).
// 'on' while the sign approaches, then 'rise' while the plates return.
const interlude = sign.interlude();
if (interlude !== interludeOn) {
interludeOn = interlude;
riseUntil = interlude ? 0 : now + INTERLUDE_RISE_MS;
document.documentElement.dataset.interlude = interlude ? 'on' : 'rise';
// A navigator click may have pre-armed the interlude; the loop owns it now.
delete document.documentElement.dataset.interludeArmed;
} else if (riseUntil > 0 && now > riseUntil) {
riseUntil = 0;
delete document.documentElement.dataset.interlude;
}
// END OF SHIFT: CSS keys the credit and the letterbox tint off this.
const nextDawnState = dawnStep(dawn);
if (nextDawnState !== dawnState) {
dawnState = nextDawnState;
document.documentElement.dataset.dawn = nextDawnState;
}
if (TEST_BUILD) {
const board = billboard.inspect();
const key = `${board.progress.activeProjectId}|${board.shown}|${board.floods.map(Math.round).join()}|${Math.round(board.lit * 10)}|${Math.round(board.rise * 10)}|${Math.round(board.zoom * 10)}|${board.flickers}|${board.shimmers}|${board.textures}`;
if (key !== billboardKey) {
billboardKey = key;
window.dispatchEvent(new CustomEvent('portfolio-billboard-frame', {
detail: { now, activeSectionId: snapshot.activeSectionId, direction: snapshot.direction, ...board },
}));
}
}
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) {
window.dispatchEvent(
new CustomEvent('portfolio-sign-frame', {
detail: {
now,
...snapshot.sign,
activeSectionId: snapshot.activeSectionId,
direction: snapshot.direction,
...presentation,
rendererPixelRatio: renderer.getPixelRatio(),
viewport: { width, height },
},
}),
);
}
}
if (renderedFrames === 0) {
// IGNITION parks the wake, the streaks and the beam cone until the
// pull-away, and the first scroll brings the sign: each would compile
// its program as a 70-280 ms hitch inside the opening shot (three slow
// frames end the crane; one over 250 ms is a quality downgrade that
// rebuilds the scene). Draw everything once now, behind the poster,
// through the real pipeline: renderer.compile() keys its programs for
// the screen (sRGB output, tone mapping) and never matches the post
// processor's linear targets, so every program compiled twice. The
// drawing buffer is presented once per task: the cold frame below
// is what the canvas shows.
// Culling is off too: the car sits outside the crane's frustum.
const hidden: { visible: boolean }[] = [];
const culled: { frustumCulled: boolean }[] = [];
scene.traverse((object: { visible: boolean; frustumCulled: boolean }) => {
if (object.frustumCulled) {
object.frustumCulled = false;
culled.push(object);
}
if (object.visible) return;
object.visible = true;
hidden.push(object);
});
try {
if (postProcessor) postProcessor.render(scene, camera);
else renderer.render(scene, camera);
} finally {
for (const object of hidden) object.visible = false;
for (const object of culled) object.frustumCulled = true;
}
}
if (postProcessor) {
postProcessor.setDynamics(response.bloomEnergy, dawnRims ? dawn : 0);
postProcessor.setLens({
time: elapsedSeconds,
letterbox: Math.max(
0.115 * (1 - THREE.MathUtils.smoothstep(introProgress, 0.5, 1)),
dashcam.letterbox,
),
energy: overdrive,
rain: service.rain,
rainTime: elapsedSeconds,
dawn,
});
postProcessor.render(scene, camera);
} else {
renderer.render(scene, camera);
}
// DASHCAM: the drawing buffer is only valid now, inside this task.
const capture = takeDashcamCapture();
if (capture !== null) {
try {
capture(canvas);
} catch {
// A failed postcard never takes the scene down.
}
}
renderedFrames += 1;
},
dispose() {
if (disposed) return;
disposed = true;
delete document.documentElement.dataset.gantry;
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
// defaults before the next renderer creates its empty array textures.
const gl = renderer.getContext();
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
scene.clear();
},
};
}
/** html[data-underpass] waits for the shade to hold this long (ms). */
const UNDERPASS_ATTR_HOLD_MS = 120;
const INTRO_SECONDS = 4.2;
/** Long enough for the dawn to read: the sun clears the skyline near 3.8 s. */
const OUTRO_SECONDS = 7.0;
const OUTRO_RETURN_SECONDS = 1.6;
const OUTRO_REACH = 0.62;
const ENGINE_OFF_NIGHT = new THREE.Color(0x000000);
const STORM_SKY = new THREE.Color(STORM_TINT);
const LIGHTNING_SKY = new THREE.Color(0x5a5ea8);
const flashRestore = { sky: new THREE.Color(), fog: new THREE.Color() };
/** Plates rise back over 720 ms with up to three 90 ms staggers. */
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;
}