Examples

Physics

A wall of crates, a ramp of rolling shapes, and a cannon scripted in DriftScript.

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

Open on its own · Source

Rigid bodies is the chapter that walks through it.

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

Source

main.ts

examples/physics/main.ts
/**
 * A wall of crates, a ramp with wheels, capsules, balls and a rock rolling down it, and a cannon
 * firing at the wall every two and a half seconds.
 *
 * Every body is a rigid body in one `PhysicsWorld`, stepped on the fixed clock. The cannon is a
 * DriftScript module that moves and fires one pooled ball through `drift/physics`, choosing its
 * aim from the shot's number, so the same ticks fire the same shots. The readout's fingerprint is
 * the world's state as one number: a replay of the same ticks reaches the same one.
 *
 * The wall is stacked again every fifteen seconds. The switches are the world's options, so
 * changing one builds the world again from the start.
 */
import { MeshBuilder, computeLightMatrix, createEnvironment } from '@driftengine/core';
import type { MeshData, MeshHandle, Vec3 } from '@driftengine/core';
import {
  BODY_DYNAMIC,
  BODY_STATIC,
  PhysicsWorld,
  boxShape,
  capsuleShape,
  cylinderShape,
  fingerprintBodies,
  hullShape,
  sphereShape,
} from '@driftengine/physics';
import type { FrictionModel } 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 cannonScript from './cannon.drs';

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

/** Each body, the mesh it is drawn with. */
const drawnAs: MeshHandle[] = [];
const mesh = (builder: MeshBuilder): MeshHandle => renderer.createMesh(builder.build());
const CRATE = mesh(new MeshBuilder().addBox([0, 0, 0], [0.5, 0.5, 0.5], [0.72, 0.55, 0.36]));
const WHEEL = mesh(
  new MeshBuilder().addCylinder([0, 0, 0], 0.6, 0.2, 'y', [0.2, 0.2, 0.22], 0, 24),
);
const PILL = mesh(new MeshBuilder().addCapsule([0, 0, 0], 0.3, 0.4, [0.3, 0.55, 0.85]));
const BALL = mesh(new MeshBuilder().addSphere([0, 0, 0], 0.45, [0.85, 0.3, 0.25], 0, 20, 10));
const SHOT = mesh(new MeshBuilder().addSphere([0, 0, 0], 0.35, [0.15, 0.15, 0.16], 0, 20, 10));
/** A rock: four points, and the hull through them, drawn face by face from the same points. */
const ROCK_POINTS = [0.6, -0.25, 0, -0.4, -0.25, 0.5, -0.3, -0.25, -0.5, 0, 0.45, 0];
const ROCK = renderer.createMesh(tetrahedron(ROCK_POINTS, [0.5, 0.48, 0.45]));

function tetrahedron(points: readonly number[], colour: Vec3): MeshData {
  const corner = (i: number): Vec3 => [
    points[i * 3] ?? 0,
    points[i * 3 + 1] ?? 0,
    points[i * 3 + 2] ?? 0,
  ];
  const faces = [
    [0, 2, 1],
    [0, 1, 3],
    [1, 2, 3],
    [2, 0, 3],
  ];
  const positions = new Float32Array(36);
  const normals = new Float32Array(36);
  faces.forEach((face, f) => {
    const [a, b, c] = face.map(corner) as [Vec3, Vec3, Vec3];
    const u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
    const v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
    const n = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]];
    const length = Math.hypot(n[0] ?? 0, n[1] ?? 0, n[2] ?? 0);
    [a, b, c].forEach((p, k) => {
      positions.set(p, (f * 3 + k) * 3);
      normals.set(
        n.map((x) => x / length),
        (f * 3 + k) * 3,
      );
    });
  });
  return {
    positions,
    normals,
    colors: new Float32Array(36).map((_, i) => colour[i % 3] ?? 1),
    emissive: new Float32Array(12),
    indices: Uint32Array.from({ length: 12 }, (_, i) => i),
  };
}

// #region world
/** The ground, a ramp, a wall of crates and things to roll down the ramp. */
let world = new PhysicsWorld();
let ball = 0;

