b3d-terrain

Procedural terrain generator using 3D Perlin noise sampled on a cylinder surface. Longitude (u) wraps seamlessly; latitude (v) reflects at the midpoint, creating symmetric hemispheres with no singularities. Two noise layers (gross contour

Demo

import { b3d, b3dSun, b3dSkybox, b3dTerrain, b3dLight, b3dFog, b3dAircraft, b3dLibrary, gameController, inputFocus, label3d, slider3d, toggle3d } from 'tosijs-3d'
import { tosi, elements } from 'tosijs'
const { div, span, p } = elements

const { demo } = tosi({
  demo: {
    seed: 42,
    grossScale: 0.03,
    detailScale: 0.15,
    horizScale: 8,
    grossAmplitude: 200,
    detailAmplitude: 10,
    wireframe: false,
    debugColor: false,
  },
})

// Priority-pool quadtree LOD: one shared pool of tiles, fine near / coarse far,
// filled by priority (biased toward where you're looking + going). horizScale 4
// makes level-0 tiles 320 across, so the fine region is broad; reach 5000 puts
// the coarse edge just past the fog. Larger radius so the cylinder doesn't repeat.
const terrain = b3dTerrain({
  seed: demo.seed,
  surfaceType: 'cylinder',
  radius: 1000,
  cylinderHeight: 1000,
  // Big tiles + few levels keep the pool small and the meshes cheap. tileSize /
  // lodLevels / reach are world-shape choices; hiResSubdivisions and poolSize are
  // left to adapt to the device tier (see b3d-quality) — a workstation gets more
  // detail, a Quest less, with no per-scene tuning.
  tileSize: 128,
  lodLevels: 3,
  splitFactor: 2,
  reach: 5000,
  grossScale: demo.grossScale,
  detailScale: demo.detailScale,
  horizScale: demo.horizScale,
  grossAmplitude: demo.grossAmplitude,
  detailAmplitude: demo.detailAmplitude,
  wireframe: demo.wireframe,
  debugColor: demo.debugColor,
})

const posDisplay = span({ class: 'pos-display' })

// Fly the terrain in the VTOL aircraft. It spawns high (well above the ~210 peaks
// with v size 200), so it's already above the hover ceiling → in FLIGHT mode:
// right trigger = forward throttle, pull back to climb, turn stick banks.
const aircraft = b3dAircraft({
  library: 'vehicles', meshName: 'scout',
  player: true, y: 400, vtolSpeed: 6, maxSpeed: 50,
})

const scene = b3d(
  {
    frameRate: 60,
    gamepad: true,
    // Controls live in the dual-presence scene panel: a ⚙ toggles them on flat
    // screens, and the SAME panel floats in front of you in VR — so you can retune
    // the terrain from inside the headset. All widgets bind the same `demo.*`
    // reactive values the regenerate observers below already watch.
    scenePanel: () => [
      label3d({ text: 'Terrain' }),
      slider3d({ label: 'gross scale', value: demo.grossScale, min: 0.005, max: 0.3, step: 0.005 }),
      slider3d({ label: 'detail scale', value: demo.detailScale, min: 0.02, max: 1, step: 0.01 }),
      slider3d({ label: 'h size', value: demo.horizScale, min: 0.25, max: 10, step: 0.05 }),
      slider3d({ label: 'v size', value: demo.grossAmplitude, min: 0, max: 400, step: 1 }),
      slider3d({ label: 'v detail', value: demo.detailAmplitude, min: 0, max: 50, step: 0.5 }),
      slider3d({ label: 'seed', value: demo.seed, min: 0, max: 999, step: 1 }),
      toggle3d({ label: 'wireframe', value: demo.wireframe }),
      toggle3d({ label: 'debug color', value: demo.debugColor }),
    ],
    update(el) {
      const cam = el.scene.activeCamera
      if (cam) {
        const p = cam.globalPosition // world pos (the chase cam is parented)
        posDisplay.textContent =
          `pos: ${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)}`
      }
    },
  },
  b3dSun({ activeDistance: 80 }),
  b3dSkybox({ timeOfDay: 10, realtimeScale: 0 }),
  b3dLight({ intensity: 0.5 }),
  b3dFog({ syncSkybox: true, start: 1000, end: 4000 }),
  b3dLibrary({ url: '/test-2.glb', type: 'vehicles' }),
  terrain,
  inputFocus(
    gameController(),
    aircraft,
  ),
)

// Only a readout stays as a flat overlay — the tweakable settings all live in the
// ⚙ scene panel (which also works in VR). See the `scenePanel` hook above.
preview.append(
  scene,
  div(
    { class: 'debug-panel' },
    p('Pull back to climb, triggers up/down (throttle when fast), turn to bank. Tweak terrain via the ⚙ (works in VR too).'),
    posDisplay,
  )
)

// Regenerate terrain when parameters change
for (const key of ['seed', 'grossScale', 'detailScale', 'horizScale', 'grossAmplitude', 'detailAmplitude', 'wireframe', 'debugColor']) {
  demo[key].observe(() => {
    terrain.regenerate()
  })
}
tosi-b3d {
  width: 100%;
  height: 100%;
}
.debug-panel {
  position: absolute;
  top: 10px;
  right: 10px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 8px 16px;
  background: rgba(0, 0, 0, 0.6);
  color: #fff;
  border-radius: 6px;
  font-size: 13px;
  z-index: 10;
}
.debug-panel label {
  display: flex;
  align-items: center;
  gap: 4px;
}
.debug-panel p {
  margin: 0;
  opacity: 0.7;
}
.pos-display {
  font-family: ui-monospace, monospace;
  font-size: 12px;
  opacity: 0.7;
}

How it works

The terrain streams from one shared priority pool of tiles over a quadtree LOD: fine near the camera, coarse far, with exactly one LOD per patch of ground (a coarse cell is exactly four finer cells — no overlap, no gaps). Each frame the pool is diffed against the cells that should exist; blanks are filled by priority (near, and biased toward where you're facing/travelling) — reusing free tiles or stealing the weakest placed one — capped at fillBudget per frame so movement never hitches. Per-tile skirts (with lied normals) hide any crack at a LOD boundary. Includes floating-origin rebasing and a recenter mechanism — when travel exceeds maxTravelDistance, a recenter-needed event fires so the game layer can orchestrate a visual transition before calling recenter().

Attributes

Attribute Default Description
seed 12345 Noise seed
surfaceType 'cylinder' 'cylinder', 'torus', or 'sphere'
majorRadius 100 Torus major radius
minorRadius 40 Torus minor radius
radius 200 Sphere/cylinder radius
cylinderHeight 200 Cylinder height (v range before reflection)
tileSize 10 World-space size of a level-0 (finest) tile
hiResSubdivisions auto Vertices per tile edge (same at every level); auto = device tier
lodLevels 5 Number of LOD levels; level k tiles are tileSize × 2^k
poolSize auto Shared tile budget; the pool renders the top-priority cells (auto = device tier)
fillBudget auto Max tiles (re)built per frame — a churn backstop (auto = device tier)
tileBuildMs auto Milliseconds of tile building allowed per frame — the cap that actually bounds the worst frame. A tile COUNT bounds it only by accident (a tile's cost swings with subdivisions, octaves, device and JS engine); a time cap bounds it by construction everywhere, and self-corrects when you raise detail. Always builds ≥1 tile. (auto = device tier)
splitFactor 2 LOD falloff: subdivide a cell when nearer than splitFactor × tileSize
reach 0 Terrain radius (0 = auto from the coarsest tile)
grossScale 0.1 Gross noise frequency (per render unit)
detailScale 0.5 Detail noise frequency (per render unit)
horizScale 1 Horizontal world scale — scales every tile's size AND the sampling together (>1 = bigger terrain that reaches further; a clean zoom, not just a frequency change)
debugColor false Debug: tint each tile a distinct hashed colour to expose the tile/LOD layout
profile false Debug: time tile building and report it on debugState (see below). Off = zero cost
grossAmplitude 8 Gross height multiplier
detailAmplitude 2 Detail height multiplier
originResetThreshold 500 Distance before origin rebase
maxTravelDistance 5000 Distance before firing recenter-needed event
wireframe false Debug: render terrain as wireframe

Profiling tile builds

Terrain is the only place this library does bulk numeric work in a burst, so it's the only real candidate for a worker or wasm. Before moving anything, measure it:

terrain.setProfiling(true)   // or the `profile` attribute
// …fly around for a bit, then:
terrain.resetProfile()       // drop the first-load burst
// …fly some more:
console.table(terrain.debugState)

debugState splits the cost where it actually matters — not into "fast" and "slow", but into movable and immovable:

Field Means
fieldMsPerTile Noise + analytic normals. Plain float arithmetic — a worker or wasm could take this
skirtMsPerTile Skirt verts + debug tint. Also movable
uploadMsPerTile updateVerticesData — a GPU handoff. Nothing moves this off the main thread
movableShare The ceiling on any threading/wasm win. If it's small, don't bother
nsPerSample Cost per heightAt (each is 2 fractal calls × 6 octaves = 12 perlin evals)
worstFrameMs The hitch you actually feel — one saturated frame, not the average tile
worstFrameSaturated Whether that frame hit fillBudget (the cap set the ceiling, not the work)

Two things to know before reading the numbers. samplesPerTile counts five heightAt per vertex — one for the height, four for the ±e normal gradient — so ~80% of the noise exists to make normals; sampling a padded grid once and central-differencing it would cut that ~4–5× in plain JS, before any new technology. And a big movableMs only becomes a felt win if it moves off-thread: making a blocking 20ms burst into a blocking 5ms burst still drops frames (and in XR a dropped frame is nausea, not jank).

Usage

import { b3d, b3dTerrain, plateauFilter } from 'tosijs-3d'

const terrain = b3dTerrain({
  seed: 42,
  surfaceType: 'cylinder',
  grossScale: 0.02,
  grossAmplitude: 10,
})

// Apply a plateau gradient filter for stepped terrain
terrain.grossFilter = plateauFilter(5)
terrain.regenerate()

document.body.append(b3d({}, terrain))