SOURCE / PINNED RELEASE
Made of little things.
Napplet Machines V8
- Release
- b3f614e739f0…
- Author-recorded commit
- 534f19acb4ba…
- License
- LICENSE
- Author’s source reference
- nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-b5572362d4a
Archive hash verified: 0f7feb97e2c3d336…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import {
Road,
lerp,
roadEdge,
roadBoundary,
WALL_HEIGHT,
type Point,
} from './track.js';
import { courseStyle, paint } from './art.js';
import { raceBuildings, type Building } from './scenery.js';
import { cars, slipAngle, type Body } from './physics.js';
type V = [number, number, number];
const hex = (s: string): V => [
parseInt(s.slice(1, 3), 16) / 255,
parseInt(s.slice(3, 5), 16) / 255,
parseInt(s.slice(5, 7), 16) / 255,
];
const sub = (a: V, b: V): V => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const cross = (a: V, b: V): V => [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
const norm = (v: V): V => {
const n = Math.hypot(...v) || 1;
return v.map((x) => x / n) as V;
};
const dot = (a: V, b: V) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
class Mesh {
vertices: number[] = [];
tri(a: V, b: V, c: V, color: V, unlit = false) {
const n = norm(cross(sub(b, a), sub(c, a))),
light = unlit
? 1
: 0.67 + Math.max(0, dot(n, norm([-0.5, -0.8, 1.5]))) * 0.33;
for (const p of [a, b, c])
this.vertices.push(...p, ...color.map((x) => x * light));
}
quad(a: V, b: V, c: V, d: V, color: V, unlit = false) {
this.tri(a, b, c, color, unlit);
this.tri(a, c, d, color, unlit);
}
box(
x: number,
y: number,
z: number,
w: number,
l: number,
h: number,
color: V,
angle = 0,
) {
const p = (a: number, b: number, c: number): V => [
x + a * Math.cos(angle) - b * Math.sin(angle),
y + a * Math.sin(angle) + b * Math.cos(angle),
z + c,
];
const a = p(-l / 2, -w / 2, 0),
b = p(l / 2, -w / 2, 0),
c = p(l / 2, w / 2, 0),
d = p(-l / 2, w / 2, 0),
e = p(-l / 2, -w / 2, h),
f = p(l / 2, -w / 2, h),
g = p(l / 2, w / 2, h),
j = p(-l / 2, w / 2, h);
this.quad(e, f, g, j, color);
this.quad(a, b, f, e, color);
this.quad(b, c, g, f, color);
this.quad(c, d, j, g, color);
this.quad(d, a, e, j, color);
}
disc(
x: number,
y: number,
z: number,
rx: number,
ry: number,
color: V,
sides = 12,
) {
for (let i = 0; i < sides; i++) {
const a = (i / sides) * Math.PI * 2,
b = ((i + 1) / sides) * Math.PI * 2;
this.tri(
[x, y, z],
[x + Math.cos(a) * rx, y + Math.sin(a) * ry, z],
[x + Math.cos(b) * rx, y + Math.sin(b) * ry, z],
color,
true,
);
}
}
cone(
x: number,
y: number,
z: number,
r: number,
h: number,
color: V,
sides = 6,
) {
for (let i = 0; i < sides; i++) {
const a = (i / sides) * Math.PI * 2,
b = ((i + 1) / sides) * Math.PI * 2;
this.tri(
[x + Math.cos(a) * r, y + Math.sin(a) * r, z],
[x + Math.cos(b) * r, y + Math.sin(b) * r, z],
[x, y, z + h],
color,
);
}
}
}
function buildingMesh(m: Mesh, b: Building) {
const c = Math.cos(b.angle),
s = Math.sin(b.angle),
ink = hex('#17254d'),
cream = hex('#fff8e7');
const color = hex(paint[b.color]);
const box = (
x: number,
y: number,
z: number,
w: number,
l: number,
h: number,
tint: V,
) =>
m.box(
b.x + x * c - y * s,
b.y + x * s + y * c,
b.z + z,
w,
l,
h,
tint,
b.angle,
);
// Molded base raises the building to the adjacent race deck on hilly courses.
m.box(
b.x,
b.y,
-0.35,
b.width + 0.8,
b.length + 0.8,
b.z + 0.35,
hex('#6685af'),
b.angle,
);
box(0, 0, 0, b.width + 0.8, b.length + 0.8, 0.22, cream);
if (b.kind === 'garage') {
box(0, 0, 0.22, b.width, b.length, 2.5, color);
box(0, 0, 2.72, b.width + 0.65, b.length + 0.6, 0.3, cream);
box(0, -b.width / 2 - 0.04, 0.3, 0.08, b.length - 0.7, 1.8, ink);
for (const x of [-b.length * 0.25, b.length * 0.25]) {
box(
x,
-b.width / 2 - 0.09,
0.45,
0.08,
b.length * 0.39,
1.45,
hex('#8be6eb'),
);
for (let row = 0; row < 3; row++)
box(
x,
-b.width / 2 - 0.14,
0.6 + row * 0.42,
0.06,
b.length * 0.39,
0.07,
cream,
);
}
box(0, -b.width / 2 - 0.18, 2.26, 0.12, b.length - 0.5, 0.3, ink);
for (let i = 0; i < 8; i++)
box(
(i - 3.5) * 0.48,
-b.width / 2 - 0.26,
2.28,
0.06,
0.45,
0.25,
i % 2 ? cream : hex('#ffdc3f'),
);
box(b.length * 0.25, 0.5, 3.02, 1, 1.35, 0.35, ink);
} else if (b.kind === 'stand') {
for (let row = 0; row < 3; row++) {
box(0, -1.45 + row * 1.25, 0.22, 1.2, b.length, 0.6 + row * 0.6, cream);
box(
0,
-1.25 + row * 1.25,
0.86 + row * 0.6,
0.6,
b.length - 0.4,
0.16,
color,
);
for (let seat = 0; seat < 7; seat++) {
const x = (seat - 3) * 0.93,
y = -1.25 + row * 1.25;
box(
x,
y,
1.02 + row * 0.6,
0.36,
0.38,
0.4,
hex(paint[(row + seat) % 4]),
);
box(x, y, 1.42 + row * 0.6, 0.3, 0.3, 0.25, hex('#f4bc91'));
}
}
for (const x of [-b.length / 2 + 0.2, b.length / 2 - 0.2])
box(x, 1.8, 0.22, 0.18, 0.18, 3.8, ink);
box(0, 0, 3.95, b.width + 0.6, b.length + 0.6, 0.28, color);
box(0, -b.width / 2 - 0.3, 3.73, 0.14, b.length + 0.6, 0.28, cream);
} else {
box(0, 0, 0.22, 2.6, 2.8, 3.4, color);
box(0, 0, 3.62, b.width, b.length, 1.55, ink);
box(
0,
-b.width / 2 - 0.02,
3.88,
0.06,
b.length - 0.35,
0.95,
hex('#8be6eb'),
);
box(
-b.length / 2 - 0.02,
0,
3.88,
b.width - 0.35,
0.06,
0.95,
hex('#8be6eb'),
);
for (const x of [-1, 0, 1])
box(x, -b.width / 2 - 0.08, 3.88, 0.06, 0.09, 0.95, cream);
box(0, 0, 5.17, b.width + 0.55, b.length + 0.55, 0.3, cream);
box(0, 0, 5.47, 0.13, 0.13, 1.4, ink);
box(0.55, 0, 6.3, 0.08, 1.1, 0.55, color);
for (let i = 0; i < 5; i++)
box((i - 2) * 0.48, -1.32, 2.8, 0.08, 0.48, 0.35, i % 2 ? ink : cream);
}
}
export class Renderer {
gl: WebGLRenderingContext;
program: WebGLProgram;
staticBuffer: WebGLBuffer;
dynamicBuffer: WebGLBuffer;
staticCount = 0;
private lastView = '';
azimuth = -0.75;
elevation = 0.84;
zoom = 1;
target: V = [0, 0, 3];
span = 112;
matrix = new Float32Array(16);
road: Road | null = null;
skidMarks: { a: V; b: V }[] = [];
lastSkid = new Map<string, { x: number; y: number; points: V[] }>();
constructor(public canvas: HTMLCanvasElement) {
const gl = canvas.getContext('webgl', { antialias: true, alpha: true });
if (!gl) throw new Error('3D graphics unavailable. Enable WebGL to drive.');
this.gl = gl;
const shader = (type: number, source: string) => {
const s = gl.createShader(type)!;
gl.shaderSource(s, source);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))
throw new Error(gl.getShaderInfoLog(s) || 'Shader failed');
return s;
};
const vs = shader(
gl.VERTEX_SHADER,
'attribute vec3 position;attribute vec3 color;uniform mat4 camera;varying vec3 tint;void main(){gl_Position=camera*vec4(position,1.);tint=color;}',
);
const fs = shader(
gl.FRAGMENT_SHADER,
'precision mediump float;varying vec3 tint;void main(){gl_FragColor=vec4(tint,1.);}',
);
this.program = gl.createProgram()!;
gl.attachShader(this.program, vs);
gl.attachShader(this.program, fs);
gl.linkProgram(this.program);
gl.deleteShader(vs);
gl.deleteShader(fs);
this.staticBuffer = gl.createBuffer()!;
this.dynamicBuffer = gl.createBuffer()!;
gl.enable(gl.DEPTH_TEST);
gl.useProgram(this.program);
}
setRoad(road: Road) {
this.lastView = '';
this.road = road;
this.skidMarks = [];
this.lastSkid.clear();
const m = new Mesh(),
ss = road.samples,
style = courseStyle(road.track.name);
const edge = (s: (typeof ss)[0], offset: number, z = 0): V => {
const p = roadEdge(s, offset);
return [p.x, p.y, p.z + z];
};
const border = (s: (typeof ss)[0], side: number, offset = 0, z = 0): V => {
const p = roadBoundary(s, side, offset);
return [p.x, p.y, p.z + z];
};
const hx = Math.max(
79,
Math.abs(road.bounds.minX) + 20,
Math.abs(road.bounds.maxX) + 20,
),
hy = Math.max(
71,
Math.abs(road.bounds.minY) + 20,
Math.abs(road.bounds.maxY) + 20,
),
scaleX = hx / 79,
scaleY = hy / 71;
this.canvas.dataset.islandWidth = String(hx * 2);
this.canvas.dataset.islandLength = String(hy * 2);
// Course-owned sky and ocean colors stay consistent through host theme changes.
this.canvas.style.background = `radial-gradient(ellipse at 65% 20%, ${style.horizon}, ${style.sky} 85%)`;
m.disc(0, 0, -6.5, hx * 1.96, hy * 2, hex(style.ocean), 32);
// A beach shelf and broken rock shelves give the little diorama a shoreline.
m.box(0, 0, -6.2, hy * 2 + 11, hx * 2 + 11, 0.7, hex(style.shore));
for (let i = 0; i < 34; i++) {
const angle = (i / 34) * Math.PI * 2,
x = Math.cos(angle) * (hx + 26),
y = Math.sin(angle) * (hy + 25);
const r = 2.4 + (i % 4) * 0.6;
if (i % 5 === 0) {
m.cone(x, y, -6.4, r + 1, 3.2, hex(style.edge), 6);
m.disc(x, y, -3.5, r, r * 0.8, hex(style.shore), 7);
m.box(x, y, -3.5, 0.25, 0.25, 2, hex('#bb7861'));
m.cone(x, y, -2.8, r * 0.7, 2.8, hex(style.leaves[i % 4]), 6);
} else {
m.disc(x, y, -6.43, 3.5 + (i % 3), 0.16, hex('#daf9ec'), 7);
m.disc(x + 1.5, y + 1.8, -6.42, 2, 0.1, hex('#b4e5e8'), 7);
}
}
// Tiny sailboats make the water read at both orbit and follow-camera scales.
for (const [bx, by, angle, color] of [
[-94, -42, 0.4, 0],
[93, 12, -0.6, 1],
[22, 90, 1.2, 3],
]) {
const x = bx * scaleX,
y = by * scaleY;
m.disc(x + 1, y, -6.38, 4.5, 1.4, hex('#a5e6df'), 9);
m.box(x, y, -6.25, 1.8, 4, 0.55, hex(paint[color]), angle);
m.box(x, y, -5.7, 0.12, 0.12, 3.5, hex('#fff8e7'));
const c = Math.cos(angle),
s = Math.sin(angle);
m.tri(
[x, y, -2.2],
[x, y, -5.5],
[x + 2.3 * c, y + 2.3 * s, -5.5],
hex('#fff8e7'),
);
m.tri(
[x, y, -2.7],
[x - 1.4 * c, y - 1.4 * s, -5.4],
[x, y, -5.4],
hex('#ffdc3f'),
);
}
// A miniature floating landscape with faceted sides.
m.box(0, 0, -5.5, hy * 2, hx * 2, 1, hex('#20365b'));
m.box(0, 0, -4.5, hy * 2, hx * 2, 1.3, hex('#ffcf64'));
m.box(0, 0, -3.2, hy * 2, hx * 2, 2.8, hex(style.edge));
m.quad(
[-hx, -hy, -0.38],
[hx, -hy, -0.38],
[hx, hy, -0.38],
[-hx, hy, -0.38],
hex(style.ground),
);
for (let i = 0; i < 20; i++) {
const side = i % 2 ? 1 : -1,
x = (-70 + ((i / 2) | 0) * 15) * scaleX,
y = side * (hy + (i % 3));
m.cone(
x,
y,
-5.5,
3.5 + (i % 3),
4.2,
hex(i % 3 ? style.edge : '#6685af'),
5,
);
}
for (let i = 0; i < ss.length - 1; i++) {
const a = ss[i],
b = ss[i + 1],
halfA = a.width / 2,
halfB = b.width / 2;
m.quad(
edge(a, -halfA),
edge(b, -halfB),
edge(b, halfB),
edge(a, halfA),
hex('#334263'),
true,
);
for (const side of [-1, 1]) {
m.quad(
edge(a, side * halfA),
edge(b, side * halfB),
edge(b, side * halfB, -0.8),
edge(a, side * halfA, -0.8),
hex('#3766b1'),
);
m.quad(
border(a, side, -0.42, 0.025),
border(b, side, -0.42, 0.025),
edge(b, side * halfB, 0.025),
edge(a, side * halfA, 0.025),
hex(Math.floor(a.distance / 2) % 2 ? '#fff8e7' : style.accent),
);
}
if (a.distance % 7 < 3)
m.quad(
edge(a, -0.055, 0.03),
edge(b, -0.055, 0.03),
edge(b, 0.055, 0.03),
edge(a, 0.055, 0.03),
hex('#a6b5d4'),
);
if (Number.isInteger(a.u) && a.z > 4) {
const deck = road.deck.at(a.x, a.y, a.z);
if (deck)
m.box(
a.x,
a.y,
0,
1.2,
1.2,
Math.max(0, deck.z - 0.85),
hex('#ffc955'),
);
}
}
for (const [i, wall] of road.walls.entries()) {
const base = wall.corners.map((p) => [p.x, p.y, p.z] as V),
top = wall.corners.map((p) => [p.x, p.y, p.z + WALL_HEIGHT] as V);
m.quad(top[0], top[1], top[2], top[3], hex('#fff4d5'));
m.quad(
base[0],
base[1],
top[1],
top[0],
hex(Math.floor(i / 12) % 2 ? '#fff4d5' : style.accent),
);
m.quad(base[2], base[3], top[3], top[2], hex('#4779cf'));
if (wall.startCap)
m.quad(base[3], base[0], top[0], top[3], hex('#ffcf47'));
if (wall.endCap) m.quad(base[1], base[2], top[2], top[1], hex('#ffcf47'));
}
// Start grid follows the tangent, including sloped track height.
for (let row = 0; row < 2; row++)
for (let col = 0; col < 12; col++) {
const a = road.at(row * 0.6),
b = road.at((row + 1) * 0.6),
l = col / 12 - 0.5,
r = (col + 1) / 12 - 0.5;
m.quad(
edge(a, l * a.width, 0.045),
edge(b, l * b.width, 0.045),
edge(b, r * b.width, 0.045),
edge(a, r * a.width, 0.045),
hex((row + col) % 2 ? '#f9f2de' : '#26353a'),
);
}
const start = road.at(0),
gateAngle = Math.atan2(start.ty, start.tx);
for (const side of [-1, 1]) {
const { x, y } = roadBoundary(start, side, 1);
m.box(x, y, start.z, 0.75, 0.75, 5.5, hex('#ffca45'), gateAngle);
m.box(x, y, start.z + 0.2, 1.3, 1.3, 0.35, hex('#426aff'), gateAngle);
}
const startCenter = roadEdge(start, 0),
startWidth = Math.hypot(
start.leftEdge.x - start.rightEdge.x,
start.leftEdge.y - start.rightEdge.y,
);
m.box(
startCenter.x,
startCenter.y,
start.z + 4.7,
startWidth + 3,
0.8,
1.1,
hex('#fff8e7'),
gateAngle,
);
for (let row = 0; row < 2; row++)
for (let col = 0; col < 14; col++) {
const width = (startWidth + 3) / 14,
offset = -(startWidth + 3) / 2 + (col + 0.5) * width;
m.box(
startCenter.x - start.ty * offset,
startCenter.y + start.tx * offset,
start.z + 4.71 + row * 0.53,
width,
0.83,
0.53,
hex((row + col) % 2 ? '#17254d' : '#fff8e7'),
gateAngle,
);
}
// Trackside pennants read as toy accessories, outside the collision surface.
for (let i = 1; i < 9; i++) {
const p = road.at((road.length * i) / 9),
side = i % 2 ? 1 : -1,
{ x, y } = roadBoundary(p, side, p.wallThickness / 2 + 2),
angle = Math.atan2(p.ty, p.tx);
m.box(x, y, p.z - 0.2, 0.16, 0.16, 3.8, hex('#fff8e7'));
m.box(
x,
y,
p.z + 2.5,
1.6,
0.12,
1.0,
hex(i % 3 === 0 ? '#ffda45' : style.accent),
angle,
);
}
const buildings = raceBuildings(road);
for (const building of buildings) buildingMesh(m, building);
this.canvas.dataset.buildings = String(buildings.length);
const nearBuilding = (x: number, y: number, r: number) =>
buildings.some((b) => Math.hypot(x - b.x, y - b.y) < b.radius + r);
let seed = 591;
const rand = () => {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 4294967296;
};
// Low faceted mounds occupy only broad empty patches, not the circuit or pits.
const hills: { x: number; y: number; r: number }[] = [];
for (let i = 0; i < 90 && hills.length < 9; i++) {
const x = (rand() - 0.5) * (hx * 2 - 24),
y = (rand() - 0.5) * (hy * 2 - 26),
r = 4 + rand() * 4;
if (
nearBuilding(x, y, r + 1) ||
hills.some((h) => Math.hypot(x - h.x, y - h.y) < r + h.r + 2)
)
continue;
if (
!ss.every(
(p) =>
Math.hypot(x - p.x, y - p.y) >
p.width / 2 + p.wallThickness / 2 + r + 3,
)
)
continue;
hills.push({ x, y, r });
m.disc(x, y, -0.35, r + 1, r * 0.85, hex(style.patch), 8);
m.cone(x, y, -0.3, r, 2 + rand() * 2, hex(style.hill), 7);
m.cone(
x + r * 0.38,
y + r * 0.15,
-0.3,
r * 0.65,
1.8,
hex(style.edge),
5,
);
}
const nearHill = (x: number, y: number, r: number) =>
hills.some((h) => Math.hypot(x - h.x, y - h.y) < r + h.r);
// Faceted turf, pools and clustered flowers break up the playset floor.
for (let i = 0; i < Math.min(130, 65 * scaleX * scaleY); i++) {
const x = (rand() - 0.5) * (hx * 2 - 24),
y = (rand() - 0.5) * (hy * 2 - 26),
r = 4 + rand() * 5,
p = road.nearest(x, y);
if (Math.hypot(x - p.x, y - p.y) < p.width / 2 + r + 2) continue;
if (nearBuilding(x, y, r) || nearHill(x, y, r)) continue;
m.disc(x, y, -0.36, r, r * 0.7, hex(style.patch));
if (i % 5 === 0) {
m.disc(x, y, -0.345, r * 0.92, r * 0.64, hex(style.shore), 12);
m.disc(x, y, -0.34, r * 0.8, r * 0.52, hex(style.water));
m.disc(x - r * 0.2, y, -0.32, r * 0.25, 0.12, hex('#c8faff'));
m.disc(x + r * 0.2, y + 0.7, -0.31, r * 0.15, 0.08, hex('#e8ffff'));
for (let j = 0; j < 3; j++) {
m.box(
x + r * 0.65,
y + (j - 1) * 0.4,
-0.3,
0.09,
0.09,
0.8 + j * 0.2,
hex('#348d77'),
);
m.box(
x + r * 0.65,
y + (j - 1) * 0.4,
0.5 + j * 0.2,
0.16,
0.16,
0.28,
hex('#e4a067'),
);
}
}
}
for (let i = 0; i < Math.min(330, 150 * scaleX * scaleY); i++) {
const x = (rand() - 0.5) * (hx * 2 - 14),
y = (rand() - 0.5) * (hy * 2 - 15),
p = road.nearest(x, y);
if (
Math.hypot(x - p.x, y - p.y) <
p.width / 2 + p.wallThickness / 2 + 3.5
)
continue;
if (nearBuilding(x, y, 3) || nearHill(x, y, 2)) continue;
const h = 2.5 + rand() * 4,
r = 1.15 + rand() * 1.35,
leaf = hex(style.leaves[i % style.leaves.length]);
m.disc(x + 0.6, y + 0.6, -0.35, r * 1.25, r, hex(style.edge), 9);
if (i % 7 === 0) {
m.cone(x, y, -0.2, r, 1.4, hex('#8eabd0'), 5);
m.cone(x + r, y + 0.4, -0.2, r * 0.6, 0.85, hex('#ced7e5'), 5);
continue;
}
if (i % 5 === 0) {
for (let j = 0; j < 3; j++) {
const fx = x + (j - 1) * 0.8,
fy = y + Math.sin(j * 3) * 0.6;
m.box(fx, fy, -0.3, 0.12, 0.12, 0.8, hex('#1cab89'));
m.disc(fx, fy, 0.55, 0.6, 0.6, hex(j % 2 ? '#ff678f' : '#fff3b6'), 5);
m.cone(fx, fy, 0.55, 0.24, 0.3, hex('#ffd13e'), 5);
}
continue;
}
m.box(x, y, -0.3, 0.5, 0.5, h * 0.5, hex('#bb7861'));
if (i % 3 === 0) {
m.cone(x, y, h * 0.65, r, h * 0.5, leaf, 7);
m.cone(x, y, h * 0.65, r, -h * 0.4, leaf, 7);
} else {
m.cone(x, y, h * 0.24, r, h * 0.7, leaf);
const tip = leaf.map((v) => Math.min(1, v * 1.12 + 0.06)) as V;
m.cone(x, y, h * 0.6, r * 0.67, h * 0.53, tip);
}
}
const gl = this.gl;
gl.bindBuffer(gl.ARRAY_BUFFER, this.staticBuffer);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array(m.vertices),
gl.STATIC_DRAW,
);
this.staticCount = m.vertices.length / 6;
}
camera(width: number, height: number, follow?: Body) {
const bounds = this.road?.bounds;
const desired: V = follow
? [follow.x + follow.vx * 0.45, follow.y + follow.vy * 0.45, follow.z]
: bounds
? [
(bounds.minX + bounds.maxX) / 2,
(bounds.minY + bounds.maxY) / 2,
bounds.maxZ / 2,
]
: [0, 0, 3];
this.target = this.target.map((v, i) =>
lerp(v, desired[i], follow ? 0.09 : 0.12),
) as V;
const ratio = width / height;
const fit = bounds
? Math.max(
(bounds.maxX - bounds.minX) * Math.abs(Math.cos(this.azimuth)) +
(bounds.maxY - bounds.minY) * Math.abs(Math.sin(this.azimuth)),
60,
) / ratio
: 120 / ratio;
const fitHeight = bounds
? ((bounds.maxX - bounds.minX) * Math.abs(Math.sin(this.azimuth)) +
(bounds.maxY - bounds.minY) * Math.abs(Math.cos(this.azimuth))) *
Math.sin(this.elevation) +
(bounds.maxZ + 8) * Math.cos(this.elevation)
: 108;
this.span = lerp(
this.span,
follow
? 48 / Math.min(Math.max(ratio, 1), 1.8)
: Math.max(108, fit * 1.18, fitHeight * 1.18),
0.055,
);
const span = this.span / this.zoom,
sy = span,
sx = sy * ratio;
const right: V = [Math.cos(this.azimuth), Math.sin(this.azimuth), 0];
const up: V = [
-Math.sin(this.azimuth) * Math.sin(this.elevation),
Math.cos(this.azimuth) * Math.sin(this.elevation),
Math.cos(this.elevation),
];
const depth = cross(right, up),
t = this.target;
this.matrix.set([
(right[0] * 2) / sx,
(up[0] * 2) / sy,
-depth[0] / 500,
0,
(right[1] * 2) / sx,
(up[1] * 2) / sy,
-depth[1] / 500,
0,
(right[2] * 2) / sx,
(up[2] * 2) / sy,
-depth[2] / 500,
0,
(-dot(right, t) * 2) / sx,
(-dot(up, t) * 2) / sy - (follow ? 0 : 0.08),
dot(depth, t) / 500,
1,
]);
}
render(bodies: Body[], follow?: Body, localId?: string) {
const gl = this.gl,
rect = this.canvas.getBoundingClientRect();
if (rect.width < 1 || rect.height < 1) return;
const dpr = Math.min(devicePixelRatio, 1.7),
w = Math.max(1, Math.round(rect.width * dpr)),
h = Math.max(1, Math.round(rect.height * dpr));
if (this.canvas.width !== w || this.canvas.height !== h) {
this.canvas.width = w;
this.canvas.height = h;
}
this.camera(rect.width, rect.height, follow);
this.canvas.dataset.renderReady = String(
Array.from(this.matrix).every(Number.isFinite),
);
// Parked garage/workshop previews are static once the camera settles. Keep
// updating the camera, but don't rebuild/upload/redraw identical geometry.
// Sub-pixel rounding avoids asymptotic camera easing triggering endless draws.
const view = [
w,
h,
localId,
...Array.from(this.matrix, (v) => v.toFixed(5)),
...bodies.flatMap((b) => [
b.id,
b.car,
b.x.toFixed(4),
b.y.toFixed(4),
b.z.toFixed(4),
b.angle.toFixed(4),
b.pitch.toFixed(4),
b.roll.toFixed(4),
b.steering.toFixed(4),
Math.floor(b.respawn * 9),
]),
].join(',');
if (view === this.lastView) return;
this.lastView = view;
gl.viewport(0, 0, w, h);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.uniformMatrix4fv(
gl.getUniformLocation(this.program, 'camera'),
false,
this.matrix,
);
const draw = (buffer: WebGLBuffer, count: number) => {
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
const p = gl.getAttribLocation(this.program, 'position'),
c = gl.getAttribLocation(this.program, 'color');
gl.enableVertexAttribArray(p);
gl.vertexAttribPointer(p, 3, gl.FLOAT, false, 24, 0);
gl.enableVertexAttribArray(c);
gl.vertexAttribPointer(c, 3, gl.FLOAT, false, 24, 12);
gl.drawArrays(gl.TRIANGLES, 0, count);
};
draw(this.staticBuffer, this.staticCount);
const m = new Mesh();
// Trail sampling is distance-based and bounded, independent of render rate.
for (const b of bodies) {
const drift =
b.grounded &&
b.respawn === 0 &&
Math.hypot(b.vx, b.vy) > 5 &&
Math.abs(slipAngle(b)) > 0.16;
const last = this.lastSkid.get(b.id);
if (!drift) {
this.lastSkid.delete(b.id);
continue;
}
const moved = last ? Math.hypot(b.x - last.x, b.y - last.y) : Infinity;
if (moved < 0.35) continue;
const points: V[] = [-1, 1].map((side) => {
const x =
b.x - Math.cos(b.angle) * 0.83 - Math.sin(b.angle) * side * 0.7;
const y =
b.y - Math.sin(b.angle) * 0.83 + Math.cos(b.angle) * side * 0.7;
const p = this.road!.nearest(x, y, b.z - 0.65);
return [x, y, p.z + 0.065];
});
if (last && moved < 2)
for (let i = 0; i < 2; i++)
this.skidMarks.push({ a: last.points[i], b: points[i] });
this.lastSkid.set(b.id, { x: b.x, y: b.y, points });
}
if (this.skidMarks.length > 800)
this.skidMarks.splice(0, this.skidMarks.length - 800);
for (const { a, b } of this.skidMarks) {
const length = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
const x = (-(b[1] - a[1]) / length) * 0.09,
y = ((b[0] - a[0]) / length) * 0.09;
m.quad(
[a[0] - x, a[1] - y, a[2]],
[b[0] - x, b[1] - y, b[2]],
[b[0] + x, b[1] + y, b[2]],
[a[0] + x, a[1] + y, a[2]],
hex('#202d49'),
true,
);
}
bodies.forEach((b) => {
const color = hex(cars[b.car].color),
angle = b.angle;
if (b.respawn > 0 && Math.floor(b.respawn * 9) % 2) return;
const p = this.road?.nearest(b.x, b.y, b.z - 0.65),
z = b.z;
if (p && Math.abs(p.offset) < p.width / 2) {
const s = 0.9 + Math.max(0, z - p.z) * 0.08;
const shadow = (x: number, y: number): V => [
b.x + x,
b.y + y,
p.z + 0.045 + p.slope * (p.tx * x + p.ty * y),
];
m.quad(
shadow(-s, -s),
shadow(s, -s),
shadow(s, s),
shadow(-s, s),
hex('#2c3e40'),
);
}
// Wheels, colored body, windscreen, cabin and headlamps are all true 3D geometry.
const carStart = m.vertices.length;
for (const side of [-1, 1])
for (const axle of [-0.83, 0.83]) {
const x =
b.x + Math.cos(angle) * axle - Math.sin(angle) * side * 0.75,
y = b.y + Math.sin(angle) * axle + Math.cos(angle) * side * 0.75;
const wheelAngle = angle + (axle > 0 ? b.steering : 0);
m.box(x, y, z - 0.55, 0.38, 0.64, 0.5, hex('#17254d'), wheelAngle);
m.box(
x - Math.sin(wheelAngle) * side * 0.2,
y + Math.cos(wheelAngle) * side * 0.2,
z - 0.42,
0.035,
0.29,
0.24,
hex('#e3eeff'),
wheelAngle,
);
}
m.box(b.x, b.y, z - 0.32, 1.42, 2.55, 0.48, color, angle);
m.box(
b.x - Math.cos(angle) * 0.24,
b.y - Math.sin(angle) * 0.24,
z + 0.16,
1.2,
1.3,
0.49,
hex('#264b79'),
angle,
);
m.box(
b.x - Math.cos(angle) * 0.42,
b.y - Math.sin(angle) * 0.42,
z + 0.64,
1.22,
0.82,
0.08,
color,
angle,
);
m.box(
b.x + Math.cos(angle) * 1.2,
b.y + Math.sin(angle) * 1.2,
z - 0.02,
1.05,
0.12,
0.16,
hex('#fff2c4'),
angle,
);
if (b.car === 3)
m.box(
b.x - Math.cos(angle) * 1.12,
b.y - Math.sin(angle) * 1.12,
z + 0.32,
1.6,
0.26,
0.12,
color,
angle,
);
const livery = hex(
b.car === 1 ? '#17254d' : b.car === 3 ? '#ff5068' : '#fff8e7',
);
for (const lateral of b.car === 1 || b.car === 3 ? [-0.27, 0.27] : [0]) {
m.box(
b.x + Math.cos(angle) * 0.76 - Math.sin(angle) * lateral,
b.y + Math.sin(angle) * 0.76 + Math.cos(angle) * lateral,
z + 0.165,
0.2,
0.9,
0.015,
livery,
angle,
);
m.box(
b.x - Math.cos(angle) * 0.42 - Math.sin(angle) * lateral,
b.y - Math.sin(angle) * 0.42 + Math.cos(angle) * lateral,
z + 0.728,
0.2,
0.82,
0.016,
livery,
angle,
);
}
m.box(
b.x - Math.cos(angle) * 1.23,
b.y - Math.sin(angle) * 1.23,
z - 0.18,
1.1,
0.14,
0.18,
hex(b.car === 2 ? '#ffda45' : '#17254d'),
angle,
);
if (b.car === 2)
m.box(
b.x - Math.cos(angle) * 0.45,
b.y - Math.sin(angle) * 0.45,
z + 0.78,
0.8,
0.5,
0.12,
hex('#ffda45'),
angle,
);
// Tilt the whole chassis with the suspension and lateral weight transfer.
const cos = Math.cos(angle),
sin = Math.sin(angle);
for (let i = carStart; i < m.vertices.length; i += 6) {
const dx = m.vertices[i] - b.x,
dy = m.vertices[i + 1] - b.y;
const forward = dx * cos + dy * sin,
lateral = -dx * sin + dy * cos;
const height = m.vertices[i + 2] - z;
const leaned = lateral * Math.cos(b.roll) - height * Math.sin(b.roll);
const raised = lateral * Math.sin(b.roll) + height * Math.cos(b.roll);
const pitched =
forward * Math.cos(b.pitch) - raised * Math.sin(b.pitch);
m.vertices[i] = b.x + pitched * cos - leaned * sin;
m.vertices[i + 1] = b.y + pitched * sin + leaned * cos;
m.vertices[i + 2] =
z + forward * Math.sin(b.pitch) + raised * Math.cos(b.pitch);
}
if (b.id === localId) {
m.cone(b.x, b.y, z + 2.5, 0.5, 0.8, hex('#ffda45'), 3);
}
});
gl.bindBuffer(gl.ARRAY_BUFFER, this.dynamicBuffer);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array(m.vertices),
gl.DYNAMIC_DRAW,
);
draw(this.dynamicBuffer, m.vertices.length / 6);
}
project(p: Point): [number, number] {
const m = this.matrix,
r = this.canvas.getBoundingClientRect();
return [
((m[0] * p.x + m[4] * p.y + m[8] * p.z + m[12] + 1) * r.width) / 2,
((1 - (m[1] * p.x + m[5] * p.y + m[9] * p.z + m[13])) * r.height) / 2,
];
}
dispose() {
this.gl.deleteBuffer(this.staticBuffer);
this.gl.deleteBuffer(this.dynamicBuffer);
this.gl.deleteProgram(this.program);
}
}
