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 #include }`; 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; }, }; }