function build(friction: FrictionModel, substeps: number): void {
  world = new PhysicsWorld({ frictionModel: friction, substeps });
  drawnAs.length = 0;
  world.addBody({ type: BODY_STATIC, shape: boxShape(30, 0.5, 30), y: -0.5, friction: 0.8 });
  /* A ramp turned twenty degrees about z, falling toward the wall. */
  const half = (20 * Math.PI) / 360;
  world.addBody({
    type: BODY_STATIC,
    shape: boxShape(5, 0.25, 2.5),
    x: 9,
    y: 1.6,
    qz: Math.sin(half),
    qw: Math.cos(half),
  });

  const body = (desc: Parameters<PhysicsWorld['addBody']>[0], drawn: MeshHandle): number => {
    const index = world.addBody(desc);
    drawnAs[index] = drawn;
    return index;
  };
  /* The wall: six crates along the bottom, one fewer each row up. */
  for (let row = 0; row < 6; row += 1) {
    for (let i = 0; i < 6 - row; i += 1) {
      body(
        {
          type: BODY_DYNAMIC,
          shape: boxShape(0.5, 0.5, 0.5),
          x: 0,
          y: 0.5 + row,
          z: i - (5 - row) / 2,
          friction: 0.6,
          density: 120,
        },
        CRATE,
      );
    }
  }
  /* Down the ramp: wheels standing on their rims, capsules lying across it, balls and a rock. */
  const quarter = Math.SQRT1_2;
  for (let i = 0; i < 3; i += 1) {
    body(
      {
        type: BODY_DYNAMIC,
        shape: cylinderShape(0.6, 0.2),
        x: 12,
        y: 4.2,
        z: i * 1.4 - 1.4,
        qx: quarter,
        qw: quarter,
      },
      WHEEL,
    );
  }
  body(
    {
      type: BODY_DYNAMIC,
      shape: capsuleShape(0.3, 0.4),
      x: 11,
      y: 4.2,
      z: 2,
      qx: quarter,
      qw: quarter,
    },
    PILL,
  );
  body(
    { type: BODY_DYNAMIC, shape: sphereShape(0.45), x: 13, y: 5, z: 0.6, restitution: 0.3 },
    BALL,
  );
  body({ type: BODY_DYNAMIC, shape: hullShape(ROCK_POINTS), x: 10.5, y: 4.4, z: -2 }, ROCK);
  /* The cannon's ball, parked out of the way until the script fires it. */
  ball = body(
    { type: BODY_DYNAMIC, shape: sphereShape(0.35), x: -14, y: 0.35, z: 0, density: 7800 },
    SHOT,
  );
}
// #endregion

let friction: FrictionModel = flag('friction', 'box') === 'elliptical' ? 'elliptical' : 'box';
let substeps = Number(flag('substeps', '4'));
build(friction, substeps);
let tick = 0;

controls([
  {
    key: 'friction',
    label: 'friction',
    value: friction,
    options: ['box', 'elliptical'].map((f) => ({ text: f, value: f })),
    change: (value) => {
      friction = value === 'elliptical' ? 'elliptical' : 'box';
      build(friction, substeps);
      tick = 0;
    },
  },
  {
    key: 'substeps',
    label: 'substeps',
    value: String(substeps),
    options: ['1', '4', '8'].map((n) => ({ text: n, value: n })),
    change: (value) => {
      substeps = Number(value);
      build(friction, substeps);
      tick = 0;
    },
  },
]);

// #region script
/** The cannon, hosted, and called once a tick with the world it fires into. */
const cannon = hostScript(cannonScript);
type Fire = (world: PhysicsWorld, ball: number, tick: number) => boolean;
if (import.meta.hot) {
  import.meta.hot.accept('./cannon.drs', (next) => {
    if (next !== undefined) patchModule(cannon, next as Record<string, unknown>, {});
  });
}
// #endregion

// #region draw
/** A body's model matrix, from its position and the quaternion the world integrates. */
const model = new Float32Array(16);
function placed(body: number): Float32Array {
  const b = world.bodies;
  const x = b.rotX[body] ?? 0;
  const y = b.rotY[body] ?? 0;
  const z = b.rotZ[body] ?? 0;
  const w = b.rotW[body] ?? 1;
  model[0] = 1 - 2 * (y * y + z * z);
  model[1] = 2 * (x * y + z * w);
  model[2] = 2 * (x * z - y * w);
  model[4] = 2 * (x * y - z * w);
  model[5] = 1 - 2 * (x * x + z * z);
  model[6] = 2 * (y * z + x * w);
  model[8] = 2 * (x * z + y * w);
  model[9] = 2 * (y * z - x * w);
  model[10] = 1 - 2 * (x * x + y * y);
  model[12] = b.posX[body] ?? 0;
  model[13] = b.posY[body] ?? 0;
  model[14] = b.posZ[body] ?? 0;
  model[15] = 1;
  return model;
}
// #endregion

