voxolith

Quality and performance

What a raymarched frame costs and the runtime knobs that trade it, from quality presets and render scale to makePerf, render on demand and adapter checks.

A raymarcher's cost is rays times steps. Every pixel walks a ray through the grid, then shades the hit with a shadow ray, ambient occlusion, point lights and materials, and the whole screen is redrawn every frame. So the knobs are: how many rays (resolution), how far each may walk (quality), and how many frames (render on demand). All of them are runtime settings; nothing needs a rebuild.

The cost knobs live in the renderer and engine, not in apps. Apps only offer UI for them, such as a quality select stored under voxolith-quality.

Quality presets

renderer.setQuality(preset | partial) sets the per-frame cost knobs, as uniforms: changing them is free and takes effect on the next frame. getQuality() reads them back.

Prop

Type

presetmaxStepsshadowStepsaotemporal
low2560offoff
medium51248onoff
high (default)76890onoff

high is the original look; low is roughly 2 to 3 times cheaper per pixel. low also drops the point lights' shadows (the lights stay) and the snow-shelter ray. With the brick index's empty-space skip, typical rays use far fewer steps than the cap, which is only a backstop.

renderer.setQuality(gpu.software ? "low" : "high");
renderer.setQuality({ shadowSteps: 32 }); // partial updates are allowed
renderer.setQuality({ ...QUALITY_PRESETS.medium, temporal: true });

The presets are exported as QUALITY_PRESETS for a UI to list.

Temporal accumulation

Shadows and AO are the same from one frame to the next wherever nothing moved, yet each frame traces them again. temporal: true keeps them instead. Per pixel, the renderer stores the key light's shadow, the face AO and the point lights' shadows, and reuses them wherever the pixel still sees the same voxel face. A pixel stops tracing those terms once it has averaged 32 samples, so a still image is stable and costs little more than its primary rays.

