# spz: a compressed container for 3D Gaussian splats

> spz is Niantic Labs' file format and C++ library for 3D Gaussian splats, roughly ten times smaller than the equivalent PLY with minimal visual difference. It is aimed at teams shipping scanned scenes to browsers, mobile AR pipelines or rendering engines that need a Gaussian cloud to travel over a network.

**nianticlabs/spz** — File format for 3D Gaussian splats. About 10x smaller than the PLY equivalent with virtually no perceptible loss in visual quality. Offered as open source by Niantic Labs. More details at https://scaniverse.com/spz #3dgaussiansplats #gaussiansplatting

- Repository: https://github.com/nianticlabs/spz
- Website: https://scaniverse.com
- Stars: 938 · Forks: 113
- Language: C++
- License: MIT
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/nianticlabs-spz

## Ten times smaller than PLY, with the splats intact

A 3D Gaussian splat is a scene representation where each point carries a position, an orientation, a scale, a colour and spherical harmonics coefficients. The point cloud is easy to store, but the spherical harmonics are bulky, and the raw format of record has been PLY.

spz exists to change that transport. The project description states that `.spz` is a file format for 3D Gaussian splats and that spz encoded splats are typically around 10x smaller than the corresponding `.ply` files, with minimal visual differences between the two. That is the whole proposition: not a lossy re-encoding of the scene, but a container that keeps the data and shrinks the bytes.

This matters most where the file has to travel. A scanned room captured on a phone and viewed in AR or in a browser pays for every byte of download. Niantic offers the format as open source through this repository, which holds the C++ library for saving and loading `.spz` data, with details about the format also referenced at scaniverse.com/spz.

The repository layout suggests a serious reference implementation rather than a script: `src/`, `tests/`, `samples/`, `cli_tools/`, `extensions/`, `cmake/` and `third_party/`, plus a `CODEOWNERS` file.

## Sixteen coordinate systems, because RUB is only a default

The most interesting piece of engineering in the format is the coordinate system handling, and it is the part most likely to cause a visible bug if you skip it.

By default SPZ stores data in an RUB coordinate system, following the OpenGL and three.js convention. That default differs from other formats the README compares: PLY typically uses RDF, GLB typically uses LUF, and Unity typically uses RUF. There are 16 named coordinate systems in two families. The standard family uses letters for the X, Y and Z axis directions, `LDB`, `RDB`, `LUB`, `RUB`, `LDF`, `RDF`, `LUF` and `RUF`. The rotated family permutes the axes by a 90 degree rotation about X, swapping Y and Z, giving `LFD`, `RFD`, `LFU`, `RFU`, `LBD`, `RBD`, `LBU` and `RBU`.

Converting between families applies that 90 degree rotation about X on top of any axis flips, and it also rotates the spherical harmonics coefficients through the appropriate Wigner D-matrix. That last part is why the coordinate system is stored rather than assumed: a flipped axis changes not just where the splat sits but how its view dependent colour behaves.

The API reflects the risk. Callers specify their source coordinate system when saving and their renderer's coordinate system when loading, through PackOptions and UnpackOptions. If the coordinate system is `UNSPECIFIED`, data is saved and loaded without conversion, and the README warns that this may harm interoperability.

## Version 4 splits the file into parallel ZSTD streams

The file format was rewritten in version 4. The README states that version 4 uses ZSTD compression with a 32 byte plaintext header, that each attribute stream is compressed independently and in parallel to improve both compression and decompression throughput, and that files from versions 1 to 3, which used a gzip compressed single stream format, can still be read.

The layout is laid out as a byte map:

```
Bytes  0–31:              NgspFileHeader (32 bytes, plaintext)
Bytes  32..(tbo-1):       Extension ILV records, if flags & 0x2  (variable)
Bytes  tbo..(tbo+N*16-1): TOC — N × [compressedSize u64, uncompressedSize u64]
Bytes  (tbo+N*16)..end:   N independent ZSTD-compressed attribute streams
```

