feat(scene): add opening crane shot and exit-gate chapter cuts
This commit is contained in:
@@ -17,6 +17,10 @@ import { createCar, loadGrandTourer } from '@/scene/world/car';
|
|||||||
import { createSign, prepareSignTypography } from '@/scene/world/sign';
|
import { createSign, prepareSignTypography } from '@/scene/world/sign';
|
||||||
import { createAtmosphere } from '@/scene/world/atmosphere';
|
import { createAtmosphere } from '@/scene/world/atmosphere';
|
||||||
import { createPostProcessor } from '@/scene/post-processing';
|
import { createPostProcessor } from '@/scene/post-processing';
|
||||||
|
import { installCinematicGrade } from '@/scene/cinematic-grade';
|
||||||
|
import { createSkyline } from '@/scene/world/skyline';
|
||||||
|
import { createTraffic } from '@/scene/world/traffic';
|
||||||
|
import { createExitGates } from '@/scene/world/exit-gates';
|
||||||
import { createDriveDynamics } from '@/scene/drive-dynamics';
|
import { createDriveDynamics } from '@/scene/drive-dynamics';
|
||||||
import { HORIZON_FOG_END, HORIZON_FOG_START } from '@/scene/world/metrics';
|
import { HORIZON_FOG_END, HORIZON_FOG_START } from '@/scene/world/metrics';
|
||||||
import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition';
|
import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition';
|
||||||
@@ -72,6 +76,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
globalThis.devicePixelRatio ?? 1,
|
globalThis.devicePixelRatio ?? 1,
|
||||||
mobileOrTablet,
|
mobileOrTablet,
|
||||||
);
|
);
|
||||||
|
installCinematicGrade();
|
||||||
const renderer = context.resources.own(
|
const renderer = context.resources.own(
|
||||||
'renderer',
|
'renderer',
|
||||||
new THREE.WebGLRenderer({
|
new THREE.WebGLRenderer({
|
||||||
@@ -83,8 +88,9 @@ export async function createProceduralSceneGeneration(
|
|||||||
);
|
);
|
||||||
renderer.setPixelRatio(quality.pixelRatio);
|
renderer.setPixelRatio(quality.pixelRatio);
|
||||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
// ACES plus the film grade in cinematic-grade.ts, shared by every tier.
|
||||||
renderer.toneMappingExposure = 1.05;
|
renderer.toneMapping = THREE.CustomToneMapping;
|
||||||
|
renderer.toneMappingExposure = 1.12;
|
||||||
|
|
||||||
const scene = new THREE.Scene();
|
const scene = new THREE.Scene();
|
||||||
scene.name = 'world-root';
|
scene.name = 'world-root';
|
||||||
@@ -121,6 +127,9 @@ export async function createProceduralSceneGeneration(
|
|||||||
const signStage = createSignStage(context.resources);
|
const signStage = createSignStage(context.resources);
|
||||||
const sun = createSun(context.resources);
|
const sun = createSun(context.resources);
|
||||||
const horizonAir = createHorizonAir(context.resources);
|
const horizonAir = createHorizonAir(context.resources);
|
||||||
|
const skyline = createSkyline(context.resources);
|
||||||
|
const traffic = createTraffic(context.resources, quality);
|
||||||
|
const exitGates = createExitGates(context.resources);
|
||||||
const authoredCar = await loadGrandTourer(context.signal);
|
const authoredCar = await loadGrandTourer(context.signal);
|
||||||
const car = createCar(context.resources, authoredCar);
|
const car = createCar(context.resources, authoredCar);
|
||||||
car.setDetail(mobileOrTablet ? 'reduced' : 'full');
|
car.setDetail(mobileOrTablet ? 'reduced' : 'full');
|
||||||
@@ -138,6 +147,9 @@ export async function createProceduralSceneGeneration(
|
|||||||
terrain.group,
|
terrain.group,
|
||||||
sun.group,
|
sun.group,
|
||||||
horizonAir.group,
|
horizonAir.group,
|
||||||
|
skyline.group,
|
||||||
|
traffic.group,
|
||||||
|
exitGates.group,
|
||||||
car.group,
|
car.group,
|
||||||
speedWake.group,
|
speedWake.group,
|
||||||
sign.group,
|
sign.group,
|
||||||
@@ -172,6 +184,9 @@ export async function createProceduralSceneGeneration(
|
|||||||
let musicPulse = 0;
|
let musicPulse = 0;
|
||||||
let terrainSpectrumPhase = 0;
|
let terrainSpectrumPhase = 0;
|
||||||
let firstFrameSuppressionRecorded = false;
|
let firstFrameSuppressionRecorded = false;
|
||||||
|
// Opening shot: a crane down from the sun to the chase position. It plays
|
||||||
|
// once, only when the visit starts at the top, and a scroll hurries it.
|
||||||
|
let introProgress: number | null = null;
|
||||||
|
|
||||||
const resize = (): void => {
|
const resize = (): void => {
|
||||||
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
|
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
|
||||||
@@ -329,6 +344,13 @@ export async function createProceduralSceneGeneration(
|
|||||||
atmosphericTime += deltaSeconds;
|
atmosphericTime += deltaSeconds;
|
||||||
const elapsedSeconds = atmosphericTime;
|
const elapsedSeconds = atmosphericTime;
|
||||||
horizonAir.update(currentPalette, elapsedSeconds);
|
horizonAir.update(currentPalette, elapsedSeconds);
|
||||||
|
skyline.update(currentPalette, elapsedSeconds);
|
||||||
|
traffic.update(
|
||||||
|
deltaSeconds,
|
||||||
|
18 + snapshot.driveSpeed * 54 + response.roadEnergy * 48,
|
||||||
|
elapsedSeconds,
|
||||||
|
);
|
||||||
|
const cut = exitGates.update(snapshot.activeSectionId, deltaSeconds);
|
||||||
sun.update(
|
sun.update(
|
||||||
currentPalette,
|
currentPalette,
|
||||||
snapshot.routeProgress,
|
snapshot.routeProgress,
|
||||||
@@ -489,23 +511,41 @@ export async function createProceduralSceneGeneration(
|
|||||||
targetVerticalShift,
|
targetVerticalShift,
|
||||||
cameraMix,
|
cameraMix,
|
||||||
);
|
);
|
||||||
|
if (introProgress === null) {
|
||||||
|
introProgress =
|
||||||
|
snapshot.activeSectionId === 'start' && snapshot.scrollY < 40 ? 0 : 1;
|
||||||
|
}
|
||||||
|
if (introProgress < 1) {
|
||||||
|
const hurry = snapshot.scrollY > 40 ? 5 : 1;
|
||||||
|
introProgress = Math.min(1, introProgress + (deltaSeconds * hurry) / INTRO_SECONDS);
|
||||||
|
}
|
||||||
|
const crane = easeInOutCubic(
|
||||||
|
THREE.MathUtils.clamp((introProgress - 0.06) / 0.94, 0, 1),
|
||||||
|
);
|
||||||
|
const craneShift = cameraState.shift * crane;
|
||||||
camera.setViewOffset(
|
camera.setViewOffset(
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
-width * cameraState.shift,
|
-width * craneShift,
|
||||||
-height * cameraState.verticalShift,
|
-height * cameraState.verticalShift,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
);
|
);
|
||||||
camera.position.set(cameraState.x, cameraState.y, cameraState.z);
|
camera.position.set(
|
||||||
camera.lookAt(
|
THREE.MathUtils.lerp(0, cameraState.x, crane),
|
||||||
cameraState.targetX,
|
THREE.MathUtils.lerp(46, cameraState.y, crane),
|
||||||
cameraState.targetY,
|
THREE.MathUtils.lerp(92, cameraState.z, crane),
|
||||||
cameraState.targetZ,
|
|
||||||
);
|
);
|
||||||
camera.rotation.z += cameraState.roll;
|
camera.lookAt(
|
||||||
if (Math.abs(camera.fov - cameraState.fov) > 0.001) {
|
THREE.MathUtils.lerp(0, cameraState.targetX, crane),
|
||||||
camera.fov = cameraState.fov;
|
THREE.MathUtils.lerp(64, cameraState.targetY, crane),
|
||||||
|
THREE.MathUtils.lerp(-720, cameraState.targetZ, crane),
|
||||||
|
);
|
||||||
|
camera.rotation.z += cameraState.roll * crane;
|
||||||
|
const lensFov =
|
||||||
|
THREE.MathUtils.lerp(30, cameraState.fov, crane) + cut * 6;
|
||||||
|
if (Math.abs(camera.fov - lensFov) > 0.001) {
|
||||||
|
camera.fov = lensFov;
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
}
|
}
|
||||||
const stage = signStage.update(width, height);
|
const stage = signStage.update(width, height);
|
||||||
@@ -536,7 +576,13 @@ export async function createProceduralSceneGeneration(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (postProcessor) {
|
if (postProcessor) {
|
||||||
postProcessor.setDynamics(response.bloomEnergy);
|
postProcessor.setDynamics(Math.max(response.bloomEnergy, cut * 0.45));
|
||||||
|
postProcessor.setLens({
|
||||||
|
time: elapsedSeconds,
|
||||||
|
letterbox: 0.115 * (1 - THREE.MathUtils.smoothstep(introProgress, 0.5, 1)),
|
||||||
|
flash: cut * 0.08,
|
||||||
|
energy: Math.max(overdrive, cut),
|
||||||
|
});
|
||||||
postProcessor.render(scene, camera);
|
postProcessor.render(scene, camera);
|
||||||
} else {
|
} else {
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
@@ -556,6 +602,12 @@ export async function createProceduralSceneGeneration(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INTRO_SECONDS = 4.2;
|
||||||
|
|
||||||
|
function easeInOutCubic(t: number): number {
|
||||||
|
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
function dampPalette(
|
function dampPalette(
|
||||||
current: MutableChapterPalette,
|
current: MutableChapterPalette,
|
||||||
target: Readonly<MutableChapterPalette>,
|
target: Readonly<MutableChapterPalette>,
|
||||||
|
|||||||
121
src/scene/world/exit-gates.ts
Normal file
121
src/scene/world/exit-gates.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
|
import type { SectionId } from '@/content/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cut between chapters: on every exit change the car runs a short gallery
|
||||||
|
* of light gates in the next chapter's accent. Nine portal frames rush past the
|
||||||
|
* camera in about two seconds while the lens flashes and the field of view
|
||||||
|
* opens. At rest the gates are hidden and cost no draw call.
|
||||||
|
*/
|
||||||
|
const GATES = 9;
|
||||||
|
const SPACING = 17;
|
||||||
|
const START_Z = -150;
|
||||||
|
const TRAVEL = GATES * SPACING + 205;
|
||||||
|
const DURATION = 2.1;
|
||||||
|
const HALF_SPAN = 11.2;
|
||||||
|
const HEIGHT = 10.5;
|
||||||
|
|
||||||
|
export const SECTION_ACCENTS: Readonly<Record<SectionId, number>> = {
|
||||||
|
start: 0xff3f9e,
|
||||||
|
profile: 0x3fe6f2,
|
||||||
|
experience: 0xffb15e,
|
||||||
|
infrastructure: 0x41e9d6,
|
||||||
|
'selected-builds': 0xff5b8a,
|
||||||
|
'additional-work': 0xb57bff,
|
||||||
|
toolkit: 0x47d2ff,
|
||||||
|
contact: 0xffc078,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createExitGates(resources: SceneResourceBag) {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.name = 'exit-gates';
|
||||||
|
group.visible = false;
|
||||||
|
const uniforms = {
|
||||||
|
uColor: { value: new THREE.Color(SECTION_ACCENTS.profile) },
|
||||||
|
uIntensity: { value: 0 },
|
||||||
|
};
|
||||||
|
const material = resources.own(
|
||||||
|
'material',
|
||||||
|
new THREE.ShaderMaterial({
|
||||||
|
uniforms,
|
||||||
|
vertexShader: /* glsl */ `
|
||||||
|
varying float vDepth;
|
||||||
|
varying vec3 vLocal;
|
||||||
|
void main() {
|
||||||
|
vLocal = position;
|
||||||
|
vec4 view = modelViewMatrix * instanceMatrix * vec4(position, 1.0);
|
||||||
|
vDepth = -view.z;
|
||||||
|
gl_Position = projectionMatrix * view;
|
||||||
|
}`,
|
||||||
|
fragmentShader: /* glsl */ `
|
||||||
|
uniform vec3 uColor;
|
||||||
|
uniform float uIntensity;
|
||||||
|
varying float vDepth;
|
||||||
|
varying vec3 vLocal;
|
||||||
|
void main() {
|
||||||
|
float near = smoothstep(5.0, 26.0, vDepth);
|
||||||
|
float far = 1.0 - smoothstep(140.0, 230.0, vDepth);
|
||||||
|
vec3 hot = mix(uColor, vec3(1.0), 0.12);
|
||||||
|
gl_FragColor = vec4(hot * 1.35 * uIntensity * near * far, 1.0);
|
||||||
|
#include <tonemapping_fragment>
|
||||||
|
#include <colorspace_fragment>
|
||||||
|
}`,
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const box = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1));
|
||||||
|
const bars = new THREE.InstancedMesh(box, material, GATES * 4);
|
||||||
|
bars.name = 'exit-gate-frames';
|
||||||
|
bars.frustumCulled = false;
|
||||||
|
bars.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||||
|
group.add(bars);
|
||||||
|
const dummy = new THREE.Object3D();
|
||||||
|
|
||||||
|
let progress = 1;
|
||||||
|
let previous: SectionId | null = null;
|
||||||
|
|
||||||
|
const place = (index: number, x: number, y: number, z: number, sx: number, sy: number) => {
|
||||||
|
dummy.position.set(x, y, z);
|
||||||
|
dummy.scale.set(sx, sy, 0.14);
|
||||||
|
dummy.updateMatrix();
|
||||||
|
bars.setMatrixAt(index, dummy.matrix);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
/** Returns the cut's energy, 0 at rest, for the lens and the camera. */
|
||||||
|
update(section: SectionId, deltaSeconds: number): number {
|
||||||
|
if (previous !== null && section !== previous) {
|
||||||
|
uniforms.uColor.value.setHex(SECTION_ACCENTS[section]);
|
||||||
|
// A cut already under way keeps its momentum instead of restarting.
|
||||||
|
if (progress >= 0.7) progress = 0;
|
||||||
|
}
|
||||||
|
previous = section;
|
||||||
|
if (progress >= 1) {
|
||||||
|
group.visible = false;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
progress = Math.min(1, progress + Math.max(0, deltaSeconds) / DURATION);
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 1.6);
|
||||||
|
const travel = eased * TRAVEL;
|
||||||
|
const envelope = THREE.MathUtils.smoothstep(progress, 0, 0.14)
|
||||||
|
* (1 - THREE.MathUtils.smoothstep(progress, 0.78, 1));
|
||||||
|
uniforms.uIntensity.value = envelope;
|
||||||
|
for (let i = 0; i < GATES; i++) {
|
||||||
|
const z = START_Z - i * SPACING + travel;
|
||||||
|
const base = i * 4;
|
||||||
|
place(base, -HALF_SPAN, HEIGHT / 2, z, 0.1, HEIGHT);
|
||||||
|
place(base + 1, HALF_SPAN, HEIGHT / 2, z, 0.1, HEIGHT);
|
||||||
|
place(base + 2, 0, HEIGHT, z, HALF_SPAN * 2 + 0.1, 0.1);
|
||||||
|
// A floor seam makes each gate cross the asphalt as a line of light.
|
||||||
|
place(base + 3, 0, 0.03, z, HALF_SPAN * 2, 0.05);
|
||||||
|
}
|
||||||
|
bars.instanceMatrix.needsUpdate = true;
|
||||||
|
group.visible = true;
|
||||||
|
return envelope;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user