SOURCE / PINNED RELEASE
Made of little things.
Pursuit PIT
- Release
- 565f4981bdc3…
- Author-recorded commit
- 197afdbed44b…
- License
- LICENSE
- Author’s source reference
- nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-951414e7fd7
Archive hash verified: 3b0485589942fd5c…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import * as THREE from 'three';
import {
EDGE, LANES, LANE_W, MERGE_LEN, MERGE_TAPER, MPS_TO_MPH, RAMP_CURVE, RAMP_EVERY, RAMP_GORE,
ROAD_HALF, RIGS, VEHICLES, laneX, rampAt, rampX, rightWall, type Kind, type VehicleType,
} from './sim.js';
import type { Gearbox } from './engine.js';
import { Effects } from './effects.js';
import { partsFor } from './parts.js';
import type { SimEvent } from './sim.js';
import { RoadFrames, grade, roadSeed } from './road.js';
import { Bender } from './bend.js';
import { Culler, boundsOf } from './cull.js';
import { AreaScenery, lieAt, terrainColor, terrainY, treeShare } from './areas.js';
import { areaWeights, closedLanes, leftWall, zoneAt } from './zones.js';
import { BRIDGE_OFFSET, mileAt, placeName, signsIn, townNear, type Sign, type SignKind } from './signs.js';
import { town } from './towns.js';
import { birdPose, type Bird } from './birds.js';
import { colorName } from './colors.js';
import { BODIES, DEFAULT_GARAGE, LIGHT_BARS, LIVERIES, PAINTS, WHEELS, type GarageSpec } from './garage.js';
// Rendering is relative to the player: three.js z = -(s - originS), so GPU floats stay small
// however far the chase goes. Scenery is placed from a hash of its road cell, so the
// endless highway looks the same each time you pass a spot and nothing pops.
// The scene is built straight and flat in that road space; bend.ts then bends it along the
// road's curves and hills in the vertex shader, and cameras go through the same mapping.
// Car-local model space: x right, y up, -z forward.
/** Time of day: the sky, the light, and whether the cars and street lamps are lit. */
export type TimeOfDay = 'day' | 'dusk' | 'night';
interface Look {
sky: [number, number]; // zenith, horizon (the fog matches the horizon)
fog: [number, number]; // near, far
hemi: [number, number, number]; // sky colour, ground colour, intensity
sun: [number, number, number]; // colour, intensity, height (0 on the horizon, 1 high)
lights: number; // car headlights and street lamps: 0 off, up to 1 at night
dash: number; // the cabin's soft fill
signs: number; // sign boards (unlit artwork) brightness
}
const LOOKS: Record<TimeOfDay, Look> = {
day: { sky: [0x4f8fd6, 0xd6e6ef], fog: [520, 2300], hemi: [0xf4f8ff, 0x5a6b3a, 1.7], sun: [0xfff4e0, 1.7, 1], lights: 0, dash: 1, signs: 1 },
dusk: { sky: [0x343a78, 0xf0a066], fog: [420, 2000], hemi: [0xffc8a0, 0x3b3550, 0.95], sun: [0xff9a4a, 1.1, 0.16], lights: 0.6, dash: 0.75, signs: 0.85 },
night: { sky: [0x03060f, 0x18213c], fog: [260, 1500], hemi: [0x7080b0, 0x0c1016, 0.32], sun: [0x9fb2e8, 0.22, 0.6], lights: 1, dash: 0.22, signs: 0.6 },
};
/** The player's own headlights (a spotlight; other cars get a pool of light on the road). */
const HEADLIGHT = 90;
/** How much road other cars' headlights light up ahead of them, and how wide. */
const POOL_LONG = 70;
/** Most lamps glowing at once (two per car end). */
const GLOW_MAX = 1200;
const POOL_WIDE = 12;
/** Beyond this distance (metres) a car is drawn as one merged mesh instead of its parts. */
const LOD_FAR = 80;
/**
* Light on the road: two beams from a car's bumper (the texture's bottom edge) spreading up
* the road, or the round pool under a street lamp. Drawn additively, so pools add up.
*/
function poolTexture(kind: 'car' | 'lamp'): THREE.CanvasTexture {
const c = document.createElement('canvas');
c.width = 128;
c.height = kind === 'car' ? 512 : 128;
const g = c.getContext('2d')!;
if (kind === 'lamp') {
const r = g.createRadialGradient(64, 64, 0, 64, 64, 64);
r.addColorStop(0, 'rgba(255,255,255,0.6)');
r.addColorStop(0.5, 'rgba(255,255,255,0.25)');
r.addColorStop(1, 'rgba(255,255,255,0)');
g.fillStyle = r;
g.fillRect(0, 0, 128, 128);
} else {
// A low beam as real lamps throw it (right-hand traffic): little light just ahead of the
// bumper (the bottom edge), a bright hot spot a third of the way out and a little right of
// centre, a wide spread across the lane, and a soft cutoff that reaches further down the
// right side (the kerb and the signs) than the left (the oncoming lanes).
const img = g.createImageData(128, 512);
const smooth = (a: number, b: number, v: number) => {
const k = Math.min(1, Math.max(0, (v - a) / (b - a)));
return k * k * (3 - 2 * k);
};
for (let py = 0; py < 512; py++) {
const v = 1 - py / 511; // 0 at the bumper, 1 at the far end
const near = 0.3 + 0.7 * smooth(0, 0.3, v); // the first few metres stay dim
for (let px = 0; px < 128; px++) {
const u = (px / 127) * 2 - 1; // -1 left edge, 1 right edge
const reach = 0.62 + (0.95 - 0.62) * smooth(-0.3, 0.4, u); // the cutoff: further on the right
const cut = 1 - smooth(reach - 0.14, reach, v);
const body = Math.exp(-((u / (0.5 + 0.4 * v)) ** 2));
const hot = Math.exp(-(((u - 0.12) / 0.35) ** 2) - (((v - 0.42) / 0.2) ** 2));
const a = 0.7 * near * cut * Math.min(1, 0.55 * body + 0.6 * hot) * (1 - smooth(0.8, 1, Math.abs(u)));
const i = (py * 128 + px) * 4;
img.data[i] = img.data[i + 1] = img.data[i + 2] = 255;
img.data[i + 3] = Math.round(255 * Math.min(0.7, a));
}
}
g.putImageData(img, 0, 0);
}
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
/** A render target stored as sRGB (precise in the darks); sampling it gives linear colour back. */
function srgbTarget(width: number, height: number, samples = 0): THREE.WebGLRenderTarget {
const target = new THREE.WebGLRenderTarget(width, height, { samples });
target.texture.colorSpace = THREE.SRGBColorSpace;
return target;
}
const DASH_PERIOD = 12;
const ROAD_BEHIND = 240;
const ROAD_AHEAD = 3000; // how far ahead the road and scenery are drawn
const RAIL_PIECE = 6;
const RAMP_LEN = RAMP_CURVE + 260; // drawn length of each slip road
// Terrain: a strip of ground each side of the road, rebuilt as the car moves on.
const TER_STEP = 10;
const TER_BEHIND = 400;
const TER_AHEAD = 3200;
const TER_X = [ROAD_HALF + 4, ROAD_HALF + 12, ROAD_HALF + 24, 45, 70, 100, 140, 190, 260, 350, 470, 620, 820, 1050, 1450, 2100];
const SIGN_POOL = 96;
/** Dev builds count each frame's CPU time and draw calls (window.__perf, for benchmarking). */
const PERF = import.meta.env.DEV;
// Driver's eye, in car-local metres: left seat, a little behind the centre.
const EYE = { lat: -0.38, lon: -0.45, up: 1.17 };
/** A box truck's short hood slopes up toward the windscreen. */
const BOX_HOOD_TILT = -0.18;
/** How far a body rides above a sedan's: high on a lifted truck, low on a sports car. */
const bodyLift = (type: VehicleType) => (type === 'lifted' ? 0.38 : type === 'sports' ? -0.07 : 0);
/** What the renderer needs to know about a car; live sim cars and replay snapshots both fit. */
export interface CarPose {
id: number;
kind: Kind;
x: number;
s: number;
yaw: number;
vx: number;
vs: number;
color: number;
len: number;
wid: number;
wrecked: boolean;
lost: number; // panels knocked off (bitmask, see parts.ts)
exploded: boolean;
type?: VehicleType;
cargoLost?: boolean; // a logging truck's logs have come off
trailerYaw?: number; // a rig's trailer heading (sim.ts trailerFollow)
signal?: number; // traffic indicator: -1 left, 1 right, 0 off
braking?: boolean; // traffic brake lights
}
export interface ViewState {
cars: readonly CarPose[];
player: CarPose;
input: { steer: number };
birds?: { birds: readonly Bird[] } | null; // free drive's bird strikes
}
/** Third-person replay camera: orbit around a point between the player and the suspect. */
export interface ReplayCamera {
angle: number; // 0 = straight behind, +π/2 = off to the right
snap: boolean; // jump instead of easing (loop restart)
rate?: number; // playback speed, so effects run in slow motion too
}
interface CarView {
group: THREE.Group;
marker?: THREE.Mesh;
panels: THREE.Mesh[]; // one per parts.ts entry
details: THREE.Mesh; // wheels, grille, mirrors, plates... merged (one per car)
attached: [number, THREE.Mesh][]; // lights that go with a panel
core: THREE.Mesh; // the chassis under the panels
cabin: THREE.Mesh;
coreMat: THREE.Material;
cabinMat: THREE.Material;
charred: boolean;
wheels?: THREE.Mesh; // kept on the ground when the body is raised or lowered
cargo?: THREE.Object3D; // a logging truck's load
trailer?: THREE.Group; // a rig's trailer, turning on the fifth wheel
trailerDetails?: THREE.Mesh;
// Patrol car only: parts that show from outside but not from the driver's seat.
exterior?: THREE.Object3D[];
beacons?: THREE.Mesh[];
// Traffic: lit brake lights and indicators, shown in render(). part: the panel they're on (-1: none).
// Every car: headlights, tail running lights and the pool of light ahead, lit at dusk and night.
lamps?: { kind: 'brake' | 'head' | 'tail' | 'pool' | -1 | 1; part: number; mesh: THREE.Mesh }[];
// Bounding spheres in the car's frame, for culling: the car, and the car with its pool of light.
bounds?: THREE.Sphere;
litBounds?: THREE.Sphere;
// Far away the car is one merged mesh (`proxy`, rebuilt when its damage changes) and the
// children in `near` are taken out of the scene graph, so nothing walks them each frame.
// The pool of light, marker, light bar and trailer stay live.
near?: THREE.Object3D[];
proxy?: THREE.Mesh;
proxyKey?: string;
}
function shade(color: number, k: number): number {
const c = new THREE.Color(color).multiplyScalar(k);
return c.getHex();
}
const speedOf = (car: CarPose) => car.vx * Math.sin(car.yaw) + car.vs * Math.cos(car.yaw);
function hash(n: number): number {
const x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
}
/** A plastic-grain texture for interior trim. */
function grainTexture(base: string): THREE.CanvasTexture {
const c = document.createElement('canvas');
c.width = c.height = 64;
const g = c.getContext('2d')!;
g.fillStyle = base;
g.fillRect(0, 0, 64, 64);
for (let i = 0; i < 700; i++) {
g.fillStyle = Math.random() < 0.5 ? 'rgba(0,0,0,0.16)' : 'rgba(255,255,255,0.05)';
g.fillRect(Math.random() * 64, Math.random() * 64, 1, 1);
}
const tex = new THREE.CanvasTexture(c);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(8, 3);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
/**
* Several coloured pieces merged into one geometry with vertex colours, so a car's fixed
* details (wheels, grille, mirrors, plates...) cost one draw call.
*/
function mergePieces(pieces: { geo: THREE.BufferGeometry; color: number; m: THREE.Matrix4 }[]): THREE.BufferGeometry {
const pos: number[] = [], col: number[] = [];
const c = new THREE.Color(), v = new THREE.Vector3();
for (const p of pieces) {
const g = p.geo.index ? p.geo.toNonIndexed() : p.geo;
const a = g.getAttribute('position');
c.setHex(p.color);
for (let i = 0; i < a.count; i++) {
v.fromBufferAttribute(a, i).applyMatrix4(p.m);
pos.push(v.x, v.y, v.z);
col.push(c.r, c.g, c.b);
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
geo.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
geo.computeVertexNormals();
return geo;
}
/**
* The visible, lit (Lambert, opaque) meshes under `roots`, merged into one vertex-coloured
* geometry in `frame`'s space: a car as it looks right now, in one draw call.
*/
function mergeVisible(frame: THREE.Object3D, roots: THREE.Object3D[]): THREE.BufferGeometry {
frame.updateMatrixWorld(true);
const inv = frame.matrixWorld.clone().invert();
const pos: number[] = [], col: number[] = [];
const m = new THREE.Matrix4(), v = new THREE.Vector3(), c = new THREE.Color();
for (const root of roots) {
root.traverseVisible((o) => {
const mesh = o as THREE.Mesh;
const mat = mesh.material as THREE.MeshLambertMaterial;
if (!mesh.isMesh || !mat.isMeshLambertMaterial || mat.transparent) return;
const g = mesh.geometry.index ? mesh.geometry.toNonIndexed() : mesh.geometry;
const a = g.getAttribute('position'), vc = mat.vertexColors ? g.getAttribute('color') : undefined;
m.multiplyMatrices(inv, mesh.matrixWorld);
for (let i = 0; i < a.count; i++) {
v.fromBufferAttribute(a, i).applyMatrix4(m);
pos.push(v.x, v.y, v.z);
c.copy(mat.color);
if (vc) c.multiply(new THREE.Color(vc.getX(i), vc.getY(i), vc.getZ(i)));
col.push(c.r, c.g, c.b);
}
});
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
geo.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
geo.computeVertexNormals();
return geo;
}
/** A unit box whose top is narrowed and raked front and back: a car's glasshouse. */
function cabinGeometry(rearRake = 0.18): THREE.BufferGeometry {
const geo = new THREE.BoxGeometry(1, 1, 1);
const p = geo.getAttribute('position') as THREE.BufferAttribute;
for (let i = 0; i < p.count; i++) {
if (p.getY(i) <= 0) continue;
p.setX(i, p.getX(i) * 0.86);
p.setZ(i, p.getZ(i) < 0 ? p.getZ(i) + 0.28 : p.getZ(i) - rearRake); // windscreen and rear-window rake
}
geo.computeVertexNormals();
return geo;
}
/** A low-poly bird facing -z: diamond body, swept wings with raised tips, a fan tail. */
function birdGeometry(): THREE.BufferGeometry {
const N = [0, 0, -0.22], T = [0, 0, 0.2], L = [-0.06, 0, 0], R = [0.06, 0, 0], U = [0, 0.05, 0], D = [0, -0.04, 0];
const tris = [
N, U, L, N, R, U, N, L, D, N, D, R, T, L, U, T, U, R, T, D, L, T, R, D, // body
[-0.05, 0, -0.06], [-0.05, 0, 0.08], [-0.45, 0.12, 0.06], // wings
[0.05, 0, -0.06], [0.45, 0.12, 0.06], [0.05, 0, 0.08],
[0, 0, 0.16], [-0.08, 0, 0.3], [0.08, 0, 0.3], // tail
];
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(tris.flat(), 3));
geo.computeVertexNormals();
return geo;
}
/** A blood splat: an irregular blot with droplets flung out round it, on a clear background. */
function splatTexture(seed: number): THREE.CanvasTexture {
const c = document.createElement('canvas');
c.width = c.height = 128;
const g = c.getContext('2d')!;
const rnd = (k: number) => hash(seed * 31.7 + k);
const blob = (x: number, y: number, r: number, color: string) => {
g.fillStyle = color;
g.beginPath();
g.arc(x, y, r, 0, Math.PI * 2);
g.fill();
};
for (let i = 0; i < 9; i++) blob(64 + (rnd(i) - 0.5) * 34, 64 + (rnd(i + 20) - 0.5) * 34, 14 + rnd(i + 40) * 14, i % 3 ? '#8a0909' : '#6c0505');
for (let i = 0; i < 22; i++) {
// Droplets and streaks spraying outward.
const a = rnd(i + 60) * Math.PI * 2, d = 30 + rnd(i + 80) * 30, r = 1.5 + rnd(i + 100) * 4;
blob(64 + Math.cos(a) * d, 64 + Math.sin(a) * d, r, '#7c0707');
g.strokeStyle = '#7c0707';
g.lineWidth = r * 0.9;
g.beginPath();
g.moveTo(64 + Math.cos(a) * 24, 64 + Math.sin(a) * 24);
g.lineTo(64 + Math.cos(a) * d * 0.8, 64 + Math.sin(a) * d * 0.8);
g.stroke();
}
blob(60, 58, 9, 'rgba(190,40,40,0.35)'); // a wet highlight
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
/**
* A wiper smear: thin streaks of blood running along the blade's path (the texture's x axis),
* heaviest where the blade picked the blood up and thinning out toward the far end.
*/
function smearTexture(): THREE.CanvasTexture {
const c = document.createElement('canvas');
c.width = 256;
c.height = 48;
const g = c.getContext('2d')!;
for (let i = 0; i < 26; i++) {
const y = 6 + hash(i * 3.1) * 36;
const len = 120 + hash(i * 7.7) * 130;
const grad = g.createLinearGradient(0, 0, len, 0);
const a = 0.45 + hash(i * 1.9) * 0.45;
grad.addColorStop(0, `rgba(150,10,10,${a})`);
grad.addColorStop(0.6, `rgba(135,12,12,${a * 0.55})`);
grad.addColorStop(1, 'rgba(100,10,10,0)');
g.strokeStyle = grad;
g.lineWidth = 1 + hash(i * 5.3) * 3.5;
g.beginPath();
g.moveTo(0, y);
g.lineTo(len, y + (hash(i * 9.1) - 0.5) * 4);
g.stroke();
}
// Soften the long edges so the smear has no hard border.
g.globalCompositeOperation = 'destination-in';
const fade = g.createLinearGradient(0, 0, 0, 48);
fade.addColorStop(0, 'rgba(0,0,0,0)');
fade.addColorStop(0.25, 'rgba(0,0,0,1)');
fade.addColorStop(0.75, 'rgba(0,0,0,1)');
fade.addColorStop(1, 'rgba(0,0,0,0)');
g.fillStyle = fade;
g.fillRect(0, 0, 256, 48);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
const PLAYER_SPLAT_LIFE = 60; // seconds before blood on the patrol car's body has gone
const SPLAT_FADE = 6;
// Windscreen, in its own plane: u across (right +), d up the glass from the dash.
const GLASS_LEN = Math.hypot(0.44, 0.8);
const WIPER_PIVOTS = [-0.52, -0.02]; // u of each wiper's spindle, just above the dash
const WIPER_D = 0.03;
const BLADE = 0.74;
// Parked just above flat (pointing right) so the right blade rests inside the glass; the
// stroke stops where the left blade's tip is just short of the left A-pillar.
const WIPER_PARK = 0.06;
const SWEEP = 1.62; // radians from parked to the top of the stroke
const WIPE_CYCLE = 1.3; // seconds for one stroke up and back
const SMEAR = 0.45; // what's left of a splat after a blade passes over it
interface SignStyle {
w: number; // board size, metres
h: number;
bottom: number; // board height above the road
}
const SIGN_STYLE: Record<SignKind, SignStyle> = {
welcome: { w: 5.4, h: 2.8, bottom: 1.6 },
tagline: { w: 5.8, h: 2.2, bottom: 1.6 },
leave: { w: 5.4, h: 2.6, bottom: 1.6 },
curve: { w: 1.8, h: 1.8, bottom: 1.5 },
chevron: { w: 0.9, h: 1.2, bottom: 0.9 },
hill: { w: 2.6, h: 2.2, bottom: 1.5 },
speed: { w: 1.6, h: 2.0, bottom: 1.5 },
mile: { w: 0.55, h: 1.1, bottom: 0.8 },
guide: { w: 11, h: 3.0, bottom: 5.0 },
warn: { w: 2.4, h: 2.4, bottom: 1.5 },
work: { w: 2.4, h: 2.4, bottom: 1.4 },
};
const PX_PER_M = 64;
/** Sign artwork, in the colours of real US road signs. */
function drawSign(kind: SignKind, lines: string[], dir: number): HTMLCanvasElement {
const style = SIGN_STYLE[kind];
const c = document.createElement('canvas');
c.width = Math.round(style.w * PX_PER_M);
c.height = Math.round(style.h * PX_PER_M);
const g = c.getContext('2d')!;
const { width: w, height: h } = c;
const font = (px: number) => `bold ${px}px "Arial Narrow", Arial, sans-serif`;
/** Text centred at y, shrunk until it fits. */
const text = (t: string, y: number, px: number, color: string, maxW = w * 0.9) => {
g.font = font(px);
while (g.measureText(t).width > maxW && px > 6) g.font = font(--px);
g.fillStyle = color;
g.textAlign = 'center';
g.textBaseline = 'middle';
g.fillText(t, w / 2, y);
};
const panel = (bg: string, fg: string, inset = 6) => {
g.fillStyle = bg;
g.fillRect(0, 0, w, h);
g.strokeStyle = fg;
g.lineWidth = 4;
g.strokeRect(inset, inset, w - inset * 2, h - inset * 2);
};
const diamond = (bg: string) => {
g.fillStyle = '#111';
g.beginPath();
g.moveTo(w / 2, 0); g.lineTo(w, h / 2); g.lineTo(w / 2, h); g.lineTo(0, h / 2);
g.fill();
g.fillStyle = bg;
g.beginPath();
g.moveTo(w / 2, 5); g.lineTo(w - 5, h / 2); g.lineTo(w / 2, h - 5); g.lineTo(5, h / 2);
g.fill();
};
const WHITE = '#f4f1e6', GREEN = '#1d6b3c', YELLOW = '#f2c21b', INK = '#141414';
switch (kind) {
case 'welcome':
case 'leave':
panel(GREEN, WHITE);
text(lines[0], h * 0.22, 26, WHITE);
text(lines[1], h * 0.5, 50, WHITE);
text(lines[2], h * 0.78, 24, WHITE);
break;
case 'tagline':
panel('#6b4527', WHITE);
text(lines[0], h * 0.28, 30, WHITE);
text(lines[1], h * 0.66, 30, '#ffe7b8');
break;
case 'speed':
panel(WHITE, INK);
text(lines[0], h * 0.2, 26, INK);
text(lines[1], h * 0.38, 26, INK);
text(lines[2], h * 0.7, 56, INK);
break;
case 'mile':
panel(GREEN, WHITE, 3);
text(lines[0], h * 0.25, 13, WHITE);
text(lines[1], h * 0.65, 22, WHITE);
break;
case 'hill':
panel(YELLOW, INK);
text(lines[0], h * 0.24, 30, INK);
text(lines[1], h * 0.52, 44, INK);
text(lines[2], h * 0.8, 20, INK);
break;
case 'warn':
case 'work':
// Diamond warnings: yellow out on the road, orange for road works.
diamond(kind === 'work' ? '#f07d1a' : YELLOW);
text(lines[0], h * 0.33, 26, INK, w * 0.5);
text(lines[1], h * 0.5, 24, INK, w * 0.62);
if (lines[2]) text(lines[2], h * 0.66, 16, INK, w * 0.48);
break;
case 'curve': {
diamond(YELLOW);
// A bent arrow: up, then curving off the way the road goes.
g.strokeStyle = INK;
g.fillStyle = INK;
g.lineWidth = 11;
g.beginPath();
g.moveTo(w / 2 - dir * 10, h * 0.78);
g.quadraticCurveTo(w / 2 - dir * 10, h * 0.4, w / 2 + dir * 18, h * 0.32);
g.stroke();
g.beginPath();
g.moveTo(w / 2 + dir * 36, h * 0.3);
g.lineTo(w / 2 + dir * 12, h * 0.18);
g.lineTo(w / 2 + dir * 16, h * 0.44);
g.fill();
break;
}
case 'chevron':
g.fillStyle = YELLOW;
g.fillRect(0, 0, w, h);
g.fillStyle = INK;
g.beginPath();
for (const [x, y] of [[0.25, 0.15], [0.5, 0.15], [0.8, 0.5], [0.5, 0.85], [0.25, 0.85], [0.55, 0.5]]) {
g.lineTo(w / 2 + dir * (x - 0.5) * w, y * h);
}
g.fill();
break;
case 'guide':
panel(GREEN, WHITE);
lines.forEach((line, i) => {
const [name, miles] = line.split('|');
const y = h * (0.32 + i * 0.38);
g.font = font(46);
g.textBaseline = 'middle';
g.fillStyle = WHITE;
g.textAlign = 'right';
g.fillText(miles, w - 30, y);
text(name, y, 46, WHITE, w * 0.7);
});
break;
}
return c;
}
function roadTexture(): THREE.CanvasTexture {
const w = 512, h = 64; // h spans one dash period
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const g = c.getContext('2d')!;
g.fillStyle = '#4a4a50';
g.fillRect(0, 0, w, h);
// A little grain so speed reads on the asphalt.
for (let i = 0; i < 500; i++) {
g.fillStyle = Math.random() < 0.5 ? 'rgba(0,0,0,0.12)' : 'rgba(255,255,255,0.06)';
g.fillRect(Math.random() * w, Math.random() * h, 2, 1);
}
const px = (x: number) => ((x + ROAD_HALF) / (2 * ROAD_HALF)) * w;
g.fillStyle = '#e8e2c8';
for (const side of [-1, 1]) g.fillRect(px(side * EDGE) - 1.5, 0, 3, h);
for (let i = 1; i < LANES; i++) g.fillRect(px((i - LANES / 2) * LANE_W) - 1, 0, 2, h * 0.3);
// Rumble strip grooves just outside both edge lines.
g.fillStyle = 'rgba(0,0,0,0.35)';
for (let y = 0; y < h; y += 4) {
for (const side of [-1, 1]) g.fillRect(px(side * (EDGE + 0.45)) - 6, y, 12, 1.5);
}
const tex = new THREE.CanvasTexture(c);
tex.wrapS = THREE.ClampToEdgeWrapping;
tex.wrapT = THREE.RepeatWrapping;
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
/**
* Ribbon along the slip road in ramp-model space (z = metres before the ramp start).
* Flat between lateral offsets a..b, or, with `height`, a vertical barrier at offset a.
*/
function rampRibbon(a: number, b: number, y: number, d0 = 0, d1 = RAMP_LEN, height = 0): THREE.BufferGeometry {
const pos: number[] = [];
const idx: number[] = [];
let n = 0;
for (let d = d0; d <= d1; d += 5) {
const x = rampX(d);
const slope = rampX(d + 0.5) - rampX(d - 0.5); // dx per metre of d
const len = Math.hypot(1, slope);
const nx = 1 / len, nz = -slope / len; // unit sideways vector in (x, z)
if (height) pos.push(x + nx * a, y, d + nz * a, x + nx * a, y + height, d + nz * a);
else pos.push(x + nx * a, y, d + nz * a, x + nx * b, y, d + nz * b);
if (n > 0) idx.push(2 * n - 2, 2 * n, 2 * n - 1, 2 * n - 1, 2 * n, 2 * n + 1);
n++;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
geo.setIndex(idx);
geo.computeVertexNormals();
return geo;
}
export class Renderer {
readonly canvas: HTMLCanvasElement;
private gl: THREE.WebGLRenderer;
private scene = new THREE.Scene();
private camera = new THREE.PerspectiveCamera(56, 1, 0.05, 4800);
private mirrorCam = new THREE.PerspectiveCamera(22, 3.6, 0.4, 800);
private mirrorTarget = srgbTarget(360, 100);
// The view is drawn into this, then copied to the screen, so it renders in the same (linear)
// colour space as the mirrors: three otherwise switches every shared material's program
// between the mirror passes and the view, every frame. Stored as sRGB, so darks don't band.
private frameTarget = srgbTarget(1, 1, 4);
private blit = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.MeshBasicMaterial({
map: this.frameTarget.texture, depthTest: false, depthWrite: false, toneMapped: false, fog: false,
}));
private blitCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
// Left and right door mirrors; one is re-rendered each frame, in turn.
private doorMirrors: { side: number; target: THREE.WebGLRenderTarget; glass: THREE.Mesh; cam: THREE.PerspectiveCamera }[] = [];
private mirrorTurn = 0;
private views = new Map<number, CarView>();
private road: THREE.Mesh;
private shoulders: THREE.Mesh;
private rails: THREE.InstancedMesh;
private posts: THREE.InstancedMesh;
private poles: THREE.InstancedMesh;
private trees: THREE.InstancedMesh;
private bridges: THREE.InstancedMesh;
private ramps: THREE.Group[] = [];
private ground: THREE.Mesh;
private boxGeo = new THREE.BoxGeometry(1, 1, 1);
private mats = new Map<number, THREE.MeshLambertMaterial>();
private capacity = new Map<THREE.InstancedMesh, number>();
private interior = new THREE.Group();
private wheel = new THREE.Group();
private mirrorGlass!: THREE.Mesh;
private gauges = document.createElement('canvas');
private gaugeTex = new THREE.CanvasTexture(this.gauges);
private steer = 0;
private shake = 0;
private effects: Effects;
private radioCanvas = document.createElement('canvas');
private radioTex = new THREE.CanvasTexture(this.radioCanvas);
private radioText = '';
private birdMesh = new THREE.InstancedMesh(birdGeometry(),
new THREE.MeshLambertMaterial({ color: 0x2a2a30, flatShading: true, side: THREE.DoubleSide }), 48);
private splatGeo = new THREE.PlaneGeometry(1, 1);
private splatTex: THREE.CanvasTexture[] = [];
private splatMats: THREE.MeshLambertMaterial[] = [];
// Blood on the patrol car. On the body it fades after PLAYER_SPLAT_LIFE; on the
// windscreen it stays until the wipers clear it.
// Blood on the patrol car. `smear` marks a streak a wiper dragged out of a splat.
private playerSplats: { mesh: THREE.Mesh; born: number; glass: boolean; smear?: boolean }[] = [];
private smearTex = smearTexture();
private glass = new THREE.Group(); // windscreen plane: x = u, y = d, z toward the driver
private wiperArms: THREE.Group[] = [];
private wiperPhase = 0; // 0..2π per stroke; parked at whole turns
private wiperAngle = WIPER_PARK;
/** Wipers switched on (they finish their stroke and park when switched off). */
wipers = false;
private splatPlayer = -1;
private laptopCanvas = document.createElement('canvas');
private radarCanvas = document.createElement('canvas');
private radarTex = new THREE.CanvasTexture(this.radarCanvas);
private switchLeds: THREE.Mesh[] = [];
// Laptop, dash radar and light switches: hidden in the getaway car.
private policeKit: THREE.Object3D[] = [];
private laptopTex = new THREE.CanvasTexture(this.laptopCanvas);
// The dash screens are redrawn (and re-uploaded) only as often as they need: the laptop and
// radar a few times a second, the gauges when their needles or numbers move.
private laptopDue = 0;
private radarDue = 0;
private gaugeKey = '';
private pitch = -0.05;
private frames = new RoadFrames(600, ROAD_AHEAD + 200, 2);
private bender = new Bender(this.frames);
private bent = { x: 0, y: 0, z: 0 };
private culler = new Culler();
private sun!: THREE.DirectionalLight;
private hemi!: THREE.HemisphereLight;
/** The time of day the scene is lit for (setTimeOfDay). */
timeOfDay: TimeOfDay = 'day';
private look: Look = LOOKS.day;
private skyCanvas = document.createElement('canvas');
private skyTex = new THREE.CanvasTexture(this.skyCanvas);
private stars!: THREE.Points;
private headlight = new THREE.SpotLight(0xfff2d8, 0, 340, 0.62, 0.85, 1); // reach, beam half-angle, soft edge
private headMat = new THREE.MeshBasicMaterial({ color: 0xfff4d6 });
private tailMat = new THREE.MeshBasicMaterial({ color: 0xa8141a });
private carPoolMat = new THREE.MeshBasicMaterial({
map: poolTexture('car'), color: 0xfff0d0, transparent: true, blending: THREE.AdditiveBlending,
depthWrite: false, polygonOffset: true, polygonOffsetFactor: -4,
});
private poolGeo?: THREE.BufferGeometry;
private lampHeads!: THREE.InstancedMesh;
private lampPools!: THREE.InstancedMesh;
private dashMats: THREE.MeshLambertMaterial[] = []; // the cabin's fill, dimmed after dark
private trimMat?: THREE.MeshLambertMaterial; // the merged static trim (its fill is a light map)
// Lamp glare ("coronas") after dark: all the headlights in one draw, all the tail lights in another.
private headGlow!: THREE.Points;
private tailGlow!: THREE.Points;
private terrain!: THREE.Mesh;
private areas!: AreaScenery;
private terrainAt = NaN; // s the terrain was last built for
private terrainSeed = -1;
// Each row's heights and colours by its s (y, r, g, b per column), so moving on a row only
// works out the new one.
private terrainRows = new Map<number, Float32Array>();
private signParts!: THREE.InstancedMesh;
private signBoards: THREE.Mesh[] = [];
private signArt = new Map<string, THREE.CanvasTexture>();
private signGeo = new THREE.PlaneGeometry(1, 1);
// Shared car-model pieces, made on first use.
private cabinGeo?: THREE.BufferGeometry;
private truckCabGeo?: THREE.BufferGeometry; // upright back, like a pickup's cab
private tireGeo?: THREE.BufferGeometry;
private hubGeo?: THREE.BufferGeometry;
private rodGeo?: THREE.BufferGeometry; // unit cylinder: stacks, tanks, logs
private detailMat?: THREE.MeshLambertMaterial;
private charredDetailMat?: THREE.MeshLambertMaterial;
mirror = true;
/** How the patrol cars and the getaway car look (garage.ts). Applies to cars built after a change. */
garage: GarageSpec = DEFAULT_GARAGE;
/** The player drives a patrol car (pursuit, free drive), not the getaway car. */
playerIsPatrol = true;
// The garage menu: a workshop with the car being customised on a turntable.
private garageScene?: THREE.Scene;
private garageCam = new THREE.PerspectiveCamera(40, 1, 0.1, 200);
private garageCar?: { view: CarView; key: string };
private turntable?: THREE.Group;
private turn = 0.6;
/** Totals since the last reset (dev builds only): frames, CPU ms by stage, draw calls, triangles. */
readonly perf = { frames: 0, road: 0, scene: 0, submit: 0, total: 0, calls: 0, triangles: 0 };
constructor(host: HTMLElement) {
// Antialiased in frameTarget (4x MSAA), so the canvas itself needn't be.
this.gl = new THREE.WebGLRenderer({ antialias: false, powerPreference: 'high-performance' });
this.blit.position.z = -0.5;
this.blit.frustumCulled = false;
this.gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
this.canvas = this.gl.domElement;
if (PERF) {
this.gl.info.autoReset = false; // the mirrors and the main view add up to one frame
(window as unknown as { __perf: Renderer['perf'] }).__perf = this.perf;
(window as unknown as { __scene: THREE.Scene }).__scene = this.scene;
}
host.append(this.canvas);
this.scene.fog = new THREE.Fog(0xffffff, 520, 2300); // coloured by setTimeOfDay
// Matrices are updated once a frame in render(), not again for each mirror pass.
this.scene.matrixWorldAutoUpdate = false;
this.hemi = new THREE.HemisphereLight(0xfff1dc, 0x5a6b3a, 1.6);
this.scene.add(this.hemi);
this.sun = new THREE.DirectionalLight(0xffe0b0, 1.6);
this.scene.add(this.sun);
// Far ground under the terrain strips, out to the fog. Flat: it is not bent.
this.ground = new THREE.Mesh(new THREE.PlaneGeometry(12000, 12000), new THREE.MeshLambertMaterial({ color: 0x7d8f4a }));
this.ground.rotation.x = -Math.PI / 2;
this.ground.userData.flat = true;
this.scene.add(this.ground);
this.terrain = this.makeTerrain();
this.areas = new AreaScenery(this.scene, (c) => this.mat(c));
// Road meshes are cut into short rows so they follow the bends.
const roadLen = ROAD_BEHIND + ROAD_AHEAD;
const rows = roadLen / 4;
const tex = roadTexture();
tex.repeat.set(1, roadLen / DASH_PERIOD);
this.road = new THREE.Mesh(new THREE.PlaneGeometry(ROAD_HALF * 2, roadLen, 1, rows), new THREE.MeshLambertMaterial({ map: tex }));
this.road.rotation.x = -Math.PI / 2;
this.scene.add(this.road);
this.shoulders = new THREE.Mesh(new THREE.PlaneGeometry(ROAD_HALF * 2 + 10, roadLen, 1, rows), this.mat(0x9a8d72));
this.shoulders.rotation.x = -Math.PI / 2;
this.shoulders.position.y = -0.02;
this.scene.add(this.shoulders);
const pieces = Math.ceil(roadLen / RAIL_PIECE) + 4;
this.rails = new THREE.InstancedMesh(this.boxGeo, this.mat(0xb9bcc0), 2 * pieces);
this.posts = new THREE.InstancedMesh(this.boxGeo, this.mat(0x6d6f72), 2 * pieces);
this.poles = new THREE.InstancedMesh(this.boxGeo, this.mat(0x55585c), 4 * Math.ceil(roadLen / 60) + 8);
this.trees = new THREE.InstancedMesh(new THREE.ConeGeometry(1, 1, 5), this.mat(0x4d6a35), 2 * Math.ceil(roadLen / 14) + 2);
this.bridges = new THREE.InstancedMesh(this.boxGeo, this.mat(0x8f8a80), 24);
for (const m of [this.rails, this.posts, this.poles, this.trees, this.bridges]) {
this.capacity.set(m, m.count);
m.frustumCulled = false;
this.scene.add(m);
}
// Sign posts and backs share one instanced mesh; each board has its own artwork.
this.signParts = new THREE.InstancedMesh(this.boxGeo, this.mat(0x7a7d80), SIGN_POOL * 3 + 8);
this.capacity.set(this.signParts, this.signParts.count);
this.scene.add(this.signParts);
for (let i = 0; i < SIGN_POOL; i++) {
const blank = new THREE.CanvasTexture(document.createElement('canvas'));
const board = new THREE.Mesh(this.signGeo, new THREE.MeshBasicMaterial({ map: blank, transparent: true, alphaTest: 0.5 }));
board.visible = false;
this.signBoards.push(board);
this.scene.add(board);
}
for (let i = 0; i < 5; i++) this.ramps.push(this.makeRamp());
this.effects = new Effects(this.scene);
this.birdMesh.count = 0;
this.birdMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
this.scene.add(this.birdMesh);
for (let i = 0; i < 5; i++) {
const tex = splatTexture(i + 1);
this.splatTex.push(tex);
this.splatMats.push(this.splatMaterial(tex));
}
this.gauges.width = 256;
this.gauges.height = 112;
this.gaugeTex.colorSpace = THREE.SRGBColorSpace;
this.buildInterior();
this.makeNightLights();
this.setTimeOfDay('day');
}
/** Stars, the player's headlights, and the street lamps on the light poles. */
private makeNightLights(): void {
// Stars on a dome round the camera; not bent, they're as good as infinitely far away.
const n = 700, pos = new Float32Array(n * 3);
for (let i = 0; i < n; i++) {
const u = Math.random() * Math.PI * 2, v = 0.05 + Math.random() * 0.95, r = 2400, ring = Math.sqrt(1 - v * v);
pos.set([Math.cos(u) * ring * r, v * r, Math.sin(u) * ring * r], i * 3);
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
this.stars = new THREE.Points(geo, new THREE.PointsMaterial({ color: 0xdfe6ff, size: 1.6, sizeAttenuation: false, fog: false }));
this.stars.frustumCulled = false;
this.scene.add(this.stars);
this.scene.add(this.headlight, this.headlight.target);
// Coronas: soft glowing sprites at every lamp, placed (already bent) each frame.
const glow = document.createElement('canvas');
glow.width = glow.height = 64;
const gg = glow.getContext('2d')!;
const r = gg.createRadialGradient(32, 32, 0, 32, 32, 32);
r.addColorStop(0, 'rgba(255,255,255,1)');
r.addColorStop(0.12, 'rgba(255,255,255,0.8)');
r.addColorStop(0.35, 'rgba(255,255,255,0.14)');
r.addColorStop(1, 'rgba(255,255,255,0)');
gg.fillStyle = r;
gg.fillRect(0, 0, 64, 64);
const glowTex = new THREE.CanvasTexture(glow);
const corona = (color: number, size: number) => {
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(GLOW_MAX * 3), 3));
const points = new THREE.Points(geo, new THREE.PointsMaterial({
map: glowTex, color, size, sizeAttenuation: true, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
}));
points.frustumCulled = false;
points.userData.flat = true; // positions are bent on the CPU
this.scene.add(points);
return points;
};
this.headGlow = corona(0xfff0d2, 1.6);
this.tailGlow = corona(0xff2a1c, 0.9);
// A glowing head on each pole's arm, and a pool of light on the road under it.
const count = 2 * Math.ceil((ROAD_BEHIND + ROAD_AHEAD) / 60) + 8;
this.lampHeads = new THREE.InstancedMesh(this.boxGeo, new THREE.MeshBasicMaterial({ color: 0xffdca8 }), count);
this.lampPools = new THREE.InstancedMesh(new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2), new THREE.MeshBasicMaterial({
map: poolTexture('lamp'), color: 0xffcf96, transparent: true, blending: THREE.AdditiveBlending,
depthWrite: false, polygonOffset: true, polygonOffsetFactor: -4,
}), count);
for (const m of [this.lampHeads, this.lampPools]) {
this.capacity.set(m, m.count);
m.frustumCulled = false;
this.scene.add(m);
}
}
/** Light the scene for the time of day: sky, fog, sun, and the cars' and street lamps' lights. */
setTimeOfDay(time: TimeOfDay): void {
this.timeOfDay = time;
const look = (this.look = LOOKS[time]);
// The sky: a gradient from the zenith down to the horizon colour, which the fog fades into.
const hex = (c: number) => `#${c.toString(16).padStart(6, '0')}`;
this.skyCanvas.width = 2;
this.skyCanvas.height = 256;
const g = this.skyCanvas.getContext('2d')!;
const grad = g.createLinearGradient(0, 0, 0, 256);
grad.addColorStop(0, hex(look.sky[0]));
grad.addColorStop(0.45, hex(look.sky[1]));
grad.addColorStop(1, hex(look.sky[1]));
g.fillStyle = grad;
g.fillRect(0, 0, 2, 256);
this.skyTex.colorSpace = THREE.SRGBColorSpace;
this.skyTex.needsUpdate = true;
this.scene.background = this.skyTex;
const fog = this.scene.fog as THREE.Fog;
fog.color.setHex(look.sky[1]);
[fog.near, fog.far] = look.fog;
this.hemi.color.setHex(look.hemi[0]);
this.hemi.groundColor.setHex(look.hemi[1]);
this.hemi.intensity = look.hemi[2];
this.sun.color.setHex(look.sun[0]);
this.sun.intensity = look.sun[1];
this.stars.visible = time === 'night';
this.lampHeads.visible = this.lampPools.visible = look.lights > 0;
(this.lampPools.material as THREE.MeshBasicMaterial).opacity = look.lights;
this.carPoolMat.opacity = look.lights;
for (const p of [this.headGlow, this.tailGlow]) {
p.visible = look.lights > 0;
(p.material as THREE.PointsMaterial).opacity = look.lights;
}
for (const m of this.dashMats) m.emissiveIntensity = look.dash;
if (this.trimMat) this.trimMat.lightMapIntensity = 0.35 * Math.PI * look.dash;
for (const b of this.signBoards) (b.material as THREE.MeshBasicMaterial).color.setScalar(look.signs);
}
/**
* Glare at every lit headlight and tail light: points just off each lamp, bent onto the
* road like everything else. Your own car's are out of sight from the driver's seat.
*/
private placeCoronas(cars: readonly CarPose[], replay: boolean, viewX: number, viewS: number): void {
if (this.look.lights <= 0) return;
const head = this.headGlow.geometry.getAttribute('position') as THREE.BufferAttribute;
const tail = this.tailGlow.geometry.getAttribute('position') as THREE.BufferAttribute;
let nh = 0, nt = 0;
const out = { x: 0, y: 0, z: 0 };
for (const car of cars) {
if (car.kind === 'log' || car.exploded || (car.kind === 'player' && !replay)) continue;
const type = car.type ?? 'sedan';
const rig = RIGS.has(type), lift = bodyLift(type);
const fx = Math.sin(car.yaw), fs = Math.cos(car.yaw), rx = Math.cos(car.yaw), rs = -Math.sin(car.yaw);
const at = (lon: number, lat: number) => [car.x + fx * lon + rx * lat, car.s + fs * lon + rs * lat];
const headY = rig ? 1.25 : type === 'pickup' || type === 'lifted' ? 0.98 + lift : type === 'box' ? 0.92 : 0.72 + lift;
const tailY = rig ? 1.0 : 0.75 + lift;
for (const side of [-1, 1]) {
// A lamp only glares at you from the side it faces: headlights from in front of the
// car, tail lights from behind it.
const ahead = (viewX - car.x) * fx + (viewS - car.s) * fs > 0;
if (ahead && nh < GLOW_MAX) {
const [x, s] = at(car.len / 2 + 0.12, side * (car.wid / 2 - 0.34));
this.frames.bend(x, headY, s, out);
head.setXYZ(nh++, out.x, out.y, out.z);
}
if (!ahead && nt < GLOW_MAX) {
const [x, s] = at(-(car.len / 2 + 0.08), side * (car.wid / 2 - 0.3));
this.frames.bend(x, tailY, s, out);
tail.setXYZ(nt++, out.x, out.y, out.z);
}
}
}
head.needsUpdate = tail.needsUpdate = true;
this.headGlow.geometry.setDrawRange(0, nh);
this.tailGlow.geometry.setDrawRange(0, nt);
}
/** The pool of light a car's headlights throw on the road ahead of it (POOL_LONG by POOL_WIDE). */
private pool(parent: THREE.Object3D, front: number): THREE.Mesh {
this.poolGeo ??= new THREE.PlaneGeometry(POOL_WIDE, POOL_LONG, 1, 10).rotateX(-Math.PI / 2);
const mesh = new THREE.Mesh(this.poolGeo, this.carPoolMat);
mesh.position.set(0, 0.05, front - POOL_LONG / 2);
mesh.visible = false;
mesh.userData.unbounded = true; // culled with the car (CarView.litBounds)
parent.add(mesh);
return mesh;
}
private mat(color: number): THREE.MeshLambertMaterial {
let m = this.mats.get(color);
if (!m) {
m = new THREE.MeshLambertMaterial({ color, flatShading: true });
this.mats.set(color, m);
}
return m;
}
// Lit lamps: brighter than the lenses under them, and unshaded.
private brakeMat = new THREE.MeshBasicMaterial({ color: 0xff3326 });
private signalMat = new THREE.MeshBasicMaterial({ color: 0xffa51f });
/** A lamp: off until render() lights it. */
private lamp(parent: THREE.Object3D, mat: THREE.Material, w: number, h: number, d: number, x: number, y: number, z: number): THREE.Mesh {
const mesh = new THREE.Mesh(this.boxGeo, mat);
mesh.scale.set(w, h, d);
mesh.position.set(x, y, z);
mesh.visible = false;
parent.add(mesh);
return mesh;
}
private box(parent: THREE.Object3D, color: number, w: number, h: number, d: number, x: number, y: number, z: number): THREE.Mesh {
const mesh = new THREE.Mesh(this.boxGeo, this.mat(color));
mesh.scale.set(w, h, d);
mesh.position.set(x, y, z);
parent.add(mesh);
return mesh;
}
/** A slip road with its merge lane, edge lines and barriers; model origin is the ramp start. */
private makeRamp(): THREE.Group {
const g = new THREE.Group();
const asphalt = new THREE.MeshLambertMaterial({ color: 0x4a4a50, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -1 });
const paint = new THREE.MeshLambertMaterial({ color: 0xe8e2c8, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -2 });
const rail = new THREE.MeshLambertMaterial({ color: 0xb9bcc0, side: THREE.DoubleSide });
g.add(new THREE.Mesh(rampRibbon(-2.4, 2.4, 0.01), asphalt));
g.add(new THREE.Mesh(rampRibbon(-2.2, -2.05, 0.015, 30), paint));
g.add(new THREE.Mesh(rampRibbon(2.05, 2.2, 0.015), paint));
g.add(new THREE.Mesh(rampRibbon(3.4, 0, 0.45, 0, RAMP_LEN, 0.32), rail));
g.add(new THREE.Mesh(rampRibbon(-3.4, 0, 0.45, RAMP_GORE + 45, RAMP_LEN, 0.32), rail));
// Merge lane: full width, then a taper back into the edge line. Strips in short steps
// (t = metres past the start, forward is -z) so they follow the road's bends.
const x0 = EDGE, x1 = EDGE + LANE_W;
const strip = (y: number, edges: (t: number) => [number, number]) => {
const pos: number[] = [];
const idx: number[] = [];
let n = 0;
for (let t = 0; t <= MERGE_LEN + MERGE_TAPER; t += 5) {
const [a, b] = edges(t);
pos.push(a, y, -t, b, y, -t);
if (n > 0) idx.push(2 * n - 2, 2 * n - 1, 2 * n, 2 * n - 1, 2 * n + 1, 2 * n);
n++;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
geo.setIndex(idx);
geo.computeVertexNormals();
return geo;
};
const taper = (t: number) => Math.max(0, t - MERGE_LEN) / MERGE_TAPER;
g.add(new THREE.Mesh(strip(0.01, (t) => [x0, x1 + (x0 - x1) * taper(t)]), asphalt));
g.add(new THREE.Mesh(strip(0.015, (t) => {
const c = x1 - 0.125 + (x0 + 0.025 - (x1 - 0.125)) * taper(t);
return [c - 0.075, c + 0.075];
}), paint));
g.visible = false;
this.scene.add(g);
return g;
}
/** Ground strips either side of the road; heights are filled in by updateTerrain. */
private makeTerrain(): THREE.Mesh {
const xs = [...TER_X.map((x) => -x).reverse(), ...TER_X];
const rows = (TER_BEHIND + TER_AHEAD) / TER_STEP + 1;
const pos = new Float32Array(rows * xs.length * 3);
const idx: number[] = [];
const cols = xs.length;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const i = r * cols + c;
pos[i * 3] = xs[c];
pos[i * 3 + 2] = -(r * TER_STEP - TER_BEHIND);
// No quad across the road between the two inner columns.
if (r > 0 && c > 0 && c !== TER_X.length) {
const a = i - cols - 1, b = i - cols, d = i - 1;
idx.push(a, b, d, b, i, d);
}
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setIndex(idx);
geo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(pos.length), 3));
// Coloured per vertex: each area's grass, and rock and snow up the mountains.
// Flat shaded: the normals come from the faces in the fragment shader, so the vertex
// normals are never used and needn't follow the heights.
geo.computeVertexNormals();
const mesh = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ vertexColors: true, flatShading: true }));
this.scene.add(mesh);
return mesh;
}
/** Rebuild terrain heights when the car has moved on a row (or the road changed). */
private updateTerrain(originS: number, z: (s: number) => number): void {
const at = Math.floor(originS / TER_STEP) * TER_STEP;
this.terrain.position.z = z(at);
if (at === this.terrainAt && roadSeed() === this.terrainSeed) return;
const rows = this.terrainRows;
if (roadSeed() !== this.terrainSeed) rows.clear();
this.terrainAt = at;
this.terrainSeed = roadSeed();
const attr = this.terrain.geometry.getAttribute('position') as THREE.BufferAttribute;
const pos = attr.array as Float32Array;
const cols = TER_X.length * 2;
const colors = this.terrain.geometry.getAttribute('color') as THREE.BufferAttribute;
const rgb = colors.array as Float32Array;
const col = new THREE.Color();
for (let i = 0; i < attr.count; i += cols) {
const s = at - pos[i * 3 + 2];
let row = rows.get(s);
if (!row) {
row = new Float32Array(cols * 4);
const lie = lieAt(s), w = areaWeights(s);
for (let k = 0; k < cols; k++) {
const x = pos[(i + k) * 3], y = terrainY(x, s, lie, w);
terrainColor(x, y, w, col);
row.set([y, col.r, col.g, col.b], k * 4);
}
rows.set(s, row);
}
for (let k = 0; k < cols; k++) {
pos[(i + k) * 3 + 1] = row[k * 4];
rgb.set(row.subarray(k * 4 + 1, k * 4 + 4), (i + k) * 3);
}
}
// Forget rows that have dropped out of the strip.
for (const s of rows.keys()) if (s < at - TER_BEHIND || s > at + TER_AHEAD) rows.delete(s);
attr.needsUpdate = true;
colors.needsUpdate = true;
}
private signTexture(sign: Sign): THREE.CanvasTexture {
const key = sign.kind + (sign.dir ?? '') + sign.lines.join('/');
let tex = this.signArt.get(key);
if (tex) {
// Most recently used last, so the oldest is evicted first.
this.signArt.delete(key);
this.signArt.set(key, tex);
return tex;
}
tex = new THREE.CanvasTexture(drawSign(sign.kind, sign.lines, sign.dir ?? 1));
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 4;
this.signArt.set(key, tex);
if (this.signArt.size > 48) {
const [oldest, old] = this.signArt.entries().next().value!;
this.signArt.delete(oldest);
old.dispose();
}
return tex;
}
/** Signs from the schedule near the car, in road space (bend.ts puts them on the road). */
private placeSigns(start: number, end: number, z: (s: number) => number,
place: (mesh: THREE.InstancedMesh, i: number, x: number, y: number, s: number, sx: number, sy: number, sz: number) => number): void {
const signs = signsIn(start, end).sort((a, b) => a.s - b.s);
let parts = 0, used = 0;
for (const sign of signs) {
if (used >= SIGN_POOL) break;
const st = SIGN_STYLE[sign.kind];
let x: number;
if (sign.kind === 'guide') x = laneX(4);
else if (sign.kind === 'chevron') x = sign.side > 0 ? rightWall(sign.s) + 0.9 : leftWall(sign.s) - 0.9;
else x = sign.side > 0 ? rightWall(sign.s) + 2.2 + st.w / 2 : leftWall(sign.s) - 2.2 - st.w / 2;
const board = this.signBoards[used++];
board.visible = true;
board.position.set(x, st.bottom + st.h / 2, z(sign.s));
board.scale.set(st.w, st.h, 1);
const mat = board.material as THREE.MeshBasicMaterial;
mat.map = this.signTexture(sign);
// A grey back, so signs read as solid from behind (and in the mirror).
const diamond = sign.kind === 'curve' || sign.kind === 'warn' || sign.kind === 'work';
if (!diamond) parts = place(this.signParts, parts, x, st.bottom + st.h / 2, sign.s + 0.05, st.w, st.h, 0.06);
if (sign.kind === 'guide') continue; // hangs off the overpass
const top = st.bottom + st.h * (diamond ? 0.5 : 0.85);
const posts = st.w > 2 ? [-st.w / 2 + 0.5, st.w / 2 - 0.5] : [0];
for (const px of posts) parts = place(this.signParts, parts, x + px, top / 2, sign.s + 0.1, 0.12, top, 0.12);
}
for (let i = used; i < SIGN_POOL; i++) this.signBoards[i].visible = false;
this.signParts.count = parts;
this.signParts.instanceMatrix.needsUpdate = true;
}
private buildInterior(): void {
const I = this.interior;
const trim = 0x5a5e66, dark = 0x34363c, fabric = 0x6a6c72;
// Dashboard: two-tone, a grained soft-touch top pad, a bright trim line along its face.
this.box(I, trim, 1.78, 0.24, 0.55, 0, 0.86, -0.62);
const pad = new THREE.Mesh(this.boxGeo, new THREE.MeshLambertMaterial({ map: grainTexture('#2b2d33'), flatShading: true }));
pad.scale.set(1.76, 0.04, 0.56);
pad.position.set(0, 0.995, -0.64);
I.add(pad);
this.box(I, 0x55585f, 1.5, 0.006, 0.004, 0.05, 0.935, -0.343);
// A rounded, padded leading edge where the top pad meets the face.
const edge = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, 1.76, 12), new THREE.MeshLambertMaterial({ map: grainTexture('#2b2d33') }));
edge.rotation.z = Math.PI / 2;
edge.position.set(0, 0.995, -0.37); // front flush with the dash face, clear of the vents
I.add(edge);
// Lower dash under the face, sloping back toward the footwells, so the dash isn't a slab
// floating in mid-air; and a knee panel under the steering column.
const lower = this.box(I, 0x484b52, 1.72, 0.26, 0.32, 0, 0.63, -0.56);
lower.rotation.x = 0.35; // its face recedes going down
this.box(I, 0x3f4249, 0.42, 0.16, 0.05, EYE.lat, 0.66, -0.37).rotation.x = 0.2;
// Air vents: dark louvres with bright slats.
for (const [x, w] of [[-0.8, 0.12], [-0.08, 0.1], [0.08, 0.1], [0.74, 0.14]]) {
this.box(I, 0x16171a, w, 0.06, 0.02, x, 0.955, -0.34);
for (const dy of [-0.016, 0, 0.016]) this.box(I, 0x6b6f77, w * 0.92, 0.005, 0.012, x, 0.955 + dy, -0.332);
}
// Glovebox lid and its latch.
this.box(I, 0x464950, 0.44, 0.15, 0.012, 0.55, 0.83, -0.339);
this.box(I, 0x9a9ea6, 0.08, 0.018, 0.012, 0.55, 0.885, -0.333);
// A hooded instrument cluster.
const hood = new THREE.Mesh(new THREE.CylinderGeometry(0.24, 0.24, 0.17, 18, 1, true, Math.PI / 2, Math.PI), new THREE.MeshLambertMaterial({ color: dark, side: THREE.DoubleSide }));
hood.rotation.x = Math.PI / 2;
hood.scale.set(1, 1, 0.5);
hood.position.set(EYE.lat, 0.9, -0.405);
I.add(hood);
// Police radar on the dash top: target speed (red) and patrol speed (green).
this.policeKit.push(this.box(I, 0x1c1d21, 0.17, 0.07, 0.1, 0.34, 1.05, -0.66));
this.radarCanvas.width = 128;
this.radarCanvas.height = 48;
this.radarTex.colorSpace = THREE.SRGBColorSpace;
const radarFace = new THREE.Mesh(new THREE.PlaneGeometry(0.15, 0.055), new THREE.MeshBasicMaterial({ map: this.radarTex }));
radarFace.position.set(0.34, 1.05, -0.609);
I.add(radarFace);
this.policeKit.push(radarFace);
// Light-bar and siren switch panel under the radio.
this.policeKit.push(this.box(I, 0x1a1b1f, 0.27, 0.05, 0.03, 0, 0.775, -0.33));
[0xff3030, 0x3070ff, 0xffb02a, 0x30d060, 0xf2f2f2, 0xff3030].forEach((c, i) => {
const led = new THREE.Mesh(this.boxGeo, new THREE.MeshBasicMaterial({ color: c }));
led.scale.set(0.028, 0.022, 0.01);
led.position.set(-0.1 + i * 0.04, 0.778, -0.312);
I.add(led);
this.policeKit.push(led);
this.switchLeds.push(led);
});
const gauge = new THREE.Mesh(new THREE.PlaneGeometry(0.4, 0.175), new THREE.MeshBasicMaterial({ map: this.gaugeTex }));
gauge.position.set(EYE.lat, 0.88, -0.335);
gauge.rotation.x = -0.18;
I.add(gauge);
// Centre stack: one panel from the dash face down to the console, so the radio is part
// of the dashboard rather than a box stuck on it.
this.box(I, 0x3b3e45, 0.34, 0.5, 0.06, 0, 0.72, -0.365); // face at z -0.335
this.box(I, 0x55585f, 0.34, 0.006, 0.004, 0, 0.912, -0.333); // trim line above the radio
// Radio head unit set into the stack: the screen sits back inside a raised bezel, with a
// volume and a channel knob either side and a row of preset buttons underneath.
this.radioCanvas.width = 128;
this.radioCanvas.height = 32;
this.radioTex.colorSpace = THREE.SRGBColorSpace;
const radio = new THREE.Mesh(new THREE.PlaneGeometry(0.2, 0.05), new THREE.MeshBasicMaterial({ map: this.radioTex }));
radio.position.set(0, 0.86, -0.334);
I.add(radio);
this.setRadio('CH2 PURSUIT', '');
const bezel = 0x232529;
this.box(I, bezel, 0.29, 0.014, 0.012, 0, 0.892, -0.329);
this.box(I, bezel, 0.29, 0.014, 0.012, 0, 0.828, -0.329);
for (const side of [-1, 1]) {
this.box(I, bezel, 0.045, 0.078, 0.012, side * 0.1225, 0.86, -0.329);
const knob = new THREE.Mesh(new THREE.CylinderGeometry(0.014, 0.016, 0.018, 14), this.mat(0x1a1b1e));
knob.rotation.x = Math.PI / 2;
knob.position.set(side * 0.122, 0.86, -0.318);
I.add(knob);
this.box(I, 0x8b8f97, 0.003, 0.012, 0.003, side * 0.122, 0.868, -0.308); // pointer
}
for (let i = 0; i < 5; i++) this.box(I, 0x2c2e33, 0.036, 0.014, 0.008, -0.088 + i * 0.044, 0.808, -0.331);
// Centre console between the seats, with a gear selector and a cupholder.
this.box(I, 0x45484f, 0.28, 0.3, 0.95, 0, 0.6, 0.08);
this.box(I, 0x2a2c31, 0.24, 0.01, 0.85, 0, 0.755, 0.08);
this.box(I, 0x1d1e22, 0.07, 0.04, 0.12, -0.04, 0.775, -0.12);
this.box(I, 0x8b8f97, 0.03, 0.09, 0.03, -0.04, 0.83, -0.12);
this.box(I, 0x1d1e22, 0.09, 0.012, 0.09, 0.07, 0.762, 0.08);
// Mounted just right of centre and turned to the driver, so its screen stays in view.
this.policeKit.push(this.box(I, 0x55585e, 0.04, 0.2, 0.04, 0.09, 0.8, -0.24));
const laptop = this.box(I, 0x1b1d22, 0.26, 0.17, 0.02, 0.08, 0.97, -0.25);
this.policeKit.push(laptop);
laptop.rotation.set(-0.1, -0.55, 0);
// Its screen is the mobile data terminal: radar of the cars around, and the suspect's details.
this.laptopCanvas.width = 264;
this.laptopCanvas.height = 160;
this.laptopTex.colorSpace = THREE.SRGBColorSpace;
this.laptopTex.anisotropy = 4;
const screen = new THREE.Mesh(new THREE.PlaneGeometry(0.3 / 0.34, 0.18 / 0.22), new THREE.MeshBasicMaterial({ map: this.laptopTex }));
screen.position.z = 0.6;
laptop.add(screen);
// Windscreen frame: A-pillars from the dash corners up to the roof, header, roof lining.
for (const side of [-1, 1]) {
const a = new THREE.Vector3(side * 0.8, 0.98, -0.95);
const b = new THREE.Vector3(side * 0.7, 1.42, -0.15);
const pillar = this.box(I, trim, 0.09, 0.07, a.distanceTo(b), 0, 0, 0);
pillar.position.copy(a).add(b).multiplyScalar(0.5);
pillar.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), b.clone().sub(a).normalize());
}
this.box(I, trim, 1.5, 0.08, 0.14, 0, 1.4, -0.16);
this.box(I, 0x8d8a84, 1.56, 0.04, 1.9, 0, 1.45, 0.78);
// The glass's own frame, from the bottom edge, tilted up the windscreen; and the wipers.
this.glass.position.set(0, 0.98, -0.95);
this.glass.rotation.x = Math.atan2(0.8, 0.44);
I.add(this.glass);
for (const pu of WIPER_PIVOTS) {
const arm = new THREE.Group();
arm.position.set(pu, WIPER_D, 0.02);
this.box(arm, 0x1d1e22, BLADE * 0.92, 0.012, 0.012, BLADE * 0.46, 0.012, 0.012);
this.box(arm, 0x0f0f12, BLADE, 0.022, 0.016, BLADE / 2, 0, 0);
this.box(arm, 0x2a2b30, 0.04, 0.04, 0.03, 0, 0, 0.01);
this.glass.add(arm);
arm.rotation.z = WIPER_PARK;
this.wiperArms.push(arm);
}
// Doors with window sills, B-pillars and the side mirrors outside.
for (const side of [-1, 1]) {
this.box(I, trim, 0.07, 0.5, 1.8, side * 0.9, 0.73, 0.0);
this.box(I, dark, 0.12, 0.05, 1.8, side * 0.87, 0.99, 0.0);
this.box(I, trim, 0.07, 0.46, 0.12, side * 0.85, 1.21, 0.62);
this.box(I, 0x1d2128, 0.16, 0.12, 0.08, side * 1.02, 1.04, -0.62);
// The glass shows a render of the road behind along that side, flipped like a mirror.
const target = srgbTarget(192, 132);
target.texture.wrapS = THREE.RepeatWrapping;
target.texture.repeat.x = -1;
const glass = new THREE.Mesh(new THREE.PlaneGeometry(0.13, 0.09), new THREE.MeshBasicMaterial({ map: target.texture }));
glass.position.set(side * 1.02, 1.04, -0.578);
glass.rotation.y = side < 0 ? 0.35 : -0.55; // turned toward the driver in the left seat
I.add(glass);
this.doorMirrors.push({ side, target, glass, cam: new THREE.PerspectiveCamera(30, 0.13 / 0.09, 0.5, 600) });
}
// Door cards: armrest, chrome pull, window switches, speaker.
for (const side of [-1, 1]) {
this.box(I, 0x3b3e45, 0.09, 0.05, 0.6, side * 0.85, 0.8, -0.05);
this.box(I, 0xa7abb2, 0.02, 0.03, 0.12, side * 0.855, 0.9, -0.42);
this.box(I, 0x16171a, 0.05, 0.015, 0.09, side * 0.83, 0.83, -0.25);
this.box(I, 0x232428, 0.02, 0.14, 0.14, side * 0.86, 0.58, -0.55);
}
// Sun visors folded up against the roof, and the dome light.
for (const side of [-1, 1]) {
const visor = this.box(I, 0x8d8a84, 0.5, 0.025, 0.2, side * 0.4, 1.395, -0.08);
visor.rotation.x = -0.12;
}
this.box(I, 0xd8d4c8, 0.16, 0.02, 0.08, 0, 1.425, 0.3);
// Passenger seat.
this.box(I, fabric, 0.52, 0.14, 0.5, 0.42, 0.5, 0.15);
this.box(I, fabric, 0.52, 0.62, 0.12, 0.42, 0.85, 0.42).rotation.x = -0.2;
// Rear-view mirror: a flipped render of the road behind.
this.box(I, dark, 0.03, 0.08, 0.03, 0, 1.36, -0.32);
const housing = this.box(I, dark, 0.26, 0.075, 0.035, 0, 1.31, -0.31);
housing.rotation.y = -0.32;
this.mirrorTarget.texture.wrapS = THREE.RepeatWrapping;
this.mirrorTarget.texture.repeat.x = -1;
this.mirrorGlass = new THREE.Mesh(new THREE.PlaneGeometry(0.24, 0.062), new THREE.MeshBasicMaterial({ map: this.mirrorTarget.texture }));
this.mirrorGlass.position.set(-0.006, 1.31, -0.29);
this.mirrorGlass.rotation.y = -0.32; // angled toward the driver
I.add(this.mirrorGlass);
// Steering wheel: rim, three spokes and hub, on a column tilted toward the driver.
this.wheel.add(new THREE.Mesh(new THREE.TorusGeometry(0.185, 0.026, 8, 28), this.mat(0x17171a)));
// Stitched leather grips at ten and two.
for (const a of [Math.PI * 0.8, Math.PI * 0.2]) {
const grip = new THREE.Mesh(new THREE.TorusGeometry(0.185, 0.03, 8, 8, 0.5), this.mat(0x26262a));
grip.rotation.z = a - 0.25;
this.wheel.add(grip);
}
const badge = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, 0.006, 12), this.mat(0x80848c));
badge.rotation.x = Math.PI / 2;
badge.position.z = 0.03;
this.wheel.add(badge);
const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.055, 0.06, 0.05, 10), this.mat(0x1e1f24));
hub.rotation.x = Math.PI / 2;
this.wheel.add(hub);
for (const angle of [-Math.PI / 2, Math.PI / 6, (5 * Math.PI) / 6]) {
const spoke = this.box(this.wheel, 0x1e1f24, 0.15, 0.03, 0.015, Math.cos(angle) * 0.09, Math.sin(angle) * 0.09, 0);
spoke.rotation.z = angle;
}
const column = new THREE.Group();
column.position.set(EYE.lat, 0.87, -0.17);
column.rotation.x = -0.42;
column.add(this.wheel);
this.box(column, dark, 0.11, 0.1, 0.32, 0, -0.01, -0.18); // column shroud
this.box(column, 0x1f2024, 0.045, 0.02, 0.1, -0.13, 0, -0.07); // indicator stalk
I.add(column);
// Below the dash: a knee panel angled back under it, and carpeted footwells.
const knee = this.box(I, trim, 1.7, 0.26, 0.06, 0, 0.62, -0.4);
knee.rotation.x = 0.45;
this.box(I, 0x2b2c30, 1.72, 0.04, 1.7, 0, 0.36, 0.2);
// The plain-coloured trim that never moves is merged into one mesh (one draw call instead
// of a hundred); the police kit's into another, shown and hidden together.
const moving = new Set<THREE.Object3D>([this.wheel, ...this.wiperArms]);
const kit = new Set(this.policeKit);
const plain: THREE.Mesh[] = [], plainKit: THREE.Mesh[] = [];
I.traverse((o) => {
const mesh = o as THREE.Mesh;
const m = mesh.material as THREE.MeshLambertMaterial;
if (!mesh.isMesh || !m.isMeshLambertMaterial || m.map || m.side !== THREE.FrontSide || mesh.children.length) return;
for (let up = o.parent; up && up !== I; up = up.parent) if (moving.has(up)) return;
(kit.has(o) ? plainKit : plain).push(mesh);
});
// A soft fill so the cabin isn't a black hole when the sun is in front: the trim glows a
// little in its own colour. A white light map adds colour × 0.35 × dash, as the emissive
// fill on the other trim does (setTimeOfDay).
const white = new THREE.DataTexture(new Uint8Array([255, 255, 255, 255]), 1, 1);
white.needsUpdate = true;
this.trimMat = new THREE.MeshLambertMaterial({ vertexColors: true, flatShading: true, lightMap: white });
for (const list of [plain, plainKit]) {
const geo = mergeVisible(I, list);
geo.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array((geo.getAttribute('position').count) * 2), 2));
const merged = new THREE.Mesh(geo, this.trimMat);
for (const mesh of list) mesh.removeFromParent();
I.add(merged);
if (list === plainKit) {
this.policeKit = this.policeKit.filter((o) => !list.includes(o as THREE.Mesh));
this.policeKit.push(merged);
}
}
const filled = new Map<THREE.Material, THREE.Material>();
I.traverse((o) => {
const mesh = o as THREE.Mesh;
const m = mesh.material as THREE.MeshLambertMaterial | undefined;
if (!mesh.isMesh || !m || !(m as THREE.MeshLambertMaterial).isMeshLambertMaterial || m === this.trimMat) return;
let lit = filled.get(m);
if (!lit) {
const copy = m.clone();
copy.emissive.copy(m.color).multiplyScalar(0.35);
if (m.map) copy.emissiveMap = m.map;
this.dashMats.push(copy);
lit = copy;
filled.set(m, lit);
}
mesh.material = lit;
});
this.scene.add(I);
}
resize(width: number, height: number): void {
this.gl.setSize(width, height, false);
const size = this.gl.getDrawingBufferSize(new THREE.Vector2());
this.frameTarget.setSize(size.x, size.y);
const aspect = width / Math.max(1, height);
this.camera.aspect = aspect;
// 72 degrees vertical on wide frames (about 105 across at 16:9); narrower frames widen
// further, up to 112 degrees vertical.
const half = Math.min(Math.atan(Math.tan((36 * Math.PI) / 180) * Math.max(1, 1.4 / aspect)), (56 * Math.PI) / 180);
this.camera.fov = (2 * half * 180) / Math.PI;
this.camera.updateProjectionMatrix();
// Tall frames would mostly show roof lining; look a little further down the road instead.
this.pitch = aspect < 1 ? -0.15 : -0.05;
}
// Replay camera and target in world terms (x, height, s), eased between frames.
private replayPos = new THREE.Vector3();
private replayLook = new THREE.Vector3();
/**
* Orbit a point weighted toward the suspect, pulled back far enough to frame both cars,
* and always aimed at the suspect. The camera sits high (about 40° down) so it looks over
* traffic at the suspect instead of through it; nothing is hidden, so nothing pops.
*/
private renderReplay(p: CarPose, suspect: CarPose, cam: ReplayCamera, dt: number): void {
const midX = suspect.x * 0.7 + p.x * 0.3;
const midS = suspect.s * 0.7 + p.s * 0.3;
const apart = Math.hypot(suspect.x - p.x, suspect.s - p.s);
const dist = 10 + Math.min(40, apart * 0.9);
const want = new THREE.Vector3(midX + Math.sin(cam.angle) * dist, 3 + dist * 0.5, midS - Math.cos(cam.angle) * dist);
const look = new THREE.Vector3(suspect.x * 0.8 + p.x * 0.2, 0.8, suspect.s * 0.8 + p.s * 0.2);
const k = cam.snap ? 1 : 1 - Math.exp(-dt * 5);
this.replayPos.lerp(want, k);
this.replayLook.lerp(look, k);
const b = this.frames.bend(this.replayPos.x, this.replayPos.y, this.replayPos.z, this.bent);
this.camera.position.set(b.x, b.y, b.z);
this.frames.bend(this.replayLook.x, this.replayLook.y, this.replayLook.z, b);
this.camera.lookAt(b.x, b.y, b.z);
this.culler.cull(this.camera);
this.present(this.scene, this.camera);
this.culler.restore();
}
/** The police radio's display: channel, and who is talking. */
setRadio(channel: string, talking: string, line2?: string): void {
const text = channel + '|' + talking + '|' + (line2 ?? '');
if (text === this.radioText) return;
this.radioText = text;
const g = this.radioCanvas.getContext('2d')!;
g.fillStyle = talking ? '#1f6b3c' : '#123522';
g.fillRect(0, 0, 128, 32);
g.fillStyle = '#b6ffcf';
g.font = 'bold 11px monospace';
g.fillText(channel, 4, 13);
g.fillText(line2 ?? (talking ? 'RX ' + talking : 'SCAN'), 4, 27);
this.radioTex.needsUpdate = true;
}
/** Feed sim events (live or replayed) to the visual effects. */
handleEvents(events: readonly SimEvent[], cars: readonly CarPose[], replaying = false): void {
if (!events.length) return;
const byId = new Map(cars.map((c) => [c.id, c]));
for (const e of events) {
// Panels fly off the patrol car only in the replay, where you can see it from outside.
if (!replaying && e.type === 'damage' && byId.get(e.car!)?.kind === 'player') continue;
this.effects.onEvent(e, byId);
if (e.type === 'bird') {
const car = byId.get(e.car!);
if (car) this.addSplat(car, e);
}
}
}
private splatMaterial(tex: THREE.Texture): THREE.MeshLambertMaterial {
return new THREE.MeshLambertMaterial({
map: tex, transparent: true, depthWrite: false, side: THREE.DoubleSide,
polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -4,
});
}
/**
* Blood where a bird hit a car: on the grille, the hood or the windscreen, depending on
* how high it was. On the patrol car the windscreen splat goes on the inside view's glass.
*/
private addSplat(car: CarPose, e: SimEvent): void {
const view = this.views.get(car.id);
if (!view) return;
const dx = e.x - car.x, ds = e.s - car.s;
const lat = dx * Math.cos(car.yaw) - ds * Math.sin(car.yaw);
const lon = dx * Math.sin(car.yaw) + ds * Math.cos(car.yaw);
const y = e.y ?? 1;
const big = car.len > 6;
const player = car.kind === 'player';
const variant = Math.floor(Math.random() * this.splatTex.length);
const mat = player ? this.splatMaterial(this.splatTex[variant]) : this.splatMats[variant];
const mesh = new THREE.Mesh(this.splatGeo, mat);
const spin = Math.random() * Math.PI * 2;
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
const front = car.len / 2, cabinFront = big ? car.len * 0.14 : car.len * 0.3;
let size = 0.35 + Math.random() * 0.3;
let parent: THREE.Object3D = view.group;
const glass = player && y >= 0.97;
if (glass) {
// Patrol car windscreen, from the dash (y 0.98) up to the roof (1.42). It lands within
// the wipers' reach, since they are the only way to get it off.
const [u, d] = this.wipeable(clamp(lat, -0.62, 0.62), clamp((y - 0.98) / 0.44, 0.08, 0.85) * GLASS_LEN);
mesh.position.set(u, d, 0.006);
mesh.rotation.set(0, 0, spin);
size *= 0.5; // close to the eye, so it reads big anyway
parent = this.glass;
} else if (big ? y < 2.1 : y < 0.8) {
mesh.position.set(clamp(lat, -car.wid / 2 + 0.2, car.wid / 2 - 0.2), clamp(y, 0.4, big ? 2 : 0.75), -(front + 0.02));
mesh.rotation.set(0, 0, spin);
size = Math.min(size, 0.45);
} else if (big || y < 0.98) {
const top = big ? 2.31 : 0.935;
mesh.position.set(clamp(lat, -car.wid / 2 + 0.25, car.wid / 2 - 0.25), top, -clamp(lon, cabinFront + 0.2, front - 0.2));
mesh.rotation.set(-Math.PI / 2, 0, spin);
} else {
mesh.position.set(clamp(lat, -car.wid * 0.43 + 0.2, car.wid * 0.43 - 0.2), clamp(y, 1.0, 1.36), -(cabinFront + 0.02));
mesh.rotation.set(0, 0, spin);
size = Math.min(size, 0.42);
}
mesh.scale.set(size, size, 1);
parent.add(mesh);
if (player) this.playerSplats.push({ mesh, born: performance.now() / 1000, glass });
}
/** Nearest point to (u, d) on the windscreen that a wiper blade sweeps over. */
private wipeable(u: number, d: number): [number, number] {
let best: [number, number] = [u, d], bestMove = Infinity;
for (const pu of WIPER_PIVOTS) {
const r = Math.min(Math.hypot(u - pu, d - WIPER_D), BLADE - 0.06);
const a = Math.min(Math.max(Math.atan2(d - WIPER_D, u - pu), WIPER_PARK + 0.12), WIPER_PARK + SWEEP - 0.12);
const p: [number, number] = [pu + Math.cos(a) * Math.max(r, 0.12), WIPER_D + Math.sin(a) * Math.max(r, 0.12)];
const move = Math.hypot(p[0] - u, p[1] - d);
if (move < bestMove) {
best = p;
bestMove = move;
}
}
return best;
}
/** Run the wipers; each blade pass smears the blood it crosses, and a few passes clear it. */
private updateWipers(dt: number): void {
if (!this.wipers && this.wiperPhase === 0) return; // parked
const before = this.wiperAngle;
this.wiperPhase += (dt / WIPE_CYCLE) * Math.PI * 2;
if (this.wiperPhase >= Math.PI * 2) {
// End of a stroke: park if switched off, otherwise go round again.
this.wiperPhase = this.wipers ? this.wiperPhase - Math.PI * 2 : 0;
}
this.wiperAngle = WIPER_PARK + (SWEEP * (1 - Math.cos(this.wiperPhase))) / 2;
for (const arm of this.wiperArms) arm.rotation.z = this.wiperAngle;
const lo = Math.min(before, this.wiperAngle), hi = Math.max(before, this.wiperAngle);
if (hi - lo < 1e-6) return;
const dir = Math.sign(this.wiperAngle - before); // +1 sweeping up, -1 coming back down
const streaks: { mesh: THREE.Mesh; born: number; glass: boolean; smear: boolean }[] = [];
this.playerSplats = this.playerSplats.filter((splat) => {
if (!splat.glass) return true;
const { x: u, y: d } = splat.mesh.position;
const pivot = WIPER_PIVOTS.find((pu) => {
const a = Math.atan2(d - WIPER_D, u - pu);
return Math.hypot(u - pu, d - WIPER_D) < BLADE + 0.04 && a > lo && a <= hi;
});
if (pivot === undefined) return true;
const mat = splat.mesh.material as THREE.MeshLambertMaterial;
const r = Math.hypot(u - pivot, d - WIPER_D);
const a = Math.atan2(d - WIPER_D, u - pivot);
const along = a + (dir * Math.PI) / 2; // direction of the blade's travel here
if (splat.smear) {
// An old streak just thins out and spreads a little further along the stroke.
mat.opacity *= SMEAR;
splat.mesh.scale.x *= 1.1;
} else {
// The blade drags the blood along its arc and leaves a streak where it went...
const pushed = Math.min(WIPER_PARK + SWEEP - 0.05, Math.max(WIPER_PARK + 0.05, a + dir * (0.07 + Math.random() * 0.06)));
const nu = pivot + Math.cos(pushed) * r, nd = WIPER_D + Math.sin(pushed) * r;
const size = (splat.mesh.scale.x + splat.mesh.scale.y) / 2;
const streakMat = this.splatMaterial(this.smearTex);
streakMat.opacity = mat.opacity * 0.85;
const streak = new THREE.Mesh(this.splatGeo, streakMat);
const chord = Math.hypot(nu - u, nd - d);
// The texture is heaviest at its start: start it where the blood was picked up.
streak.position.set((u + nu) / 2, (d + nd) / 2, 0.0055);
streak.rotation.z = Math.atan2(nd - d, nu - u);
streak.scale.set(chord + size * 0.9, size * 0.6, 1);
streak.renderOrder = -1;
this.glass.add(streak);
streaks.push({ mesh: streak, born: performance.now() / 1000, glass: true, smear: true });
// ...and what's left of the splat is pushed along, stretched out with the stroke.
splat.mesh.position.set(nu, nd, splat.mesh.position.z);
splat.mesh.rotation.z = along;
splat.mesh.scale.x *= 1.25;
splat.mesh.scale.y *= 0.85;
mat.opacity *= 0.6;
}
if (mat.opacity > 0.06) return true;
splat.mesh.removeFromParent();
mat.dispose();
return false;
});
this.playerSplats.push(...streaks);
// Keep the glass from filling up with faint streaks: drop the faintest beyond a cap.
const glass = this.playerSplats.filter((s) => s.glass);
if (glass.length > 60) {
glass.sort((x, y) => (x.mesh.material as THREE.MeshLambertMaterial).opacity - (y.mesh.material as THREE.MeshLambertMaterial).opacity);
const drop = new Set(glass.slice(0, glass.length - 60));
for (const s of drop) {
s.mesh.removeFromParent();
(s.mesh.material as THREE.Material).dispose();
}
this.playerSplats = this.playerSplats.filter((s) => !drop.has(s));
}
}
/** Fade blood on the patrol car's body after a minute; clear it all for a new car. */
private updatePlayerSplats(playerId: number, dt: number): void {
const now = performance.now() / 1000;
const fresh = playerId === this.splatPlayer;
this.splatPlayer = playerId;
this.playerSplats = this.playerSplats.filter(({ mesh, born, glass }) => {
const left = PLAYER_SPLAT_LIFE - (now - born);
if (fresh && (glass || left > 0)) {
// Windscreen blood stays put: only the wipers shift it.
if (!glass) (mesh.material as THREE.MeshLambertMaterial).opacity = Math.min(1, left / SPLAT_FADE);
return true;
}
mesh.removeFromParent();
(mesh.material as THREE.Material).dispose();
return false;
});
this.updateWipers(dt);
}
/** The flock, flapping, nose up or down with its swoop. */
private placeBirds(birds: readonly Bird[], z: (s: number) => number): void {
const m = new THREE.Matrix4(), q = new THREE.Quaternion(), e = new THREE.Euler(0, 0, 0, 'YXZ');
const pos = new THREE.Vector3(), scl = new THREE.Vector3();
let n = 0;
for (const b of birds) {
if (n >= 48) break;
const pose = birdPose(b);
e.set(pose.pitch, pose.yaw, 0);
q.setFromEuler(e);
const flap = Math.sin(b.flap * 2.2) * 1.6;
m.compose(pos.set(b.x, b.y, z(b.s)), q, scl.set(1.2, 1.2 * flap, 1.2));
this.birdMesh.setMatrixAt(n++, m);
}
this.birdMesh.count = n;
this.birdMesh.instanceMatrix.needsUpdate = true;
}
/** Where sounds are heard from during a replay. */
get replayListener(): { x: number; s: number; yaw: number } {
const yaw = Math.atan2(this.replayLook.x - this.replayPos.x, this.replayLook.z - this.replayPos.z);
return { x: this.replayPos.x, s: this.replayPos.z, yaw };
}
bump(strength: number): void {
this.shake = Math.min(0.6, this.shake + strength * 0.03);
}
private makeCarView(pose: CarPose): CarView {
const type: VehicleType = pose.type ?? (pose.len > 6 ? 'box' : 'sedan');
if (pose.kind === 'log') return this.makeLogView(pose);
if (RIGS.has(type)) return this.makeRigView(pose, type);
const player = pose.kind === 'player';
// Police cars, and the player's own car unless it's the getaway car, are patrol cars,
// painted in the garage's livery.
const patrol = pose.kind === 'police' || (player && this.playerIsPatrol);
const livery = LIVERIES[this.garage.police.livery];
const car = patrol ? { ...pose, color: livery.body } : pose;
const hub = patrol ? WHEELS[this.garage.police.wheels].color : player ? WHEELS[this.garage.getaway.wheels].color : 0xb6bbc1;
const group = new THREE.Group();
// The body rides high on a lifted truck and low on a sports car; the wheels stay on the road.
const lift = bodyLift(type);
const body = new THREE.Group();
body.position.y = lift;
group.add(body);
const big = type === 'box';
const truck = type === 'pickup' || type === 'lifted';
// Cab length and place: mid-car on a sedan, a short cab ahead of the bed on a pickup, set
// back behind a long hood on a sports car. On a box truck this is the cargo box.
const cabinLen = big ? car.len * 0.62 : truck ? car.len * 0.3 : type === 'sports' ? car.len * 0.4 : car.len * 0.48;
const cabinZ = big ? car.len * 0.17 : truck ? -car.len * 0.1 : type === 'sports' ? car.len * 0.04 : -car.len * 0.06;
const cabinH = type === 'sports' ? 0.4 : 0.52;
// A pickup's cab stands on a body-coloured base, so it towers over the bed behind it.
const cabBase = truck ? 0.32 : 0;
const cabinTop = big ? 2.9 : 0.91 + cabBase + cabinH;
const front = -car.len / 2, rear = car.len / 2; // the nose and tail faces (forward is -z)
// The front end. A pickup's hood is tall, flat and square, level with the cab's window
// line. A box truck is a cutaway van: a short sloping hood, an upright cab, then the box.
const boxFront = cabinZ - cabinLen / 2; // where the pickup's cab, or the truck's cargo box, starts
const cabFront = big ? front + 1.0 : boxFront;
const hoodLen = cabFront - front;
const hoodTop = 0.91 + cabBase;
// Knock-off panels, moved to fit the truck bodies (the sim only needs them roughly placed).
const parts = partsFor(car.len, car.wid).map((pt) => ({ ...pt }));
if (truck) Object.assign(parts[2], { lon: -(front + cabFront) / 2, l: hoodLen - 0.12, y: hoodTop - 0.035 });
if (big) {
Object.assign(parts[2], { lon: -(front + cabFront) / 2, l: hoodLen - 0.06, y: 1.16 });
for (const i of [4, 5]) Object.assign(parts[i], { lon: -(front + cabFront) / 2, l: hoodLen - 0.2, y: 0.78, h: 0.62 });
for (const i of [6, 7]) Object.assign(parts[i], { lon: -(cabFront + boxFront) / 2, l: boxFront - cabFront - 0.1, y: 0.95, h: 0.8 });
}
// Body = a darker chassis core with knock-off panels over it (parts.ts).
const core = this.box(body, shade(car.color, 0.45), car.wid - 0.18, 0.5, car.len - 0.55, 0, 0.6, 0);
const panels = parts.map((pt, i) =>
// A patrol car's doors are in the livery's door colour; everything else is body colour.
this.box(body, patrol && (i === 6 || i === 7) ? livery.doors : car.color, pt.w, pt.h, pt.l, pt.lat, pt.y, -pt.lon));
if (big) panels[2].rotation.x = BOX_HOOD_TILT;
let cabin: THREE.Mesh;
if (big) {
// The cargo box: white or silver, or painted to match the cab.
cabin = this.box(body, [0xeeeeea, 0xdcdfe2, car.color][car.id % 3], car.wid, 1.95, cabinLen, 0, 1.925, cabinZ);
} else {
// Tinted glasshouse, raked front and back.
this.cabinGeo ??= cabinGeometry();
this.truckCabGeo ??= cabinGeometry(0.04);
cabin = new THREE.Mesh(truck ? this.truckCabGeo : this.cabinGeo, this.mat(0x2a3340));
cabin.scale.set(car.wid * 0.86, cabinH, cabinLen);
cabin.position.set(0, 0.91 + cabBase + cabinH / 2, cabinZ);
body.add(cabin);
}
// Things mounted on a knock-off panel go with it (part index from parts.ts): the lights,
// front plate and intake with the bumpers, the handles with the doors.
const attached: [number, THREE.Mesh][] = [];
const lampY = truck ? 0.98 : big ? 0.92 : 0.72; // headlights: high on the trucks' square noses
for (const side of [-1, 1]) {
// Lenses set into the nose and tail, standing just proud of them.
attached.push([1, this.box(body, 0xd0161c, 0.42, 0.16, 0.04, side * (car.wid / 2 - 0.3), 0.75, rear + 0.012)]);
attached.push([0, this.box(body, 0xf2eedc, truck ? 0.3 : 0.36, truck ? 0.18 : 0.12, 0.04, side * (car.wid / 2 - (truck ? 0.27 : 0.32)), lampY, front - 0.012)]);
attached.push([side < 0 ? 6 : 7, this.box(body, 0x222326, 0.014, 0.026, 0.13, side * (car.wid / 2 + 0.006), big ? 1.15 : 0.78, -parts[6].lon - car.len * 0.02)]);
}
if (patrol && livery.stripe !== undefined) {
// The livery's stripe runs down both sides, a band on each side panel.
for (let i = 4; i < 10; i++) {
const pt = parts[i];
attached.push([i, this.box(body, livery.stripe, pt.w + 0.02, 0.09, pt.l + 0.005, pt.lat, pt.y + 0.04, -pt.lon)]);
}
}
attached.push([0, this.box(body, 0xe6e1cc, 0.42, 0.12, 0.02, 0, 0.47, front - 0.008)]); // front plate
attached.push([0, this.box(body, 0x141416, car.wid * 0.5, 0.07, 0.02, 0, 0.36, front - 0.008)]); // bumper intake
// Fixed details, merged: wheels and hubcaps, grille, mirrors, plates, roof, exhaust.
const pieces: { geo: THREE.BufferGeometry; color: number; m: THREE.Matrix4 }[] = [];
const at = (geo: THREE.BufferGeometry, color: number, x: number, y: number, z: number, sx = 1, sy = 1, sz = 1, rx = 0, ry = 0, rz = 0) =>
pieces.push({ geo, color, m: new THREE.Matrix4().compose(new THREE.Vector3(x, y, z), new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, ry, rz)), new THREE.Vector3(sx, sy, sz)) });
this.tireGeo ??= new THREE.CylinderGeometry(0.31, 0.31, 0.22, 12);
this.hubGeo ??= new THREE.CylinderGeometry(0.17, 0.17, 0.03, 10);
const wheelPieces: typeof pieces = [];
const wheelAt = (geo: THREE.BufferGeometry, color: number, x: number, y: number, z: number, sx = 1, sy = 1, sz = 1, rx = 0, ry = 0, rz = 0) =>
wheelPieces.push({ geo, color, m: new THREE.Matrix4().compose(new THREE.Vector3(x, y, z), new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, ry, rz)), new THREE.Vector3(sx, sy, sz)) });
const axles = big ? [-0.34, 0.18, 0.36] : [-0.32, 0.32];
const wheelR = type === 'lifted' ? 0.46 : 0.31; // big off-road tyres on the lifted truck
const tread = type === 'lifted' ? 1.5 : type === 'sports' ? 1.15 : 1;
const r = wheelR / 0.31;
for (const side of [-1, 1]) {
const wx = side * (car.wid / 2 - 0.1 + (type === 'lifted' ? 0.08 : 0));
for (const a of axles) {
wheelAt(this.tireGeo, 0x141416, wx, wheelR, a * car.len, r, tread, r, 0, 0, Math.PI / 2);
wheelAt(this.hubGeo, hub, wx + side * 0.115 * tread, wheelR, a * car.len, r, 1, r, 0, 0, Math.PI / 2);
}
// Door mirrors, just ahead of the front side windows, on stems out from the door/cab.
if (big) {
// Tall west-coast mirrors on arms out from the cab doors.
at(this.boxGeo, 0x1d1e21, side * (car.wid / 2 + 0.06), 1.72, cabFront + 0.3, 0.14, 0.03, 0.03);
at(this.boxGeo, 0x1d1e21, side * (car.wid / 2 + 0.14), 1.62, cabFront + 0.3, 0.06, 0.4, 0.18);
} else {
const mz = cabinZ - cabinLen * 0.36;
at(this.boxGeo, car.color, side * (car.wid / 2 + 0.05), 0.98 + cabBase, mz, 0.12, 0.09, 0.15);
at(this.boxGeo, 0x1d1e21, side * (car.wid / 2 - 0.04), 0.9 + cabBase, mz, 0.12, 0.08, 0.05);
}
// Headlight housings and amber turn signals in the nose; dark sills between the wheels.
at(this.boxGeo, 0x1a1b1e, side * (car.wid / 2 - (truck ? 0.27 : 0.32)), lampY, front - 0.004, truck ? 0.36 : 0.42, truck ? 0.24 : 0.16, 0.02);
at(this.boxGeo, 0xff9a1f, side * (car.wid / 2 - 0.07), truck ? lampY - 0.16 : lampY, front - 0.006, 0.08, 0.1, 0.02);
at(this.boxGeo, 0x1d1e21, side * (car.wid / 2 - 0.03), 0.36, 0, 0.06, 0.06, car.len * 0.36);
}
// Nose and tail: solid body between the bumpers and the hood/trunk, which the lights, grille
// and rear plate are mounted on (the chassis core stops short of the ends).
at(this.boxGeo, car.color, 0, 0.73, front + 0.2, car.wid - 0.06, 0.3, 0.4);
at(this.boxGeo, car.color, 0, 0.73, rear - 0.2, car.wid - 0.06, 0.3, 0.4);
if (!truck && !big) at(this.boxGeo, 0x1a1b1e, 0, 0.72, front - 0.006, car.wid * 0.42, 0.16, 0.03); // grille
at(this.boxGeo, 0xe6e1cc, 0, 0.72, rear + 0.012, 0.42, 0.13, 0.02); // rear plate
if (type === 'sedan' || type === 'sports') at(this.boxGeo, 0xd0161c, 0, 0.893, rear - 0.06, 0.3, 0.03, 0.04); // centre brake light
// Exhaust: a pipe running under the car from a muffler tucked against the floor, its tip
// just showing past the rear bumper.
at(this.boxGeo, 0x2e2f33, car.wid / 2 - 0.45, 0.32, rear - 1.0, 0.26, 0.12, 0.45);
at(this.boxGeo, 0x3a3b3f, car.wid / 2 - 0.45, 0.31, rear - 0.45, 0.07, 0.07, 0.95);
if (big) {
// Cutaway cab: nose and sloping hood, the cab with its raked windscreen, and the front of
// the cargo box (the "attic") jutting out over the cab roof.
const cabLen = boxFront - cabFront, rake = 0.46;
at(this.boxGeo, car.color, 0, 0.82, front + hoodLen / 2, car.wid - 0.1, 0.54, hoodLen);
at(this.boxGeo, car.color, 0, 1.0, front + hoodLen / 2, car.wid - 0.12, 0.26, hoodLen - 0.04, BOX_HOOD_TILT);
at(this.boxGeo, car.color, 0, 0.95, cabFront + cabLen / 2, car.wid * 0.96, 0.8, cabLen);
at(this.boxGeo, car.color, 0, 1.7, cabFront + 0.35 + (cabLen - 0.35) / 2, car.wid * 0.92, 0.7, cabLen - 0.35);
at(this.boxGeo, car.color, 0, 1.7 - 0.18 * Math.sin(rake), cabFront + 0.175 + 0.18 * Math.cos(rake), car.wid * 0.92, 0.78, 0.36, rake);
at(this.boxGeo, 0x2a3340, 0, 1.7, cabFront + 0.175 - 0.005, car.wid * 0.86, 0.74, 0.04, rake); // windscreen
for (const side of [-1, 1]) at(this.boxGeo, 0x2a3340, side * (car.wid * 0.46 + 0.01), 1.68, cabFront + 0.35 + (cabLen - 0.35) * 0.45, 0.03, 0.46, (cabLen - 0.35) * 0.7);
const boxColor = (cabin.material as THREE.MeshLambertMaterial).color.getHex();
at(this.boxGeo, boxColor, 0, 2.5, boxFront - 0.4, car.wid * 0.96, 0.8, 0.8); // attic
at(this.boxGeo, 0x9a9ea6, 0, 1.9, rear + 0.01, 0.03, 1.8, 0.02); // rear door seam
at(this.boxGeo, 0x16171a, 0, 0.8, front - 0.01, car.wid * 0.5, 0.26, 0.03); // grille
attached.push([0, this.box(body, 0x9a9ea6, car.wid + 0.02, 0.2, 0.14, 0, 0.47, front - 0.05)]); // bumper
} else {
// Body-coloured roof over the glass.
at(this.boxGeo, car.color, 0, cabinTop + 0.018, cabinZ + 0.05 * cabinLen, car.wid * 0.86 * 0.84, 0.036, cabinLen * 0.53);
}
if (truck) {
// Front clip: a tall square nose up to the hood line, and a big grille between the
// headlights: chrome on the pickup, black steel on the lifted truck.
const steel = type === 'lifted' ? 0x1b1c1f : 0xb8bcc2;
at(this.boxGeo, car.color, 0, (0.58 + hoodTop - 0.07) / 2, front + hoodLen / 2, car.wid - 0.06, hoodTop - 0.07 - 0.58, hoodLen);
at(this.boxGeo, steel, 0, 0.92, front - 0.01, car.wid * 0.56, 0.44, 0.03); // grille surround
at(this.boxGeo, 0x16171a, 0, 0.92, front - 0.02, car.wid * 0.48, 0.34, 0.03); // grille
for (const y of [0.86, 0.98]) at(this.boxGeo, steel, 0, y, front - 0.035, car.wid * 0.48, 0.035, 0.02);
attached.push([0, this.box(body, steel, car.wid + 0.04, 0.24, 0.16, 0, 0.5, front - 0.07)]); // bumper
if (type === 'pickup') {
attached.push([2, this.box(body, car.color, car.wid * 0.42, 0.05, hoodLen * 0.8, 0, hoodTop + 0.02, front + hoodLen / 2)]); // power dome
} else {
attached.push([2, this.box(body, 0x1b1c1f, 0.55, 0.09, 0.5, 0, hoodTop + 0.04, front + hoodLen * 0.55)]); // hood scoop
for (const side of [-1, 1]) attached.push([0, this.box(body, 0xc23b22, 0.08, 0.06, 0.1, side * 0.5, 0.42, front - 0.18)]); // tow hooks
// Off-road light bar along the front of the roof.
at(this.boxGeo, 0x1b1c1f, 0, cabinTop + 0.08, cabFront + 0.3, car.wid * 0.7, 0.09, 0.1);
for (let k = 0; k < 4; k++) at(this.boxGeo, 0xf2f4ff, (k - 1.5) * car.wid * 0.17, cabinTop + 0.08, cabFront + 0.245, car.wid * 0.14, 0.06, 0.02);
// Black flares over the big tyres.
for (const a of axles) for (const side of [-1, 1]) at(this.boxGeo, 0x1b1c1f, side * (car.wid / 2 + 0.03), 0.6, a * car.len, 0.12, 0.14, 1.15);
}
// The cab's base: doors and back wall up to the window line.
at(this.boxGeo, car.color, 0, 0.91 + cabBase / 2, cabinZ, car.wid - 0.08, cabBase, cabinLen);
// Open bed behind the cab: a dark floor between body-coloured sides and a tailgate.
const bedFront = cabinZ + cabinLen / 2, bedLen = rear - bedFront, bz = bedFront + bedLen / 2;
at(this.boxGeo, 0x1e1f22, 0, 0.9, bz, car.wid - 0.16, 0.04, bedLen - 0.04);
for (const side of [-1, 1]) at(this.boxGeo, car.color, side * (car.wid / 2 - 0.05), 1.03, bz, 0.1, 0.3, bedLen);
at(this.boxGeo, car.color, 0, 1.03, rear - 0.05, car.wid - 0.2, 0.3, 0.1);
}
if (type === 'lifted') {
// Under the raised body: frame rails, axles and a shock absorber at each wheel.
for (const side of [-1, 1]) wheelAt(this.boxGeo, 0x1b1c1f, side * 0.45, lift + 0.3, 0, 0.12, 0.14, car.len * 0.75);
for (const a of axles) {
wheelAt(this.boxGeo, 0x1b1c1f, 0, wheelR, a * car.len, car.wid - 0.3, 0.12, 0.12);
for (const side of [-1, 1]) wheelAt(this.boxGeo, 0xc23b22, side * 0.55, (wheelR + lift + 0.3) / 2, a * car.len + 0.18, 0.07, lift + 0.3 - wheelR, 0.07);
}
}
if (type === 'sports') {
// Rear wing on two struts standing on the tail.
at(this.boxGeo, car.color, 0, 1.0, rear - 0.15, car.wid * 0.82, 0.04, 0.24);
for (const side of [-1, 1]) at(this.boxGeo, 0x1d1e21, side * car.wid * 0.3, 0.94, rear - 0.15, 0.04, 0.1, 0.06);
}
if (patrol) {
// Push bar (unless taken off in the garage), A-pillar spotlight and a radio aerial.
if (this.garage.police.pushBar) {
at(this.boxGeo, 0x161618, 0, 0.62, -(car.len / 2 + 0.16), car.wid * 0.62, 0.07, 0.06);
at(this.boxGeo, 0x161618, 0, 0.42, -(car.len / 2 + 0.16), car.wid * 0.5, 0.06, 0.06);
for (const side of [-1, 1]) at(this.boxGeo, 0x161618, side * car.wid * 0.22, 0.5, -(car.len / 2 + 0.1), 0.07, 0.35, 0.18);
}
at(this.hubGeo, 0xc9ccd0, -(car.wid / 2 + 0.06), 1.18, cabinZ - cabinLen * 0.3, 0.6, 3, 0.6, Math.PI / 2, 0, 0);
at(this.boxGeo, 0x161618, -(car.wid * 0.43 + 0.06), 1.18, cabinZ - cabinLen * 0.3, 0.12, 0.04, 0.04); // its bracket
at(this.boxGeo, 0x111113, 0.4, 1.25, car.len * 0.36, 0.015, 0.7, 0.015);
}
this.detailMat ??= new THREE.MeshLambertMaterial({ vertexColors: true, flatShading: true });
const details = new THREE.Mesh(mergePieces(pieces), this.detailMat);
body.add(details);
const wheels = new THREE.Mesh(mergePieces(wheelPieces), this.detailMat);
group.add(wheels);
const view: CarView = {
group, panels, attached, core, cabin, details, wheels, coreMat: core.material as THREE.Material,
cabinMat: cabin.material as THREE.Material, charred: false,
};
// Headlights, tail running lights and the pool of light ahead (dusk and night).
const lamps: NonNullable<CarView['lamps']> = [];
for (const side of [-1, 1] as const) {
lamps.push({ kind: 'head', part: 0, mesh: this.lamp(body, this.headMat, truck ? 0.3 : 0.36, truck ? 0.18 : 0.12, 0.02, side * (car.wid / 2 - (truck ? 0.27 : 0.32)), lampY, front - 0.03) });
lamps.push({ kind: 'tail', part: 1, mesh: this.lamp(body, this.tailMat, 0.44, 0.18, 0.02, side * (car.wid / 2 - 0.3), 0.75, rear + 0.025) });
}
lamps.push({ kind: 'pool', part: -1, mesh: this.pool(group, front) });
view.lamps = lamps;
if (car.kind === 'traffic') {
// Brake lights over the tail lenses (and the centre one), indicators at the four corners.
for (const side of [-1, 1] as const) {
lamps.push({ kind: 'brake', part: 1, mesh: this.lamp(body, this.brakeMat, 0.44, 0.18, 0.02, side * (car.wid / 2 - 0.3), 0.75, rear + 0.04) });
lamps.push({ kind: side, part: 1, mesh: this.lamp(body, this.signalMat, 0.14, 0.2, 0.02, side * (car.wid / 2 - 0.1), 0.75, rear + 0.05) });
lamps.push({ kind: side, part: 0, mesh: this.lamp(body, this.signalMat, 0.1, 0.12, 0.02, side * (car.wid / 2 - 0.07), truck ? lampY - 0.16 : lampY, front - 0.02) });
}
if (type === 'sedan' || type === 'sports') lamps.push({ kind: 'brake', part: 3, mesh: this.lamp(body, this.brakeMat, 0.32, 0.05, 0.06, 0, 0.893, rear - 0.06) });
}
if (car.kind === 'suspect') {
const marker = new THREE.Mesh(new THREE.OctahedronGeometry(0.55), new THREE.MeshBasicMaterial({ color: 0xff2a2a, fog: false }));
marker.position.y = 3;
group.add(marker);
view.marker = marker;
}
let bar: THREE.Mesh | undefined;
if (patrol) {
// A light bar, seen in replays. From the driver's seat the
// panels show as the hood and wings; the cabin, wheels and lights would clip the camera.
const lightBar = this.box(group, 0x202024, 1.3, 0.12, 0.3, 0, 1.5, 0.1);
bar = lightBar;
view.beacons = [-0.3, 0.3].map((x) => {
const beacon = new THREE.Mesh(this.boxGeo, new THREE.MeshBasicMaterial({ color: x < 0 ? 0xff2020 : 0x2050ff }));
beacon.scale.set(0.42 / 1.3, 1.1, 1.1);
beacon.position.x = x;
lightBar.add(beacon);
return beacon;
});
}
if (player) {
view.exterior = [core, cabin, ...(bar ? [bar] : []), details, wheels, ...attached.map(([, m]) => m), ...panels.slice(4)];
}
this.scene.add(group);
this.setBounds(view, front);
return view;
}
/**
* A tractor-trailer: a conventional tractor (long hood, tall cab, chrome exhaust stacks and
* fuel tanks) pulling a box trailer, a fuel tanker or a logging flatbed. Rigs have no
* knock-off panels; a logging truck's load is its own group, hidden once it falls off.
*/
private makeRigView(car: CarPose, type: VehicleType): CarView {
const group = new THREE.Group();
const L = car.len, W = car.wid, front = -L / 2, rear = L / 2;
const pieces: { geo: THREE.BufferGeometry; color: number; m: THREE.Matrix4 }[] = [];
const piece = (list: typeof pieces) => (geo: THREE.BufferGeometry, color: number, x: number, y: number, z: number, sx = 1, sy = 1, sz = 1, rx = 0, ry = 0, rz = 0) =>
list.push({ geo, color, m: new THREE.Matrix4().compose(new THREE.Vector3(x, y, z), new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, ry, rz)), new THREE.Vector3(sx, sy, sz)) });
// Tractor pieces, and trailer pieces (model coordinates; the trailer group pivots them).
const at = piece(pieces);
const trailerPieces: typeof pieces = [];
const tat = piece(trailerPieces);
const hitchZ = front + 5.2; // the fifth wheel the trailer pivots on (sim.ts RIG_HITCH)
this.rodGeo ??= new THREE.CylinderGeometry(1, 1, 1, 12);
this.tireGeo ??= new THREE.CylinderGeometry(0.31, 0.31, 0.22, 12);
this.hubGeo ??= new THREE.CylinderGeometry(0.17, 0.17, 0.03, 10);
const chrome = 0xc9ccd0, dark = 0x1b1c1f;
// Frame rails, front to back; everything else sits on them.
const core = this.box(group, 0x232427, W * 0.55, 0.32, 6.0, 0, 0.78, front + 3.1); // tractor frame
// Tractor: hood, cab, grille, bumper, lights, mirrors, stacks, tanks, fifth wheel.
at(this.boxGeo, car.color, 0, 1.55, front + 1.0, W * 0.84, 1.0, 2.0);
const cab = this.box(group, car.color, W, 1.9, 2.3, 0, 2.0, front + 3.15);
at(this.boxGeo, chrome, 0, 1.45, front - 0.02, W * 0.62, 0.85, 0.05);
at(this.boxGeo, 0x9a9ea6, 0, 0.8, front + 0.1, W + 0.05, 0.32, 0.25);
at(this.boxGeo, 0x2a3340, 0, 2.47, front + 2.0 - 0.012, W * 0.88, 0.72, 0.03);
for (const side of [-1, 1]) {
at(this.boxGeo, 0xf2eedc, side * W * 0.3, 1.25, front - 0.012, 0.32, 0.18, 0.03);
at(this.boxGeo, 0xff9a1f, side * W * 0.4, 1.45, front - 0.012, 0.1, 0.1, 0.03);
at(this.boxGeo, 0x2a3340, side * (W / 2 + 0.005), 2.45, front + 2.7, 0.02, 0.6, 0.9);
at(this.boxGeo, dark, side * (W / 2 + 0.1), 2.3, front + 2.05, 0.2, 0.04, 0.04);
at(this.boxGeo, dark, side * (W / 2 + 0.22), 2.3, front + 2.05, 0.06, 0.4, 0.14);
at(this.rodGeo, chrome, side * (W / 2 - 0.12), 2.5, front + 4.4, 0.09, 2.4, 0.09);
at(this.rodGeo, chrome, side * (W / 2 - 0.3), 0.85, front + 3.2, 0.3, 1.2, 0.3, Math.PI / 2, 0, 0);
}
at(this.boxGeo, dark, 0, 1.0, front + 5.2, W * 0.8, 0.12, 1.6);
// Trailer.
const z0 = front + 4.8, TL = rear - z0, tz = z0 + TL / 2;
let bottom = 1.25; // underside of the trailer body, for the rear guard posts
let cargo: THREE.Group | undefined;
if (type === 'semi') {
const trailer = [0xe6e8ea, 0xc9ccd0, 0xf0f0ea][car.id % 3];
tat(this.boxGeo, trailer, 0, 2.6, tz, W, 2.7, TL);
for (const side of [-1, 1]) tat(this.boxGeo, car.color, side * (W / 2 + 0.005), 2.0, tz, 0.01, 0.25, TL * 0.9);
tat(this.boxGeo, 0x9a9ea6, 0, 2.6, rear + 0.01, 0.03, 2.6, 0.02); // rear door seam
tat(this.boxGeo, 0x232427, 0, 1.0, tz, W * 0.5, 0.25, TL - 0.4); // frame rails
} else if (type === 'tanker') {
tat(this.rodGeo, 0xc8cdd2, 0, 2.3, tz, 1.1, TL - 0.3, 1.1, Math.PI / 2, 0, 0);
for (const z of [z0 + 0.1, rear - 0.1]) tat(this.rodGeo, 0xaab0b6, 0, 2.3, z, 1.05, 0.12, 1.05, Math.PI / 2, 0, 0);
tat(this.boxGeo, 0x7d8288, 0, 3.42, tz, 0.5, 0.05, TL * 0.7); // catwalk
tat(this.boxGeo, 0x232427, 0, 1.05, tz, W * 0.6, 0.3, TL); // frame under the tank
tat(this.boxGeo, 0xd8352a, 0, 2.2, rear - 0.03, 0.35, 0.35, 0.02, 0, 0, Math.PI / 4); // flammable placard
bottom = 0.9;
} else {
tat(this.boxGeo, 0x3a2f28, 0, 1.3, tz, W, 0.14, TL); // flatbed
tat(this.boxGeo, 0x232427, 0, 1.0, tz, W * 0.5, 0.45, TL - 0.4); // frame rails
for (let i = 0; i < 4; i++) {
const z = z0 + 0.6 + (i * (TL - 1.2)) / 3;
for (const side of [-1, 1]) tat(this.boxGeo, 0x2a2b2f, side * (W / 2 - 0.06), 2.1, z, 0.1, 1.5, 0.1); // stakes
}
// The load: three logs on the bed, two on top, cut ends showing.
cargo = new THREE.Group();
const logMat = this.mat(0x6b4a2b), endMat = this.mat(0xb48a5a);
for (const [x, y] of [[-0.7, 1.7], [0, 1.7], [0.7, 1.7], [-0.35, 2.27], [0.35, 2.27]]) {
const log = new THREE.Mesh(this.rodGeo, logMat);
log.rotation.x = Math.PI / 2;
log.scale.set(0.33, TL - 0.5, 0.33);
log.position.set(x, y, tz);
cargo.add(log);
for (const z of [tz - (TL - 0.5) / 2 - 0.005, tz + (TL - 0.5) / 2 + 0.005]) {
const end = new THREE.Mesh(this.rodGeo, endMat);
end.rotation.x = Math.PI / 2;
end.scale.set(0.3, 0.01, 0.3);
end.position.set(x, y, z);
cargo.add(end);
}
}
bottom = 1.23;
}
// Rear: underride guard on posts, and a light bar with the tail lights on the frame.
tat(this.boxGeo, dark, 0, 0.6, rear - 0.15, W * 0.9, 0.14, 0.1);
for (const side of [-1, 1]) tat(this.boxGeo, dark, side * W * 0.3, (0.6 + bottom) / 2, rear - 0.15, 0.08, bottom - 0.6, 0.08);
tat(this.boxGeo, 0x232427, 0, 1.0, rear - 0.05, W * 0.95, 0.14, 0.08);
for (const side of [-1, 1]) tat(this.boxGeo, 0xd0161c, side * (W / 2 - 0.25), 1.0, rear - 0.005, 0.3, 0.1, 0.03);
// Wheels: steer axle, two drive axles, two trailer axles; twin tyres on all but the steer.
for (const [z, twin, onTrailer] of [[front + 1.2, 1, 0], [front + 4.0, 1.8, 0], [front + 5.2, 1.8, 0], [rear - 3.2, 1.8, 1], [rear - 2.0, 1.8, 1]]) {
const put = onTrailer ? tat : at;
for (const side of [-1, 1]) {
put(this.tireGeo, 0x141416, side * (W / 2 - 0.25), 0.5, z, 0.5 / 0.31, twin, 0.5 / 0.31, 0, 0, Math.PI / 2);
put(this.hubGeo, chrome, side * (W / 2 - 0.25 + 0.115 * twin), 0.5, z, 0.5 / 0.31, 1, 0.5 / 0.31, 0, 0, Math.PI / 2);
}
}
this.detailMat ??= new THREE.MeshLambertMaterial({ vertexColors: true, flatShading: true });
const details = new THREE.Mesh(mergePieces(pieces), this.detailMat);
group.add(details);
// The trailer: a group pivoting on the fifth wheel, its parts kept in model coordinates.
const trailer = new THREE.Group();
trailer.position.z = hitchZ;
group.add(trailer);
const trailerDetails = new THREE.Mesh(mergePieces(trailerPieces), this.detailMat);
trailerDetails.position.z = -hitchZ;
trailer.add(trailerDetails);
if (cargo) {
cargo.position.z = -hitchZ;
trailer.add(cargo);
}
const lamps: NonNullable<CarView['lamps']> = [];
for (const side of [-1, 1] as const) {
lamps.push({ kind: 'head', part: -1, mesh: this.lamp(group, this.headMat, 0.32, 0.18, 0.02, side * W * 0.3, 1.25, front - 0.03) });
lamps.push({ kind: 'tail', part: -1, mesh: this.lamp(trailer, this.tailMat, 0.3, 0.1, 0.02, side * (W / 2 - 0.25), 1.0, rear + 0.008 - hitchZ) });
}
lamps.push({ kind: 'pool', part: -1, mesh: this.pool(group, front) });
if (car.kind === 'traffic') {
for (const side of [-1, 1] as const) {
lamps.push({ kind: side, part: -1, mesh: this.lamp(group, this.signalMat, 0.12, 0.12, 0.02, side * W * 0.4, 1.45, front - 0.03) });
lamps.push({ kind: 'brake', part: -1, mesh: this.lamp(trailer, this.brakeMat, 0.34, 0.14, 0.02, side * (W / 2 - 0.25), 1.0, rear + 0.015 - hitchZ) });
lamps.push({ kind: side, part: -1, mesh: this.lamp(trailer, this.signalMat, 0.16, 0.1, 0.02, side * (W / 2 - 0.55), 1.0, rear + 0.005 - hitchZ) });
}
}
this.scene.add(group);
const view: CarView = {
group, panels: [], attached: [], core, cabin: cab, details, cargo, trailer, trailerDetails, coreMat: core.material as THREE.Material,
cabinMat: cab.material as THREE.Material, charred: false, lamps,
};
this.setBounds(view, front);
return view;
}
/**
* The car's bounding spheres for culling, worked out once as built. The trailer swings and
* the marker bobs, so allow a little extra; at night the pool of light reaches far ahead.
*/
private setBounds(view: CarView, front: number): void {
const b = boundsOf(view.group);
b.radius += 1.5;
view.bounds = b;
view.litBounds = b.clone().union(new THREE.Sphere(new THREE.Vector3(0, 0, front - POOL_LONG / 2), Math.hypot(POOL_WIDE, POOL_LONG) / 2));
const live = new Set<THREE.Object3D | undefined>([view.trailer, view.marker, view.beacons?.[0].parent ?? undefined]);
view.near = view.group.children.filter((o) => !live.has(o) && !o.userData.unbounded);
}
/** Full detail near the camera; the merged proxy far away. */
private setDetail(view: CarView, car: CarPose, far: boolean): void {
const near = view.near!, group = view.group;
if (far) {
const key = `${car.lost}|${car.exploded}|${car.cargoLost ?? false}`;
if (view.proxyKey !== key) {
for (const o of near) if (!o.parent) group.add(o);
if (view.proxy) {
view.proxy.removeFromParent();
view.proxy.geometry.dispose();
}
this.detailMat ??= new THREE.MeshLambertMaterial({ vertexColors: true, flatShading: true });
view.proxy = new THREE.Mesh(mergeVisible(group, near), this.detailMat);
view.proxy.frustumCulled = false; // bent: culled by hand (cull.ts)
group.add(view.proxy);
view.proxyKey = key;
}
view.proxy!.visible = true;
for (const o of near) if (o.parent) group.remove(o);
} else {
if (view.proxy) view.proxy.visible = false;
for (const o of near) if (!o.parent) group.add(o);
}
}
/**
* What can be culled this frame, once everything is placed: the cars (not your own), the
* sign boards, the slip roads, and the area scenery that comes in pieces.
*/
private collectCullables(cars: readonly CarPose[], originS: number, replay: boolean): void {
const c = this.culler;
c.begin(this.frames, originS);
const lit = this.look.lights > 0;
for (const car of cars) {
const view = this.views.get(car.id);
if (!view?.bounds || (car.kind === 'player' && !replay)) continue;
c.add(view.group, lit && !car.wrecked ? view.litBounds : view.bounds);
}
for (const board of this.signBoards) c.add(board);
for (const ramp of this.ramps) c.add(ramp);
this.areas.cullables((o) => c.add(o));
}
/** A log fallen off a logging truck: bark-brown, with pale cut ends. */
private makeLogView(car: CarPose): CarView {
const group = new THREE.Group();
this.rodGeo ??= new THREE.CylinderGeometry(1, 1, 1, 12);
const log = new THREE.Mesh(this.rodGeo, this.mat(car.color));
log.rotation.x = Math.PI / 2;
log.scale.set(0.3, car.len, 0.3);
log.position.y = 0.3;
group.add(log);
const ends = new THREE.Group();
for (const z of [-car.len / 2 - 0.005, car.len / 2 + 0.005]) {
const end = new THREE.Mesh(this.rodGeo, this.mat(0xb48a5a));
end.rotation.x = Math.PI / 2;
end.scale.set(0.27, 0.01, 0.27);
end.position.set(0, 0.3, z);
ends.add(end);
}
group.add(ends);
this.scene.add(group);
const view: CarView = { group, panels: [], attached: [], core: log, cabin: log, details: log, coreMat: log.material as THREE.Material, cabinMat: log.material as THREE.Material, charred: false };
this.setBounds(view, -car.len / 2);
view.near = undefined; // two meshes already
return view;
}
/** Patrol car seen from outside (replays) or from the driver's seat. */
private setExterior(view: CarView, outside: boolean, time: number): void {
for (const part of view.exterior!) part.visible = outside;
this.flashBeacons(view, time);
}
/** Light bar: its two colours alternating (each police car slightly out of step). */
private flashBeacons(view: CarView, time: number, offset = 0): void {
const flash = Math.floor(time * 6 + offset) % 2;
const bar = LIGHT_BARS[this.garage.police.lights];
view.beacons?.forEach((b, i) => ((b.material as THREE.MeshBasicMaterial).color.setHex(
(i === flash) ? (i ? bar.b : bar.a) : 0x202024)));
}
/**
* The dash radar: the target's speed in red (the suspect, or the nearest car ahead in your
* lane), your own in green; and the switch panel's light-bar LEDs flashing.
*/
private drawRadarUnit(cars: readonly CarPose[], p: CarPose, suspect: CarPose | undefined, units: 'mph' | 'kmh', time: number): void {
const flash = Math.floor(time * 6) % 2;
this.switchLeds.forEach((led, i) => (led.visible = i > 1 || i === flash));
if (time < this.radarDue && time > this.radarDue - 1) return; // a jump back in time redraws at once
this.radarDue = time + 1 / 6;
let target = suspect && suspect.s - p.s > 0 && suspect.s - p.s < 400 ? suspect : undefined;
if (!target) {
let best = 300;
for (const c of cars) {
const d = c.s - p.s;
if (c !== p && d > 0 && d < best && Math.abs(c.x - p.x) < 2.5) {
best = d;
target = c;
}
}
}
const k = units === 'kmh' ? 3.6 : MPS_TO_MPH;
const show = (v: number) => String(Math.min(999, Math.round(Math.abs(v) * k))).padStart(3, ' ');
const g = this.radarCanvas.getContext('2d')!;
g.fillStyle = '#0b0b0d';
g.fillRect(0, 0, 128, 48);
g.font = 'bold 30px monospace';
g.textBaseline = 'middle';
g.textAlign = 'left';
g.fillStyle = '#ff3a2a';
g.fillText(target ? show(speedOf(target)) : '---', 4, 26);
g.fillStyle = '#4dff7a';
g.fillText(show(speedOf(p)), 68, 26);
g.fillStyle = '#6b6f77';
g.font = '8px monospace';
g.fillText('TARGET', 6, 6);
g.fillText('PATROL', 70, 6);
this.radarTex.needsUpdate = true;
}
/** The laptop's screen: a top-down radar of the nearby road, and the suspect's details. */
private drawLaptop(cars: readonly CarPose[], p: CarPose, suspect: CarPose | undefined, units: 'mph' | 'kmh'): void {
const g = this.laptopCanvas.getContext('2d')!;
const { width: w, height: h } = this.laptopCanvas;
const AMBER = '#ffb43a', DIM = '#7f8ea3', TEXT = '#dbe6f5';
g.fillStyle = '#0b1522';
g.fillRect(0, 0, w, h);
g.fillStyle = '#1b3350';
g.fillRect(0, 0, w, 18);
g.font = 'bold 12px monospace';
g.textBaseline = 'middle';
g.textAlign = 'left';
g.fillStyle = TEXT;
g.fillText('MDT UNIT 14', 6, 10);
g.textAlign = 'right';
g.fillStyle = DIM;
const area = zoneAt(p.s).area;
g.fillStyle = area === 'construction' ? '#ffb43a' : DIM;
g.fillText(area === 'construction' ? 'ROAD WORKS' : area.toUpperCase(), w - 6, 10);
// Radar: the road straight up the panel, the patrol car low down so it shows what's ahead.
const rx = 6, ry = 24, rw = 104, rh = h - 30;
g.save();
g.beginPath();
g.rect(rx, ry, rw, rh);
g.clip();
g.fillStyle = '#132235';
g.fillRect(rx, ry, rw, rh);
// Zoomed out along the road (about 200 m ahead, 80 m behind) but only a little across it,
// so the lanes stay readable; cars get a minimum size so they don't shrink to slivers.
const across = (rw - 8) / (ROAD_HALF * 2 + 2); // pixels per metre across the road
const along = rh / 280; // pixels per metre along it
const cx = rx + rw / 2, cy = ry + rh * 0.72;
g.fillStyle = '#1f3550';
g.fillRect(cx - ROAD_HALF * across, ry, ROAD_HALF * 2 * across, rh);
g.fillStyle = '#3c5878';
for (let i = 1; i < LANES; i++) g.fillRect(cx + (i - LANES / 2) * LANE_W * across - 0.5, ry, 1, rh);
// Distance ticks every 50 m along the road edge.
g.fillStyle = '#3c5878';
for (let d = Math.ceil((p.s - (ry + rh - cy) / along) / 50) * 50; d < p.s + (cy - ry) / along; d += 50) {
const y = cy - (d - p.s) * along;
g.fillRect(cx - ROAD_HALF * across - 4, y, 3, 1);
g.fillRect(cx + ROAD_HALF * across + 1, y, 3, 1);
}
// Lanes closed for road works, shaded orange.
g.fillStyle = 'rgba(255,140,40,0.45)';
for (let y = ry; y < ry + rh; y += 3) {
const shut = closedLanes(p.s + (cy - y) / along);
if (shut) g.fillRect(cx - EDGE * across, y, shut * LANE_W * across, 3);
}
for (const car of cars) {
const dy = (car.s - p.s) * along;
if (dy > cy - ry + 6 || -dy > ry + rh - cy + 6) continue;
g.fillStyle = car.kind === 'player' ? '#3b8bff' : car.kind === 'suspect' ? '#ff3b3b' : car.wrecked ? '#5d6670' : '#e9dcc0';
// At least 3 px long and 2.5 px wide, so even a car far down the road is a clear mark.
const hl = Math.max(car.len / 2, 1.5 / along), hw = Math.max(car.wid / 2, 1.25 / across);
const fx = Math.sin(car.yaw), fs = Math.cos(car.yaw), sx = Math.cos(car.yaw), ss = -Math.sin(car.yaw);
g.beginPath();
for (const [a, b] of [[1, 1], [1, -1], [-1, -1], [-1, 1]]) {
const x = car.x + fx * hl * a + sx * hw * b, s = car.s + fs * hl * a + ss * hw * b;
g.lineTo(cx + x * across, cy - (s - p.s) * along);
}
g.fill();
}
// Suspect off the top of the radar: an arrow at the edge.
if (suspect && cy - (suspect.s - p.s) * along < ry) {
const gx = cx + suspect.x * across;
g.fillStyle = '#ff3b3b';
g.beginPath();
g.moveTo(gx, ry + 2);
g.lineTo(gx - 6, ry + 11);
g.lineTo(gx + 6, ry + 11);
g.fill();
}
g.restore();
// Details: the suspect, and where we are.
const tx = rx + rw + 10, tw = w - tx - 6;
const fit = (text: string, y: number, px: number, color: string) => {
g.font = `bold ${px}px monospace`;
while (g.measureText(text).width > tw && px > 8) g.font = `bold ${--px}px monospace`;
g.fillStyle = color;
g.textAlign = 'left';
g.fillText(text, tx, y);
};
if (suspect) {
const gap = suspect.s - p.s;
const v = Math.abs(speedOf(suspect));
fit('SUSPECT', 32, 11, DIM);
fit(gap < 0 ? `${Math.round(-gap)} m BEHIND` : `${Math.round(gap)} m`, 52, 22, gap > 500 ? '#ff5a4a' : AMBER);
fit(`${Math.round(units === 'kmh' ? v * 3.6 : v * MPS_TO_MPH)} ${units === 'kmh' ? 'KM/H' : 'MPH'} ${(colorName(suspect.color) ?? '').toUpperCase()} SEDAN`, 74, 12, TEXT);
} else {
fit('FREE PATROL', 32, 11, DIM);
fit('NO WARRANTS', 52, 18, AMBER);
}
const { j, inside } = townNear(p.s);
fit(`I-87 N MILE ${mileAt(p.s)}`, 102, 12, TEXT);
fit(inside ? 'IN' : 'NEXT', 122, 11, DIM);
fit(town(j, roadSeed()).name.toUpperCase(), 140, 14, TEXT);
this.laptopTex.needsUpdate = true;
}
private drawGauges(speed: number, gearbox: Gearbox, units: 'mph' | 'kmh'): void {
const shown = units === 'kmh' ? speed * 3.6 : speed * MPS_TO_MPH;
const key = `${units}|${Math.round(shown * 2)}|${Math.round(gearbox.rpm / 40)}|${gearbox.gear}`;
if (key === this.gaugeKey) return;
this.gaugeKey = key;
const g = this.gauges.getContext('2d')!;
const { width: w, height: h } = this.gauges;
g.fillStyle = '#0c0d10';
g.fillRect(0, 0, w, h);
const dial = (cx: number, value: number, max: number, label: string, ticks: number, red = 2) => {
const r = 46, cy = 58;
const a0 = Math.PI * 0.75, a1 = Math.PI * 2.25;
g.strokeStyle = '#3a3c44';
g.lineWidth = 2;
g.beginPath();
g.arc(cx, cy, r, a0, a1);
g.stroke();
for (let i = 0; i <= ticks; i++) {
const a = a0 + ((a1 - a0) * i) / ticks;
g.strokeStyle = i / ticks >= red ? '#ff4030' : '#d8d2c0';
g.beginPath();
g.moveTo(cx + Math.cos(a) * (r - 7), cy + Math.sin(a) * (r - 7));
g.lineTo(cx + Math.cos(a) * r, cy + Math.sin(a) * r);
g.stroke();
}
const a = a0 + (a1 - a0) * Math.min(1, value / max);
g.strokeStyle = '#ff9a2a';
g.lineWidth = 3;
g.beginPath();
g.moveTo(cx, cy);
g.lineTo(cx + Math.cos(a) * (r - 4), cy + Math.sin(a) * (r - 4));
g.stroke();
g.fillStyle = '#8b8778';
g.font = '10px monospace';
g.textAlign = 'center';
g.fillText(label, cx, cy + 30);
};
dial(66, shown, units === 'kmh' ? 300 : 180, units === 'kmh' ? 'km/h' : 'MPH', 8);
dial(190, gearbox.rpm, 8000, 'x1000 rpm', 8, 0.76);
g.fillStyle = '#ffb43a';
g.font = 'bold 15px monospace';
g.fillText(String(Math.round(shown)), 66, 64);
g.fillText(gearbox.gear === 0 ? 'R' : String(gearbox.gear), 190, 64);
this.gaugeTex.needsUpdate = true;
}
/** Draw a frame from the driver's seat, or from `replay`'s orbiting camera when given. */
/** Drop every car model so each is built again, with the garage's current look. */
rebuildCars(): void {
for (const view of this.views.values()) {
this.scene.remove(view.group);
view.details.geometry.dispose();
view.proxy?.geometry.dispose();
}
this.views.clear();
}
/** The garage: a workshop corner with a turntable in the middle, under strip lights. */
private makeGarage(): THREE.Scene {
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x15171b);
scene.add(new THREE.HemisphereLight(0xe8eef8, 0x2a2622, 1.5));
const key = new THREE.DirectionalLight(0xfff4e6, 1.5);
key.position.set(3, 8, 5);
scene.add(key);
const rim = new THREE.DirectionalLight(0x9ab8ff, 0.7);
rim.position.set(-6, 4, -5);
scene.add(rim);
const box = (color: number, w: number, h: number, l: number, x: number, y: number, z: number, glow = false) => {
const m = new THREE.Mesh(this.boxGeo, glow ? new THREE.MeshBasicMaterial({ color }) : this.mat(color));
m.scale.set(w, h, l);
m.position.set(x, y, z);
scene.add(m);
return m;
};
// Concrete floor with painted bay lines; two walls behind; a roll-up door on the left wall.
box(0x4a4c50, 24, 0.1, 24, 0, -0.05, 0);
for (const x of [-3.4, 3.4]) box(0xd8b23a, 0.12, 0.012, 10, x, 0.005, 1);
box(0x5d6168, 0.3, 6, 18, -8, 3, 0); // left wall
box(0x666a71, 18, 6, 0.3, 0, 3, -8); // back wall
box(0x34373c, 0.32, 0.5, 18, -7.98, 0.25, 0); // skirting
box(0x34373c, 18, 0.5, 0.32, 0, 0.25, -7.98);
for (let i = 0; i < 9; i++) box(i % 2 ? 0x8a8f96 : 0x7d828a, 0.1, 0.42, 5, -7.82, 0.6 + i * 0.42, 2.6); // door slats
box(0x2a2c30, 0.14, 4.2, 0.2, -7.8, 2.1, -0.05);
box(0x2a2c30, 0.14, 4.2, 0.2, -7.8, 2.1, 5.25);
// Workbench and a red tool chest along the back wall, a pegboard over them, tyres stacked.
box(0x6b4a2b, 3.6, 0.1, 0.9, -3.5, 1.0, -7.3);
for (const x of [-5.1, -1.9]) box(0x2a2c30, 0.08, 1.0, 0.8, x, 0.5, -7.3);
box(0xa8783c, 3.6, 1.8, 0.05, -3.5, 2.3, -7.8);
box(0xb3262b, 1.4, 1.5, 0.7, 1.2, 0.75, -7.35);
for (let i = 0; i < 5; i++) box(0x1d1e21, 1.39, 0.02, 0.02, 1.2, 0.35 + i * 0.27, -6.99); // drawer lines
this.tireGeo ??= new THREE.CylinderGeometry(0.31, 0.31, 0.22, 12);
for (let i = 0; i < 4; i++) {
const tire = new THREE.Mesh(this.tireGeo, this.mat(0x141416));
tire.scale.set(2.1, 1.2, 2.1);
tire.position.set(5.6, 0.14 + i * 0.27, -6.4);
scene.add(tire);
}
// A blue stripe and a light box over the bay, and strip lights in the ceiling.
box(0x1d3f8a, 18, 0.35, 0.05, 0, 4.2, -7.83);
for (const z of [-3, 1.5, 6]) box(0xf4f6ff, 5, 0.06, 0.25, 0, 5.6, z, true);
// The turntable: a dark disc with a light rim, level with the floor.
const table = new THREE.Group();
const disc = new THREE.Mesh(new THREE.CylinderGeometry(3.3, 3.3, 0.08, 48), this.mat(0x2b2d31));
disc.position.y = 0.04;
table.add(disc);
const ring = new THREE.Mesh(new THREE.TorusGeometry(3.3, 0.05, 6, 64), new THREE.MeshBasicMaterial({ color: 0x6fa0ff }));
ring.rotation.x = Math.PI / 2;
ring.position.y = 0.08;
scene.add(ring);
scene.add(table);
this.turntable = table;
return scene;
}
/**
* The garage menu: the patrol car or the getaway car, built from `garage` as the game would
* build it, turning on the turntable. Rebuilt whenever the spec changes.
*/
renderGarage(which: 'police' | 'getaway', dt: number, time: number): void {
const scene = (this.garageScene ??= this.makeGarage());
const spec = this.garage;
const key = JSON.stringify([which, spec]);
if (this.garageCar?.key !== key) {
if (this.garageCar) {
this.turntable!.remove(this.garageCar.view.group);
this.garageCar.view.details.geometry.dispose();
this.garageCar.view.wheels?.geometry.dispose();
}
const type = which === 'police' ? 'sedan' : BODIES[spec.getaway.body].type;
const v = VEHICLES[type];
const pose: CarPose = {
id: -1, kind: 'player', x: 0, s: 0, yaw: 0, vx: 0, vs: 0, len: v.len, wid: v.wid,
color: PAINTS[spec.getaway.paint].color, wrecked: false, exploded: false, type,
lost: type === 'pickup' || type === 'lifted' ? 1 << 3 : 0,
};
const was = this.playerIsPatrol;
this.playerIsPatrol = which === 'police';
const view = this.makeCarView(pose);
this.playerIsPatrol = was;
this.scene.remove(view.group);
this.turntable!.add(view.group);
view.group.position.y = 0.08;
for (const part of view.exterior ?? []) part.visible = true;
view.panels.forEach((m, i) => (m.visible = !(pose.lost & (1 << i))));
for (const [i, m] of view.attached) m.visible = !(pose.lost & (1 << i));
this.garageCar = { view, key };
}
this.flashBeacons(this.garageCar.view, time);
this.turn += dt * 0.35;
this.turntable!.rotation.y = this.turn;
// Straight, flat road frames, so the bend shader leaves the garage as it was built.
const f = this.frames;
for (let i = 0; i < f.count; i++) f.data.set([0, -(f.from - f.origin + i * f.step), 0, 0], i * 4);
this.bender.sync();
this.bender.patch(scene);
// A three-quarter view from the front. Wide frames put the car left of the panel on the
// right; tall ones put it above the panel at the bottom.
const aspect = this.camera.aspect, cam = this.garageCam;
cam.aspect = aspect;
cam.fov = aspect < 1 ? 58 : 36;
const back = aspect < 1 ? 10 : 11;
cam.position.set(Math.sin(0.75) * back, 3.3, Math.cos(0.75) * back);
cam.lookAt(0, 0.9, 0);
const W = aspect * 1000, H = 1000;
if (aspect > 1.15) cam.setViewOffset(W, H, W * 0.17, 0, W, H);
else if (aspect < 1) cam.setViewOffset(W, H, 0, H * 0.2, W, H);
else cam.clearViewOffset();
cam.updateProjectionMatrix();
this.present(scene, cam);
}
/** Draw `scene` from `camera` to the screen, by way of frameTarget. */
private present(scene: THREE.Scene, camera: THREE.Camera): void {
this.gl.setRenderTarget(this.frameTarget);
this.gl.render(scene, camera);
this.gl.setRenderTarget(null);
this.gl.render(this.blit, this.blitCam);
}
render(sim: ViewState, dt: number, time: number, gearbox: Gearbox, units: 'mph' | 'kmh', replay?: ReplayCamera): void {
const p = sim.player;
const suspect = sim.cars.find((c) => c.kind === 'suspect');
const originS = p.s;
const z = (s: number) => -(s - originS);
const t0 = PERF ? performance.now() : 0;
if (PERF) this.gl.info.reset();
// The road's shape around the car, for the bend shader and the cameras.
this.frames.update(originS);
this.bender.sync();
const t1 = PERF ? performance.now() : 0;
// The sun stays put in the world, so it swings round as the road turns.
this.stars.position.copy(this.camera.position); // last frame's eye: near enough at this distance
this.sun.position.set(-0.6, this.look.sun[2], -0.4).applyAxisAngle(new THREE.Vector3(0, 1, 0), this.frames.baseHeading);
this.updateTerrain(originS, z);
let lowest = 0;
for (let i = 3; i < this.frames.data.length; i += 4) lowest = Math.min(lowest, this.frames.data[i]);
this.ground.position.set(0, lowest - 25, 0);
// Cars
const live = new Set<number>();
for (const car of sim.cars) {
live.add(car.id);
let view = this.views.get(car.id);
if (!view) {
view = this.makeCarView(car);
this.views.set(car.id, view);
}
view.group.visible = true;
if (view.exterior) this.setExterior(view, Boolean(replay), time);
else if (view.beacons) this.flashBeacons(view, time, car.id * 0.37);
// Missing panels (and the lights on them), and the charred shell of an exploded car.
// The patrol car stays whole from the driver's seat; its dents show in the replay.
if (car.kind === 'player' && !replay) {
for (const m of view.panels.slice(0, 4)) m.visible = true; // hood and wings, seen from inside
} else {
view.panels.forEach((m, i) => (m.visible = !(car.lost & (1 << i))));
for (const [i, m] of view.attached) m.visible = !(car.lost & (1 << i));
}
if (view.cargo) view.cargo.visible = !car.cargoLost;
if (view.lamps) {
// Indicators flash about 1.5 times a second, each car in its own rhythm.
const blink = Math.floor(time * 3 + car.id * 0.37) % 2 === 0;
// After dark everyone drives with lights on. From the driver's seat your own car's
// lamps are out of sight; its beam pattern on the road shows, with the spotlight.
const lit = this.look.lights > 0, inside = car.kind === 'player' && !replay;
for (const l of view.lamps) {
const on = l.kind === 'brake' ? Boolean(car.braking)
: l.kind === 'head' || l.kind === 'tail' ? lit && !inside
: l.kind === 'pool' ? lit && !car.wrecked
: car.signal === l.kind && blink;
l.mesh.visible = on && !car.exploded && !(l.part >= 0 && car.lost & (1 << l.part));
}
}
// The trailer turned on its hitch relative to the tractor (yaw toward +x is clockwise).
if (view.trailer) view.trailer.rotation.y = -Math.atan2(Math.sin((car.trailerYaw ?? car.yaw) - car.yaw), Math.cos((car.trailerYaw ?? car.yaw) - car.yaw));
if (car.exploded !== view.charred) {
view.charred = car.exploded;
view.core.material = car.exploded ? this.mat(0x151515) : view.coreMat;
view.cabin.material = car.exploded ? this.mat(0x1c1a19) : view.cabinMat;
this.charredDetailMat ??= new THREE.MeshLambertMaterial({ vertexColors: true, flatShading: true, color: 0x2a2826 });
view.details.material = car.exploded ? this.charredDetailMat : this.detailMat!;
if (view.trailerDetails) view.trailerDetails.material = view.details.material;
}
if (view.near && car.kind !== 'player') {
const [vx, vs] = replay ? [this.replayPos.x, this.replayPos.z] : [p.x, p.s];
this.setDetail(view, car, Math.hypot(car.x - vx, car.s - vs) > LOD_FAR);
}
view.group.position.set(car.x, 0, z(car.s));
// Cars are modelled facing -z (forward); yaw toward +x is a clockwise turn seen from above.
view.group.rotation.y = -car.yaw;
if (view.marker) {
view.marker.position.y = 3 + Math.sin(time * 4) * 0.25;
view.marker.rotation.y = time * 2;
view.marker.visible = !car.wrecked || Math.floor(time * 4) % 2 === 0;
}
}
for (const [id, view] of this.views) {
if (!live.has(id)) {
this.scene.remove(view.group);
view.details.geometry.dispose(); // built per car
view.proxy?.geometry.dispose();
this.views.delete(id);
}
}
// Endless scenery, snapped to its repeat period so it never slides under the car.
const snap = (v: number, step: number) => Math.floor(v / step) * step;
const roadMid = snap(originS + (ROAD_AHEAD - ROAD_BEHIND) / 2, DASH_PERIOD);
this.road.position.z = z(roadMid);
this.shoulders.position.z = z(roadMid);
const m = new THREE.Matrix4();
const q = new THREE.Quaternion();
const up = new THREE.Vector3(0, 1, 0);
const pos = new THREE.Vector3();
const scl = new THREE.Vector3();
const place = (mesh: THREE.InstancedMesh, i: number, x: number, y: number, s: number, sx: number, sy: number, sz: number, yaw = 0) => {
if (i >= this.capacity.get(mesh)!) return i;
q.setFromAxisAngle(up, yaw);
m.compose(pos.set(x, y, z(s)), q, scl.set(sx, sy, sz));
mesh.setMatrixAt(i, m);
return i + 1;
};
const start = originS - ROAD_BEHIND;
const end = originS + ROAD_AHEAD;
const inGore = (s: number) => {
const d = s - rampAt(s);
return d > -RAMP_GORE && d < 0;
};
let n = 0, posts = 0;
for (let s = snap(start, RAIL_PIECE); s < end; s += RAIL_PIECE) {
n = place(this.rails, n, -(ROAD_HALF + 0.15), 0.62, s + RAIL_PIECE / 2, 0.12, 0.3, RAIL_PIECE + 0.02);
posts = place(this.posts, posts, -(ROAD_HALF + 0.3), 0.4, s, 0.12, 0.8, 0.12);
if (inGore(s) || inGore(s + RAIL_PIECE)) {
// Where the slip road meets the highway: a line of flexible delineator posts.
for (const k of [0, 0.5]) posts = place(this.posts, posts, ROAD_HALF, 0.5, s + k * RAIL_PIECE, 0.1, 1.0, 0.1);
continue;
}
// The right rail follows the wall around merge lanes, angled through the tapers.
const xa = rightWall(s) + 0.15, xb = rightWall(s + RAIL_PIECE) + 0.15;
const len = Math.hypot(RAIL_PIECE, xb - xa);
n = place(this.rails, n, (xa + xb) / 2, 0.62, s + RAIL_PIECE / 2, 0.12, 0.3, len + 0.02, -Math.atan2(xb - xa, RAIL_PIECE));
posts = place(this.posts, posts, xa + 0.15, 0.4, s, 0.12, 0.8, 0.12);
}
this.rails.count = n;
this.posts.count = posts;
n = 0;
let heads = 0, pools = 0;
const lamp = (x: number, s: number, inward: number) => {
heads = place(this.lampHeads, heads, x, 8.88, s, 0.55, 0.08, 0.32);
pools = place(this.lampPools, pools, x + inward * 2.5, 0.06, s, 16, 1, 18);
};
for (let s = snap(start, 60); s < end; s += 60) {
n = place(this.poles, n, -(ROAD_HALF + 1.6), 4.5, s, 0.22, 9, 0.22);
n = place(this.poles, n, -(ROAD_HALF + 0.4), 9, s, 2.6, 0.18, 0.3);
lamp(-(ROAD_HALF - 0.75), s, 1);
if (inGore(s)) continue;
const xr = rightWall(s);
n = place(this.poles, n, xr + 1.6, 4.5, s, 0.22, 9, 0.22);
n = place(this.poles, n, xr + 0.4, 9, s, 2.6, 0.18, 0.3);
lamp(xr - 0.75, s, -1);
}
this.poles.count = n;
this.lampHeads.count = heads;
this.lampPools.count = pools;
this.lampHeads.instanceMatrix.needsUpdate = this.lampPools.instanceMatrix.needsUpdate = true;
n = 0;
for (let s = snap(start, 14); s < end; s += 14) {
const cell = s / 14;
const d = s - rampAt(s);
for (const side of [-1, 1]) {
// Keep trees off the slip road.
if (side > 0 && d > -RAMP_LEN - 20 && d < MERGE_LEN + MERGE_TAPER) continue;
const h = hash(cell * 2 + (side > 0 ? 1 : 0));
if (h < 1 - treeShare(areaWeights(s))) continue; // fewer in towns, none in cities
const height = 4 + h * 7;
const tx = side * (ROAD_HALF + 9 + hash(cell + 7.3) * 40), ts = s + h * 10;
n = place(this.trees, n, tx, terrainY(tx, ts, lieAt(ts)) + height / 2 - 0.3, ts, 1.8 + h, height, 1.8 + h);
}
}
this.trees.count = n;
n = 0;
for (let s = snap(start - BRIDGE_OFFSET, RAMP_EVERY) + BRIDGE_OFFSET; s < end; s += RAMP_EVERY) {
if (s < start) continue;
n = place(this.bridges, n, 0, 6.2, s, ROAD_HALF * 2 + 30, 1.1, 12);
for (const side of [-1, 1]) n = place(this.bridges, n, side * (ROAD_HALF + 3), 3, s, 1.4, 6, 9);
}
this.bridges.count = n;
for (const mesh of [this.rails, this.posts, this.poles, this.trees, this.bridges]) mesh.instanceMatrix.needsUpdate = true;
this.placeSigns(start, originS + 2600, z, place);
this.areas.update(start, end, z, time, (j) => placeName(j, roadSeed()));
// The (at most two) ramps near the view.
let r0 = rampAt(start);
while (r0 + MERGE_LEN + MERGE_TAPER < start) r0 += RAMP_EVERY;
for (const ramp of this.ramps) {
ramp.visible = r0 - RAMP_LEN < end;
ramp.position.set(0, 0, z(r0));
r0 += RAMP_EVERY;
}
this.effects.update(replay ? dt * (replay.rate ?? 1) : dt, originS, sim.cars, !replay);
this.placeBirds(sim.birds?.birds ?? [], z);
this.updatePlayerSplats(p.id, dt);
// Lights aren't bent by the shader; move the explosion flash onto the road by hand.
const fp = this.effects.flash.position;
const fb = this.frames.bend(fp.x, fp.y, originS - fp.z, this.bent);
fp.set(fb.x, fb.y, fb.z);
// The interior rides with the player car; the wheel turns with the steering input.
// The seat (and the dash and the eye with it) rides as high as the body does.
const seat = bodyLift(p.type ?? 'sedan');
this.interior.position.set(p.x, seat, z(p.s));
// Coronas face the viewer: the replay camera, or the player's car (the mirrors look back from it too).
this.placeCoronas(sim.cars, Boolean(replay), replay ? this.replayPos.x : p.x, replay ? this.replayPos.z : p.s);
// The player's headlights: a spotlight from the front bumper down the road ahead.
const beams = this.look.lights > 0 && !replay;
this.headlight.intensity = beams ? HEADLIGHT * (0.5 + 0.5 * this.look.lights) : 0;
if (beams) {
const hx = Math.sin(p.yaw), hz = Math.cos(p.yaw);
const a = this.frames.bend(p.x + hx * p.len / 2, 0.75 + seat, p.s + hz * p.len / 2, this.bent);
this.headlight.position.set(a.x, a.y, a.z);
const b = this.frames.bend(p.x + hx * 70, 0, p.s + hz * 70, this.bent);
this.headlight.target.position.set(b.x, b.y, b.z);
}
this.interior.rotation.y = -p.yaw;
this.steer += (sim.input.steer - this.steer) * Math.min(1, dt * 6);
this.wheel.rotation.z = -this.steer * 1.4;
this.interior.visible = !replay;
this.bender.patch(this.scene);
this.collectCullables(sim.cars, originS, Boolean(replay));
this.scene.updateMatrixWorld();
const t2 = PERF ? performance.now() : 0;
if (replay) {
this.renderReplay(p, suspect ?? p, replay, dt);
if (PERF) this.countFrame(t0, t1, t2);
return;
}
this.drawGauges(Math.abs(speedOf(p)), gearbox, units);
const patrolCar = this.playerIsPatrol;
for (const o of this.policeKit) o.visible = patrolCar;
if (patrolCar) {
if (time >= this.laptopDue || time < this.laptopDue - 1) {
this.laptopDue = time + 1 / 8;
this.drawLaptop(sim.cars, p, suspect, units);
}
this.drawRadarUnit(sim.cars, p, suspect, units, time);
}
// First-person camera at the driver's eye.
this.shake = Math.max(0, this.shake - dt * 1.5);
const fx = Math.sin(p.yaw), fz = Math.cos(p.yaw);
const rx = Math.cos(p.yaw), rz = -Math.sin(p.yaw);
const eyeX = p.x + rx * EYE.lat + fx * EYE.lon;
const eyeS = p.s + rz * EYE.lat + fz * EYE.lon;
const jitter = this.shake * 0.08;
const buzz = Math.min(1, Math.abs(speedOf(p)) / 50) * 0.002; // road buzz at speed
// The eye goes through the same bend as the scene, and turns and tilts with the road.
const eye = this.frames.bend(
eyeX + (Math.random() - 0.5) * jitter,
EYE.up + seat + (Math.random() - 0.5) * (jitter + buzz),
eyeS,
this.bent,
);
this.camera.position.set(eye.x, eye.y, eye.z);
const slope = Math.atan(grade(eyeS));
this.camera.rotation.set(this.pitch + slope, -(p.yaw + this.frames.headingAt(eyeS)), 0, 'YXZ');
this.mirrorGlass.visible = this.mirror;
if (this.mirror) {
// Rear view from the mirror position, looking back through the rear window.
const ms = p.s - fz * 0.1;
const mp = this.frames.bend(p.x - fx * 0.1, 1.28 + seat, ms, this.bent);
this.mirrorCam.position.set(mp.x, mp.y, mp.z);
this.mirrorCam.rotation.set(-0.03 - slope, -(p.yaw + this.frames.headingAt(ms)) + Math.PI, 0, 'YXZ');
this.interior.visible = false;
this.gl.setRenderTarget(this.mirrorTarget);
this.culler.cull(this.mirrorCam);
this.gl.render(this.scene, this.mirrorCam);
// A door mirror: from its place on the door, looking back and a little outward.
const door = this.doorMirrors[this.mirrorTurn++ % this.doorMirrors.length];
if (door) {
const lat = door.side * 1.05, lon = 0.62;
const ds = p.s + rz * lat + fz * lon;
const dp = this.frames.bend(p.x + rx * lat + fx * lon, 1.04 + seat, ds, this.bent);
door.cam.position.set(dp.x, dp.y, dp.z);
door.cam.rotation.set(-0.04 - slope, -(p.yaw + this.frames.headingAt(ds)) + Math.PI + door.side * 0.15, 0, 'YXZ');
this.gl.setRenderTarget(door.target);
this.culler.cull(door.cam);
this.gl.render(this.scene, door.cam);
}
this.gl.setRenderTarget(null);
this.interior.visible = true;
}
for (const door of this.doorMirrors) door.glass.visible = this.mirror;
this.culler.cull(this.camera);
this.present(this.scene, this.camera);
this.culler.restore();
if (PERF) this.countFrame(t0, t1, t2);
}
private countFrame(t0: number, t1: number, t2: number): void {
const t3 = performance.now(), f = this.perf, info = this.gl.info.render;
f.frames++;
f.road += t1 - t0;
f.scene += t2 - t1;
f.submit += t3 - t2;
f.total += t3 - t0;
f.calls += info.calls;
f.triangles += info.triangles;
}
}
