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, ornullgetNormal(worldX, worldZ)— surface normal at a point, ornullgetTile(worldX, worldZ)— quadtree tile containing a pointgetTileBounds(worldX, worldZ)— tile with GPU-computed min/max elevationgetGlobalElevationRange()— min/max elevation across all active tilessampleTerrain(worldX, worldZ)— elevation + normal + validity in one callsampleTerrainBatch(positions)— batched elevation + normal samplinggeneration— 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.
Freshness and startup behavior
graph.get(terrainTasks.terrainQuery)always returns aTerrainQueryContext. Before the first successful readback, individual queries return{ valid: false }.- Per-sample
validindicates whether the queried point maps to an active tile with readback data. - Use
terrainQuery.generationto 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
terrainReadbackTaskthat runs on the GPU lane. It is fire-and-forget — downstream tasks liketerrainRaycastTaskdepend on the stableterrainQueryTask, not the readback, so they don't block on GPU compute.