Back to Napplet Machines V8
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.

src/render.ts
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);
  }
}