961 lines
39 KiB
TypeScript
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;
|
|
}
|