const ground = mesh(new MeshBuilder().addBox([0, -0.5, 0], [30, 0.5, 30], [0.36, 0.4, 0.32]));
const ramp = mesh(new MeshBuilder().addBox([0, 0, 0], [5, 0.25, 2.5], [0.55, 0.52, 0.48]));
const rampModel = new Float32Array(16);
const angle = (20 * Math.PI) / 180;
rampModel.set([
  Math.cos(angle),
  Math.sin(angle),
  0,
  0,
  -Math.sin(angle),
  Math.cos(angle),
  0,
  0,
  0,
  0,
  1,
  0,
  9,
  1.6,
  0,
  1,
]);
const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);

const env = createEnvironment({
  directionalDir: [-0.35, 0.75, 0.55],
  directionalColor: [1.8, 1.7, 1.55],
  ambient: [0.32, 0.35, 0.42],
  ambientGround: [0.14, 0.13, 0.12],
});
const lightMatrix = new Float32Array(16);
env.lightViewProj = lightMatrix;
env.shadowStrength = 0.8;
const readout = createReadout(renderer, 2);
const SKY: Vec3 = [0.58, 0.64, 0.72];
let time = 0;

stage.run({
  simulate(dt) {
    time += dt;
    tick += 1;
    /* Every fifteen seconds the wall is stacked again. */
    if (tick % 900 === 0) build(friction, substeps);
    exported<Fire>(cannon, 'fire')(world, ball, tick);
    world.step(dt);
  },
  render() {
    camera.fovYDeg = 50;
    camera.position[0] = -13 + Math.sin(time * 0.1) * 3;
    camera.position[1] = 6;
    camera.position[2] = 11;
    camera.lookAt(2, 1.5, 0);
    env.shadowDepthSpan = computeShadow();
    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));
    let awake = 0;
    for (let i = 0; i < world.bodies.count; i += 1) if (!world.sleeping(i)) awake += 1;
    readout.set(0, `TICK ${tick}  ${awake} OF ${world.bodies.count} BODIES AWAKE`);
    readout.set(1, `FINGERPRINT ${fingerprintBodies(world.bodies).slice(0, 12)}`);
    readout.draw(time);
    renderer.endFrame();
  },
});

function drawAll(draw: (mesh: MeshHandle, matrix: Float32Array) => void): void {
  draw(ground, IDENTITY);
  draw(ramp, rampModel);
  for (let body = 0; body < drawnAs.length; body += 1) {
    const drawn = drawnAs[body];
    if (drawn !== undefined) draw(drawn, placed(body));
  }
}

function computeShadow(): number {
  return computeLightMatrix(env.directionalDir, 2, 1, 0, 18, renderer.shadowMapSize, lightMatrix);
}

cannon.drs

examples/physics/cannon.drs
// The cannon: every two and a half seconds it puts the ball back at the muzzle and fires it at the
// wall, a little differently each shot. Which shot it is decides the aim, so a replay fires the
// same shots in the same order.
//
// Under `npm run examples`, change the rules and save: the next shot follows them. Try firing
// harder, or lobbing the ball over the wall.

import { setPosition, setVelocity, wake } from "drift/physics"
import { range } from "drift/random"

// #region fire
// Called every tick with the tick's number. Answers whether it fired on this one.
fn fire(world: PhysicsWorld, ball: i32, tick: u32) -> bool {
    if tick % 150 != 30 {
        return false
    }
    let shot = tick / 150
    physics.setPosition(world, ball, -14, 1.2, random.range(shot, -1.5, 1.5))
    physics.setVelocity(world, ball, 30, random.range(shot + 1, 1, 4), random.range(shot + 2, -0.6, 0.6))
    physics.wake(world, ball)
    return true
}
// #endregion