Quadtree
Fast level-of-detail terrain subdivision
Overview
The Quadtree class provides dynamic level-of-detail (LOD) subdivision for terrain rendering. It recursively subdivides terrain tiles based on position's distance to node centers, which can be used to ensure higher detail near the viewer and lower detail in the distance.
The quadtree operates in 2D space (X/Z plane) and supports efficient buffer-based storage for GPU-friendly data access.
Constructor
import { Quadtree } from "@hello-terrain/three";
const quadtree = new Quadtree(config, subdivisionStrategy?, nodeView?);| Parameter | Type | Description |
|---|---|---|
config | QuadtreeParams | Configuration object (see below) |
subdivisionStrategy | SubdivisionStrategy | Optional. Strategy function for subdivision decisions. Defaults to distanceBasedSubdivision(2). |
nodeView | QuadtreeNodeView | Optional. Pre-allocated node view for buffer reuse. |
QuadtreeParams
| Property | Type | Description |
|---|---|---|
maxLevel | number | Maximum subdivision depth. Higher values allow finer detail. |
rootSize | number | World-space size of the root tile (edge length). |
minNodeSize | number | Minimum allowed tile size. Prevents over-subdivision. |
origin | Vector3Like | World-space origin of the terrain center. Any object with { x, y, z } properties. |
maxNodes | number | Maximum number of nodes the quadtree can allocate. Prevents buffer overflow. |
Methods
update
Updates the quadtree based on a camera or reference position. Returns the index of the closest leaf node.
const closestLeafIndex = quadtree.update(position);| Parameter | Type | Description |
|---|---|---|
position | Vector3Like | Camera or reference position for LOD calculations. Any object with { x, y, z }. |
getLeafNodes
Returns all active leaf nodes as an array of objects.
const leaves = quadtree.getLeafNodes();
// [{ level: 0, x: 0, y: 0 }, { level: 2, x: 1, y: 3 }, ...]getActiveLeafNodeIndices
Returns active leaf node indices for efficient GPU processing. This is a zero-copy operation.
const { indices, count } = quadtree.getActiveLeafNodeIndices();
// indices: Uint16Array, count: number of valid entriessubdivisionStrategy
The subdivisionStrategy property can be read or written to change the subdivision strategy at runtime.
import { screenSpaceSubdivision } from "@hello-terrain/three";
quadtree.subdivisionStrategy = screenSpaceSubdivision({
targetTrianglePixels: 6,
getScreenSpaceInfo: () => screenInfo,
});findNeighbor
Finds the adjacent node(s) in a given direction. Returns a single index, an array of indices (for finer neighbors), or 0xFFFF if at boundary.
import { Direction } from "@hello-terrain/three";
const leftNeighbor = quadtree.findNeighbor(nodeIndex, Direction.LEFT);
const rightNeighbor = quadtree.findNeighbor(nodeIndex, Direction.RIGHT);| Parameter | Type | Description |
|---|---|---|
nodeIndex | number | Index of the node to find neighbors for |
direction | Direction | Direction.LEFT, RIGHT, TOP, or BOTTOM |
Returns: number | number[] - Single neighbor index, array of finer neighbor indices, or 0xFFFF (empty sentinel).
findAllNeighbors
Finds all four neighbors of a node at once.
const neighbors = quadtree.findAllNeighbors(nodeIndex);
console.log(neighbors.left); // number | number[]
console.log(neighbors.right); // number | number[]
console.log(neighbors.top); // number | number[]
console.log(neighbors.bottom); // number | number[]| Parameter | Type | Description |
|---|---|---|
nodeIndex | number | Index of the node to find neighbors for |
Returns: NeighborResult - Object with left, right, top, bottom properties.
getSpatialIndex
Returns the spatial index used for O(1) neighbor lookups. Useful for advanced use cases or GPU upload.
const spatialIndex = quadtree.getSpatialIndex();
const { keys, values, size } = spatialIndex.getBuffers();
// keys: Uint32Array, values: Uint16ArrayOther Methods
| Method | Returns | Description |
|---|---|---|
getNodeCount() | number | Total number of nodes in the tree |
getLeafNodeCount() | number | Number of active leaf nodes |
getDeepestLevel() | number | Maximum subdivision depth reached |
getConfig() | QuadtreeParams | Current configuration |
setConfig(config, reset?) | void | Updates configuration. Pass reset: true to reinitialize the tree. |
getNodeView() | QuadtreeNodeView | Direct access to the underlying buffer view |
getSpatialIndex() | SpatialIndex | Typed array spatial index for neighbor lookups |
reset() | void | Clears the tree and recreates the root node |
destroy() | void | Releases all internal resources |
Subdivision Strategies
Subdivision strategies determine when a tile should be split into four children. The package provides two built-in strategies.
distanceBasedSubdivision
Subdivides based on distance from the camera. Simple and predictable.
import { distanceBasedSubdivision } from "@hello-terrain/three";
// Subdivide when distance < nodeSize * factor
const strategy = distanceBasedSubdivision(2); // factor = 2| Parameter | Type | Default | Description |
|---|---|---|---|
factor | number | 2 | Multiplier for subdivision threshold |
screenSpaceSubdivision
Subdivides based on projected triangle size in screen pixels. Ensures consistent visual quality across different distances and screen resolutions.
import { screenSpaceSubdivision, computeScreenSpaceInfo } from "@hello-terrain/three";
const strategy = screenSpaceSubdivision({
targetTrianglePixels: 6,
tileSegments: 13,
getScreenSpaceInfo: () => {
const fovRadians = camera.fov * Math.PI / 180;
return computeScreenSpaceInfo(fovRadians, renderer.domElement.height);
},
});| Option | Type | Default | Description |
|---|---|---|---|
targetTrianglePixels | number | 6 | Maximum triangle size in screen pixels |
tileSegments | number | 13 | Segments per tile edge (should match TerrainGeometry) |
getScreenSpaceInfo | () => ScreenSpaceInfo | null | Required | Callback returning current screen-space projection info |
Custom Strategies
You can create custom subdivision strategies by implementing the SubdivisionStrategy type. The strategy receives a ShouldSubdivideContext tuple with node information.
import type { SubdivisionStrategy } from "@hello-terrain/three";
const customStrategy: SubdivisionStrategy = (
qt, // quadtree instance
distance, // distance from position to node center
level, // current quadtree level (0 = root)
nodeSize, // world-space size of the node
minNodeSize // minimum allowed node size
// ... additional parameters: rootSize, nodeX, nodeY, minX, minY, worldX, worldY
) => {
// Return true to subdivide, false to keep as leaf
return distance < nodeSize * 1.5 && nodeSize > minNodeSize;
};Frustum Culling with Closure Pattern
For frustum culling, use a factory function that captures the frustum via closure:
import * as THREE from "three";
import type { SubdivisionStrategy } from "@hello-terrain/three";
// Factory function that creates a strategy with frustum access
function createFrustumCullingStrategy(
frustum: THREE.Frustum,
factor = 2
): SubdivisionStrategy {
const tempBox = new THREE.Box3();
const tempMin = new THREE.Vector3();
const tempMax = new THREE.Vector3();
return (
qt,
distance,
level,
nodeSize,
minNodeSize,
rootSize,
nodeX,
nodeY,
minX,
minY, // This is world Z coordinate
worldX,
worldY // This is world Z center
) => {
const config = qt.getConfig();
// Build bounding box for this node
// minX/minY are world X/Z minimums, nodeSize is the edge length
const altitude = config.rootSize; // Approximate vertical extent
tempMin.set(minX, config.origin.y - altitude, minY);
tempMax.set(minX + nodeSize, config.origin.y + altitude, minY + nodeSize);
tempBox.set(tempMin, tempMax);
// Skip nodes outside frustum
if (!frustum.intersectsBox(tempBox)) {
return false;
}
// Normal distance-based subdivision for nodes in frustum
if (nodeSize <= minNodeSize) {
return false;
}
return distance < nodeSize * factor;
};
}
// Usage in render loop:
const frustum = new THREE.Frustum();
const projScreenMatrix = new THREE.Matrix4();
function updateQuadtree() {
projScreenMatrix.multiplyMatrices(
camera.projectionMatrix,
camera.matrixWorldInverse
);
frustum.setFromProjectionMatrix(projScreenMatrix);
// Update strategy with current frustum
quadtree.subdivisionStrategy = createFrustumCullingStrategy(frustum, 2);
quadtree.update(camera.position);
}Neighbor Finding
The quadtree provides O(1) neighbor lookups using a typed-array spatial index. This is essential for LOD stitching—connecting terrain tiles at different levels of detail without visible seams.
Direction Enum
import { Direction } from "@hello-terrain/three";
Direction.LEFT // 0 - Adjacent cell to the left (−X)
Direction.RIGHT // 1 - Adjacent cell to the right (+X)
Direction.TOP // 2 - Adjacent cell above (−Z)
Direction.BOTTOM // 3 - Adjacent cell below (+Z)Cross-Level Neighbors
Neighbors can exist at different levels of detail:
- Same level: Direct 1:1 neighbor relationship
- Coarser neighbor: A larger tile containing the adjacent area
- Finer neighbors: Multiple smaller tiles along the shared edge
const neighbors = quadtree.findAllNeighbors(leafIndex);
if (Array.isArray(neighbors.left)) {
// Multiple finer neighbors - need to stitch to all of them
console.log(`${neighbors.left.length} smaller tiles on left edge`);
} else if (neighbors.left === 0xFFFF) {
// At boundary - no neighbor exists
} else {
// Single neighbor (same or coarser level)
const neighborLevel = nodeView.getLevel(neighbors.left);
}Spatial Index
The spatial index uses typed arrays for GPU compatibility:
const spatialIndex = quadtree.getSpatialIndex();
// Get raw buffers for GPU upload
const { keys, values, size } = spatialIndex.getBuffers();
// keys: Uint32Array - packed (level, x, y) coordinates
// values: Uint16Array - node indices
// size: number - hash table size
// Manual lookup (usually not needed)
const nodeIndex = spatialIndex.lookup(level, x, y);Usage
React Three Fiber
import { useMemo } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { Quadtree } from "@hello-terrain/three";
function TerrainLOD() {
const { camera } = useThree();
const quadtree = useMemo(() => new Quadtree({
maxLevel: 8,
rootSize: 1000,
minNodeSize: 10,
origin: { x: 0, y: 0, z: 0 },
maxNodes: 4096,
}), []);
useFrame(() => {
// Update quadtree with camera position
quadtree.update(camera.position);
// Get leaf nodes for rendering
const leaves = quadtree.getLeafNodes();
// Use leaves to position terrain tile instances...
});
return null;
}Vanilla Three.js
import * as THREE from "three";
import { Quadtree, screenSpaceSubdivision, computeScreenSpaceInfo } from "@hello-terrain/three";
// Create quadtree - origin can be a plain object or THREE.Vector3
const quadtree = new Quadtree({
maxLevel: 8,
rootSize: 1000,
minNodeSize: 10,
origin: { x: 0, y: 0, z: 0 },
maxNodes: 4096,
});
// Use screen-space subdivision for consistent visual quality
quadtree.subdivisionStrategy = screenSpaceSubdivision({
targetTrianglePixels: 6,
getScreenSpaceInfo: () => {
const fovRadians = camera.fov * Math.PI / 180;
return computeScreenSpaceInfo(fovRadians, renderer.domElement.height);
},
});
// In render loop
function animate() {
// Update quadtree with camera position
quadtree.update(camera.position);
// Get active leaves for instanced rendering
const { indices, count } = quadtree.getActiveLeafNodeIndices();
// Use indices to update instance matrices...
requestAnimationFrame(animate);
}QuadtreeNodeView
The QuadtreeNodeView class manages the underlying typed array buffers that store quadtree node data. You typically don't need to interact with it directly, but it's useful for advanced use cases like GPU buffer uploads.
Buffer Access
const nodeView = quadtree.getNodeView();
const buffers = nodeView.getBuffers();Buffer Structures
The quadtree uses several typed array buffers to store node data efficiently. This structure-of-arrays (SoA) layout enables cache-friendly access patterns and direct GPU uploads without data transformation.
nodeBuffer
Type: Int32Array | Stride: 4 | Size: maxNodes × 4
Stores the core metadata for each node. This is the primary buffer for node identification and spatial positioning.
| Offset | Property | Type | Description |
|---|---|---|---|
+0 | level | Int32 | Subdivision depth (0 = root) |
+1 | x | Int32 | Grid coordinate X at this level |
+2 | y | Int32 | Grid coordinate Y at this level |
+3 | leaf | Int32 | Whether this node is a leaf (1) or branch (0) |
Usage: Node world position calculation, level-of-detail determination, GPU vertex shader transforms.
childrenIndicesBuffer
Type: Uint16Array | Stride: 4 | Size: maxNodes × 4
Stores indices to child nodes for tree traversal. Uses the sentinel value 0xFFFF to indicate no child (leaf nodes).
| Offset | Property | Type | Description |
|---|---|---|---|
+0 | children[0] | Uint16 | Top-left child index |
+1 | children[1] | Uint16 | Top-right child index |
+2 | children[2] | Uint16 | Bottom-left child index |
+3 | children[3] | Uint16 | Bottom-right child index |
Usage: Tree traversal, subdivision operations, parent-child relationship queries.
neighborsIndicesBuffer
Type: Uint16Array | Stride: 4 | Size: maxNodes × 4
Stores indices to neighboring nodes for LOD stitching. Uses the sentinel value 0xFFFF to indicate no neighbor (edge of terrain or not yet computed).
| Offset | Property | Type | Description |
|---|---|---|---|
+0 | neighbors[0] | Uint16 | Left neighbor index |
+1 | neighbors[1] | Uint16 | Right neighbor index |
+2 | neighbors[2] | Uint16 | Top neighbor index |
+3 | neighbors[3] | Uint16 | Bottom neighbor index |
Usage: Seamless LOD transitions, edge stitching to prevent T-junctions, skirt generation.
leafNodeMask
Type: Uint8Array | Stride: 1 | Size: maxNodes
A bitmask indicating which nodes are active leaves that should be rendered. Provides O(1) leaf status lookup.
| Value | Meaning |
|---|---|
0 | Branch node (not rendered directly) |
1 | Leaf node (should be rendered) |
Usage: Fast leaf filtering, GPU instancing, draw call generation.
leafNodeCountBuffer
Type: Uint16Array | Size: 1
Single-element buffer storing the total count of active leaf nodes. Updated automatically when nodes are marked as leaves.
Usage: Instance count for GPU draw calls, progress tracking, debugging.
activeLeafIndices
Type: Uint16Array | Size: maxNodes
A compact list of indices pointing to all currently active leaf nodes. Enables iteration over only renderable nodes without scanning the entire tree.
Usage: Efficient leaf iteration, GPU indirect draws, culling passes.
All indices use Uint16, supporting up to 65,534 nodes (with 0xFFFF reserved as the empty sentinel). For larger terrains, consider using multiple quadtree instances or tiling strategies.