voxolith

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/renderer

A consumer needs:

  • Vite, or another bundler that understands import code from "./file.wesl?raw".
  • typescript, @webgpu/types and vite as dev dependencies, with "types": ["@webgpu/types", "vite/client"] in tsconfig.json.
  • In vite.config.ts, the package excluded from dependency pre-bundling, so Vite compiles it as source:
vite.config.ts
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

importuse fromcontents
@voxolith/rendererbrowser codeeverything: initGpu, createRenderer, makeFrameLoop, observeResize, plus all of core
@voxolith/renderer/coreNode and bun tools, workerseverything 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/voxNode and bun toolsparseVox, parseVoxScene, writeVox, decodeVoxRotation
@voxolith/renderer/rayNode and bun toolsrayAABB, 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 ready

Starts 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 later
  • PrepareNeeds (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's temporal; target defaults to false). scaled is for instances of models added with a scale above 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 the pipeline mode: fragment passes, compute kernels (plus temporal ones), or for "auto" the compute kernels and the fragment pass for posed instances.
  • deferPipelines leaves the base pipelines out of the constructor, so prepare compiles 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, and render falls 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.

helperframe
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.

On this page