Help support this project by starring the repo on GitHub!

Topology Types

Flat, infinite flat, and cube-sphere topologies — what each one does, their APIs, and how to set them up in a react-three/fiber scene

A topology defines the terrain's tile layout, shape, and bounds behavior. It tells the quadtree how root tiles are laid out, how neighboring tiles connect (including across edges), and how conservative bounds are computed for LOD decisions. It also carries an injected surface projection — the strategy that assembles GPU world positions/normals and powers the CPU query/raycast/LOD for that surface shape.

Hello Terrain ships four topologies:

TopologyFactorySpacesShape
Bounded flatcreateFlatTopology1 rootA single finite tile in the XZ plane (the default).
Infinite flatcreateInfiniteFlatTopology1 spaceA camera-centered grid of roots that extends forever.
Cube-spherecreateCubeSphereTopology6 rootsSix quadtree faces wrapped onto a sphere to form a planet.
ToruscreateTorusTopologybaseU × baseV rootsA closed donut surface, periodic in both axes.

All three are imported from @hello-terrain/three and assigned through the topology param.

import { useMemo } from "react";
import { Canvas, extend } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";
import * as THREE from "three/webgpu";
import {
  float, vec2, vec3, vec4, Fn, Loop,
  normalize, max, dot, normalWorld,
  floor, fract, mix, sin, cos,
} from "three/tsl";
import { Terrain, useTerrain } from "@hello-terrain/react";
import { createFlatTopology } from "@hello-terrain/three";
import type { ElevationCallback } from "@hello-terrain/three";

// Only the node material needs registering in R3F's catalogue; <Terrain>
// attaches its own mesh via <primitive>.
extend({ MeshBasicNodeMaterial: THREE.MeshBasicNodeMaterial });

// Ignore the following terrainLighting node, this is a workaround for Sandpack:
// Fake lighting. Sandpack's bundler loads core "three"
// (via fiber/drei) and "three/webgpu" (via this app) as separate instances, so
// the WebGPU renderer's node-lights registry can't match R3F scene lights
// ("Light node not found for AmbientLight") and the terrain would render unlit.
// Computing diffuse + ambient from the world normal sidesteps scene lights
// entirely. The terrain pipeline assigns world-space normals, so normalWorld is
// valid here.
const terrainLighting = Fn(() => {
  const baseColor = vec3(0.48, 0.52, 0.46);
  const lightDir = normalize(vec3(0.5, 0.8, 0.3));
  const ambient = float(0.3);
  const diff = max(dot(normalWorld, lightDir), float(0));
  return vec4(baseColor.mul(ambient.add(diff.mul(float(0.85)))), float(1));
});

// ── TSL Perlin / fBm elevation ─────────────────────────────
const randomGradient = Fn(([p]) => {
  const angle = fract(
    sin(dot(p, vec2(127.1, 311.7))).mul(43758.5453)
  ).mul(Math.PI * 2);
  return vec2(cos(angle), sin(angle));
});

const perlinNoise = Fn(([p]) => {
  const i = floor(p).toVar();
  const f = fract(p).toVar();
  const u = f.mul(f).mul(float(3).sub(f.mul(2)));
  const g00 = randomGradient(i);
  const g10 = randomGradient(i.add(vec2(1, 0)));
  const g01 = randomGradient(i.add(vec2(0, 1)));
  const g11 = randomGradient(i.add(vec2(1, 1)));
  const d00 = dot(g00, f);
  const d10 = dot(g10, f.sub(vec2(1, 0)));
  const d01 = dot(g01, f.sub(vec2(0, 1)));
  const d11 = dot(g11, f.sub(vec2(1, 1)));
  return mix(mix(d00, d10, u.x), mix(d01, d11, u.x), u.y).add(0.5);
});

const fbm = Fn(([pos]) => {
  const p = vec2(pos).toVar();
  const total = float(0).toVar();
  const amp = float(0.5).toVar();
  const freq = float(1).toVar();
  Loop(5, () => {
    total.addAssign(perlinNoise(p.mul(freq)).mul(amp));
    freq.mulAssign(2.03);
    amp.mulAssign(0.5);
  });
  return total;
});

