diff --git a/src/scene/world/environment.ts b/src/scene/world/environment.ts index 8080744..c622465 100644 --- a/src/scene/world/environment.ts +++ b/src/scene/world/environment.ts @@ -17,6 +17,10 @@ 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 { createExitGates } from '@/scene/world/exit-gates'; import { createDriveDynamics } from '@/scene/drive-dynamics'; import { HORIZON_FOG_END, HORIZON_FOG_START } from '@/scene/world/metrics'; import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition'; @@ -72,6 +76,7 @@ export async function createProceduralSceneGeneration( globalThis.devicePixelRatio ?? 1, mobileOrTablet, ); + installCinematicGrade(); const renderer = context.resources.own( 'renderer', new THREE.WebGLRenderer({ @@ -83,8 +88,9 @@ export async function createProceduralSceneGeneration( ); renderer.setPixelRatio(quality.pixelRatio); renderer.outputColorSpace = THREE.SRGBColorSpace; - renderer.toneMapping = THREE.ACESFilmicToneMapping; - renderer.toneMappingExposure = 1.05; + // ACES plus the film grade in cinematic-grade.ts, shared by every tier. + renderer.toneMapping = THREE.CustomToneMapping; + renderer.toneMappingExposure = 1.12; const scene = new THREE.Scene(); scene.name = 'world-root'; @@ -121,6 +127,9 @@ export async function createProceduralSceneGeneration( 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); + const exitGates = createExitGates(context.resources); const authoredCar = await loadGrandTourer(context.signal); const car = createCar(context.resources, authoredCar); car.setDetail(mobileOrTablet ? 'reduced' : 'full'); @@ -138,6 +147,9 @@ export async function createProceduralSceneGeneration( terrain.group, sun.group, horizonAir.group, + skyline.group, + traffic.group, + exitGates.group, car.group, speedWake.group, sign.group, @@ -172,6 +184,9 @@ export async function createProceduralSceneGeneration( 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; const resize = (): void => { const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1); @@ -329,6 +344,13 @@ export async function createProceduralSceneGeneration( atmosphericTime += deltaSeconds; const elapsedSeconds = atmosphericTime; horizonAir.update(currentPalette, elapsedSeconds); + skyline.update(currentPalette, elapsedSeconds); + traffic.update( + deltaSeconds, + 18 + snapshot.driveSpeed * 54 + response.roadEnergy * 48, + elapsedSeconds, + ); + const cut = exitGates.update(snapshot.activeSectionId, deltaSeconds); sun.update( currentPalette, snapshot.routeProgress, @@ -489,23 +511,41 @@ export async function createProceduralSceneGeneration( targetVerticalShift, cameraMix, ); + if (introProgress === null) { + introProgress = + snapshot.activeSectionId === 'start' && snapshot.scrollY < 40 ? 0 : 1; + } + if (introProgress < 1) { + const hurry = snapshot.scrollY > 40 ? 5 : 1; + introProgress = Math.min(1, introProgress + (deltaSeconds * hurry) / INTRO_SECONDS); + } + const crane = easeInOutCubic( + THREE.MathUtils.clamp((introProgress - 0.06) / 0.94, 0, 1), + ); + const craneShift = cameraState.shift * crane; camera.setViewOffset( width, height, - -width * cameraState.shift, + -width * craneShift, -height * cameraState.verticalShift, width, height, ); - camera.position.set(cameraState.x, cameraState.y, cameraState.z); - camera.lookAt( - cameraState.targetX, - cameraState.targetY, - cameraState.targetZ, + camera.position.set( + THREE.MathUtils.lerp(0, cameraState.x, crane), + THREE.MathUtils.lerp(46, cameraState.y, crane), + THREE.MathUtils.lerp(92, cameraState.z, crane), ); - camera.rotation.z += cameraState.roll; - if (Math.abs(camera.fov - cameraState.fov) > 0.001) { - camera.fov = cameraState.fov; + camera.lookAt( + THREE.MathUtils.lerp(0, cameraState.targetX, crane), + THREE.MathUtils.lerp(64, cameraState.targetY, crane), + THREE.MathUtils.lerp(-720, cameraState.targetZ, crane), + ); + camera.rotation.z += cameraState.roll * crane; + const lensFov = + THREE.MathUtils.lerp(30, cameraState.fov, crane) + cut * 6; + if (Math.abs(camera.fov - lensFov) > 0.001) { + camera.fov = lensFov; camera.updateProjectionMatrix(); } const stage = signStage.update(width, height); @@ -536,7 +576,13 @@ export async function createProceduralSceneGeneration( } } if (postProcessor) { - postProcessor.setDynamics(response.bloomEnergy); + postProcessor.setDynamics(Math.max(response.bloomEnergy, cut * 0.45)); + postProcessor.setLens({ + time: elapsedSeconds, + letterbox: 0.115 * (1 - THREE.MathUtils.smoothstep(introProgress, 0.5, 1)), + flash: cut * 0.08, + energy: Math.max(overdrive, cut), + }); postProcessor.render(scene, camera); } else { renderer.render(scene, camera); @@ -556,6 +602,12 @@ export async function createProceduralSceneGeneration( }; } +const INTRO_SECONDS = 4.2; + +function easeInOutCubic(t: number): number { + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; +} + function dampPalette( current: MutableChapterPalette, target: Readonly, diff --git a/src/scene/world/exit-gates.ts b/src/scene/world/exit-gates.ts new file mode 100644 index 0000000..ad8676a --- /dev/null +++ b/src/scene/world/exit-gates.ts @@ -0,0 +1,121 @@ +import * as THREE from 'three'; +import type { SceneResourceBag } from '@/scene/resources'; +import type { SectionId } from '@/content/types'; + +/** + * The cut between chapters: on every exit change the car runs a short gallery + * of light gates in the next chapter's accent. Nine portal frames rush past the + * camera in about two seconds while the lens flashes and the field of view + * opens. At rest the gates are hidden and cost no draw call. + */ +const GATES = 9; +const SPACING = 17; +const START_Z = -150; +const TRAVEL = GATES * SPACING + 205; +const DURATION = 2.1; +const HALF_SPAN = 11.2; +const HEIGHT = 10.5; + +export const SECTION_ACCENTS: Readonly> = { + start: 0xff3f9e, + profile: 0x3fe6f2, + experience: 0xffb15e, + infrastructure: 0x41e9d6, + 'selected-builds': 0xff5b8a, + 'additional-work': 0xb57bff, + toolkit: 0x47d2ff, + contact: 0xffc078, +}; + +export function createExitGates(resources: SceneResourceBag) { + const group = new THREE.Group(); + group.name = 'exit-gates'; + group.visible = false; + const uniforms = { + uColor: { value: new THREE.Color(SECTION_ACCENTS.profile) }, + uIntensity: { value: 0 }, + }; + const material = resources.own( + 'material', + new THREE.ShaderMaterial({ + uniforms, + vertexShader: /* glsl */ ` + varying float vDepth; + varying vec3 vLocal; + void main() { + vLocal = position; + vec4 view = modelViewMatrix * instanceMatrix * vec4(position, 1.0); + vDepth = -view.z; + gl_Position = projectionMatrix * view; + }`, + fragmentShader: /* glsl */ ` + uniform vec3 uColor; + uniform float uIntensity; + varying float vDepth; + varying vec3 vLocal; + void main() { + float near = smoothstep(5.0, 26.0, vDepth); + float far = 1.0 - smoothstep(140.0, 230.0, vDepth); + vec3 hot = mix(uColor, vec3(1.0), 0.12); + gl_FragColor = vec4(hot * 1.35 * uIntensity * near * far, 1.0); + #include + #include + }`, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + }), + ); + const box = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1)); + const bars = new THREE.InstancedMesh(box, material, GATES * 4); + bars.name = 'exit-gate-frames'; + bars.frustumCulled = false; + bars.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + group.add(bars); + const dummy = new THREE.Object3D(); + + let progress = 1; + let previous: SectionId | null = null; + + const place = (index: number, x: number, y: number, z: number, sx: number, sy: number) => { + dummy.position.set(x, y, z); + dummy.scale.set(sx, sy, 0.14); + dummy.updateMatrix(); + bars.setMatrixAt(index, dummy.matrix); + }; + + return { + group, + /** Returns the cut's energy, 0 at rest, for the lens and the camera. */ + update(section: SectionId, deltaSeconds: number): number { + if (previous !== null && section !== previous) { + uniforms.uColor.value.setHex(SECTION_ACCENTS[section]); + // A cut already under way keeps its momentum instead of restarting. + if (progress >= 0.7) progress = 0; + } + previous = section; + if (progress >= 1) { + group.visible = false; + return 0; + } + progress = Math.min(1, progress + Math.max(0, deltaSeconds) / DURATION); + const eased = 1 - Math.pow(1 - progress, 1.6); + const travel = eased * TRAVEL; + const envelope = THREE.MathUtils.smoothstep(progress, 0, 0.14) + * (1 - THREE.MathUtils.smoothstep(progress, 0.78, 1)); + uniforms.uIntensity.value = envelope; + for (let i = 0; i < GATES; i++) { + const z = START_Z - i * SPACING + travel; + const base = i * 4; + place(base, -HALF_SPAN, HEIGHT / 2, z, 0.1, HEIGHT); + place(base + 1, HALF_SPAN, HEIGHT / 2, z, 0.1, HEIGHT); + place(base + 2, 0, HEIGHT, z, HALF_SPAN * 2 + 0.1, 0.1); + // A floor seam makes each gate cross the asphalt as a line of light. + place(base + 3, 0, 0.03, z, HALF_SPAN * 2, 0.05); + } + bars.instanceMatrix.needsUpdate = true; + group.visible = true; + return envelope; + }, + }; +}