Examples

Vehicles

A raycast car on a ring of cones, driven by a DriftScript driver or by you, on tarmac or ice.

Starts examples/vehicle in this page, on WebGPU where your browser has it.

Open on its own · Source

Vehicles is the chapter that walks through it.

From a checkout of the engine, npm run examples serves it at /vehicle/.

Source

main.ts

examples/vehicle/main.ts
/**
 * A car on a ring of cones, with a ramp to jump. The car is a raycast vehicle: a chassis body and
 * four wheels that are rays, each on a spring, gripping by a tyre curve the page supplies.
 *
 * The driver is a DriftScript module: the keys or a stick through `drift/input`, or a lap of the
 * ring the car drives by itself until a key is pressed. W and S or the arrows drive, A and D steer,
 * Space brakes. Switch the tyres to ice to drive on a curve with a fifth of the grip.
 */
import {
  ActionMap,
  InputSource,
  MeshBuilder,
  computeLightMatrix,
  createEnvironment,
} from '@driftengine/core';
import type { MeshHandle, Vec3 } from '@driftengine/core';
import {
  BODY_DYNAMIC,
  BODY_STATIC,
  PhysicsWorld,
  Vehicle,
  boxShape,
  cylinderShape,
  defaultTyreCurve,
} from '@driftengine/physics';
import type { VehicleOptions } from '@driftengine/physics';
import { patchModule } from 'driftscript';
import { createReadout } from '../common/readout';
import { exported, hostScript } from '../common/script';
import { controls, flag, openStage } from '../common/stage';
import * as driveScript from './drive.drs';

const stage = await openStage({
  directionalShadows: true,
  outputTransform: 'aces',
  sceneSamples: 4,
});
const { renderer, camera, canvas } = stage;

// #region world
/** Flat ground, a ring of cones the car laps inside, and a ramp across the ring. */
const world = new PhysicsWorld({ substeps: 4 });
world.addBody({ type: BODY_STATIC, shape: boxShape(60, 0.5, 60), y: -0.5, friction: 0.9 });
const cones: [number, number][] = [];
for (let i = 0; i < 36; i += 1) {
  const a = (i / 36) * Math.PI * 2;
  for (const r of [14, 22]) cones.push([Math.cos(a) * r, Math.sin(a) * r]);
}
for (const [x, z] of cones) {
  world.addBody({ type: BODY_STATIC, shape: cylinderShape(0.2, 0.35), x, y: 0.35, z });
}
/* Rising twelve degrees toward +z, the way the lap runs past it, so the car jumps off its end. */
const rampTilt = (12 * Math.PI) / 360;
world.addBody({
  type: BODY_STATIC,
  shape: boxShape(3, 0.3, 2.5),
  x: 18,
  y: 0.25,
  z: 0,
  qx: -Math.sin(rampTilt),
  qw: Math.cos(rampTilt),
});
// #endregion

// #region car
/**
 * The chassis, a box of about 1,200 kilograms, and four wheels: the front ones steer, all drive.
 *
 * It starts on the line through the middle of the ramp, facing it, so the first press of W goes
 * straight up and off the end. Seven metres short of the ramp the chassis clears the outer ring of
 * cones by almost a metre; started at twelve, it overlapped one and came to rest propped on it with
 * two wheels in the air, and no key moved it.
 */
const chassis = world.addBody({
  type: BODY_DYNAMIC,
  shape: boxShape(0.9, 0.35, 2),
  x: 18,
  y: 1.2,
  z: -7,
  density: 240,
});
const WHEELS = [
  { x: -0.85, y: -0.2, z: 1.3, steers: true, driven: true },
  { x: 0.85, y: -0.2, z: 1.3, steers: true, driven: true },
  { x: -0.85, y: -0.2, z: -1.3, driven: true },
  { x: 0.85, y: -0.2, z: -1.3, driven: true },
];
const TYRES: Record<string, Pick<VehicleOptions, 'longitudinal' | 'lateral'>> = {
  tarmac: { longitudinal: defaultTyreCurve(1.2), lateral: defaultTyreCurve(1.4) },
  ice: { longitudinal: defaultTyreCurve(0.25), lateral: defaultTyreCurve(0.3) },
};
let tyres = flag('tyres', 'tarmac');
let car = new Vehicle(chassis, { wheels: WHEELS, ...TYRES[tyres] });
// #endregion

const input = new InputSource(canvas, ['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']);
const actions = new ActionMap(input, {
  move: {
    stick: 'left',
    up: ['KeyW', 'ArrowUp'],
    down: ['KeyS', 'ArrowDown'],
    left: ['KeyA', 'ArrowLeft'],
    right: ['KeyD', 'ArrowRight'],
  },
  brake: { keys: ['Space'], buttons: ['faceDown'] },
});

