Help support this project by starring the repo on GitHub!

Terrain Query API

Synchronous CPU terrain sampling from the shared elevation readback cache

Overview

terrainTasks.terrainQuery exposes a synchronous CPU query API for:

  • getElevation(worldX, worldZ) — elevation at a point, or null
  • getNormal(worldX, worldZ) — surface normal at a point, or null
  • getTile(worldX, worldZ)quadtree tile containing a point
  • getTileBounds(worldX, worldZ) — tile with GPU-computed min/max elevation
  • getGlobalElevationRange() — min/max elevation across all active tiles
  • sampleTerrain(worldX, worldZ) — elevation + normal + validity in one call
  • sampleTerrainBatch(positions) — batched elevation + normal sampling
  • generation — increments each time the cache receives new readback data

The query path reads from a shared CPU cache populated by async GPU readback of the elevation field buffer. This keeps point queries fast while avoiding any duplicate CPU elevationFn implementation.

Get the query context

terrainTasks.terrainQuery returns a TerrainQueryContext containing the TerrainQuery API on its query property.

import { terrainGraph, terrainTasks } from "@hello-terrain/three";

const graph = terrainGraph();
await graph.run({ resources: { renderer } });

const { query: terrainQuery } = graph.get(terrainTasks.terrainQuery);

Single-point queries

const elevation = terrainQuery.getElevation(player.position.x, player.position.z);
const normal = terrainQuery.getNormal(player.position.x, player.position.z);

const sample = terrainQuery.sampleTerrain(player.position.x, player.position.z);
if (sample.valid) {
  player.position.y = sample.elevation;
  player.up.copy(sample.normal);
}

Batch queries

Use batch mode when you need many samples per frame (physics probes, AI paths, crowds).

// Interleaved x,z pairs.
const positions = new Float32Array([
  0, 0,
  8, 0,
  16, 0,
  24, 0,
]);

const batch = terrainQuery.sampleTerrainBatch(positions);

for (let i = 0; i < batch.elevations.length; i++) {
  if (!batch.valid[i]) continue;
  const y = batch.elevations[i];
  const nx = batch.normals[i * 3];
  const ny = batch.normals[i * 3 + 1];
  const nz = batch.normals[i * 3 + 2];
  // ...consume y / normal...
}

Tile lookup

getTile returns the quadtree leaf tile that covers a world position, or null if no tile is active there.

const tile = terrainQuery.getTile(worldX, worldZ);
if (tile) {
  console.log(`Tile L${tile.level} (${tile.x},${tile.y}) index=${tile.index}`);
}

Tile bounds

Each tile's min/max elevation is computed on the GPU via a parallel reduction pass and read back alongside the elevation data.

const bounds = terrainQuery.getTileBounds(worldX, worldZ);
if (bounds) {
  console.log(`Tile L${bounds.level} (${bounds.x},${bounds.y})`);
  console.log(`Elevation range: ${bounds.minElevation} – ${bounds.maxElevation}`);
}

The global elevation range across all active tiles is also available:

const range = terrainQuery.getGlobalElevationRange();
if (range) {
  console.log(`Terrain spans Y: ${range.min} – ${range.max}`);
}

This is used internally by the raycast system to derive a tight AABB instead of a conservative estimate.

Quadtree LOD feedback

Per-tile elevation ranges also feed back into quadtree subdivision. After each readback, the cache builds a conservative elevation pyramid: every leaf's min/max values propagate up to ancestor tiles so coarse tiles know about mountains beneath them.

During quadtreeUpdate, the graph wires UpdateParams.tileElevationRange from the previous-frame snapshot (scaled by elevationScale). Each topology's tileBounds uses that range to inflate LOD bounding spheres — tall terrain refines from farther away without a manual maxHeight padding knob.

type ElevationRangeOut = { min: number; max: number };

type UpdateParams = {
  // ...
  tileElevationRange?: (tile: TileId, out: ElevationRangeOut) => boolean;
};

