fix: polish pass for traffic, nav, icons, contact email reveal and dashcam robustness

This commit is contained in:
2026-10-11 15:11:44 +03:00
parent ca12cfda44
commit 1326cee886
49 changed files with 809 additions and 226 deletions

View File

@@ -351,7 +351,7 @@ export interface BillboardWorld {
}
export interface BillboardOptions {
/** 'full': pulse, flicker, shimmer, hover zoom. 'lite' (low tier, touch): one static flood. */
/** 'full': pulse, flicker, shimmer, hover zoom. 'lite' (low tier, touch): both floods steady. */
mode: 'full' | 'lite';
textureSize: number;
io?: BillboardIO;
@@ -531,8 +531,9 @@ export function createBillboard(resources: SceneResourceBag, options: BillboardO
const p = frame.p ?? (presence === 1 && frame.section === 'infrastructure' ? 0 : null);
progress = billboardProgress(standing ? p : null, feed.stops, feed.projects.map((project) => project.id), frame.direction);
// Floods: off while rising; lite keeps one steady lamp.
const [left, right] = full ? floodTargets(progress) : [standing ? 1 : 0, 0];
// Floods: off while rising; lite keeps both lamps steady (one lamp left
// the right half of the face unlit, and phone captures read dim).
const [left, right] = full ? floodTargets(progress) : [standing ? 1 : 0, standing ? 1 : 0];
floods[0] = toward(floods[0], left, dt / BILLBOARD_TIMING.flood);
floods[1] = toward(floods[1], right, dt / BILLBOARD_TIMING.flood);

View File

@@ -149,7 +149,7 @@ export async function createProceduralSceneGeneration(
const sun = createSun(context.resources);
const horizonAir = createHorizonAir(context.resources);
const skyline = createSkyline(context.resources);
const traffic = createTraffic(context.resources, quality);
const traffic = createTraffic(context.resources, quality, scene.fog.color);
// Phones and the low tier get the decimated car; it is about 200 px wide
// there and seen from behind.
const liteCar =
@@ -214,6 +214,7 @@ export async function createProceduralSceneGeneration(
let underpassShade = 0;
let underpassKey = '';
let underpassAttr = false;
let underpassSince = -1;
scene.add(
underpass.group,
relay.group,
@@ -786,7 +787,13 @@ export async function createProceduralSceneGeneration(
},
);
underpassShade = passage.shade;
const under = shadeDepth(passage.shade) > 0.2;
// The HUD tint follows the shade, but only once it has held for
// UNDERPASS_ATTR_HOLD_MS: an approach walk can graze the threshold for a
// frame or three before the real pass. Leaving is immediate.
const shaded = shadeDepth(passage.shade) > 0.2;
if (!shaded) underpassSince = -1;
else if (underpassSince < 0) underpassSince = now;
const under = shaded && now - underpassSince >= UNDERPASS_ATTR_HOLD_MS;
if (under !== underpassAttr) {
underpassAttr = under;
if (under) document.documentElement.dataset.underpass = 'under';
@@ -934,6 +941,8 @@ export async function createProceduralSceneGeneration(
};
}
/** html[data-underpass] waits for the shade to hold this long (ms). */
const UNDERPASS_ATTR_HOLD_MS = 120;
const INTRO_SECONDS = 4.2;
/** Long enough for the dawn to read: the sun clears the skyline near 3.8 s. */
const OUTRO_SECONDS = 7.0;

View File

@@ -36,7 +36,7 @@ export function createExpressway(
gl_Position = projectionMatrix * view;
}
`;
function material(luminous: boolean, coral = false) {
function material(luminous: boolean, coral = false, fade: readonly [number, number] = [380, 660]) {
return resources.own(
'material',
new THREE.ShaderMaterial({
@@ -49,7 +49,7 @@ export function createExpressway(
varying vec3 vWorld;
varying float vDepth;
void main() {
float farFade = 1.0 - smoothstep(380.0, 660.0, vDepth);
float farFade = 1.0 - smoothstep(${fade[0].toFixed(1)}, ${fade[1].toFixed(1)}, vDepth);
float nearFade = smoothstep(0.0, 9.0, vDepth);
vec3 cyan = mix(vec3(1.0, 0.025, 0.34), vec3(0.02, 0.78, 0.93), step(0.0, vWorld.x));
vec3 coral = vec3(1.0, 0.18, 0.095);
@@ -71,7 +71,9 @@ export function createExpressway(
}
const graphite = material(false);
const cyan = material(true);
const coral = material(true, true);
// Delineator reflectors fade out by ~240 m: beyond that a 0.4 m plate is a
// sub-pixel speck, and the specks read as random orange dots (owner).
const coral = material(true, true, [110, 240]);
const box = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1));
const dummy = new THREE.Object3D();
type Part = {
@@ -113,12 +115,14 @@ export function createExpressway(
for (const side of [-1, 1]) {
const z = 30 - (i * WORLD_DEPTH) / pairs;
posts.push({ x: side * 9.5, y: 0.63, z, sx: 0.22, sy: 1.26, sz: 0.32 });
// A tall reflector plate on the post's face: guard-rail delineators,
// the post and its plate one readable unit.
reflectors.push({
x: side * 9.48,
y: 1.1,
z: z + 0.18,
sx: 0.14,
sy: 0.22,
x: side * 9.5,
y: 1.02,
z: z + 0.17,
sx: 0.16,
sy: 0.4,
sz: 0.03,
});
rails.push({

View File

@@ -44,6 +44,14 @@ const PLATE_YAW = 0.3;
const FLASH_SECONDS = 0.3;
/** One flash per post at most every 0.6 s, however fast the scroll (photosensitivity). */
const FLASH_GAP_SECONDS = 0.6;
/** Smoothed section-local progress per second above which a crossing does not
* flash: a navigator jump or a fling sweeps several posts past in well under
* a second, and their flashes stacked into one bright bloom frame on arrival.
* A wheel tick lands in one frame, so the rate is smoothed: it rises over
* ~80 ms (a sweep is caught within three frames) and falls over ~400 ms. */
export const MILESTONE_FLASH_MAX_RATE = 0.8;
const RATE_RISE_SECONDS = 0.08;
const RATE_FALL_SECONDS = 0.4;
const PULSE_GAIN = 0.08;
/** The cap plate's resting glow: it reads as lit retro-reflective sheeting at night. */
const PLATE_GLOW = 0.42;
@@ -198,10 +206,14 @@ export function createMilestones(resources: SceneResourceBag, full: boolean): Mi
} else if (vPart < 1.5) {
// Lit retro-reflective plate: brightest facing the camera, and in the beam.
// Tinted by the chapter accent so it never reads as one more street lamp.
color = mix(vec3(0.86, 0.88, 0.96), uAccent, 0.7) * (${PLATE_GLOW.toFixed(2)} + 0.12 * key + 0.5 * facing * facing);
color += uAccent * 0.35;
${full ? 'color += vec3(1.0, 0.9, 0.76) * headlightBeam(vWorld) * (0.5 + 1.8 * facing);' : ''}
color += vec3(1.0, 0.96, 0.9) * flash * 1.4;
// Kept below the tonemapper's shoulder, so the plate stays the
// chapter colour instead of clipping to white; the beam and the
// flash lift it in the accent's own hue.
vec3 sheet = mix(vec3(0.86, 0.88, 0.96), uAccent, 0.82);
color = sheet * (${PLATE_GLOW.toFixed(2)} + 0.1 * key + 0.32 * facing * facing);
color += uAccent * 0.18;
${full ? 'color += sheet * headlightBeam(vWorld) * (0.3 + 0.8 * facing);' : ''}
color += mix(uAccent, vec3(1.0, 0.96, 0.9), 0.35) * flash * 0.9;
} else {
color = uAccent * (1.5 * (1.0 + ${PULSE_GAIN.toFixed(2)} * uPulse) + flash * 1.8);
}
@@ -267,6 +279,8 @@ export function createMilestones(resources: SceneResourceBag, full: boolean): Mi
const sinceFlash = new Float32Array(INSTANCES).fill(Number.POSITIVE_INFINITY);
const side = new Float32Array(INSTANCES);
let lastSection: SectionId | null = null;
let lastProgress: number | null = null;
let rate = 0;
const matrix = new THREE.Matrix4();
const rotation = new THREE.Quaternion();
const position = new THREE.Vector3();
@@ -281,12 +295,17 @@ export function createMilestones(resources: SceneResourceBag, full: boolean): Mi
const posts = milestoneLayout(section, p);
const changed = section !== lastSection;
lastSection = section;
const dt = Math.max(deltaSeconds, 1 / 240);
const instant = p !== null && lastProgress !== null && !changed ? Math.abs(p - lastProgress) / dt : 0;
rate += (instant - rate) * Math.min(1, dt / (instant > rate ? RATE_RISE_SECONDS : RATE_FALL_SECONDS));
const sweeping = rate > MILESTONE_FLASH_MAX_RATE;
lastProgress = p;
let any = false;
posts.forEach((post, i) => {
// The flash fires when the plate crosses the line, either way.
const crossing = p === null || post.presence === 0 ? 0 : Math.sign(p - passPoint(post.index, MILESTONE_COUNTS[post.section]));
sinceFlash[i]! += deltaSeconds;
if (full && !changed && crossing !== 0 && side[i] !== 0 && crossing !== side[i] && sinceFlash[i]! >= FLASH_GAP_SECONDS) {
if (full && !changed && !sweeping && crossing !== 0 && side[i] !== 0 && crossing !== side[i] && sinceFlash[i]! >= FLASH_GAP_SECONDS) {
flash[i] = 1;
sinceFlash[i] = 0;
} else {

View File

@@ -176,9 +176,8 @@ export function createRoad(
color += vec3(0.11, 0.065, 0.17) * shoulderRibs;
float joint = 1.0 - smoothstep(0.02, 0.075, abs(mod(travelZ + 36.0, 72.0) - 36.0));
color *= 1.0 - joint * 0.5;
float studs = (1.0 - smoothstep(0.07, 0.14, abs(roadX - 7.53)))
* (1.0 - smoothstep(0.10, 0.23, abs(mod(travelZ, 12.0) - 6.0)));
color += uLaneColor * studs * 0.7;
// (Amber edge studs removed in the polish round: sub-pixel beyond
// ~60 m, they aliased into random orange specks along the edge.)
// Rear lamp footprint: grows towards the camera from the actual rear axle.
float behindCar = smoothstep(7.0, 8.8, vWorldZ);

View File

@@ -3,30 +3,54 @@ 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
* 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.
* resting lets the faster lanes pull away. Instances wrap through a hidden
* band behind the camera, so nothing pops in view.
*
* 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. 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).
* 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.
*/
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;
/** Max lateral jitter of a vehicle inside its lane. */
export const TRAFFIC_LANE_JITTER = 0.1;
/** Visibility of a lamp, trail or pool at world z (1 = fully lit). */
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));
return t * t * (3 - 2 * t);
@@ -40,44 +64,114 @@ export function wrapTrafficZ(z: number): number {
return z;
}
interface Lamp {
x: number;
y: number;
z: number;
w: number;
h: number;
/** 0..0.6 red to red-orange, 2 amber. */
level: 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;
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 },
];
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 },
// 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 },
];
}
const LAMPS_PER_VEHICLE = 4;
/** Axis-aligned boxes [x0, x1, y0, y1, z0, z1] in 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;
}
interface Vehicle {
lane: TrafficLane;
x: number;
z: number;
speed: number;
width: number;
height: number;
tint: number;
}
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 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;
};
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 lane = lanes[i % lanes.length]!;
// Left lanes run faster than the cruise, the right lane is slower trucks.
const fast = lane < 0;
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({
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,
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 uniforms = { uTime: { value: 0 } };
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 vertexShader = /* glsl */ `
attribute float aLevel;
varying vec2 vUv;
@@ -89,7 +183,7 @@ export function createTraffic(
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));
vNear = ${nearFade};
vec4 view = viewMatrix * world;
vDepth = -view.z;
gl_Position = projectionMatrix * view;
@@ -103,7 +197,8 @@ export function createTraffic(
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);
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);
#include <tonemapping_fragment>
#include <colorspace_fragment>
@@ -122,40 +217,122 @@ export function createTraffic(
side: THREE.DoubleSide,
}),
);
// Lamps: a hot core in a soft disc, bright enough to bloom.
// 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 = exp(-r * r * 26.0) * 3.2 + exp(-r * r * 5.0) * 0.5;`);
float glow = core * 2.4 + exp(-r * r * 22.0) * 1.2 + exp(-r * r * 5.0) * 0.3;`);
// 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.
float glow = across * pow(vUv.y, 1.6) * 0.8;`);
// Wet-road pools: long and soft under each 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 */ `
varying vec3 vLocal;
varying vec3 vNormal;
varying float vDepth;
varying float vNear;
void main() {
vLocal = position;
vNormal = normal;
vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0);
vNear = ${nearFade};
vec4 view = viewMatrix * world;
vDepth = -view.z;
gl_Position = projectionMatrix * view;
}`,
fragmentShader: /* glsl */ `
uniform vec3 uSheen;
varying vec3 vLocal;
varying vec3 vNormal;
varying float vDepth;
varying float vNear;
void main() {
float alpha = (1.0 - smoothstep(420.0, 660.0, vDepth)) * vNear;
if (alpha < 0.02) 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));
// 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.
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.
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));`}
// The tail lamps spill red on the bumper around them.
color += vec3(0.5, 0.02, 0.04) * rear * lampBand * 0.35;
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));
const lampCount = count * 2;
const mesh = (name: string, material: unknown, instances: number) => {
const instanced = new THREE.InstancedMesh(plane, material, instances);
const lampCount = count * LAMPS_PER_VEHICLE;
const quad = (name: string, material: unknown, instances: number, level: (i: number) => number) => {
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);
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 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);
// 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 bodies = (['car', 'truck'] as const).map((kind) => {
const members = vehicles.filter((v) => v.lane.kind === kind);
const geometry = resources.own('geometry', boxGeometry(kind === 'car' ? CAR_BOXES : TRUCK_BOXES));
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 };
});
const dummy = new THREE.Object3D();
const hidden = new THREE.Matrix4().makeScale(0, 0, 0);
return {
group,
/**
@@ -166,33 +343,49 @@ export function createTraffic(
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
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.16, 0.3, 14);
const trailLength = THREE.MathUtils.clamp(Math.abs(relative) * 0.14, 0.3, 12);
// 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);
const own = lampsFor[i]!;
for (let k = 0; k < LAMPS_PER_VEHICLE; k++) {
const lamp = own[k];
if (lamp === undefined) {
lamps.setMatrixAt(i * LAMPS_PER_VEHICLE + k, hidden);
continue;
}
dummy.position.set(v.x + lamp.x, lamp.y, v.z + lamp.z);
dummy.rotation.set(0, 0, 0);
dummy.scale.set(0.62, 0.36, 1);
dummy.scale.set(lamp.w, lamp.h, 1);
dummy.updateMatrix();
lamps.setMatrixAt(index, dummy.matrix);
lamps.setMatrixAt(i * LAMPS_PER_VEHICLE + k, dummy.matrix);
if (k > 1) continue;
// 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.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(index, dummy.matrix);
trails.setMatrixAt(i * 2 + k, dummy.matrix);
}
const halfWidth = TRAFFIC_BODY[v.lane.kind].halfWidth;
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.scale.set(halfWidth * 1.7, poolLength, 1);
dummy.updateMatrix();
pools.setMatrixAt(i, dummy.matrix);
}
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);
});
mesh.instanceMatrix.needsUpdate = true;
}
lamps.instanceMatrix.needsUpdate = true;
trails.instanceMatrix.needsUpdate = true;
pools.instanceMatrix.needsUpdate = true;