feat: ignition opening with cold start, lamps, crossfade and shader warm-up

This commit is contained in:
2026-10-11 09:54:48 +03:00
parent 33585f7e34
commit 7423f5d52e
18 changed files with 516 additions and 25 deletions

View File

@@ -46,6 +46,8 @@ import { dawnLightUniforms } from '@/scene/world/dawn-light';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
import { readDashcam, takeDashcamCapture } from '@/journey/dashcam-feed';
import { createDashcamLens } from '@/scene/world/dashcam-lens';
import { createIgnition, IGNITION } from '@/scene/world/ignition';
import { readIgnition } from '@/journey/ignition-feed';
declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD =
@@ -104,7 +106,7 @@ export async function createProceduralSceneGeneration(
renderer.outputColorSpace = THREE.SRGBColorSpace;
// ACES plus the film grade in cinematic-grade.ts, shared by every tier.
renderer.toneMapping = THREE.CustomToneMapping;
renderer.toneMappingExposure = 1.12;
renderer.toneMappingExposure = IGNITION.exposure;
const scene = new THREE.Scene();
scene.name = 'world-root';
@@ -257,6 +259,9 @@ export async function createProceduralSceneGeneration(
let dawnState: DawnStep | '' = '';
// DASHCAM: the pointer drifts the lens and the letterbox closes while open.
const dashcamLens = createDashcamLens();
// IGNITION: the first frame is cold; lamps, beam, roll and exposure come up
// over three seconds (src/scene/world/ignition.ts).
const ignition = createIgnition();
const resize = (): void => {
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
@@ -367,6 +372,23 @@ export async function createProceduralSceneGeneration(
// lifting off slows the spectrum to rest without rewinding its phase.
terrainSpectrumPhase =
(terrainSpectrumPhase + deltaSeconds * overdrive * 0.38) % 1;
// IGNITION: the first three seconds. A scroll, a navigation, a sign, the
// dashcam or a stuttering start (the crane's rule) cuts to driving. The
// clock starts on the frame after the first: the first frame compiles
// the shaders (hundreds of ms on a cold GPU) while the canvas dissolves
// in on the compositor, and that stall must not eat the cold hold. A
// later hitch stretches the timeline instead of skipping a beat.
const ignited = ignition.update(renderedFrames <= 1 ? 0 : Math.min(0.1, wallSeconds), {
cut: snapshot.scrollY > 40
|| snapshot.activeSectionId !== 'start'
|| snapshot.sign !== null
|| readDashcam().open
|| introSlowFrames >= 3,
short: readIgnition().returning,
});
const roll = ignited.roll;
renderer.toneMappingExposure = ignited.exposure;
if (renderedFrames === 0) canvas.dataset.lit = '';
writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0);
// The 03:12 window: section-local scroll progress drives every beat.
const service = maintenance.update(
@@ -427,7 +449,7 @@ export async function createProceduralSceneGeneration(
dawnLightUniforms.uDawnLight.value = dawnRims ? dawn : 0;
dampPalette(currentPalette, targetPalette, deltaSeconds);
const driveDistance = travelDistance.advance(
deltaSeconds * (1 - engineOff),
deltaSeconds * (1 - engineOff) * roll,
snapshot.driveSpeed,
response.roadEnergy,
);
@@ -435,11 +457,11 @@ export async function createProceduralSceneGeneration(
0.18 + response.roadEnergy * 0.82,
0.18,
1,
) * (1 - engineOff);
) * (1 - engineOff) * roll;
road.update(
driveDistance,
visualSpeed,
response.roadEnergy,
response.roadEnergy * roll,
currentPalette,
);
expressway.update(
@@ -473,7 +495,7 @@ export async function createProceduralSceneGeneration(
skyline.update(currentPalette, elapsedSeconds);
traffic.update(
deltaSeconds,
(18 + snapshot.driveSpeed * 54 + response.roadEnergy * 48) * (1 - engineOff),
(18 + snapshot.driveSpeed * 54 + response.roadEnergy * 48) * (1 - engineOff) * roll,
elapsedSeconds,
);
sun.update(
@@ -490,9 +512,10 @@ export async function createProceduralSceneGeneration(
elapsedSeconds,
driveDistance,
engineOff,
ignited.lamps,
);
speedWake.update(
overdrive,
overdrive * roll,
elapsedSeconds,
car.group.position,
width < 1100 || height < 620 ? 2.32 : 2.78,
@@ -509,7 +532,7 @@ export async function createProceduralSceneGeneration(
atmosphere.update(
driveDistance,
visualSpeed,
response.streakEnergy,
response.streakEnergy * roll,
currentPalette,
atmosphericTime,
overdrive,
@@ -658,7 +681,8 @@ export async function createProceduralSceneGeneration(
introFrames += 1;
if (introFrames > 3 && wallSeconds > 0.1) introSlowFrames += 1;
if (introSlowFrames >= 3) introProgress = 1;
introProgress = Math.min(1, introProgress + deltaSeconds / INTRO_SECONDS);
// IGNITION: the crane holds its top position until the main beam is on.
if (ignited.phase >= 2) introProgress = Math.min(1, introProgress + deltaSeconds / INTRO_SECONDS);
}
outroProgress = THREE.MathUtils.clamp(
outroProgress
@@ -699,8 +723,11 @@ export async function createProceduralSceneGeneration(
camera.fov = lensFov;
camera.updateProjectionMatrix();
}
const beam = headlights.update(snapshot.beam, snapshot, camera, 1 - engineOff, deltaSeconds);
road.setBeam(beam.yaw, beam.lift);
// IGNITION: the dipped level lights the road throw; the steerable main
// beam (spot, haze cone, lift) only comes in above it.
const mainBeam = THREE.MathUtils.clamp((ignited.lamps - IGNITION.dipped) / (1 - IGNITION.dipped), 0, 1);
const beam = headlights.update(snapshot.beam, snapshot, camera, (1 - engineOff) * mainBeam, deltaSeconds);
road.setBeam(beam.yaw, beam.lift, ignited.lamps);
const stage = signStage.update(width, height);
sign.compose(camera, width, height, stage.dock, now, stage.tilt);
const gantry = sign.dockState();
@@ -756,6 +783,37 @@ export async function createProceduralSceneGeneration(
);
}
}
if (renderedFrames === 0) {
// IGNITION parks the wake, the streaks and the beam cone until the
// pull-away, and the first scroll brings the sign: each would compile
// its program as a 70-280 ms hitch inside the opening shot (three slow
// frames end the crane; one over 250 ms is a quality downgrade that
// rebuilds the scene). Draw everything once now, behind the poster,
// through the real pipeline: renderer.compile() keys its programs for
// the screen (sRGB output, tone mapping) and never matches the post
// processor's linear targets, so every program compiled twice. The
// drawing buffer is presented once per task: the cold frame below
// is what the canvas shows.
// Culling is off too: the car sits outside the crane's frustum.
const hidden: { visible: boolean }[] = [];
const culled: { frustumCulled: boolean }[] = [];
scene.traverse((object: { visible: boolean; frustumCulled: boolean }) => {
if (object.frustumCulled) {
object.frustumCulled = false;
culled.push(object);
}
if (object.visible) return;
object.visible = true;
hidden.push(object);
});
try {
if (postProcessor) postProcessor.render(scene, camera);
else renderer.render(scene, camera);
} finally {
for (const object of hidden) object.visible = false;
for (const object of culled) object.frustumCulled = true;
}
}
if (postProcessor) {
postProcessor.setDynamics(response.bloomEnergy, dawnRims ? dawn : 0);
postProcessor.setLens({
@@ -791,6 +849,7 @@ export async function createProceduralSceneGeneration(
delete document.documentElement.dataset.interlude;
delete document.documentElement.dataset.interludeArmed;
delete document.documentElement.dataset.dawn;
ignition.dispose();
dawnLightUniforms.uDawnLight.value = 0;
// Quality changes reuse the canvas context. Leave texture upload flags at
// defaults before the next renderer creates its empty array textures.