Splat files#

loadSplats reads PLY (standard or SuperSplat compressed), SPZ, RAD, and RDX files into a table, and writeSplats writes a table to any of these formats. Both run on a worker by default, so a large file does not block the page. SplatFiles and the per-format classes do the same work on the current thread, in a page, a worker, or Node.

Code: src/worker/splatFiles.ts (loadSplats, loadSplatFile, writeSplats); src/core/files.ts (SplatFiles), ply.ts, compressedPly.ts, spz.ts, rad.ts, and rdx.ts.

// Ext splats by default, in data.bundles.extSplats
const data = await loadSplats("scene.spz");

// Packed splats and packed SH, reporting progress
const packed = await loadSplats(file, {
  layout: "packedSplats",
  sh: "packed",
  onProgress: (f, stage) => bar.update(f, stage),
});

// Write an SPZ file and save it
const bytes = await writeSplats(data, "spz", { version: 3 });
const blob = new Blob([bytes]);
download(blob, "scene.spz");

loadSplats#

loadSplats(source, options?) returns a table (SplatData<L>). The splats are in the bundle named after the layout. A file with a LoD tree adds a lodTree bundle, and a file with spherical harmonics adds an SH bundle (extSh by default). Clustered SH in RAD files is expanded to per-splat SH on the worker.

loadSplatFile(source, options?) takes the same options and returns { splats, shPalette? } (LoadedSplats<L>). The only difference: clustered SH stays a palette. shPalette is an ShPalette, and the table's shLabel bundle indexes it (Spherical harmonics). A palette uses far less memory: 2 bytes per splat plus the palette, against 48 to 60 bytes per splat at SH3. Pass shPalette on to GpuShPalette.upload, writeSplats, and Axes.convert.

Option (LoadSplatsOptions)DefaultWhat it does
sourcea URL (string or URL, resolved against the page), a Blob or File, or the file's bytes (Uint8Array or ArrayBuffer): a SplatSource
layout"extSplats""extSplats", "covSplats", or "packedSplats"; the file decodes directly into it (Splats)
encodingthe file's, else the defaultsthe ranges of a packed layout's 8-bit codes (Partial<PackedEncoding>)
sh"ext" (RDX: its stored codec)the SH codec ("ext" or "packed"), or false to skip SH (ShTarget; Spherical harmonics)
coordinateSystemas storedSPZ only: the axes to convert to, such as "RUB" or "RDF" (CoordinateSystem). meta.coordinateSystem names the table's axes after loading (below)
namethe URL or file nameidentifies the format by its extension when the first bytes do not
workerthe shared poolwhere it runs (below)
onProgress(fraction, stage), where stage is "downloading", "decoding", "encoding", or "compressing" (a ProgressStage)
requestfetch options for a URL source, such as { credentials: "include", headers: { Authorization } } (SplatRequestInit: RequestInit without signal, body, window, or method). A Headers object becomes a plain record on its way to a worker
signalan AbortSignal: aborting rejects the promise with signal.reason at once. The download stops, and so does the decode, at its next progress report, on a worker or here. A load still waiting for a pool's worker leaves the queue

RDX, RAD, and PLY are detected by the magic number in their first bytes, and SPZ by a gzip header, SPZ 4's NGSP header, or its extension. A compressed PLY is detected as "ply": the PLY reader tells the two kinds apart by their header.

writeSplats#

writeSplats(data, format, options?) returns the file's bytes. format is "ply", "compressed-ply", "spz", "rad", or "rdx" (a SplatFormat). PLY, compressed PLY, SPZ, and RAD write the splat bundle in any layout, the SH bundle, and the lodTree where the format supports one. They write PBR data (normals, roughness, metallic) only with pbr: true. RDX writes every bundle marked serialize: true as it is, including ext splats' PBR bits.

