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.
This commit is contained in:
2026-10-10 23:37:51 +03:00
parent 07e3ea014d
commit f08dfc3fba
7 changed files with 124 additions and 34 deletions

View File

@@ -10,11 +10,15 @@ const CRUISE_ROAD_GAIN = 0.006;
const MAX_ROAD_GAIN = 0.024; const MAX_ROAD_GAIN = 0.024;
const PASS_BY_GAIN = 0.05; const PASS_BY_GAIN = 0.05;
const PASS_BY_SECONDS = 1.6; 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 { export interface DriveTexture {
setEnergy(sample: DriveEnergySample): void; setEnergy(sample: DriveEnergySample): void;
/** Passing under the gantry: a filtered air sweep across the stereo field. */ /** Passing under the gantry: a filtered air sweep across the stereo field. */
passBy?(): void; passBy?(): void;
/** Parked at the final exit (true) or driving again (false). */
park?(parked: boolean): void;
dispose(): void; dispose(): void;
} }
@@ -141,6 +145,7 @@ export const createDriveTexture: DriveTextureFactory = (context, destination) =>
let currentEnergy = 0; let currentEnergy = 0;
let disposed = false; let disposed = false;
let parked = false;
const automate = (parameter: AudioParam, value: number, duration: number): void => { const automate = (parameter: AudioParam, value: number, duration: number): void => {
const now = context.currentTime; const now = context.currentTime;
holdParameter(parameter, now); holdParameter(parameter, now);
@@ -151,6 +156,10 @@ export const createDriveTexture: DriveTextureFactory = (context, destination) =>
setEnergy(sample) { setEnergy(sample) {
if (disposed) return; if (disposed) return;
const next = Math.min(1, Math.max(0, sample.energy)); const next = Math.min(1, Math.max(0, sample.energy));
if (parked) {
currentEnergy = next;
return;
}
const duration = next > currentEnergy ? ATTACK_SECONDS : RELEASE_SECONDS; const duration = next > currentEnergy ? ATTACK_SECONDS : RELEASE_SECONDS;
automate(fundamental.source.frequency, 42 + next * 20, duration); automate(fundamental.source.frequency, 42 + next * 20, duration);
automate(harmonic.source.frequency, 84 + next * 40, 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); automate(roadGain.gain, CRUISE_ROAD_GAIN + next * (MAX_ROAD_GAIN - CRUISE_ROAD_GAIN), duration);
currentEnergy = next; 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() { passBy() {
if (disposed) return; if (disposed) return;
const now = context.currentTime; const now = context.currentTime;

View File

@@ -556,7 +556,11 @@ export function createSoundtrackEngine(
if (closed) return; if (closed) return;
// Every chapter change passes under the overhead gantry; the drive // Every chapter change passes under the overhead gantry; the drive
// texture answers with the air of the structure going by. // 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; latestChapter = intent;
mixer.setChapterIntent(intent); mixer.setChapterIntent(intent);
if (!mainCommitted && introStarted) { if (!mainCommitted && introStarted) {

View File

@@ -57,8 +57,9 @@ if (posterFailed.value) recordTestFaultEvent('poster-failure');
:data-static-fallback="!showCanvas" :data-static-fallback="!showCanvas"
:data-poster-failed="posterFailed" :data-poster-failed="posterFailed"
:data-arrived="journey.arrived" :data-arrived="journey.arrived"
:data-credit="credit"
> >
<!-- Closing credit, typed into the outro letterbox (CSS generated text). -->
<span v-if="credit" class="journey-world__credit" :data-credit="credit" />
<picture v-if="!posterFailed"> <picture v-if="!posterFailed">
<source media="(max-width: 767px)" :srcset="mobilePosterUrl"> <source media="(max-width: 767px)" :srcset="mobilePosterUrl">
<img :src="posterUrl" class="journey-poster" alt="" width="1440" height="900" @error="posterFailed = true"> <img :src="posterUrl" class="journey-poster" alt="" width="1440" height="900" @error="posterFailed = true">

View File

@@ -15,6 +15,8 @@ export interface CarWorld {
acceleration: number, acceleration: number,
elapsedSeconds: number, elapsedSeconds: number,
distance?: number, distance?: number,
/** 0 driving, 1 parked at the destination: lamps drop to parking level. */
engineOff?: number,
): void; ): void;
} }
@@ -417,7 +419,8 @@ export function createCar(
return { return {
group, group,
setDetail, setDetail,
update(speed, acceleration, elapsedSeconds) { update(speed, acceleration, elapsedSeconds, _distance, engineOff = 0) {
const lamps = 1 - clamp01(engineOff) * 0.62;
const safeSpeed = clamp01(speed); const safeSpeed = clamp01(speed);
const response = clamp01(acceleration); const response = clamp01(acceleration);
const elapsed = Number.isFinite(elapsedSeconds) const elapsed = Number.isFinite(elapsedSeconds)
@@ -441,9 +444,9 @@ export function createCar(
group.rotation.z = group.rotation.z =
Math.sin(elapsed * 0.78) * safeSpeed * 0.0024 + Math.sin(elapsed * 0.78) * safeSpeed * 0.0024 +
Math.sin(elapsed * 1.31) * response * 0.0014; 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 = 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; contact.uniforms.uOpacity.value = 0.2 + response * 0.06;
}, },
}; };
@@ -600,7 +603,10 @@ function createAuthoredCar(
detail = nextDetail; detail = nextDetail;
setScale(detail); 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 safeSpeed = clamp01(speed);
const response = clamp01(acceleration); const response = clamp01(acceleration);
const elapsed = Number.isFinite(elapsedSeconds) const elapsed = Number.isFinite(elapsedSeconds)
@@ -634,12 +640,12 @@ function createAuthoredCar(
group.rotation.x = 0; group.rotation.x = 0;
group.rotation.z = Math.sin(elapsed * 0.8) * safeSpeed * 0.0018; group.rotation.z = Math.sin(elapsed * 0.8) * safeSpeed * 0.0018;
for (const material of tailMaterials) for (const material of tailMaterials)
material.emissiveIntensity = 2.2 + response * 1.2; material.emissiveIntensity = (2.2 + response * 1.2) * lamps;
haloUniforms.uOpacity.value = 0.2 + response * 0.16; haloUniforms.uOpacity.value = (0.2 + response * 0.16) * lamps;
for (const material of cyanMaterials) for (const material of cyanMaterials)
material.emissiveIntensity = 2.2 + response * 1.6; material.emissiveIntensity = (2.2 + response * 1.6) * lamps;
underglowUniforms.uOpacity.value = 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; contact.uniforms.uOpacity.value = 0.21 + response * 0.055;
}, },
}; };

View File

@@ -307,9 +307,17 @@ export async function createProceduralSceneGeneration(
terrainSpectrumPhase = terrainSpectrumPhase =
(terrainSpectrumPhase + deltaSeconds * overdrive * 0.38) % 1; (terrainSpectrumPhase + deltaSeconds * overdrive * 0.38) % 1;
writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0); 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); dampPalette(currentPalette, targetPalette, deltaSeconds);
const driveDistance = travelDistance.advance( const driveDistance = travelDistance.advance(
deltaSeconds, deltaSeconds * (1 - engineOff),
snapshot.driveSpeed, snapshot.driveSpeed,
response.roadEnergy, response.roadEnergy,
); );
@@ -317,7 +325,7 @@ export async function createProceduralSceneGeneration(
0.18 + response.roadEnergy * 0.82, 0.18 + response.roadEnergy * 0.82,
0.18, 0.18,
1, 1,
); ) * (1 - engineOff);
road.update( road.update(
driveDistance, driveDistance,
visualSpeed, visualSpeed,
@@ -347,6 +355,7 @@ export async function createProceduralSceneGeneration(
overdrive, overdrive,
terrainSurge, terrainSurge,
terrainSpectrumPhase, terrainSpectrumPhase,
engineOff,
); );
atmosphericTime += deltaSeconds; atmosphericTime += deltaSeconds;
const elapsedSeconds = atmosphericTime; const elapsedSeconds = atmosphericTime;
@@ -354,7 +363,7 @@ export async function createProceduralSceneGeneration(
skyline.update(currentPalette, elapsedSeconds); skyline.update(currentPalette, elapsedSeconds);
traffic.update( traffic.update(
deltaSeconds, deltaSeconds,
18 + snapshot.driveSpeed * 54 + response.roadEnergy * 48, (18 + snapshot.driveSpeed * 54 + response.roadEnergy * 48) * (1 - engineOff),
elapsedSeconds, elapsedSeconds,
); );
sun.update( sun.update(
@@ -369,6 +378,7 @@ export async function createProceduralSceneGeneration(
response.suspensionEnergy, response.suspensionEnergy,
elapsedSeconds, elapsedSeconds,
driveDistance, driveDistance,
engineOff,
); );
speedWake.update( speedWake.update(
overdrive, overdrive,
@@ -634,6 +644,7 @@ const INTRO_SECONDS = 4.2;
const OUTRO_SECONDS = 5.5; const OUTRO_SECONDS = 5.5;
const OUTRO_RETURN_SECONDS = 1.6; const OUTRO_RETURN_SECONDS = 1.6;
const OUTRO_REACH = 0.62; 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. */ /** Plates rise back over 720 ms with up to three 90 ms staggers. */
const INTERLUDE_RISE_MS = 1100; const INTERLUDE_RISE_MS = 1100;

View File

@@ -24,6 +24,8 @@ export interface TerrainWorld {
overdrive?: number, overdrive?: number,
surge?: number, surge?: number,
spectrumPhase?: number, spectrumPhase?: number,
/** 0 driving, 1 parked: the grid fades out under the end shot. */
engineOff?: number,
): void; ): void;
} }
@@ -59,6 +61,7 @@ export function createTerrain(
uSurge: { value: 0 }, uSurge: { value: 0 },
uSpectrumPhase: { value: 0 }, uSpectrumPhase: { value: 0 },
uRouteProgress: { value: 0 }, uRouteProgress: { value: 0 },
uEngineOff: { value: 0 },
uTerrainEnergy: { value: initialPalette.terrainEnergy }, uTerrainEnergy: { value: initialPalette.terrainEnergy },
uOctaves: { value: quality.terrainOctaves }, uOctaves: { value: quality.terrainOctaves },
uRelief: { value: 49 }, uRelief: { value: 49 },
@@ -180,6 +183,7 @@ export function createTerrain(
uniform vec3 uFogColor; uniform vec3 uFogColor;
uniform vec3 uHazeColor; uniform vec3 uHazeColor;
uniform float uRouteProgress; uniform float uRouteProgress;
uniform float uEngineOff;
uniform float uOverdrive; uniform float uOverdrive;
uniform float uSurge; uniform float uSurge;
varying float vHeight; varying float vHeight;
@@ -200,7 +204,8 @@ export function createTerrain(
float road = 1.0 - smoothstep(7.6, 8.4, abs(vTerrainPosition.x)); 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 apron = 1.0 - smoothstep(12.0, 32.0, abs(vTerrainPosition.x));
float surfaceStrength = mix(0.72, 0.12 + uOverdrive * 0.3, road); 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); float charge = uOverdrive * (0.35 + vFlow * 0.8) * (0.5 + apron * 0.5);
charge += uSurge * (0.28 + vCrest * 0.5) * (1.0 - road); charge += uSurge * (0.28 + vCrest * 0.5) * (1.0 - road);
gl_FragColor = vec4(color * (1.46 + ridgeMix * 0.54 + charge), alpha); gl_FragColor = vec4(color * (1.46 + ridgeMix * 0.54 + charge), alpha);
@@ -282,7 +287,9 @@ export function createTerrain(
overdrive = 0, overdrive = 0,
surge = 0, surge = 0,
spectrumPhase = 0, spectrumPhase = 0,
engineOff = 0,
) { ) {
uniforms.uEngineOff.value = clamp01(engineOff);
const chapterPalette = CHAPTER_PALETTES[chapter]; const chapterPalette = CHAPTER_PALETTES[chapter];
uniforms.uDistance.value = uniforms.uDistance.value =
((finiteOrZero(distance) % NOISE_PERIOD) + NOISE_PERIOD) % NOISE_PERIOD; ((finiteOrZero(distance) % NOISE_PERIOD) + NOISE_PERIOD) % NOISE_PERIOD;

View File

@@ -507,15 +507,15 @@
/* Round 5 outro: letterbox bars close in on arrival, on every tier (they are /* 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, 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. 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 The upper bar hangs below the gantry; the lower bar carries a closing
from real identity facts; the lower bar is left clear for the trip credit built from real identity facts, typed in once the bars close. */
summary that ends the contact chapter. */
.journey-world::before, .journey-world::before,
.journey-world::after { .journey-world::after {
position: absolute; position: absolute;
z-index: 1; z-index: 1;
inset-inline: 0; 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; background: #020108;
content: ''; content: '';
transform: scaleY(0); transform: scaleY(0);
@@ -526,20 +526,45 @@
display: grid; display: grid;
align-items: end; align-items: end;
justify-items: center; justify-items: center;
block-size: calc(var(--gantry-h, 72px) + max(30px, 4.5vh)); block-size: calc(var(--gantry-h, 72px) + min(5vh, 44px));
padding: 0 16px max(8px, 1.2vh); transform-origin: top;
color: rgb(255 226 196 / 0%); }
/* 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); font: 600 0.6rem/1.3 var(--mono, ui-monospace, monospace);
letter-spacing: 0.32em; letter-spacing: 0.32em;
text-align: center;
text-transform: uppercase; text-transform: uppercase;
white-space: nowrap; white-space: nowrap;
content: attr(data-credit) / ''; clip-path: inset(0 100% 0 0);
transform-origin: top; opacity: 0;
transition:
transform 1.6s cubic-bezier(0.65, 0, 0.35, 1),
color 1.2s ease;
} }
.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 { .journey-world::after {
inset-block-end: 0; inset-block-end: 0;
transform-origin: bottom; transform-origin: bottom;
@@ -549,17 +574,22 @@
transform: scaleY(1); transform: scaleY(1);
transition-delay: 0.5s; 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) { @media (max-width: 767px) {
.journey-world::after { block-size: max(40px, 7vh); } .journey-world::after { block-size: min(7vh, 56px); }
.journey-world::before { /* 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; font-size: 0.5rem;
letter-spacing: 0.14em; letter-spacing: 0.14em;
text-align: center;
white-space: normal; 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) { @media (prefers-reduced-motion: reduce) {
.journey-world::before, .journey-world::before,
@@ -571,7 +601,11 @@
} }
@media (forced-colors: active) { @media (forced-colors: active) {
.journey-world::before, .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) { @media (prefers-reduced-motion: reduce) {