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'; import type { SceneResourceBag } from '@/scene/resources';
export type LiveQualityTier = Exclude<QualityTier, 'static'>; export type LiveQualityTier = Exclude<QualityTier, 'static'>;
export type SceneDetailMode = 'full' | 'reduced';
export type SceneLifecycleState = export type SceneLifecycleState =
| { kind: 'idle' } | { kind: 'idle' }

View File

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

View File

@@ -110,6 +110,7 @@ export async function createProceduralSceneGeneration(
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
car.setDetail(width < 1_100 || height < 620 ? 'reduced' : 'full'); car.setDetail(width < 1_100 || height < 620 ? 'reduced' : 'full');
car.group.visible = height >= 340; car.group.visible = height >= 340;
sign.setViewportAspect(camera.aspect);
postProcessor?.resize(width, height, quality.pixelRatio * quality.postScale); postProcessor?.resize(width, height, quality.pixelRatio * quality.postScale);
if (TEST_BUILD && context.quality !== 'medium') { if (TEST_BUILD && context.quality !== 'medium') {
window.__PORTFOLIO_PERFORMANCE__?.recordRuntimeAllocation(context.resources.gpuBudgetSnapshot({ 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'; import type { SceneResourceBag } from '@/scene/resources';
const routeLabels: Readonly<Record<SectionId, string>> = { const routeLabels: Readonly<Record<SectionId, string>> = {
start: '00 / START', start: 'Start',
profile: '01 / PROFILE', profile: 'Profile',
experience: '02 / EXPERIENCE', experience: 'Experience',
infrastructure: '03 / INFRASTRUCTURE', infrastructure: 'Infrastructure',
'selected-builds': '04 / SELECTED BUILDS', 'selected-builds': 'Selected builds',
'additional-work': '05 / ADDITIONAL WORK', 'additional-work': 'Additional work',
toolkit: '06 / TOOLKIT', toolkit: 'Toolkit',
contact: '07 / DESTINATION', 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 { export function routeLabelFor(id: SectionId): string {
return routeLabels[id]; return routeLabels[id];
} }
export function createSign(resources: SceneResourceBag) { export function createSign(resources: SceneResourceBag): SignWorld {
const group = new THREE.Group(); const group = new THREE.Group();
group.name = 'roadside-sign';
group.visible = false; 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'); const canvas = document.createElement('canvas');
canvas.width = 768; canvas.width = 1_024;
canvas.height = 192; canvas.height = 256;
const context = canvas.getContext('2d'); const context = canvas.getContext('2d');
const texture = resources.own('texture', new THREE.CanvasTexture(canvas)); const texture = resources.own('texture', new THREE.CanvasTexture(canvas));
texture.colorSpace = THREE.SRGBColorSpace; 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, color: 0xffffff,
transparent: true, transparent: true,
opacity: 0.96, opacity: 0,
side: THREE.DoubleSide, side: THREE.DoubleSide,
map: texture, map: texture,
toneMapped: true,
})); }));
const panel = new THREE.Mesh(geometry, material); const face = new THREE.Mesh(
group.add(panel); 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 frame = new THREE.Group();
const thrusterMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0x44e8f5 })); frame.name = 'sign-frame';
for (const x of [-3.5, 3.5]) { const horizontalGeometry = resources.own('geometry', new THREE.BoxGeometry(8.42, 0.12, 0.32));
const thruster = new THREE.Mesh(thrusterGeometry, thrusterMaterial); const verticalGeometry = resources.own('geometry', new THREE.BoxGeometry(0.12, 2.35, 0.32));
thruster.rotation.x = Math.PI / 2; for (const y of [-1.175, 1.175]) {
thruster.position.set(x, -1.22, 0.08); const rail = new THREE.Mesh(horizontalGeometry, frameMaterial);
group.add(thruster); 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 target: SectionId | null = null;
let narrowViewport = false;
const updateTexture = (id: SectionId): void => { const updateTexture = (id: SectionId): void => {
if (target === id) return; if (target === id) return;
target = id; target = id;
if (!context) return; face.userData.textureUpdateCount += 1;
context.fillStyle = '#07111b'; if (context) drawLabel(context, routeLabelFor(id), canvas.width, canvas.height);
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);
texture.needsUpdate = true; texture.needsUpdate = true;
}; };
const applyOpacity = (opacity: number): void => {
for (const material of fadeMaterials) material.opacity = opacity;
};
return { return {
group, group,
update(sign: SignSnapshot | null) { setViewportAspect(aspect) {
group.visible = sign !== null && sign.opacity > 0; narrowViewport = !Number.isFinite(aspect) || aspect < 0.85;
if (!sign) return; },
update(sign) {
if (!sign) {
group.visible = false;
applyOpacity(0);
return;
}
updateTexture(sign.targetId); updateTexture(sign.targetId);
material.opacity = sign.opacity; const opacity = clamp01(sign.opacity);
const phaseOffset = sign.phase === 'anticipate' applyOpacity(opacity);
? -30 group.visible = opacity > 0;
: sign.phase === 'approach' const progress = smoothstep(clamp01(sign.progress));
? -26 + sign.progress * 21 const dwell = narrowViewport
: sign.phase === 'dwell' ? { x: 3.1, y: 3.25, z: -20, scale: 0.68 }
? -5 : { x: 8.25, y: 3.1, z: -8.5, scale: 1 };
: -5 + sign.progress * 18; const horizon = narrowViewport
group.position.set(7.2, 4.1, phaseOffset); ? { x: 4.2, y: 4.1, z: -88, scale: 0.2 }
group.rotation.y = -0.12; : { 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);
}

View File

@@ -89,7 +89,7 @@ test('draws only the independently eligible next roadside sign during passive fo
const viewportHeight = window.visualViewport?.height ?? window.innerHeight; const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
const headingTop = profile.getBoundingClientRect().top + window.scrollY; const headingTop = profile.getBoundingClientRect().top + window.scrollY;
const destination = Math.max(1, headingTop - viewportHeight * (0.35 + 0.8)); const destination = Math.max(1, headingTop - viewportHeight * (0.35 + 0.8));
return { destination, viewportHeight, expectedLabel: '01 / PROFILE' }; return { destination, viewportHeight, expectedLabel: 'Profile' };
}); });
await page.evaluate(() => window.dispatchEvent(new WheelEvent('wheel', { deltaY: 1 }))); await page.evaluate(() => window.dispatchEvent(new WheelEvent('wheel', { deltaY: 1 })));
for (let y = 1; y < oracle.destination; y += oracle.viewportHeight * 0.5) { for (let y = 1; y < oracle.destination; y += oracle.viewportHeight * 0.5) {

View File

@@ -78,13 +78,23 @@ export async function installSceneInstrumentation(page: Page): Promise<void> {
}, { once: true }); }, { once: true });
const nativeFillText = CanvasRenderingContext2D.prototype.fillText; const nativeFillText = CanvasRenderingContext2D.prototype.fillText;
const routeLabels = new Set([
'Start',
'Profile',
'Experience',
'Infrastructure',
'Selected builds',
'Additional work',
'Toolkit',
'Contact',
]);
CanvasRenderingContext2D.prototype.fillText = function fillText( CanvasRenderingContext2D.prototype.fillText = function fillText(
text: string, text: string,
x: number, x: number,
y: number, y: number,
maxWidth?: number, maxWidth?: number,
): void { ): void {
if (/^0[0-7] \/ /u.test(text)) state.routeLabelsDrawn.push(text); if (routeLabels.has(text)) state.routeLabelsDrawn.push(text);
if (maxWidth === undefined) nativeFillText.call(this, text, x, y); if (maxWidth === undefined) nativeFillText.call(this, text, x, y);
else nativeFillText.call(this, text, x, y, maxWidth); else nativeFillText.call(this, text, x, y, maxWidth);
}; };

View File

@@ -192,9 +192,10 @@ describe('scene render quality', () => {
}); });
it('keeps route sign labels canonical and compact', () => { it('keeps route sign labels canonical and compact', () => {
expect(routeLabelFor('start')).toBe('00 / START'); expect(routeLabelFor('start')).toBe('Start');
expect(routeLabelFor('infrastructure')).toBe('03 / INFRASTRUCTURE'); expect(routeLabelFor('infrastructure')).toBe('Infrastructure');
expect(routeLabelFor('contact')).toBe('07 / DESTINATION'); expect(routeLabelFor('selected-builds')).toBe('Selected builds');
expect(routeLabelFor('contact')).toBe('Contact');
}); });
it('keeps the chase car recognizably wide and fully inside the desktop view', () => { it('keeps the chase car recognizably wide and fully inside the desktop view', () => {

View File

@@ -15,6 +15,7 @@ import { createAtmosphere } from '@/scene/world/atmosphere';
import { createPostProcessor } from '@/scene/post-processing'; import { createPostProcessor } from '@/scene/post-processing';
import { createCar } from '@/scene/world/car'; import { createCar } from '@/scene/world/car';
import { createChaseCamera } from '@/scene/world/environment'; import { createChaseCamera } from '@/scene/world/environment';
import { createSign, routeLabelFor } from '@/scene/world/sign';
describe('procedural world models', () => { describe('procedural world models', () => {
it('scales geometry and atmosphere work monotonically by live quality tier', () => { it('scales geometry and atmosphere work monotonically by live quality tier', () => {
@@ -299,6 +300,61 @@ describe('procedural world models', () => {
expect(resources.snapshot()).toEqual(allocation); expect(resources.snapshot()).toEqual(allocation);
resources.dispose(); resources.dispose();
}); });
it('renders one physical roadside sign and updates its plain label once per target', () => {
const resources = new SceneResourceBag();
const sign = createSign(resources);
for (const name of [
'sign-backplate',
'sign-face',
'sign-frame',
'sign-post-left',
'sign-post-right',
]) {
expect(sign.group.getObjectByName(name), name).toBeDefined();
}
const face = sign.group.getObjectByName('sign-face') as {
userData: { textureUpdateCount: number };
material: { opacity: number };
};
const allocation = resources.snapshot();
sign.update(null);
expect((sign.group as { visible: boolean }).visible).toBe(false);
sign.update({
targetId: 'infrastructure',
phase: 'anticipate',
progress: 0,
opacity: 0,
});
expect((sign.group as { visible: boolean }).visible).toBe(false);
expect(face.userData.textureUpdateCount).toBe(1);
expect(routeLabelFor('infrastructure')).toBe('Infrastructure');
sign.update({
targetId: 'infrastructure',
phase: 'approach',
progress: 0.5,
opacity: 0.7,
});
expect((sign.group as { visible: boolean }).visible).toBe(true);
expect(face.userData.textureUpdateCount).toBe(1);
expect(face.material.opacity).toBeCloseTo(0.7, 6);
const desktopX = (sign.group as { position: { x: number } }).position.x;
sign.setViewportAspect(0.6);
sign.update({
targetId: 'selected-builds',
phase: 'dwell',
progress: 1,
opacity: 1,
});
expect(face.userData.textureUpdateCount).toBe(2);
expect(routeLabelFor('selected-builds')).toBe('Selected builds');
expect((sign.group as { position: { x: number } }).position.x).toBeLessThan(desktopX);
expect(resources.snapshot()).toEqual(allocation);
resources.dispose();
});
}); });
function paletteSnapshot(palette: ReturnType<typeof createMutableChapterPalette>) { function paletteSnapshot(palette: ReturnType<typeof createMutableChapterPalette>) {