Taos Engine

Terrain & Worlds

Terranaut — Technical Deep Dive

A spherical, fully editable marching-cubes planet you can walk around. Gravity always points toward the world origin, so the astronaut roams the whole sphere; the terrain is sculpted live by raycasting the camera's aim ray and editing the density volume under the hit point. Built on the new deferred render graph — cascaded shadows, GTAO, atmosphere sky, dynamic-sky IBL, spherical-shell volumetric clouds, TAA, bloom and auto-exposure — with a player-pinned sun so the whole planet stays lit no matter where you walk.

File map#

Everything lives in samples/terranaut.{html,ts} plus the support folder samples/terranaut/:

FileRole
terranaut.htmlPage shell + crosshair + #info HUD + #loading splash.
terranaut.tsmain(): assets, scene objects, controller, all passes, the per-frame render graph, player-pinned sun + IBL re-bake, terraforming.
terranaut/planet_density.tsCPU PlanetDensity: the 64³ density volume — generate (sphere SDF + ridged fbm + floating-island shell), edit (sphere brush), sampleDensity (trilinear), raycast (ray-march). The single source of truth for the terrain.
terranaut/planet_mc_pass.tsPlanetMarchingCubesPass: GPU density mirror, vertex/counter/indirect buffers, the march compute, the G-Buffer render pipeline, the depth-only shadow caster pipeline.
terranaut/mc_march.wgslcs_march (classifies cells, interpolates edge crossings, emits triangles with density-gradient normals so the curved planet shades smooth) and cs_write_indirect.
terranaut/mc_gbuffer.wgslProgrammable vertex-pulling shader. vs_main / fs_main fill the G-Buffer (slope-blended grass / rock); vs_shadow is depth-only for the cascade pipeline.
terranaut/mc_tables.tsThe standard 256-entry edge / triangle tables.
terranaut/astronaut_controller.tsPlanet-gravity third-person controller — tangent-plane movement, jump + radial gravity, orbit camera in the local tangent frame, animation state machine, pointer-lock mouse-look.

Controls#

ActionKey
MoveWASD
Runhold Shift
JumpSpace
Lookmouse (the canvas captures the cursor on first click)
Add terrainleft-mouse
Carve terrainright-mouse
Brush size[ / ]
Camera zoommouse wheel
Toggle auto-exposureE
Regenerate planetR

A small crosshair in the screen center shows where the brush will act. A translucent green (add) or red (carve) sphere at the camera-ray hit shows the brush volume.

Pipeline — the per-frame render graph#

ProbeMC compute              cs_march + cs_write_indirect (only on edit frames)
ShadowPass                   skinned astronaut → cascade depth (clears)
  ↳ PlanetMC shadow ×N       MC terrain → each cascade depth (loads), drawIndirect