// ── Scene ──────────────────────────────────────────────────

function Scene() {
  const topology = useMemo(() => createFlatTopology({
        rootSize: 256,
        origin: { x: 0, y: 0, z: 0 },
      }), []);
  const elevation = useMemo<ElevationCallback>(() => {
    return ({ worldPosition }) => {
  const p = vec2(worldPosition.x, worldPosition.z).mul(float(0.02));
  return fbm(p).sub(float(0.3));
    };
  }, []);
  const outputNode = useMemo(() => terrainLighting(), []);

  const terrain = useTerrain({
    topology,
    maxLevel: 10,
    maxNodes: 512,
    innerTileSegments: 13,
    elevationScale: 40,
    skirtScale: 8,
    elevation,
  });

  return (
    <Terrain terrain={terrain} innerTileSegments={13} maxNodes={512}>
      {({ positionNode }) => (
        <meshBasicNodeMaterial positionNode={positionNode} outputNode={outputNode} />
      )}
    </Terrain>
  );
}

export default function App() {
  return (
    <Canvas
      style={{ width: "100vw", height: "100vh" }}
      gl={async (props) => {
        props.alpha = true;
        props.antialias = true;
        const renderer = new THREE.WebGPURenderer(props);
        await renderer.init();
        return renderer;
      }}
      camera={{ position: [0, 80, 150], near: 0.1, far: 5000 }}
      dpr={[1, 1]}
      performance={{ min: 0.5 }}
    >
      <Scene />
      <OrbitControls makeDefault />
    </Canvas>
  );
}

How a Topology Is Selected

The terrain graph reads the topology param to derive its tile layout. When topology is null (the default), the graph falls back to a bounded (finite) flat topology built from rootSize and origin:

import { topology, createInfiniteFlatTopology } from "@hello-terrain/three";

// Default: leave `topology` unset → bounded flat topology from rootSize/origin.

// Or assign an explicit topology:
graph.set(topology, () =>
  createInfiniteFlatTopology({ rootSize: 256, origin: { x: 0, y: 0, z: 0 } }),
);

In @hello-terrain/react, the same value is passed as the topology option/prop on useTerrain() and <Terrain>.

graph.set(topology, () => createInfiniteFlatTopology(...)) takes graph-local ownership of the param. This is the idiomatic @hello-terrain/work pattern that lets multiple terrain instances coexist without sharing topology state.

Flat Topology (default)

createFlatTopology produces a single finite root tile lying in the XZ plane. Elevation displaces vertices along +Y. This is what you get when you leave topology unset, so most simple scenes never call it directly.

import { createFlatTopology } from "@hello-terrain/three";

const flat = createFlatTopology({
  rootSize: 256,
  origin: { x: 0, y: 0, z: 0 },
});

FlatTopologyConfig

FieldTypeDefaultDescription
rootSizenumberWorld-space size of the root tile edge. The tile covers [-rootSize/2, +rootSize/2] around origin in X/Z.
origin{ x, y, z }World-space origin of the root tile.

Because the topology is bounded, neighborSameLevel returns false at the outer edges — there is no terrain beyond the root tile. See the Bounded flat tab above for a runnable scene.

Infinite Flat Topology

createInfiniteFlatTopology extends a flat topology infinitely by placing a grid of root tiles centered on the camera. As the camera moves, the root grid re-centers so terrain is always present in every direction.

import { createInfiniteFlatTopology } from "@hello-terrain/three";

const infinite = createInfiniteFlatTopology({
  rootSize: 256,
  origin: { x: 0, y: 0, z: 0 },
  rootGridRadius: 1,
});

InfiniteFlatTopologyConfig

FieldTypeDefaultDescription
rootSizenumberWorld-space size of each root tile edge.
origin{ x, y, z }World-space origin of the root grid.
rootGridRadiusnumber1Half-width of the root grid in root tiles. 1 → a 3×3 grid, 2 → 5×5, etc.

Key differences from bounded flat

  • No boundaryneighborSameLevel always succeeds, so a tile at the edge of one root connects seamlessly to a tile in the neighboring root.
  • Camera-centered rootsrootTiles recomputes which level-0 tiles surround the camera each frame.
  • Signed tile coordinates — roots can have negative (x, y) positions when the camera is near the origin.

