SplatTools
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