feat: interactive traffic with car-following, overtakes and flash-to-pass

This commit is contained in:
2026-10-11 23:13:05 +03:00
parent 8c9ec69565
commit 50da616a46
22 changed files with 2513 additions and 230 deletions

View File

@@ -19,6 +19,7 @@ import { createPostProcessor } from '@/scene/post-processing';
import { installCinematicGrade } from '@/scene/cinematic-grade';
import { createSkyline } from '@/scene/world/skyline';
import { createTraffic } from '@/scene/world/traffic';
import { createFlashDetector, type TrafficInput } from '@/scene/world/traffic-model';
import { createDriveDynamics } from '@/scene/drive-dynamics';
import { HORIZON_FOG_END, HORIZON_FOG_START } from '@/scene/world/metrics';
import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition';
@@ -211,6 +212,24 @@ export async function createProceduralSceneGeneration(
// NIGHT FREIGHT: a gantry crane works the Additional work container yard.
// Low tier and touch: crane, stacks and plates; no floods, lamps or pulse.
const yard = createYard(context.resources, maintenanceTier === 'full');
// NIGHT TRAFFIC: a car-following model (traffic-model.ts). The reader's
// speed, chapter, camera side, high-beam flash and a sweep (navigator jump,
// fling) drive it; one input object is reused every frame.
const trafficInput: TrafficInput = {
dt: 0,
heroSpeed: 0,
chapter: 'deep-night',
sweeping: false,
flash: false,
cameraX: 0,
engineOff: 0,
};
let trafficRoute: number | null = null;
let trafficSweep = 0;
const trafficFlash = createFlashDetector();
let trafficKey = '';
const trafficSwell = context.quality !== 'low'
&& (globalThis.navigator as { connection?: { saveData?: boolean } } | undefined)?.connection?.saveData !== true;
let underpassShade = 0;
let underpassKey = '';
let underpassAttr = false;
@@ -530,11 +549,61 @@ export async function createProceduralSceneGeneration(
const elapsedSeconds = atmosphericTime;
horizonAir.update(currentPalette, elapsedSeconds, dawn);
skyline.update(currentPalette, elapsedSeconds);
traffic.update(
deltaSeconds,
(18 + snapshot.driveSpeed * 54 + response.roadEnergy * 48) * (1 - engineOff) * roll,
elapsedSeconds,
);
// A sweep: the route moving faster than any reading scroll (a navigator
// jump or a fling), or a navigator arrival. No manoeuvre starts in one.
const routeRate = trafficRoute === null || deltaSeconds <= 0
? 0
: Math.abs(snapshot.routeProgress - trafficRoute) / deltaSeconds;
trafficRoute = snapshot.routeProgress;
trafficSweep += (routeRate - trafficSweep)
* (1 - Math.exp(-deltaSeconds / (routeRate > trafficSweep ? 0.08 : 0.4)));
// Flash-to-pass: the rising edge of the high beam's flash (click on the
// road, or the beam's own flash triggers; headlight-beam.ts spaces them).
const flashed = trafficFlash(snapshot.beam?.intensity ?? 0);
trafficInput.dt = deltaSeconds;
trafficInput.heroSpeed = (18 + snapshot.driveSpeed * 54 + response.roadEnergy * 48) * (1 - engineOff) * roll;
trafficInput.chapter = snapshot.chapter;
trafficInput.sweeping = trafficSweep > 0.12 || snapshot.sign?.navigationArrival === true;
trafficInput.flash = flashed;
trafficInput.cameraX = cameraState.x;
trafficInput.engineOff = engineOff;
const trafficFrame = traffic.update(trafficInput, elapsedSeconds);
const passSide = traffic.passed();
if (passSide !== 0 && trafficSwell) {
void import('@/audio/traffic-pass').then((pass) => pass.playTrafficPass(passSide), () => undefined);
}
if (TEST_BUILD) {
const events = traffic.model.events();
let visible = 0;
let centre = 0;
let indicators = 0;
for (let i = 0; i < trafficFrame.count; i += 1) {
if (trafficFrame.active[i] !== 1) continue;
visible += 1;
if (Math.abs(trafficFrame.x[i]!) < 2.7) centre += 1;
if (trafficFrame.indicator[i] !== 0) indicators += 1;
}
const key = `${visible}|${centre}|${indicators}|${events.passes}|${events.laneChanges}|${events.emergencyExits}|${flashed}|${trafficInput.sweeping}`;
if (key !== trafficKey) {
trafficKey = key;
window.dispatchEvent(new CustomEvent('portfolio-traffic-frame', {
detail: {
now,
chapter: snapshot.chapter,
heroSpeed: trafficInput.heroSpeed,
sweeping: trafficInput.sweeping,
flash: flashed,
visible,
centre,
indicators,
passes: events.passes,
laneChanges: events.laneChanges,
brakeOnsets: events.brakeOnsets,
emergencyExits: events.emergencyExits,
},
}));
}
}
sun.update(
currentPalette,
snapshot.routeProgress,

View File

@@ -56,6 +56,14 @@ export const BEAM_PARS = /* glsl */ `
float reach = 1.0 - smoothstep(${(SPOT_DISTANCE * 0.45).toFixed(1)}, ${SPOT_DISTANCE.toFixed(1)}, d);
return uBeamGain * cone * reach * (14.0 / (14.0 + d));
}
// The cone alone, without the spot's 70 m reach: retro-reflectors (traffic
// tail lamps, an under-run bar) answer the beam from hundreds of metres.
float headlightCone(vec3 p) {
if (uBeamGain <= 0.0) return 0.0;
vec3 ray = p - uBeamOrigin;
float cosA = dot(ray / max(length(ray), 0.001), uBeamDir);
return uBeamGain * smoothstep(${Math.cos(SPOT_ANGLE).toFixed(4)}, ${Math.cos(SPOT_ANGLE * (1 - SPOT_PENUMBRA)).toFixed(4)}, cosA);
}
`;
/** How much open road is on screen: 1 between chapters, 0 under a plate. */

View File

@@ -56,7 +56,8 @@ export const MILESTONE_SIDE: Readonly<Record<MilestoneSection, number>> = { expe
const POST_W = 0.4;
const POST_H = 6.4;
/** The plate hangs on an arm this far over the road (the post stays on the shoulder),
* so it stays in frame on the narrow side and clears traffic (4 m) by 2 m. */
* so it stays in frame on the narrow side. Its bottom edge (~6.3 m) clears the
* tallest traffic, the 1.4x box trucks (5.67 m, traffic-model.ts), by ~0.6 m. */
const ARM = 4.2;
const PLATE_W = 2.2;
const PLATE_H = 1.0;

View File

@@ -137,7 +137,9 @@ export function createRoad(
* (1.0 - smoothstep(7.46, 7.62, roadX));
float outerEdge = smoothstep(7.86, 7.98, roadX)
* (1.0 - smoothstep(8.06, 8.18, roadX));
float laneWidth = 1.0 - smoothstep(0.035, 0.07, abs(roadX - 0.17));
// Lane dividers between the reader's centre lane and the traffic
// lanes (lane centres at 0 and +-5.4): dashes just outside the car.
float laneWidth = 1.0 - smoothstep(0.08, 0.13, abs(roadX - 3.0));
float laneCycle = fract(travelZ / 18.0);
float laneDash = smoothstep(0.05, 0.1, laneCycle)
* (1.0 - smoothstep(0.48, 0.56, laneCycle));

File diff suppressed because it is too large Load Diff

View File

@@ -1,100 +1,86 @@
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: dark low-poly cars and box trucks with
* readable rear lamps (a tail-light pair, a high centre brake light on cars,
* amber clearance lamps on trucks), their roofs catching the horizon glow,
* their rear faces our headlights, plus short long-exposure trails 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.
* 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.
*
* Owner feedback (polish round): bodiless lamp pairs read as random red dots
* on the road. Each lane now runs at one speed, so vehicles in a lane never
* drive through each other, and adjacent lanes never overlap.
*/
/**
* 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. Vehicles fade out over TRAFFIC_FADE_LENGTH before TRAFFIC_NEAR_Z
* and wrap inside the hidden band, so nothing ever slides past the car.
* 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;
export const TRAFFIC_NEAR_Z = -24;
/** A vehicle fades over this many metres before its near limit. */
export const TRAFFIC_FADE_LENGTH = 22;
const FAR_Z = -660;
const SPAN = TRAFFIC_NEAR_Z - FAR_Z;
/** Max lateral jitter of a vehicle inside its lane. */
export const TRAFFIC_LANE_JITTER = 0.1;
export type TrafficKind = 'car' | 'truck';
export interface TrafficLane {
x: number;
/** World units per second; one speed per lane, so no one drives through anyone. */
speed: number;
kind: TrafficKind;
}
/** Fast cars on the left, slower cars and box trucks on the right. */
export const TRAFFIC_LANES: readonly TrafficLane[] = [
{ x: -6.5, speed: 46, kind: 'car' },
{ x: 4.0, speed: 16, kind: 'car' },
{ x: -4.0, speed: 33, kind: 'car' },
{ x: 6.6, speed: 11, kind: 'truck' },
];
/** Body half widths and lengths (metres, world scale). */
export const TRAFFIC_BODY = {
car: { halfWidth: 1.1, length: 4.8 },
truck: { halfWidth: 1.25, length: 15 },
} as const;
/** Visibility of a lamp, trail, pool or body 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));
/** 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);
}
/** 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 Lamp {
export type TrafficLampRole = 'tail' | 'stop' | 'clearance' | 'indicator';
export interface TrafficLamp {
x: number;
y: number;
z: number;
w: number;
h: number;
/** 0..0.6 red to red-orange, 2 amber. */
level: number;
role: TrafficLampRole;
/** -1 left, 1 right (indicators), 0 otherwise. */
side: number;
}
/** Rear lamps in body space (origin at the rear face, ground level). */
export function trafficLamps(kind: TrafficKind, tint: number): Lamp[] {
const red = tint * 0.6;
/** 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.8, y: 0.92, z: 0.05, w: 0.62, h: 0.3, level: red },
{ x: 0.8, y: 0.92, z: 0.05, w: 0.62, h: 0.3, level: red },
// Centre high-mounted brake light on the rear window's top edge.
{ x: 0, y: 1.55, z: -0.9, w: 0.62, h: 0.12, level: red },
{ 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.95, y: 1.3, z: 0.05, w: 0.6, h: 0.32, level: red },
{ x: 0.95, y: 1.3, z: 0.05, w: 0.6, h: 0.32, level: red },
{ 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, level: 2 },
{ x: 1.12, y: 3.9, z: 0.05, w: 0.26, h: 0.16, level: 2 },
{ 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 },
];
}
const LAMPS_PER_VEHICLE = 4;
export const LAMPS_PER_VEHICLE = 6;
/** Axis-aligned boxes [x0, x1, y0, y1, z0, z1] in body space. */
/** 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
@@ -130,13 +116,36 @@ function boxGeometry(boxes: readonly Box[]) {
return geometry;
}
interface Vehicle {
lane: TrafficLane;
x: number;
z: number;
tint: number;
/** 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,
@@ -145,45 +154,46 @@ export function createTraffic(
) {
const group = new THREE.Group();
group.name = 'expressway-traffic';
const count = quality.postEffects ? (quality.postScale >= 1 ? 14 : 10) : 7;
const vehicles: Vehicle[] = [];
let seed = 7;
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 lanes = TRAFFIC_LANES.length;
const perLane = Array.from({ length: lanes }, (_, lane) => Math.ceil((count - lane) / lanes));
for (let i = 0; i < count; i++) {
const laneIndex = i % lanes;
const lane = TRAFFIC_LANES[laneIndex]!;
// Evenly spread through the span with jitter smaller than the gap, so
// vehicles in one lane (one speed) keep their spacing forever.
const slot = Math.floor(i / lanes);
const gap = SPAN / perLane[laneIndex]!;
const jitter = (random() - 0.5) * Math.max(0, gap - TRAFFIC_BODY[lane.kind].length - 30);
vehicles.push({
lane,
x: lane.x + (random() - 0.5) * 2 * TRAFFIC_LANE_JITTER,
z: wrapTrafficZ(FAR_Z + gap * (slot + 0.5) + laneIndex * 37 + jitter),
tint: random(),
});
}
const tints = kinds.map(() => random());
const lampsFor = kinds.map((kind) => trafficLamps(kind));
const uniforms = { uTime: { value: 0 }, uSheen: { value: sheen } };
const nearFade = /* glsl */ `smoothstep(0.0, 1.0, clamp((${TRAFFIC_NEAR_Z.toFixed(1)} - world.z) / ${TRAFFIC_FADE_LENGTH.toFixed(1)}, 0.0, 1.0))`;
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 vNear;
varying float vGain;
varying float vFlare;
void main() {
vUv = uv;
vLevel = aLevel;
vGain = aGain.x;
vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0);
// Matches trafficNearFade(): dark before the hero car's near field.
vNear = ${nearFade};
// 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;
@@ -192,14 +202,17 @@ export function createTraffic(
varying vec2 vUv;
varying float vDepth;
varying float vLevel;
varying float vNear;
varying float vGain;
varying float vFlare;
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), clamp(vLevel, 0.0, 1.0));
color = mix(color, vec3(1.0, 0.46, 0.06), step(1.5, vLevel));
gl_FragColor = vec4(color * glow * far * near, 1.0);
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>
}`;
@@ -227,41 +240,66 @@ export function createTraffic(
const trailMaterial = additive(`
float across = exp(-pow((vUv.x - 0.5) * 5.0, 2.0));
float glow = across * pow(vUv.y, 1.6) * 0.8;`);
// Wet-road pools: long and soft under each vehicle.
// Wet-road pools: long and soft, brightest at the vehicle.
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;`);
// Bodies: near-black coachwork that reads as a silhouette against the
// horizon, with the fog glow on the roof, our headlights on the rear face,
// a glass rear window, door seams on the trucks and red lamp spill.
const bodyMaterial = (kind: TrafficKind) => resources.own(
'material',
new THREE.ShaderMaterial({
uniforms,
vertexShader: /* glsl */ `
// 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);
vNear = ${nearFade};
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 */ `
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);
@@ -270,56 +308,95 @@ export function createTraffic(
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));
// Our headlights on the rear face, falling off down the road.
float beam = exp(-max(0.0, vDepth - 40.0) / 90.0);
color += vec3(0.19, 0.15, 0.27) * rear * beam * 0.55;
${kind === 'car'
? `// Rear window: glass reflects a lavender band of sky.
// 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((vLocal.y - 0.92) / 0.18, 2.0));`
: `// Box doors: a centre seam and two lock bars.
: `// 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((vLocal.y - 1.3) / 0.2, 2.0));`}
float lampBand = exp(-pow((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;
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,
}),
);
transparent: true,
depthWrite: true,
depthTest: true,
}),
);
const plane = resources.own('geometry', new THREE.PlaneGeometry(1, 1));
const lampCount = count * LAMPS_PER_VEHICLE;
const quad = (name: string, material: unknown, instances: number, level: (i: number) => number) => {
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 instanced = new THREE.InstancedMesh(geometry, material, instances);
instanced.name = name;
instanced.frustumCulled = false;
instanced.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
group.add(instanced);
return instanced;
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 lampsFor = vehicles.map((v) => trafficLamps(v.lane.kind, v.tint));
const lamps = quad('traffic-lamps', lampMaterial, lampCount, (i) => lampsFor[Math.floor(i / LAMPS_PER_VEHICLE)]![i % LAMPS_PER_VEHICLE]?.level ?? 0);
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, count * 2, (i) => vehicles[Math.floor(i / 2)]!.tint * 0.6);
// One pool per vehicle, tinted like that vehicle's own lamps.
const pools = quad('traffic-road-pools', poolMaterial, count, (i) => vehicles[i]!.tint * 0.6);
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 members = vehicles.filter((v) => v.lane.kind === 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;
@@ -328,67 +405,174 @@ export function createTraffic(
// Bodies draw before the additive lamps so the lamps sit on them.
mesh.renderOrder = -1;
group.add(mesh);
return { mesh, members };
return { mesh, members, body, bodyAttribute, blink, blinkAttribute };
});
const dummy = new THREE.Object3D();
const hidden = new THREE.Matrix4().makeScale(0, 0, 0);
// 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,
/**
* @param travelSpeed world units per second the camera car is covering.
* Steps the model (the input object is the caller's, reused every frame)
* and writes the instances. Returns the model frame.
*/
update(deltaSeconds: number, travelSpeed: number, time: number) {
update(input: TrafficInput, time: number): TrafficFrame {
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.lane.speed; // > 0: we are overtaking
v.z = wrapTrafficZ(v.z + relative * dt);
const trailLength = THREE.MathUtils.clamp(Math.abs(relative) * 0.14, 0.3, 12);
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.setMatrixAt(i * LAMPS_PER_VEHICLE + k, hidden);
lamps.mesh.setMatrixAt(lampBase + k, hidden);
lamps.gain[g] = 0;
lamps.gain[g + 1] = 0;
continue;
}
dummy.position.set(v.x + lamp.x, lamp.y, v.z + lamp.z);
dummy.rotation.set(0, 0, 0);
dummy.scale.set(lamp.w, lamp.h, 1);
dummy.updateMatrix();
lamps.setMatrixAt(i * LAMPS_PER_VEHICLE + k, dummy.matrix);
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.
dummy.position.set(v.x + lamp.x, lamp.y - 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(i * 2 + k, dummy.matrix);
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 halfWidth = TRAFFIC_BODY[v.lane.kind].halfWidth;
const poolFade = trafficNearFade(z, near) * visible * (under ? underFade(z) : 1);
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(halfWidth * 1.7, poolLength, 1);
dummy.updateMatrix();
pools.setMatrixAt(i, dummy.matrix);
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 } of bodies) {
members.forEach((v, j) => {
dummy.position.set(v.x, 0, v.z);
dummy.rotation.set(0, 0, 0);
dummy.scale.set(1, 1, 1);
dummy.updateMatrix();
mesh.setMatrixAt(j, dummy.matrix);
});
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;
}
lamps.instanceMatrix.needsUpdate = true;
trails.instanceMatrix.needsUpdate = true;
pools.instanceMatrix.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;
},
};
}