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:
@@ -1,11 +1,12 @@
|
||||
import * as THREE from 'three';
|
||||
import type { RenderQualitySettings } from '@/scene/render-quality';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import type { MutableChapterPalette } from '@/scene/world/palette';
|
||||
import type { MutableChapterPalette, MutableColor } from '@/scene/world/palette';
|
||||
import { CHAPTER_PALETTES } from '@/scene/world/palette';
|
||||
|
||||
export interface AtmosphereWorld {
|
||||
group: WorldGroup;
|
||||
setViewportAspect(aspect: number): void;
|
||||
update(
|
||||
distance: number,
|
||||
speed: number,
|
||||
@@ -27,30 +28,44 @@ export function createAtmosphere(
|
||||
group.name = 'atmosphere-world';
|
||||
const night = CHAPTER_PALETTES['deep-night'];
|
||||
|
||||
const skyGeometry = resources.own('geometry', new THREE.PlaneGeometry(520, 280));
|
||||
const skyGeometry = resources.own('geometry', new THREE.PlaneGeometry(2, 2));
|
||||
const skyMaterial = resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uSkyColor: { value: new THREE.Color(night.sky) },
|
||||
uFogColor: { value: new THREE.Color(night.fog) },
|
||||
uHazeColor: { value: new THREE.Color(night.haze) },
|
||||
uWarmth: { value: night.warmth },
|
||||
uAspect: { value: 1 },
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec2 vSkyUv;
|
||||
void main() {
|
||||
vSkyUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
gl_Position = vec4(position.xy, 1.0, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform vec3 uSkyColor;
|
||||
uniform vec3 uFogColor;
|
||||
uniform vec3 uHazeColor;
|
||||
uniform float uWarmth;
|
||||
uniform float uAspect;
|
||||
varying vec2 vSkyUv;
|
||||
void main() {
|
||||
float horizon = 1.0 - smoothstep(0.08, 0.72, vSkyUv.y);
|
||||
float upperNight = smoothstep(0.35, 1.0, vSkyUv.y);
|
||||
vec3 color = mix(uSkyColor, uHazeColor, horizon * (0.38 + uWarmth * 0.2));
|
||||
color *= mix(1.04, 0.72, upperNight);
|
||||
float verticalSky = smoothstep(0.08, 0.66, vSkyUv.y);
|
||||
vec3 lowerField = mix(uSkyColor, uFogColor, 0.34);
|
||||
vec3 color = mix(lowerField * 0.72, uSkyColor * 0.48, verticalSky);
|
||||
float horizonBand = exp(-pow(abs(vSkyUv.y - 0.47) * 6.2, 2.0));
|
||||
float normalizedX = abs(vSkyUv.x - 0.5) * 2.0 * min(uAspect, 2.35) / 2.35;
|
||||
float centerGlow = 1.0 - smoothstep(0.18, 1.16, normalizedX);
|
||||
color = mix(
|
||||
color,
|
||||
uHazeColor * (0.72 + uWarmth * 0.14),
|
||||
horizonBand * mix(0.13, 0.42, centerGlow)
|
||||
);
|
||||
float topFalloff = smoothstep(0.54, 1.0, vSkyUv.y);
|
||||
float vignette = smoothstep(0.36, 1.18, normalizedX);
|
||||
color *= 1.0 - topFalloff * 0.28 - vignette * 0.08;
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
`,
|
||||
@@ -61,10 +76,33 @@ export function createAtmosphere(
|
||||
}));
|
||||
const sky = new THREE.Mesh(skyGeometry, skyMaterial);
|
||||
sky.name = 'atmosphere-sky';
|
||||
sky.position.set(0, 69, -300);
|
||||
sky.frustumCulled = false;
|
||||
sky.renderOrder = -100;
|
||||
group.add(sky);
|
||||
|
||||
const silhouettes = new THREE.Group();
|
||||
silhouettes.name = 'distant-silhouettes';
|
||||
const silhouetteMaterials: Array<{ color: MutableColor }> = [];
|
||||
for (let band = 0; band < 3; band += 1) {
|
||||
const material = resources.own('material', new THREE.MeshBasicMaterial({
|
||||
color: new THREE.Color(night.fog).lerp(new THREE.Color(night.terrainLow), 0.46 + band * 0.12),
|
||||
transparent: true,
|
||||
opacity: 0.2 - band * 0.035,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
toneMapped: true,
|
||||
}));
|
||||
silhouetteMaterials.push(material);
|
||||
const ridge = new THREE.Mesh(
|
||||
resources.own('geometry', createSilhouetteGeometry(band)),
|
||||
material,
|
||||
);
|
||||
ridge.name = `distant-silhouette-${band + 1}`;
|
||||
ridge.renderOrder = -30 + band;
|
||||
silhouettes.add(ridge);
|
||||
}
|
||||
group.add(silhouettes);
|
||||
|
||||
const starGeometry = resources.own('geometry', createStarGeometry(quality.starCount));
|
||||
const starMaterial = resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
@@ -115,18 +153,25 @@ export function createAtmosphere(
|
||||
stars.frustumCulled = false;
|
||||
group.add(stars);
|
||||
|
||||
const hazeGeometry = resources.own('geometry', new THREE.PlaneGeometry(260, 58));
|
||||
const nearHazeMaterial = resources.own('material', createHazeMaterial(night.haze, 0.2));
|
||||
const farHazeMaterial = resources.own('material', createHazeMaterial(night.haze, 0.12));
|
||||
const hazeGroup = new THREE.Group();
|
||||
hazeGroup.name = 'horizon-haze';
|
||||
const hazeGeometry = resources.own('geometry', new THREE.PlaneGeometry(2, 2));
|
||||
const nearHazeMaterial = resources.own('material', createHazeMaterial(night.haze, 0.15, 0.47, 8.4));
|
||||
const farHazeMaterial = resources.own('material', createHazeMaterial(night.haze, 0.1, 0.5, 5.8));
|
||||
const farHaze = new THREE.Mesh(hazeGeometry, farHazeMaterial);
|
||||
farHaze.name = 'horizon-haze-far';
|
||||
farHaze.position.set(0, 10, -214);
|
||||
farHaze.scale.set(1.35, 1.1, 1);
|
||||
group.add(farHaze);
|
||||
farHaze.frustumCulled = false;
|
||||
farHaze.renderOrder = 60;
|
||||
hazeGroup.add(farHaze);
|
||||
const nearHaze = new THREE.Mesh(hazeGeometry, nearHazeMaterial);
|
||||
nearHaze.name = 'horizon-haze-near';
|
||||
nearHaze.position.set(0, 8, -132);
|
||||
group.add(nearHaze);
|
||||
nearHaze.frustumCulled = false;
|
||||
nearHaze.renderOrder = 61;
|
||||
hazeGroup.add(nearHaze);
|
||||
group.add(hazeGroup);
|
||||
|
||||
const anchors = createRoadsideAnchors(resources, quality);
|
||||
group.add(anchors.mesh);
|
||||
|
||||
const streakGeometry = resources.own('geometry', createStreakGeometry(quality.streakCount));
|
||||
const streakMaterial = resources.own('material', new THREE.ShaderMaterial({
|
||||
@@ -143,8 +188,8 @@ export function createAtmosphere(
|
||||
varying float vTail;
|
||||
void main() {
|
||||
vec3 streak = position;
|
||||
float depthCycle = mod(streak.z + uDistance * 1.4 + 280.0, 286.0) - 280.0;
|
||||
streak.z = depthCycle + endFactor * (1.2 + uAcceleration * 18.0);
|
||||
float depthCycle = mod(streak.z + uDistance * 1.55 + 520.0, 526.0) - 520.0;
|
||||
streak.z = depthCycle + endFactor * (1.4 + uAcceleration * 31.0);
|
||||
vTail = endFactor;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(streak, 1.0);
|
||||
}
|
||||
@@ -154,7 +199,7 @@ export function createAtmosphere(
|
||||
uniform float uVisibility;
|
||||
varying float vTail;
|
||||
void main() {
|
||||
gl_FragColor = vec4(uColor, uVisibility * mix(0.15, 0.72, vTail));
|
||||
gl_FragColor = vec4(uColor * (1.0 + uVisibility * 0.45), uVisibility * mix(0.08, 0.62, vTail));
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
@@ -164,27 +209,42 @@ export function createAtmosphere(
|
||||
toneMapped: true,
|
||||
}));
|
||||
const streaks = new THREE.LineSegments(streakGeometry, streakMaterial);
|
||||
streaks.name = 'atmosphere-streaks';
|
||||
streaks.name = 'speed-streaks';
|
||||
streaks.frustumCulled = false;
|
||||
group.add(streaks);
|
||||
|
||||
return {
|
||||
group,
|
||||
setViewportAspect(aspect) {
|
||||
const safeAspect = Number.isFinite(aspect) ? THREE.MathUtils.clamp(aspect, 0.35, 4) : 1;
|
||||
skyMaterial.uniforms.uAspect.value = safeAspect;
|
||||
nearHazeMaterial.uniforms.uAspect.value = safeAspect;
|
||||
farHazeMaterial.uniforms.uAspect.value = safeAspect;
|
||||
},
|
||||
update(distance, speed, acceleration, palette) {
|
||||
const safeAcceleration = clamp01(acceleration);
|
||||
const visibility = smoothstep(0.18, 0.82, safeAcceleration) * palette.streakEnergy;
|
||||
const visibility = Math.pow(safeAcceleration, 1.12) * palette.streakEnergy * 0.82;
|
||||
starMaterial.uniforms.uDistance.value = finiteOrZero(distance);
|
||||
starMaterial.uniforms.uVisibility.value = palette.starVisibility;
|
||||
streakMaterial.uniforms.uDistance.value = finiteOrZero(distance) * (0.72 + clamp01(speed) * 0.28);
|
||||
streakMaterial.uniforms.uAcceleration.value = safeAcceleration;
|
||||
streakMaterial.uniforms.uVisibility.value = visibility;
|
||||
skyMaterial.uniforms.uSkyColor.value.copy(palette.sky);
|
||||
skyMaterial.uniforms.uFogColor.value.copy(palette.fog);
|
||||
skyMaterial.uniforms.uHazeColor.value.copy(palette.haze);
|
||||
skyMaterial.uniforms.uWarmth.value = palette.warmth;
|
||||
nearHazeMaterial.uniforms.uColor.value.copy(palette.haze);
|
||||
farHazeMaterial.uniforms.uColor.value.copy(palette.haze);
|
||||
nearHazeMaterial.uniforms.uEnergy.value = 0.16 + palette.warmth * 0.08;
|
||||
farHazeMaterial.uniforms.uEnergy.value = 0.09 + palette.warmth * 0.06;
|
||||
for (let index = 0; index < silhouetteMaterials.length; index += 1) {
|
||||
silhouetteMaterials[index]?.color
|
||||
.copy(palette.fog)
|
||||
.lerp(palette.terrainLow, 0.46 + index * 0.12);
|
||||
}
|
||||
anchors.uniforms.uDistance.value = finiteOrZero(distance);
|
||||
anchors.uniforms.uEnergy.value = 0.38 + palette.terrainEnergy * 0.22;
|
||||
anchors.uniforms.uColor.value.copy(palette.terrainHigh);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -195,9 +255,9 @@ function createStarGeometry(count: number) {
|
||||
const brightness = new Float32Array(count);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const offset = index * 3;
|
||||
positions[offset] = seeded(index, 11) * 210 - 105;
|
||||
positions[offset + 1] = 8 + seeded(index, 23) * 72;
|
||||
positions[offset + 2] = -18 - seeded(index, 37) * 270;
|
||||
positions[offset] = seeded(index, 11) * 680 - 340;
|
||||
positions[offset + 1] = 16 + seeded(index, 23) * 230;
|
||||
positions[offset + 2] = -48 - seeded(index, 37) * 540;
|
||||
sizes[index] = 2.4 + seeded(index, 53) * 4.8;
|
||||
brightness[index] = 0.28 + seeded(index, 71) * 0.72;
|
||||
}
|
||||
@@ -212,9 +272,9 @@ function createStreakGeometry(count: number) {
|
||||
const positions = new Float32Array(count * 2 * 3);
|
||||
const ends = new Float32Array(count * 2);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const x = (seeded(index, 101) > 0.5 ? 1 : -1) * (12 + seeded(index, 103) * 52);
|
||||
const y = 0.8 + seeded(index, 107) * 12;
|
||||
const z = -8 - seeded(index, 109) * 272;
|
||||
const x = (seeded(index, 101) > 0.5 ? 1 : -1) * (14 + seeded(index, 103) * 102);
|
||||
const y = 0.8 + seeded(index, 107) * 32;
|
||||
const z = -8 - seeded(index, 109) * 512;
|
||||
const offset = index * 6;
|
||||
positions[offset] = x;
|
||||
positions[offset + 1] = y;
|
||||
@@ -231,27 +291,113 @@ function createStreakGeometry(count: number) {
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function createHazeMaterial(color: number, energy: number) {
|
||||
function createHazeMaterial(
|
||||
color: number,
|
||||
energy: number,
|
||||
center: number,
|
||||
spread: number,
|
||||
) {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uColor: { value: new THREE.Color(color) },
|
||||
uEnergy: { value: energy },
|
||||
uCenter: { value: center },
|
||||
uSpread: { value: spread },
|
||||
uAspect: { value: 1 },
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec2 vHazeUv;
|
||||
void main() {
|
||||
vHazeUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
gl_Position = vec4(position.xy, 1.0, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform vec3 uColor;
|
||||
uniform float uEnergy;
|
||||
uniform float uCenter;
|
||||
uniform float uSpread;
|
||||
uniform float uAspect;
|
||||
varying vec2 vHazeUv;
|
||||
void main() {
|
||||
float vertical = 1.0 - smoothstep(0.04, 0.84, abs(vHazeUv.y - 0.42) * 1.9);
|
||||
float horizontal = 1.0 - smoothstep(0.26, 0.5, abs(vHazeUv.x - 0.5));
|
||||
gl_FragColor = vec4(uColor, vertical * horizontal * uEnergy);
|
||||
float vertical = exp(-pow(abs(vHazeUv.y - uCenter) * uSpread, 2.0));
|
||||
float normalizedX = abs(vHazeUv.x - 0.5) * 2.0 * min(uAspect, 2.35) / 2.35;
|
||||
float centerLift = 1.0 - smoothstep(0.12, 1.2, normalizedX);
|
||||
float horizontal = mix(0.64, 1.0, centerLift);
|
||||
gl_FragColor = vec4(uColor * 1.08, vertical * horizontal * uEnergy);
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
blending: THREE.NormalBlending,
|
||||
toneMapped: true,
|
||||
});
|
||||
}
|
||||
|
||||
function createSilhouetteGeometry(band: number) {
|
||||
const segments = 18;
|
||||
const positions = new Float32Array((segments + 1) * 2 * 3);
|
||||
const indices: number[] = [];
|
||||
const width = 1_900;
|
||||
const centerZ = -455 - band * 54;
|
||||
const baseY = -38;
|
||||
for (let index = 0; index <= segments; index += 1) {
|
||||
const progress = index / segments;
|
||||
const x = (progress - 0.5) * width;
|
||||
const lowWave = Math.sin(progress * Math.PI * (2.2 + band * 0.35) + band * 1.7);
|
||||
const broadWave = Math.sin(progress * Math.PI * 5.2 + band * 2.4);
|
||||
const topY = 4 + band * 2.6 + lowWave * (8 + band * 1.8) + broadWave * 3.2;
|
||||
const offset = index * 6;
|
||||
positions[offset] = x;
|
||||
positions[offset + 1] = baseY;
|
||||
positions[offset + 2] = centerZ;
|
||||
positions[offset + 3] = x;
|
||||
positions[offset + 4] = topY;
|
||||
positions[offset + 5] = centerZ;
|
||||
if (index < segments) {
|
||||
const base = index * 2;
|
||||
indices.push(base, base + 1, base + 2, base + 1, base + 3, base + 2);
|
||||
}
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setIndex(indices);
|
||||
geometry.computeBoundingSphere();
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function createRoadsideAnchors(
|
||||
resources: SceneResourceBag,
|
||||
quality: RenderQualitySettings,
|
||||
) {
|
||||
const count = Math.max(12, Math.min(32, Math.round(quality.terrainSegments / 4)));
|
||||
const geometry = resources.own('geometry', new THREE.BoxGeometry(0.15, 0.72, 0.15));
|
||||
const uniforms = {
|
||||
uDistance: { value: 0 },
|
||||
uEnergy: { value: 0.5 },
|
||||
uColor: { value: new THREE.Color(CHAPTER_PALETTES['deep-night'].terrainHigh) },
|
||||
};
|
||||
const material = resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: `
|
||||
uniform float uDistance;
|
||||
varying float vDepthFade;
|
||||
void main() {
|
||||
vec4 anchor = instanceMatrix * vec4(position, 1.0);
|
||||
anchor.z = mod(anchor.z + uDistance + 410.0, 438.0) - 410.0;
|
||||
vec4 viewPosition = modelViewMatrix * anchor;
|
||||
vDepthFade = (1.0 - smoothstep(250.0, 390.0, -viewPosition.z))
|
||||
* smoothstep(2.0, 20.0, -viewPosition.z);
|
||||
gl_Position = projectionMatrix * viewPosition;
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform vec3 uColor;
|
||||
uniform float uEnergy;
|
||||
varying float vDepthFade;
|
||||
void main() {
|
||||
gl_FragColor = vec4(uColor * (0.7 + uEnergy * 0.55), vDepthFade * uEnergy);
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
@@ -259,7 +405,21 @@ function createHazeMaterial(color: number, energy: number) {
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
toneMapped: true,
|
||||
});
|
||||
}));
|
||||
const mesh = new THREE.InstancedMesh(geometry, material, count);
|
||||
mesh.name = 'roadside-anchors';
|
||||
mesh.frustumCulled = false;
|
||||
const matrix = new THREE.Matrix4();
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const side = index % 2 === 0 ? -1 : 1;
|
||||
const lane = Math.floor(index / 2);
|
||||
const z = -8 - (lane / Math.max(1, Math.ceil(count / 2) - 1)) * 420;
|
||||
const x = side * (9.6 + seeded(index, 181) * 1.1);
|
||||
matrix.makeTranslation(x, 0.38, z);
|
||||
mesh.setMatrixAt(index, matrix);
|
||||
}
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
return { mesh, uniforms };
|
||||
}
|
||||
|
||||
function seeded(index: number, salt: number): number {
|
||||
@@ -274,8 +434,3 @@ function finiteOrZero(value: number): number {
|
||||
function clamp01(value: number): number {
|
||||
return THREE.MathUtils.clamp(finiteOrZero(value), 0, 1);
|
||||
}
|
||||
|
||||
function smoothstep(edge0: number, edge1: number, value: number): number {
|
||||
const progress = THREE.MathUtils.clamp((value - edge0) / (edge1 - edge0), 0, 1);
|
||||
return progress * progress * (3 - 2 * progress);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
import grandTourerUrl from '@/assets/nightdrive-grand-tourer.glb?url';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import type { SceneDetailMode } from '@/scene/types';
|
||||
|
||||
@@ -9,6 +11,7 @@ export interface CarWorld {
|
||||
}
|
||||
|
||||
interface CarGroup {
|
||||
name: string;
|
||||
children: unknown[];
|
||||
position: { x: number; y: number; z: number; set(x: number, y: number, z: number): void };
|
||||
rotation: { x: number; y: number; z: number };
|
||||
@@ -19,15 +22,54 @@ interface CarGroup {
|
||||
updateMatrixWorld(force: boolean): void;
|
||||
}
|
||||
|
||||
interface AuthoredMaterial {
|
||||
dispose(): void;
|
||||
isMeshStandardMaterial?: boolean;
|
||||
emissive?: { setHex(value: number): void };
|
||||
emissiveIntensity: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AuthoredObject {
|
||||
name: string;
|
||||
isMesh?: boolean;
|
||||
geometry?: { dispose(): void };
|
||||
material?: AuthoredMaterial | AuthoredMaterial[];
|
||||
}
|
||||
|
||||
interface AuthoredCarGroup extends CarGroup {
|
||||
removeFromParent(): void;
|
||||
traverse(callback: (object: AuthoredObject) => void): void;
|
||||
}
|
||||
|
||||
const FULL_BASE_Y = 0.58;
|
||||
const REDUCED_BASE_Y = 0.42;
|
||||
const GRAND_TOURER_URL = grandTourerUrl;
|
||||
|
||||
export function createCar(resources: SceneResourceBag): CarWorld {
|
||||
export async function loadGrandTourer(signal: AbortSignal): Promise<AuthoredCarGroup> {
|
||||
const response = await fetch(GRAND_TOURER_URL, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Grand tourer asset failed to load (${response.status}).`);
|
||||
}
|
||||
const bytes = await response.arrayBuffer();
|
||||
if (signal.aborted) throw new DOMException('Scene build aborted', 'AbortError');
|
||||
const gltf = await new GLTFLoader().parseAsync(bytes, GRAND_TOURER_URL);
|
||||
if (signal.aborted) throw new DOMException('Scene build aborted', 'AbortError');
|
||||
const authored = gltf.scene.getObjectByName('chase-car') ?? gltf.scene;
|
||||
authored.removeFromParent();
|
||||
return authored as AuthoredCarGroup;
|
||||
}
|
||||
|
||||
export function createCar(
|
||||
resources: SceneResourceBag,
|
||||
authoredModel?: AuthoredCarGroup,
|
||||
): CarWorld {
|
||||
if (authoredModel) return createAuthoredCar(resources, authoredModel);
|
||||
const group = new THREE.Group();
|
||||
group.name = 'chase-car';
|
||||
let baseY = FULL_BASE_Y;
|
||||
group.position.set(0, baseY, 0.72);
|
||||
group.scale.set(0.9, 0.72, 0.82);
|
||||
group.scale.set(1.1, 0.82, 0.98);
|
||||
|
||||
const bodyMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x304e68,
|
||||
@@ -222,8 +264,11 @@ export function createCar(resources: SceneResourceBag): CarWorld {
|
||||
const underglow = new THREE.Mesh(underglowGeometry, underglowMaterial);
|
||||
underglow.name = 'car-underglow';
|
||||
underglow.rotation.x = -Math.PI / 2;
|
||||
underglow.position.set(0, -0.5, 0.45);
|
||||
underglow.position.set(0, -0.67, 0.45);
|
||||
group.add(underglow);
|
||||
const contact = createContactShadow(resources, 4.5, 4.8, 0.22);
|
||||
contact.mesh.position.set(0, -0.695, 0.52);
|
||||
group.add(contact.mesh);
|
||||
|
||||
const secondary = new THREE.Group();
|
||||
secondary.name = 'secondary-detail';
|
||||
@@ -258,11 +303,11 @@ export function createCar(resources: SceneResourceBag): CarWorld {
|
||||
secondary.visible = detail === 'full';
|
||||
if (detail === 'reduced') {
|
||||
baseY = REDUCED_BASE_Y;
|
||||
group.scale.set(0.7, 0.56, 0.66);
|
||||
group.scale.set(0.86, 0.66, 0.76);
|
||||
group.position.z = 0.62;
|
||||
} else {
|
||||
baseY = FULL_BASE_Y;
|
||||
group.scale.set(0.9, 0.72, 0.82);
|
||||
group.scale.set(1.1, 0.82, 0.98);
|
||||
group.position.z = 0.72;
|
||||
}
|
||||
group.position.y = baseY - squat;
|
||||
@@ -292,10 +337,201 @@ export function createCar(resources: SceneResourceBag): CarWorld {
|
||||
);
|
||||
tailMaterial.emissiveIntensity = 1.25 + response * 1.35;
|
||||
underglowUniforms.uOpacity.value = 0.1 + safeSpeed * 0.055 + response * 0.07;
|
||||
contact.uniforms.uOpacity.value = 0.2 + response * 0.06;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAuthoredCar(
|
||||
resources: SceneResourceBag,
|
||||
authoredModel: AuthoredCarGroup,
|
||||
): CarWorld {
|
||||
const group = new THREE.Group();
|
||||
group.name = 'chase-car';
|
||||
authoredModel.name = 'grand-tourer-model';
|
||||
authoredModel.rotation.y = Math.PI;
|
||||
group.add(authoredModel);
|
||||
|
||||
const tailMaterials: AuthoredMaterial[] = [];
|
||||
const cyanMaterials: AuthoredMaterial[] = [];
|
||||
authoredModel.traverse((object) => {
|
||||
if (object.name.endsWith('.001')) object.name = object.name.slice(0, -4);
|
||||
if (!object.isMesh || !object.geometry || !object.material) return;
|
||||
resources.own('geometry', object.geometry);
|
||||
const materials = Array.isArray(object.material) ? object.material : [object.material];
|
||||
for (const material of materials) {
|
||||
resources.own('material', material);
|
||||
ownMaterialTextures(resources, material);
|
||||
if (
|
||||
material.isMeshStandardMaterial
|
||||
&& (object.name === 'car-body' || object.name === 'body-haunches')
|
||||
) {
|
||||
material.emissive?.setHex(0x0b2738);
|
||||
material.emissiveIntensity = 0.58;
|
||||
}
|
||||
if (material.isMeshStandardMaterial && object.name === 'rear-glass') {
|
||||
material.emissive?.setHex(0x041725);
|
||||
material.emissiveIntensity = 0.31;
|
||||
}
|
||||
if (object.name === 'rear-light-bar' && material.isMeshStandardMaterial) {
|
||||
tailMaterials.push(material);
|
||||
}
|
||||
if (object.name === 'car-underglow-source' && material.isMeshStandardMaterial) {
|
||||
cyanMaterials.push(material);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const underglowUniforms = {
|
||||
uColor: { value: new THREE.Color(0x47ddec) },
|
||||
uOpacity: { value: 0.12 },
|
||||
};
|
||||
const underglow = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.PlaneGeometry(1.72, 3.82)),
|
||||
resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms: underglowUniforms,
|
||||
vertexShader: `
|
||||
varying vec2 vGlowUv;
|
||||
void main() {
|
||||
vGlowUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform vec3 uColor;
|
||||
uniform float uOpacity;
|
||||
varying vec2 vGlowUv;
|
||||
void main() {
|
||||
vec2 centered = (vGlowUv - 0.5) * vec2(1.7, 1.0);
|
||||
float glow = 1.0 - smoothstep(0.08, 0.74, length(centered));
|
||||
gl_FragColor = vec4(uColor * 1.35, glow * uOpacity);
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
toneMapped: true,
|
||||
})),
|
||||
);
|
||||
underglow.name = 'car-underglow';
|
||||
underglow.rotation.x = -Math.PI / 2;
|
||||
underglow.position.set(0, 0.012, 0.08);
|
||||
group.add(underglow);
|
||||
const contact = createContactShadow(resources, 1.98, 3.95, 0.24);
|
||||
contact.mesh.position.set(0, 0.004, 0.14);
|
||||
group.add(contact.mesh);
|
||||
|
||||
let detail: SceneDetailMode = 'full';
|
||||
let baseY = 0.02;
|
||||
let squat = 0;
|
||||
let squatVelocity = 0;
|
||||
let previousElapsed: number | null = null;
|
||||
const setScale = (nextDetail: SceneDetailMode): void => {
|
||||
if (nextDetail === 'reduced') {
|
||||
baseY = -0.02;
|
||||
group.scale.set(2.32, 2.32, 2.32);
|
||||
group.position.z = 0.62;
|
||||
} else {
|
||||
baseY = 0.02;
|
||||
group.scale.set(2.78, 2.78, 2.78);
|
||||
group.position.z = 0.72;
|
||||
}
|
||||
group.position.y = baseY - squat;
|
||||
};
|
||||
setScale(detail);
|
||||
|
||||
return {
|
||||
group,
|
||||
setDetail(nextDetail) {
|
||||
if (detail === nextDetail) return;
|
||||
detail = nextDetail;
|
||||
setScale(detail);
|
||||
},
|
||||
update(speed, acceleration, elapsedSeconds) {
|
||||
const safeSpeed = clamp01(speed);
|
||||
const response = clamp01(acceleration);
|
||||
const elapsed = Number.isFinite(elapsedSeconds) ? Math.max(0, elapsedSeconds) : 0;
|
||||
const deltaSeconds = previousElapsed === null
|
||||
? 1 / 60
|
||||
: THREE.MathUtils.clamp(elapsed - previousElapsed, 0, 0.05);
|
||||
previousElapsed = elapsed;
|
||||
const targetSquat = response * 0.04;
|
||||
const springAcceleration = (targetSquat - squat) * 34 - squatVelocity * 10;
|
||||
squatVelocity += springAcceleration * deltaSeconds;
|
||||
squat += squatVelocity * deltaSeconds;
|
||||
const vibration = Math.sin(elapsed * (7.4 + safeSpeed * 2.2)) * (0.0014 + safeSpeed * 0.0022);
|
||||
group.position.y = baseY - squat + vibration;
|
||||
group.rotation.x = -squat * 0.035;
|
||||
group.rotation.z = Math.sin(elapsed * 0.8) * safeSpeed * 0.0018;
|
||||
for (const material of tailMaterials) material.emissiveIntensity = 4.4 + response * 3.2;
|
||||
for (const material of cyanMaterials) material.emissiveIntensity = 2.2 + response * 1.6;
|
||||
underglowUniforms.uOpacity.value = 0.09 + safeSpeed * 0.04 + response * 0.075;
|
||||
contact.uniforms.uOpacity.value = 0.21 + response * 0.055;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createContactShadow(
|
||||
resources: SceneResourceBag,
|
||||
width: number,
|
||||
depth: number,
|
||||
opacity: number,
|
||||
) {
|
||||
const uniforms = {
|
||||
uOpacity: { value: opacity },
|
||||
};
|
||||
const material = resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: `
|
||||
varying vec2 vShadowUv;
|
||||
void main() {
|
||||
vShadowUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform float uOpacity;
|
||||
varying vec2 vShadowUv;
|
||||
void main() {
|
||||
vec2 centered = (vShadowUv - 0.5) * vec2(1.65, 1.0);
|
||||
float shadow = 1.0 - smoothstep(0.16, 0.72, length(centered));
|
||||
gl_FragColor = vec4(vec3(0.002, 0.004, 0.008), shadow * uOpacity);
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.NormalBlending,
|
||||
toneMapped: true,
|
||||
}));
|
||||
const mesh = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.PlaneGeometry(width, depth)),
|
||||
material,
|
||||
);
|
||||
mesh.name = 'car-contact-shadow';
|
||||
mesh.rotation.x = -Math.PI / 2;
|
||||
mesh.renderOrder = 2;
|
||||
return { mesh, uniforms };
|
||||
}
|
||||
|
||||
function ownMaterialTextures(
|
||||
resources: SceneResourceBag,
|
||||
material: AuthoredMaterial,
|
||||
): void {
|
||||
for (const key of [
|
||||
'map',
|
||||
'emissiveMap',
|
||||
'normalMap',
|
||||
'roughnessMap',
|
||||
'metalnessMap',
|
||||
'aoMap',
|
||||
]) {
|
||||
const texture = material[key] as { isTexture?: boolean; dispose(): void } | null | undefined;
|
||||
if (texture?.isTexture) resources.own('texture', texture);
|
||||
}
|
||||
}
|
||||
|
||||
function createRearProfile() {
|
||||
const shape = new THREE.Shape();
|
||||
shape.moveTo(-2.7, -0.42);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -12,9 +12,10 @@ interface WorldGroup {
|
||||
getObjectByName(name: string): unknown;
|
||||
}
|
||||
|
||||
const ROAD_WIDTH = 22;
|
||||
const ROAD_DEPTH = 300;
|
||||
const ROAD_CENTER_Z = -136;
|
||||
const ROAD_WIDTH = 20.4;
|
||||
const ROAD_DEPTH = 438;
|
||||
const ROAD_CENTER_Z = -191;
|
||||
const FLOW_PERIOD = 5_694;
|
||||
|
||||
export function createRoad(
|
||||
resources: SceneResourceBag,
|
||||
@@ -43,12 +44,19 @@ export function createRoad(
|
||||
uniforms,
|
||||
vertexShader: `
|
||||
varying vec2 vRoadUv;
|
||||
varying float vRoadX;
|
||||
varying float vDepth;
|
||||
varying float vDistanceFade;
|
||||
|
||||
void main() {
|
||||
vRoadUv = uv;
|
||||
vRoadX = position.x;
|
||||
vDepth = smoothstep(0.0, 1.0, uv.y);
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
vec4 viewPosition = modelViewMatrix * vec4(position, 1.0);
|
||||
float viewDistance = -viewPosition.z;
|
||||
vDistanceFade = smoothstep(4.0, 16.0, viewDistance)
|
||||
* (1.0 - smoothstep(315.0, 430.0, viewDistance));
|
||||
gl_Position = projectionMatrix * viewPosition;
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
@@ -60,25 +68,38 @@ export function createRoad(
|
||||
uniform vec3 uEdgeColor;
|
||||
uniform vec3 uLaneColor;
|
||||
varying vec2 vRoadUv;
|
||||
varying float vRoadX;
|
||||
varying float vDepth;
|
||||
varying float vDistanceFade;
|
||||
|
||||
void main() {
|
||||
float roadX = abs(vRoadUv.x - 0.5) * 2.0;
|
||||
float centerValley = 1.0 - smoothstep(0.0, 0.82, roadX);
|
||||
float outerEdge = smoothstep(0.82, 0.9, roadX) * (1.0 - smoothstep(0.94, 1.0, roadX));
|
||||
float shoulder = smoothstep(0.66, 0.92, roadX) * 0.12;
|
||||
float laneWidth = 1.0 - smoothstep(0.012, 0.03, abs(vRoadUv.x - 0.5));
|
||||
float laneCycle = fract(vRoadUv.y * 34.0 + uDistance * (0.12 + uSpeed * 0.025));
|
||||
float laneDash = smoothstep(0.16, 0.24, laneCycle) * (1.0 - smoothstep(0.68, 0.78, laneCycle));
|
||||
float centerLane = laneWidth * laneDash * smoothstep(0.04, 0.22, vRoadUv.y);
|
||||
float edgeLuminance = outerEdge * (0.58 + uAcceleration * 0.34);
|
||||
float grain = sin((vRoadUv.y * 410.0 + vRoadUv.x * 37.0) + uDistance * 0.08) * 0.008;
|
||||
float roadX = abs(vRoadX);
|
||||
float coreX = roadX / 8.2;
|
||||
float centerValley = 1.0 - smoothstep(0.0, 0.86, coreX);
|
||||
float outerEdge = smoothstep(7.76, 7.93, roadX)
|
||||
* (1.0 - smoothstep(8.06, 8.19, roadX));
|
||||
float shoulder = smoothstep(7.2, 7.82, roadX)
|
||||
* (1.0 - smoothstep(8.18, 10.0, roadX));
|
||||
float shoulderFalloff = 1.0 - smoothstep(8.1, 10.2, roadX);
|
||||
float laneWidth = 1.0 - smoothstep(0.075, 0.18, roadX);
|
||||
float laneCycle = fract(vRoadUv.y * 33.7 + mod(uDistance, ${FLOW_PERIOD.toFixed(1)}) * 0.078);
|
||||
float laneDash = smoothstep(0.12, 0.2, laneCycle)
|
||||
* (1.0 - smoothstep(0.62, 0.72, laneCycle));
|
||||
float centerLane = laneWidth * laneDash * smoothstep(0.035, 0.18, vRoadUv.y);
|
||||
float edgeLuminance = outerEdge * (1.2 + uAcceleration * 0.68);
|
||||
float coarseAsphalt = sin(vRoadUv.y * 173.0 + roadX * 4.7 + uDistance * 0.08)
|
||||
* sin(vRoadUv.y * 47.0 - roadX * 8.1) * 0.006;
|
||||
float fineGrain = sin((vRoadUv.y * 530.0 + roadX * 5.0) + uDistance * 0.1) * 0.003;
|
||||
float horizonReflection = smoothstep(0.42, 0.94, vRoadUv.y)
|
||||
* exp(-roadX * 0.34) * (0.028 + uAcceleration * 0.018);
|
||||
|
||||
vec3 color = mix(uAsphaltLift, uAsphalt, centerValley * 0.84);
|
||||
color += uEdgeColor * (edgeLuminance + shoulder);
|
||||
color += uLaneColor * centerLane * (0.66 + uSpeed * 0.18);
|
||||
color += grain;
|
||||
color *= mix(0.7, 1.0, smoothstep(0.0, 0.24, vRoadUv.y));
|
||||
color += uEdgeColor * (edgeLuminance * 1.35 + shoulder * 0.18);
|
||||
color += uLaneColor * centerLane * (1.15 + uSpeed * 0.28);
|
||||
color += uLaneColor * horizonReflection;
|
||||
color += coarseAsphalt + fineGrain;
|
||||
color = mix(uAsphalt * 0.68, color, shoulderFalloff);
|
||||
color *= vDistanceFade * mix(0.58, 1.0, smoothstep(0.0, 0.2, vRoadUv.y));
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
`,
|
||||
@@ -95,7 +116,7 @@ export function createRoad(
|
||||
return {
|
||||
group,
|
||||
update(distance, speed, acceleration) {
|
||||
uniforms.uDistance.value = finiteOrZero(distance);
|
||||
uniforms.uDistance.value = ((finiteOrZero(distance) % FLOW_PERIOD) + FLOW_PERIOD) % FLOW_PERIOD;
|
||||
uniforms.uSpeed.value = clamp01(speed);
|
||||
uniforms.uAcceleration.value = clamp01(acceleration);
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CHAPTER_PALETTES } from '@/scene/world/palette';
|
||||
|
||||
export interface SunWorld {
|
||||
group: WorldGroup;
|
||||
update(palette: Readonly<MutableChapterPalette>, routeProgress: number): void;
|
||||
update(palette: Readonly<MutableChapterPalette>, routeProgress: number, elapsedSeconds: number): void;
|
||||
}
|
||||
|
||||
interface WorldGroup {
|
||||
@@ -20,11 +20,12 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
const sunColor = new THREE.Color(night.sun);
|
||||
const uniforms = {
|
||||
uColor: { value: sunColor },
|
||||
uScanBands: { value: 14 },
|
||||
uScanBands: { value: 11 },
|
||||
uWarmth: { value: night.warmth },
|
||||
uRouteProgress: { value: 0 },
|
||||
uTime: { value: 0 },
|
||||
};
|
||||
const geometry = resources.own('geometry', new THREE.CircleGeometry(20, 96));
|
||||
const geometry = resources.own('geometry', new THREE.CircleGeometry(44, 128));
|
||||
const material = resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: `
|
||||
@@ -40,17 +41,21 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
uniform float uScanBands;
|
||||
uniform float uWarmth;
|
||||
uniform float uRouteProgress;
|
||||
uniform float uTime;
|
||||
varying vec2 vSunUv;
|
||||
|
||||
void main() {
|
||||
float radius = distance(vSunUv, vec2(0.5));
|
||||
float softEdge = 1.0 - smoothstep(0.435, 0.5, radius);
|
||||
float lowerDisc = 1.0 - smoothstep(0.42, 0.76, vSunUv.y);
|
||||
float scanBand = smoothstep(0.16, 0.28, fract(vSunUv.y * uScanBands));
|
||||
float bandCut = mix(1.0, 0.32 + scanBand * 0.68, lowerDisc * 0.82);
|
||||
float horizonFade = smoothstep(0.02, 0.14, vSunUv.y);
|
||||
float luminance = 0.72 + uWarmth * 0.18 + uRouteProgress * 0.06;
|
||||
gl_FragColor = vec4(uColor * luminance, softEdge * bandCut * horizonFade * 0.9);
|
||||
float softEdge = 1.0 - smoothstep(0.475, 0.5, radius);
|
||||
float lowerDisc = 1.0 - smoothstep(0.46, 0.84, vSunUv.y);
|
||||
float scanPhase = fract(vSunUv.y * uScanBands + sin(uTime * 0.12) * 0.012);
|
||||
float scanBand = smoothstep(0.12, 0.2, scanPhase)
|
||||
* (1.0 - smoothstep(0.58, 0.72, scanPhase));
|
||||
float bandCut = mix(1.0, 0.18 + scanBand * 0.82, lowerDisc * 0.9);
|
||||
float horizonFade = smoothstep(0.01, 0.11, vSunUv.y);
|
||||
float verticalHeat = mix(0.78, 1.3, smoothstep(0.1, 0.88, vSunUv.y));
|
||||
float luminance = verticalHeat * (1.28 + uWarmth * 0.24 + uRouteProgress * 0.08);
|
||||
gl_FragColor = vec4(uColor * luminance, softEdge * bandCut * horizonFade);
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
@@ -60,8 +65,8 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
toneMapped: true,
|
||||
}));
|
||||
const disc = new THREE.Mesh(geometry, material);
|
||||
disc.name = 'sun-disc';
|
||||
disc.position.set(0, 11.5, -142);
|
||||
disc.name = 'horizon-sun';
|
||||
disc.position.set(0, 28, -410);
|
||||
group.add(disc);
|
||||
|
||||
const haloTexture = resources.own('texture', createHaloTexture());
|
||||
@@ -76,22 +81,23 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
toneMapped: true,
|
||||
}));
|
||||
const halo = new THREE.Sprite(haloMaterial);
|
||||
halo.name = 'sun-halo';
|
||||
halo.name = 'horizon-halo';
|
||||
halo.position.copy(disc.position);
|
||||
halo.position.z -= 0.5;
|
||||
halo.scale.set(57, 43, 1);
|
||||
halo.scale.set(220, 138, 1);
|
||||
group.add(halo);
|
||||
|
||||
return {
|
||||
group,
|
||||
update(palette, routeProgress) {
|
||||
update(palette, routeProgress, elapsedSeconds) {
|
||||
const progress = clamp01(routeProgress);
|
||||
sunColor.copy(palette.sun);
|
||||
haloMaterial.color.copy(palette.sun);
|
||||
uniforms.uWarmth.value = clamp01(palette.warmth);
|
||||
uniforms.uRouteProgress.value = progress;
|
||||
haloMaterial.opacity = 0.18 + palette.warmth * 0.12;
|
||||
disc.position.y = 11.5 + palette.warmth * 4.8;
|
||||
uniforms.uTime.value = Number.isFinite(elapsedSeconds) ? elapsedSeconds : 0;
|
||||
haloMaterial.opacity = 0.12 + palette.warmth * 0.08;
|
||||
disc.position.y = 28 + palette.warmth * 7;
|
||||
halo.position.y = disc.position.y;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,11 +20,12 @@ interface WorldGroup {
|
||||
getObjectByName(name: string): unknown;
|
||||
}
|
||||
|
||||
const TERRAIN_WIDTH = 160;
|
||||
const TERRAIN_DEPTH = 300;
|
||||
const NEAR_Z = 14;
|
||||
const ROAD_CLEAR_HALF_WIDTH = 11.4;
|
||||
const RIDGE_START = 24;
|
||||
const TERRAIN_WIDTH = 360;
|
||||
const TERRAIN_DEPTH = 438;
|
||||
const NEAR_Z = 28;
|
||||
const ROAD_CLEAR_HALF_WIDTH = 9.4;
|
||||
const RIDGE_START = 46;
|
||||
const NOISE_PERIOD = 5_694;
|
||||
|
||||
export function createTerrain(
|
||||
resources: SceneResourceBag,
|
||||
@@ -42,6 +43,8 @@ export function createTerrain(
|
||||
uRouteProgress: { value: 0 },
|
||||
uTerrainEnergy: { value: initialPalette.terrainEnergy },
|
||||
uOctaves: { value: quality.terrainOctaves },
|
||||
uRelief: { value: 34 },
|
||||
uFar: { value: 430 },
|
||||
uLowColor: { value: lowColor },
|
||||
uHighColor: { value: highColor },
|
||||
};
|
||||
@@ -54,12 +57,15 @@ export function createTerrain(
|
||||
uniform float uRouteProgress;
|
||||
uniform float uTerrainEnergy;
|
||||
uniform float uOctaves;
|
||||
uniform float uRelief;
|
||||
uniform float uFar;
|
||||
varying float vHeight;
|
||||
varying float vRoadMask;
|
||||
varying float vDepthFade;
|
||||
varying float vFlow;
|
||||
|
||||
float hash21(vec2 point) {
|
||||
point = mod(point, 256.0);
|
||||
point = fract(point * vec2(123.34, 456.21));
|
||||
point += dot(point, point + 45.32);
|
||||
return fract(point.x * point.y);
|
||||
@@ -85,19 +91,22 @@ export function createTerrain(
|
||||
|
||||
void main() {
|
||||
vec3 displaced = position;
|
||||
float wrappedPhase = mod(uDistance * (0.18 + uSpeed * 0.06), 96.0);
|
||||
float ridgeNoise = multiOctaveNoise(vec2(position.x * 0.038, (position.z - wrappedPhase) * 0.028));
|
||||
float macroRidge = multiOctaveNoise(vec2(position.x * 0.014 + 31.0, position.z * 0.012));
|
||||
float edgeGrowth = mix(0.6, 1.0, smoothstep(0.0, 72.0, abs(position.x)));
|
||||
float height = roadMask * (0.25 + ridgeNoise * ridgeNoise * 8.8 + macroRidge * 2.4);
|
||||
height *= edgeGrowth * uTerrainEnergy;
|
||||
displaced.y += height - 0.22;
|
||||
float wrappedPhase = mod(uDistance, ${NOISE_PERIOD.toFixed(1)});
|
||||
const float S = 0.04495961;
|
||||
vec2 world = vec2(position.x, position.z - wrappedPhase);
|
||||
float ridgeNoise = multiOctaveNoise(world * S);
|
||||
float edgeGrowth = mix(7.0, uRelief, smoothstep(14.0, 150.0, abs(position.x)));
|
||||
float height = roadMask * ridgeNoise * edgeGrowth * uTerrainEnergy;
|
||||
displaced.y += height;
|
||||
|
||||
vHeight = clamp(height / 10.0, 0.0, 1.0);
|
||||
vHeight = clamp(height / max(1.0, uRelief), 0.0, 1.0);
|
||||
vRoadMask = roadMask;
|
||||
vDepthFade = smoothstep(-286.0, -205.0, position.z) * (1.0 - smoothstep(7.0, 14.0, position.z));
|
||||
vec4 viewPosition = modelViewMatrix * vec4(displaced, 1.0);
|
||||
float viewDistance = -viewPosition.z;
|
||||
vDepthFade = smoothstep(4.0, 18.0, viewDistance)
|
||||
* (1.0 - smoothstep(uFar * 0.32, uFar * 0.88, viewDistance));
|
||||
vFlow = fract((-position.z + uDistance * (0.9 + uSpeed * 0.25)) / 11.0);
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(displaced, 1.0);
|
||||
gl_Position = projectionMatrix * viewPosition;
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
@@ -112,10 +121,10 @@ export function createTerrain(
|
||||
void main() {
|
||||
float ridgeMix = smoothstep(0.16, 0.78, vHeight);
|
||||
vec3 color = mix(uLowColor, uHighColor, ridgeMix);
|
||||
float pulse = 0.78 + 0.22 * smoothstep(0.18, 0.52, vFlow);
|
||||
float pulse = 0.72 + 0.28 * smoothstep(0.16, 0.55, vFlow);
|
||||
float routeCalm = mix(1.0, 0.9, smoothstep(0.82, 1.0, uRouteProgress));
|
||||
float alpha = vRoadMask * vDepthFade * pulse * routeCalm * mix(0.34, 0.66, ridgeMix);
|
||||
gl_FragColor = vec4(color, alpha);
|
||||
float alpha = vRoadMask * vDepthFade * pulse * routeCalm * mix(0.32, 0.82, ridgeMix);
|
||||
gl_FragColor = vec4(color * mix(0.78, 1.55, ridgeMix), alpha);
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
@@ -126,7 +135,7 @@ export function createTerrain(
|
||||
}));
|
||||
|
||||
const lines = new THREE.LineSegments(geometry, material);
|
||||
lines.name = 'terrain-lines';
|
||||
lines.name = 'terrain-grid';
|
||||
lines.frustumCulled = false;
|
||||
group.add(lines);
|
||||
|
||||
@@ -134,7 +143,7 @@ export function createTerrain(
|
||||
group,
|
||||
update(distance, speed, chapter, routeProgress, palette) {
|
||||
const chapterPalette = CHAPTER_PALETTES[chapter];
|
||||
uniforms.uDistance.value = finiteOrZero(distance);
|
||||
uniforms.uDistance.value = ((finiteOrZero(distance) % NOISE_PERIOD) + NOISE_PERIOD) % NOISE_PERIOD;
|
||||
uniforms.uSpeed.value = clamp01(speed);
|
||||
uniforms.uRouteProgress.value = clamp01(routeProgress);
|
||||
uniforms.uTerrainEnergy.value = palette?.terrainEnergy ?? chapterPalette.terrainEnergy;
|
||||
@@ -151,7 +160,8 @@ export function createTerrain(
|
||||
|
||||
function createIndexedTerrainGeometry(segments: number) {
|
||||
const zSegments = Math.max(16, Math.floor(segments));
|
||||
const xSegments = Math.max(24, Math.floor(zSegments * 0.75 / 2) * 2);
|
||||
const approximateCell = TERRAIN_DEPTH / zSegments;
|
||||
const xSegments = Math.max(48, Math.floor((TERRAIN_WIDTH / approximateCell) / 2) * 2);
|
||||
const columnCount = xSegments + 1;
|
||||
const rowCount = zSegments + 1;
|
||||
const positions = new Float32Array(columnCount * rowCount * 3);
|
||||
|
||||
Reference in New Issue
Block a user