Examples

Navigation

Villagers walking lanes in DriftScript, and dogs crossing a square on a mesh built from it.

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

Open on its own · Source

Navigation is the chapter that walks through it.

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

Source

main.ts

examples/navigation/main.ts
/**
 * A town square: villagers walk the lanes between eight doors, and three dogs run where they like
 * across the open ground, round the fountain and the market stalls.
 *
 * Two kinds of navigation, for two shapes of world. The lanes are a graph, a network of places and
 * the ways between them, and the villagers' walking is a DriftScript module that routes and steers
 * through `drift/navigation`. The open ground is a navigation mesh built from the square's own
 * geometry, and the dogs path across it in TypeScript. Put the stalls out and the mesh is rebuilt
 * round them while everyone keeps walking.
 */
import {
  MeshBuilder,
  NavPath,
  NavSearch,
  buildNavGraph,
  computeLightMatrix,
  createEnvironment,
  createLineSegments,
  hashToUnit,
} from '@driftengine/core';
import type { MeshData, MeshHandle, NavEdge, NavGraph, Vec3 } from '@driftengine/core';
import {
  NavMeshQuery,
  buildContours,
  buildPolyMesh,
  buildRegions,
  polyVertexCount,
  voxeliseWalkable,
} from '@driftengine/nav';
import type { PolyMesh } from '@driftengine/nav';
import { patchModule } from 'driftscript';
import { createReadout } from '../common/readout';
import { exported, hostScript } from '../common/script';
import { controls, flag, openStage } from '../common/stage';
import * as walkScript from './walk.drs';

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

// #region square
/** Four buildings round a cross of streets, a fountain in the middle, and four market stalls. */
const BUILDINGS: [number, number][] = [
  [-13.5, -13.5],
  [13.5, -13.5],
  [-13.5, 13.5],
  [13.5, 13.5],
];
const STALLS: [number, number][] = [
  [5, -3.5],
  [-4.5, 5],
  [3.5, 5.5],
  [-5.5, -4],
];

/** The square as triangles: what is drawn, and what the navigation mesh is built from. */
function square(stalls: boolean): MeshData {
  const builder = new MeshBuilder().addBox([0, -0.25, 0], [20, 0.25, 20], [0.55, 0.52, 0.47]);
  for (const [x, z] of BUILDINGS) builder.addBox([x, 2.5, z], [6.5, 2.5, 6.5], [0.78, 0.66, 0.52]);
  builder.addCylinder([0, 0.4, 0], 2.5, 0.4, 'y', [0.62, 0.62, 0.66], 0, 24);
  if (stalls) {
    for (const [x, z] of STALLS) builder.addBox([x, 0.6, z], [1.2, 0.6, 0.8], [0.55, 0.38, 0.24]);
  }
  return builder.build();
}
// #endregion

// #region mesh
/** Geometry in, convex walkable polygons out, sized for a dog: a metre tall and 0.4 m across. */
function bake(stalls: boolean): { mesh: PolyMesh; query: NavMeshQuery; ms: number } {
  const started = performance.now();
  const field = voxeliseWalkable(square(stalls), {
    cellSize: 0.3,
    cellHeight: 0.2,
    maxSlope: 45,
    agentHeight: 1,
    agentRadius: 0.4,
    maxStep: 2,
  });
  const regions = buildRegions(field, { minRegionSpans: 40, maxStep: 2 });
  const mesh = buildPolyMesh(buildContours(field, regions, 1.3), 6, field);
  return { mesh, query: new NavMeshQuery(mesh), ms: performance.now() - started };
}
// #endregion

// #region lanes
/**
 * The lanes, as places and the ways between them: eight doors, then the middle of each street,
 * then eight points round the fountain. The doors come first, so a door is a node below eight.
 */
const lanePoints: number[] = [];
for (const [x, z] of BUILDINGS) {
  /* Each building has a door on the street running north and south, and one on the other. */
  lanePoints.push(Math.sign(x) * 6.2, 0, z, x, 0, Math.sign(z) * 6.2);
}
/* Nodes 8 to 11, the streets: south, east, north, west. */
for (const [x, z] of [
  [0, -13.5],
  [13.5, 0],
  [0, 13.5],
  [-13.5, 0],
]) {
  lanePoints.push(x ?? 0, 0, z ?? 0);
}
/* Nodes 12 to 19, the ring, starting east and turning toward the north. */
for (let k = 0; k < 8; k += 1) {
  lanePoints.push(Math.cos((k * Math.PI) / 4) * 4.5, 0, Math.sin((k * Math.PI) / 4) * 4.5);
}
/** Each door to its street, each street to the ring, and the ring round. Every way runs both ways. */
const DOOR_TO_STREET = [8, 11, 8, 9, 10, 11, 10, 9];
const STREET_TO_RING = [18, 12, 14, 16];
const laneEdges: NavEdge[] = [
  ...DOOR_TO_STREET.map((street, door) => ({ from: door, to: street })),
  ...STREET_TO_RING.map((ring, at) => ({ from: 8 + at, to: ring })),
  ...Array.from({ length: 8 }, (_, k) => ({ from: 12 + k, to: 12 + ((k + 1) % 8) })),
];
const lanes: NavGraph = buildNavGraph(lanePoints, laneEdges);
// #endregion

