Radix stores 3D Gaussian splats in tables of typed arrays. A table has the same layout and bits on the CPU, in workers, in files, and on the GPU.
This page covers tables, formats, texture layout, splat layouts, typed access, writers, double buffering, workers, and files. The widgets run JavaScript ports of the codecs in core/.
An ArrayData is a table. Each row is one element, such as a splat. Each column is a channel, one flat typed array. Channels that belong together form a named bundle, and all bundles have the same rows.
Capacity is always a multiple of 4096. 4096 elements fill whole 1024-wide texture rows at every packing, so CPU rows and GPU texels line up.
// click a column header
Channel names: a channel's name says what its bits are (extA, covB, packed), and code finds splat data by those names and formats. A bundle definition is plain data, so it can be sent to a worker with postMessage or saved in a file header unchanged.
A format is a scalar type with an optional component count, named like WebGPU vertex formats ("uint32x4"). packing puts 1, 2, or 4 elements in one texel. It only changes the GPU layout; CPU memory stays the same.
See also: Formats
On the GPU, GpuArrays holds the same table: each channel is a 2D array texture, a stack of equal layers that are 1024 texels wide. Width and height are powers of two, so a texel's address is found with shifts and masks only.
x = t & 1023 y = (t >> 10) & (height - 1) layer = t >> (10 + log2(height))
Layout rule: the height is the largest power of two at or below half the number of rows (at most 4096), and there are 2 to 512 layers. Small data uses 2 to 4 layers. Past 4096 rows per layer, it adds one full layer at a time. The largest texture holds 231 texels.
See also: Texture layout TextureLayout
A Gaussian splat has a center, three scales, a rotation, a color, and an opacity. Ext splats add PBR fields: a normal, roughness, and metallic. Three layouts, extSplats, covSplats, and packedSplats, pack these into 32-bit words, and each layout's name is also the name of the bundle that holds it. Hover over a field for its description.
Files decode directly into the requested layout. Packed splats take an optional encoding, the color and scale ranges of their 8-bit codes; without it, the defaults apply. The rest of the program is the same for every layout.
const data = await loadSplats(url, {
layout: settings.lowMemory ? "packedSplats" : "extSplats",
encoding: { rgbMin: -0.5, rgbMax: 1.5 }, // optional
});
// the same code for every layout, on the CPU:
const { bundle } = Splats.get(data);
// and on the GPU:
const arrays = GpuArrays.fromData(renderer, data);
const reader = GpuSplatReader.from(arrays);
The page runs the same color, opacity, and half-float encoders as core/encoding.ts.
Bands 1 to 4 (up to 24 RGB coefficients) sit in an SH bundle beside the splats. Band 0 is the base color above. extSh stores each coefficient in one shared-exponent word, like the base color. packedSh stores a scale byte per band, then one signed byte per value. Neither needs a range per file. The page runs the same codecs as core/sh.ts.
HDR color: 8-bit magnitudes with three sign bits and a shared 5-bit exponent let colors go above 1 or below 0, which merged LoD nodes and lighting require. Opacity uses 128 for 1.0 so merged nodes can reach 255/128. The same word stores one spherical-harmonics coefficient in extSh. Palettes (RAD's clustered SH, SOG's) store each entry's texels side by side, and splats pick an entry by a 16-bit label (Spherical harmonics).
See also: Layouts How each field is stored
Normals and rotation axes are stored octahedrally: the sphere is projected onto an octahedron and unfolded into a square. Drag on either view to change the direction. The encoder rounds each coordinate to its code, with no search, and gives the same code on the CPU and the GPU.
Rotation bits (10 + 10 + 12): many splats are close to the identity rotation. For a small rotation the axis error is scaled by sin(angle / 2), so the angle's step determines the precision. With 12 bits, rotations under 2° decode within about 0.03°. Compare a small angle with a large one above.
See also: Octahedral directions Quaternions
Splat access functions take the bundle as their first argument, with no wrapper object around it. TypeScript uses the bundle's type to decide which functions apply. GPU readers use the same names, and the TSL codecs produce the same bits as the CPU codecs.
The CPU and GPU use the same layouts. Shaders use the GPU's half-float pack and unpack and its sine and cosine, and round with floor(x + 0.5). Where 32-bit GPU math falls on the other side of a rounding boundary than the CPU's 64-bit math, a code differs by one step.
See also: Splats on the CPU In shaders
Shaders write arrays through a writer. A writer takes a TSL function, write(index, out), that fills one texel. The fragment writer draws quads and works on both backends. The compute writer in wgpu/ takes the same arguments and writes the same texels in one dispatch. For writes to arbitrary indices, WgpuArrayStore stores at any index from any kernel.
| GpuArrayWriter | WgpuArrayWriter | WgpuArrayStore | |
|---|---|---|---|
| backend | WebGPU and WebGL2 | WebGPU | WebGPU |
| runs as | a render pass per layer, quads over rows | one dispatch, a thread per texel | inside any WgpuCompute kernel |
| writes | texels [at, at + count) | the same texels, same indices | any index (scatter) |
| limits | 4 channels, 64 bytes per texel, renderable formats | 4 channels, storable formats, storage: true | storable formats, storage: true |
| count | count = max(count, at + count) | set by the kernel | |
Either writer: arrays.write(writer, range) takes either writer, and writeBatch groups items by kind, so one batch can contain fragment and compute writers.
See also: GpuArrayWriter WgpuArrayWriter WgpuArrayStore
A shader cannot read and write the same texture in one pass. Instead, read from the front buffer, write the next frame into the back buffer, then call swap. Readers hold texture references, so bind them again after a swap.
CPU: ArrayData.swap works the same way. When a worker hands arrays back, setFromTransferable adopts them in place, so references held elsewhere stay valid.
See also: Double buffering GpuArrays
WorkerPool starts with zero workers and creates one only when a call needs it, up to its size. Arrays are passed in and out as transferables, so nothing is copied. In Node or Cloudflare, where there are no workers, the same call runs on the current thread.
worker optionSee also: Workers
loadSplats reads PLY, SPZ, RAD, and RDX into the requested layout, extSplats by default (plus a lodTree bundle when the file has a tree), and writeSplats writes any layout to any of these formats. Files are recognised by their first bytes, and by the file name's extension when the bytes are not conclusive. Drop a file to detect its format. The file is read only in this browser page.
See also: Splat files
An RDX file is a header followed by chunks of rows, in one file or split across files for streaming. A whole file can be read by seeking: chunk i starts at the header's length plus the sizes of the chunks before it, so one HTTP Range request fetches any chunk.
Each channel's rows are byte-transposed before gzip: byte 0 of every element, then byte 1, and so on. Similar bytes end up next to each other and compress better: the high bytes of nearby half floats (their sign and exponent), the nearly constant alpha, and colors that change slowly.
The sizes on the left are measured. The page gzips 4096 elements of rgba (unorm8x4) and center (float16x3) both ways, one payload per channel as in an RDX chunk, with your browser's CompressionStream.
See also: RDX files