Hex.pm Docs

Server-side toolkit for 3D Gaussian splat assets in Elixir. Validate a capture, convert it to a web-ready format, and get back the metadata your application needs to store alongside it.

def deps do
  [{:splat_tools, "~> 0.1"}]
end

Usage

{:ok, asset} = SplatTools.prepare("capture.ply", "priv/static/scans")

asset.sog          #=> "priv/static/scans/capture.sog"
asset.source_splat_count  #=> 1_284_331
asset.sh_bands     #=> 3
asset.camera       #=> %{position: {…}, target: {…}, fov: 50.0}
asset.source_bytes #=> 318_513_920
asset.sog_bytes    #=> 7_071_744

Check a file before spending anything on it — this reads only the header, so it is cheap on a gigabyte capture:

{:ok, info} = SplatTools.inspect_file("maybe.ply")
info.kind   #=> :splat | :mesh | :point_cloud

SplatTools.splat?("maybe.ply")   #=> true

Frame a scene without converting it:

{:ok, %{bounds: bounds, camera: camera}} = SplatTools.measure("capture.ply")

Install the converter

Compression is done by splat-transform, the MIT-licensed converter from PlayCanvas:

npm install -g @playcanvas/splat-transform   # needs Node 22+
SplatTools.Transform.available?()  #=> true
SplatTools.Transform.version()     #=> {:ok, "3.3.0"}

What runs where

Elixir owns decisions and records; the bytes are somebody else's job. Validation, argument construction, metadata, camera framing and error reporting live here. Compression does not.

That is not squeamishness — a gigabyte PLY is 4.2 million rows and 260 million float extractions, which is not a BEAM workload under any strategy, and the SOG encoder needs k-means clustering plus a spatial sort that no library on Hex or crates.io provides. It is also the same shape every other media type already has: shell out for the codec, own the pipeline.

Things worth knowing

Store the camera, not just the file. No splat format carries a viewpoint — PlayCanvas's own publish flow stores a camera pose rather than a preview image — so a viewer opening your file points at nothing unless you saved one. prepare/3 derives one; persist it next to the asset.

Bounds use percentiles, not extremes. A real capture has stray splats flung far from the scene by reconstruction noise, and a single one inflates the bounding box enough to push the camera so far back the room becomes a dot. The outer 1% is trimmed at each end by default (:percentile).

Non-finite values are normal. Real training output contains NaN — that is why splat-transform ships --filter-nan, and why it is on by default here. Bounds computation skips non-finite values rather than letting one poison every derived number.

Dropping spherical harmonics buys time, not bytes. sh_bands: 0 makes encoding roughly 25× faster while the file shrinks only about a quarter, because SOG's palette already compresses SH to about 2 bytes per splat. Reach for it when conversion time hurts, not when file size does.

Compression ratios below 1 are normal on toy files. SOG has fixed container overhead — meta.json, WebP images, a ZIP wrapper — so a four-splat test file comes out larger. Real captures land around 45×.

Previews need a GPU. splat-transform has no CPU rasterisation path; without a WebGPU device it reports "writeImage requires a createDevice function". On a headless server that means mesa-vulkan-drivers and libvulkan1 for llvmpipe. Preview rendering is therefore off by default — failing an entire conversion for a thumbnail is the wrong trade. Output is lossless WebP only; a .png path is rejected at the call site.

Long conversions are expected. A 2M-splat scene with full SH spends most of its time in k-means; the default timeout is 45 minutes, and the subprocess runs in a task that can actually be killed (System.cmd/3 has no timeout, so a hung converter would otherwise pin a worker forever).

Development

mix test                    # integration tests skip if splat-transform is absent
PLY_PATH=../ply mix test    # against a local ply checkout
mix precommit

License

MIT