// #region script
/** The driver, hosted, and the pedals it sets each tick. */
const driver = hostScript(driveScript);
interface Pedals {
  throttle: number;
  brake: number;
  steer: number;
}
const pedals = exported<() => Pedals>(driver, 'createPedals')();
type Drive = (pedals: Pedals, actions: ActionMap) => void;
type Lap = (pedals: Pedals, x: number, z: number, heading: number, radius: number) => void;
if (import.meta.hot) {
  import.meta.hot.accept('./drive.drs', (next) => {
    if (next !== undefined)
      patchModule(driver, next as Record<string, unknown>, { Pedals: [pedals] });
  });
}
// #endregion

controls([
  {
    key: 'tyres',
    label: 'tyres',
    value: tyres,
    options: ['tarmac', 'ice'].map((t) => ({ text: t, value: t })),
    change: (value) => {
      tyres = value;
      /* The curves are the vehicle's options: a new vehicle takes over the same chassis. */
      car = new Vehicle(chassis, { wheels: WHEELS, ...TYRES[tyres] });
    },
  },
]);

/** The chassis's matrix, from its body, and the way it points as a heading about y. */
const model = new Float32Array(16);
function chassisMatrix(): Float32Array {
  const b = world.bodies;
  const x = b.rotX[chassis] ?? 0;
  const y = b.rotY[chassis] ?? 0;
  const z = b.rotZ[chassis] ?? 0;
  const w = b.rotW[chassis] ?? 1;
  model.set([
    1 - 2 * (y * y + z * z),
    2 * (x * y + z * w),
    2 * (x * z - y * w),
    0,
    2 * (x * y - z * w),
    1 - 2 * (x * x + z * z),
    2 * (y * z + x * w),
    0,
    2 * (x * z + y * w),
    2 * (y * z - x * w),
    1 - 2 * (x * x + y * y),
    0,
    b.posX[chassis] ?? 0,
    b.posY[chassis] ?? 0,
    b.posZ[chassis] ?? 0,
    1,
  ]);
  return model;
}
const heading = (): number => Math.atan2(model[8] ?? 0, model[10] ?? 1);

let playing = false;
let time = 0;
stage.run({
  simulate(dt) {
    time += dt;
    chassisMatrix();
    if (actions.down('brake') || actions.axis('move', 'x') !== 0 || actions.axis('move', 'y') !== 0)
      playing = true;
    if (playing) exported<Drive>(driver, 'drive')(pedals, actions);
    else
      exported<Lap>(driver, 'lap')(
        pedals,
        world.bodies.posX[chassis] ?? 0,
        world.bodies.posZ[chassis] ?? 0,
        heading(),
        18,
      );
    car.update(world, dt, pedals);
    world.step(dt);
  },
  render: drawFrame,
});

const ground = renderer.createMesh(
  new MeshBuilder().addBox([0, -0.5, 0], [60, 0.5, 60], [0.33, 0.36, 0.34]).build(),
);
const cone = renderer.createMesh(
  cones
    .reduce(
      (b, [x, z]) => b.addCylinder([x, 0.35, z], 0.2, 0.35, 'y', [1, 0.5, 0.15], 0, 8),
      new MeshBuilder(),
    )
    .build(),
);
const ramp = renderer.createMesh(
  new MeshBuilder().addBox([0, 0, 0], [3, 0.3, 2.5], [0.6, 0.58, 0.54]).build(),
);
const rampModel = Float32Array.of(
  ...[1, 0, 0, 0],
  ...[0, Math.cos(rampTilt * 2), -Math.sin(rampTilt * 2), 0],
  ...[0, Math.sin(rampTilt * 2), Math.cos(rampTilt * 2), 0],
  ...[18, 0.25, 0, 1],
);
const body = renderer.createMesh(
  new MeshBuilder()
    .addBox([0, 0, 0], [0.9, 0.35, 2], [0.85, 0.2, 0.18])
    .addBox([0, 0.5, -0.3], [0.75, 0.25, 0.9], [0.2, 0.25, 0.3])
    .build(),
);
const wheel = renderer.createMesh(
  new MeshBuilder().addCylinder([0, 0, 0], 0.35, 0.15, 'x', [0.12, 0.12, 0.13], 0, 16).build(),
);
const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
const wheelModel = new Float32Array(16);

/** A wheel hangs below its anchor by the travel it has left, in the chassis's own frame. */
function wheelMatrix(i: number): Float32Array {
  const w = WHEELS[i];
  const m = model;
  const lx = w?.x ?? 0;
  const ly = (w?.y ?? 0) - (0.3 - (car.compression[i] ?? 0));
  const lz = w?.z ?? 0;
  const steer = w?.steers === true ? Math.atan(pedals.steer * 0.6) : 0;
  const c = Math.cos(steer);
  const s = Math.sin(steer);
  /* The chassis's axes, the wheel turned about the chassis's up by its steering. */
  for (let k = 0; k < 3; k += 1) {
    wheelModel[k] = (m[k] ?? 0) * c - (m[8 + k] ?? 0) * s;
    wheelModel[4 + k] = m[4 + k] ?? 0;
    wheelModel[8 + k] = (m[k] ?? 0) * s + (m[8 + k] ?? 0) * c;
    wheelModel[12 + k] =
      (m[12 + k] ?? 0) + (m[k] ?? 0) * lx + (m[4 + k] ?? 0) * ly + (m[8 + k] ?? 0) * lz;
  }
  wheelModel[15] = 1;
  return wheelModel;
}

