From f08dfc3fba6746d6a8a9a08d482189b8b9defd8e Mon Sep 17 00:00:00 2001 From: LucaP Date: Sat, 10 Oct 2026 23:37:51 +0300 Subject: [PATCH] feat: engine off at the destination On arrival the drive winds down to a stop, the tail and running lamps drop to parking level, the sky darkens about 10% with more stars and the grid fades. The engine and road noise take a 1.2 s low-pass sweep to idle. The letterbox stays within 8vh and the closing credit types into the lower bar; reduced motion keeps only the bars and dimming. --- src/audio/drive-texture.ts | 27 +++++++++++++ src/audio/soundtrack-engine.ts | 6 ++- src/components/SceneCanvas.vue | 3 +- src/scene/world/car.ts | 22 ++++++---- src/scene/world/environment.ts | 17 ++++++-- src/scene/world/terrain.ts | 9 ++++- src/styles/world.css | 74 +++++++++++++++++++++++++--------- 7 files changed, 124 insertions(+), 34 deletions(-) diff --git a/src/audio/drive-texture.ts b/src/audio/drive-texture.ts index 0fb02d1..4f571b4 100644 --- a/src/audio/drive-texture.ts +++ b/src/audio/drive-texture.ts @@ -10,11 +10,15 @@ const CRUISE_ROAD_GAIN = 0.006; const MAX_ROAD_GAIN = 0.024; const PASS_BY_GAIN = 0.05; const PASS_BY_SECONDS = 1.6; +/** Engine off at the destination: a low-pass sweep down to a parked idle. */ +const PARK_SECONDS = 1.2; export interface DriveTexture { setEnergy(sample: DriveEnergySample): void; /** Passing under the gantry: a filtered air sweep across the stereo field. */ passBy?(): void; + /** Parked at the final exit (true) or driving again (false). */ + park?(parked: boolean): void; dispose(): void; } @@ -141,6 +145,7 @@ export const createDriveTexture: DriveTextureFactory = (context, destination) => let currentEnergy = 0; let disposed = false; + let parked = false; const automate = (parameter: AudioParam, value: number, duration: number): void => { const now = context.currentTime; holdParameter(parameter, now); @@ -151,6 +156,10 @@ export const createDriveTexture: DriveTextureFactory = (context, destination) => setEnergy(sample) { if (disposed) return; const next = Math.min(1, Math.max(0, sample.energy)); + if (parked) { + currentEnergy = next; + return; + } const duration = next > currentEnergy ? ATTACK_SECONDS : RELEASE_SECONDS; automate(fundamental.source.frequency, 42 + next * 20, duration); automate(harmonic.source.frequency, 84 + next * 40, duration); @@ -160,6 +169,24 @@ export const createDriveTexture: DriveTextureFactory = (context, destination) => automate(roadGain.gain, CRUISE_ROAD_GAIN + next * (MAX_ROAD_GAIN - CRUISE_ROAD_GAIN), duration); currentEnergy = next; }, + park(next) { + if (disposed || next === parked) return; + parked = next; + if (!parked) { + const energy = currentEnergy; + currentEnergy = -1; + this.setEnergy({ energy, source: 'idle' }); + return; + } + const now = context.currentTime; + for (const parameter of [engineFilter.frequency, roadLowpass.frequency, engineGain.gain, roadGain.gain]) { + holdParameter(parameter, now); + } + engineFilter.frequency.exponentialRampToValueAtTime(70, now + PARK_SECONDS); + roadLowpass.frequency.exponentialRampToValueAtTime(260, now + PARK_SECONDS); + engineGain.gain.linearRampToValueAtTime(CRUISE_ENGINE_GAIN * 0.3, now + PARK_SECONDS); + roadGain.gain.linearRampToValueAtTime(CRUISE_ROAD_GAIN * 0.25, now + PARK_SECONDS); + }, passBy() { if (disposed) return; const now = context.currentTime; diff --git a/src/audio/soundtrack-engine.ts b/src/audio/soundtrack-engine.ts index ef03558..ced25ec 100644 --- a/src/audio/soundtrack-engine.ts +++ b/src/audio/soundtrack-engine.ts @@ -556,7 +556,11 @@ export function createSoundtrackEngine( if (closed) return; // Every chapter change passes under the overhead gantry; the drive // texture answers with the air of the structure going by. - if (intent.sectionId !== latestChapter.sectionId) driveTexture?.passBy?.(); + if (intent.sectionId !== latestChapter.sectionId) { + driveTexture?.passBy?.(); + // The last exit is the destination: the engine winds down there. + driveTexture?.park?.(intent.sectionId === 'contact'); + } latestChapter = intent; mixer.setChapterIntent(intent); if (!mainCommitted && introStarted) { diff --git a/src/components/SceneCanvas.vue b/src/components/SceneCanvas.vue index 1332e6d..b04bf86 100644 --- a/src/components/SceneCanvas.vue +++ b/src/components/SceneCanvas.vue @@ -57,8 +57,9 @@ if (posterFailed.value) recordTestFaultEvent('poster-failure'); :data-static-fallback="!showCanvas" :data-poster-failed="posterFailed" :data-arrived="journey.arrived" - :data-credit="credit" > + + diff --git a/src/scene/world/car.ts b/src/scene/world/car.ts index 3efdfc0..5fcafc1 100644 --- a/src/scene/world/car.ts +++ b/src/scene/world/car.ts @@ -15,6 +15,8 @@ export interface CarWorld { acceleration: number, elapsedSeconds: number, distance?: number, + /** 0 driving, 1 parked at the destination: lamps drop to parking level. */ + engineOff?: number, ): void; } @@ -417,7 +419,8 @@ export function createCar( return { group, setDetail, - update(speed, acceleration, elapsedSeconds) { + update(speed, acceleration, elapsedSeconds, _distance, engineOff = 0) { + const lamps = 1 - clamp01(engineOff) * 0.62; const safeSpeed = clamp01(speed); const response = clamp01(acceleration); const elapsed = Number.isFinite(elapsedSeconds) @@ -441,9 +444,9 @@ export function createCar( group.rotation.z = Math.sin(elapsed * 0.78) * safeSpeed * 0.0024 + Math.sin(elapsed * 1.31) * response * 0.0014; - tailMaterial.emissiveIntensity = 1.25 + response * 1.35; + tailMaterial.emissiveIntensity = (1.25 + response * 1.35) * lamps; underglowUniforms.uOpacity.value = - 0.1 + safeSpeed * 0.055 + response * 0.07; + (0.1 + safeSpeed * 0.055 + response * 0.07) * lamps; contact.uniforms.uOpacity.value = 0.2 + response * 0.06; }, }; @@ -600,7 +603,10 @@ function createAuthoredCar( detail = nextDetail; setScale(detail); }, - update(speed, acceleration, elapsedSeconds, distance) { + update(speed, acceleration, elapsedSeconds, distance, engineOff = 0) { + // Parked at the destination: the tail bar and the cyan running lamps + // drop to parking level, the underglow almost out. + const lamps = 1 - clamp01(engineOff) * 0.62; const safeSpeed = clamp01(speed); const response = clamp01(acceleration); const elapsed = Number.isFinite(elapsedSeconds) @@ -634,12 +640,12 @@ function createAuthoredCar( group.rotation.x = 0; group.rotation.z = Math.sin(elapsed * 0.8) * safeSpeed * 0.0018; for (const material of tailMaterials) - material.emissiveIntensity = 2.2 + response * 1.2; - haloUniforms.uOpacity.value = 0.2 + response * 0.16; + material.emissiveIntensity = (2.2 + response * 1.2) * lamps; + haloUniforms.uOpacity.value = (0.2 + response * 0.16) * lamps; for (const material of cyanMaterials) - material.emissiveIntensity = 2.2 + response * 1.6; + material.emissiveIntensity = (2.2 + response * 1.6) * lamps; underglowUniforms.uOpacity.value = - 0.09 + safeSpeed * 0.04 + response * 0.075; + (0.09 + safeSpeed * 0.04 + response * 0.075) * (1 - clamp01(engineOff) * 0.8); contact.uniforms.uOpacity.value = 0.21 + response * 0.055; }, }; diff --git a/src/scene/world/environment.ts b/src/scene/world/environment.ts index b1911c9..352428e 100644 --- a/src/scene/world/environment.ts +++ b/src/scene/world/environment.ts @@ -307,9 +307,17 @@ export async function createProceduralSceneGeneration( terrainSpectrumPhase = (terrainSpectrumPhase + deltaSeconds * overdrive * 0.38) % 1; writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0); + // Engine off: parked at the destination the sky drops about 10%, more + // stars come out, 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 *= 1 + 0.8 * engineOff; + } dampPalette(currentPalette, targetPalette, deltaSeconds); const driveDistance = travelDistance.advance( - deltaSeconds, + deltaSeconds * (1 - engineOff), snapshot.driveSpeed, response.roadEnergy, ); @@ -317,7 +325,7 @@ export async function createProceduralSceneGeneration( 0.18 + response.roadEnergy * 0.82, 0.18, 1, - ); + ) * (1 - engineOff); road.update( driveDistance, visualSpeed, @@ -347,6 +355,7 @@ export async function createProceduralSceneGeneration( overdrive, terrainSurge, terrainSpectrumPhase, + engineOff, ); atmosphericTime += deltaSeconds; const elapsedSeconds = atmosphericTime; @@ -354,7 +363,7 @@ export async function createProceduralSceneGeneration( skyline.update(currentPalette, elapsedSeconds); traffic.update( deltaSeconds, - 18 + snapshot.driveSpeed * 54 + response.roadEnergy * 48, + (18 + snapshot.driveSpeed * 54 + response.roadEnergy * 48) * (1 - engineOff), elapsedSeconds, ); sun.update( @@ -369,6 +378,7 @@ export async function createProceduralSceneGeneration( response.suspensionEnergy, elapsedSeconds, driveDistance, + engineOff, ); speedWake.update( overdrive, @@ -634,6 +644,7 @@ const INTRO_SECONDS = 4.2; const OUTRO_SECONDS = 5.5; const OUTRO_RETURN_SECONDS = 1.6; const OUTRO_REACH = 0.62; +const ENGINE_OFF_NIGHT = new THREE.Color(0x000000); /** Plates rise back over 720 ms with up to three 90 ms staggers. */ const INTERLUDE_RISE_MS = 1100; diff --git a/src/scene/world/terrain.ts b/src/scene/world/terrain.ts index b08372c..3930378 100644 --- a/src/scene/world/terrain.ts +++ b/src/scene/world/terrain.ts @@ -24,6 +24,8 @@ export interface TerrainWorld { overdrive?: number, surge?: number, spectrumPhase?: number, + /** 0 driving, 1 parked: the grid fades out under the end shot. */ + engineOff?: number, ): void; } @@ -59,6 +61,7 @@ export function createTerrain( uSurge: { value: 0 }, uSpectrumPhase: { value: 0 }, uRouteProgress: { value: 0 }, + uEngineOff: { value: 0 }, uTerrainEnergy: { value: initialPalette.terrainEnergy }, uOctaves: { value: quality.terrainOctaves }, uRelief: { value: 49 }, @@ -180,6 +183,7 @@ export function createTerrain( uniform vec3 uFogColor; uniform vec3 uHazeColor; uniform float uRouteProgress; + uniform float uEngineOff; uniform float uOverdrive; uniform float uSurge; varying float vHeight; @@ -200,7 +204,8 @@ export function createTerrain( float road = 1.0 - smoothstep(7.6, 8.4, abs(vTerrainPosition.x)); float apron = 1.0 - smoothstep(12.0, 32.0, abs(vTerrainPosition.x)); float surfaceStrength = mix(0.72, 0.12 + uOverdrive * 0.3, road); - float alpha = vDepthFade * pulse * routeCalm * mix(surfaceStrength, 0.91, ridgeMix); + float alpha = vDepthFade * pulse * routeCalm * mix(surfaceStrength, 0.91, ridgeMix) + * (1.0 - uEngineOff * 0.72); float charge = uOverdrive * (0.35 + vFlow * 0.8) * (0.5 + apron * 0.5); charge += uSurge * (0.28 + vCrest * 0.5) * (1.0 - road); gl_FragColor = vec4(color * (1.46 + ridgeMix * 0.54 + charge), alpha); @@ -282,7 +287,9 @@ export function createTerrain( overdrive = 0, surge = 0, spectrumPhase = 0, + engineOff = 0, ) { + uniforms.uEngineOff.value = clamp01(engineOff); const chapterPalette = CHAPTER_PALETTES[chapter]; uniforms.uDistance.value = ((finiteOrZero(distance) % NOISE_PERIOD) + NOISE_PERIOD) % NOISE_PERIOD; diff --git a/src/styles/world.css b/src/styles/world.css index ea5cfc3..cdf094d 100644 --- a/src/styles/world.css +++ b/src/styles/world.css @@ -507,15 +507,15 @@ /* Round 5 outro: letterbox bars close in on arrival, on every tier (they are CSS, so the static poster gets them too). They sit in the world layer, under the reading column, so they frame the sky and never cover content. - The upper bar hangs below the gantry and carries a closing credit built - from real identity facts; the lower bar is left clear for the trip - summary that ends the contact chapter. */ + The upper bar hangs below the gantry; the lower bar carries a closing + credit built from real identity facts, typed in once the bars close. */ .journey-world::before, .journey-world::after { position: absolute; z-index: 1; inset-inline: 0; - block-size: max(54px, 10vh); + /* Engine off: the bars stay within 8vh of the frame each. */ + block-size: min(8vh, 72px); background: #020108; content: ''; transform: scaleY(0); @@ -526,20 +526,45 @@ display: grid; align-items: end; justify-items: center; - block-size: calc(var(--gantry-h, 72px) + max(30px, 4.5vh)); - padding: 0 16px max(8px, 1.2vh); - color: rgb(255 226 196 / 0%); + block-size: calc(var(--gantry-h, 72px) + min(5vh, 44px)); + transform-origin: top; +} +/* The closing credit types in, in mono, once the bars have closed. */ +.journey-world__credit { + position: absolute; + z-index: 2; + /* In the lower bar: the upper one is shared with the contact avatar. */ + inset: auto 0 0; + display: flex; + align-items: center; + justify-content: center; + block-size: min(8vh, 72px); + padding-inline: 16px; + pointer-events: none; +} +.journey-world__credit::before { + content: attr(data-credit) / ''; + color: rgb(255 226 196 / 78%); font: 600 0.6rem/1.3 var(--mono, ui-monospace, monospace); letter-spacing: 0.32em; - text-align: center; text-transform: uppercase; white-space: nowrap; - content: attr(data-credit) / ''; - transform-origin: top; - transition: - transform 1.6s cubic-bezier(0.65, 0, 0.35, 1), - color 1.2s ease; + clip-path: inset(0 100% 0 0); + opacity: 0; } +.journey-world[data-arrived='true'] .journey-world__credit::before { + clip-path: inset(0 -1em 0 0); + opacity: 1; + transition: + opacity 1ms linear 1.9s, + clip-path 1.5s steps(36, end) 1.9s; +} +/* Reduced motion: the bars and the dimming only; the credit simply shows. */ +@media (prefers-reduced-motion: reduce) { + .journey-world[data-arrived='true'] .journey-world__credit::before { transition: none; } +} +.journey-poster { transition: filter 1.6s ease; } +.journey-world[data-arrived='true'] .journey-poster { filter: brightness(0.9); } .journey-world::after { inset-block-end: 0; transform-origin: bottom; @@ -549,17 +574,22 @@ transform: scaleY(1); transition-delay: 0.5s; } -.journey-world[data-arrived='true']::before { - color: rgb(255 226 196 / 74%); - transition-delay: 0.5s, 1.9s; -} + @media (max-width: 767px) { - .journey-world::after { block-size: max(40px, 7vh); } - .journey-world::before { + .journey-world::after { block-size: min(7vh, 56px); } + /* Phones: the credit may wrap, so it fades in rather than types. */ + .journey-world__credit { block-size: min(7vh, 56px); } + .journey-world__credit::before { + clip-path: none; font-size: 0.5rem; letter-spacing: 0.14em; + text-align: center; white-space: normal; } + .journey-world[data-arrived='true'] .journey-world__credit::before { + clip-path: none; + transition: opacity 0.8s ease 1.9s; + } } @media (prefers-reduced-motion: reduce) { .journey-world::before, @@ -571,7 +601,11 @@ } @media (forced-colors: active) { .journey-world::before, - .journey-world::after { display: none; } + .journey-world::after, + .journey-world__credit { display: none; } +} +@media (prefers-reduced-motion: reduce) { + .journey-world[data-arrived='true'] .journey-poster { filter: saturate(0.86) brightness(0.83); } } @media (prefers-reduced-motion: reduce) {