Examples

Terrain

A valley drawn as a clipmap, painted and planted by rules in DriftScript.

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

Open on its own · Source

Terrain is the chapter that walks through it.

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

Source

main.ts

examples/terrain/main.ts
/**
 * A river valley two kilometres across, flown over: hills, rock where the slope is steep, snow on
 * the tops and a forest below the tree line.
 *
 * The ground is one heightfield drawn as a clipmap, so the patches under the camera are fine and
 * the distant ones coarse, and the patches are rebuilt as the camera crosses them. Which ground is
 * rock, where snow lies, where trees grow and how the camera flies are rules in `ground.drs`, and
 * every rule asks the terrain the same question the mesh was built from.
 *
 * The switches show what the clipmap is doing: paint each level its own colour, change how many
 * cells a patch has, and change how many levels reach out.
 */
import {
  MeshBuilder,
  SceneNode,
  computeLightMatrix,
  concatMeshes,
  createEnvironment,
  createMeshInstances,
  hashToUnit,
} from '@driftengine/core';
import type { MeshHandle, ShadowCasters, SkyColors, Vec3, WaterBody } from '@driftengine/core';
import {
  Terrain,
  TerrainMaterials,
  clipmapFrame,
  clipmapPatchOptions,
  heightfieldPatch,
  selectClipmap,
} from '@driftengine/terrain';
import type { ClipmapFrame, HeightfieldPatchOptions } from '@driftengine/terrain';
import { patchModule } from 'driftscript';
import { exported, hostScript } from '../common/script';
import { controls, flag, flagNumber, openStage } from '../common/stage';
import * as groundScript from './ground.drs';

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

// #region field
/** Samples across each side, two metres apart: a field 2,048 metres square, centred on the origin. */
const SAMPLES = 1025;
const SPACING = 2;
const HALF = ((SAMPLES - 1) * SPACING) / 2;

/** Smooth noise on a unit lattice, from a hash of each corner. */
function lattice(x: number, z: number): number {
  const ix = Math.floor(x);
  const iz = Math.floor(z);
  const fx = x - ix;
  const fz = z - iz;
  const sx = fx * fx * (3 - 2 * fx);
  const sz = fz * fz * (3 - 2 * fz);
  const a = hashToUnit(Math.imul(ix, 73856093) ^ Math.imul(iz, 19349663));
  const b = hashToUnit(Math.imul(ix + 1, 73856093) ^ Math.imul(iz, 19349663));
  const c = hashToUnit(Math.imul(ix, 73856093) ^ Math.imul(iz + 1, 19349663));
  const d = hashToUnit(Math.imul(ix + 1, 73856093) ^ Math.imul(iz + 1, 19349663));
  return (a + (b - a) * sx + (c - a) * sz + (a - b - c + d) * sx * sz) * 2 - 1;
}

/** Height in metres: a river winding along z, valley walls rising either side, hills over all. */
function valley(x: number, z: number): number {
  const river = Math.sin(z / 260) * 120 + Math.sin(z / 97) * 25;
  const fromRiver = Math.abs(x - river);
  const wall = Math.min(1, Math.max(0, (fromRiver - 30) / 700));
  const rise = 230 * wall * wall * (3 - 2 * wall);
  let hills = 0;
  let amplitude = 1;
  let frequency = 1 / 220;
  for (let octave = 0; octave < 4; octave += 1) {
    hills += lattice(x * frequency, z * frequency) * amplitude;
    amplitude *= 0.5;
    frequency *= 2.1;
  }
  const bed = 5 * Math.max(0, 1 - fromRiver / 22);
  return rise + hills * (4 + rise * 0.35) - bed + 3;
}

const heights = new Float32Array(SAMPLES * SAMPLES);
for (let row = 0; row < SAMPLES; row += 1)
  for (let column = 0; column < SAMPLES; column += 1)
    heights[row * SAMPLES + column] = valley(column * SPACING - HALF, row * SPACING - HALF);

const terrain = new Terrain({
  width: SAMPLES,
  depth: SAMPLES,
  spacingM: SPACING,
  heights,
  origin: [-HALF, 0, -HALF],
});
// #endregion

// #region script
/** The valley's rules, hosted. The flight is a record the page holds, so an edit keeps it flying. */
const ground = hostScript(groundScript);
interface Flight {
  angle: number;
  x: number;
  y: number;
  z: number;
  lookX: number;
  lookY: number;
  lookZ: number;
}
const flight = exported<() => Flight>(ground, 'createFlight')();
type Rule<T> = (field: Terrain, x: number, z: number) => T;
type Glide = (flight: Flight, field: Terrain, dt: number) => void;
// #endregion

