Voxler / get started

Write a world, get a renderer

You write two WGSL functions: one says how far away the nearest surface is, the other says what each voxel is made of. Voxler does the streaming, meshing, culling, horizon and shadows.

It needs WebGPU. Chrome or Edge on desktop, Safari 26 on macOS 26 or iOS 26, or Firefox on Windows or Apple Silicon. There is no WebGL fallback.

Try it

starting
Click the view, then drag to look, WASD to move, Space and C for up and down. The running engine is voxler in the console.
nothing loaded Edit the WGSL and run it. Nothing leaves the page.
loading the Hills source...
Edits, applied to whichever world is running:

Each is one brush. They go in front of where you started; fly back if you have wandered off. Unticking removes the brush and the terrain closes over it.

Install

npm install https://voxler.dev/voxler-0.1.0.tgz

No TypeScript types yet.

The whole thing

That is the whole of it. Three lines, and the rest of this page is optional.

import { Voxler } from "voxler";

const voxler = await Voxler.create(canvas, { world: { code: myWorldWgsl } });
voxler.start();

A world that runs

Press Hills and its source fills the editor: twenty lines, four octaves of noise, sand in the hollows and snow on the tops.

No data was downloaded to draw that. The terrain is evaluated on the GPU from the source above: full resolution near the camera, coarser cells out to the fog.

Something with more in it

The second button loads a longer one: the same three functions, plus a mask deciding where mountains rise, a sea filling everything below its level, and boulders scattered over the ground. Read the scatter. Placing objects on a lattice is where worlds usually go wrong.

The comments in the scatter are rules, not preferences. Skip one and the lattice shows through: nine boulders stacked on one cell, or an even spread that reads as a grid from the air.

What a world has to define

Three things, in WGSL. Your code is concatenated after the generated block constants and the SDF library, so BLOCK_GRASS, fbm2 and WorldPoint are all in scope.

NameWhat it is
WORLD_LIPSCHITZ: f32 A bound on the gradient of world_sdf, at least 1. The engine skips whole regions when the distance is larger than this times their half diagonal, so an underestimate silently deletes terrain. Overestimating only costs speed.
world_sdf(p) -> f32 Signed distance in voxels, negative inside. It may underestimate the true distance; it must never overestimate it.
world_material(p) -> u32 A block id. Only called where the field says solid, so it never has to decide whether something is there.

p is a WorldPoint: an integer voxel coordinate plus a fraction. That split is what keeps the world exact far from the origin, where a f32 position would have lost the low bits. To compute with it, use wp_f32(p), wp_local(p, anchor) or wp_repeat_near(p, period).

Two rules that are not obvious, and each cost an afternoon here:

Options

Only the world is required. Every other default is what the demo on the front page runs.

await Voxler.create(canvas, {
  world:  { code, spawn: [0, 100, 0], start, sky: "night", birds: true },
  seed:   1,
  camera: { at: [0, 100, 0], yaw: 0, pitch: -0.25 },

  stream: { radius: 16, height: 6, arenaBytes: 128 << 20 },  // or false
  mesh:   { ao: true, blockLight: true, clusterQuads: 32 },   // or false
  far:    { size: 32, firstLevel: 1, shadows: true },         // or false
  render: { size: "auto", textures: true, glow: true, wind: true },

  workers:  { count: 8, factory: (i) => new Worker(myWorkerUrl, { type: "module" }) },
  controls: true,
  onError:  (message) => console.warn(message),
});
OptionNote
stream: falseNo chunks, so no meshes either. The far field still draws.
far: falseTakes the shadows with it: shadow rays march the far field's clipmap, so there is nothing to march without it.
render.sizeA fixed render size is letterboxed into the element at its own aspect rather than stretched, so the pixels stay square.
workers.factoryThe default resolves the worker next to the engine bundle. Pass your own if your bundler does not follow new URL(..., import.meta.url).
controls: falseNo input listeners at all. Drive voxler.camera yourself.
render.gizmoThe debug axis cross in the corner. On by default because the demo wants it; the frame above passes false.

