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
| preset | maxSteps | shadowSteps | ao | temporal |
|---|---|---|---|---|
low | 256 | 0 | off | off |
medium | 512 | 48 | on | off |
high (default) | 768 | 90 | on | off |
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 tomakeFrameLoopasconvergingand 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:
| scene | off | on |
|---|---|---|
| world example, fixed view | 11.0 | 7.3 |
| nightwood, render scale 0.5 | 51.5 | 28.6 |
| temporal example, still, scale 0.8, drawn every frame | 6.2 | 4.2 |
| temporal example, rotating slowly | 6.3 | 4.2 |
| temporal example, still, scale 0.5 | 4.0 | 2.2 |
| world, while the camera spins | 5.9 | 5.2 |
| crowds over the whole map | unchanged | unchanged (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 byinitGpu(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 withsetRenderScale(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 atmaxFps(default 60), and is only for while something animates: a crowd, particles, a held key. Water and precipitation animate fromFrameParams.time, so an app with them on screen renders continuously.render(now, dt)receivesperformance.now()and a cappeddtin 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
| cost | scales with | knob |
|---|---|---|
| primary rays | pixels × steps to the hit | render scale, pixel ratio, maxSteps |
| key-light shadow | one ray per lit pixel | shadowSteps, temporal |
| point lights | lights in range per pixel; one shadow ray each | range, shadows, shadowSteps, temporal |
| ambient occlusion | 8 lookups per hit | ao, temporal |
| reflections, glass, water | one secondary ray per such pixel | materials in view |
| precipitation | 8 hashes per pixel | density 0 turns it off |
| edits | bricks touched | batch with editMany |
| instances | cells their boxes touch | static sets are built once |
| frames | how often you render | makeFrameLoop |
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, orbgra8unormwith thebgra8unorm-storagefeature), 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 framesGpuContext.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.
Atmosphere
The renderer's raw atmospheric effects (fog, clouds, rain and snow, wet and snow-covered surfaces, wind on water, effectScale) and what each one costs.
Formats
Reading and writing MagicaVoxel .vox (base models, the extended scene graph, materials and animation frames) and loading Minecraft Anvil .mca regions.