// #region script
/** The villagers' walking, hosted with the lanes it routes over, and a route for each villager. */
const walking = hostScript(walkScript, {
  navigation: { graph: lanes, search: new NavSearch(lanes) },
});
interface Villager {
  x: number;
  z: number;
  heading: number;
  pace: number;
  resting: number;
  trips: number;
  seed: number;
}
type Walk = (villager: Villager, route: NavPath, lanes: NavGraph, dt: number) => void;
const villagers = Array.from({ length: 6 }, (_, at) => {
  const villager = exported<() => Villager>(walking, 'createVillager')();
  villager.x = lanePoints[at * 3] ?? 0;
  villager.z = lanePoints[at * 3 + 2] ?? 0;
  villager.seed = at * 97;
  villager.pace = 1.1 + hashToUnit(at) * 0.5;
  return { villager, route: new NavPath(lanes, 32, { lookaheadM: 1.2, arriveM: 0.3 }) };
});
if (import.meta.hot) {
  import.meta.hot.accept('./walk.drs', (next) => {
    if (next !== undefined) {
      patchModule(walking, next as Record<string, unknown>, {
        Villager: villagers.map(({ villager }) => villager),
      });
    }
  });
}
// #endregion

let stallsOut = flag('stalls', 'out') === 'out';
let baked = bake(stallsOut);
let overlay = flag('overlay', 'mesh');

/** A dog runs to a random place it can reach, waits, and picks another. */
interface Dog {
  x: number;
  z: number;
  heading: number;
  path: Float64Array;
  count: number;
  next: number;
  goalX: number;
  goalZ: number;
  waiting: number;
  picks: number;
}
const dogs: Dog[] = [
  [-3, -16],
  [16, 3],
  [-15, 2],
].map(([x = 0, z = 0], at) => ({
  x,
  z,
  heading: 0,
  path: new Float64Array(256),
  count: 0,
  next: 1,
  goalX: x,
  goalZ: z,
  waiting: 0.5 + at,
  picks: at * 31,
}));

// #region route
/** Path from where the dog is to its goal, over whichever mesh is current. */
function route(dog: Dog): boolean {
  dog.count = baked.query.findPath(dog.x, dog.z, dog.goalX, dog.goalZ, 1.5, dog.path);
  dog.next = 1;
  return dog.count > 1;
}
// #endregion

function run(dog: Dog, dt: number): void {
  if (dog.waiting > 0) {
    dog.waiting -= dt;
    if (dog.waiting > 0) return;
    /* A goal on the open ground; one inside a building or past the edge gives no route, so try again. */
    for (let attempt = 0; attempt < 8; attempt += 1) {
      dog.picks += 1;
      dog.goalX = (hashToUnit(dog.picks * 2) - 0.5) * 36;
      dog.goalZ = (hashToUnit(dog.picks * 2 + 1) - 0.5) * 36;
      if (route(dog)) return;
    }
    dog.waiting = 0.5;
    return;
  }
  const tx = dog.path[dog.next * 2] ?? dog.x;
  const tz = dog.path[dog.next * 2 + 1] ?? dog.z;
  const dx = tx - dog.x;
  const dz = tz - dog.z;
  const distance = Math.hypot(dx, dz);
  const step = 3.2 * dt;
  if (distance <= step) {
    dog.x = tx;
    dog.z = tz;
    dog.next += 1;
    if (dog.next >= dog.count) dog.waiting = 1 + hashToUnit(dog.picks) * 2;
    return;
  }
  dog.x += (dx / distance) * step;
  dog.z += (dz / distance) * step;
  dog.heading = Math.atan2(dx, dz);
}

