feat: sculpt portfolio into an immersive midnight expressway
Author physical road furniture, layered terrain and atmosphere, projected illuminated route signs, and detailed Blender coachwork. Refine camera mass, wheel contact, chapter composition, and compact reading space while preserving approved content and audio contracts. Include isolated Blender source, before/after and hardware Chrome motion evidence, preservation hashes, validated preview checks, and documented inherited unit failures.
This commit is contained in:
@@ -3,6 +3,9 @@ import type { JourneySnapshot } from '@/journey/types';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import type { SceneBuildContext, SceneGeneration } from '@/scene/types';
|
||||
import { renderQualityFor } from '@/scene/render-quality';
|
||||
import { createHorizonAir } from '@/scene/world/horizon-air';
|
||||
import { createExpressway } from '@/scene/world/expressway';
|
||||
import { createSignStage } from '@/scene/world/sign-stage';
|
||||
import { createRoad } from '@/scene/world/road';
|
||||
import { createTerrain } from '@/scene/world/terrain';
|
||||
import { createSun } from '@/scene/world/sun';
|
||||
@@ -11,10 +14,7 @@ import { createSign, prepareSignTypography } from '@/scene/world/sign';
|
||||
import { createAtmosphere } from '@/scene/world/atmosphere';
|
||||
import { createPostProcessor } from '@/scene/post-processing';
|
||||
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 { createCoachworkEnvironment } from '@/scene/world/coachwork-light';
|
||||
import { createTravelDistance } from '@/scene/world/travel-distance';
|
||||
@@ -26,12 +26,14 @@ import {
|
||||
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
|
||||
|
||||
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
||||
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
|
||||
const TEST_BUILD =
|
||||
typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
|
||||
|
||||
export function createChaseCamera(aspect = 1) {
|
||||
const camera = new THREE.PerspectiveCamera(56, aspect, 0.1, 1_200);
|
||||
camera.position.set(-2.6, 6.4, 21);
|
||||
camera.lookAt(0, 3.9, -90);
|
||||
const c = CHAPTER_COMPOSITIONS.start;
|
||||
const camera = new THREE.PerspectiveCamera(c.fov, aspect, 0.1, 1_200);
|
||||
camera.position.set(c.x, c.y, c.z);
|
||||
camera.lookAt(0, c.targetY, c.targetZ);
|
||||
return camera;
|
||||
}
|
||||
|
||||
@@ -54,17 +56,27 @@ export async function createProceduralSceneGeneration(
|
||||
recordTestFaultEvent('car-rejected');
|
||||
throw new Error('Injected car failure');
|
||||
}
|
||||
if (context.signal.aborted) throw new DOMException('Scene build aborted', 'AbortError');
|
||||
if (context.signal.aborted)
|
||||
throw new DOMException('Scene build aborted', 'AbortError');
|
||||
const canvas = context.canvas as HTMLCanvasElement;
|
||||
if (typeof canvas.getContext !== 'function') throw new Error('A canvas element is required');
|
||||
const mobileOrTablet = Math.max(canvas.clientWidth || 0, canvas.clientHeight || 0) < 1280;
|
||||
const quality = renderQualityFor(context.quality, globalThis.devicePixelRatio ?? 1, mobileOrTablet);
|
||||
const renderer = context.resources.own('renderer', new THREE.WebGLRenderer({
|
||||
canvas,
|
||||
alpha: false,
|
||||
antialias: context.quality !== 'low',
|
||||
powerPreference: 'high-performance',
|
||||
}));
|
||||
if (typeof canvas.getContext !== 'function')
|
||||
throw new Error('A canvas element is required');
|
||||
const mobileOrTablet =
|
||||
Math.max(canvas.clientWidth || 0, canvas.clientHeight || 0) < 1280;
|
||||
const quality = renderQualityFor(
|
||||
context.quality,
|
||||
globalThis.devicePixelRatio ?? 1,
|
||||
mobileOrTablet,
|
||||
);
|
||||
const renderer = context.resources.own(
|
||||
'renderer',
|
||||
new THREE.WebGLRenderer({
|
||||
canvas,
|
||||
alpha: false,
|
||||
antialias: context.quality !== 'low',
|
||||
powerPreference: 'high-performance',
|
||||
}),
|
||||
);
|
||||
renderer.setPixelRatio(quality.pixelRatio);
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
@@ -80,6 +92,8 @@ export async function createProceduralSceneGeneration(
|
||||
scene.background = background;
|
||||
scene.fog = createWorldFog(currentPalette.fog.getHex());
|
||||
const camera = createChaseCamera();
|
||||
camera.name = 'journey-camera';
|
||||
scene.add(camera);
|
||||
|
||||
const ambient = new THREE.HemisphereLight(0x6aa9c5, 0x100812, 1.25);
|
||||
scene.add(ambient);
|
||||
@@ -97,7 +111,10 @@ export async function createProceduralSceneGeneration(
|
||||
|
||||
const road = createRoad(context.resources, quality);
|
||||
const terrain = createTerrain(context.resources, quality);
|
||||
const expressway = createExpressway(context.resources, quality);
|
||||
const signStage = createSignStage(context.resources);
|
||||
const sun = createSun(context.resources);
|
||||
const horizonAir = createHorizonAir(context.resources);
|
||||
const authoredCar = await loadGrandTourer(context.signal);
|
||||
const car = createCar(context.resources, authoredCar);
|
||||
car.setDetail(mobileOrTablet ? 'reduced' : 'full');
|
||||
@@ -110,8 +127,10 @@ export async function createProceduralSceneGeneration(
|
||||
scene.add(
|
||||
atmosphere.group,
|
||||
road.group,
|
||||
expressway.group,
|
||||
terrain.group,
|
||||
sun.group,
|
||||
horizonAir.group,
|
||||
car.group,
|
||||
sign.group,
|
||||
);
|
||||
@@ -127,7 +146,7 @@ export async function createProceduralSceneGeneration(
|
||||
targetX: 0,
|
||||
targetY: 3.9,
|
||||
targetZ: -90,
|
||||
shift: 0.2,
|
||||
shift: CHAPTER_COMPOSITIONS.start.shift,
|
||||
verticalShift: 0,
|
||||
fov: camera.fov,
|
||||
roll: 0,
|
||||
@@ -138,6 +157,7 @@ export async function createProceduralSceneGeneration(
|
||||
let lastNow: number | null = null;
|
||||
let disposed = false;
|
||||
let renderedFrames = 0;
|
||||
let atmosphericTime = 0;
|
||||
let firstFrameSuppressionRecorded = false;
|
||||
|
||||
const resize = (): void => {
|
||||
@@ -151,9 +171,11 @@ export async function createProceduralSceneGeneration(
|
||||
camera.updateProjectionMatrix();
|
||||
if (lastNow === null) {
|
||||
const wideComposition = camera.aspect > 1.18;
|
||||
cameraState.x = wideComposition ? -2.6 : 0;
|
||||
cameraState.x = wideComposition ? CHAPTER_COMPOSITIONS.start.x : 0;
|
||||
cameraState.targetX = 0;
|
||||
cameraState.shift = wideComposition ? 0.2 : 0;
|
||||
cameraState.shift = wideComposition
|
||||
? CHAPTER_COMPOSITIONS.start.shift
|
||||
: 0;
|
||||
cameraState.verticalShift = wideComposition ? 0 : 0.16;
|
||||
camera.position.set(cameraState.x, cameraState.y, cameraState.z);
|
||||
camera.lookAt(
|
||||
@@ -166,18 +188,24 @@ export async function createProceduralSceneGeneration(
|
||||
car.group.visible = height >= 340;
|
||||
sign.setViewportSize(width, height);
|
||||
atmosphere.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') {
|
||||
window.__PORTFOLIO_PERFORMANCE__?.recordRuntimeAllocation(context.resources.gpuBudgetSnapshot({
|
||||
tier: context.quality,
|
||||
defaultFramebuffer: {
|
||||
width: Math.max(1, canvas.width),
|
||||
height: Math.max(1, canvas.height),
|
||||
bytesPerPixel: 4,
|
||||
attachments: 2,
|
||||
samples: context.quality === 'low' ? 1 : 4,
|
||||
},
|
||||
}));
|
||||
window.__PORTFOLIO_PERFORMANCE__?.recordRuntimeAllocation(
|
||||
context.resources.gpuBudgetSnapshot({
|
||||
tier: context.quality,
|
||||
defaultFramebuffer: {
|
||||
width: Math.max(1, canvas.width),
|
||||
height: Math.max(1, canvas.height),
|
||||
bytesPerPixel: 4,
|
||||
attachments: 2,
|
||||
samples: context.quality === 'low' ? 1 : 4,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -197,15 +225,18 @@ export async function createProceduralSceneGeneration(
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
TEST_BUILD
|
||||
&& isTestFault('F3-post-first-frame-exception')
|
||||
&& renderedFrames > 0
|
||||
TEST_BUILD &&
|
||||
isTestFault('F3-post-first-frame-exception') &&
|
||||
renderedFrames > 0
|
||||
) {
|
||||
recordTestFaultEvent('post-first-frame-exception');
|
||||
throw new Error('Injected post-first-frame exception');
|
||||
}
|
||||
resize();
|
||||
const deltaSeconds = lastNow === null ? 0 : Math.min(0.1, Math.max(0, (now - lastNow) / 1000));
|
||||
const deltaSeconds =
|
||||
lastNow === null
|
||||
? 0
|
||||
: Math.min(0.1, Math.max(0, (now - lastNow) / 1000));
|
||||
lastNow = now;
|
||||
dynamics.update(snapshot.acceleration, snapshot.direction, deltaSeconds);
|
||||
const response = dynamics.snapshot();
|
||||
@@ -221,7 +252,19 @@ export async function createProceduralSceneGeneration(
|
||||
0.18,
|
||||
1,
|
||||
);
|
||||
road.update(driveDistance, visualSpeed, response.roadEnergy, currentPalette);
|
||||
road.update(
|
||||
driveDistance,
|
||||
visualSpeed,
|
||||
response.roadEnergy,
|
||||
currentPalette,
|
||||
);
|
||||
expressway.update(
|
||||
driveDistance,
|
||||
response.roadEnergy,
|
||||
currentPalette.warmth,
|
||||
snapshot.activeSectionId,
|
||||
deltaSeconds,
|
||||
);
|
||||
terrain.update(
|
||||
driveDistance,
|
||||
visualSpeed,
|
||||
@@ -229,14 +272,21 @@ export async function createProceduralSceneGeneration(
|
||||
snapshot.routeProgress,
|
||||
currentPalette,
|
||||
);
|
||||
const elapsedSeconds = now / 1000;
|
||||
atmosphericTime += deltaSeconds;
|
||||
const elapsedSeconds = atmosphericTime;
|
||||
horizonAir.update(currentPalette, elapsedSeconds);
|
||||
sun.update(
|
||||
currentPalette,
|
||||
snapshot.routeProgress,
|
||||
elapsedSeconds,
|
||||
response.bloomEnergy,
|
||||
);
|
||||
car.update(visualSpeed, response.suspensionEnergy, elapsedSeconds);
|
||||
car.update(
|
||||
visualSpeed,
|
||||
response.suspensionEnergy,
|
||||
elapsedSeconds,
|
||||
driveDistance,
|
||||
);
|
||||
sign.update(snapshot.sign, now);
|
||||
atmosphere.update(
|
||||
driveDistance,
|
||||
@@ -246,7 +296,9 @@ export async function createProceduralSceneGeneration(
|
||||
);
|
||||
background.copy(currentPalette.sky);
|
||||
scene.fog.color.copy(currentPalette.fog);
|
||||
ambient.color.copy(currentPalette.haze).lerp(currentPalette.terrainHigh, 0.26);
|
||||
ambient.color
|
||||
.copy(currentPalette.haze)
|
||||
.lerp(currentPalette.terrainHigh, 0.26);
|
||||
ambient.groundColor.copy(currentPalette.fog);
|
||||
ambient.intensity = 1.05 + currentPalette.warmth * 0.18;
|
||||
carKeyLight.color.setHex(0xb9e3ef);
|
||||
@@ -255,16 +307,63 @@ export async function createProceduralSceneGeneration(
|
||||
carFillLight.intensity = 12.5 + currentPalette.warmth * 2.8;
|
||||
rearLight.intensity = 4.2 + response.suspensionEnergy * 1.4;
|
||||
const wideComposition = camera.aspect > 1.18;
|
||||
const composition = CHAPTER_COMPOSITIONS[snapshot.activeSectionId];
|
||||
const baseComposition = CHAPTER_COMPOSITIONS[snapshot.activeSectionId];
|
||||
const upcoming =
|
||||
snapshot.sign && snapshot.sign.phase !== 'depart'
|
||||
? CHAPTER_COMPOSITIONS[snapshot.sign.targetId]
|
||||
: baseComposition;
|
||||
const anticipation =
|
||||
snapshot.sign?.phase === 'approach'
|
||||
? Math.min(0.55, snapshot.sign.progress * 0.55)
|
||||
: snapshot.sign?.phase === 'dwell'
|
||||
? 0.55
|
||||
: 0;
|
||||
const composition = {
|
||||
x: THREE.MathUtils.lerp(baseComposition.x, upcoming.x, anticipation),
|
||||
y: THREE.MathUtils.lerp(baseComposition.y, upcoming.y, anticipation),
|
||||
z: THREE.MathUtils.lerp(baseComposition.z, upcoming.z, anticipation),
|
||||
targetY: THREE.MathUtils.lerp(
|
||||
baseComposition.targetY,
|
||||
upcoming.targetY,
|
||||
anticipation,
|
||||
),
|
||||
targetZ: THREE.MathUtils.lerp(
|
||||
baseComposition.targetZ,
|
||||
upcoming.targetZ,
|
||||
anticipation,
|
||||
),
|
||||
fov: THREE.MathUtils.lerp(
|
||||
baseComposition.fov,
|
||||
upcoming.fov,
|
||||
anticipation,
|
||||
),
|
||||
shift: THREE.MathUtils.lerp(
|
||||
baseComposition.shift,
|
||||
upcoming.shift,
|
||||
anticipation,
|
||||
),
|
||||
};
|
||||
// The document stays native. Only the rendered lens eases to the chapter's safe zone.
|
||||
const cameraX = wideComposition ? composition.x : 0;
|
||||
const targetX = 0;
|
||||
const targetShift = wideComposition ? composition.shift : 0;
|
||||
const targetVerticalShift = wideComposition ? 0 : (snapshot.activeSectionId === 'start' ? 0.16 : 0.07);
|
||||
const roadVibration = Math.sin(driveDistance * 0.105) * (
|
||||
0.012 + response.roadEnergy * 0.024
|
||||
);
|
||||
const cameraMix = 1 - Math.exp(-deltaSeconds / 0.85);
|
||||
const targetShift =
|
||||
height < 560 && width >= 768
|
||||
? 0.22
|
||||
: wideComposition
|
||||
? composition.shift
|
||||
: 0;
|
||||
const targetVerticalShift = wideComposition
|
||||
? 0
|
||||
: snapshot.activeSectionId === 'start'
|
||||
? 0.16
|
||||
: 0.07;
|
||||
const roadVibration =
|
||||
Math.sin(driveDistance * 0.105) * (0.012 + response.roadEnergy * 0.024);
|
||||
const cameraMix =
|
||||
1 -
|
||||
Math.exp(
|
||||
-deltaSeconds / (snapshot.activeSectionId === 'contact' ? 1.5 : 1.05),
|
||||
);
|
||||
cameraState.x = THREE.MathUtils.lerp(
|
||||
cameraState.x,
|
||||
cameraX - response.directionBias * 0.16,
|
||||
@@ -277,7 +376,7 @@ export async function createProceduralSceneGeneration(
|
||||
);
|
||||
cameraState.z = THREE.MathUtils.lerp(
|
||||
cameraState.z,
|
||||
(wideComposition ? composition.z : 29) - response.cameraEnergy * 1.2,
|
||||
(wideComposition ? composition.z : 31) + response.cameraEnergy * 0.9,
|
||||
cameraMix,
|
||||
);
|
||||
cameraState.targetX = THREE.MathUtils.lerp(
|
||||
@@ -297,7 +396,7 @@ export async function createProceduralSceneGeneration(
|
||||
);
|
||||
cameraState.fov = THREE.MathUtils.lerp(
|
||||
cameraState.fov,
|
||||
(wideComposition ? composition.fov : 62) + response.cameraEnergy * 7,
|
||||
(wideComposition ? composition.fov : 61) + response.cameraEnergy * 5.5,
|
||||
cameraMix,
|
||||
);
|
||||
cameraState.roll = THREE.MathUtils.lerp(
|
||||
@@ -305,30 +404,53 @@ export async function createProceduralSceneGeneration(
|
||||
-response.directionBias * 0.003 + roadVibration * 0.018,
|
||||
cameraMix,
|
||||
);
|
||||
cameraState.shift = THREE.MathUtils.lerp(cameraState.shift, targetShift, cameraMix);
|
||||
cameraState.verticalShift = THREE.MathUtils.lerp(cameraState.verticalShift, targetVerticalShift, cameraMix);
|
||||
camera.setViewOffset(width, height, -width * cameraState.shift, -height * cameraState.verticalShift, width, height);
|
||||
cameraState.shift = THREE.MathUtils.lerp(
|
||||
cameraState.shift,
|
||||
targetShift,
|
||||
cameraMix,
|
||||
);
|
||||
cameraState.verticalShift = THREE.MathUtils.lerp(
|
||||
cameraState.verticalShift,
|
||||
targetVerticalShift,
|
||||
cameraMix,
|
||||
);
|
||||
camera.setViewOffset(
|
||||
width,
|
||||
height,
|
||||
-width * cameraState.shift,
|
||||
-height * cameraState.verticalShift,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
camera.position.set(cameraState.x, cameraState.y, cameraState.z);
|
||||
camera.lookAt(cameraState.targetX, cameraState.targetY, cameraState.targetZ);
|
||||
camera.lookAt(
|
||||
cameraState.targetX,
|
||||
cameraState.targetY,
|
||||
cameraState.targetZ,
|
||||
);
|
||||
camera.rotation.z += cameraState.roll;
|
||||
if (Math.abs(camera.fov - cameraState.fov) > 0.001) {
|
||||
camera.fov = cameraState.fov;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
const stage = signStage.update(width, height);
|
||||
sign.compose(camera, width, height, stage.navigationBottom, now);
|
||||
if (TEST_BUILD && snapshot.sign !== null) {
|
||||
const presentation = sign.inspect(camera, width, height);
|
||||
if (presentation !== null) {
|
||||
window.dispatchEvent(new CustomEvent('portfolio-sign-frame', {
|
||||
detail: {
|
||||
now,
|
||||
...snapshot.sign,
|
||||
activeSectionId: snapshot.activeSectionId,
|
||||
direction: snapshot.direction,
|
||||
...presentation,
|
||||
rendererPixelRatio: renderer.getPixelRatio(),
|
||||
viewport: { width, height },
|
||||
},
|
||||
}));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('portfolio-sign-frame', {
|
||||
detail: {
|
||||
now,
|
||||
...snapshot.sign,
|
||||
activeSectionId: snapshot.activeSectionId,
|
||||
direction: snapshot.direction,
|
||||
...presentation,
|
||||
rendererPixelRatio: renderer.getPixelRatio(),
|
||||
viewport: { width, height },
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (postProcessor) {
|
||||
@@ -365,7 +487,19 @@ function dampPalette(
|
||||
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);
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user