feat: integrate the cinematic nightdrive scene

This commit is contained in:
2026-09-02 15:14:19 +03:00
parent e4c832356b
commit 8deb56ed0c
8 changed files with 257 additions and 55 deletions

View File

@@ -3,6 +3,7 @@ import type { QualityTier } from '@/journey/motion-policy';
import type { SceneResourceBag } from '@/scene/resources';
export type LiveQualityTier = Exclude<QualityTier, 'static'>;
export type SceneDetailMode = 'full' | 'reduced';
export type SceneLifecycleState =
| { kind: 'idle' }

View File

@@ -1,9 +1,10 @@
import * as THREE from 'three';
import type { SceneResourceBag } from '@/scene/resources';
import type { SceneDetailMode } from '@/scene/types';
export interface CarWorld {
group: CarGroup;
setDetail(detail: 'full' | 'reduced'): void;
setDetail(detail: SceneDetailMode): void;
update(speed: number, acceleration: number, elapsedSeconds: number): void;
}
@@ -246,12 +247,12 @@ export function createCar(resources: SceneResourceBag): CarWorld {
}
group.add(secondary);
let detail: 'full' | 'reduced' = 'full';
let detail: SceneDetailMode = 'full';
let squat = 0;
let squatVelocity = 0;
let previousElapsed: number | null = null;
const setDetail = (nextDetail: 'full' | 'reduced'): void => {
const setDetail = (nextDetail: SceneDetailMode): void => {
if (detail === nextDetail) return;
detail = nextDetail;
secondary.visible = detail === 'full';

View File

@@ -110,6 +110,7 @@ export async function createProceduralSceneGeneration(
camera.updateProjectionMatrix();
car.setDetail(width < 1_100 || height < 620 ? 'reduced' : 'full');
car.group.visible = height >= 340;
sign.setViewportAspect(camera.aspect);
postProcessor?.resize(width, height, quality.pixelRatio * quality.postScale);
if (TEST_BUILD && context.quality !== 'medium') {
window.__PORTFOLIO_PERFORMANCE__?.recordRuntimeAllocation(context.resources.gpuBudgetSnapshot({

View File

@@ -4,83 +4,215 @@ import type { SignSnapshot } from '@/journey/types';
import type { SceneResourceBag } from '@/scene/resources';
const routeLabels: Readonly<Record<SectionId, string>> = {
start: '00 / START',
profile: '01 / PROFILE',
experience: '02 / EXPERIENCE',
infrastructure: '03 / INFRASTRUCTURE',
'selected-builds': '04 / SELECTED BUILDS',
'additional-work': '05 / ADDITIONAL WORK',
toolkit: '06 / TOOLKIT',
contact: '07 / DESTINATION',
start: 'Start',
profile: 'Profile',
experience: 'Experience',
infrastructure: 'Infrastructure',
'selected-builds': 'Selected builds',
'additional-work': 'Additional work',
toolkit: 'Toolkit',
contact: 'Contact',
};
export interface SignWorld {
group: SignGroup;
setViewportAspect(aspect: number): void;
update(sign: SignSnapshot | null): void;
}
interface SignGroup {
visible: boolean;
position: { x: number; y: number; z: number; set(x: number, y: number, z: number): void };
rotation: { y: number };
scale: { setScalar(scale: number): void };
add(...objects: unknown[]): void;
getObjectByName(name: string): unknown;
}
export function routeLabelFor(id: SectionId): string {
return routeLabels[id];
}
export function createSign(resources: SceneResourceBag) {
export function createSign(resources: SceneResourceBag): SignWorld {
const group = new THREE.Group();
group.name = 'roadside-sign';
group.visible = false;
const geometry = resources.own('geometry', new THREE.PlaneGeometry(8.4, 2.2));
const backplateMaterial = resources.own('material', new THREE.MeshStandardMaterial({
color: 0x07101a,
roughness: 0.7,
metalness: 0.42,
transparent: true,
opacity: 0,
}));
const frameMaterial = resources.own('material', new THREE.MeshStandardMaterial({
color: 0x2d6f79,
emissive: 0x43cfdd,
emissiveIntensity: 0.68,
roughness: 0.38,
metalness: 0.58,
transparent: true,
opacity: 0,
}));
const postMaterial = resources.own('material', new THREE.MeshStandardMaterial({
color: 0x172b35,
roughness: 0.64,
metalness: 0.62,
transparent: true,
opacity: 0,
}));
const backplate = new THREE.Mesh(
resources.own('geometry', new THREE.BoxGeometry(8.2, 2.35, 0.24)),
backplateMaterial,
);
backplate.name = 'sign-backplate';
group.add(backplate);
const canvas = document.createElement('canvas');
canvas.width = 768;
canvas.height = 192;
canvas.width = 1_024;
canvas.height = 256;
const context = canvas.getContext('2d');
const texture = resources.own('texture', new THREE.CanvasTexture(canvas));
texture.colorSpace = THREE.SRGBColorSpace;
const material = resources.own('material', new THREE.MeshBasicMaterial({
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
const faceMaterial = resources.own('material', new THREE.MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.96,
opacity: 0,
side: THREE.DoubleSide,
map: texture,
toneMapped: true,
}));
const panel = new THREE.Mesh(geometry, material);
group.add(panel);
const face = new THREE.Mesh(
resources.own('geometry', new THREE.PlaneGeometry(7.72, 1.87)),
faceMaterial,
);
face.name = 'sign-face';
face.position.z = 0.132;
face.userData.textureUpdateCount = 0;
group.add(face);
const thrusterGeometry = resources.own('geometry', new THREE.ConeGeometry(0.18, 0.72, 8));
const thrusterMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0x44e8f5 }));
for (const x of [-3.5, 3.5]) {
const thruster = new THREE.Mesh(thrusterGeometry, thrusterMaterial);
thruster.rotation.x = Math.PI / 2;
thruster.position.set(x, -1.22, 0.08);
group.add(thruster);
const frame = new THREE.Group();
frame.name = 'sign-frame';
const horizontalGeometry = resources.own('geometry', new THREE.BoxGeometry(8.42, 0.12, 0.32));
const verticalGeometry = resources.own('geometry', new THREE.BoxGeometry(0.12, 2.35, 0.32));
for (const y of [-1.175, 1.175]) {
const rail = new THREE.Mesh(horizontalGeometry, frameMaterial);
rail.position.y = y;
frame.add(rail);
}
for (const x of [-4.15, 4.15]) {
const rail = new THREE.Mesh(verticalGeometry, frameMaterial);
rail.position.x = x;
frame.add(rail);
}
group.add(frame);
const postGeometry = resources.own('geometry', new THREE.BoxGeometry(0.17, 3.5, 0.17));
for (const [x, side] of [[-2.9, 'left'], [2.9, 'right']] as const) {
const post = new THREE.Mesh(postGeometry, postMaterial);
post.name = `sign-post-${side}`;
post.position.set(x, -2.86, -0.08);
group.add(post);
}
const fadeMaterials = [backplateMaterial, frameMaterial, postMaterial, faceMaterial];
let target: SectionId | null = null;
let narrowViewport = false;
const updateTexture = (id: SectionId): void => {
if (target === id) return;
target = id;
if (!context) return;
context.fillStyle = '#07111b';
context.fillRect(0, 0, canvas.width, canvas.height);
context.strokeStyle = '#35ddeb';
context.lineWidth = 7;
context.strokeRect(8, 8, canvas.width - 16, canvas.height - 16);
context.fillStyle = '#e8fbff';
context.font = '600 46px ui-monospace, monospace';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText(routeLabelFor(id), canvas.width / 2, canvas.height / 2);
face.userData.textureUpdateCount += 1;
if (context) drawLabel(context, routeLabelFor(id), canvas.width, canvas.height);
texture.needsUpdate = true;
};
const applyOpacity = (opacity: number): void => {
for (const material of fadeMaterials) material.opacity = opacity;
};
return {
group,
update(sign: SignSnapshot | null) {
group.visible = sign !== null && sign.opacity > 0;
if (!sign) return;
setViewportAspect(aspect) {
narrowViewport = !Number.isFinite(aspect) || aspect < 0.85;
},
update(sign) {
if (!sign) {
group.visible = false;
applyOpacity(0);
return;
}
updateTexture(sign.targetId);
material.opacity = sign.opacity;
const phaseOffset = sign.phase === 'anticipate'
? -30
: sign.phase === 'approach'
? -26 + sign.progress * 21
: sign.phase === 'dwell'
? -5
: -5 + sign.progress * 18;
group.position.set(7.2, 4.1, phaseOffset);
group.rotation.y = -0.12;
const opacity = clamp01(sign.opacity);
applyOpacity(opacity);
group.visible = opacity > 0;
const progress = smoothstep(clamp01(sign.progress));
const dwell = narrowViewport
? { x: 3.1, y: 3.25, z: -20, scale: 0.68 }
: { x: 8.25, y: 3.1, z: -8.5, scale: 1 };
const horizon = narrowViewport
? { x: 4.2, y: 4.1, z: -88, scale: 0.2 }
: { x: 12.4, y: 5.3, z: -92, scale: 0.24 };
if (sign.phase === 'anticipate') {
group.position.set(horizon.x, horizon.y, horizon.z);
group.scale.setScalar(horizon.scale);
} else if (sign.phase === 'approach') {
group.position.set(
THREE.MathUtils.lerp(horizon.x, dwell.x, progress),
THREE.MathUtils.lerp(horizon.y, dwell.y, progress),
THREE.MathUtils.lerp(horizon.z, dwell.z, progress),
);
group.scale.setScalar(THREE.MathUtils.lerp(horizon.scale, dwell.scale, progress));
} else if (sign.phase === 'dwell') {
group.position.set(dwell.x, dwell.y, dwell.z);
group.scale.setScalar(dwell.scale);
} else {
group.position.set(
dwell.x + progress * (narrowViewport ? 1.4 : 3.2),
dwell.y + progress * 0.7,
THREE.MathUtils.lerp(dwell.z, 17, progress),
);
group.scale.setScalar(dwell.scale * THREE.MathUtils.lerp(1, 1.08, progress));
}
group.rotation.y = narrowViewport ? -0.08 : -0.16;
},
};
}
function drawLabel(
context: CanvasRenderingContext2D,
label: string,
width: number,
height: number,
): void {
context.clearRect(0, 0, width, height);
context.fillStyle = '#07111b';
context.strokeStyle = '#56d4df';
context.lineWidth = 4;
context.beginPath();
if (typeof context.roundRect === 'function') {
context.roundRect(14, 14, width - 28, height - 28, 12);
} else {
context.rect(14, 14, width - 28, height - 28);
}
context.fill();
context.stroke();
context.fillStyle = '#eef7f8';
context.font = '600 58px "Geist Mono Variable", ui-monospace, monospace';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText(label, width / 2, height / 2 + 2, width - 88);
}
function clamp01(value: number): number {
return Number.isFinite(value) ? THREE.MathUtils.clamp(value, 0, 1) : 0;
}
function smoothstep(value: number): number {
return value * value * (3 - 2 * value);
}