Files
Portfolio/src/scene/world/environment.ts

578 lines
20 KiB
TypeScript

import * as THREE from 'three';
import type { JourneySnapshot } from '@/journey/types';
import { readMusicPulse } from '@/audio/music-pulse';
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 { usesRoadsideSign } from '@/scene/world/sign-layout';
import { createSpeedWake } from '@/scene/world/speed-wake';
import { createRoad } from '@/scene/world/road';
import { createTerrain } from '@/scene/world/terrain';
import { createSun } from '@/scene/world/sun';
import { createCar, loadGrandTourer } from '@/scene/world/car';
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 { CHAPTER_COMPOSITIONS } from '@/scene/world/composition';
import { createCoachworkEnvironment } from '@/scene/world/coachwork-light';
import { createTravelDistance } from '@/scene/world/travel-distance';
import {
createMutableChapterPalette,
writeChapterPalette,
type MutableChapterPalette,
} from '@/scene/world/palette';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD =
typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
export function createChaseCamera(aspect = 1) {
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;
}
export function createWorldFog(color: number) {
return new THREE.Fog(color, HORIZON_FOG_START, HORIZON_FOG_END);
}
export async function createProceduralSceneGeneration(
context: SceneBuildContext,
): Promise<SceneGeneration> {
if (TEST_BUILD && isTestFault('F1-shader')) {
recordTestFaultEvent('shader-rejected');
throw new Error('Injected shader failure');
}
if (TEST_BUILD && isTestFault('F1-texture')) {
recordTestFaultEvent('texture-rejected');
throw new Error('Injected texture failure');
}
if (TEST_BUILD && isTestFault('F1-car')) {
recordTestFaultEvent('car-rejected');
throw new Error('Injected car failure');
}
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',
}),
);
renderer.setPixelRatio(quality.pixelRatio);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
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();
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);
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 speedWake = createSpeedWake(context.resources);
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');
await prepareSignTypography(context.signal);
const sign = createSign(context.resources, {
maximumAnisotropy: renderer.capabilities.getMaxAnisotropy(),
maximumTextureSize: renderer.capabilities.maxTextureSize,
});
const atmosphere = createAtmosphere(context.resources, quality);
scene.add(
atmosphere.group,
road.group,
expressway.group,
terrain.group,
sun.group,
horizonAir.group,
car.group,
speedWake.group,
sign.group,
);
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: 3.9,
targetZ: -90,
shift: CHAPTER_COMPOSITIONS.start.shift,
verticalShift: 0,
fov: camera.fov,
roll: 0,
};
let width = 0;
let height = 0;
const travelDistance = createTravelDistance();
let lastNow: number | null = null;
let disposed = false;
let renderedFrames = 0;
let atmosphericTime = 0;
let signCameraWeight = 0;
let overdrive = 0;
let terrainSurge = 0;
let musicPulse = 0;
let terrainSpectrumPhase = 0;
let firstFrameSuppressionRecorded = false;
const resize = (): void => {
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
const nextHeight = Math.max(1, canvas.clientHeight || canvas.height || 1);
if (nextWidth === width && nextHeight === height) return;
width = nextWidth;
height = nextHeight;
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
if (lastNow === null) {
const wideComposition = camera.aspect > 1.18;
cameraState.x = wideComposition ? CHAPTER_COMPOSITIONS.start.x : 0;
cameraState.targetX = 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(
cameraState.targetX,
cameraState.targetY,
cameraState.targetZ,
);
}
car.setDetail(width < 1_100 || height < 620 ? 'reduced' : 'full');
car.group.visible = height >= 340;
sign.setViewportSize(width, height);
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({
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,
},
}),
);
}
};
if (context.signal.aborted) {
context.resources.dispose();
throw new DOMException('Scene build aborted', 'AbortError');
}
return {
render(snapshot: Readonly<JourneySnapshot>, now: number) {
if (disposed) return;
if (TEST_BUILD && isTestFault('F2-first-frame-timeout')) {
if (!firstFrameSuppressionRecorded) {
firstFrameSuppressionRecorded = true;
recordTestFaultEvent('first-frame-suppressed');
}
return false;
}
if (
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));
lastNow = now;
dynamics.update(snapshot.acceleration, snapshot.direction, deltaSeconds);
const response = dynamics.snapshot();
// Read the existing speed contract; visual charge has its own slow release
// and cannot change the soundtrack or the canonical drive dynamics.
const chargeTarget = THREE.MathUtils.smoothstep(
snapshot.driveSpeed,
0.68,
0.9,
);
overdrive +=
(chargeTarget - overdrive) *
(1 -
Math.exp(-deltaSeconds / (chargeTarget > overdrive ? 0.28 : 1.05)));
// Terrain feedback reads acceleration directly, with its own fast attack
// and long release. Audio and the shared drive responses stay untouched.
const surgeTarget = Math.sqrt(
THREE.MathUtils.clamp(snapshot.acceleration, 0, 1),
);
// Soundtrack coupling: the bass the visitor hears lifts the terrain and
// the sun. One analyser read per frame; zero when nothing is audible.
const pulseTarget = readMusicPulse();
musicPulse +=
(pulseTarget - musicPulse) *
(1 - Math.exp(-deltaSeconds / (pulseTarget > musicPulse ? 0.05 : 0.32)));
const beat = THREE.MathUtils.smoothstep(musicPulse, 0.42, 0.92);
terrainSurge +=
(Math.max(surgeTarget, beat * 0.55) - terrainSurge) *
(1 -
Math.exp(-deltaSeconds / (Math.max(surgeTarget, beat * 0.55) > terrainSurge ? 0.11 : 1.15)));
// Integrate color travel instead of multiplying elapsed time by speed:
// lifting off slows the spectrum to rest without rewinding its phase.
terrainSpectrumPhase =
(terrainSpectrumPhase + deltaSeconds * overdrive * 0.38) % 1;
writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0);
dampPalette(currentPalette, targetPalette, deltaSeconds);
const driveDistance = travelDistance.advance(
deltaSeconds,
snapshot.driveSpeed,
response.roadEnergy,
);
const visualSpeed = THREE.MathUtils.clamp(
0.18 + response.roadEnergy * 0.82,
0.18,
1,
);
road.update(
driveDistance,
visualSpeed,
response.roadEnergy,
currentPalette,
);
expressway.update(
driveDistance,
response.roadEnergy,
currentPalette.warmth,
snapshot.activeSectionId,
deltaSeconds,
);
terrain.update(
driveDistance,
visualSpeed,
snapshot.chapter,
snapshot.routeProgress,
currentPalette,
overdrive,
terrainSurge,
terrainSpectrumPhase,
);
atmosphericTime += deltaSeconds;
const elapsedSeconds = atmosphericTime;
horizonAir.update(currentPalette, elapsedSeconds);
sun.update(
currentPalette,
snapshot.routeProgress,
elapsedSeconds,
Math.min(1, response.bloomEnergy + beat * 0.35),
snapshot.arrived,
);
car.update(
visualSpeed,
response.suspensionEnergy,
elapsedSeconds,
driveDistance,
);
speedWake.update(
overdrive,
elapsedSeconds,
car.group.position,
width < 1100 || height < 620 ? 2.32 : 2.78,
);
sign.update(snapshot.sign, now);
atmosphere.update(
driveDistance,
visualSpeed,
response.streakEnergy,
currentPalette,
atmosphericTime,
overdrive,
);
background.copy(currentPalette.sky);
scene.fog.color.copy(currentPalette.fog);
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(0xb2d5ff);
carKeyLight.intensity = 32 + response.bloomEnergy * 3.6 + overdrive * 8;
carFillLight.color.setHex(0xff62ad);
carFillLight.intensity = 12.5 + currentPalette.warmth * 2.8;
rearLight.intensity = 4.2 + response.suspensionEnergy * 1.4;
const wideComposition = camera.aspect > 1.18;
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 signTarget =
usesRoadsideSign(width, height) && snapshot.sign !== null
? snapshot.sign.phase === 'depart'
? 1 - snapshot.sign.progress
: 1
: 0;
signCameraWeight +=
(signTarget - signCameraWeight) * (1 - Math.exp(-deltaSeconds / 0.48));
const targetShift =
height < 560 && width >= 768
? 0.22
: wideComposition
? THREE.MathUtils.lerp(
composition.shift,
composition.shift * 0.28,
signCameraWeight,
)
: 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,
cameraMix,
);
cameraState.y = THREE.MathUtils.lerp(
cameraState.y,
(wideComposition ? composition.y : 6.5) + roadVibration,
cameraMix,
);
cameraState.z = THREE.MathUtils.lerp(
cameraState.z,
(wideComposition ? composition.z : 31) + response.cameraEnergy * 0.9,
cameraMix,
);
cameraState.targetX = THREE.MathUtils.lerp(
cameraState.targetX,
targetX + response.directionBias * 0.32,
cameraMix,
);
cameraState.targetY = THREE.MathUtils.lerp(
cameraState.targetY,
composition.targetY + response.cameraEnergy * 0.32,
cameraMix,
);
cameraState.targetZ = THREE.MathUtils.lerp(
cameraState.targetZ,
composition.targetZ - response.cameraEnergy * 16,
cameraMix,
);
cameraState.fov = THREE.MathUtils.lerp(
cameraState.fov,
(wideComposition ? composition.fov : 61) + response.cameraEnergy * 5.5,
cameraMix,
);
cameraState.roll = THREE.MathUtils.lerp(
cameraState.roll,
-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;
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,
car.group.position,
);
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 },
},
}),
);
}
}
if (postProcessor) {
postProcessor.setDynamics(response.bloomEnergy);
postProcessor.render(scene, camera);
} else {
renderer.render(scene, camera);
}
renderedFrames += 1;
},
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();
},
};
}
function dampPalette(
current: MutableChapterPalette,
target: Readonly<MutableChapterPalette>,
deltaSeconds: number,
): void {
const mix = 1 - Math.exp(-Math.max(0, deltaSeconds) * 2.8);
current.sky.lerp(target.sky, mix);
current.fog.lerp(target.fog, mix);
current.terrainLow.lerp(target.terrainLow, mix);
current.terrainHigh.lerp(target.terrainHigh, mix);
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,
);
}