Taos Engine ▦ Taos: API Documentation

9 — Terrain Physics

Live demo: run this tutorial in your browser — built from src/09_terrain_physics.ts. Click to drop balls.

Streamed terrain is just geometry on screen until you give it collision. This tutorial builds Jolt static mesh colliders from the terrain tiles as they stream in (and retires them as they stream out), so dynamic bodies roll down real mountains. Sources: samples/geo_physics.ts, the Physics module guide.

The idea#

The geo scene streams a constantly-changing set of terrain tiles. To collide against exactly what's drawn, you mirror that set into Jolt colliders each frame: build a collider for any rendered tile that doesn't have one yet, and remove the collider for any tile that left the rendered set. Bake each collider's triangles through the same geodetic→ECEF→world path the rendered mesh used, so the physics lines up with the pixels.

Reading the streamed tiles#

GeoFeature — registered by geo.attach(engine) — drives the scene and stashes the result on geo.lastResult, a GeoFrameResult whose terrainContents are the terrain tiles rendered that frame. Read it after the geo feature has run:

import { PhysicsWorld } from 'taos/physics/index.js';
import { geodeticToEcef } from 'taos/geo/index.js';
import { Vec3 } from 'taos/math/index.js';

const physics = await PhysicsWorld.create();

// Which streamed tile each collider came from, so we can retire it later.
const terrainColliders = new Map<unknown, ReturnType<PhysicsWorld['addMeshCollider']>>();
const COLLIDERS_PER_FRAME = 3;   // cap new colliders/frame to avoid hitches

Syncing colliders each frame#

Terrain content exposes sampler.collisionGeometry(project) — it bakes the tile's surface triangles, calling your project(lon, lat, h) to place each vertex. Feed it the floating-origin transform so colliders sit in engine world space:

function syncTerrainColliders(contents: any[]): void {
  // 1. Retire colliders whose tiles are no longer rendered.
  const present = new Set(contents);
  for (const [content, body] of terrainColliders) {
    if (!present.has(content)) {
      physics.removeBody(body);
      terrainColliders.delete(content);
    }
  }
  // 2. Build colliders for newly rendered tiles (rate-limited).
  let built = 0;
  for (const content of contents) {
    if (built >= COLLIDERS_PER_FRAME) {
      break;
    }
    if (terrainColliders.has(content)) {
      continue;
    }
    const mesh = content.sampler.collisionGeometry(
      (lon: number, lat: number, h: number) =>
        geo.frame.worldFromEcefPoint(geodeticToEcef(lon, lat, h)));
    if (mesh.indices.length === 0) {
      continue;
    }
    try {
      const body = physics.addMeshCollider(mesh.positions, mesh.indices, new Vec3(0, 0, 0), null, {
        friction: 1.0, restitution: 0.0,   // grip, no bounce — balls settle instead of skating
      });
      terrainColliders.set(content, body);
      built++;
    } catch {
      // Degenerate tile geometry — skip rather than break the frame.
    }
  }
}

engine.afterFrame(() => {
  const result = geo.lastResult;
  if (result) {
    syncTerrainColliders(result.terrainContents);
  }
  physics.step(/* dt */ 1 / 60);   // or pass the frame dt
});

addMeshCollider(positions, indices, position, rotation, opts) returns a static body (terrain doesn't move). Rate-limiting with COLLIDERS_PER_FRAME keeps a burst of newly-streamed tiles from stalling a frame — over a few frames the set catches up.

Re-baking on reanchor. The collider positions are baked in the current world frame. If you reanchor (you should — see tutorial 2), world coordinates shift, so existing colliders drift. The simplest robust approach is to anchor the GeoFrame once for a bounded play area (as geo_physics.ts does for its canyon) so the origin stays pinned. For free roaming you'd rebuild colliders after each reanchor.

Dynamic bodies#

Spawn dynamic bodies normally and push their meshes into the same draw list so they share the scene's lighting — see the Physics guide for the body factories and the step/sync loop. A ball shot from the camera:

const ball = physics.addSphere(18, origin, {
  restitution: 0.1, friction: 1.0,
  gravityFactor: 3.5,                       // > 1 reads as "heavy" at canyon scale
  linearVelocity: new Vec3(fx * 500, fy * 500, fz * 500),
});

Gravity is Jolt's default (0, -9.81, 0), which is already correct: the GeoFrame ENU basis makes +Y local-up at the anchor, so "down" is -Y.

3D-tiles collision soup#

The same idea works for 3D tiles (buildings, photorealistic meshes), but you must opt in to retaining their CPU triangle soup — it's off by default to save memory:

geo.add3DTiles(asset, { collision: true });
// then read geo.lastResult.tileContents and use each content's retained soup.

geo.heightAt for cheap clamping#

If you only need to place something on the ground (not full physics), skip colliders and query the terrain height directly:

import { DEG2RAD } from 'taos/geo/index.js';

const groundHeight = geo.heightAt(lonDeg * DEG2RAD, latDeg * DEG2RAD);  // null until streamed

This is what tutorial 4 uses to clamp anchored models to terrain.

Next#