splat_viewer

Display 3D Gaussian splats in a Phoenix application.

<.splat_viewer src={~p"/scans/room.sog"} camera={@scan.camera} height="500px" />

A Gaussian splat is a photographic 3D capture — a room, an object, a site — reconstructed from ordinary video as a few million translucent ellipsoids. It is the closest thing to walking into a photograph, and it is how you preserve a space rather than describe it.

Nothing on Hex renders one. The engines that do are JavaScript, so this is a Phoenix component and a LiveView hook around one of them.

Installation

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

Then the hook, in assets/js/app.js:

import SplatViewer from "splat_viewer"
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { SplatViewer, ...otherHooks }
})

and in assets/package.json:

{
"dependencies": {
"splat_viewer": "file:../deps/splat_viewer",
"playcanvas": "^2.21.0"
}
}

Finally, in your HTML helpers:

import SplatViewer.Components

The engine is not bundled

PlayCanvas is around two megabytes. A Hex package has no business shipping that, or pinning which copy of it your application uses, so the hook imports it at runtime from a URL you control — /assets/playcanvas.mjs by default:

config :splat_viewer, engine_path: "/assets/vendor/playcanvas.mjs"

Per element with data-engine-url, or globally with window.SPLAT_VIEWER_ENGINE_URL, if a page needs something different.

Feed it a .sog

.ply is what a splat trainer emits, and it is enormous — a captured room is commonly a hundred megabytes or more. .sog is the compressed delivery format, roughly 45× smaller, and it is what splat_tools produces:

{:ok, asset} = SplatTools.prepare("room.ply", "priv/static/scans")
asset.sog #=> "priv/static/scans/room.sog"
asset.camera #=> a viewpoint worth storing

A .ply will load. On a real capture it will cost your visitor the full hundred megabytes to find that out.

Store the camera

No splat format carries a viewpoint. A .sog is a list of gaussians and nothing else, so a viewer opening without one points wherever its default points — which, for most real captures, is at nothing.

SplatTools.prepare/3 derives a camera from the scene's own geometry at conversion time. Persist it beside the file and pass it back:

<.splat_viewer src={@scan.url} camera={@scan.camera} />

Without one this frames the bounding box, which is a guess a single stray splat can ruin.

The lifecycle is the point

Putting a canvas in a LiveView is easy. Keeping it correct is not, and every item below is something that goes wrong slowly, in production, on somebody's phone. All of them are covered by the hook's own test suite, which runs under mix test.

Options

AttributeDefault
srcrequiredURL of the .sog (or .ply)
cameranilstarting viewpoint; falls back to framing the bounding box
height"480px"a CSS length; or use class with an aspect ratio
autorotatefalseorbit until the visitor interacts, then stop for good
background"#111318"hex colour behind the splat (#rgb/#rgba/#rrggbb/#rrggbbaa)
dpr_cap2.0ceiling on device pixel ratio
pause_offscreentruestop rendering when scrolled away or the tab is hidden
interactivetrueorbit, pan and zoom

Options are validated in Elixir, not in the hook. A bad :camera raises at render time with a message naming what was wrong; the same mistake caught in JavaScript is a console warning nobody sees.

:background takes hex only — deliberately narrower than CSS. The renderer reads it as hex digits, so a named colour would come out black and rgb(...) transparent, silently, while the element's own background showed what you asked for. Refusing them is better than the two disagreeing.

Testing

mix test # Elixir, plus the hook's suite via Node
node test/js/hook_test.mjs # just the hook

The hook is tested against a stub DOM and a stub engine — no browser, no dependencies. A comment claiming the renderer is disposed is not evidence that it is.

Licence

MIT. PlayCanvas is MIT too, and is not redistributed here.