Understanding Spatial Codecs

Copy Markdown View Source

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.

        ·  ·
     ·     ·  ·
   ·   ·         ·     ← 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)
        (~~)           ← 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 cloudGaussian splat
PrimitiveHard sample (dot)Soft ellipsoid (blob)
Typical sourceLiDAR, depth cameras, photogrammetry pointsTrained / exported radiance-field scenes
Looks like geometry?Sparse surface samplesAppearance-oriented volume of blobs
ExCodecs typesPoint / PointCloudGaussian / 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:

{: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

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.

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.

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.

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.

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/scaleTransform
Applies toOne splat’s ellipsoidThe entire cloud (if you apply it)
ScaleAnisotropic {sx,sy,sz}Uniform float
Applied by ExCodecs?Stored as splat paramsStored 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:

KindExamplesWhat it is
Domain conceptpoint cloud, splat / Gaussian splatThe kind of 3D data in memory
Format atom:ply, :spatial_binary, :gsplatWhat you pass to ExCodecs.Spatial.encode/decode
On-wire magic / file typePLY text/binary, EXCP, GSPLBytes on disk / on the network
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

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):

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 codeBytes begin withHolds
format: :spatial_binaryEXCPPointCloud 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)
{: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 codeBytes begin withHolds
format: :gsplatGSPLGaussianCloud 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)
{:ok, gspl} = ExCodecs.Spatial.encode(gaussian_cloud, format: :gsplat)
# gspl starts with <<"GSPL", ...>>
{:ok, cloud} = ExCodecs.Spatial.decode(gspl, format: :gsplat)

Quick chooser

GoalUse
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. Streaming behavior is described in the 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

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

{: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

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.