Swapping createFlatTopology for createInfiniteFlatTopology is the only change from the bounded flat scene. Try the Infinite flat tab above and orbit the camera — fresh root tiles stay present in every direction.

See the Infinite Flat Terrain example for a richer interactive demo.

Cube-Sphere Topology

createCubeSphereTopology wraps six independent quadtree faces around a sphere to produce a full planet. Each face refines toward the camera with the same LOD machinery as the flat topologies, and the topology stitches faces together so the planet behaves as one continuous surface.

import { createCubeSphereTopology } from "@hello-terrain/three";

const planet = createCubeSphereTopology({
  radius: 1000,
  center: { x: 0, y: 0, z: 0 },
});

CubeSphereTopologyConfig

FieldTypeDefaultDescription
radiusnumberSphere radius in world units.
center{ x, y, z }{ 0, 0, 0 }Planet center in world space.
invertbooleanfalseWhen true, elevation displaces inward and skirts point outward.

Unlike the flat topologies, the cube-sphere injects a cube-sphere projection (its kind is "cubeSphere") along with its radius and center. The GPU position pipeline calls into the projection to map each tile vertex from its face-local (u, v) onto the cube, normalize it onto the unit sphere, scale by radius, and displace it radially by elevation. Normals are reconstructed from the displaced neighbor positions so lighting follows the planet's curvature.

Cross-face topology (neighborSameLevel) is derived numerically from a shared cube-face basis, so tiles that cross a face edge — including the rotated edges near the poles — resolve to the correct neighbor for 2:1 balancing and seam filling.

The same graph wiring drives a full planet — only the topology, the radius param, and the camera distance change. Open the Cube-sphere tab above and orbit around to see the six faces refine toward the camera.

See the Cube-Sphere Planet example for a richer interactive demo.

Torus Topology

createTorusTopology produces a closed donut surface that is periodic in both axes: u sweeps around the major circle (the ring) and v sweeps around the tube cross-section. Because both axes wrap, every tile has a same-level neighbor.

Because the major circumference (2π × majorRadius) is typically much longer than the tube circumference (2π × minorRadius), the topology auto-derives an anisotropic base resolution so root tiles are approximately square in world space:

  • baseU = round(majorRadius / minorRadius) tiles along u
  • baseV = 1 tile along v

At level L, resolution is baseU × 2^L by baseV × 2^L tiles. Isotropic 2×2 subdivision then keeps descendants square, so LOD distance metrics behave correctly without stretched tiles. The torus is primarily a stress test of the topology + projection abstraction — adding it required no changes to the GPU pipeline, the CPU query/raycast machinery, or the React runtime.

import { createTorusTopology } from "@hello-terrain/three";

const donut = createTorusTopology({
  majorRadius: 1000,
  minorRadius: 360,
  center: { x: 0, y: 0, z: 0 },
});

TorusTopologyConfig

FieldTypeDefaultDescription
majorRadiusnumberDistance from the donut center to the tube center.
minorRadiusnumberRadius of the tube cross-section.
center{ x, y, z }{ 0, 0, 0 }Torus center in world space.
invertbooleanfalseWhen true, elevation displaces inward and skirts point outward.

The torus injects a torus projection (its kind is "torus", faceOutward: true, baseResolution: { u: baseU, v: baseV }). neighborSameLevel wraps x modulo baseU × 2^level and y modulo baseV × 2^level, and tileBounds samples a 3×3 grid across each tile so that tiles spanning a full circle still produce a conservative bounding sphere.

See the Torus example for a richer interactive demo.

Elevation-aware LOD bounds

LOD subdivision uses each tile's real elevation range, not a fixed padding constant. After each GPU readback, the terrain cache builds a conservative elevation pyramid: every leaf's min/max field values are propagated up to all ancestor tiles so coarse tiles know about mountains beneath them.

During quadtreeUpdate, the graph wires UpdateParams.tileElevationRange from the previous-frame snapshot. Each topology's tileBounds then displaces its sampled corners (or vertical extent on flat surfaces) by that range, which is what makes LOD distance surface-relative — no extra camera offset is applied. Newly-seen areas fall back to the datum surface for one frame until readback data arrives.

