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.

  • 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 ResizeObserver on the container, not window.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. webglcontextlost is not optional on mobile — without preventDefault() the context is never restored, and the canvas is black until reload with nothing telling the visitor why.
  • Paused off-screen. IntersectionObserver plus visibilitychange, 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. Changing src rebuilds deliberately; nothing else does.
  • Fails visibly. A load failure sets data-splat-state="error", shows a message, and dispatches a splat:error event — rather than leaving an empty grey box that neither the visitor nor the page author can interpret.

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.

  • splat_tools — convert a capture to .sog and derive its camera
  • ply — read the format trainers emit
  • gltf — the other 3D interchange format

Licence

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