202 lines
7.8 KiB
TypeScript
202 lines
7.8 KiB
TypeScript
import * as THREE from 'three';
|
|
import type { SceneResourceBag } from '@/scene/resources';
|
|
import type { RenderQualitySettings } from '@/scene/render-quality';
|
|
|
|
/**
|
|
* Other cars on the expressway, seen only as their lamps: taillight pairs,
|
|
* long-exposure trails whose length follows the speed difference, and red
|
|
* pools on the wet asphalt. Scrolling faster than the traffic overtakes it;
|
|
* resting lets the faster lanes pull away. Instances wrap through a hidden band
|
|
* behind the camera, so nothing pops in view.
|
|
*/
|
|
/**
|
|
* The hero car renders at 2.78x scale: its body spans |x| < 2.6 and its nose
|
|
* reaches z = -5.5. Traffic never enters that envelope or the near field
|
|
* around it. Lamps fade out over TRAFFIC_FADE_LENGTH before TRAFFIC_NEAR_Z and
|
|
* wrap inside the hidden band, so no light ever slides past the car (round 5:
|
|
* bodiless lamp pairs passing the flank read as the car's own taillights
|
|
* coming loose).
|
|
*/
|
|
export const HERO_CAR_HALF_WIDTH = 2.6;
|
|
export const TRAFFIC_NEAR_Z = -24;
|
|
export const TRAFFIC_FADE_LENGTH = 22;
|
|
const FAR_Z = -660;
|
|
const SPAN = TRAFFIC_NEAR_Z - FAR_Z;
|
|
/** Lane centres. Fast lanes on the left, slower trucks on the right. */
|
|
export const TRAFFIC_LANES = [-5.7, -5.7, -4.3, 5.4, 5.7] as const;
|
|
export const TRAFFIC_LAMP_HALF_SPACING = { fast: 0.9, slow: 1.0 } as const;
|
|
|
|
/** Visibility of a lamp, trail or pool at world z (1 = fully lit). */
|
|
export function trafficNearFade(z: number): number {
|
|
const t = Math.min(1, Math.max(0, (TRAFFIC_NEAR_Z - z) / TRAFFIC_FADE_LENGTH));
|
|
return t * t * (3 - 2 * t);
|
|
}
|
|
|
|
/** Keeps a vehicle inside [FAR_Z, TRAFFIC_NEAR_Z], wrapping through the
|
|
* hidden band at either end. */
|
|
export function wrapTrafficZ(z: number): number {
|
|
if (z > TRAFFIC_NEAR_Z) return z - SPAN * Math.ceil((z - TRAFFIC_NEAR_Z) / SPAN);
|
|
if (z < FAR_Z) return z + SPAN * Math.ceil((FAR_Z - z) / SPAN);
|
|
return z;
|
|
}
|
|
|
|
interface Vehicle {
|
|
x: number;
|
|
z: number;
|
|
speed: number;
|
|
width: number;
|
|
height: number;
|
|
tint: number;
|
|
}
|
|
|
|
export function createTraffic(
|
|
resources: SceneResourceBag,
|
|
quality: RenderQualitySettings,
|
|
) {
|
|
const group = new THREE.Group();
|
|
group.name = 'expressway-traffic';
|
|
const count = quality.postEffects ? (quality.postScale >= 1 ? 14 : 10) : 7;
|
|
const lanes = TRAFFIC_LANES;
|
|
const vehicles: Vehicle[] = [];
|
|
let seed = 7;
|
|
const random = () => {
|
|
seed = (seed * 16807) % 2147483647;
|
|
return (seed - 1) / 2147483646;
|
|
};
|
|
for (let i = 0; i < count; i++) {
|
|
const lane = lanes[i % lanes.length]!;
|
|
// Left lanes run faster than the cruise, the right lane is slower trucks.
|
|
const fast = lane < 0;
|
|
vehicles.push({
|
|
x: lane + (random() - 0.5) * 0.5,
|
|
z: FAR_Z + 20 + random() * (SPAN - 40),
|
|
speed: fast ? 26 + random() * 22 : 9 + random() * 6,
|
|
width: fast ? TRAFFIC_LAMP_HALF_SPACING.fast : TRAFFIC_LAMP_HALF_SPACING.slow,
|
|
height: fast ? 0.95 : 1.55 + random() * 0.4,
|
|
tint: random(),
|
|
});
|
|
}
|
|
|
|
const uniforms = { uTime: { value: 0 } };
|
|
const vertexShader = /* glsl */ `
|
|
attribute float aLevel;
|
|
varying vec2 vUv;
|
|
varying float vDepth;
|
|
varying float vLevel;
|
|
varying float vNear;
|
|
void main() {
|
|
vUv = uv;
|
|
vLevel = aLevel;
|
|
vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0);
|
|
// Matches trafficNearFade(): dark before the hero car's near field.
|
|
vNear = smoothstep(0.0, 1.0, clamp((${TRAFFIC_NEAR_Z.toFixed(1)} - world.z) / ${TRAFFIC_FADE_LENGTH.toFixed(1)}, 0.0, 1.0));
|
|
vec4 view = viewMatrix * world;
|
|
vDepth = -view.z;
|
|
gl_Position = projectionMatrix * view;
|
|
}`;
|
|
const fragment = (shape: string) => /* glsl */ `
|
|
varying vec2 vUv;
|
|
varying float vDepth;
|
|
varying float vLevel;
|
|
varying float vNear;
|
|
void main() {
|
|
float far = 1.0 - smoothstep(420.0, 660.0, vDepth);
|
|
float near = vNear;
|
|
${shape}
|
|
vec3 color = mix(vec3(1.0, 0.03, 0.06), vec3(1.0, 0.22, 0.05), vLevel);
|
|
gl_FragColor = vec4(color * glow * far * near, 1.0);
|
|
#include <tonemapping_fragment>
|
|
#include <colorspace_fragment>
|
|
}`;
|
|
const additive = (shape: string) =>
|
|
resources.own(
|
|
'material',
|
|
new THREE.ShaderMaterial({
|
|
uniforms,
|
|
vertexShader,
|
|
fragmentShader: fragment(shape),
|
|
transparent: true,
|
|
depthWrite: false,
|
|
depthTest: true,
|
|
blending: THREE.AdditiveBlending,
|
|
side: THREE.DoubleSide,
|
|
}),
|
|
);
|
|
// Lamps: a hot core in a soft disc, bright enough to bloom.
|
|
const lampMaterial = additive(`
|
|
vec2 c = vUv - 0.5;
|
|
float r = length(c * vec2(1.0, 1.6));
|
|
float glow = exp(-r * r * 26.0) * 3.2 + exp(-r * r * 5.0) * 0.5;`);
|
|
// Trails: thin, brightest at the lamp, fading over their length.
|
|
const trailMaterial = additive(`
|
|
float across = exp(-pow((vUv.x - 0.5) * 5.0, 2.0));
|
|
float glow = across * pow(vUv.y, 1.6) * 1.1;`);
|
|
// Wet-road pools: long and soft under each car.
|
|
const poolMaterial = additive(`
|
|
float across = exp(-pow((vUv.x - 0.5) * 3.2, 2.0));
|
|
float glow = across * pow(vUv.y, 2.2) * 0.2;`);
|
|
|
|
const plane = resources.own('geometry', new THREE.PlaneGeometry(1, 1));
|
|
const lampCount = count * 2;
|
|
const mesh = (name: string, material: unknown, instances: number) => {
|
|
const instanced = new THREE.InstancedMesh(plane, material, instances);
|
|
instanced.name = name;
|
|
instanced.frustumCulled = false;
|
|
instanced.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
|
const levels = new Float32Array(instances);
|
|
for (let i = 0; i < instances; i++) levels[i] = vehicles[Math.floor(i / 2) % count]!.tint * 0.6;
|
|
instanced.geometry = plane.clone();
|
|
resources.own('geometry', instanced.geometry);
|
|
instanced.geometry.setAttribute('aLevel', new THREE.InstancedBufferAttribute(levels, 1));
|
|
group.add(instanced);
|
|
return instanced;
|
|
};
|
|
const lamps = mesh('traffic-taillights', lampMaterial, lampCount);
|
|
const trails = mesh('traffic-light-trails', trailMaterial, lampCount);
|
|
const pools = mesh('traffic-road-pools', poolMaterial, count);
|
|
|
|
const dummy = new THREE.Object3D();
|
|
return {
|
|
group,
|
|
/**
|
|
* @param travelSpeed world units per second the camera car is covering.
|
|
*/
|
|
update(deltaSeconds: number, travelSpeed: number, time: number) {
|
|
uniforms.uTime.value = time;
|
|
const dt = Math.min(0.1, Math.max(0, deltaSeconds));
|
|
for (let i = 0; i < count; i++) {
|
|
const v = vehicles[i]!;
|
|
const relative = travelSpeed - v.speed; // > 0: we are overtaking
|
|
v.z = wrapTrafficZ(v.z + relative * dt);
|
|
const trailLength = THREE.MathUtils.clamp(Math.abs(relative) * 0.16, 0.3, 14);
|
|
// Trails stream away from the direction of relative travel.
|
|
const trailDirection = relative >= 0 ? -1 : 1;
|
|
for (let side = 0; side < 2; side++) {
|
|
const index = i * 2 + side;
|
|
const x = v.x + (side === 0 ? -v.width : v.width);
|
|
dummy.position.set(x, v.height, v.z);
|
|
dummy.rotation.set(0, 0, 0);
|
|
dummy.scale.set(0.62, 0.36, 1);
|
|
dummy.updateMatrix();
|
|
lamps.setMatrixAt(index, dummy.matrix);
|
|
// Horizontal ribbon at lamp height, uv.y = 1 at the lamp.
|
|
dummy.position.set(x, v.height - 0.02, v.z + (trailDirection * trailLength) / 2);
|
|
dummy.rotation.set(trailDirection < 0 ? Math.PI / 2 : -Math.PI / 2, 0, 0);
|
|
dummy.scale.set(0.2, trailLength, 1);
|
|
dummy.updateMatrix();
|
|
trails.setMatrixAt(index, dummy.matrix);
|
|
}
|
|
const poolLength = 9 + Math.min(10, Math.abs(relative) * 0.08);
|
|
dummy.position.set(v.x, 0.04, v.z + poolLength / 2);
|
|
dummy.rotation.set(-Math.PI / 2, 0, 0);
|
|
dummy.scale.set(v.width * 2.3, poolLength, 1);
|
|
dummy.updateMatrix();
|
|
pools.setMatrixAt(i, dummy.matrix);
|
|
}
|
|
lamps.instanceMatrix.needsUpdate = true;
|
|
trails.instanceMatrix.needsUpdate = true;
|
|
pools.instanceMatrix.needsUpdate = true;
|
|
},
|
|
};
|
|
}
|