Option (SplatWriteOptions)FormatsDefaultWhat it does
pbrPLY, SPZfalsewrite normals, roughness, and metallic (SPZ). Ext splats only
compatibilityPLYfalsezero normals (nx, ny, nz) for tools that expect them
sortcompressed PLYtruewrite the splats in Morton order of their centers, as SuperSplat does, so each chunk of 256 covers a small box; false keeps the table's order
versionSPZ32 or 3 (gzip container) or 4 (SPZ 4: NGSP header, zstd). All keep SH up to degree 4
lodSPZwhen the data has a treewrite the lodTree bundle (child counts below 2^16)
coordinateSystemSPZmeta.coordinateSystemversion 4: the axes to record, if not "RUB". The data is not converted
zstdLevelSPZ12version 4: Node's zstd level, 1 to 22. Browsers use Radix's own encoder, which has no levels
fractionalBitsSPZ12fraction bits of the 24-bit fixed-point centers
antialiasedSPZtruethe file's antialiasing flag
chunkSizeRAD, RDX65536splats per chunk (RDX: a multiple of 4096)
shPLY, compressed PLY, SPZ, RADtruethe SH bundle, when the data has one
radShRAD"s8"the SH encoding: "s8" over each band's largest value in the chunk (the property's min / max), "f16", or "f32"
shPaletteallthe ShPalette that the table's shLabel bundle indexes. RAD writes it as clustered SH (sh1_code..sh3_code in chunk 0 as radSh, s8 over each band's largest value; sh_label as u16). Other formats write per-splat SH
worker, onProgressallas for loadSplats

On a worker, writeSplats transfers the table's arrays (and the palette's) to the worker and puts them back in place afterwards, so the table and its bundle objects are usable again when the promise settles, even if the write fails. Do not use the table while a write is running, and take its arrays from its bundles again afterwards: the ones held before were detached. For the same reason writeSplats takes no signal: an aborted write would leave the arrays on the worker.

Where the work runs#

worker (WorkerChoice)Runs on
left outthe shared pool where the page can make workers; otherwise the current thread (Node, or code already in a worker)
truethe shared pool; throws where workers are unavailable
falsethe current thread
a WorkerPoolone call on that pool
a WorkerLeasethat worker; the caller keeps the lease

What each format keeps#

PLY, SPZ, and RAD decode directly into the requested layout. RDX returns every bundle it saved (RDX files) and converts its splats when they are stored in a different layout.