You do not configure this manually; it is automatic when the terrain query readback is active.

The Topology Type

All four factories return the same Topology shape. You rarely implement this by hand, but it's useful to know what the quadtree consumes:

type Topology = {
  spaceCount: number;
  maxRootCount: number;
  /** Injected surface projection (GPU position/normal + CPU query/raycast/LOD). */
  projection: SurfaceProjection;

  neighborSameLevel(tile: TileId, dir: Dir, out: TileId): boolean;
  tileBounds(
    tile: TileId,
    cameraOrigin: { x: number; y: number; z: number },
    out: TileBounds,
    elevationRange?: { min: number; max: number },
  ): void;
  rootTiles(cameraOrigin: { x: number; y: number; z: number }, out: TileId[]): number;
};
  • spaceCount — number of independent root spaces (1 for flat/torus, 6 for the cube-sphere's faces).
  • maxRootCount — the maximum number of roots rootTiles can emit in a frame.
  • projection — the injected SurfaceProjection strategy. The pipeline never branches on a projection kind; it calls into the projection's GPU and CPU hooks. The built-in factories set this for you.
  • neighborSameLevel — fills the same-level neighbor in a direction; returns false when there is no neighbor (e.g. the edge of a bounded topology).
  • tileBounds — writes a conservative, camera-relative bounding sphere used for LOD decisions. When an elevation range is supplied, bounds account for displaced geometry.
  • rootTiles — fills the active level-0 tiles for the current frame and returns the count.

Setting Up a Topology in react-three/fiber

There are two ways to wire a topology into an R3F scene, depending on which API you use.

High-level: <Terrain> / useTerrain()

The React bindings accept topology as an option. Pass a memoized topology so it isn't recreated every render:

import { Terrain } from "@hello-terrain/react";
import { createCubeSphereTopology } from "@hello-terrain/three";
import { Canvas } from "@react-three/fiber";
import { useMemo } from "react";
import { float } from "three/tsl";
import * as THREE from "three/webgpu";
import type { WebGPURendererParameters } from "three/src/renderers/webgpu/WebGPURenderer.js";

const elevation = () => float(0);

export function Planet() {
  const topology = useMemo(
    () => createCubeSphereTopology({ radius: 1000, center: { x: 0, y: 0, z: 0 } }),
    [],
  );

  return (
    <Canvas
      gl={async (props) => {
        const renderer = new THREE.WebGPURenderer(props as WebGPURendererParameters);
        await renderer.init();
        return renderer;
      }}
      camera={{ position: [0, 0, 3000], near: 0.1, far: Number.MAX_SAFE_INTEGER }}
    >
      <ambientLight intensity={0.15} />
      <directionalLight intensity={1} position={[1, 1, 1]} />

      <Terrain topology={topology} radius={1000} maxLevel={12} elevation={elevation}>
        {({ positionNode }) => (
          <meshStandardNodeMaterial positionNode={positionNode} />
        )}
      </Terrain>
    </Canvas>
  );
}

Swapping createCubeSphereTopology for createInfiniteFlatTopology (or omitting topology entirely for the default bounded flat) is all that changes between flat and sphere scenes — the rest of the rendering pipeline stays the same.

Low-level: setting the topology param on a graph

When you drive a terrainGraph() directly, set the topology param yourself. Re-assign it from an effect whenever the topology config changes:

import {
  createInfiniteFlatTopology,
  topology,
  terrainGraph,
} from "@hello-terrain/three";
import { useEffect, useMemo } from "react";

function useInfiniteFlatGraph(rootSize: number, rootGridRadius: number) {
  const g = useMemo(() => terrainGraph(), []);

  useEffect(() => {
    g.set(topology, () =>
      createInfiniteFlatTopology({
        rootSize,
        origin: { x: 0, y: 0, z: 0 },
        rootGridRadius,
      }),
    );
  }, [g, rootSize, rootGridRadius]);

  return g;
}

Because the topology param feeds the topologyTask, re-assigning it re-derives the quadtree tile layout and keeps CPU LOD and GPU geometry in sync. The Infinite Flat and Cube-Sphere Planet example sources show this pattern end to end.