// #region paint
/** Grass, rock and snow, weighted across the field by what the script says each place is. */
const WEIGHTS_ACROSS = 513;
const GRASS: Vec3 = [0.25, 0.38, 0.16];
const ROCK: Vec3 = [0.4, 0.38, 0.35];
const SNOW: Vec3 = [0.86, 0.88, 0.92];

function paintGround(): TerrainMaterials {
  const rockiness = exported<Rule<number>>(ground, 'rockiness');
  const snowiness = exported<Rule<number>>(ground, 'snowiness');
  const weights = new Float32Array(WEIGHTS_ACROSS * WEIGHTS_ACROSS * 3);
  const step = (2 * HALF) / (WEIGHTS_ACROSS - 1);
  for (let row = 0; row < WEIGHTS_ACROSS; row += 1) {
    for (let column = 0; column < WEIGHTS_ACROSS; column += 1) {
      const x = column * step - HALF;
      const z = row * step - HALF;
      const rock = rockiness(terrain, x, z);
      const snow = snowiness(terrain, x, z);
      const at = (row * WEIGHTS_ACROSS + column) * 3;
      weights[at] = Math.max(0, 1 - rock - snow);
      weights[at + 1] = rock;
      weights[at + 2] = snow;
    }
  }
  return new TerrainMaterials({
    materials: [{ color: GRASS }, { color: ROCK, specular: 0.15 }, { color: SNOW, specular: 0.4 }],
    width: WEIGHTS_ACROSS,
    depth: WEIGHTS_ACROSS,
    weights,
  });
}
// #endregion

// #region forest
/** One fir, drawn as many times as the script lets trees grow. */
const fir = renderer.createMesh(
  new MeshBuilder()
    .addCylinder([0, 1.2, 0], 0.22, 1.2, 'y', [0.3, 0.22, 0.16])
    .addCapsule([0, 4, 0], 1.7, 1.2, [0.16, 0.27, 0.15])
    .addCapsule([0, 6.6, 0], 1.1, 0.9, [0.18, 0.3, 0.16])
    .build(),
);
const TREE_SPACING = 9;
const TREES_ACROSS = Math.floor((2 * HALF) / TREE_SPACING);
const trees = createMeshInstances(TREES_ACROSS * TREES_ACROSS);
const forest = renderer.createInstanced(fir, trees.capacity, { cull: true });

function plantForest(): void {
  const grows = exported<Rule<boolean>>(ground, 'grows');
  trees.count = 0;
  for (let row = 0; row < TREES_ACROSS; row += 1) {
    for (let column = 0; column < TREES_ACROSS; column += 1) {
      const seed = row * TREES_ACROSS + column;
      const x = (column + hashToUnit(seed * 2)) * TREE_SPACING - HALF;
      const z = (row + hashToUnit(seed * 2 + 1)) * TREE_SPACING - HALF;
      if (!grows(terrain, x, z)) continue;
      const scale = 0.7 + hashToUnit(seed * 3 + 7) * 0.6;
      /* Sunk a little, so a trunk on a slope meets the ground on its downhill side too. */
      const o = trees.count * 16;
      trees.models.fill(0, o, o + 16);
      trees.models[o] = scale;
      trees.models[o + 5] = scale;
      trees.models[o + 10] = scale;
      trees.models[o + 12] = x;
      trees.models[o + 13] = terrain.heightAt(x, z) - 0.3;
      trees.models[o + 14] = z;
      trees.models[o + 15] = 1;
      const shade = 0.8 + hashToUnit(seed * 5 + 3) * 0.4;
      trees.tints.set([shade, shade, shade], trees.count * 3);
      trees.count += 1;
    }
  }
  renderer.uploadInstanced(forest, trees);
}
// #endregion

let materials = paintGround();
plantForest();

