Help support this project by starring the repo on GitHub!

Changelog

Release notes for the Hello Terrain packages

Release notes for @hello-terrain/three and @hello-terrain/react. These two ship together, so they share a version.

0.0.0-alpha.14 — Jupiter Stable Orbit

Heightmap and high-relief terrain should no longer show visible stairstepping in geometry and slope shadows, especially visible near ridges. The terrain field uses half-float storage (rgba16float), but elevation is now stored per-tile normalized so float16 precision tracks local relief instead of absolute meters at mountain scale. This means each tile consumes the entire range/bit depth of f16, so each tile will be variably precise per relief. Near / Higher LOD tiles will have more precision, normally, as the area size is smaller and therefore elevation delta is minimized.

Before / After

Click any image to zoom.

Before (alpha.13)After (alpha.14)
Terracing visible in slope shadows — view 1Smooth slopes without terracing — view 1
Terracing visible in slope shadows — view 2Smooth slopes without terracing — view 2

Per-tile normalized terrain field

Each tile's .r channel stores elevation in [0, 1] relative to that tile's pack bounds (packMin / packMax), then denormalizes at sample time before elevationScale is applied. At ~3000 m of local relief this cuts float16 quantization error from ~2 m down to ~0.2 m; on typical 50–200 m tiles the error drops to sub-centimeter scale.

The compute pipeline now runs elevation → bounds reduction → terrain-field pack, so pack bounds are available before the field is written. Tile bounds storage grows from two to four floats per tile:

  • [0] / [1] — LOD min/max (inner vertices only; unchanged semantics for readback and subdivision)
  • [2] / [3] — pack min/max (all vertices, including skirts)

Render and GPU sampler paths bilinearly filter the terrain field for both elevation and normals.

Heightmap TSL helpers

New exports for RG-packed 16-bit PNG heightmaps:

import { decodeUint16RG, sampleHeightmapMeters } from "@hello-terrain/three";

const elevation: ElevationCallback = ({ rootUV }) =>
  sampleHeightmapMeters(heightmap, rootUV, float(minM), float(maxM), float(rangeM));

Under the hood

  • New TSL helpers: packNormalizedTerrainFieldSample, denormalizeTerrainFieldElevation, loadTilePackBounds
  • midPipelineExecute hook on compute pipelines for bounds reduction between upstream stages and terrain-field pack
  • CPU readback consumers updated for the 4-float tile-bounds stride (LOD fields remain at offsets 0/1)
  • @hello-terrain/react: TerrainProps is now a discriminated union — when you pass a terrain handle from useTerrain(), TypeScript no longer incorrectly requires rootSize, origin, maxLevel, and the other construction options on <Terrain>.

0.0.0-alpha.13 — Freezing Venus

LOD now subdivides against the real terrain relief instead of a flat datum. This fixes a regression from the last release.

Surface-relative subdivision

Each tile's LOD bounding sphere is now built from its elevation:

  • Skirts are excluded from the per-tile elevation range, so the bounds track the visible surface rather than the downward skirt geometry.
  • Flat topologies measure vertical extent from the tile's own min/max displacement instead of a datum-relative height, fixing tiles that split too eagerly far from the camera.
  • Remove camera "surface offset" hack, so that elevation is accounted for in the bounds, fixing a double-count.

Fixes 🐛

  • Inverted cube-sphere picking. Raycasts and surface queries on an invert: true cube-sphere now land on the correct side of the planet with correctly oriented (inward) normals. Previously hits were mirrored to the opposite face.
  • Stale picks after geometry changes. Changing a curved surface's geometry at runtime (cube-sphere radius/center, torus majorRadius/minorRadius, invert) now rebuilds the CPU surface math, so markers and raycasts use the new geometry instead of the old radius.
  • Offset tile splitting on flat and infinite-flat topologies, caused by the datum-relative bounds radius described above.

Breaking ⚠️

  • Topology.radius / Topology.center removed. These were duplicated on the topology; read them from topology.projection.radius / topology.projection.center instead.
  • UpdateParams LOD fields restructured into a LodCriteria discriminated union. mode: "screen" now requires both projectionFactor and targetPixels; mode: "distance" (the default) takes an optional distanceFactor. The standalone hysteresis and elevationAtCameraXZ fields were removed.
  • tileElevationRange callback signature changed from (space, level, x, y, out) to (tile: TileId, out).
  • SurfaceProjectionCpu.cameraSurfaceOffset removed along with the camera offset mechanism.

0.0.0-alpha.12 — Consensus Control

Terrain can now wrap a torus. Let's mainstream more fantastical world shapes! LOD and terrain querying are supported just as for planes and spheres.

Spinning up a donut

import { Terrain, useTerrain } from "@hello-terrain/react";
import { createTorusTopology } from "@hello-terrain/three";
import { useMemo } from "react";

function Donut({ elevation }) {
  const topology = useMemo(
    () => createTorusTopology({ majorRadius: 1000, minorRadius: 360 }),
    [],
  );

  const terrain = useTerrain({
    topology,
    maxLevel: 16,
    elevationScale: 40,
    elevation,
  });

  return (
    <Terrain terrain={terrain} frustumCulled={false}>
      {({ positionNode }) => (
        <meshStandardNodeMaterial positionNode={positionNode} />
      )}
    </Terrain>
  );
}

