Entry points
Installing @voxolith/renderer, the bundler setup its raw TypeScript and ?raw shaders need, which import to use where, and a first frame.
@voxolith/renderer ships raw TypeScript: your bundler compiles it together with your app, under
your own compiler flags. The renderer module imports its shaders with Vite's ?raw suffix, which
is what decides how each part of the package may be imported.
Install
bun add @voxolith/rendererA consumer needs:
- Vite, or another bundler that understands
import code from "./file.wesl?raw". typescript,@webgpu/typesandviteas dev dependencies, with"types": ["@webgpu/types", "vite/client"]intsconfig.json.- In
vite.config.ts, the package excluded from dependency pre-bundling, so Vite compiles it as source:
import { defineConfig } from "vite";
import basicSsl from "@vitejs/plugin-basic-ssl";
export default defineConfig({
// WebGPU needs a secure context; basic-ssl serves HTTPS on localhost and the LAN.
plugins: [basicSsl()],
optimizeDeps: { exclude: ["@voxolith/renderer"] },
});The HTTPS plugin is optional on localhost, which already counts as a secure context, but a phone
opening the dev server over a LAN address sees no navigator.gpu without it.
Which import, where
| import | use from | contents |
|---|---|---|
@voxolith/renderer | browser code | everything: initGpu, createRenderer, makeFrameLoop, observeResize, plus all of core |
@voxolith/renderer/core | Node and bun tools, workers | everything that does not touch the GPU: formats, bricks and sparse volumes, cameras, lights, makePerf, ray helpers, seededRandom, GridStamper, effects, the worker halves of instancing: model encoding (encodeModel, encodedTransferables, placementModelOf, ENCODED_MODEL_VERSION; see encoding models on a worker) and the static placement bake (bakePlacement, PlacementBaker) |
@voxolith/renderer/vox | Node and bun tools | parseVox, parseVoxScene, writeVox, decodeVoxRotation |
@voxolith/renderer/ray | Node and bun tools | rayAABB, makeRay, voxelRaycast |
Never import the barrel outside a bundler
The main barrel pulls in the renderer, whose ?raw shader imports only a bundler can resolve. A
script run with bun or Node, and any module such a script imports transitively, must import from
@voxolith/renderer/core (or /vox, /ray). The generators follow this rule, which is why they
run in tools, workers and tests alike.
// In a bun tool: headless only.
import { parseVox, seededRandom, sparseFromDense } from "@voxolith/renderer/core";
// In browser code: the full barrel.
import { initGpu, createRenderer } from "@voxolith/renderer";The headless entries are also typed without the WebGPU and Vite ambient types, so a Node project
can typecheck against them without @webgpu/types.
Starting the GPU
initGpu(canvas, options?) requests a
high-performance adapter, negotiates device limits, configures the canvas and returns a
GpuContext. It throws
WebGPUUnsupportedError when
WebGPU is missing, and says so specifically when the page is not a secure context (the usual cause
on a phone hitting a dev server over plain HTTP).
showUnsupportedScreen(message, options)
renders a card into the #app element instead of a blank page; the app styles its
.unsupported* classes.
import { initGpu, showUnsupportedScreen, WebGPUUnsupportedError } from "@voxolith/renderer";
let gpu;
try {
gpu = await initGpu(canvas, { maxPixelRatio: 2 });
} catch (e) {
if (e instanceof WebGPUUnsupportedError) showUnsupportedScreen(e.message, { appName: "My game" });
throw e;
}Prop
Type
The WebGPU default limits are far below what most adapters offer (a 128 MiB storage binding, a
256 MiB buffer), and a large world's brick pool needs more, so initGpu asks for generous limits,
clamped to the adapter. What was granted is in gpu.limits; size scenes against it. The brick
pools grow within those limits as content arrives, copied GPU-side, and
renderer.reserveBricks grows them
once ahead of a batch of models or a streamed ground; renderer.stats() reports what they hold
(slots4, slots8) and their room (cap4, cap8). See
growth and limits.
gpu.adapterInfo is what the browser reported and gpu.software is true for CPU implementations
(see Quality and performance).
A first frame
createRenderer(gpu, scene) links the
WESL modules into WGSL (once, cached for every renderer on the page) and builds the pipeline. A
RenderScene is the grid size, optionally a
dense voxel array, a 256-entry RGBA palette and optionally per-slot materials.
import { initGpu, createRenderer, makeCamera, makeFrameLoop, observeResize, resizeToDisplay, type CameraFrame, type FrameParams } from "@voxolith/renderer";
const canvas = document.querySelector("canvas")!;
const gpu = await initGpu(canvas);
// A 32³ grid with one palette slot filled. Slot 0 is always empty.
const size = { x: 32, y: 32, z: 32 };
const data = new Uint8Array(size.x * size.y * size.z).fill(1); // index x + y*sx + z*sx*sy
const palette = new Float32Array(256 * 4);
palette.set([0.9, 0.5, 0.2, 1], 4); // slot 1, linear RGBA 0..1
const renderer = await createRenderer(gpu, { size, data, palette });
const camera = makeCamera({ target: [16, 16, 16], distance: 80, pitchDeg: 30, fovDeg: 35 });
const sun: [number, number, number] = [0.5, 0.8, 0.35];
const LIGHTING = {
lightDir: sun, lightColor: [1, 0.98, 0.94], ambientSky: [0.5, 0.53, 0.6], ambientGround: [0.3, 0.29, 0.27],
sunDir: sun, moonDir: [0, -1, 0], sunColor: [1, 0.96, 0.85], moonColor: [0, 0, 0],
skyTop: [0.35, 0.55, 0.9], skyHorizon: [0.75, 0.82, 0.92],
nightFactor: 0, sunIntensity: 1, moonIntensity: 0,
} satisfies Omit<FrameParams, keyof CameraFrame>;
const loop = makeFrameLoop({
render() {
resizeToDisplay(gpu);
renderer.render({ ...camera(35), ...LIGHTING });
},
});
observeResize(canvas, loop);
loop.invalidate();The grid is Y-up: y is height. Call renderer.destroy() before dropping a renderer (an app that
builds a new one per file load otherwise leaks its GPU buffers).
FrameParams takes every lighting field
on every frame; the renderer holds no environment state of its own. Rather than writing them by
hand, spread timeOfDay(phase) from
@voxolith/engine/atmosphere. The loop and the scale knobs are covered in
Quality and performance.
Load reporting
createRenderer(gpu, scene, { onLoad }) reports its loading steps to a callback,
RendererLoadHook: "start" and
"end" around linking the shaders (phase "shaders") and around creating the shader module and
each pipeline (phase "pipelines", labelled with the variant, e.g. "fragment+instances").
Variants that a later frame needs are created in that frame's render, so they report then. For
pipelines made synchronously these time only the create calls, which return before the browser
has compiled (Chrome compiles on first use); the variants prepare compiles (below) report from
the call to the pipeline being ready, which is the real compile. A hook that throws is logged once
and never breaks rendering. The engine's load tracker takes it directly:
{ onLoad: trackRenderer(load) } (see Loading screens).
Without the engine, a callback of your own pairs each start with its end:
import { createRenderer, type RendererLoadHook } from "@voxolith/renderer";
const started = new Map<string, number>();
const onLoad: RendererLoadHook = (phase, kind, label) => {
const step = label ? `${phase} ${label}` : phase; // e.g. "pipelines fragment+instances"
if (kind === "start") started.set(step, performance.now());
else console.log(`${step}: ${(performance.now() - (started.get(step) ?? 0)).toFixed(1)} ms`);
};
const renderer = await createRenderer(gpu, scene, { onLoad, deferPipelines: true });
await renderer.prepare(); // logs each variant as it becomes readyStarts and ends carry the same label, and the variants prepare compiles may overlap, so key the
timings by label rather than assuming one step at a time.
Warming the pipelines up
Every variant beyond the base pipelines (instances, posed instances, temporal accumulation, the
offscreen kernels) is compiled by the first frame that needs it, which stalls that frame.
renderer.prepare(needs?) compiles them
ahead with the asynchronous pipeline calls and resolves when they are ready, so a loading screen
can await the compile:
import { createRenderer } from "@voxolith/renderer";
import { trackRenderer } from "@voxolith/engine";
const renderer = await createRenderer(gpu, scene, { onLoad: trackRenderer(load), deferPipelines: true });
renderer.setInstances(trees);
renderer.setQuality(saved ?? "medium");
await renderer.prepare({ parts: true }); // posed instances come laterPrepareNeeds(instances,parts,temporal,target,scaled) says which variants to compile; a field left out is read from the current state (what is placed now, the quality'stemporal;targetdefaults to false).scaledis for instances of models added with ascaleabove 1 (scaled models), whose sampling is a variant of its own: a scene that places coarse models first and swaps in plain ones later prepares both. What that compiles depends on thepipelinemode: fragment passes, compute kernels (plus temporal ones), or for"auto"the compute kernels and the fragment pass for posed instances.deferPipelinesleaves the base pipelines out of the constructor, sopreparecompiles and times them too. Without it the constructor makes them synchronously, as it always has.- Variants already made are skipped and compiles in flight are shared, so calling it again costs
only what is new. A frame that needs a variant still compiling makes it synchronously, as without
prepare, and the asynchronous result is dropped; a failed compile rejects, andrenderfalls back to the synchronous path.
An app that never calls prepare behaves as before. The manual's
pipeline warm-up has the per-mode
table.
Cameras
The renderer takes a camera as a ray basis, a
CameraFrame: camPos, camRight,
camUp, camFwd and tanHalfFov. Three helpers build one; how they are driven is up to the app,
normally through the controllers in @voxolith/engine/input.
| helper | frame |
|---|---|
makeCamera(config) | an orbit around a target; returns frame(yawDeg, distance?, target?, pitchDeg?) |
firstPersonFrame(eye, yawDeg, pitchDeg, fovDeg) | a look from eye; yaw 0 faces +Z, positive pitch looks up |
chaseFrame(target, options) | behind and above a heading, looking past it; distance near 0 is a cockpit view |
Screen-right is cross(forward, up), so turning right increases a first-person yaw, while an
orbit camera's yaw decreases as you drag right. Clamp first-person pitch to about ±89° so the up
vector does not degenerate.
Picking
The CPU helpers in @voxolith/renderer/ray rebuild the shader's rays on the CPU:
makeRay(canvas, frame, clientX, clientY) gives
the world ray through a pointer position, rayAABB
tests it against a box, and voxelRaycast walks
a dense grid with the same DDA the GPU uses, returning the hit cell and face normal. Worlds without
a dense copy pick against their own data instead (the terrain generator's pick, for example).
import { makeRay, voxelRaycast } from "@voxolith/renderer/ray";
const frame = camera(orbit.yaw(), orbit.distance(), orbit.target(), orbit.pitch());
const { origin, dir } = makeRay(canvas, frame, e.clientX, e.clientY);
const hit = voxelRaycast(size, data, origin, dir, 4096);
if (hit) console.log("cell", hit.cell, "face", hit.normal);Effects
makeExplosion({ size }) (small, medium,
large: a ground-anchored fire dome with debris streaks) and
makeMuzzleFlash({ type }) (star, cone,
bloom) are VoxEffects: each owns a small
stage grid and palette and mutates its data on tick(dt). The host draws it with a renderer of
its own and re-uploads data every frame with updateVoxels.
Seeded randomness
seededRandom(seed) returns a
deterministic () => number and hashSeed(text)
turns a string into a seed. They are the rng every generator is given, so the same seed rebuilds
the same model in a tool, a worker or the page.
Renderer
@voxolith/renderer: a WebGPU raymarcher over sparse 8³ bricks, with instances, point lights, materials, atmospheric effects and .vox and .mca I/O.
Storage
How the renderer stores voxels: 8³ bricks with per-brick palettes, uniform bricks, a two-level index in one buffer, and editing a brick at a time.