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 { 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[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, 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; }