FormatReadsWritesPBRLoD treeSH (degree 1 to 4)
PLYbinary 3DGS, RGB point clouds, and compressed PLY3DGS, float32normals, roughness, and metallic (pbr)nof_rest_* float32 (72 at degree 4): read and written exactly
Compressed PLYSuperSplat files with or without color ranges16 bytes per splat in chunks of 256, with color rangesnonoan sh element of uchar codes over ±4; degree 3 at most (degree-4 SH writes its first 3 bands)
SPZversions 1 to 4 in the gzip container, and SPZ 4version 2 or 3 (gzip), or 4normals, roughness, and metallic (pbr)yes (lod)8-bit codes over ±1, up to degree 4
RADRAD0 with embedded chunks, or one RADC chunkRAD0, gzipped float32 and uint32 columnsnoyessh1..sh3 (degree-4 SH writes its first 3 bands): s8 (default; over each band's largest value per chunk), s8_delta, f16, or f32 (radSh); clustered SH (a palette) read and written
RDXRadix's own: every saved bundleevery serialized bundle, whole or chunkedyesyesthe SH bundle as stored, in any codec

SPZ#

Niantic's SPZ (nianticlabs/spz) has two containers. SpzFile reads both.

ContainerVersionsLayout
gzipreads 1 to 4; writes 2 or 3 (default 3)16-byte header; columns for centers, alphas, colors, scales, rotations, and SH; Radix's LoD tree (flag 0x80) and PBR (flag 0x40); upstream extension records (flag 0x02)
SPZ 4 (NGSP)reads and writes 432-byte plaintext header; extension records; a table of contents; one zstd stream per non-empty column

zstd#

SPZ 4 compresses with zstd (Zstd.compress, Zstd.decompress). Radix uses the first of these that is available: Node's zlib (compressing at zstdLevel, default 12), the browser's CompressionStream / DecompressionStream("zstd") (no browser has them yet), then fzstd to decode and Radix's own encoder to encode. Radix's encoder codes literals only, with Huffman codes.

Sizes against version 3 (gzip), over 2 GB of columns from the splats viewer's samples and SharedTestModels' SH files:

EncoderSizeSpeed
Radix's encoder+2.1% (+8 to +22% on small files with SH)170 MB/s
Node, level 3+1.7%310 MB/s
Node, level 6±0%117 MB/s
Node, level 12 (default)-1.7%25 MB/s
Node, level 15-2.4%6 MB/s
Node, level 19-4.9%2 MB/s

SPZ 4 does not make files much smaller, so version 3 stays the default. Write version 4 for readers that need it.

Coordinate systems#

SPZ names axes like upstream: three letters for the directions of +x, +y, and +z. For example "RUB" (right, up, back: three.js and SPZ's default), "RDF" (common in PLY files), and "RFU" (z up). Axes.SYSTEMS lists all 16 in SPZ's numbering. A file without a coordinate-system record is "RUB".

Compressed PLY#

SuperSplat and PlayCanvas's splat-transform write a quantized PLY, usually named .compressed.ply. It is valid PLY syntax but not the standard 3DGS layout, so it has its own writer, CompressedPlyFile.write (or writeSplats(data, "compressed-ply")). PlyFile.read reads both kinds; CompressedPlyFile.read is the same function.

The file has a chunk element with one row per 256 splats: the box of their centers (min_x to max_z), the range of their log scales (min_scale_x to max_scale_z) and, in current files, the range of their colors (min_r to max_r; older files have none, and colors then span 0 to 1). The vertex element holds four uint words per splat:

WordBitsWhat it holds
packed_position11, 10, 11x, y, z in the chunk's box
packed_rotation2, then 10, 10, 10the index of the largest of w, x, y, z, then the other three, each within ±1/√2
packed_scale11, 10, 11log scales in the chunk's range
packed_color8, 8, 8, 8r, g, b (0.5 + SH_C0 * f_dc) in the chunk's range, then linear opacity

An optional sh element holds spherical harmonics as one uchar per value, channel-major (f_rest_{c * N + k}), mapped as splat-transform does: ((b + 0.5) / 256 - 0.5) * 8, with 0 and 255 at the ends, so values span ±4. They load into the SH bundle like the float ones. A compressed file loads with meta.compressed set to true.

The writer writes the 18-property chunks, an sh element when the data has SH (in the same order as the vertices), and no LoD tree. Log scales are clamped to ±20, as splat-transform does. With the default sort: true the splats come back in a different order than the table's.

SplatFiles and the per-format classes#

These run the same steps on the current thread, without workers:

MemberWhat it does
SplatFiles.detectFormat(bytes, name?)the SplatFormat, from the first bytes or else the extension; throws for unknown files
SplatFiles.decode(bytes, { name?, layout?, encoding?, sh?, coordinateSystem?, progress? })a file's bytes to { splats, shPalette? }
SplatFiles.fetch(url, { layout?, encoding?, sh?, coordinateSystem?, progress?, init? })downloads (the first 40% of the progress) and decodes; init is passed to fetch
SplatFiles.encode(data, format, { ...SplatWriteOptions, progress? })a table to a file's bytes
PlyFile.read / write, CompressedPlyFile.read / write, SpzFile.read / write, RadFile.read / writeone format; readers take (bytes, { layout?, encoding?, sh?, progress? }) (SPZ also coordinateSystem, and a ReadableStream) and return { splats, shPalette? } (only RAD's clustered SH makes a palette)
SpzFile.unpackan SPZ file's header and byte columns, undecoded (SPZ)
RdxFileRDX files, whole, chunked, or over HTTP range requests (RDX files)
Compression.gzip, gunzip, readBytes, compressTransposed, decompressTransposedgzip through the platform's CompressionStream, and byte-transposed gzip for RDX
Zstd.compress, Zstd.decompresszstd frames (SPZ)
ByteReaderreads bytes in order from a buffer or a stream with chunks of any size: read(n), peek(n), skip(n), rest(), atEnd(), stream(), cancel()
Axescoordinate systems and conversions (SPZ)

Types: LoadedSplats, LoadSplatsOptions, SplatFormat, SplatLoadOptions, SplatRequestInit, SplatWriteOptions, SpzWriteOptions, SpzUnpacked, SpzExtension, CoordinateSystem, AxesConverter, SplatSource, WorkerChoice, Progress, ProgressStage, and BytesInput (Uint8Array, ArrayBuffer, or a ReadableStream).

Gotchas#

Source: docs/files.md