Driving it

voxler.start();          // owns requestAnimationFrame
voxler.stop();
voxler.frame(now);       // or drive it from a loop of your own
voxler.resize(w, h);
voxler.dispose();        // workers terminated, device destroyed, listeners removed

voxler.camera            // FlyCamera: setPosition, setOrientation, worldPosition
voxler.renderer          // null until ready, and again across a device loss
voxler.store             // the resident chunks
voxler.stats             // frame interval, CPU sections, GPU passes
voxler.edit.fillSphere(x, y, z, r, id);

renderer is nullable on purpose. A lost GPU device throws away every GPU object, and the engine builds a replacement with the camera where it was. Read it through the instance each time, or take the new one from onReady. Do not cache it.

Changing a world after it is running

The world function is fixed once the page is running. Edits go in beside it, in two forms that behave differently.

Field brushes: a shape folded into the SDF

A CSG brush is a bounded primitive that joins the world's field on the GPU, before anything is voxelized. The field itself changes, so the shape appears in the near field, the far field and the preview alike, and terrain closes around it.

import { BLEND_SUBTRACT } from "voxler";

// A sphere of stone.
const id = voxler.edit.csgSphere(120, 64, -40, 12);

// A sphere of nothing: a cave mouth carved out of the hillside.
voxler.edit.csgSphere(120, 58, -40, 9, 0, BLEND_SUBTRACT);

voxler.brushes.remove(id);   // and it closes up again

Seven primitives: sphere, box, rounded box, torus, capsule, cylinder and ellipsoid. A brush folds into the world with BLEND_UNION, BLEND_SUBTRACT or BLEND_SMIN, and only those three. Intersect and smooth-max are refused here because they reach outside the brush's bound. Use them inside a CSG op list.

The bound matters in the other direction too. The engine skips a region when the distance says nothing can be in it. So outside its own box a brush returns the distance to that box, never a large constant. Return a large constant and the brush disappears.

Voxel edits: a journal replayed over the field

The other kind writes block ids directly. That is what a placement tool wants, and what a field brush cannot express: one voxel of one type, in a chunk that otherwise came straight out of the terrain function.

voxler.edit.setVoxel(x, y, z, 3);
voxler.edit.fillBox(x0, y0, z0, x1, y1, z1, 0);   // 0 is air
voxler.edit.fillSphere(x, y, z, 6, 1);

One brush, several primitives, a blend each

csgSphere is the shortcut for one primitive. The general form is an op list. A brush holds a sequence of primitives, each folding into the one before it with its own blend, and the result folds into the world as one bounded instance. This is the only place BLEND_INTERSECT and BLEND_SMAX can be used.

import { BLEND_SMIN, BLEND_SUBTRACT, BRUSH_CSG, packCsg,
         PRIM_BOX, PRIM_CYLINDER, PRIM_SPHERE, BLOCKS } from "voxler";

const block = (name) => BLOCKS.find((b) => b.name === name).id;

// A pillar with a domed top and a bore down the middle. Coordinates in the op list are
// relative to `cell`, so the whole thing moves by moving one number.
const pillar = voxler.brushes.add({
  kind: BRUSH_CSG,
  cell: [120, 70, -40],
  material: block("stone"),
  ops: packCsg([
    // Half extents. First op has nothing to blend with, so its blend is ignored.
    { prim: PRIM_BOX, params: [6, 20, 6] },
    // Smooth union, k = 5: the dome melts into the shaft instead of sitting on it.
    { prim: PRIM_SPHERE, center: [0, 20, 0], params: [8], blend: BLEND_SMIN, k: 5 },
    // Half height, radius. Subtracted, so it is the hole and not the drill.
    { prim: PRIM_CYLINDER, params: [26, 2.5], blend: BLEND_SUBTRACT },
  ]),
});

