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
- fine detail) each pass through gradient filters for shaping plateaus, mesas, etc.
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().
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;
}
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 — caps streaming cost (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 |
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 |
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))