Quadtree

Fast level-of-detail terrain subdivision

Tiles: 0
Deepest Level: 0
LOD Levels
Level 0
Level 1
Level 2
Level 3
Level 4
Level 5
Hover to see neighbors

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?);
ParameterTypeDescription
configQuadtreeParamsConfiguration object (see below)
subdivisionStrategySubdivisionStrategyOptional. Strategy function for subdivision decisions. Defaults to distanceBasedSubdivision(2).
nodeViewQuadtreeNodeViewOptional. Pre-allocated node view for buffer reuse.

QuadtreeParams

PropertyTypeDescription
maxLevelnumberMaximum subdivision depth. Higher values allow finer detail.
rootSizenumberWorld-space size of the root tile (edge length).
minNodeSizenumberMinimum allowed tile size. Prevents over-subdivision.
originVector3LikeWorld-space origin of the terrain center. Any object with { x, y, z } properties.
maxNodesnumberMaximum 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);
ParameterTypeDescription
positionVector3LikeCamera 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 entries

subdivisionStrategy

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);
ParameterTypeDescription
nodeIndexnumberIndex of the node to find neighbors for
directionDirectionDirection.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[]
ParameterTypeDescription
nodeIndexnumberIndex 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: Uint16Array

Other Methods

MethodReturnsDescription
getNodeCount()numberTotal number of nodes in the tree
getLeafNodeCount()numberNumber of active leaf nodes
getDeepestLevel()numberMaximum subdivision depth reached
getConfig()QuadtreeParamsCurrent configuration
setConfig(config, reset?)voidUpdates configuration. Pass reset: true to reinitialize the tree.
getNodeView()QuadtreeNodeViewDirect access to the underlying buffer view
getSpatialIndex()SpatialIndexTyped array spatial index for neighbor lookups
reset()voidClears the tree and recreates the root node
destroy()voidReleases 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
ParameterTypeDefaultDescription
factornumber2Multiplier 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);
  },
});
OptionTypeDefaultDescription
targetTrianglePixelsnumber6Maximum triangle size in screen pixels
tileSegmentsnumber13Segments per tile edge (should match TerrainGeometry)
getScreenSpaceInfo() => ScreenSpaceInfo | nullRequiredCallback 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.

OffsetPropertyTypeDescription
+0levelInt32Subdivision depth (0 = root)
+1xInt32Grid coordinate X at this level
+2yInt32Grid coordinate Y at this level
+3leafInt32Whether 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).

OffsetPropertyTypeDescription
+0children[0]Uint16Top-left child index
+1children[1]Uint16Top-right child index
+2children[2]Uint16Bottom-left child index
+3children[3]Uint16Bottom-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).

OffsetPropertyTypeDescription
+0neighbors[0]Uint16Left neighbor index
+1neighbors[1]Uint16Right neighbor index
+2neighbors[2]Uint16Top neighbor index
+3neighbors[3]Uint16Bottom 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.

ValueMeaning
0Branch node (not rendered directly)
1Leaf 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.