/** The switches: how the levels are painted, how many cells a patch has, and how many levels. */
const LEVEL_COLOURS: Vec3[] = [
  [0.85, 0.42, 0.25],
  [0.85, 0.75, 0.3],
  [0.35, 0.7, 0.35],
  [0.3, 0.55, 0.85],
  [0.6, 0.4, 0.8],
];
let paint = flag('paint', 'ground');
let cells = Math.min(16, Math.max(4, Math.round(flagNumber('cells', 8))));
let levels = Math.min(5, Math.max(3, Math.round(flagNumber('levels', 4))));
let stale = true;
controls([
  {
    key: 'paint',
    label: 'paint',
    value: paint,
    options: ['ground', 'levels'].map((p) => ({ text: p, value: p })),
    change: (value) => {
      paint = value;
      stale = true;
    },
  },
  {
    key: 'cells',
    label: 'patch cells',
    value: String(cells),
    options: ['4', '8', '16'].map((n) => ({ text: n, value: n })),
    change: (value) => {
      cells = Number(value);
      stale = true;
    },
  },
  {
    key: 'levels',
    label: 'levels',
    value: String(levels),
    options: ['3', '4', '5'].map((n) => ({ text: n, value: n })),
    change: (value) => {
      levels = Number(value);
      stale = true;
    },
  },
]);

if (import.meta.hot) {
  import.meta.hot.accept('./ground.drs', (next) => {
    if (next === undefined) return;
    patchModule(ground, next as Record<string, unknown>, { Flight: [flight] });
    /* New rules: repaint the ground and replant the forest with them. */
    materials = paintGround();
    plantForest();
    stale = true;
  });
}

// #region clipmap
/** The ground the camera is over, as one mesh rebuilt only when a level's block moves. */
let groundMesh: MeshHandle | null = null;
let shown: ClipmapFrame | null = null;
let shownPatchX = Number.NaN;
let shownPatchZ = Number.NaN;

/** One selected patch, painted by its level or by the ground's materials. */
function patchFor(options: HeightfieldPatchOptions, level: number): HeightfieldPatchOptions {
  return paint === 'levels'
    ? { ...options, color: LEVEL_COLOURS[level] ?? [1, 1, 1] }
    : { ...options, materials };
}

function chooseGround(cameraX: number, cameraZ: number): void {
  /* A block snaps to whole patches, so nothing can move until the camera crosses one. */
  const patchX = Math.floor(cameraX / (cells * SPACING));
  const patchZ = Math.floor(cameraZ / (cells * SPACING));
  if (!stale && patchX === shownPatchX && patchZ === shownPatchZ) return;
  shownPatchX = patchX;
  shownPatchZ = patchZ;

  const frame = clipmapFrame(terrain, cameraX, cameraZ, { levels, patchCells: cells });
  const moved = shown === null || frame.origins.some((origin, i) => origin !== shown?.origins[i]);
  if (!stale && !moved) return;
  shown = frame;
  stale = false;

  const patches = selectClipmap(frame).map((patch) =>
    heightfieldPatch(terrain, patchFor(clipmapPatchOptions(patch), patch.level)),
  );
  const next = renderer.createMesh(concatMeshes(patches));
  if (groundMesh !== null) renderer.disposeMesh(groundMesh);
  groundMesh = next;
}
// #endregion

const HORIZON: Vec3 = [0.66, 0.74, 0.84];
const env = createEnvironment({
  directionalDir: [-0.55, 0.5, -0.4],
  directionalColor: [2.1, 1.95, 1.75],
  ambient: [0.32, 0.38, 0.48],
  ambientGround: [0.14, 0.13, 0.11],
  fogColor: HORIZON,
  fogDensity: 0.0012,
  fogHeightFalloff: 0.004,
});
const sky: SkyColors = {
  top: [0.22, 0.4, 0.7],
  horizon: HORIZON,
  deep: [0.3, 0.36, 0.42],
  sunDir: env.directionalDir,
  sunColor: [1.8, 1.6, 1.3],
  sunAngularRadius: 0.02,
  moonDir: [0, -1, 0],
  moonColor: [0, 0, 0],
  moonAngularRadius: 0.03,
  moonPhase: 0,
  nightFactor: 0,
  cloudOffsetX: 0,
  cloudOffsetZ: 0,
};

/** The river, as the endless water at its level: it shows wherever the valley floor dips below. */
const river = renderer.createWater();
const riverBody: WaterBody = {
  level: 0.6,
  deepColor: [0.04, 0.09, 0.1],
  shallowColor: [0.1, 0.2, 0.2],
  density: 0.8,
  waveScale: 0.15,
};

const still = new SceneNode();
still.updateWorld();
const casters: ShadowCasters = (sink) => {
  if (groundMesh !== null) sink.mesh(groundMesh, still.worldMatrix);
  sink.instanced?.(forest, trees);
};
const lightMatrix = new Float32Array(16);
env.lightViewProj = lightMatrix;
env.shadowStrength = 0.8;

