Builtin Library
README.md
volume
High-level Luau API for 3D textures and volumetric rendering. Composes
the engine's generic compute.* primitives into volume-specific
helpers — procedural builders (sphere, box, noise), a sparse
NanoVDB-style occupancy grid for empty-space skipping, and a one-call
raymarcher that composites a volume through any entity's transform.
Pairs with the Volume component which attaches a built (or imported)
texture to an entity.
Exports
M.create({name, width, height, depth, format?, storage?}) -> Volume— allocate a fresh, zero-filled 3D texture and return a Volume wrapper.M.wrap({name, format, occupancyName?, occupancyBrick?}) -> Volume— wrap an existing named texture without recreating it (used by the .zvol viewer).M.get(name) -> Volume?— the live volume built under that texture name, with its dimensions, channel count, occupancy grid and brick pool.M.names() -> {string}— every nameM.getanswers to, sorted.M.register(volume) -> Volume— publish a wrapped volume under its own texture name.M.unregister(name) -> Volume?— stop answering for a name and hand back the volume that held it, leaving its GPU resources alone.M.releaseTarget(name) -> boolean— destroy a render target this module built forrenderand forget its size.M.sphere(opts) -> Volume— procedural Volume filled with a soft sphere SDF.M.box(opts) -> Volume— procedural Volume filled with a soft box SDF.M.noise(opts) -> Volume— procedural Volume filled with FBM noise.M.raymarchShaderIdentity() -> string— absolute identity of the shared raymarch shader.M.compositeShaderSource() -> string— WGSL source for the post-process composite blendingscene_volume_rtover the scene.M.FORMATS— table of canonical format strings (R8,R16F,R32F,RGBA8,RGBA16F,RGBA32F).volume:destroy()— release the GPU texture + occupancy.volume:writeBytes(bytes) / :writeFloats(floats)— upload payload to the 3D texture (matches the texture's format).volume:fill(fn)— CPU procedural fill viafn(x,y,z) -> color.volume:readback() -> bytes— pull the texture's current contents back to Luau.volume:buildOccupancy({brickSize, sampler?})— build an R8 occupancy companion for empty-space skipping.volume:buildSparse({brickSize?, threshold?, headroom?, capacity?, mode?})— page the volume into bricks, keeping only the ones holding something. Yields while the brick count reads back — twice unlesscapacitysays how many slots to take.volume:refreshSparse()— re-page from the volume's current contents; two dispatches and no wait.volume:releaseDense()— free the dense texture and render from the brick pool alone.volume:releaseSparse()— free the brick pool and page table and go back to rendering the dense texture.volume:readSparseCounts()—{ bricks, wanted, overflow, capacity }off the GPU; yields.volume:sparseStats()— byte accounting:denseBytes,poolBytes,pageBytes,residentBytes,ratio.volume:fillCompute(shaderName, opts)— drive a custom fill shader against this volume.volume:render(opts)— dispatch the shared compute raymarcher into a 2D storage target, usingopts.entityfor AABB. A paged volume renders through its pool with no change at the call site.
Usage
local Volume = require("@builtin::systems.volumetrics.volume")
-- Build a procedural noise cloud + occupancy.
local cloud = Volume.noise({
name = "cloud", size = 64, scale = 4, octaves = 4, threshold = 0.55,
})
cloud:buildOccupancy({ brickSize = 8 })
-- Render through the shared raymarcher; output to scene_volume_rt.
cloud:render({
entity = entity(id),
target = "scene_volume_rt",
targetWidth = 1280, targetHeight = 720,
density = 8.0, absorption = 1.2, emission = 0.9,
color = { 1.0, 0.9, 0.7 },
maxSteps = 256, mode = "rgba",
})
The Volume.component is the per-entity wrapper around this module — it
resolves the name it carries through Volume.get and dispatches
volume:render from its update hook.
Addressing a volume by name
Every volume this module builds answers to its texture name, so anything
holding only the name — an entity's Volume component, a second script, a
tool — reaches the volume itself:
local cloud = Volume.noise({ name = "cloud", size = 128 })
cloud:buildSparse({ brickSize = 8 })
cloud:releaseDense()
-- somewhere else entirely
Volume.get("cloud"):render({ entity = entity.find("cloud_host") })
What the name alone cannot express is what makes this matter: the voxel dimensions, which channel holds density, and whether the volume is paged into a brick pool. A holder that reconstructs a wrapper from the name gets a dense volume that the paging may already have released.
One volume owns a name at a time: the 3D texture, the page table and the brick
pool all address it, so create over a name a live volume holds releases that
volume first and says so in the log. destroy clears the entry. A wrapper
around a texture another system owns is published with Volume.register and
dropped with Volume.unregister, which hands the volume back so it can still
be destroyed. The names live where this module is loaded — one set per Luau
VM, alongside the raymarch target registry.
render builds its target on the first call that names one and keeps its
size, so repeated renders reuse it; Volume.releaseTarget(name) destroys that
target and forgets the size, and the next render builds it again.
Paging a volume into bricks
A dense volume costs its bounding box. buildSparse splits it into bricks,
keeps only the ones holding anything over threshold, and stores those in a
brick pool; a page table one texel per brick says which slot each resident
brick took, and 0 says the brick holds nothing. That page table is also the
grid the raymarcher already skips empty space with, so paging and empty-space
skipping are the same structure.
local cloud = Volume.noise({ name = "cloud", size = 128, threshold = 0.6 })
local stats = cloud:buildSparse({ brickSize = 8, threshold = 0.01 })
print(stats.bricks, "of", stats.grid[1] * stats.grid[2] * stats.grid[3], "bricks")
print(stats.poolBytes, "vs dense", stats.denseBytes)
cloud:releaseDense() -- the pool is now the only copy
cloud:render({ entity = entity.find("cloud_host") })
Both passes run on the GPU with no readback between them, so a volume a compute shader rewrites keeps a matching page table for the cost of two dispatches:
cloud:fillCompute(myFillShader, { extraResources = { { kind = "buffer", name = fillU } } })
cloud:refreshSparse()
The pool is fixed at the size the build measured, so a volume that grows past
it loses the surplus bricks. refreshSparse asks the GPU for the counts
without waiting on them and folds them in on a later call, so sparseStats()
reports the overflow — and the log carries a warning naming the capacity to
page at — a frame or two after the refresh that caused it. buildSparse's
headroom buys the slots up front for a volume that is expected to grow.
What it costs:
- A block in the pool is the brick plus a one-voxel apron on every side, so a
resident brick costs
(brick + 2)³voxels. AtbrickSize = 8that is 1.95x per resident brick, at 16 it is 1.42x. The apron is what makes a trilinear sample inside a brick read exactly what the dense volume would have given it. - A pool is always
rgba16f: it has to be writable as a storage texture and sampled with filtering, and that is the format that is both. A scalar volume therefore spends more per resident voxel than it did dense (its density rides in the pool's red channel), sosparseStats()reportsdenseBytesandresidentBytesside by side rather than assuming paging is the smaller of the two. refreshSparsere-pages from the dense volume, so a volume that will keep changing keeps its dense copy;releaseDenseis for one that is finished.- Classification reads one channel: alpha for a four-channel volume, red for a
scalar one. A four-channel volume that carries density in red — the one
render'smode = "density"marches — has to say so, with the same word:buildSparse({ mode = "density" }). Arenderthat names nomodemarches a paged volume from the channel its pool was filled from, so the two agree without the caller repeating the choice.
Notes
- Volumes are GPU-owned; the Luau wrapper just holds the named handle.
volume:destroy()releases both the texture and any occupancy companion. - The composite target
scene_volume_rtis shared across every Volume entity in the scene, so multiple volumes composite into one image. Overrideopts.targetif you need separate render targets per volume. - Occupancy grids use a brick size (default 8) — smaller = more precise skipping at the cost of grid memory.
- The
vdb_to_zvol.importerin this package produces dense voxel buffers consumable bycompute.writeFloatsTexture3D, which can be wrapped viaVolume.wrap(see thezvol_viewer.scene/demo for the full path).