Skip to main content

Module voxel

Module voxel 

Source
Expand description

GPU-accelerated 3D rendering

§Theory

This module implements an algorithm similar to the one described in Massively Parallel Rendering of Complex Closed-Form Implicit Surfaces (Keeter ’20). The rest of this section is intended for people who have read that paper (“MPR” for short).

We use interval arithmetic on a high-fanout hierarchy of tiles (64³, 16³, 4³), followed by voxel and normal evaluation. At each stage of interval arithmetic, we compute a simplified tape for each tile containing only portions of the expression which are active.

After the root tile evaluation, tiles are sparse. Tiles and tapes use an atomic bump allocator to claim portions of a fixed buffer. The tile buffer is always sized to fit all possible tiles; the tape buffer can run out of space, in which case we fall back to the previous (unsimplified) tape.

§Changes versus MPR

There are a few notable changes compared to the MPR paper and reference implementation.

First, modern GPU APIs support indirect dispatch based on buffers on the GPU itself. This saves a round-trip: the 64³ shader can compute a dispatch size for the 16³ shader and store it in a buffer (and so on for subsequent stages).

In a more significant change, evaluation is broken into strata:

  • The initial pass of 64³ tiles renders all of those tiles, any which are active are accumulated into a set of depth / 64 strata
  • Strata are evaluated one at a time in z-sorted order; this is where 16³, 4³, voxel, and normal evaluation happens. You can think of this as doing raymarching on 64³ voxels at a time.

Strata-sorted evaluation has a few advantages:

  • We can statically allocate enough space for all tiles: all 64³ in the image, then all 16³ and 4³ tiles in a single strata. It would be prohibitive to allocate storage for all 4³ tiles in the entire volume, but doing per-strata evaluation reduces the memory scaling from N³ to N².
  • We get some amount of Z culling, because each pass can bail out if the result in the heightmap fully covers the tile

§Practice

There are four core objects, each with different lifetimes

  • Context contains all of the pipelines used for 3D rendering. It is very expensive to build and should be constructed once per thread / worker.
  • RenderShape contains serialized bytecode to render a particular shape. Best practice is to rebuild it only when a shape changes (i.e. not once per frame), although in practice it’s pretty fast to construct.
  • Buffers contains GPU buffers needed for rendering at a particular image size. It is primarily expensive in GPU memory, as it contains several full-frame buffers. Best practice is to construct one Buffers object per worker context (or per simultaneous render); if image sizes change, it can be resized with Context::set_buffers_image_size (which will grow buffers, but does not shrink them). Systems with high variability in image size may want to periodically compare size versus capacity and fully reallocate buffers (by constructing a new Buffers object) if they get too out of whack.
  • RenderConfig sets the transform matrix for rendering. This is cheap to construct and could be built once per frame

With all that out of the way, usage is pretty simple:

§Sync and async operation

GPU operations are asynchronous; operations are submitted to a queue, and are completed at some point in the future. Context::run blocks until operations are complete, but is only valid on the desktop; it uses wgpu::Device::poll, which is a no-op on the web. Context::run_async is the async equivalent, and is only valid in WebGPU. These functions are feature-flagged and available depending on compile target (native versus WebAssembly).

§Low-level building blocks

Context::run and run_async do four things:

  • Run the GPU kernels to produce an output image, which is a GeometryPixel array in a GPU storage buffer
  • Copy from that GPU storage buffer to a mappable buffer (for read-back)
  • Map that buffer into a MappedImage
  • Read image data back to the CPU

Lower-level building blocks are also available: Context::submit submits the render operations to the GPU, and Context::map_image / Context::map_image_async map the image buffer back to the GPU.

To reuse the image buffer within a more complex GPU pipeline – without copying to the mappable buffer or CPU – Context::submit may be called with None for its out argument. In this case, the output is available in Buffers::image_storage_buffer for subsequent pipelines.

Structs§

Buffers
Buffers for rendering, which control the rendered image size
BuffersError
Error returned when resizing a Buffers object
Context
Context for 3D (combined heightmap and normal) rendering
GeomBufferTag
Tag for a on-GPU buffer storing GeometryPixel values
ImageReadBuffer
Buffer for reading data back from the GPU
MappedImage
Handle to a mapped image, which unmaps the image when dropped
RenderConfig
Settings for 3D rendering
RenderShape
Shape for rendering
RootTileBuffersError
Error type when resizing root tile buffers
TileBuffersError
Error type when resizing intermediate tile buffers

Enums§

BufferName
Names of all buffers, used for error reporting
RenderShapeError
Error type when constructing a RenderShape
RootTileBufferName
Names of buffers used by the root tile rendering pass (for error reporting)
TileBufferName
Names of buffers used by the intermediate tile rendering pass