Current section
Files
Jump to
Current section
Files
guides/understanding_spatial_codecs.md
# Understanding Spatial Codecs
This guide is a **domain primer** for ExCodecs' spatial category: what the
data is, what each Elixir type means, which file formats you encode to, and
when to use each piece. It is not a rendering tutorial - ExCodecs only moves
spatial data in and out of interchange formats.
## What problem does this solve?
You have 3D samples from a scanner, photogrammetry pipeline, or radiance-field
export, and you need to:
1. Represent them as Elixir structs in your BEAM process
2. Write them to a portable file (or bytes)
3. Read them back later - often into another tool that expects PLY or a compact
binary container
Spatial codecs are the encode/decode layer for that. They do **not** draw
pixels, build meshes, or run a viewer.
## Point clouds vs Gaussian splats
These are two different ways to describe a scene as **many independent
samples**. Neither is a polygon mesh (no faces, no connectivity).
### Point cloud - a bag of dots
A **point cloud** is a list of positions in space. Each point is usually just
`(x, y, z)`. Optionally it can also carry:
- a color (what the scanner/camera saw)
- a surface normal (which way the surface faced)
- arbitrary attributes (intensity, classification, …)
Think of LiDAR returns, depth-camera frames, or the corners of a scanned room:
lots of floating dots, not triangles.
```text
· ·
· · ·
· · · ← each · is one Point {x,y,z[,color,…]}
· ·
·
```
In ExCodecs that bag is a `PointCloud` (the collection) of `Point` structs.
### Gaussian splat - a bag of fuzzy ellipsoids
A **3D Gaussian splat** (often from *3D Gaussian Splatting* research) is also
a sample in space, but instead of a hard dot it is a small **oriented blob**:
- **position** - where the blob is centered
- **scale** - how large it is along its local axes (an ellipsoid, not a sphere)
- **rotation** - how that ellipsoid is oriented (a quaternion)
- **opacity** - how solid / transparent it is
- **color** - base RGB (often the “DC” spherical-harmonic color)
- **SH** - optional higher-order **spherical harmonic** coefficients that
encode view-dependent appearance (fancy lighting detail for renderers)
```text
(~~) ← one Gaussian: soft blob, not a hard vertex
(~~~~) (~~)
(~~~~)
```
A renderer turns millions of these blobs into an image. **ExCodecs does not
render them** - it only stores and reloads the parameters so another program
(or a future library) can.
In ExCodecs that bag is a `GaussianCloud` of `Gaussian` structs.
| | Point cloud | Gaussian splat |
|--|-------------|----------------|
| Primitive | Hard sample (dot) | Soft ellipsoid (blob) |
| Typical source | LiDAR, depth cameras, photogrammetry points | Trained / exported radiance-field scenes |
| Looks like geometry? | Sparse surface samples | Appearance-oriented volume of blobs |
| ExCodecs types | `Point` / `PointCloud` | `Gaussian` / `GaussianCloud` |
## Elixir-first, with optional Rust acceleration
Spatial encode/decode is **Elixir-first**: every format has a complete
pure-Elixir implementation, so the API works on any BEAM without a native
backend. Since **v0.2.3**, an optional Rust NIF accelerator is loaded when
the precompiled native library is available:
- **Chunked Rust pack/unpack** for EXCP and GSPL (byte-compatible with the
Elixir path; verified by property tests).
- **mmap-backed file `stream_decode`** for EXCP/GSPL/PLY, and **binary PLY
body unpack**.
- **Chunked `stream_encode_to_file`** for EXCP/GSPL.
Compression codecs (`:zstd`, `:lz4`, …) always use Rust NIFs; spatial formats
use the Elixir path unless acceleration is available. Pass `accel: false` to
force pure Elixir for a given call.
A common pattern is:
```elixir
{:ok, ply} = ExCodecs.Spatial.encode(cloud, format: :ply)
{:ok, packed} = ExCodecs.encode(:zstd, ply) # NIF compresses the bytes
```
Spatial work stays on the BEAM (or the DirtyCpu NIF pool when accelerating);
you optionally compress the resulting binary with the registry codecs.
### Streaming (v0.2.3+)
EXCP / GSPL / PLY **files** decode record-by-record from disk (bounded
memory; mmap + Rust unpack when available). In-memory binaries use chunked
Rust unpack when the NIF is loaded, otherwise materialize through `decode/2`.
`stream_encode/2` still collects then encodes; use `encode_to_file/3` with an
explicit `:schema` for EXCP/GSPL incremental writes. Prefer explicit
`source: :file` or `source: :binary`. See `ExCodecs.Spatial.Stream` for
details.
## Domain types - what each one is for
```text
Point ──┐
├──► PointCloud ──► encode as :ply or :spatial_binary
Bounds ─┘ │
Metadata ─────────┘
Transform ────────┘ (optional pose of the whole cloud; not applied here)
Gaussian ──┐
├──► GaussianCloud ──► encode as :ply or :gsplat
Bounds / Metadata / Transform ─┘
```
### `Point` - one sample
One location in 3D, optionally colored / with a normal / with attributes.
```elixir
alias ExCodecs.Spatial.Point
Point.new(1.0, 2.0, 0.5,
color: {255, 128, 0},
normal: {0.0, 0.0, 1.0},
attributes: %{"intensity" => 0.82}
)
```
**Use when:** you are building or iterating individual samples.
### `PointCloud` - the whole set (not a polygon)
A **collection** of points plus bookkeeping. It is **not** a mesh and **not**
a polygon. There are no edges or faces - only the list of points.
```elixir
alias ExCodecs.Spatial.{Point, PointCloud}
cloud =
PointCloud.new([
Point.new(0.0, 0.0, 0.0, color: {255, 0, 0}),
Point.new(1.0, 0.0, 0.0, color: {0, 255, 0}),
Point.new(0.0, 1.0, 0.0, color: {0, 0, 255})
])
```
`PointCloud` may also hold:
- `bounds` - axis-aligned box around the points (see below)
- `metadata` - free-form notes / comments
- `transform` - optional pose of the *entire* cloud (see below)
**Use when:** this is your unit of encode/decode for point data.
### `Bounds` - “how big is this cloud?”
An **axis-aligned bounding box** (AABB): min/max corners in XYZ. Useful for
culling, indexing, or quick spatial queries. It describes the extent of the
data; it is not the data itself.
```elixir
alias ExCodecs.Spatial.Bounds
Bounds.new({0.0, 0.0, 0.0}, {1.0, 1.0, 0.5})
```
**Use when:** you care about the cloud’s spatial footprint. Many codecs
recompute or omit bounds on the wire; keep them in the struct for app logic.
### `Gaussian` - one splat
One oriented ellipsoid with appearance parameters. The rotation/scale here
belong to **this splat**, not to a whole-scene pose.
```elixir
alias ExCodecs.Spatial.Gaussian
Gaussian.new({1.0, 2.0, 3.0},
scale: {0.05, 0.02, 0.05},
rotation: {1.0, 0.0, 0.0, 0.0}, # identity quaternion {w,x,y,z}
opacity: 0.9,
color: {0.8, 0.4, 0.1}
)
```
**Use when:** importing/exporting Gaussian-splat datasets.
### `GaussianCloud` - the whole splat set
Same idea as `PointCloud`, but for Gaussians.
**Use when:** this is your unit of encode/decode for splat data.
### `Transform` - pose of the *whole* cloud (not a splat)
A similarity transform (translation + quaternion rotation + **uniform** scale)
stored as **metadata**. ExCodecs does **not** apply it to points/Gaussians.
This is different from a Gaussian’s per-splat rotation/scale:
| | `Gaussian` rotation/scale | `Transform` |
|--|---------------------------|-------------|
| Applies to | One splat’s ellipsoid | The entire cloud (if you apply it) |
| Scale | Anisotropic `{sx,sy,sz}` | Uniform float |
| Applied by ExCodecs? | Stored as splat params | Stored only; you apply it |
**Use when:** a format or pipeline records “this cloud was captured with
sensor pose X” and you want to keep that alongside the samples.
### `Metadata` - comments and leftovers
String comments and a free-form map for application keys that are not geometry.
**Use when:** you need PLY header comments or app-specific tags to round-trip.
## Formats glossary - names that look similar but mean different things
Three layers of vocabulary get mixed up. Keep them separate:
| Kind | Examples | What it is |
|------|----------|------------|
| **Domain concept** | point cloud, **splat** / Gaussian splat | The *kind of 3D data* in memory |
| **Format atom** | `:ply`, `:spatial_binary`, `:gsplat` | What you pass to `ExCodecs.Spatial.encode/decode` |
| **On-wire magic / file type** | PLY text/binary, **`EXCP`**, **`GSPL`** | Bytes on disk / on the network |
```text
In memory On the wire
───────── ───────────
PointCloud ──format: :ply──► *.ply (PLY header + body)
PointCloud ──format: :spatial_binary──► magic "EXCP" …
GaussianCloud ──format: :ply──► *.ply (Gaussian property names)
GaussianCloud ──format: :gsplat──► magic "GSPL" …
```
### “Splat” (domain concept - not a format)
**Splat** is short for **Gaussian splat**: one soft 3D ellipsoid sample (see
above). A **splat cloud** is many of them (`GaussianCloud`).
There is **no** `format: :splat`. To store splats you choose either:
- `:ply` - portable interchange (Gaussian PLY properties), or
- `:gsplat` - ExCodecs’ compact binary (on-wire magic **`GSPL`**)
### PLY / `format: :ply` - the portable interchange format
**PLY** (Polygon File Format / Stanford Triangle Format) is a **decades-old,
widely supported** 3D file format. A PLY file always starts with a readable
header, then a body of per-vertex (or per-face) properties.
Despite “polygon” in the name, PLY is routinely used as a **point list** -
just vertices with `x y z` (and maybe colors), no faces at all. Research and
Gaussian-splat tools also stuff splat fields into the same PLY property
mechanism (`f_dc_*`, `opacity`, `scale_*`, `rot_*`, `f_rest_*`).
**What it’s for**
- Hand files to MeshLab, CloudCompare, Blender, Python/`open3d`, research code
- Debug by opening ASCII PLY in a text editor
- Exchange with people / tools that have never heard of ExCodecs
**What you write in Elixir**
```elixir
ExCodecs.Spatial.encode(cloud, format: :ply)
ExCodecs.Spatial.encode(cloud, format: :ply, ply_format: :binary_le)
ExCodecs.Spatial.encode(gaussians, format: :ply) # Gaussian property names
```
**Trade-offs**
- Universal tooling, human-inspectable (ASCII)
- Larger / slower than EXCP/GSPL; schema is flexible but verbose
- Works for **both** point clouds and Gaussian clouds
PLY options (after `format: :ply`):
- `:ply_format` or `:format` - `:ascii` (default), `:binary` / `:binary_le`,
or `:binary_be` (**PLY body encoding**, not the Spatial format atom)
- `:comments` - header comments
- `:as` - on decode: `:auto`, `:point_cloud`, or `:gaussian_cloud`
Tiny ASCII PLY (three colored points):
```text
ply
format ascii 1.0
element vertex 3
property float x
property float y
property float z
property uchar red
property uchar green
property uchar blue
end_header
0 0 0 255 0 0
1 0 0 0 255 0
0 1 0 0 0 255
```
### EXCP / `format: :spatial_binary` - compact point-cloud bytes
**EXCP** is ExCodecs’ **own** little-endian binary container for point clouds.
The name is the 4-byte magic at the start of the payload (`"EXCP"`).
**`:spatial_binary`** is the **Elixir format atom** you pass to the API.
Same thing, two names:
| You say in code | Bytes begin with | Holds |
|-----------------|------------------|-------|
| `format: :spatial_binary` | `EXCP` | `PointCloud` only |
**What it’s for**
- Store or ship point clouds **inside your own system** (DB blob, message,
object store) when PLY would be wasteful
- Fast encode/decode with a fixed record layout (xyz + optional color/alpha/normals)
- Pair with `:zstd` / `:lz4` afterward if you want compression
**What it’s not for**
- Giving files to MeshLab / Blender (they don’t speak EXCP)
- Storing Gaussian splats (use `:gsplat` or Gaussian `:ply`)
- Carrying arbitrary per-point string attributes, cloud metadata, or transforms
on the wire (v1 drops those - see [Spatial wire formats](../docs/spatial_formats.md))
```elixir
{:ok, excp} = ExCodecs.Spatial.encode(point_cloud, format: :spatial_binary)
# excp starts with <<"EXCP", ...>>
{:ok, cloud} = ExCodecs.Spatial.decode(excp, format: :spatial_binary)
```
### GSPL / `format: :gsplat` - compact Gaussian-splat bytes
**GSPL** is ExCodecs’ **own** little-endian binary container for Gaussian
clouds. Magic bytes: `"GSPL"`.
**`:gsplat`** is the format atom. Again: atom in Elixir, magic on the wire.
| You say in code | Bytes begin with | Holds |
|-----------------|------------------|-------|
| `format: :gsplat` | `GSPL` | `GaussianCloud` only |
**What it’s for**
- Compact storage/transfer of splat parameters (position, DC color, opacity,
scale, rotation, optional SH rest coefficients)
- Internal pipelines where PLY Gaussian dumps are too fat
**What it’s not for**
- Point clouds (use `:spatial_binary` or `:ply`)
- Universal desktop 3D tools (use Gaussian `:ply` for interchange)
- Preserving arbitrary per-Gaussian metadata maps on the wire (v1 drops them)
```elixir
{:ok, gspl} = ExCodecs.Spatial.encode(gaussian_cloud, format: :gsplat)
# gspl starts with <<"GSPL", ...>>
{:ok, cloud} = ExCodecs.Spatial.decode(gspl, format: :gsplat)
```
### Quick chooser
| Goal | Use |
|------|-----|
| Share points with other 3D software | `:ply` |
| Share Gaussians with research / splat tools | `:ply` (Gaussian properties) |
| Compact points inside *your* stack | `:spatial_binary` → optional `:zstd` |
| Compact Gaussians inside *your* stack | `:gsplat` → optional `:zstd` |
| “I heard splat / EXCP / GSPL” | splat = data kind; EXCP/GSPL = our binary containers |
Wire layouts and schema rules are frozen in
[Spatial wire formats](../docs/spatial_formats.md). Streaming behavior is
described in the [Elixir-first, with optional Rust acceleration](#elixir-first-with-optional-rust-acceleration)
section above and in `ExCodecs.Spatial.Stream`.
## Why a separate `ExCodecs.Spatial` API?
Compression codecs map **binary → binary** (`ExCodecs.encode(:zstd, bin)`).
Spatial codecs map **structs ↔ formats**. Overloading one function for both
shapes is confusing, so spatial work goes through `ExCodecs.Spatial`.
Both categories share the catalog (`ExCodecs.available_codecs(:spatial)`),
tagged `{:ok, _}` / `{:error, %ExCodecs.Error{}}` results, and discovery
metadata. Calling `ExCodecs.encode(:ply, …)` intentionally fails with guidance
to use the spatial API.
## End-to-end examples
### Point cloud → PLY → back
```elixir
alias ExCodecs.Spatial.{Point, PointCloud}
cloud =
PointCloud.new([
Point.new(0.0, 0.0, 0.0, color: {255, 0, 0}),
Point.new(1.0, 1.0, 0.0, color: {0, 255, 0})
])
{:ok, ply} = ExCodecs.Spatial.encode(cloud, format: :ply)
{:ok, decoded} = ExCodecs.Spatial.decode(ply, format: :ply)
ExCodecs.Spatial.available_formats()
ExCodecs.codec_info(:ply)
```
### Compact binary for points, then compress
```elixir
{:ok, excp} = ExCodecs.Spatial.encode(decoded, format: :spatial_binary)
{:ok, packed} = ExCodecs.encode(:zstd, excp)
{:ok, excp2} = ExCodecs.decode(:zstd, packed)
{:ok, ^decoded} = ExCodecs.Spatial.decode(excp2, format: :spatial_binary)
```
### Load a sample from the package
```elixir
path = Application.app_dir(:ex_codecs, "priv/examples/spatial/cube_corners.ply")
{:ok, cloud} = ExCodecs.Spatial.decode(File.read!(path), format: :ply)
path = Application.app_dir(:ex_codecs, "priv/examples/spatial/two_gaussians.ply")
{:ok, gaussians} = ExCodecs.Spatial.decode(File.read!(path), format: :ply)
```
## Out of scope
ExCodecs does **not** provide:
- a viewer / rasterizer for points or Gaussians
- mesh topology (faces, edges)
- spatial indexes, LOD, or true progressive streaming
- supercompressed containers such as SOG
Those belong in higher-level libraries built on top of these codecs.