feat: build procedural lifecycle-safe drive scene
This commit is contained in:
69
src/scene/world/car.ts
Normal file
69
src/scene/world/car.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import * as THREE from 'three';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
|
||||
export function createCar(resources: SceneResourceBag) {
|
||||
const group = new THREE.Group();
|
||||
group.position.set(0, 0.72, 4.4);
|
||||
|
||||
const bodyMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x111722,
|
||||
roughness: 0.28,
|
||||
metalness: 0.78,
|
||||
}));
|
||||
const glassMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x10273a,
|
||||
roughness: 0.12,
|
||||
metalness: 0.35,
|
||||
}));
|
||||
const tailMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0xff315f }));
|
||||
const underglowMaterial = resources.own('material', new THREE.MeshBasicMaterial({
|
||||
color: 0x28d8ef,
|
||||
transparent: true,
|
||||
opacity: 0.42,
|
||||
}));
|
||||
|
||||
const lowerGeometry = resources.own('geometry', new THREE.BoxGeometry(4.6, 0.55, 6.6));
|
||||
const lower = new THREE.Mesh(lowerGeometry, bodyMaterial);
|
||||
lower.scale.set(1, 1, 0.92);
|
||||
group.add(lower);
|
||||
|
||||
const cabinGeometry = resources.own('geometry', new THREE.CylinderGeometry(1.25, 1.72, 2.9, 4));
|
||||
const cabin = new THREE.Mesh(cabinGeometry, glassMaterial);
|
||||
cabin.rotation.set(Math.PI / 2, Math.PI / 4, 0);
|
||||
cabin.scale.set(1, 1, 0.62);
|
||||
cabin.position.set(0, 0.64, -0.35);
|
||||
group.add(cabin);
|
||||
|
||||
const lightGeometry = resources.own('geometry', new THREE.BoxGeometry(3.9, 0.12, 0.08));
|
||||
const rearLight = new THREE.Mesh(lightGeometry, tailMaterial);
|
||||
rearLight.position.set(0, 0.04, 3.08);
|
||||
group.add(rearLight);
|
||||
|
||||
const glowGeometry = resources.own('geometry', new THREE.PlaneGeometry(4.2, 6.2));
|
||||
const underglow = new THREE.Mesh(glowGeometry, underglowMaterial);
|
||||
underglow.rotation.x = -Math.PI / 2;
|
||||
underglow.position.y = -0.31;
|
||||
group.add(underglow);
|
||||
|
||||
const wheelGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.62, 0.62, 0.42, 18));
|
||||
const wheelMaterial = resources.own('material', new THREE.MeshStandardMaterial({ color: 0x030407, roughness: 0.92 }));
|
||||
for (const x of [-2.05, 2.05]) {
|
||||
for (const z of [-2.15, 2.15]) {
|
||||
const wheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
|
||||
wheel.rotation.z = Math.PI / 2;
|
||||
wheel.position.set(x, -0.18, z);
|
||||
group.add(wheel);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
group,
|
||||
update(speed: number, acceleration: number, elapsedSeconds: number) {
|
||||
const response = Math.min(1, Math.max(0, acceleration));
|
||||
group.position.y = 0.72 + Math.sin(elapsedSeconds * (2.2 + speed)) * (0.012 + response * 0.018);
|
||||
group.rotation.z = Math.sin(elapsedSeconds * 1.4) * 0.003 * response;
|
||||
tailMaterial.color.setRGB(1, 0.12 + response * 0.08, 0.28 + response * 0.08);
|
||||
underglowMaterial.opacity = 0.32 + Math.min(0.3, speed * 0.2);
|
||||
},
|
||||
};
|
||||
}
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
40
src/scene/world/particles.ts
Normal file
40
src/scene/world/particles.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import * as THREE from 'three';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
|
||||
export function createParticles(resources: SceneResourceBag, count: number) {
|
||||
const positions = new Float32Array(count * 3);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const offset = index * 3;
|
||||
positions[offset] = seeded(index * 3) * 30 - 15;
|
||||
positions[offset + 1] = seeded(index * 3 + 1) * 7 + 0.2;
|
||||
positions[offset + 2] = -(seeded(index * 3 + 2) * 150);
|
||||
}
|
||||
const geometry = resources.own('geometry', new THREE.BufferGeometry());
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
const material = resources.own('material', new THREE.PointsMaterial({
|
||||
color: 0xa8f5ff,
|
||||
size: 0.07,
|
||||
transparent: true,
|
||||
opacity: 0.42,
|
||||
depthWrite: false,
|
||||
}));
|
||||
const points = new THREE.Points(geometry, material);
|
||||
|
||||
return {
|
||||
points,
|
||||
update(distance: number, speed: number, acceleration: number) {
|
||||
points.position.z = modulo(distance * (1 + speed * 2), 150);
|
||||
material.opacity = Math.min(0.78, 0.25 + speed * 0.28 + acceleration * 0.18);
|
||||
material.size = 0.055 + Math.min(0.1, speed * 0.04);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function seeded(value: number): number {
|
||||
const result = Math.sin(value * 91.713 + 17.17) * 43758.5453;
|
||||
return result - Math.floor(result);
|
||||
}
|
||||
|
||||
function modulo(value: number, divisor: number): number {
|
||||
return ((value % divisor) + divisor) % divisor;
|
||||
}
|
||||
50
src/scene/world/road.ts
Normal file
50
src/scene/world/road.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import * as THREE from 'three';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
|
||||
export function createRoad(resources: SceneResourceBag) {
|
||||
const group = new THREE.Group();
|
||||
const roadGeometry = resources.own('geometry', new THREE.PlaneGeometry(18, 240));
|
||||
const roadMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x070b16,
|
||||
roughness: 0.88,
|
||||
metalness: 0.12,
|
||||
}));
|
||||
const road = new THREE.Mesh(roadGeometry, roadMaterial);
|
||||
road.rotation.x = -Math.PI / 2;
|
||||
road.position.z = -92;
|
||||
group.add(road);
|
||||
|
||||
const edgeGeometry = resources.own('geometry', new THREE.BoxGeometry(0.12, 0.035, 240));
|
||||
const edgeMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0x38dff2 }));
|
||||
for (const x of [-8.65, 8.65]) {
|
||||
const edge = new THREE.Mesh(edgeGeometry, edgeMaterial);
|
||||
edge.position.set(x, 0.035, -92);
|
||||
group.add(edge);
|
||||
}
|
||||
|
||||
const markerGeometry = resources.own('geometry', new THREE.BoxGeometry(0.14, 0.025, 3.8));
|
||||
const markerMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0xff9e68 }));
|
||||
const markers: Array<{ position: { z: number } }> = [];
|
||||
for (let index = 0; index < 22; index += 1) {
|
||||
const marker = new THREE.Mesh(markerGeometry, markerMaterial);
|
||||
marker.position.set(0, 0.045, 8 - index * 10);
|
||||
markers.push(marker);
|
||||
group.add(marker);
|
||||
}
|
||||
|
||||
return {
|
||||
group,
|
||||
update(distance: number, speed: number) {
|
||||
const flow = distance * (5 + speed * 8);
|
||||
for (let index = 0; index < markers.length; index += 1) {
|
||||
const marker = markers[index];
|
||||
if (!marker) continue;
|
||||
marker.position.z = 8 - modulo(index * 10 - flow, 220);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function modulo(value: number, divisor: number): number {
|
||||
return ((value % divisor) + divisor) % divisor;
|
||||
}
|
||||
79
src/scene/world/sign.ts
Normal file
79
src/scene/world/sign.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as THREE from 'three';
|
||||
import type { SectionId } from '@/content/types';
|
||||
import type { SignSnapshot } from '@/journey/types';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
|
||||
const routeLabels: Readonly<Record<SectionId, string>> = {
|
||||
start: '00 / START',
|
||||
profile: '01 / PROFILE',
|
||||
experience: '02 / EXPERIENCE',
|
||||
infrastructure: '03 / INFRASTRUCTURE',
|
||||
'selected-builds': '04 / SELECTED BUILDS',
|
||||
'additional-work': '05 / ADDITIONAL WORK',
|
||||
toolkit: '06 / TOOLKIT',
|
||||
contact: '07 / DESTINATION',
|
||||
};
|
||||
|
||||
export function routeLabelFor(id: SectionId): string {
|
||||
return routeLabels[id];
|
||||
}
|
||||
|
||||
export function createSign(resources: SceneResourceBag) {
|
||||
const group = new THREE.Group();
|
||||
group.visible = false;
|
||||
const geometry = resources.own('geometry', new THREE.PlaneGeometry(8.4, 2.2));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 768;
|
||||
canvas.height = 192;
|
||||
const context = canvas.getContext('2d');
|
||||
const texture = resources.own('texture', new THREE.CanvasTexture(canvas));
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
const material = resources.own('material', new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
transparent: true,
|
||||
opacity: 0.96,
|
||||
side: THREE.DoubleSide,
|
||||
map: texture,
|
||||
}));
|
||||
const panel = new THREE.Mesh(geometry, material);
|
||||
group.add(panel);
|
||||
|
||||
const thrusterGeometry = resources.own('geometry', new THREE.ConeGeometry(0.18, 0.72, 8));
|
||||
const thrusterMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0x44e8f5 }));
|
||||
for (const x of [-3.5, 3.5]) {
|
||||
const thruster = new THREE.Mesh(thrusterGeometry, thrusterMaterial);
|
||||
thruster.rotation.x = Math.PI / 2;
|
||||
thruster.position.set(x, -1.22, 0.08);
|
||||
group.add(thruster);
|
||||
}
|
||||
|
||||
let target: SectionId | null = null;
|
||||
const updateTexture = (id: SectionId): void => {
|
||||
if (target === id) return;
|
||||
target = id;
|
||||
if (!context) return;
|
||||
context.fillStyle = '#07111b';
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.strokeStyle = '#35ddeb';
|
||||
context.lineWidth = 7;
|
||||
context.strokeRect(8, 8, canvas.width - 16, canvas.height - 16);
|
||||
context.fillStyle = '#e8fbff';
|
||||
context.font = '600 46px ui-monospace, monospace';
|
||||
context.textAlign = 'center';
|
||||
context.textBaseline = 'middle';
|
||||
context.fillText(routeLabelFor(id), canvas.width / 2, canvas.height / 2);
|
||||
texture.needsUpdate = true;
|
||||
};
|
||||
|
||||
return {
|
||||
group,
|
||||
update(sign: SignSnapshot | null) {
|
||||
group.visible = sign !== null;
|
||||
if (!sign) return;
|
||||
updateTexture(sign.targetId);
|
||||
const phaseOffset = sign.phase === 'approach' ? -26 : sign.phase === 'stabilized' ? -5 : 8;
|
||||
group.position.set(7.2, 4.1, phaseOffset + sign.progress * 8);
|
||||
group.rotation.y = -0.12;
|
||||
},
|
||||
};
|
||||
}
|
||||
33
src/scene/world/sun.ts
Normal file
33
src/scene/world/sun.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as THREE from 'three';
|
||||
import type { EnvironmentChapter } from '@/journey/types';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
|
||||
const chapterWarmth: Readonly<Record<EnvironmentChapter, number>> = {
|
||||
'deep-night': 0,
|
||||
systems: 0.12,
|
||||
infrastructure: 0.24,
|
||||
builds: 0.42,
|
||||
'pre-dawn': 0.68,
|
||||
destination: 1,
|
||||
};
|
||||
|
||||
export function createSun(resources: SceneResourceBag) {
|
||||
const geometry = resources.own('geometry', new THREE.CircleGeometry(18, 64));
|
||||
const material = resources.own('material', new THREE.MeshBasicMaterial({
|
||||
color: 0xff6f83,
|
||||
transparent: true,
|
||||
opacity: 0.82,
|
||||
fog: false,
|
||||
}));
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.position.set(0, 12, -128);
|
||||
|
||||
return {
|
||||
mesh,
|
||||
update(chapter: EnvironmentChapter, routeProgress: number) {
|
||||
const warmth = Math.max(chapterWarmth[chapter], routeProgress);
|
||||
material.color.setRGB(1, 0.34 + warmth * 0.24, 0.42 + warmth * 0.22);
|
||||
mesh.position.y = 12 + warmth * 5;
|
||||
},
|
||||
};
|
||||
}
|
||||
43
src/scene/world/terrain.ts
Normal file
43
src/scene/world/terrain.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as THREE from 'three';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
|
||||
export function createTerrain(resources: SceneResourceBag, segments: number) {
|
||||
const group = new THREE.Group();
|
||||
const geometry = resources.own(
|
||||
'geometry',
|
||||
new THREE.PlaneGeometry(72, 240, Math.max(4, Math.floor(segments / 4)), segments),
|
||||
);
|
||||
const cyan = resources.own('material', new THREE.MeshBasicMaterial({
|
||||
color: 0x168fae,
|
||||
wireframe: true,
|
||||
transparent: true,
|
||||
opacity: 0.34,
|
||||
}));
|
||||
const magenta = resources.own('material', new THREE.MeshBasicMaterial({
|
||||
color: 0xbd317f,
|
||||
wireframe: true,
|
||||
transparent: true,
|
||||
opacity: 0.22,
|
||||
}));
|
||||
|
||||
const planes: Array<{ position: { z: number } }> = [];
|
||||
for (const [x, material] of [[-44, magenta], [44, cyan]] as const) {
|
||||
const plane = new THREE.Mesh(geometry, material);
|
||||
plane.rotation.x = -Math.PI / 2;
|
||||
plane.position.set(x, -0.2, -92);
|
||||
planes.push(plane);
|
||||
group.add(plane);
|
||||
}
|
||||
|
||||
return {
|
||||
group,
|
||||
update(distance: number) {
|
||||
const drift = modulo(distance * 1.4, 8);
|
||||
for (const plane of planes) plane.position.z = -92 + drift;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function modulo(value: number, divisor: number): number {
|
||||
return ((value % divisor) + divisor) % divisor;
|
||||
}
|
||||
Reference in New Issue
Block a user