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. On a phone, one finger looks and two fly: drag to go, pinch to approach. 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.
render.bloomA soft glow around the glowing blocks: their fogged emission blurred and screened over the frame. Unset takes the world's default (the forest has it on, the others off); about 0.2 ms at 1080p, and a startup choice: it is built into the near pass.

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.

What is under the cursor

onClick fires for a pointer that went down and came up in place, with no second pointer down meanwhile: a drag is a look and two fingers are a flight, and the engine tells them apart so a host does not have to. pick(x, y) says what is under a point on the view, in client coordinates: the voxel the meshes hold there, or past them the cell the far field's ray march meets, which is coarser the further it is.

const voxler = await Voxler.create(canvas, options, {
  onClick: (x, y) => voxler.pick(x, y).then((hit) => {
    if (hit === null) return;              // sky
    console.log(hit.name, hit.position);   // "stone", [x, y, z] of the voxel's min corner
    // hit.size is 1 for a meshed voxel, the cell width for a far-field hit;
    // hit.normal is the face the ray came in through; hit.far says which answered.
  }),
});

The far answer is a GPU probe and resolves a frame or so later; its pipeline is built on the first pick. The election map is a host of both: a click on a bubble or on the ground names the municipality and opens its numbers.

Something that moves

A world function takes no time: a chunk is voxelized once. What moves is a brush. A world can declare orbits, CSG spheres on circles that the engine steps every frame through BrushStore.move(), regenerating the chunks each one crosses; the showcase has six. voxler.orbits holds them and voxler.orbiting = false holds them still. For a motion of your own, move any brush the same way: voxler.brushes.move(id, x, y, z) on the id csgSphere returned, and budget a few regenerated chunks a frame per moving brush.

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.

SDF
Signed distance field: a function f(p) that returns how far point p is from the nearest surface, negative inside. A voxler world is one such function in WGSL plus a material function, and everything is sampled from it rather than stored.
Voxel, chunk
A voxel is one cell of the unit grid the world is sampled on, holding a block id. A chunk is 32×32×32 of them, the unit that is generated, stored, meshed and thrown away.
Near field, far field
Near: the chunks around the camera, voxelized and drawn as triangles. Far: the same SDF sampled into coarse bricks and ray-marched. Both come from the one field.
Field brush
A shape whose distance is folded into the world's SDF before sampling, so it is there at every resolution and terrain closes around it. Two kinds: an SDF brush is a WGSL function per brush type, compiled into the world and fixed when it is built; a CSG brush is a list of primitives interpreted on the GPU, and is the one a page can create while the world runs.
CSG
Constructive solid geometry: a shape built from primitives with set operations. On distances those are min(a, b) for union, max(a, -b) for subtraction, and a smooth minimum for a union with a rounded seam of radius k.
Primitive, blend
A primitive is one of seven parametric shapes: sphere, box, rounded box, torus, capsule, cylinder, ellipsoid. The blend is which operation joins it to the shape so far, or joins a whole brush to the world.
Voxel edit
Sets block ids directly and does not touch the field. Recorded in a journal, the ordered list of edits, which is replayed whenever the chunk is generated again.

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";