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 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;

View File

@@ -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) {

View File

@@ -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"
>
<!-- 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">
<source media="(max-width: 767px)" :srcset="mobilePosterUrl">
<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,
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;
},
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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) {