See it running on the torus example page.

Querying a closed surface

The torus doesn't have a tidy lat/long, so it uses the generic surface query: give it a world point in vec3 and it projects onto the surface for you.

// `probe` is a THREE.Vector3 somewhere near the surface
const query = terrain.runtime.surfaceQuery;
const sample = query.sampleTerrainByPosition(probe);

if (sample.valid) {
  marker.position.copy(sample.position);
  marker.quaternion.setFromUnitVectors(up, sample.normal);
}

// Pointer/raycast picking works identically across every topology
const hit = terrain.runtime.raycast.pick(event.ray);

Turn it inside out

Sphere and torus projections now take an invert flag. Flip it and elevation displaces inward while skirts point outward.

createCubeSphereTopology({ radius: 1000, invert: true });
createTorusTopology({ majorRadius: 1000, minorRadius: 360, invert: true });

What's New

  • createTorusTopology(...): a closed donut world with full render, LOD, query, and raycast parity.
  • Projection dependency injection: topologies carry a SurfaceProjection strategy instead of using internal switching. New projection/ module exports createFlatProjection, createCubeSphereProjection, and createTorusProjection (plus CubeSphereProjectionConfig / TorusProjectionConfig) for examples.
  • invert option on the sphere and torus projection/topology configs.
  • Elevation-aware subdivision via a new tile elevation "pyramid" from n-1 frame readback, so LOD reacts to elevation height better.
  • New torus example.
  • GPU objects are now labeled for the WebGPU Inspector to make profiling easier.

Fixes 🐛

  • Continuous normals at cube-sphere seams. The terrain field now stores true world-space normals [height, Nx, Ny, Nz] (computed from neighbor world positions), so shading no longer breaks across cube-face seams and tile edges.
  • GPU readback buffer leak fixed. Three's getArrayBufferAsync was leaking a _readback buffer every frame; it's been replaced with a reused, properly destroyed staging buffer. This reduces hitching.

0.0.0-alpha.11 — No Cuts Appropriate

You can now wrap terrain onto a cube-sphere and build planets, with seam-stitched LOD all the way down to the surface.

This release also introduces a new Terrain Query API for asking the terrain questions on the CPU, and renames the old "surface" concept to "topology" everywhere for consistency.

Building a planet

import { Terrain, useTerrain } from "@hello-terrain/react";
import { createCubeSphereTopology } from "@hello-terrain/three";
import { useMemo } from "react";

function Planet({ elevation }) {
  const topology = useMemo(
    () => createCubeSphereTopology({ radius: 1000 }),
    [],
  );

  const terrain = useTerrain({
    topology,
    radius: 1000,
    maxLevel: 16,
    elevationScale: 60,
    elevation,
  });

  return (
    <Terrain terrain={terrain} frustumCulled={false}>
      {({ positionNode }) => (
        <meshStandardNodeMaterial positionNode={positionNode} />
      )}
    </Terrain>
  );
}

There's a full runnable version on the cube-sphere example page.

Asking the planet questions

Cube-sphere worlds get a sphere query keyed by latitude/longitude.

const query = terrain.runtime.sphereQuery;
const sample = query.sampleTerrainByLatLong(20, 40);

if (sample.valid) {
  marker.position.copy(sample.position);
  marker.quaternion.setFromUnitVectors(up, sample.normal);
}

Raycasting now lives in its own path and works on every topology:

const hit = terrain.runtime.raycast.pick(ray);
// hit.position, hit.normal

What's New

  • createCubeSphereTopology(...) — six quadtree faces wrapped onto a sphere, with seam-stitching and curvature-aware LOD. Assign it through the topology param.
  • Terrain Query API — a consolidated CPU surface for elevation/position lookups, plus dedicated sphere queries for cube-sphere worlds.
  • Raycasting split into its own terrain-raycast path with a generic signed-distance march.
  • Terrain Sampler unified into a single builder with cached results.
  • New docs for topologies: Topology, Terrain Query, Raycasting, Terrain Sampler, and the cube-sphere example.
  • @hello-terrain/react tracks above changes

Under the hood

  • CPU/GPU terrain math deduped. The 800+ line cpu-terrain-cache.ts was split into focused terrain-snapshot, tile-lookup, and elevation-field-sampling modules, with shared field/sphere math co-located in gpu/tile.ts behind CPU↔TSL parity tests.
  • nodes/ folded into tsl/. and other small re-arranging.
  • Added a typecheck script and fixed the docs type-check.

Breaking ⚠️

  • surfacetopology. The concept formerly called "surface" is now "topology" across the API, types, and docs. Update surface references and any deep imports from quadtree/surface/* (now quadtree/topology/*).
  • Dead exports removed during the nodes/tsl/ consolidation, including createTileRender, readElevationFieldVertex, sampleTerrainFieldNormal, and the public Texture3DBackend.