584 lines
26 KiB
TypeScript
584 lines
26 KiB
TypeScript
import * as THREE from 'three';
|
|
import type { SceneResourceBag } from '@/scene/resources';
|
|
import type { RenderQualitySettings } from '@/scene/render-quality';
|
|
import { BEAM_PARS, beamUniforms } from '@/scene/world/headlights';
|
|
import {
|
|
TRAFFIC_BEHIND,
|
|
TRAFFIC_CAR,
|
|
TRAFFIC_KINDS,
|
|
TRAFFIC_PENDING,
|
|
TRAFFIC_TRUCK,
|
|
createTrafficModel,
|
|
type TrafficFrame,
|
|
type TrafficInput,
|
|
} from '@/scene/world/traffic-model';
|
|
|
|
/**
|
|
* Other vehicles on the night shift, drawn from the car-following model in
|
|
* traffic-model.ts: dark low-poly cars and box trucks at a scale that reads
|
|
* beside the 2.78x hero, with live rear lamps (tails that brighten under
|
|
* braking, a high-mount stop lamp that only lights then, amber indicators at
|
|
* 1.5 Hz, clearance lamps on the trucks), long-exposure trails, red pools
|
|
* behind them and warm headlight pools ahead of them on the wet asphalt.
|
|
* Their tail lamps and the trucks' under-run bars are retro-reflective: they
|
|
* flare when the reader's high beam (pointer aim, click to flash) finds them.
|
|
*
|
|
* Vehicles pass alongside the hero and leave beneath the chase camera; only
|
|
* a truck in the lane under the camera fades before it would fill the lens.
|
|
*/
|
|
/** The hero car's body (2.78x) spans |x| < 2.6. */
|
|
export const HERO_CAR_HALF_WIDTH = 2.6;
|
|
/** A vehicle fades over this many metres before its near limit. */
|
|
export const TRAFFIC_FADE_LENGTH = 22;
|
|
|
|
export type TrafficKind = 'car' | 'truck';
|
|
|
|
/** 1 lit, 0 dark: the near fade of a point at world z for a near limit. */
|
|
export function trafficNearFade(z: number, nearLimit: number): number {
|
|
const t = Math.min(1, Math.max(0, (nearLimit - z) / TRAFFIC_FADE_LENGTH));
|
|
return t * t * (3 - 2 * t);
|
|
}
|
|
|
|
export type TrafficLampRole = 'tail' | 'stop' | 'clearance' | 'indicator';
|
|
export interface TrafficLamp {
|
|
x: number;
|
|
y: number;
|
|
z: number;
|
|
w: number;
|
|
h: number;
|
|
role: TrafficLampRole;
|
|
/** -1 left, 1 right (indicators), 0 otherwise. */
|
|
side: number;
|
|
}
|
|
|
|
/** Rear lamps in 1x body space (origin at the rear face, ground level). */
|
|
export function trafficLamps(kind: TrafficKind): TrafficLamp[] {
|
|
if (kind === 'car')
|
|
return [
|
|
{ x: -0.62, y: 0.92, z: 0.05, w: 0.44, h: 0.3, role: 'tail', side: 0 },
|
|
{ x: 0.62, y: 0.92, z: 0.05, w: 0.44, h: 0.3, role: 'tail', side: 0 },
|
|
// High-mounted stop lamp on the rear window's top edge: braking only.
|
|
{ x: 0, y: 1.55, z: -0.9, w: 0.62, h: 0.12, role: 'stop', side: 0 },
|
|
{ x: -0.96, y: 0.92, z: 0.05, w: 0.24, h: 0.26, role: 'indicator', side: -1 },
|
|
{ x: 0.96, y: 0.92, z: 0.05, w: 0.24, h: 0.26, role: 'indicator', side: 1 },
|
|
];
|
|
return [
|
|
{ x: -0.72, y: 1.3, z: 0.05, w: 0.42, h: 0.32, role: 'tail', side: 0 },
|
|
{ x: 0.72, y: 1.3, z: 0.05, w: 0.42, h: 0.32, role: 'tail', side: 0 },
|
|
// Amber clearance lamps at the top corners give the box its outline.
|
|
{ x: -1.12, y: 3.9, z: 0.05, w: 0.26, h: 0.16, role: 'clearance', side: 0 },
|
|
{ x: 1.12, y: 3.9, z: 0.05, w: 0.26, h: 0.16, role: 'clearance', side: 0 },
|
|
{ x: -1.07, y: 1.3, z: 0.05, w: 0.26, h: 0.3, role: 'indicator', side: -1 },
|
|
{ x: 1.07, y: 1.3, z: 0.05, w: 0.26, h: 0.3, role: 'indicator', side: 1 },
|
|
];
|
|
}
|
|
export const LAMPS_PER_VEHICLE = 6;
|
|
|
|
/** 1x body half widths and lengths; the model's sizes are these times the kind's scale. */
|
|
export const TRAFFIC_BODY = {
|
|
car: { halfWidth: 1.1, length: 4.8, roof: 1.6 },
|
|
truck: { halfWidth: 1.25, length: 15, roof: 4.05 },
|
|
} as const;
|
|
|
|
/** Axis-aligned boxes [x0, x1, y0, y1, z0, z1] in 1x body space. */
|
|
type Box = readonly [number, number, number, number, number, number];
|
|
const CAR_BOXES: readonly Box[] = [
|
|
[-1.1, 1.1, 0.34, 1.1, -4.8, 0], // body
|
|
[-0.92, 0.92, 1.1, 1.6, -3.5, -0.95], // cabin (the rear face is the window)
|
|
[-1.08, -0.72, 0, 0.6, -1.25, -0.45], // wheels
|
|
[0.72, 1.08, 0, 0.6, -1.25, -0.45],
|
|
[-1.08, -0.72, 0, 0.6, -4.25, -3.45],
|
|
[0.72, 1.08, 0, 0.6, -4.25, -3.45],
|
|
];
|
|
const TRUCK_BOXES: readonly Box[] = [
|
|
[-1.25, 1.25, 1.05, 4.05, -12.4, 0], // box body
|
|
[-1.18, 1.18, 0.55, 3.3, -15, -12.6], // cab
|
|
[-1.1, 1.1, 0.42, 0.56, -0.12, 0.02], // under-run bar
|
|
[-1.2, -0.7, 0, 1.0, -3.5, -1.5], // rear tandem
|
|
[0.7, 1.2, 0, 1.0, -3.5, -1.5],
|
|
[-1.2, -0.7, 0, 1.0, -14.4, -13.2], // front axle
|
|
[0.7, 1.2, 0, 1.0, -14.4, -13.2],
|
|
];
|
|
|
|
function boxGeometry(boxes: readonly Box[]) {
|
|
const positions: number[] = [];
|
|
const normals: number[] = [];
|
|
for (const [x0, x1, y0, y1, z0, z1] of boxes) {
|
|
const part = new THREE.BoxGeometry(x1 - x0, y1 - y0, z1 - z0).toNonIndexed();
|
|
part.translate((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2);
|
|
positions.push(...(part.getAttribute('position').array as Float32Array));
|
|
normals.push(...(part.getAttribute('normal').array as Float32Array));
|
|
part.dispose();
|
|
}
|
|
const geometry = new THREE.BufferGeometry();
|
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
|
|
return geometry;
|
|
}
|
|
|
|
/** Level codes for the additive quads: <1 red, 2 amber, 3 warm headlight. */
|
|
const AMBER = 2;
|
|
const WARM = 3;
|
|
/** Indicators: brighter and larger than the lamp while lit, so the blink reads at 200 m. */
|
|
export const INDICATOR_GAIN = 2.4;
|
|
export const INDICATOR_SWELL = 1.6;
|
|
/** Tail lamps swell this much at full high-beam lift (the flash to pass). */
|
|
export const FLARE_SWELL = 0.3;
|
|
const FRONT_POOL_LENGTH = 14;
|
|
const PASS_Z = 8;
|
|
/** Camera-side vehicles dissolve over this z band as they pass beneath the lens. */
|
|
const UNDER_FADE_START = 6;
|
|
const UNDER_FADE_END = 22;
|
|
const underFade = (z: number): number => {
|
|
const t = Math.min(1, Math.max(0, (z - UNDER_FADE_START) / (UNDER_FADE_END - UNDER_FADE_START)));
|
|
return 1 - t * t * (3 - 2 * t);
|
|
};
|
|
const PASS_RELATIVE_SPEED = 6;
|
|
|
|
/** Pool size per quality tier: 12 high, 10 medium, 6 low (simplified). */
|
|
export function trafficPoolSize(quality: Pick<RenderQualitySettings, 'postEffects' | 'postScale'>): 6 | 10 | 12 {
|
|
return quality.postEffects ? (quality.postScale >= 1 ? 12 : 10) : 6;
|
|
}
|
|
|
|
/** Is a vehicle at x in the lane beneath a chase camera at cameraX? */
|
|
export function trafficCameraSide(x: number, cameraX: number): boolean {
|
|
return Number.isFinite(cameraX) && Math.abs(cameraX) > 1.5 && Math.abs(x) > 2.7 && Math.sign(x) === Math.sign(cameraX);
|
|
}
|
|
const cameraSide = trafficCameraSide;
|
|
|
|
export function createTraffic(
|
|
resources: SceneResourceBag,
|
|
quality: RenderQualitySettings,
|
|
/** The horizon fog colour: roofs and upper edges catch it as sheen. */
|
|
sheen: unknown = new THREE.Color(0x8a3d7a),
|
|
) {
|
|
const group = new THREE.Group();
|
|
group.name = 'expressway-traffic';
|
|
const pool = trafficPoolSize(quality);
|
|
const lite = pool === 6;
|
|
const model = createTrafficModel({ pool, seed: 7, lite });
|
|
const frame = model.frame;
|
|
const kinds: TrafficKind[] = Array.from(frame.kind, (k) => (k === TRAFFIC_TRUCK ? 'truck' : 'car'));
|
|
let seed = 11;
|
|
const random = () => {
|
|
seed = (seed * 16807) % 2147483647;
|
|
return (seed - 1) / 2147483646;
|
|
};
|
|
const tints = kinds.map(() => random());
|
|
const lampsFor = kinds.map((kind) => trafficLamps(kind));
|
|
|
|
const uniforms = { uTime: { value: 0 }, uSheen: { value: sheen }, ...beamUniforms };
|
|
const vertexShader = /* glsl */ `
|
|
${BEAM_PARS}
|
|
attribute float aLevel;
|
|
attribute vec2 aGain;
|
|
varying vec2 vUv;
|
|
varying float vDepth;
|
|
varying float vLevel;
|
|
varying float vGain;
|
|
varying float vFlare;
|
|
void main() {
|
|
vUv = uv;
|
|
vLevel = aLevel;
|
|
vGain = aGain.x;
|
|
vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0);
|
|
// Retro-reflection: the lamp's centre in the beam's cone, out to 420 m.
|
|
vFlare = 0.0;
|
|
if (aGain.y > 0.0) {
|
|
vec3 centre = (modelMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0)).xyz;
|
|
float lift = smoothstep(0.78, 1.0, uBeamGain);
|
|
// Not up close (a near car's lamps would bloom into a bright patch).
|
|
float d = distance(centre, uBeamOrigin);
|
|
float reach = smoothstep(18.0, 50.0, d) * (1.0 - smoothstep(180.0, 420.0, d));
|
|
// Brightness stays where it was (the bloom pass spreads anything
|
|
// hotter into a white patch); the flash reads by colour and size.
|
|
vFlare = aGain.y * headlightCone(centre) * reach * (0.25 + 1.6 * lift);
|
|
}
|
|
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 vGain;
|
|
varying float vFlare;
|
|
void main() {
|
|
float far = 1.0 - smoothstep(420.0, 660.0, vDepth);
|
|
${shape}
|
|
vec3 color = mix(vec3(1.0, 0.03, 0.06), vec3(1.0, 0.22, 0.05), clamp(vLevel, 0.0, 1.0));
|
|
color = mix(color, vec3(1.0, 0.46, 0.06), step(1.5, vLevel));
|
|
color = mix(color, vec3(1.0, 0.86, 0.6), step(2.5, vLevel));
|
|
// A reflector answering the beam whitens toward the headlight colour.
|
|
color = mix(color, vec3(1.0, 0.72, 0.58), clamp(vFlare, 0.0, 1.0) * 0.7);
|
|
gl_FragColor = vec4(color * glow * (vGain + vFlare) * far, 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 lamp-shaped core in a soft disc, bright enough to bloom.
|
|
const lampMaterial = additive(`
|
|
vec2 c = vUv - 0.5;
|
|
float core = exp(-pow(abs(c.x) * 2.3, 6.0) - pow(abs(c.y) * 2.4, 6.0));
|
|
float r = length(c * vec2(1.0, 1.6));
|
|
float glow = core * 2.4 + exp(-r * r * 22.0) * 1.2 + exp(-r * r * 5.0) * 0.3;`);
|
|
// Every pow() base is clamped: interpolation can land a fragment a hair
|
|
// outside 0..1 (a quad edge-on or clipped by the near plane), and pow() of a
|
|
// negative base is NaN. One NaN pixel in the HDR target is spread by the
|
|
// bloom blurs and the lens streak over the whole frame: a one-frame black
|
|
// flash (tests/unit/shader-pow-safety.test.ts).
|
|
// Trails: thin, brightest at the lamp, fading over their length.
|
|
const trailMaterial = additive(`
|
|
float across = exp(-pow(abs(vUv.x - 0.5) * 5.0, 2.0));
|
|
float glow = across * pow(clamp(vUv.y, 0.0, 1.0), 1.6) * 0.8;`);
|
|
// Wet-road pools: long and soft, brightest at the vehicle.
|
|
const poolMaterial = additive(`
|
|
float across = exp(-pow(abs(vUv.x - 0.5) * 3.2, 2.0));
|
|
float glow = across * pow(clamp(vUv.y, 0.0, 1.0), 2.2) * 0.2;`);
|
|
|
|
// Bodies: near-black coachwork that reads as a silhouette against the
|
|
// horizon, with the fog glow on the roof, the reader's headlights on the
|
|
// rear face, a glass rear window, door seams on the trucks, red lamp spill
|
|
// (stronger under braking) and the screen-door fade of an emergency exit.
|
|
const bodyMaterial = (kind: TrafficKind) =>
|
|
resources.own(
|
|
'material',
|
|
new THREE.ShaderMaterial({
|
|
uniforms,
|
|
vertexShader: /* glsl */ `
|
|
attribute vec4 aBody;
|
|
attribute float aBlink;
|
|
varying vec3 vLocal;
|
|
varying vec3 vNormal;
|
|
varying vec3 vWorld;
|
|
varying float vDepth;
|
|
varying float vNear;
|
|
varying float vDither;
|
|
varying float vBrake;
|
|
varying float vUnder;
|
|
varying float vBlink;
|
|
void main() {
|
|
vLocal = position;
|
|
vBlink = aBlink;
|
|
vNormal = normal;
|
|
vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0);
|
|
vWorld = world.xyz;
|
|
// Matches trafficNearFade(): only a camera-side truck has a near limit in view.
|
|
vNear = smoothstep(0.0, 1.0, clamp((aBody.x - world.z) / ${TRAFFIC_FADE_LENGTH.toFixed(1)}, 0.0, 1.0));
|
|
vDither = aBody.y;
|
|
vBrake = aBody.z;
|
|
// Under the chase camera's own lane, a body dissolves as it slides
|
|
// beneath the lens instead of filling the frame's bottom edge.
|
|
vUnder = aBody.w * smoothstep(${UNDER_FADE_START.toFixed(1)}, ${UNDER_FADE_END.toFixed(1)}, world.z);
|
|
vec4 view = viewMatrix * world;
|
|
vDepth = -view.z;
|
|
gl_Position = projectionMatrix * view;
|
|
}`,
|
|
fragmentShader: /* glsl */ `
|
|
${BEAM_PARS}
|
|
uniform vec3 uSheen;
|
|
varying vec3 vLocal;
|
|
varying vec3 vNormal;
|
|
varying vec3 vWorld;
|
|
varying float vDepth;
|
|
varying float vNear;
|
|
varying float vDither;
|
|
varying float vBrake;
|
|
varying float vUnder;
|
|
varying float vBlink;
|
|
void main() {
|
|
float alpha = (1.0 - smoothstep(420.0, 660.0, vDepth)) * vNear;
|
|
if (alpha < 0.02) discard;
|
|
float screen = max(vDither, vUnder);
|
|
if (screen > 0.0 && fract(sin(dot(floor(gl_FragCoord.xy), vec2(12.9898, 78.233))) * 43758.5453) < screen) discard;
|
|
vec3 n = normalize(vNormal);
|
|
float up = max(0.0, n.y);
|
|
float rear = max(0.0, n.z);
|
|
float side = abs(n.x);
|
|
float wheel = ${kind === 'car' ? 'step(vLocal.y, 0.6) * step(0.7, abs(vLocal.x))' : 'step(vLocal.y, 1.0) * step(0.68, abs(vLocal.x))'};
|
|
vec3 color = vec3(0.034, 0.02, 0.058);
|
|
// Roof and shoulders: the horizon glow as a satin sheen.
|
|
color += uSheen * (up * 0.42 + side * 0.07 * smoothstep(0.5, 1.4, vLocal.y));
|
|
// The reader's headlights on the rear face: a faint fill down the
|
|
// road, the real beam (pointer aim, high-beam flash) up close.
|
|
float fill = exp(-max(0.0, vDepth - 40.0) / 90.0) * 0.3;
|
|
color += vec3(0.19, 0.15, 0.27) * rear * (fill + headlightBeam(vWorld));
|
|
// A flash to pass paints the whole rear face from 300 m in.
|
|
float lift = smoothstep(0.78, 1.0, uBeamGain);
|
|
float beamDistance = distance(vWorld, uBeamOrigin);
|
|
color += vec3(0.62, 0.5, 0.4) * rear * headlightCone(vWorld) * (1.0 - smoothstep(120.0, 320.0, beamDistance)) * lift * 0.16;
|
|
${
|
|
kind === 'car'
|
|
? `// Rear window: glass reflects a lavender band of sky.
|
|
float glass = rear * step(1.12, vLocal.y) * step(vLocal.z, -0.9);
|
|
color = mix(color, vec3(0.05, 0.05, 0.12) + uSheen * 0.22 * smoothstep(1.2, 1.55, vLocal.y), glass);
|
|
float lampBand = exp(-pow(abs(vLocal.y - 0.92) / 0.18, 2.0));`
|
|
: `// Box doors: a centre seam and two lock bars.
|
|
float seam = 1.0 - smoothstep(0.02, 0.05, abs(vLocal.x));
|
|
float bars = 1.0 - smoothstep(0.03, 0.06, abs(abs(vLocal.x) - 0.42));
|
|
color *= 1.0 - rear * max(seam * 0.6, bars * 0.35);
|
|
float lampBand = exp(-pow(abs(vLocal.y - 1.3) / 0.2, 2.0));
|
|
// The under-run bar is a reflector strip: it answers the beam from far off.
|
|
float bar = rear * step(0.42, vLocal.y) * step(vLocal.y, 0.56);
|
|
color += vec3(1.0, 0.34, 0.22) * bar * headlightCone(vWorld) * smoothstep(18.0, 50.0, beamDistance) * (1.0 - smoothstep(180.0, 420.0, beamDistance)) * (0.2 + 1.4 * lift);`
|
|
}
|
|
// The tail lamps spill red on the bumper around them.
|
|
color += vec3(0.5, 0.02, 0.04) * rear * lampBand * 0.35 * (1.0 + vBrake * 1.5);
|
|
// ...and a lit indicator floods its corner amber.
|
|
color += vec3(1.0, 0.55, 0.08) * rear * abs(vBlink) * smoothstep(0.3, 0.95, vLocal.x * sign(vBlink)) * lampBand * 1.2;
|
|
color *= 1.0 - wheel * 0.6;
|
|
gl_FragColor = vec4(color, alpha);
|
|
#include <tonemapping_fragment>
|
|
#include <colorspace_fragment>
|
|
}`,
|
|
transparent: true,
|
|
depthWrite: true,
|
|
depthTest: true,
|
|
}),
|
|
);
|
|
|
|
const plane = resources.own('geometry', new THREE.PlaneGeometry(1, 1));
|
|
interface Quad {
|
|
mesh: InstanceType<typeof THREE.InstancedMesh>;
|
|
gain: Float32Array;
|
|
gainAttribute: InstanceType<typeof THREE.InstancedBufferAttribute>;
|
|
}
|
|
const hidden = new THREE.Matrix4().makeScale(0, 0, 0);
|
|
const quad = (name: string, material: unknown, instances: number, level: (i: number) => number): Quad => {
|
|
const geometry = resources.own('geometry', plane.clone());
|
|
const levels = new Float32Array(instances);
|
|
for (let i = 0; i < instances; i++) levels[i] = level(i);
|
|
geometry.setAttribute('aLevel', new THREE.InstancedBufferAttribute(levels, 1));
|
|
const gain = new Float32Array(instances * 2);
|
|
const gainAttribute = new THREE.InstancedBufferAttribute(gain, 2);
|
|
gainAttribute.setUsage(THREE.DynamicDrawUsage);
|
|
geometry.setAttribute('aGain', gainAttribute);
|
|
const mesh = new THREE.InstancedMesh(geometry, material, instances);
|
|
mesh.name = name;
|
|
mesh.frustumCulled = false;
|
|
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
|
for (let i = 0; i < instances; i++) mesh.setMatrixAt(i, hidden);
|
|
group.add(mesh);
|
|
return { mesh, gain, gainAttribute };
|
|
};
|
|
const lampLevel = (vehicle: number, lamp: TrafficLamp | undefined): number => {
|
|
if (!lamp) return 0;
|
|
return lamp.role === 'clearance' || lamp.role === 'indicator' ? AMBER : tints[vehicle]! * 0.6;
|
|
};
|
|
const lamps = quad('traffic-lamps', lampMaterial, pool * LAMPS_PER_VEHICLE, (i) =>
|
|
lampLevel(Math.floor(i / LAMPS_PER_VEHICLE), lampsFor[Math.floor(i / LAMPS_PER_VEHICLE)]![i % LAMPS_PER_VEHICLE]),
|
|
);
|
|
// Two trails per vehicle, from the tail-lamp pair.
|
|
const trails = quad('traffic-light-trails', trailMaterial, pool * 2, (i) => tints[Math.floor(i / 2)]! * 0.6);
|
|
// A red pool behind each vehicle, tinted like its own lamps; on high and
|
|
// medium a warm headlight pool ahead of it.
|
|
const pools = quad('traffic-road-pools', poolMaterial, pool, (i) => tints[i]! * 0.6);
|
|
const frontPools = lite ? null : quad('traffic-headlight-pools', poolMaterial, pool, () => WARM);
|
|
|
|
const bodies = (['car', 'truck'] as const).map((kind) => {
|
|
const code = kind === 'truck' ? TRAFFIC_TRUCK : TRAFFIC_CAR;
|
|
const members: number[] = [];
|
|
for (let i = 0; i < pool; i++) if (frame.kind[i] === code) members.push(i);
|
|
const geometry = resources.own('geometry', boxGeometry(kind === 'car' ? CAR_BOXES : TRUCK_BOXES));
|
|
const body = new Float32Array(Math.max(1, members.length) * 4);
|
|
const bodyAttribute = new THREE.InstancedBufferAttribute(body, 4);
|
|
bodyAttribute.setUsage(THREE.DynamicDrawUsage);
|
|
geometry.setAttribute('aBody', bodyAttribute);
|
|
const blink = new Float32Array(Math.max(1, members.length));
|
|
const blinkAttribute = new THREE.InstancedBufferAttribute(blink, 1);
|
|
blinkAttribute.setUsage(THREE.DynamicDrawUsage);
|
|
geometry.setAttribute('aBlink', blinkAttribute);
|
|
const mesh = new THREE.InstancedMesh(geometry, bodyMaterial(kind), Math.max(1, members.length));
|
|
mesh.name = `traffic-${kind}-bodies`;
|
|
mesh.frustumCulled = false;
|
|
mesh.count = members.length;
|
|
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
|
// Bodies draw before the additive lamps so the lamps sit on them.
|
|
mesh.renderOrder = -1;
|
|
group.add(mesh);
|
|
return { mesh, members, body, bodyAttribute, blink, blinkAttribute };
|
|
});
|
|
|
|
// The pass swell: a visible vehicle's rear crossing z 8 at |relative| > 6.
|
|
const lastZ = new Float32Array(pool).fill(Number.NaN);
|
|
let passSide = 0;
|
|
const quads = frontPools ? [lamps, trails, pools, frontPools] : [lamps, trails, pools];
|
|
const matrix = new THREE.Matrix4();
|
|
const scale = new THREE.Vector3();
|
|
const place = (
|
|
target: Quad['mesh'],
|
|
index: number,
|
|
x: number,
|
|
y: number,
|
|
z: number,
|
|
rotateX: number,
|
|
yaw: number,
|
|
sx: number,
|
|
sy: number,
|
|
) => {
|
|
if (rotateX !== 0) matrix.makeRotationX(rotateX);
|
|
else matrix.makeRotationY(yaw);
|
|
matrix.scale(scale.set(sx, sy, 1));
|
|
matrix.setPosition(x, y, z);
|
|
target.setMatrixAt(index, matrix);
|
|
};
|
|
|
|
return {
|
|
group,
|
|
model,
|
|
/**
|
|
* Steps the model (the input object is the caller's, reused every frame)
|
|
* and writes the instances. Returns the model frame.
|
|
*/
|
|
update(input: TrafficInput, time: number): TrafficFrame {
|
|
uniforms.uTime.value = time;
|
|
model.step(input);
|
|
const hero = input.heroSpeed;
|
|
// The flash to pass: tail lamps in reach swell with the beam.
|
|
const lift = THREE.MathUtils.smoothstep(beamUniforms.uBeamGain.value, 0.78, 1);
|
|
passSide = 0;
|
|
for (let i = 0; i < pool; i++) {
|
|
const state = frame.active[i]!;
|
|
const was = lastZ[i]!;
|
|
lastZ[i] = state === TRAFFIC_PENDING ? Number.NaN : frame.z[i]!;
|
|
if (
|
|
!lite &&
|
|
state !== TRAFFIC_PENDING &&
|
|
(was - PASS_Z) * (frame.z[i]! - PASS_Z) < 0 &&
|
|
Math.abs(hero - frame.speed[i]!) > PASS_RELATIVE_SPEED
|
|
)
|
|
passSide = Math.sign(frame.x[i]!);
|
|
const lampBase = i * LAMPS_PER_VEHICLE;
|
|
if (state === TRAFFIC_PENDING || state === TRAFFIC_BEHIND) {
|
|
for (let k = 0; k < LAMPS_PER_VEHICLE; k++) {
|
|
lamps.mesh.setMatrixAt(lampBase + k, hidden);
|
|
lamps.gain[(lampBase + k) * 2] = 0;
|
|
lamps.gain[(lampBase + k) * 2 + 1] = 0;
|
|
}
|
|
trails.mesh.setMatrixAt(i * 2, hidden);
|
|
trails.mesh.setMatrixAt(i * 2 + 1, hidden);
|
|
pools.mesh.setMatrixAt(i, hidden);
|
|
frontPools?.mesh.setMatrixAt(i, hidden);
|
|
continue;
|
|
}
|
|
const size = TRAFFIC_KINDS[frame.kind[i]!]!;
|
|
const s = size.scale;
|
|
const x = frame.x[i]!;
|
|
const z = frame.z[i]!;
|
|
const yaw = frame.yaw[i]!;
|
|
const near = frame.nearLimit[i]!;
|
|
const visible = 1 - frame.dither[i]!;
|
|
const under = cameraSide(x, input.cameraX);
|
|
const brake = frame.brake[i]!;
|
|
const blink = frame.blink[i]!;
|
|
const indicator = frame.indicator[i]!;
|
|
const cos = Math.cos(yaw);
|
|
const sin = Math.sin(yaw);
|
|
const relative = hero - frame.speed[i]!; // > 0: the reader is overtaking it
|
|
const trailLength = THREE.MathUtils.clamp(Math.abs(relative) * 0.14, 0.3, 12) * s * 0.6;
|
|
// Trails stream away from the direction of relative travel.
|
|
const trailDirection = relative >= 0 ? -1 : 1;
|
|
const own = lampsFor[i]!;
|
|
for (let k = 0; k < LAMPS_PER_VEHICLE; k++) {
|
|
const lamp = own[k];
|
|
const g = (lampBase + k) * 2;
|
|
if (lamp === undefined) {
|
|
lamps.mesh.setMatrixAt(lampBase + k, hidden);
|
|
lamps.gain[g] = 0;
|
|
lamps.gain[g + 1] = 0;
|
|
continue;
|
|
}
|
|
const lx = x + s * (lamp.x * cos + lamp.z * sin);
|
|
const lz = z + s * (-lamp.x * sin + lamp.z * cos);
|
|
const fade = trafficNearFade(lz, near) * visible * (under ? underFade(lz) : 1);
|
|
let gain = 0;
|
|
let flare = 0;
|
|
if (lamp.role === 'tail') {
|
|
gain = 1 + 1.3 * brake;
|
|
flare = 1;
|
|
} else if (lamp.role === 'stop') gain = brake;
|
|
else if (lamp.role === 'clearance') {
|
|
gain = 0.9;
|
|
flare = 0.6;
|
|
} else gain = indicator === lamp.side ? blink * INDICATOR_GAIN : 0;
|
|
lamps.gain[g] = gain * fade;
|
|
lamps.gain[g + 1] = flare * fade;
|
|
// A lit indicator and a flared tail lamp swell: legible at 200 m.
|
|
const swell =
|
|
lamp.role === 'indicator' ? 1 + INDICATOR_SWELL * blink : lamp.role === 'tail' ? 1 + FLARE_SWELL * lift : 1;
|
|
place(lamps.mesh, lampBase + k, lx, s * lamp.y, lz, 0, yaw, s * lamp.w * swell, s * lamp.h * swell);
|
|
if (k > 1) continue;
|
|
// Horizontal ribbon at lamp height, uv.y = 1 at the lamp.
|
|
place(
|
|
trails.mesh,
|
|
i * 2 + k,
|
|
lx,
|
|
s * lamp.y - 0.03,
|
|
lz + (trailDirection * trailLength) / 2,
|
|
trailDirection < 0 ? Math.PI / 2 : -Math.PI / 2,
|
|
0,
|
|
0.2 * s,
|
|
trailLength,
|
|
);
|
|
trails.gain[(i * 2 + k) * 2] = fade;
|
|
}
|
|
const poolFade = trafficNearFade(z, near) * visible * (under ? underFade(z) : 1);
|
|
const poolLength = 9 + Math.min(10, Math.abs(relative) * 0.08);
|
|
place(pools.mesh, i, x, 0.04, z + poolLength / 2, -Math.PI / 2, 0, size.halfWidth * 1.7, poolLength);
|
|
pools.gain[i * 2] = poolFade * (1 + brake * 0.8);
|
|
if (frontPools) {
|
|
const nose = z - size.length;
|
|
place(frontPools.mesh, i, x, 0.045, nose - FRONT_POOL_LENGTH / 2, Math.PI / 2, 0, size.halfWidth * 2, FRONT_POOL_LENGTH);
|
|
frontPools.gain[i * 2] = trafficNearFade(nose, near) * visible * 0.55;
|
|
}
|
|
}
|
|
for (const { mesh, members, body, bodyAttribute, blink, blinkAttribute } of bodies) {
|
|
for (let j = 0; j < members.length; j++) {
|
|
const i = members[j]!;
|
|
const state = frame.active[i]!;
|
|
if (state === TRAFFIC_PENDING || state === TRAFFIC_BEHIND) {
|
|
mesh.setMatrixAt(j, hidden);
|
|
continue;
|
|
}
|
|
const s = TRAFFIC_KINDS[frame.kind[i]!]!.scale;
|
|
matrix.makeRotationY(frame.yaw[i]!);
|
|
matrix.scale(scale.set(s, s, s));
|
|
matrix.setPosition(frame.x[i]!, 0, frame.z[i]!);
|
|
mesh.setMatrixAt(j, matrix);
|
|
body[j * 4] = frame.nearLimit[i]!;
|
|
body[j * 4 + 1] = frame.dither[i]!;
|
|
body[j * 4 + 2] = frame.brake[i]!;
|
|
body[j * 4 + 3] = cameraSide(frame.x[i]!, input.cameraX) ? 1 : 0;
|
|
blink[j] = frame.indicator[i]! * frame.blink[i]! * (1 - frame.dither[i]!);
|
|
}
|
|
mesh.instanceMatrix.needsUpdate = true;
|
|
bodyAttribute.needsUpdate = true;
|
|
blinkAttribute.needsUpdate = true;
|
|
}
|
|
for (const q of quads) {
|
|
q.mesh.instanceMatrix.needsUpdate = true;
|
|
q.gainAttribute.needsUpdate = true;
|
|
}
|
|
return frame;
|
|
},
|
|
/** -1 / 1: a vehicle passed the reader on that side this frame (0: none). */
|
|
passed(): number {
|
|
return passSide;
|
|
},
|
|
};
|
|
}
|