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 { usesRoadsideSign } from '@/scene/world/sign-layout'; 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 { 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 { createMutableChapterPalette, writeChapterPalette, type MutableChapterPalette, } from '@/scene/world/palette'; import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks'; 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, ); 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; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.05; 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 authoredCar = await loadGrandTourer(context.signal); const car = createCar(context.resources, authoredCar); 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); scene.add( atmosphere.group, road.group, expressway.group, districts.group, terrain.group, sun.group, horizonAir.group, car.group, speedWake.group, sign.group, ); 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 signCameraWeight = 0; let overdrive = 0; let terrainSurge = 0; let musicPulse = 0; let terrainSpectrumPhase = 0; let firstFrameSuppressionRecorded = false; 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 deltaSeconds = lastNow === null ? 0 : Math.min(0.1, Math.max(0, (now - lastNow) / 1000)); 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; writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0); dampPalette(currentPalette, targetPalette, deltaSeconds); const driveDistance = travelDistance.advance( deltaSeconds, snapshot.driveSpeed, response.roadEnergy, ); const visualSpeed = THREE.MathUtils.clamp( 0.18 + response.roadEnergy * 0.82, 0.18, 1, ); road.update( driveDistance, visualSpeed, response.roadEnergy, 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, ); atmosphericTime += deltaSeconds; const elapsedSeconds = atmosphericTime; horizonAir.update(currentPalette, elapsedSeconds); sun.update( currentPalette, snapshot.routeProgress, elapsedSeconds, Math.min(1, response.bloomEnergy + beat * 0.35), snapshot.arrived, ); car.update( visualSpeed, response.suspensionEnergy, elapsedSeconds, driveDistance, ); speedWake.update( overdrive, elapsedSeconds, car.group.position, width < 1100 || height < 620 ? 2.32 : 2.78, ); sign.update(snapshot.sign, now); atmosphere.update( driveDistance, visualSpeed, response.streakEnergy, currentPalette, atmosphericTime, overdrive, ); background.copy(currentPalette.sky); scene.fog.color.copy(currentPalette.fog); ambient.color .copy(currentPalette.haze) .lerp(currentPalette.terrainHigh, 0.26); ambient.groundColor.copy(currentPalette.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, ), targetZ: THREE.MathUtils.lerp( baseComposition.targetZ, upcoming.targetZ, anticipation, ), fov: THREE.MathUtils.lerp( baseComposition.fov, upcoming.fov, anticipation, ), shift: THREE.MathUtils.lerp( baseComposition.shift, upcoming.shift, anticipation, ), }; // 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 signTarget = usesRoadsideSign(width, height) && snapshot.sign !== null ? snapshot.sign.phase === 'depart' ? 1 - snapshot.sign.progress : 1 : 0; signCameraWeight += (signTarget - signCameraWeight) * (1 - Math.exp(-deltaSeconds / 0.48)); const targetShift = height < 560 && width >= 768 ? 0.22 : wideComposition ? THREE.MathUtils.lerp( composition.shift, composition.shift * 0.28, signCameraWeight, ) : 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, ); camera.setViewOffset( width, height, -width * cameraState.shift, -height * cameraState.verticalShift, width, height, ); camera.position.set(cameraState.x, cameraState.y, cameraState.z); camera.lookAt( cameraState.targetX, cameraState.targetY, cameraState.targetZ, ); camera.rotation.z += cameraState.roll; if (Math.abs(camera.fov - cameraState.fov) > 0.001) { camera.fov = cameraState.fov; camera.updateProjectionMatrix(); } const stage = signStage.update(width, height); sign.compose( camera, width, height, stage.navigationBottom, now, car.group.position, ); 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 (postProcessor) { postProcessor.setDynamics(response.bloomEnergy); postProcessor.render(scene, camera); } else { renderer.render(scene, camera); } renderedFrames += 1; }, dispose() { if (disposed) return; disposed = true; // 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(); }, }; } function dampPalette( current: MutableChapterPalette, target: Readonly, deltaSeconds: number, ): void { const mix = 1 - Math.exp(-Math.max(0, deltaSeconds) * 2.8); current.sky.lerp(target.sky, mix); current.fog.lerp(target.fog, mix); current.terrainLow.lerp(target.terrainLow, mix); current.terrainHigh.lerp(target.terrainHigh, mix); current.sun.lerp(target.sun, mix); current.haze.lerp(target.haze, mix); current.warmth = THREE.MathUtils.lerp(current.warmth, target.warmth, mix); current.terrainEnergy = THREE.MathUtils.lerp( current.terrainEnergy, target.terrainEnergy, mix, ); current.starVisibility = THREE.MathUtils.lerp( current.starVisibility, target.starVisibility, mix, ); current.streakEnergy = THREE.MathUtils.lerp( current.streakEnergy, target.streakEnergy, mix, ); }