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"}]
endThen 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.ComponentsThe 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 storingA .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.
- Teardown on
destroyed()— the frame loop is cancelled, GPU resources disposed, observers disconnected, listeners removed. Miss it and every LiveView navigation leaks a renderer plus tens of megabytes of buffers. - Resize via
ResizeObserveron the container, notwindow.onresize: a LiveView can resize an element with no window event at all, and a splat at the wrong aspect ratio is subtly and permanently stretched. - Device pixel ratio, capped. Mobile GPUs are tile-based and splats are overdraw with alpha blending, so a 3× backing store costs nine times the fill rate. This is the single most effective mobile lever, and it is not about arithmetic speed. Default cap is 2.
- WebGL context loss.
webglcontextlostis not optional on mobile — withoutpreventDefault()the context is never restored, and the canvas is black until reload with nothing telling the visitor why. - Paused off-screen.
IntersectionObserverplusvisibilitychange, or the page renders a scene nobody is looking at, on a battery. - Survives re-renders.
phx-update="ignore"keeps the canvas alive when a parent diff would otherwise replace it. Changingsrcrebuilds deliberately; nothing else does. - Fails visibly. A load failure sets
data-splat-state="error", shows a message, and dispatches asplat:errorevent — rather than leaving an empty grey box that neither the visitor nor the page author can interpret.
Options
| Attribute | Default | |
|---|---|---|
src | required | URL of the .sog (or .ply) |
camera | nil | starting viewpoint; falls back to framing the bounding box |
height | "480px" | a CSS length; or use class with an aspect ratio |
autorotate | false | orbit until the visitor interacts, then stop for good |
background | "#111318" | hex colour behind the splat (#rgb/#rgba/#rrggbb/#rrggbbaa) |
dpr_cap | 2.0 | ceiling on device pixel ratio |
pause_offscreen | true | stop rendering when scrolled away or the tab is hidden |
interactive | true | orbit, 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 hookThe 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.
Related
splat_tools— convert a capture to.sogand derive its cameraply— read the format trainers emitgltf— the other 3D interchange format
Licence
MIT. PlayCanvas is MIT too, and is not redistributed here.