controls([
  {
    key: 'stalls',
    label: 'stalls',
    value: stallsOut ? 'out' : 'packed',
    options: ['out', 'packed'].map((s) => ({ text: s, value: s })),
    change: (value) => {
      stallsOut = value === 'out';
      baked = bake(stallsOut);
      /* Every dog on its way somewhere is routed again over the new mesh, from where it is. */
      for (const dog of dogs) if (dog.waiting <= 0 && !route(dog)) dog.waiting = 0.2;
      outline();
    },
  },
  {
    key: 'overlay',
    label: 'show',
    value: overlay,
    options: ['mesh', 'lanes', 'nothing'].map((o) => ({ text: o, value: o })),
    change: (value) => {
      overlay = value;
    },
  },
]);

/* Drawing: the square, the people, the dogs, and the overlay lines. */
const ground = renderer.createMesh(
  new MeshBuilder()
    .addBox([0, -0.25, 0], [20, 0.25, 20], [0.55, 0.52, 0.47])
    /* The fields round the town, drawn and not walked: they are not in the mesh's geometry. */
    .addBox([0, -0.4, 0], [90, 0.1, 90], [0.32, 0.42, 0.26])
    .build(),
);
const houses = BUILDINGS.reduce((builder, [x, z]) => {
  builder.addBox([x, 2.5, z], [6.5, 2.5, 6.5], [0.78, 0.66, 0.52]);
  builder.addBox([x, 5.2, z], [6.7, 0.2, 6.7], [0.45, 0.27, 0.2]);
  /* The doors, a little proud of the walls. */
  builder.addBox([Math.sign(x) * 6.96, 1.1, z], [0.06, 1.1, 0.6], [0.28, 0.18, 0.12]);
  builder.addBox([x, 1.1, Math.sign(z) * 6.96], [0.6, 1.1, 0.06], [0.28, 0.18, 0.12]);
  return builder;
}, new MeshBuilder());
const housesMesh = renderer.createMesh(houses.build());
const fountain = renderer.createMesh(
  new MeshBuilder()
    .addCylinder([0, 0.4, 0], 2.5, 0.4, 'y', [0.62, 0.62, 0.66], 0, 24)
    .addCylinder([0, 0.79, 0], 2.2, 0.02, 'y', [0.25, 0.45, 0.6], 0.15, 24)
    .addCylinder([0, 1.2, 0], 0.25, 0.8, 'y', [0.62, 0.62, 0.66], 0, 12)
    .build(),
);
const stallsMesh = renderer.createMesh(
  STALLS.reduce((builder, [x, z], at) => {
    const awning: Vec3 = at % 2 === 0 ? [0.75, 0.2, 0.18] : [0.2, 0.45, 0.7];
    builder.addBox([x, 0.6, z], [1.2, 0.6, 0.8], [0.55, 0.38, 0.24]);
    builder.addBox([x, 2.1, z], [1.4, 0.06, 1.0], awning);
    for (const [px, pz] of [
      [-1.15, -0.75],
      [1.15, -0.75],
      [-1.15, 0.75],
      [1.15, 0.75],
    ] as const) {
      builder.addCylinder([x + px, 1.65, z + pz], 0.04, 0.45, 'y', [0.35, 0.3, 0.25]);
    }
    return builder;
  }, new MeshBuilder()).build(),
);
const VILLAGER_COLOURS: Vec3[] = [
  [0.75, 0.25, 0.2],
  [0.25, 0.45, 0.75],
  [0.85, 0.7, 0.25],
  [0.3, 0.6, 0.35],
  [0.6, 0.35, 0.65],
  [0.85, 0.5, 0.3],
];
const villagerMeshes: MeshHandle[] = VILLAGER_COLOURS.map((colour) =>
  renderer.createMesh(
    new MeshBuilder()
      .addCapsule([0, 0.75, 0], 0.22, 0.4, colour)
      .addSphere([0, 1.6, 0], 0.17, [0.85, 0.68, 0.55])
      .addBox([0, 1.6, 0.15], [0.05, 0.03, 0.04], [0.3, 0.2, 0.15])
      .build(),
  ),
);
const dogMesh = renderer.createMesh(
  new MeshBuilder()
    .addBox([0, 0.4, 0], [0.16, 0.14, 0.38], [0.5, 0.36, 0.22])
    .addBox([0, 0.6, 0.42], [0.12, 0.12, 0.14], [0.5, 0.36, 0.22])
    .addBox([0, 0.55, -0.45], [0.03, 0.03, 0.12], [0.45, 0.32, 0.2])
    .addBox([-0.1, 0.13, 0.25], [0.04, 0.13, 0.04], [0.4, 0.28, 0.17])
    .addBox([0.1, 0.13, 0.25], [0.04, 0.13, 0.04], [0.4, 0.28, 0.17])
    .addBox([-0.1, 0.13, -0.25], [0.04, 0.13, 0.04], [0.4, 0.28, 0.17])
    .addBox([0.1, 0.13, -0.25], [0.04, 0.13, 0.04], [0.4, 0.28, 0.17])
    .build(),
);

