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 / 64strata - 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
Contextcontains all of the pipelines used for 3D rendering. It is very expensive to build and should be constructed once per thread / worker.RenderShapecontains 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.Bufferscontains 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 oneBuffersobject per worker context (or per simultaneous render); if image sizes change, it can be resized withContext::set_buffers_image_size(which will grow buffers, but does not shrink them). Systems with high variability in image size may want to periodically comparesizeversuscapacityand fully reallocate buffers (by constructing a newBuffersobject) if they get too out of whack.RenderConfigsets 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:
- Build a
Context - Use
Context::shapeto convert from aVmShapeto aRenderShape - Use
Context::buffersto getBuffersat a particular image size - Use
Context::image_bufferto get anImageReadBuffer - Call
Context::runorContext::run_asyncto get an image
§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
GeometryPixelarray 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
- Buffers
Error - Error returned when resizing a
Buffersobject - Context
- Context for 3D (combined heightmap and normal) rendering
- Geom
Buffer Tag - Tag for a on-GPU buffer storing
GeometryPixelvalues - Image
Read Buffer - Buffer for reading data back from the GPU
- Mapped
Image - Handle to a mapped image, which unmaps the image when dropped
- Render
Config - Settings for 3D rendering
- Render
Shape - Shape for rendering
- Root
Tile Buffers Error - Error type when resizing root tile buffers
- Tile
Buffers Error - Error type when resizing intermediate tile buffers
Enums§
- Buffer
Name - Names of all buffers, used for error reporting
- Render
Shape Error - Error type when constructing a
RenderShape - Root
Tile Buffer Name - Names of buffers used by the root tile rendering pass (for error reporting)
- Tile
Buffer Name - Names of buffers used by the intermediate tile rendering pass