The table of contents sits at `tocByteOffset` from the header, and when no extensions are present that offset is 32. A reader can therefore seek to a single attribute stream without decompressing the whole file, which is the point of splitting by attribute in the first place.

The header itself is a plain C struct:

```c
struct NgspFileHeader {        // 32 bytes, all fields little-endian
  uint32_t magic;              // 0x5053474e ("NGSP")
  uint32_t version;            // 4
  uint32_t numPoints;
  uint8_t  shDegree;
  uint8_t  fractionalBits;
  uint8_t  flags;
  uint8_t  numStreams;         // number of ZSTD-compressed attribute streams (typically 6)
  uint32_t tocByteOffset;     // byte offset from file start to the TOC
  uint8_t  reserved[12];      // zero, reserved for future use
};
```

The magic value spells NGSP in file order, `shDegree` must be between 0 and 4 inclusive, and `fractionalBits` records how many bits of the fixed point coordinates hold the fractional part. Two flag bits are defined: `0x1` marks a splat trained with antialiasing, and `0x2` says the header zone holds vendor specific extension records. There is no maximum point count enforced during loading, which is a deliberate choice and also a memory consideration on the loading side.

## Four C++ entry points and one options struct

The C++ API is small and symmetric. Saving to memory and saving to a file both take a `GaussianCloud`, a `PackOptions` and an output, and both return true on success and false on failure:

```cpp
bool saveSpz(
   const GaussianCloud &gaussians, const PackOptions &options, std::vector<uint8_t> *output);
```

Loading comes in the same two shapes, a byte vector and a filename, both taking `UnpackOptions` and returning a `GaussianCloud`. The documentation is blunt about the failure case: in case of an error, the load returns a result with no gaussians. There is no error code and no exception in the described interface, so a caller that ignores the size of the returned cloud will silently work with nothing.

The knobs live in `PackOptions`, which the README lists as four fields. `from` is the source coordinate system, defaulting to `UNSPECIFIED`. `version` is the packed format version, defaulting to 4. `sh1Bits` is the number of quantization bits for spherical harmonics degree 1 coefficients, defaulting to 5 with a range of 1 to 8. `shRestBits` does the same for degree 2 and above coefficients, defaulting to 4 with a range of 1 to 8.

Those last two are the compression controls. Fewer bits means fewer bytes per coefficient and more quantization error, so the defaults are a starting point to test against your own scenes rather than a setting to accept unexamined.

## Three ways to build it: CMake, Emscripten, Python wheels

The C++ side has a CMake build and needs `libz` and `libzstd`, with everything else self-contained. The README adds a practical note: if `libz` or `libzstd` are not found on the system, the CMake build downloads them with FetchContent, which the project describes as ensuring consistent builds across different environments. That is convenient for CI and a supply chain consideration for anyone who cares where those sources come from.

The TypeScript interface is a WebAssembly build, which needs an Emscripten environment set up before compilation. The Makefile is generated with:

```bash
emcmake cmake -B build-wasm .
```

and built with:

```bash
cmake --build build-wasm
```

The package is then built and installed into the `dist` folder. The important limitation is stated plainly: because the Emscripten and JavaScript memory are handled separately, only a limited set of functionalities is exposed to the TypeScript interface. The full definitions live in `src/emscripten/spz.d.ts.in` before a build and in `dist/spz.d.ts` after one.

Python is served by bindings built with nanobind and scikit-build, and pyproject.toml shows the shape of it. The package name is `spz`, it requires Python 3.8 or newer, and numpy is the only runtime dependency. The version is extracted from CMakeLists.txt with a regex, so the Python and C++ versions cannot drift apart. Wheels are built against the stable ABI for CPython 3.12 and later, and the test command is pytest against the `tests` directory.

## Where the format stops being enough

Three boundaries are worth naming before you commit.

The first is write compatibility. Versions 1 to 3 can be read, but the version field is 4 and the format is documented as the legacy gzip single stream layout being read-only. If you have an existing pipeline producing v2 spz files, this library will read them but will not produce them, so anything that depends on the old layout needs to keep its old writer.