const env = createEnvironment({
  directionalDir: [-0.4, 0.75, 0.5],
  directionalColor: [1.8, 1.7, 1.5],
  ambient: [0.32, 0.35, 0.42],
  ambientGround: [0.13, 0.12, 0.11],
  fogColor: [0.62, 0.68, 0.76],
  fogDensity: 0.008,
});
const lightMatrix = new Float32Array(16);
env.lightViewProj = lightMatrix;
env.shadowStrength = 0.8;
const readout = createReadout(renderer, 1);
const SKY: Vec3 = [0.62, 0.68, 0.76];
const chase = { x: 18, y: 4, z: -16 };

function drawFrame(): void {
  chassisMatrix();
  const cx = world.bodies.posX[chassis] ?? 0;
  const cy = world.bodies.posY[chassis] ?? 0;
  const cz = world.bodies.posZ[chassis] ?? 0;
  /* Behind the car along its heading, eased so the camera trails through a turn. */
  const h = heading();
  chase.x += (cx - Math.sin(h) * 9 - chase.x) * 0.06;
  chase.y += (cy + 3.5 - chase.y) * 0.06;
  chase.z += (cz - Math.cos(h) * 9 - chase.z) * 0.06;
  camera.fovYDeg = 55;
  camera.far = 300;
  camera.position[0] = chase.x;
  camera.position[1] = chase.y;
  camera.position[2] = chase.z;
  camera.lookAt(cx, cy + 0.8, cz);

  const drawAll = (draw: (mesh: MeshHandle, matrix: Float32Array) => void): void => {
    draw(ground, IDENTITY);
    draw(cone, IDENTITY);
    draw(ramp, rampModel);
    draw(body, chassisMatrix());
    for (let i = 0; i < WHEELS.length; i += 1) draw(wheel, wheelMatrix(i));
  };
  env.shadowDepthSpan = computeLightMatrix(
    env.directionalDir,
    cx,
    cy,
    cz,
    16,
    renderer.shadowMapSize,
    lightMatrix,
  );
  renderer.beginShadowPass(lightMatrix, 'static');
  renderer.drawShadowCasters((sink) => drawAll((mesh, matrix) => sink.mesh(mesh, matrix)));
  renderer.endShadowPass();
  renderer.beginFrame(SKY);
  renderer.bindMeshPass(camera, env);
  drawAll((mesh, matrix) => renderer.drawMesh(mesh, matrix));
  const vx = world.bodies.velX[chassis] ?? 0;
  const vz = world.bodies.velZ[chassis] ?? 0;
  const onGround = car.grounded.reduce((sum, g) => sum + g, 0);
  readout.set(
    0,
    `${Math.round(Math.hypot(vx, vz) * 3.6)} KM/H  ${onGround} WHEELS DOWN  ${playing ? 'YOURS' : 'DRIVING ITSELF'}`,
  );
  readout.draw(time);
  renderer.endFrame();
}

drive.drs

examples/vehicle/drive.drs
// The driver: the player's keys or stick as throttle, brake and steering, and the lap the car
// drives itself until somebody takes over.
//
// Under `npm run examples`, change a rule and save: the car drives by it from the next tick. Try a
// gentler throttle, or a driver who brakes into every corner.

import { axisX, axisY, down } from "drift/input"
import { atan2, clamp, cos, sin } from "std/math"

data Pedals {
    // Throttle from -1 for reverse to 1, brake from 0 to 1, steering from -1 to 1.
    throttle: f32 = 0
    brake: f32 = 0
    steer: f32 = 0
}

// #region drive
// Forward on the stick is throttle, back is reverse, sideways is steering, and Space brakes.
fn drive(pedals: mut Pedals, actions: Actions) {
    pedals.throttle = 0 - input.axisY(actions, "move")
    pedals.steer = 0 - input.axisX(actions, "move")
    if input.down(actions, "brake") {
        pedals.brake = 1
    } else {
        pedals.brake = 0
    }
}
// #endregion

// #region lap
// Steer toward a point ahead on a circle, at a steady throttle: the car laps by itself.
fn lap(pedals: mut Pedals, x: f32, z: f32, heading: f32, radius: f32) {
    // The point a third of a radian further round the circle than the car is now.
    let around = math.atan2(z, x) + 0.35
    let tx = math.cos(around) * radius - x
    let tz = math.sin(around) * radius - z
    // The turn from where the car points to the target, wrapped to half a turn either way.
    let turn = math.atan2(tx, tz) - heading
    let wrapped = math.atan2(math.sin(turn), math.cos(turn))
    pedals.steer = math.clamp(wrapped * 2, -1, 1)
    pedals.throttle = 0.6
    pedals.brake = 0
}
// #endregion