/** The overlay: the mesh's polygon edges or the lanes, and each dog's path. */
const outlineSegments = createLineSegments(4096);
const outlineBatch = renderer.createLines(4096, 'navigation-mesh');
const laneSegments = createLineSegments(laneEdges.length);
const laneBatch = renderer.createLines(laneEdges.length, 'lanes');
const pathSegments = createLineSegments(dogs.length * 128);
const pathBatch = renderer.createLines(dogs.length * 128, 'dog-paths');

/** Every polygon edge of the current mesh, a few centimetres above the ground. */
function outline(): void {
  const { mesh } = baked;
  let count = 0;
  for (let poly = 0; poly < mesh.polyCount; poly += 1) {
    const corners = polyVertexCount(mesh, poly);
    for (let at = 0; at < corners && count < outlineSegments.capacity; at += 1) {
      const a = mesh.polys[poly * mesh.maxVertsPerPoly + at] ?? 0;
      const b = mesh.polys[poly * mesh.maxVertsPerPoly + ((at + 1) % corners)] ?? 0;
      outlineSegments.from.set(
        [
          mesh.originX + (mesh.vertices[a * 2] ?? 0) * mesh.cellSize,
          0.04,
          mesh.originZ + (mesh.vertices[a * 2 + 1] ?? 0) * mesh.cellSize,
        ],
        count * 3,
      );
      outlineSegments.to.set(
        [
          mesh.originX + (mesh.vertices[b * 2] ?? 0) * mesh.cellSize,
          0.04,
          mesh.originZ + (mesh.vertices[b * 2 + 1] ?? 0) * mesh.cellSize,
        ],
        count * 3,
      );
      count += 1;
    }
  }
  outlineSegments.count = count;
}
outline();
laneEdges.forEach(({ from, to }, at) => {
  laneSegments.from.set([lanePoints[from * 3] ?? 0, 0.05, lanePoints[from * 3 + 2] ?? 0], at * 3);
  laneSegments.to.set([lanePoints[to * 3] ?? 0, 0.05, lanePoints[to * 3 + 2] ?? 0], at * 3);
});
laneSegments.count = laneEdges.length;

const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
const placed = (x: number, z: number, heading: number, out: Float32Array): Float32Array => {
  const c = Math.cos(heading);
  const s = Math.sin(heading);
  out.set([c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, x, 0, z, 1]);
  return out;
};
const villagerModels = villagers.map(() => new Float32Array(16));
const dogModels = dogs.map(() => new Float32Array(16));

const env = createEnvironment({
  directionalDir: [0.45, 0.8, -0.35],
  directionalColor: [1.7, 1.6, 1.45],
  ambient: [0.34, 0.37, 0.44],
  ambientGround: [0.14, 0.13, 0.12],
});
const lightMatrix = new Float32Array(16);
env.lightViewProj = lightMatrix;
env.shadowStrength = 0.8;
env.shadowDepthSpan = computeLightMatrix(
  env.directionalDir,
  0,
  2,
  0,
  30,
  renderer.shadowMapSize,
  lightMatrix,
);
const readout = createReadout(renderer, 2);
let time = 0;

