The steps of one frame
A file is loaded once and uploaded to the GPU once. Then, every frame, the splats are projected for the camera, sorted back to front, and drawn.
The two modes are the same up to and including the projection. They differ in where the sort runs. On WebGPU, compute shaders sort on the GPU in the same frame, and the draw reads the splat count directly from a GPU buffer (an indirect draw), so no data is read back to JavaScript. WebGL2 has no compute shaders, so the view computes the sort keys on the GPU, reads them back, and sorts them on a worker thread. The resulting order arrives a few frames later, so the view keeps drawing the splats that order was sorted for, and sorts again only when the camera, an object, or the collection of splats has changed enough.
Data flow: data passes through file → table on the CPU → the same table on the GPU → projected words and sort keys → an order → pixels. Each arrow is one class, and each class can be used on its own.
See also: What a render does Quickstart
ArrayData and GpuArrays: one table on the CPU and on the GPU
Radix stores all its data, including splats, in tables. Rows are elements. Columns are channels, each stored as one typed array. Channels used together form a named bundle. ArrayData holds the table on the CPU; GpuArrays holds the same table in GPU textures, so each CPU row corresponds to one GPU row.
On the CPU: ArrayData
On the GPU: GpuArrays
These rules keep the two copies aligned:
- A format is a scalar type with a component count, named like WebGPU vertex formats:
"uint32x4","unorm8x4","float16x2". ArrayFormats tells you its bytes and typed array. - A bundle definition (defineBundle) is plain data: channel formats plus a
packingof 1, 2, or 4 elements per GPU texel. It can be posted to a worker or written into a file header. countrows are valid;capacityrows are allocated, always a multiple of 4096. This fills whole 1024-texel texture rows at every packing.- Every bundle of a table shares its
countandcapacity: a splat and its LoD tree node sit in the same row. Data with other rows, such as the sort keys of a projected collection or a draw order, is a table of its own. - On the GPU each channel is a 2D array texture, 1024 texels wide. TextureLayout picks its height and layer count; an element's texel is found with shifts and masks only.
new GpuArrays({ ..., storage: true })(WebGPU only) can also be written by compute shaders. The flag is part of the type,GpuArrays<S, true>, so compute writers refuse other arrays at compile time.
const data = new ArrayData({
bundles: { extSplats: Splats.EXT },
count: 1000,
});
// a Uint32Array, capacity * 4 long
data.bundles.extSplats.channels.extA.data;
// one texture per channel, uploaded
const arrays = GpuArrays.fromData(renderer, data);
// rows read back, as an ArrayData
const back = await arrays.read({ start: 0, count: 10 });
Why tables: splats, sort keys, draw orders, and LoD trees all use the same structure, so uploads, readbacks, worker transfers, and file formats are implemented once for tables instead of once per kind of data.
See also: ArrayData GPU arrays The data, in depth
Twin classes: the same bits on the CPU and the GPU
Code that encodes or decodes bits comes in pairs. The CPU class is in core/ and has no prefix. Its shader twin is in gpu/, starts with Gpu, has the same method names, and builds TSL (three.js shader code) instead of computing numbers. Both produce the same bits, so a table written on one side reads correctly on the other.
| CPU (core/) | GPU (gpu/, TSL) | What they share |
|---|---|---|
| Splats | GpuSplats | the three splat layouts: encode, decode, store one splat |
| Encoding | GpuEncoding | half floats, HDR colors, octahedral directions, and rotations |
| SortKeys | GpuSortKeys | a distance as a sort key that orders splats back to front |
| SplatMath | GpuSplatMath | quaternions, covariances, and eigenvectors |
| Sh, ShPalette | GpuSh, GpuShPalette | spherical harmonics: the codecs (ext, packed), the basis, and palettes |
A splat is a center, three scales, a rotation, a color, and an opacity. Three layouts store one splat per table row; a layout's name is also its bundle's name:
Codec example: the same call on the CPU and the GPU
Layout independence: Splats.get(data) finds the splat bundle of any layout on the CPU, and GpuSplatReader.from(arrays) does the same in shaders. Choose the layout by memory use (16 or 32 bytes per splat); the rest of the code does not depend on which layout it receives.
See also: The shader twins Splats
Files and workers: from bytes to a table
loadSplats turns a URL, a File, or bytes into an ArrayData with the splats in the requested layout (loadSplatFile also keeps RAD's clustered SH as a palette: { splats, shPalette? }). By default it runs on a worker, so decoding 10M splats does not block the page.
const data = await loadSplats("scene.spz", {
// or extSplats (default), or covSplats
layout: "packedSplats",
// stage: downloading, decoding, ...
onProgress: (fraction, stage) => show(stage, fraction),
});
// encoded back to file bytes
const bytes = await writeSplats(data, "spz", { version: 3 });
The formats
| Class | Format | decode |
|---|---|---|
| PlyFile | 3DGS PLY, point clouds, compressed PLY | 1.4M/s |
| CompressedPlyFile | writes SuperSplat compressed PLY | as PlyFile |
| SpzFile | SPZ 1-3 (writes 2 or 3) | 1.0M/s |
| RadFile | RAD, with LoD trees | 1.3M/s |
| RdxFile | any table, chunked | 6M/s |
Splats decoded per second into extSplats, on one core of an M3 Max. SplatFiles identifies the format from the first bytes of the file.
The pool
WorkerPool runs core/ functions on up to 4 workers, created on first use. Arrays are passed as transfers: the buffer moves to the other thread instead of being copied, and the sender's view becomes empty.
The worker option: if omitted, the shared pool when workers are available (otherwise this thread); true: the pool; false: this thread; a pool or a WorkerLease: to choose one worker.
RdxFile is Radix's own format. It stores any table's bundles in chunks of rows, byte-transposed and gzipped by Compression. The header lists the size of every chunk, so a viewer can read chunks in any order with HTTP range requests.
Copies: loading makes two copies of the splats, not three. The worker decodes the file bytes directly into the table's arrays, the arrays are transferred back to the page without a copy, and GpuArrays.fromData copies them once into textures.
See also: Splat files Workers RDX
Readers, writers, and the splat generator
Shaders access tables through two kinds of objects. A reader returns the texels of a row index, in any shader stage. A writer runs a function once per output row and stores what it returns, as a fragment pass (both backends) or a compute dispatch (WebGPU).
Read
GpuArrayReader reads one bundle: load(channel, index). Its shader is built from the bundle definition, so it can be bound to any arrays of that shape.
GpuSplatReader reads splats of any layout as the same struct: loadSplat(i) (center, scales, quaternion, rgb, opacity) or loadCovSplat(i) (a covariance instead). GpuShReader reads their spherical harmonics, per splat or through a palette, and gives the color seen from a point: color(i, origin, center).
Write
GpuArrayWriter renders a full-screen pass per texture layer, one fragment per row. WgpuArrayWriter takes the same arguments and gives the same results as one compute dispatch, for storage: true arrays.
At a lower level, wgpu/ has the basic building blocks: WgpuCompute kernels, WgpuBuffer storage buffers, and WgpuArrayStore scattered writes.
The renderer is built around the splat generator: any object with generateSplat(index, context) or generateCovSplat(index, context), and a bind(data, context) that points it at its data. The context tells it about the object and the view in the object's own space (viewOrigin, viewForward, objectToHead, ...), set per object. SplatGenerator.fromSplats({ layout, remap, modifiers, sh }) makes one for stored splats. It applies an optional remap of the index (either an index list, for example the splats of a Level-of-Detail subset, or a TSL function), then the reader's load, then the spherical harmonics' color seen from context.viewOrigin, then each modifier in turn; its data is { splats, indices?, shPalette? }. SplatGenerator.forArrays(arrays) picks the right one for given arrays, SH included. A plain object with a function that computes a splat from its index is also a generator, and its data, if it binds any, can be of any type. The projection requests splat i from a generator and does not depend on which kind of generator it is.
Generators: a generator in a renderer's collection runs inside the projection pass. No splats are stored for it, and its splats are projected and sorted together with all other splats.
See also: Shaders Writers WebGPU compute
Projection: a few words per splat, per eye
Every frame, each splat's 3D Gaussian is converted into what the draw needs on screen: its screen position, its 2D shape, its color, and its opacity. These values are packed into a few 32-bit words per splat, plus a sort key for its distance. Computing them once per splat, instead of at each of a quad's four corners, reduces the cost of the draw.
- Internal classes of the renderer: SplatCamera holds the camera of one render as shader uniforms: the head (the point that distances are measured from) and one eye, or two for XR.
set(renderer, camera)fills it from any camera; a camera made for the other clip convention is converted, not modified. An XRArrayCameragives two eyes. Splats enter the projection in head space. - SplatCollection holds what is projected, in order: objects of a generator, its data, a count, and a
matrixWorld(the object's world transform: object to world, such as anObject3D'smatrixWorld), each at an offset in one output. Each object's generator, data, and count are fixed; its world transform, params, and release stay live and can change after the collection is created. - SplatProjection (internal) implements one of four projection methods, selected by a renderer's
method. It defines its words, how to compute them for a splat, and how the draw decodes them. - SplatProjector keeps one program per generator (generate, transform, the modifier, project) and runs it once per object, one dispatch per object. Apps only read
programsand calldispose(generator). - SplatParams (internal) holds the defaults and the uniforms: a renderer's
params(maxStdDev,minAlpha,falloff: the draw reads them too) and each object'sobjectParams(blurAmount, depth of field,sortGuard, ...); a view or an object can set its own.
const collection = new SplatCollection([
// GpuArrays of any layout, placed by its world transform
SplatCollection.arrays(splats, { matrixWorld: model.matrixWorld }),
// a generator, at the next offset
{ generator: ring, count: 4000, matrixWorld: ringObject.matrixWorld },
]);
// projected for each render's camera
const splatRenderer = new SplatRenderer({
renderer,
method: "ewa",
collection,
});
scene.add(splatRenderer);
Stereo cost: words that depend only on the head (center direction, distance, color, rotation) are written once; each eye adds only a few words of its own. Both eyes are projected in the same pass and share one sort.
See also: Projection The pipeline, in depth
Sorting: on the GPU, or on the CPU a few frames later
Splats are transparent, so they must be blended from far to near. The projection has already written each splat's sort key; sorting turns the sort keys into an order, the list of splat indices to draw.
On WebGPU: WgpuSplatSorter
WgpuCompact first keeps only the splats with a valid sort key (visible in at least one eye) and writes their count to a GPU buffer. WgpuRadixSort then sorts those sort keys 8 bits at a time and reads the count from that buffer, so the count is never read back to JavaScript. Both classes are general-purpose: they accept any uint32 keys and values. The last kernel writes the arguments of the draw call.
On WebGL2: sort keys on the GPU, a sort on a worker, then the order is applied
// the camera's viewpoint, with its own CPU order const view = splatRenderer.viewFor(camera); // the collection's sort keys: computed on the GPU, read back, // sorted on a worker const order = await view.sort(camera); // drawn from the next frame on (autoSort does both automatically) view.apply(order);
sortSplatKeys runs SortKeys.sort, a stable radix sort in two passes (16 and 16 bits for 32-bit sort keys, 11 and 13 for 24-bit ones), in about 7 ms for 2M splats with 24-bit sort keys. In the meantime, the GPU keeps drawing with the previous order and the current projection of the collection that order was sorted for, so splats stay in their positions and only their order is slightly out of date. Each order has a version; an order that arrives with a newer version than the displayed one is applied even if the collection has changed since. With a sortGuard, the sort keys of splats far outside the view are also dropped, so fewer splats are sorted and drawn; in that case, sort again when the camera turns as well as when it moves.
Sort key bits: sort keys are the distance's float bits truncated to 16, 24, or 32 bits (set by sortBits). 24 is the default: 3 GPU radix passes, and neighboring splats closer than about 0.0015% of their distance may swap order. 32 bits takes a fourth pass and keeps the exact float order. 16 bits sorts faster but gives splats within 0.1% of each other the same sort key.
SplatRenderer: the draw mesh
SplatRenderer is a THREE.Mesh: give it a collection, add it to a scene, and render as usual, from any camera. Immediately before each render, it projects the collection for the camera, sorts it, and then draws one quad per splat in that order.
// WebGPU, or WebGL2 as a fallback
const renderer = await createRenderer();
// GPU sort on WebGPU, otherwise CPU sort
const splatRenderer = new SplatRenderer({
renderer,
method: "ewa",
collection,
});
scene.add(splatRenderer);
// applied to every splat; programs are cached
splatRenderer.modifier = depthColor;
renderer.render(scene, camera);
// a second camera: on WebGPU, nothing else is needed
renderer.render(scene, otherCamera);
Other parts of the API:
- A SplatView is one viewpoint of a renderer:
splatRenderer.viewFor(camera, { collection, objectParams, modifier })draws that camera with settings of its own (its own LoD subset, say); the rest follow the renderer. With the CPU sort, the renderer makes a view for each camera it renders, because each camera needs its own order. - Set
splatRenderer.view = splatRenderer.viewFor("thumbnail")to pick a view explicitly, for example to draw one camera twice with different settings; unset it to go back to choosing by camera. - The
modifierruns on every splat inmodifierSpace(view space, world space, the object's space, or a frame'smatrixWorld), similar to an override material. Switching between modifiers does not recompile any programs. prepare(camera)projects before the render instead of during it;phasewraps the project and sort steps for a profiler;onInvalidatetells an on-demand app when an order landed or a sort was requested (requestSort()), so a render would show something new.params(maxStdDev,minAlpha,falloff) andobjectParams(blur, culling limits, depth of field,sortGuard) are plain values changed in place; params lists each with its default.- An object's
releaseruns once nothing draws or sorts that object. It frees what the object owns, as the app defines it: for example, the index list of a Level-of-Detail subset.
For navigation, CameraControls moves the camera from pointer, touch, keyboard, gamepad, or WebXR controller input. It has two modes, orbit around a target and fly; the F key switches between them without moving the view. Call controls.update() before each render. The viewer at /build/examples/splats/index.html uses all of these classes in one page; /build/examples/scenes/index.html shows several objects, several cameras and views, modifiers, and a subset drawn through an index list recomputed from the camera (as a Level-of-Detail subset would be).
Draw cost: the fragment stage blends every pixel of every quad, so splats near the camera cost the most. Discarding fragments beyond the splat's cutoff and below minAlpha limits this cost.
See also: SplatRenderer Views Params
Recipes: different pipelines from the same classes
Each step is a separate class, so a pipeline takes a few lines of code. Select a recipe to highlight the classes it uses on the map.
See also: Recipes
A cost model
The cost of each step scales with a different quantity. Knowing which one helps you choose what to change: fewer splats, fewer pixels, fewer sort key bits, or a different projection method.
| Step | Scales with | bytes per splat |
|---|---|---|
| source splats (GpuArrays) | splats | 32 ext/cov, 16 packed |
| projection (projected splats) | splats × eyes | 20 to 44 (ch. 6) |
| GPU sort (compaction + radix) | visible splats × sort key bytes | 16 (sort keys, values, scratch) |
| CPU sort (readback, worker, upload) | splats | 4 down, 4 up |
| draw: vertex stage | drawn splats | reads 4 to 44 |
| draw: fragment stage | pixels × splats over each pixel |
GpuProfiler measures this in your own app: createRenderer({ profile: true }), then profiler.phase("project", fn) around your steps and await profiler.collect() for milliseconds per phase.
Main cost: the draw takes most of the frame time. Splats close around the camera are the most expensive, especially with the ray methods (3dgut, htgs), which evaluate them across the whole screen. Projection and sorting take a few milliseconds even at 10M splats.
See also: Performance