The second is the JavaScript surface. The WebAssembly build exposes a limited set of functions because of how Emscripten and JavaScript memory are managed separately. A browser application that wants fine control over packing options, or over the coordinate system on both sides, will find more of that in the C++ API than in the TypeScript one.

The third is the coordinate system trap. Leaving `from` as `UNSPECIFIED` means no conversion happens, and the README says that may harm interoperability. Combined with a renderer that assumes a different convention than the RUB default, the failure mode is a scene that loads successfully and looks wrong, with no error to trace. Since a load error also returns an empty cloud rather than an exception, both failure modes need explicit checks in your code.

None of this makes the format a poor choice. It makes it a format where you have to state your assumptions.

## MIT terms and a fast release line since 2025

The licence field on the repository says MIT, which is permissive enough for commercial use, for embedding the library in a closed product, and for shipping the format as a de facto interchange standard. Niantic Labs offers the format as open source, and more details are pointed at scaniverse.com/spz.

The release history shows a format that is still moving quickly. v2.0.0 was tagged on 2025-07-11, v2.1.0 on 2025-10-27 and v3.0.0 on 2026-05-05, with the last push on 2026-09-01. Three minor line changes in about a year on a file format is the pace you would expect from something still finding its shape, and it means the version number in a header field is worth checking before you write a reader.

For an adopter, the cost is compatibility work rather than dependency churn. The Python wheels are built for the stable ABI on CPython 3.12 and later, so older interpreters are a consideration. And because the format version and the library version move separately, a file written by one build can carry a version your other build reads as legacy.

## Conclusion

Adopt spz when you are distributing 3D Gaussian splats and need a format that survives a network and a browser, and you can commit to naming a coordinate system explicitly rather than leaving it UNSPECIFIED. Skip it if you need to write older spz versions, since versions 1 to 3 are read-only here, or if you need the full API from JavaScript, because the WebAssembly build exposes only a limited set of functions. Verify first that the `PackOptions` quantization bit values you choose hold up against your own scenes, and that your renderer can read a coordinate system other than the RUB default. The licence field on the repository says MIT, the last push was on 2026-09-01, and the current tagged release is v3.0.0 from 2026-05-05.

## FAQ

### What is the spz file format used for?

`.spz` is a compressed file format for 3D Gaussian splats, and the repository contains a C++ library for saving and loading it. The project states that spz encoded splats are typically around 10x smaller than the corresponding `.ply` files, with minimal visual differences.

### How do I build the spz C++ library?

A CMake build system is provided and the code needs `libz` and `libzstd`. If either library is not found on the system, the README states that the CMake build downloads them automatically using FetchContent.

### Which coordinate system does spz use by default?

By default SPZ stores data in an RUB coordinate system following the OpenGL and three.js convention. There are 16 named coordinate systems in a standard family and a rotated family, and if the coordinate system is `UNSPECIFIED` data is saved and loaded without conversion, which may harm interoperability.

### Can spz read files made by older versions of the format?

Yes. Files from versions 1 to 3, which used the gzip compressed single stream format, can still be read. Version 4 uses ZSTD compression with a 32 byte plaintext header and independent per-attribute streams.

### How do I use spz from Python or TypeScript?

The Python bindings are built with nanobind and scikit-build, require Python 3.8 or newer and depend on numpy. The TypeScript interface is a WebAssembly build requiring Emscripten, with `emcmake cmake -B build-wasm .` followed by `cmake --build build-wasm`, and exposes a limited set of functionalities.

### What licence is spz released under?

The licence field on the repository says MIT. The current tagged release is v3.0.0 from 2026-05-05, and the last push was on 2026-09-01.

## Sources

- [License: MIT](https://github.com/nianticlabs/spz/blob/main/LICENSE)
- [nianticlabs/spz on GitHub](https://github.com/nianticlabs/spz)
- [Project website](https://scaniverse.com)
- [README](https://github.com/nianticlabs/spz/blob/main/README.md)
- [Releases](https://github.com/nianticlabs/spz/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/nianticlabs-spz