// The instance's own blend is how the finished shape meets the terrain, and there it is
// union, subtract or smooth union only.
voxler.brushes.move(pillar, 120, 66, -40);
voxler.brushes.remove(pillar);

Every primitive takes its own parameters: sphere a radius, box three half extents, cylinder a half height and a radius, torus two radii, capsule seven numbers, ellipsoid three. packCsg throws if the count is wrong rather than reading past the end of the list.

A list of explicit voxels

The voxel side takes a list too, and it is the one to reach for when what you want is particular blocks in particular places rather than a shape. The ops run in order, so a later one writes over an earlier one.

import { BRUSH_VOXEL, packVoxel, SHAPE_BOX, SHAPE_SPHERE, SHAPE_VOXEL,
         VOXEL_CARVE, VOXEL_REPLACE, VOXEL_SET, BLOCKS } from "voxler";

const block = (name) => BLOCKS.find((b) => b.name === name).id;

// A waymark: a post, a glowing cap on it, moss at its foot, and the air above cleared.
const marker = voxler.brushes.add({
  kind: BRUSH_VOXEL,
  cell: [64, 80, 64],
  ops: packVoxel([
    // SHAPE_BOX takes two inclusive corners, relative to `cell`.
    { mode: VOXEL_SET, shape: SHAPE_BOX, id: block("wood"), params: [0, 0, 0, 0, 5, 0] },
    // SHAPE_VOXEL takes one position: a single voxel, six up.
    { mode: VOXEL_SET, shape: SHAPE_VOXEL, id: block("glowcap"), params: [0, 6, 0] },
    // REPLACE writes only where the voxel already matches, so the moss follows the
    // ground instead of hanging in the air over a dip.
    { mode: VOXEL_REPLACE, shape: SHAPE_BOX, id: block("moss"), match: block("grass"),
      params: [-2, -1, -2, 2, -1, 2] },
    // SHAPE_SPHERE takes a centre and a radius. CARVE writes air whatever is there, so
    // keep it clear of the cap at 6: ops run in order and this one would eat it.
    { mode: VOXEL_CARVE, shape: SHAPE_SPHERE, params: [0, 12, 0, 3] },
  ]),
});

Four modes: VOXEL_SET writes the id, VOXEL_CARVE writes air, VOXEL_REPLACE writes only where the voxel is match, and VOXEL_PAINT writes only where it is not air. The last two are what keep a decoration from filling a hollow it was never meant to reach into.

Either list is one brush and one journal entry, so it survives regeneration as a unit and undoes as a unit. A hundred separate setVoxel calls also work, and give you a hundred entries to replay instead of one.

Why an edit survives its chunk being thrown away

Chunks are not permanent. Streaming evicts them, a brush change regenerates them, and a regenerated chunk is built from the world function again, which knows nothing about what anyone typed. Edits survive that because a chunk is evaluated in two stages, always in this order:

  1. The field stage: the terrain SDF with the field brushes folded in, on the GPU.
  2. The voxel stage: the journal of voxel edits, replayed on the CPU over the result.

Regenerating a chunk is stage one followed by a replay of stage two, so an edit made an hour ago is reapplied to a chunk voxelized a second ago. The journal is indexed by chunk, so a replay costs the entries whose bounds overlap that chunk and nothing else.

Surviving regeneration is not surviving a reload. Nothing is written to disk. To keep edits, save voxler.brushes.records and voxler.brushes.ops, which are plain word buffers, and replay brushes.add() on the next load.

There is also an edit tool with undo and redo over the same journal (voxler.tool), which is what the demo binds to its keys.

When the parts are what you want

The class assembles parts that are all exported on their own. Drive them directly if you have a frame loop, a camera or a scheduler already.

import { Renderer, ChunkStore, ChunkStreamer, MeshScheduler, WorkerPool, FlyCamera } from "voxler";