feat: build procedural lifecycle-safe drive scene
This commit is contained in:
157
src/scene/world/environment.ts
Normal file
157
src/scene/world/environment.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import * as THREE from 'three';
|
||||
import type { EnvironmentChapter, JourneySnapshot } from '@/journey/types';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import type { SceneBuildContext, SceneGeneration } from '@/scene/types';
|
||||
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 { createSign } from '@/scene/world/sign';
|
||||
import { createParticles } from '@/scene/world/particles';
|
||||
|
||||
const skyColors: Readonly<Record<EnvironmentChapter, number>> = {
|
||||
'deep-night': 0x040713,
|
||||
systems: 0x060918,
|
||||
infrastructure: 0x080b1d,
|
||||
builds: 0x0d1025,
|
||||
'pre-dawn': 0x18152c,
|
||||
destination: 0x2b2035,
|
||||
};
|
||||
|
||||
export async function createProceduralSceneGeneration(
|
||||
context: SceneBuildContext,
|
||||
): Promise<SceneGeneration> {
|
||||
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();
|
||||
const background = new THREE.Color(skyColors['deep-night']);
|
||||
scene.background = background;
|
||||
scene.fog = new THREE.FogExp2(0x080c18, 0.012);
|
||||
const camera = new THREE.PerspectiveCamera(52, 1, 0.1, 320);
|
||||
camera.position.set(0, 4.8, 12.5);
|
||||
camera.lookAt(0, 1.6, -22);
|
||||
|
||||
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 road = createRoad(context.resources);
|
||||
const terrain = createTerrain(context.resources, quality.terrainSegments);
|
||||
const sun = createSun(context.resources);
|
||||
const car = createCar(context.resources);
|
||||
const sign = createSign(context.resources);
|
||||
const particles = createParticles(context.resources, quality.particleCount);
|
||||
scene.add(road.group, terrain.group, sun.mesh, car.group, sign.group, particles.points);
|
||||
|
||||
const postTarget = quality.postEffects
|
||||
? context.resources.own('target', new THREE.WebGLRenderTarget(1, 1, {
|
||||
depthBuffer: true,
|
||||
minFilter: THREE.LinearFilter,
|
||||
magFilter: THREE.LinearFilter,
|
||||
}))
|
||||
: null;
|
||||
const postScene = postTarget ? new THREE.Scene() : null;
|
||||
const postCamera = postTarget ? new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1) : null;
|
||||
if (postScene && postTarget) {
|
||||
const postGeometry = context.resources.own('geometry', new THREE.PlaneGeometry(2, 2));
|
||||
const postMaterial = context.resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms: { source: { value: postTarget.texture } },
|
||||
vertexShader: `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position.xy, 0.0, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform sampler2D source;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vec4 color = texture2D(source, vUv);
|
||||
float vignette = smoothstep(0.92, 0.24, distance(vUv, vec2(0.5)));
|
||||
gl_FragColor = vec4(color.rgb * mix(0.82, 1.0, vignette), color.a);
|
||||
}
|
||||
`,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}));
|
||||
postScene.add(new THREE.Mesh(postGeometry, postMaterial));
|
||||
}
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let driveDistance = 0;
|
||||
let lastNow: number | null = null;
|
||||
let disposed = 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();
|
||||
postTarget?.setSize(
|
||||
Math.max(1, Math.floor(width * quality.pixelRatio)),
|
||||
Math.max(1, Math.floor(height * quality.pixelRatio)),
|
||||
);
|
||||
};
|
||||
|
||||
if (context.signal.aborted) {
|
||||
context.resources.dispose();
|
||||
throw new DOMException('Scene build aborted', 'AbortError');
|
||||
}
|
||||
|
||||
return {
|
||||
render(snapshot: Readonly<JourneySnapshot>, now: number) {
|
||||
if (disposed) return;
|
||||
resize();
|
||||
const deltaSeconds = lastNow === null ? 0 : Math.min(0.1, Math.max(0, (now - lastNow) / 1000));
|
||||
lastNow = now;
|
||||
driveDistance += snapshot.driveSpeed * deltaSeconds * 18;
|
||||
road.update(driveDistance, snapshot.driveSpeed);
|
||||
terrain.update(driveDistance);
|
||||
sun.update(snapshot.chapter, snapshot.routeProgress);
|
||||
car.update(snapshot.driveSpeed, snapshot.acceleration, now / 1000);
|
||||
sign.update(snapshot.sign);
|
||||
particles.update(driveDistance, snapshot.driveSpeed, snapshot.acceleration);
|
||||
background.setHex(skyColors[snapshot.chapter]);
|
||||
const fov = 52 + Math.min(7, snapshot.acceleration * 4.5);
|
||||
if (camera.fov !== fov) {
|
||||
camera.fov = fov;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
if (postTarget) {
|
||||
renderer.setRenderTarget(postTarget);
|
||||
renderer.render(scene, camera);
|
||||
renderer.setRenderTarget(null);
|
||||
renderer.render(postScene, postCamera);
|
||||
} else {
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
scene.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user