renderer.setQuality({ ...QUALITY_PRESETS.medium, temporal: true });
const loop = makeFrameLoop({
  render: () => renderer.render(frame()),
  converging: () => renderer.converging(),
});
  • Opt-in, compute only. It is off in every preset, and it runs in the compute pipeline only, in kernels of its own. With the "fragment" pipeline it has no effect. With it off, the image is byte-identical to a renderer without it.
  • Soft shadows. With temporal on, the key light is jittered within its angular radius, FrameParams.lightAngle (default 0.02 rad, about 1.1°; the real sun's is about 0.0047). The accumulated shadows get soft penumbrae that widen with the distance to the caster. The jitter follows the R2 low-discrepancy sequence. Point lights accumulate the ratio of shadowed to unshadowed light, applied to the unshadowed light evaluated every frame, so a light that changes colour or brightness (a flicker, a firefly) needs no reset. Without temporal, shadows stay hard.
  • Converging. After the view stops changing, the image needs a few dozen more frames to settle. renderer.converging() is true until it has; give it to makeFrameLoop as converging and a render-on-demand loop keeps drawing until then, then goes idle. It is always false without temporal.

History without motion vectors

There are no motion vectors. Each pixel stores the hash of the face it hit and the distance to it. Reprojected through the previous frame's camera, a pixel reuses the old value only where it hit the same face at a consistent distance; anywhere else it starts over. While the camera moves, tiles take turns to sample the pixels that have not converged.

What changes the shading of a face the pixel still sees is tracked instead:

  • Change stamps. Edits and the moving instance set stamp the 8-voxel columns they touch, swept along each shadowing light as far as the shadow can reach. Stamped pixels are traced afresh, so a moving rat leaves no trail and no stale shadow.
  • Lights. A key light that turns by more than about 2° in one frame retraces its shadow everywhere; a slow turn keeps sampling. Moving a shadowed point light retraces point shadows.
  • Resets. A resize, a quality or debug change, a new static instance set and a camera cut (a turn of more than about 10°, or a jump of more than 32 voxels in one frame) start the history over.
  • The crowd fallback. When more than 30% of the world's columns change in one frame (a crowd over the whole map), nearly every pixel would trace anyway and the temporal kernel costs more than the plain one, so that frame renders without temporal. It comes back once under 20% change.

What it costs

  • Memory: 32 bytes of history per pixel (two 16-byte textures, ping-ponged).
  • A full retrace: about 24% more than the plain kernel, when an instance-heavy frame must be traced in full.
  • Near movers: stamped areas are traced every frame, hard, as without temporal. Near moving instances and edits, shadows lose their soft edges.

Measured on a desktop Radeon at 1280 × 720, GPU ms per frame, off → on:

sceneoffon
world example, fixed view11.07.3
nightwood, render scale 0.551.528.6
temporal example, still, scale 0.8, drawn every frame6.24.2
temporal example, rotating slowly6.34.2
temporal example, still, scale 0.54.02.2
world, while the camera spins5.95.2
crowds over the whole mapunchangedunchanged (the fallback engages)

Fixed views gain most, a slowly moving one nearly as much, a fast camera little, and a crowd nothing. The temporal example has a toggle and a readout to judge it on your own device.

Resolution

Rays per frame scale with the square of the resolution. Two numbers set it:

  • gpu.pixelRatio: window.devicePixelRatio, capped by initGpu(canvas, { maxPixelRatio }) (default 2, so phones do not render at 3× for free). Use 1 on weak GPUs.
  • gpu.renderScale: a scale on top of that, 0.1 to 1 (default 0.8). Set it with setRenderScale(gpu, s) or directly; resizeToDisplay(gpu) then resizes the canvas backing store to its CSS size × pixel ratio × render scale. Call it at the start of each frame.

Adaptive scale

makePerf(options) is an adaptive render-scale controller with an optional overlay (frame time, fps, scale). Call perf.frame(now) on every rendered frame and apply perf.scale(): it climbs towards maxScale while frames comfortably hit targetMs and backs off quickly when they stretch. perf.setScale(s) pins a scale (adaptation continues from there).

Prop

Type

makePerf learns a ceiling. Under vsync, frame times arrive in whole intervals, so a frame that fits reads the same as one with room to spare. A naive controller climbs, misses, drops and climbs again every second, and each change visibly resamples a still image. So a scale that proved too slow is not retried for retryAfterMs.

An app that renders continuously with a still camera (water, weather) passes retryAfterMs: Infinity and calls perf.reprobe() when the view moves, when a probe is invisible. A scene that is legitimately slower than 250 ms a frame must raise maxSampleMs, or every sample is discarded as idle and the overlay sits at 60 fps.

Render on demand

A raymarcher redraws the whole screen every frame, so a static scene should not render at all. makeFrameLoop({ render }) schedules requestAnimationFrame only while there is work, so an idle page costs nothing:

  • loop.invalidate() requests one frame (coalesced). Call it when the camera, scene or viewport changed. The engine's input takes the loop (createInput(canvas, { loop })) and invalidates on every input event.
  • observeResize(canvas, loop) invalidates on CSS size changes (window resize, split view, orientation).
  • loop.setContinuous(true) renders every frame, capped at maxFps (default 60), and is only for while something animates: a crowd, particles, a held key. Water and precipitation animate from FrameParams.time, so an app with them on screen renders continuously.
  • render(now, dt) receives performance.now() and a capped dt in seconds.
import { initGpu, createRenderer, makeFrameLoop, makePerf, observeResize, resizeToDisplay, makeCamera } from "@voxolith/renderer";
import { createInput, makeOrbitController } from "@voxolith/engine/input";
import { timeOfDay } from "@voxolith/engine/atmosphere";

const gpu = await initGpu(canvas);
const renderer = await createRenderer(gpu, scene);
if (gpu.software) renderer.setQuality("low"); // and tell the user why it is slow

const perf = makePerf({ enabled: false, scale: gpu.renderScale, minScale: 0.35 });
const camera = makeCamera({ target: [64, 16, 64], distance: 120, pitchDeg: 30, fovDeg: 35 });
const loop = makeFrameLoop({
  render(now) {
    perf.frame(now);
    gpu.renderScale = perf.scale();
    resizeToDisplay(gpu);
    renderer.render({ ...camera(orbit.yaw(), orbit.distance(), orbit.target(), orbit.pitch()), ...timeOfDay(0.45) });
  },
});
observeResize(canvas, loop);
// Every input event invalidates the loop.
const orbit = makeOrbitController(createInput(canvas, { loop }), { distance: 120, distanceLimits: [20, 600] });
loop.invalidate();

Software adapters

gpu.adapterInfo is what the browser reported for the adapter, and gpu.software is true for CPU implementations (SwiftShader, llvmpipe, lavapipe, or a browser fallback adapter). Expect single-digit frame rates there; apps warn and start on the low preset. initGpu logs the adapter and the granted limits to the console. If a capable GPU shows as software, the browser is not using it.

Linux Chrome

Chrome on Linux can be slow despite a hardware adapter. When chrome://gpu reports Disabled Features: webgpu_on_vk_via_gl_interop, the compositor runs on OpenGL and each WebGPU frame is copied through the CPU. Enabling chrome://flags/#enable-vulkan fixes it, but globally it also changes how video frames reach the screen (it can break camera feeds in video calls). The Voxolith workspace's bun run dev therefore launches a separate Chrome with Vulkan enabled and its own --user-data-dir, so the flag applies to development only.

Where the time goes

costscales withknob
primary rayspixels × steps to the hitrender scale, pixel ratio, maxSteps
key-light shadowone ray per lit pixelshadowSteps, temporal
point lightslights in range per pixel; one shadow ray eachrange, shadows, shadowSteps, temporal
ambient occlusion8 lookups per hitao, temporal
reflections, glass, waterone secondary ray per such pixelmaterials in view
precipitation8 hashes per pixeldensity 0 turns it off
editsbricks touchedbatch with editMany
instancescells their boxes touchstatic sets are built once
frameshow often you rendermakeFrameLoop

renderer.stats() and instanceStats() report the memory side; see Storage.

Pipelines and GPU timing

The renderer shades each pixel in one of two ways, with the same shading code and the same image:

  • compute: 8x8 compute tiles, written straight into the canvas when its format allows storage writes (rgba8unorm, or bgra8unorm with the bgra8unorm-storage feature), otherwise into a frame texture that a small present pass copies out;
  • fragment: one fullscreen fragment pass.

createRenderer(gpu, scene, { pipeline }) picks one; the default, "auto", uses compute except for frames with instances posed on the GPU, whose kernel runs faster as a fragment shader on the hardware measured so far. The compute path is also the base for the passes that build on it: temporal accumulation now, and a coarse depth pre-pass and global illumination next.

When the adapter offers timestamp-query, initGpu enables it and the renderer times its passes:

const perf = makePerf({ enabled: true, gpuTimings: () => renderer.gpuTimings() });
renderer.gpuTimings(); // e.g. { trace: 4.21 } in milliseconds, averaged over frames

GpuContext.features says what was granted. Browsers quantise timestamps (to about 0.1 ms unless the page is cross-origin isolated), so read the numbers as averages.

References

  • Eric Heitz, Stephen Hill, Morgan McGuire. Combining Analytic Direct Illumination and Stochastic Shadows. Proceedings of the ACM SIGGRAPH Symposium on Interactive 3D Graphics and Games (I3D 2018). doi:10.1145/3190834.3190852
  • Martin Roberts. The Unreasonable Effectiveness of Quasirandom Sequences. 2018, blog post (the R2 sequence). extremelearning.com.au

What Voxolith took from them and what is its own: Research and credits.

On this page