feat: refine nightdrive world and authored car

Integrate the Blender grand tourer with a cohesive shader-driven road, terrain, atmosphere, lighting, and post-processing pass. Refine responsive drive dynamics and sign lifecycle behavior, and add durable visual, accessibility, and performance verification evidence.
This commit is contained in:
2026-09-02 23:16:25 +03:00
parent 7dae835ac5
commit bc18bc2043
73 changed files with 1555 additions and 293 deletions

View File

@@ -6,10 +6,11 @@ import { renderQualityFor } from '@/scene/render-quality';
import { createRoad } from '@/scene/world/road';
import { createTerrain } from '@/scene/world/terrain';
import { createSun } from '@/scene/world/sun';
import { createCar } from '@/scene/world/car';
import { createCar, loadGrandTourer } from '@/scene/world/car';
import { createSign } from '@/scene/world/sign';
import { createAtmosphere } from '@/scene/world/atmosphere';
import { createPostProcessor } from '@/scene/post-processing';
import { createDriveDynamics } from '@/scene/drive-dynamics';
import {
createMutableChapterPalette,
writeChapterPalette,
@@ -21,9 +22,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(70, aspect, 0.1, 320);
camera.position.set(0, 4.8, 12.5);
camera.lookAt(0, 1.6, -22);
const camera = new THREE.PerspectiveCamera(66, aspect, 0.1, 900);
camera.position.set(0, 8.6, 22);
camera.lookAt(0, 4.8, -128);
return camera;
}
@@ -59,11 +60,12 @@ export async function createProceduralSceneGeneration(
renderer.toneMappingExposure = 1.05;
const scene = new THREE.Scene();
scene.name = 'world-root';
const currentPalette = createMutableChapterPalette('deep-night');
const targetPalette = createMutableChapterPalette('deep-night');
const background = currentPalette.sky.clone();
scene.background = background;
scene.fog = new THREE.FogExp2(currentPalette.fog.getHex(), 0.012);
scene.fog = new THREE.FogExp2(currentPalette.fog.getHex(), 0.0024);
const camera = createChaseCamera();
const ambient = new THREE.HemisphereLight(0x6aa9c5, 0x100812, 1.25);
@@ -71,11 +73,20 @@ export async function createProceduralSceneGeneration(
const rearLight = new THREE.PointLight(0xff365f, 5, 18);
rearLight.position.set(0, 1.2, 7);
scene.add(rearLight);
const carKeyLight = new THREE.PointLight(0x72dce8, 18, 34, 2);
carKeyLight.name = 'car-key-light';
carKeyLight.position.set(-4.8, 5.4, 9.5);
scene.add(carKeyLight);
const carFillLight = new THREE.PointLight(0xb83f93, 11, 28, 2);
carFillLight.name = 'car-fill-light';
carFillLight.position.set(4.2, 3.4, 8);
scene.add(carFillLight);
const road = createRoad(context.resources, quality);
const terrain = createTerrain(context.resources, quality);
const sun = createSun(context.resources);
const car = createCar(context.resources);
const authoredCar = await loadGrandTourer(context.signal);
const car = createCar(context.resources, authoredCar);
car.setDetail(mobileOrTablet ? 'reduced' : 'full');
const sign = createSign(context.resources);
const atmosphere = createAtmosphere(context.resources, quality);
@@ -91,6 +102,17 @@ export async function createProceduralSceneGeneration(
const postProcessor = quality.postEffects
? createPostProcessor(context.resources, renderer)
: null;
const dynamics = createDriveDynamics();
const cameraState = {
x: camera.position.x,
y: camera.position.y,
z: camera.position.z,
targetX: 0,
targetY: 4.8,
targetZ: -128,
fov: camera.fov,
roll: 0,
};
let width = 0;
let height = 0;
let driveDistance = 0;
@@ -108,9 +130,21 @@ export async function createProceduralSceneGeneration(
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
if (lastNow === null) {
const wideComposition = camera.aspect > 1.18;
cameraState.x = wideComposition ? -1.35 : 0;
cameraState.targetX = wideComposition ? 1.6 : 0;
camera.position.set(cameraState.x, cameraState.y, cameraState.z);
camera.lookAt(
cameraState.targetX,
cameraState.targetY,
cameraState.targetZ,
);
}
car.setDetail(width < 1_100 || height < 620 ? 'reduced' : 'full');
car.group.visible = height >= 340;
sign.setViewportAspect(camera.aspect);
atmosphere.setViewportAspect(camera.aspect);
postProcessor?.resize(width, height, quality.pixelRatio * quality.postScale);
if (TEST_BUILD && context.quality !== 'medium') {
window.__PORTFOLIO_PERFORMANCE__?.recordRuntimeAllocation(context.resources.gpuBudgetSnapshot({
@@ -152,35 +186,105 @@ export async function createProceduralSceneGeneration(
resize();
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();
writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0);
dampPalette(currentPalette, targetPalette, deltaSeconds);
driveDistance += snapshot.driveSpeed * deltaSeconds * 18;
road.update(driveDistance, snapshot.driveSpeed, snapshot.acceleration);
driveDistance += deltaSeconds * (
18
+ snapshot.driveSpeed * 54
+ response.roadEnergy * 48
);
const visualSpeed = THREE.MathUtils.clamp(
0.18 + response.roadEnergy * 0.82,
0.18,
1,
);
road.update(driveDistance, visualSpeed, response.roadEnergy);
terrain.update(
driveDistance,
snapshot.driveSpeed,
visualSpeed,
snapshot.chapter,
snapshot.routeProgress,
currentPalette,
);
sun.update(currentPalette, snapshot.routeProgress);
car.update(snapshot.driveSpeed, snapshot.acceleration, now / 1000);
const elapsedSeconds = now / 1000;
sun.update(currentPalette, snapshot.routeProgress, elapsedSeconds);
car.update(visualSpeed, response.suspensionEnergy, elapsedSeconds);
sign.update(snapshot.sign);
atmosphere.update(
driveDistance,
snapshot.driveSpeed,
snapshot.acceleration,
visualSpeed,
response.streakEnergy,
currentPalette,
);
background.copy(currentPalette.sky);
scene.fog.color.copy(currentPalette.fog);
scene.fog.density = 0.0105 + (1 - currentPalette.warmth) * 0.0025;
const fov = 70 + Math.min(5, snapshot.acceleration * 4.2);
if (camera.fov !== fov) {
camera.fov = fov;
scene.fog.density = 0.0019 + (1 - currentPalette.warmth) * 0.0008;
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);
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 roadVibration = Math.sin(driveDistance * 0.105) * (
0.012 + response.roadEnergy * 0.024
);
const cameraMix = 1 - Math.exp(-deltaSeconds / 0.2);
cameraState.x = THREE.MathUtils.lerp(
cameraState.x,
cameraX - response.directionBias * 0.16,
cameraMix,
);
cameraState.y = THREE.MathUtils.lerp(
cameraState.y,
8.6 + roadVibration,
cameraMix,
);
cameraState.z = THREE.MathUtils.lerp(
cameraState.z,
22 - response.cameraEnergy * 1.65,
cameraMix,
);
cameraState.targetX = THREE.MathUtils.lerp(
cameraState.targetX,
targetX + response.directionBias * 0.32,
cameraMix,
);
cameraState.targetY = THREE.MathUtils.lerp(
cameraState.targetY,
4.8 + response.cameraEnergy * 0.58,
cameraMix,
);
cameraState.targetZ = THREE.MathUtils.lerp(
cameraState.targetZ,
-128 - response.cameraEnergy * 24,
cameraMix,
);
cameraState.fov = THREE.MathUtils.lerp(
cameraState.fov,
66 + response.cameraEnergy * 13,
cameraMix,
);
cameraState.roll = THREE.MathUtils.lerp(
cameraState.roll,
-response.directionBias * 0.003 + roadVibration * 0.018,
cameraMix,
);
camera.position.set(cameraState.x, cameraState.y, cameraState.z);
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();
}
if (postProcessor) {
postProcessor.setDynamics(response.bloomEnergy);
postProcessor.render(scene, camera);
} else {
renderer.render(scene, camera);