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.




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 midPipelineExecutehook 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:TerrainPropsis now a discriminated union — when you pass aterrainhandle fromuseTerrain(), TypeScript no longer incorrectly requiresrootSize,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: truecube-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, torusmajorRadius/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.centerremoved. These were duplicated on the topology; read them fromtopology.projection.radius/topology.projection.centerinstead.UpdateParamsLOD fields restructured into aLodCriteriadiscriminated union.mode: "screen"now requires bothprojectionFactorandtargetPixels;mode: "distance"(the default) takes an optionaldistanceFactor. The standalonehysteresisandelevationAtCameraXZfields were removed.tileElevationRangecallback signature changed from(space, level, x, y, out)to(tile: TileId, out).SurfaceProjectionCpu.cameraSurfaceOffsetremoved 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
SurfaceProjectionstrategy instead of using internal switching. Newprojection/module exportscreateFlatProjection,createCubeSphereProjection, andcreateTorusProjection(plusCubeSphereProjectionConfig/TorusProjectionConfig) for examples. invertoption 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
getArrayBufferAsyncwas leaking a_readbackbuffer 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.normalWhat's New
createCubeSphereTopology(...)— six quadtree faces wrapped onto a sphere, with seam-stitching and curvature-aware LOD. Assign it through thetopologyparam.- 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-raycastpath 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/reacttracks above changes
Under the hood
- CPU/GPU terrain math deduped. The 800+ line
cpu-terrain-cache.tswas split into focusedterrain-snapshot,tile-lookup, andelevation-field-samplingmodules, with shared field/sphere math co-located ingpu/tile.tsbehind CPU↔TSL parity tests. nodes/folded intotsl/. and other small re-arranging.- Added a
typecheckscript and fixed the docs type-check.
Breaking ⚠️
surface→topology. The concept formerly called "surface" is now "topology" across the API, types, and docs. Updatesurfacereferences and any deep imports fromquadtree/surface/*(nowquadtree/topology/*).- Dead exports removed during the
nodes/→tsl/consolidation, includingcreateTileRender,readElevationFieldVertex,sampleTerrainFieldNormal, and the publicTexture3DBackend.