feat: evolve portfolio into an authored midnight grand tour

This commit is contained in:
2026-09-05 03:55:18 +03:00
parent 642f5b583d
commit c9ac36e249
41 changed files with 812 additions and 65 deletions

View File

@@ -15,6 +15,8 @@ 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';
import {
createMutableChapterPalette,
@@ -27,9 +29,9 @@ declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
export function createChaseCamera(aspect = 1) {
const camera = new THREE.PerspectiveCamera(66, aspect, 0.1, 1_200);
camera.position.set(0, 8.6, 22);
camera.lookAt(0, 4.8, -128);
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);
return camera;
}
@@ -70,6 +72,8 @@ export async function createProceduralSceneGeneration(
const scene = new THREE.Scene();
scene.name = 'world-root';
scene.environment = createCoachworkEnvironment(context.resources, renderer);
scene.environmentIntensity = 0.85;
const currentPalette = createMutableChapterPalette('deep-night');
const targetPalette = createMutableChapterPalette('deep-night');
const background = currentPalette.sky.clone();
@@ -121,8 +125,10 @@ export async function createProceduralSceneGeneration(
y: camera.position.y,
z: camera.position.z,
targetX: 0,
targetY: 4.8,
targetZ: -128,
targetY: 3.9,
targetZ: -90,
shift: 0.2,
verticalShift: 0,
fov: camera.fov,
roll: 0,
};
@@ -145,8 +151,10 @@ export async function createProceduralSceneGeneration(
camera.updateProjectionMatrix();
if (lastNow === null) {
const wideComposition = camera.aspect > 1.18;
cameraState.x = wideComposition ? -1.35 : 0;
cameraState.targetX = wideComposition ? 1.6 : 0;
cameraState.x = wideComposition ? -2.6 : 0;
cameraState.targetX = 0;
cameraState.shift = wideComposition ? 0.2 : 0;
cameraState.verticalShift = wideComposition ? 0 : 0.16;
camera.position.set(cameraState.x, cameraState.y, cameraState.z);
camera.lookAt(
cameraState.targetX,
@@ -241,18 +249,22 @@ export async function createProceduralSceneGeneration(
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.copy(currentPalette.terrainHigh).lerp(currentPalette.haze, 0.22);
carKeyLight.intensity = 21 + response.bloomEnergy * 3.6;
carFillLight.color.copy(currentPalette.haze).lerp(currentPalette.sun, 0.18);
carKeyLight.color.setHex(0xb9e3ef);
carKeyLight.intensity = 32 + response.bloomEnergy * 3.6;
carFillLight.color.setHex(0x809ec1);
carFillLight.intensity = 12.5 + currentPalette.warmth * 2.8;
rearLight.intensity = 4.2 + response.suspensionEnergy * 1.4;
const wideComposition = camera.aspect > 1.18;
const cameraX = wideComposition ? -1.35 : 0;
const targetX = wideComposition ? 1.6 : 0;
const composition = CHAPTER_COMPOSITIONS[snapshot.activeSectionId];
// 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.2);
const cameraMix = 1 - Math.exp(-deltaSeconds / 0.85);
cameraState.x = THREE.MathUtils.lerp(
cameraState.x,
cameraX - response.directionBias * 0.16,
@@ -260,12 +272,12 @@ export async function createProceduralSceneGeneration(
);
cameraState.y = THREE.MathUtils.lerp(
cameraState.y,
8.6 + roadVibration,
(wideComposition ? composition.y : 6.5) + roadVibration,
cameraMix,
);
cameraState.z = THREE.MathUtils.lerp(
cameraState.z,
22 - response.cameraEnergy * 1.65,
(wideComposition ? composition.z : 29) - response.cameraEnergy * 1.2,
cameraMix,
);
cameraState.targetX = THREE.MathUtils.lerp(
@@ -275,17 +287,17 @@ export async function createProceduralSceneGeneration(
);
cameraState.targetY = THREE.MathUtils.lerp(
cameraState.targetY,
4.8 + response.cameraEnergy * 0.58,
composition.targetY + response.cameraEnergy * 0.32,
cameraMix,
);
cameraState.targetZ = THREE.MathUtils.lerp(
cameraState.targetZ,
-128 - response.cameraEnergy * 24,
composition.targetZ - response.cameraEnergy * 16,
cameraMix,
);
cameraState.fov = THREE.MathUtils.lerp(
cameraState.fov,
66 + response.cameraEnergy * 13,
(wideComposition ? composition.fov : 62) + response.cameraEnergy * 7,
cameraMix,
);
cameraState.roll = THREE.MathUtils.lerp(
@@ -293,6 +305,9 @@ 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);
camera.position.set(cameraState.x, cameraState.y, cameraState.z);
camera.lookAt(cameraState.targetX, cameraState.targetY, cameraState.targetZ);
camera.rotation.z += cameraState.roll;
@@ -327,6 +342,11 @@ export async function createProceduralSceneGeneration(
dispose() {
if (disposed) return;
disposed = true;
// Quality changes reuse the canvas context. Leave texture upload flags at
// defaults before the next renderer creates its empty array textures.
const gl = renderer.getContext();
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
scene.clear();
},
};