let time = 0;
stage.run({
  simulate(dt) {
    time += dt;
    exported<Glide>(ground, 'glide')(flight, terrain, dt);
  },
  render() {
    camera.fovYDeg = 60;
    camera.near = 0.5;
    camera.far = 2400;
    camera.position[0] = flight.x;
    camera.position[1] = flight.y;
    camera.position[2] = flight.z;
    camera.lookAt(flight.lookX, flight.lookY, flight.lookZ);
    chooseGround(flight.x, flight.z);

    /* The sun's map covers the ground between the camera and where it looks. */
    env.shadowDepthSpan = computeLightMatrix(
      env.directionalDir,
      (flight.x + flight.lookX) / 2,
      flight.lookY,
      (flight.z + flight.lookZ) / 2,
      120,
      renderer.shadowMapSize,
      lightMatrix,
    );
    renderer.beginShadowPass(lightMatrix, 'static');
    renderer.drawShadowCasters(casters);
    renderer.endShadowPass();

    renderer.beginFrame(HORIZON);
    renderer.bindMeshPass(camera, env);
    renderer.drawSceneCasters(casters);
    renderer.drawSky(camera, sky, env);
    renderer.drawWater(river, camera, time, riverBody, env, 1, 0.5);
    renderer.endFrame();
  },
});

ground.drs

examples/terrain/ground.drs
// The valley's rules: which ground is bare rock, where snow lies, where a tree can grow, and how the
// camera flies over it all. Every rule asks the terrain, so each answers the ground that is drawn.
//
// Under `npm run examples`, change a number here and save: the page repaints the valley and replants
// the forest with the new rules, and the flight carries on from where it was. Try letting trees
// climb to 160 metres, or rock that starts on gentler slopes.

import { heightAt, slopeAt } from "drift/terrain"
import { clamp, cos, max, sin } from "std/math"

data Flight {
    // How far round its loop the camera is, in radians.
    angle: f32 = 0
    // Where it is now, and the point it looks at. The page reads these to place the camera.
    x: f32 = 0
    y: f32 = 80
    z: f32 = 0
    lookX: f32 = 0
    lookY: f32 = 0
    lookZ: f32 = 0
}

// #region paint
// How much of the ground at a place is bare rock, 0 to 1: none below a slope of 0.45 radians
// (about 26 degrees), all of it from 0.7.
fn rockiness(ground: Terrain, x: f32, z: f32) -> f32 {
    return math.clamp((terrain.slopeAt(ground, x, z) - 0.45) / 0.25, 0, 1)
}

// How much is snow: it lies above 140 metres, fully by 170, and slides off the steepest faces.
fn snowiness(ground: Terrain, x: f32, z: f32) -> f32 {
    let high = math.clamp((terrain.heightAt(ground, x, z) - 140) / 30, 0, 1)
    return high * (1 - rockiness(ground, x, z) * 0.8)
}

// Whether a tree can grow here: on gentle ground, above the river and below the tree line.
fn grows(ground: Terrain, x: f32, z: f32) -> bool {
    let height = terrain.heightAt(ground, x, z)
    return terrain.slopeAt(ground, x, z) < 0.35 && height > 6 && height < 115
}
// #endregion

// #region glide
// One frame of flight round the valley. The camera holds its height above the ground ahead of it as
// well as the ground beneath, so it climbs before a ridge arrives instead of on it.
fn glide(flight: mut Flight, ground: Terrain, dt: f32) {
    flight.angle = flight.angle + dt * 0.045
    let radius = 300
    flight.x = math.cos(flight.angle) * radius
    flight.z = math.sin(flight.angle) * radius
    let aheadX = math.cos(flight.angle + 0.25) * radius
    let aheadZ = math.sin(flight.angle + 0.25) * radius
    let floor = math.max(terrain.heightAt(ground, flight.x, flight.z),
                         terrain.heightAt(ground, aheadX, aheadZ))
    let wanted = floor + 45
    // Ease toward it, so the flight rises and settles instead of following every bump.
    flight.y = flight.y + (wanted - flight.y) * math.clamp(dt * 0.6, 0, 1)
    // Look further along the loop, at the ground there.
    flight.lookX = math.cos(flight.angle + 0.6) * radius
    flight.lookZ = math.sin(flight.angle + 0.6) * radius
    flight.lookY = terrain.heightAt(ground, flight.lookX, flight.lookZ)
}
// #endregion