stage.run({
  simulate(dt) {
    time += dt;
    const walk = exported<Walk>(walking, 'walk');
    for (const { villager, route: path } of villagers) walk(villager, path, lanes, dt);
    for (const dog of dogs) run(dog, dt);
  },
  render() {
    camera.fovYDeg = 45;
    camera.position[0] = Math.sin(time * 0.05) * 5;
    camera.position[1] = 34;
    camera.position[2] = 24;
    camera.lookAt(0, 0, 1.5);
    villagers.forEach(({ villager }, at) =>
      placed(villager.x, villager.z, villager.heading, villagerModels[at] as Float32Array),
    );
    dogs.forEach((dog, at) => placed(dog.x, dog.z, dog.heading, dogModels[at] as Float32Array));
    const drawAll = (draw: (mesh: MeshHandle, model: Float32Array) => void): void => {
      draw(ground, IDENTITY);
      draw(housesMesh, IDENTITY);
      draw(fountain, IDENTITY);
      if (stallsOut) draw(stallsMesh, IDENTITY);
      villagerMeshes.forEach((mesh, at) => draw(mesh, villagerModels[at] as Float32Array));
      for (const model of dogModels) draw(dogMesh, model);
    };
    renderer.beginShadowPass(lightMatrix, 'static');
    renderer.drawShadowCasters((sink) => drawAll((mesh, model) => sink.mesh(mesh, model)));
    renderer.endShadowPass();
    renderer.beginFrame([0.58, 0.66, 0.76]);
    renderer.bindMeshPass(camera, env);
    drawAll((mesh, model) => renderer.drawMesh(mesh, model));

    if (overlay === 'mesh') {
      renderer.drawLines(
        outlineBatch,
        outlineSegments,
        IDENTITY,
        camera,
        env,
        [0.2, 0.9, 0.7],
        0.08,
        0.9,
        0.5,
      );
      /* Each dog's path from where it is, over the polygons it was found across. */
      let count = 0;
      for (const dog of dogs) {
        if (dog.waiting > 0) continue;
        let fromX = dog.x;
        let fromZ = dog.z;
        for (let at = dog.next; at < dog.count && count < pathSegments.capacity; at += 1) {
          const toX = dog.path[at * 2] ?? fromX;
          const toZ = dog.path[at * 2 + 1] ?? fromZ;
          pathSegments.from.set([fromX, 0.08, fromZ], count * 3);
          pathSegments.to.set([toX, 0.08, toZ], count * 3);
          fromX = toX;
          fromZ = toZ;
          count += 1;
        }
      }
      pathSegments.count = count;
      renderer.drawLines(
        pathBatch,
        pathSegments,
        IDENTITY,
        camera,
        env,
        [1.6, 0.8, 0.2],
        0.09,
        1,
        0.5,
      );
    }
    if (overlay === 'lanes') {
      renderer.drawLines(
        laneBatch,
        laneSegments,
        IDENTITY,
        camera,
        env,
        [1.5, 1.2, 0.4],
        0.08,
        0.9,
        0.5,
      );
    }
    readout.set(0, `MESH ${baked.mesh.polyCount} POLYGONS, BUILT IN ${baked.ms.toFixed(0)} MS`);
    readout.set(
      1,
      `${villagers.reduce((sum, { villager }) => sum + villager.trips, 0)} DOORS VISITED ON ${lanes.nodeCount} LANE NODES`,
    );
    readout.draw(time);
    renderer.endFrame();
  },
});

walk.drs

examples/navigation/walk.drs
// The villagers: each walks the lanes from one door to another, rests there, and picks the next.
//
// Under `npm run examples`, change a rule and save: they walk by it from the next frame. Try a
// longer rest, a brisker pace, or villagers who only ever visit the first four doors.

import { arrived, nearest, routeBetween, steerX, steerZ } from "drift/navigation"
import { index } from "drift/random"
import { atan2, sqrt } from "std/math"

data Villager {
    // Where it stands on the square, and which way it faces.
    x: f32 = 0
    z: f32 = 0
    heading: f32 = 0
    // Metres a second.
    pace: f32 = 1.3
    // Seconds left before it sets off from the door it is at.
    resting: f32 = 0
    // Doors walked to so far, and its own seed: together they pick the next door.
    trips: u32 = 0
    seed: u32 = 0
}

// #region walk
// One frame of one villager: rest at a door, choose the next, or walk toward the point its route
// says to aim at, which is a little way along the lane and so cuts corners the way people do.
fn walk(villager: mut Villager, route: NavPath, lanes: NavGraph, dt: f32) {
    if navigation.arrived(route, villager.x, 0, villager.z) {
        if villager.resting > 0 {
            villager.resting = villager.resting - dt
            return
        }
        // The doors are the lanes' first eight nodes.
        let door = random.index(villager.seed + villager.trips, 8)
        let here = navigation.nearest(lanes, villager.x, 0, villager.z)
        if navigation.routeBetween(route, lanes, here, i32.clamp(door)) {
            villager.trips = villager.trips + 1
            villager.resting = 2.5
        }
        return
    }
    let dx = navigation.steerX(route, villager.x, 0, villager.z) - villager.x
    let dz = navigation.steerZ(route, villager.x, 0, villager.z) - villager.z
    let distance = math.sqrt(dx * dx + dz * dz)
    if distance > 0.01 {
        villager.x = villager.x + dx / distance * villager.pace * dt
        villager.z = villager.z + dz / distance * villager.pace * dt
        villager.heading = math.atan2(dx, dz)
    }
}
// #endregion