feat: underpass overpasses carry the chapter transitions

This commit is contained in:
2026-10-11 10:48:31 +03:00
parent 7423f5d52e
commit 989928c5bb
19 changed files with 1084 additions and 33 deletions

View File

@@ -26,12 +26,13 @@ import { createCoachworkEnvironment } from '@/scene/world/coachwork-light';
import { createTravelDistance } from '@/scene/world/travel-distance';
import {
blendDaybreak,
dampPalette,
engineOffStarScale,
paletteDampRate,
createMutableChapterPalette,
STORM_STAR_SCALE,
STORM_TINT,
writeChapterPalette,
type MutableChapterPalette,
} from '@/scene/world/palette';
import { createMaintenanceWindow } from '@/scene/world/maintenance-window';
import { createHeadlights } from '@/scene/world/headlights';
@@ -48,6 +49,9 @@ 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';
import { createUnderpass, underpassUniforms } from '@/scene/world/underpass';
import { DEFAULT_LEAD_VH, nextSectionId, shadeDepth } from '@/journey/underpass';
import { signRouteFor, signTriggerMaximumVhFor } from '@/journey/sign-routes';
declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD =
@@ -195,7 +199,19 @@ export async function createProceduralSceneGeneration(
maintenanceTier === 'full'
&& (globalThis.navigator as { connection?: { saveData?: boolean } } | undefined)?.connection?.saveData !== true,
);
// UNDERPASS: every chapter boundary is a deck the car drives beneath.
// High/medium: shade, sodium pool, palette boost and the pass swell (the
// swell only with the soundtrack audible). Low tier: deck and strips only.
const underpass = createUnderpass(context.resources, {
full: context.quality !== 'low'
&& (globalThis.navigator as { connection?: { saveData?: boolean } } | undefined)?.connection?.saveData !== true,
fogColor: scene.fog.color,
});
let underpassShade = 0;
let underpassKey = '';
let underpassAttr = false;
scene.add(
underpass.group,
relay.group,
billboard.mesh,
maintenance.group,
@@ -447,7 +463,9 @@ export async function createProceduralSceneGeneration(
targetPalette.starVisibility *= engineOffStarScale(engineOff, dawn);
}
dawnLightUniforms.uDawnLight.value = dawnRims ? dawn : 0;
dampPalette(currentPalette, targetPalette, deltaSeconds);
// UNDERPASS: deep in the deck's shade the palette settles x4 (last
// frame's shade: the deck moves after the camera below).
dampPalette(currentPalette, targetPalette, deltaSeconds, paletteDampRate(shadeDepth(underpassShade), service.storm));
const driveDistance = travelDistance.advance(
deltaSeconds * (1 - engineOff) * roll,
snapshot.driveSpeed,
@@ -540,6 +558,11 @@ export async function createProceduralSceneGeneration(
);
background.copy(currentPalette.sky);
scene.fog.color.copy(currentPalette.fog);
if (underpassShade > 0) {
// The deck occludes the sky: at most 35% darker for a beat.
background.lerp(ENGINE_OFF_NIGHT, underpassShade);
scene.fog.color.lerp(ENGINE_OFF_NIGHT, underpassShade);
}
ambient.color
.copy(currentPalette.haze)
.lerp(currentPalette.terrainHigh, 0.26);
@@ -731,6 +754,29 @@ export async function createProceduralSceneGeneration(
const stage = signStage.update(width, height);
sign.compose(camera, width, height, stage.dock, now, stage.tilt);
const gantry = sign.dockState();
// UNDERPASS: after the sign so the deck holds behind it while it comes in.
const nextRoute = nextSectionId(snapshot.activeSectionId);
const passage = underpass.update(
snapshot.activeSectionId,
null,
snapshot.driveSpeed,
deltaSeconds,
now,
{
activation: snapshot.sectionActivation,
leadVh: nextRoute === null ? DEFAULT_LEAD_VH : signTriggerMaximumVhFor(signRouteFor(nextRoute), width, height),
cameraZ: camera.position.z,
signZ: gantry === 'incoming' ? sign.group.position.z : null,
explicit: snapshot.sign?.navigationArrival === true,
},
);
underpassShade = passage.shade;
const under = shadeDepth(passage.shade) > 0.2;
if (under !== underpassAttr) {
underpassAttr = under;
if (under) document.documentElement.dataset.underpass = 'under';
else delete document.documentElement.dataset.underpass;
}
if (gantry !== gantryState) {
gantryState = gantry;
document.documentElement.dataset.gantry = gantry;
@@ -765,6 +811,16 @@ export async function createProceduralSceneGeneration(
}));
}
}
if (TEST_BUILD) {
const deck = underpass.inspect();
const key = `${deck.exit}|${deck.z === null ? '' : Math.round(deck.z / 5)}|${Math.round(deck.shade * 100)}|${deck.passes}|${deck.held}|${deck.visible}`;
if (key !== underpassKey) {
underpassKey = key;
window.dispatchEvent(new CustomEvent('portfolio-underpass-frame', {
detail: { now, activeSectionId: snapshot.activeSectionId, direction: snapshot.direction, driveSpeed: snapshot.driveSpeed, ...deck },
}));
}
}
if (TEST_BUILD && snapshot.sign !== null) {
const presentation = sign.inspect(camera, width, height);
if (presentation !== null) {
@@ -849,6 +905,8 @@ export async function createProceduralSceneGeneration(
delete document.documentElement.dataset.interlude;
delete document.documentElement.dataset.interludeArmed;
delete document.documentElement.dataset.dawn;
delete document.documentElement.dataset.underpass;
underpassUniforms.uShade.value = 0;
ignition.dispose();
dawnLightUniforms.uDawnLight.value = 0;
// Quality changes reuse the canvas context. Leave texture upload flags at
@@ -876,33 +934,3 @@ const INTERLUDE_RISE_MS = 800;
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
function dampPalette(
current: MutableChapterPalette,
target: Readonly<MutableChapterPalette>,
deltaSeconds: number,
): void {
const mix = 1 - Math.exp(-Math.max(0, deltaSeconds) * 2.8);
current.sky.lerp(target.sky, mix);
current.fog.lerp(target.fog, mix);
current.terrainLow.lerp(target.terrainLow, mix);
current.terrainHigh.lerp(target.terrainHigh, mix);
current.sun.lerp(target.sun, mix);
current.haze.lerp(target.haze, mix);
current.warmth = THREE.MathUtils.lerp(current.warmth, target.warmth, mix);
current.terrainEnergy = THREE.MathUtils.lerp(
current.terrainEnergy,
target.terrainEnergy,
mix,
);
current.starVisibility = THREE.MathUtils.lerp(
current.starVisibility,
target.starVisibility,
mix,
);
current.streakEnergy = THREE.MathUtils.lerp(
current.streakEnergy,
target.streakEnergy,
mix,
);
}