PlanetMC.addToGraph          MC → G-Buffer (clear), drawIndirect
SkinnedGeometryPass          astronaut → G-Buffer (load)
GTAOPass                     normals + depth → AO
AtmospherePass               sky → HDR (clear, view frame rotated so the
                             astronaut's local up is the zenith)
DeferredLightingPass         G-Buffer + shadow + AO + dynamic-sky IBL → HDR
                             (disableHorizonFade=1 — see §8)
AutoExposurePass             histogram of the lit HDR → exposure buffer
BrushGizmoPass               translucent sphere at the aim hit (inline pass)
PlanetCloudPass              spherical shell ray-march → HDR (overlay, see §7)
TAA → Bloom → CompositePass → backbuffer

PlanetMC imports its indirect buffer via graph.importExternalBuffer so the compute pass isn't culled; the G-Buffer and every shadow sub-pass b.read(indirect, 'indirect') to order the draws after the compute.

§1 — Density volume (CPU, single source of truth)#

The density volume lives on the CPU as a Float32Array of 64³ samples. Editing, ray-casting for grounding, and ray-casting for terraforming all run on the CPU array — exact, synchronous, no GPU read-back latency. After every edit the dirty volume is uploaded once via queue.writeBuffer and the GPU marcher re-polygonizes from it.

Density convention: negative = solid, positive = air, isosurface at 0. The planet is centered on the world origin.

PlanetDensity.generate() builds the field analytically:

TypeScript
density = r - PLANET_RADIUS
        - heightFbm(p) - ridgedMountains(p)    // surface relief in world units
        - islandMass(p) * shellMask(r)         // floating islands in the shell above

The outermost cell ring is forced to +40 (air) so the marched mesh is always closed even if you sculpt out to the volume bounds.

PlanetDensity.edit(center, radius, strength) applies a sphere brush with smoothstep falloff in place, marking the volume dirty. raycast steps along the ray at half-cell size, finds the first negative crossing, refines with five bisection steps, and returns the hit + the gradient normal — used for both astronaut grounding and the brush aim.

§2 — Marching cubes (mc_march.wgsl, planet_mc_pass.ts)#

The kernel is the standard pure-GPU pipeline: one compute invocation per cell classifies its 8 corners, looks up an edge mask + triangle list from the preloaded tables, interpolates the edge crossings, and atomicAdds into a shared vertex buffer. A tiny follow-up workgroup copies the counter into a 4-word drawIndirect args buffer so the host never reads the triangle count.

Two refinements over the previous flat-terrain sample:

The render side keeps the original sample's programmable vertex pulling: the vertex buffer is bound as a read-only storage buffer (no vertex attributes declared on the pipeline) and drawIndirect supplies the vertex count.

The G-Buffer fragment is a low-poly stylized surface: rock on cliffs, grass on flats (dot(N, radial_up)), plus a coarse per-block hash tint so flat fields aren't dead-flat.

§3 — Astronaut#

AstronautSmooth.glb is loaded by the engine's GltfLoader. The model is an FBX2glTF export whose inverse-bind matrices omit the Armature node's 100× scale and -90° X rotation, so the skinned (animated) pose comes out 100× too big and sideways. The sample applies the same fix used by the glTF viewer — pre- multiply Mat4.rotationX(π/2) · Mat4.scale(0.01) onto skin.rootTransform — which corrects the animated pose without touching the bind-pose mesh.

The astronaut is rendered through the engine's existing SkinnedGeometryPass (GPU skinning into the G-Buffer) and cast into every shadow cascade through ShadowPass's ShadowSkinnedDraw path, so its shadow tracks the live pose.

§4 — Controller (planet gravity)#

up = normalize(pos - planetCenter) is recomputed every frame from the astronaut's world position; movement is in the tangent plane around that up; the astronaut's facing and the orbit-camera's forward are both carried as persistent tangent vectors parallel-transported each frame (re-projected onto the new tangent plane). That means there's no fixed world-up reference — crossing the planet's poles is seamless.

§5 — Terraforming#

Each frame the sample casts a ray from camera.position along camera.forward (the cross-hair direction) into the density volume. When the ray hits a surface and a mouse button is held, density.edit(hit, brushRadius, ±BRUSH_RATE*dt) modifies the volume — left-click adds material (positive strength → density pushed negative → solid), right-click carves (negative strength → density pushed positive → air). The volume's dirty flag is checked once per frame; if set, the CPU array is re-uploaded to the GPU density buffer and the marcher is told to re-march.

The brush gizmo is a translucent sphere drawn from an inline tiny pipeline into the HDR after lighting (so it never pollutes the G-Buffer), colored green for add and red for carve.

§6 — Sky#

AtmospherePass.setPlanet({ radius: PLANET_RADIUS, atmosphereHeight: 12 }) puts the pass into planet mode, where the shader's "zenith" is computed per-camera as normalize(cameraPos) rather than world +Y — see atmosphere.wgsl. No host-side view-frame rotation is required; the per-spot horizon, day-night fade and stars all track the camera's radial direction natively. The dynamic-sky IBL is left in world space — it's an ambient contribution, so any slight inconsistency between visible sky (local-up) and ambient (world-up) is invisible.

§7 — Volumetric clouds (spherical shell)#

Clouds use a new PlanetCloudPass (engine-side, under src/renderer/render_graph/passes/) that ray-marches a thin spherical shell between planetRadius + cloudBaseAlt and planetRadius + cloudTopAlt centered on the world origin. The flat-world CloudPass was unusable here — its slab assumes world +Y up and would slice across the screen as you walk.

Pass shape#

Shell intersection — shell_range(ro, rd, r_inner, r_outer)#

The single-range traversal handles three camera positions:

Planet-aware density — pc_sample_density(p, r_inner, r_outer, …)#

Same Perlin-Worley field as the flat clouds (large + medium passes, detail erosion), with one critical difference: the height gradient is driven by length(p) (radial altitude from the planet center) instead of world p.y. The noise pattern is still sampled in world space, so clouds at a given world position look the same to every observer — there's just no "Y is up" assumption baked into the density profile.

The two-tap light_march toward the sun is identical to the flat-world version, except it clips against the radial shell (r < r_inner || r > r_outer) instead of a Y range.

Adaptive step count#

A ray crossing the shell straight up traverses (cloudTopAlt − cloudBaseAlt) units; a grazing ray crosses several times that. The step count is clamp(span_len / (Δalt × 0.4), 16, 64), so short overhead rays get the minimum and long grazing spans get up to 64 taps. Step size is the same per pixel within a frame, jittered by an IGN hash so TAA resolves the cloud edge instead of dithering it.

Limitations (v1)#

§8 — Lighting (player-pinned sun)#

There is no day/night cycle. The sun is pinned to the astronaut's local tangent frame every frame, so wherever you walk on the sphere, the sun is always overhead. Walking around the planet rotates the sun smoothly with the astronaut's up; turning in place does not.

Sun direction each frame#

TypeScript
const SUN_TILT = 0.45;                     // ~26° off zenith
const up        = astro.up;                // radial — planet center → astronaut
let tangentX    = worldX − (worldX · up) · up;   // azimuth reference
if (tangentX is degenerate)                // up nearly parallel to world +X
  tangentX = worldZ − (worldZ · up) · up;
toward_sun     = up * cos(SUN_TILT) + normalize(tangentX) * sin(SUN_TILT);
sun.direction  = −toward_sun;              // engine wants light-travel dir

The tilt reference is decoupled from the astronaut's heading on purpose — a heading-derived tilt would swing the sun every time you turn, which reads as flickering. A world-axis reference rotates the sun only when you actually translate across the sphere.

IBL re-bake on a sun-angle gate#

DynamicSky.bake() re-renders the equirectangular sky panorama and the convolved IBL cubes so they track the (astronaut-pinned) sun. The bake is ~1–2 ms, so it's gated on the toward-sun direction having moved past cos(0.02 rad) (≈1.1°) since the last bake — invisible at the IBL's effective angular resolution, but it keeps the panorama + IBL convolve off the per-frame hot path. The IBL bake exposure is set higher than other samples (DynamicSky.create(ctx, 0.7)) so shadowed pixels — which only see the IBL — don't crush to black against the bright lit side of the curved terrain.

disableHorizonFade — the bug that motivated the new flag#

DeferredLightingPass's default shader behavior multiplies both the direct sun and the IBL ambient by smoothstep(L.y, −0.05, 0.05) — a world-Y horizon fade designed so a setting sun (day/night) smoothly extinguishes lighting. On a sphere with a player-pinned sun this is catastrophic: walking onto the world's southern hemisphere puts the sun direction below y = 0 in world space — even though it's still high in the astronaut's local sky — and the fade silently zeros every contribution. The screen goes black.

The lighting pass now takes an optional disableHorizonFade flag that short-circuits the fade to 1.0. Terranaut sets it; flat-world samples leave the default.

Plumbing:

Auto-exposure on by default (toggle with E)#

autoExposurePass.enabled is true at startup, clamped to minExposure 0.8, maxExposure 2.6 so the dark-space limb doesn't drag the histogram percentile metering past anything useful.

The trade-off: with a player-pinned sun the scene's lighting is roughly constant, so letting the adapter run makes the perceived "ambient" change as the camera reframes — a shadow looks bright when you stare straight into it and dark when you pan off, because the histogram metering shifts with what's on screen. Press E to lock the tonemap to a fixed 1.5 (HUD shows exp: 1.5 vs exp: AUTO); ?noexp starts the page locked.

§9 — Notable techniques and gotchas#