Returns world-space displacement min/max when data is available; false when the tile has no readback yet (datum-only bounds for that frame, a one-frame latency until readback data arrives).

Closed-surface queries

The TerrainQuery above describes a flat heightfield keyed on world XZ. Closed surfaces (cube-sphere, torus, …) have no single ground plane, so they expose a generic TerrainSurfaceQuery keyed on a world position projected onto the surface:

const { query, surfaceQuery, sphereQuery } = graph.get(terrainTasks.terrainQuery);
// react: const { query, surfaceQuery, sphereQuery } = terrain.runtime;

surfaceQuery is null on flat surfaces and present for every closed surface. Its methods take a THREE.Vector3 world position (projected onto the surface):

  • getElevationByPosition(position) / getNormalByPosition(position)
  • sampleTerrainByPosition(position)TerrainSurfaceSample
  • getTileByPosition(position) / getTileBoundsByPosition(position)
  • sampleTerrainBatchByPosition(positions) — xyz triples

This is the surface API used by the torus example, and it is identical regardless of the surface shape.

Cube-sphere queries

On a cube-sphere the canonical key is a direction from the planet center, so the cube-sphere exposes a TerrainSphereQuery that extends TerrainSurfaceQuery with direction/lat-long variants:

const { sphereQuery } = graph.get(terrainTasks.terrainQuery);
// react: const { sphereQuery } = terrain.runtime;

sphereQuery is null unless the active topology uses the cubeSphere projection. Each getter comes in three explicit variants — no argument overloading:

  • ...ByDirection(direction)direction is a THREE.Vector3 from the planet center (normalized internally). This is the canonical form.
  • ...ByPosition(position) — any world-space THREE.Vector3; it is projected onto its direction from the center.
  • ...ByLatLong(latitudeDeg, longitudeDeg) — degrees, latitude [-90, 90], longitude [-180, 180]. Latitude is measured from the equator toward +Y (north pole); longitude is measured from +Z toward +X.

The variants exist for getElevation, getNormal, sampleTerrain, getTile, and getTileBounds, plus a sampleTerrainBatchByDirection(directions) batch (xyz triples).

sampleTerrain* returns a TerrainSurfaceSample:

interface TerrainSurfaceSample {
  position: Vector3; // center + direction * (radius + elevation)
  normal: Vector3; // world-space surface normal
  direction: Vector3; // unit direction used for the lookup
  elevation: number; // radial displacement above the base radius (scaled)
  valid: boolean;
}
// Snap an object to the planet surface at a lat/long.
const sample = sphereQuery.sampleTerrainByLatLong(20, 40);
if (sample.valid) {
  object.position.copy(sample.position);
  object.quaternion.setFromUnitVectors(object.up, sample.normal);
}

// Project an arbitrary world position down onto the terrain.
const ground = sphereQuery.sampleTerrainByPosition(camera.position);

// Elevation only (radial displacement above the base radius).
const elevation = sphereQuery.getElevationByDirection(new Vector3(0, 1, 0));

For cube-sphere tile bounds, minElevation / maxElevation are radial displacements above the base radius (not absolute world Y).

Freshness and startup behavior

  • graph.get(terrainTasks.terrainQuery) always returns a TerrainQueryContext. Before the first successful readback, individual queries return { valid: false }.
  • Per-sample valid indicates whether the queried point maps to an active tile with readback data.
  • Use terrainQuery.generation to detect when cache content changes between frames.
  • Values are frame-coherent snapshots: tile lookup, elevation data, and per-tile bounds are all paired from the same readback generation.
  • Readback is triggered by a separate terrainReadbackTask that runs on the GPU lane. It is fire-and-forget — downstream tasks like terrainRaycastTask depend on the stable terrainQueryTask, not the readback, so they don't block on GPU compute.
  • Readback reuses a persistent per-attribute GPU staging buffer instead of allocating one each frame, so the GPU buffer pool stays flat over time. (Three.js' getArrayBufferAsync leaks a _readback buffer per call and is only used as a no-backend fallback.)