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

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