Packages
Phoenix component and LiveView hook for displaying 3D Gaussian splats in the browser, wrapping the PlayCanvas engine.
Current section
Files
Jump to
Current section
Files
splat_viewer
README.md
README.md
# splat_viewer
Display **3D Gaussian splats** in a Phoenix application.
```heex
<.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
```elixir
def deps do
[{:splat_viewer, "~> 0.1"}]
end
```
Then the hook, in `assets/js/app.js`:
```javascript
import SplatViewer from "splat_viewer"
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { SplatViewer, ...otherHooks }
})
```
and in `assets/package.json`:
```json
{
"dependencies": {
"splat_viewer": "file:../deps/splat_viewer",
"playcanvas": "^2.21.0"
}
}
```
Finally, in your HTML helpers:
```elixir
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:
```elixir
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`](https://hex.pm/packages/splat_tools) produces:
```elixir
{: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:
```heex
<.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
| 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 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.
## Related
- [`splat_tools`](https://github.com/mdon/splat_tools) — convert a capture to
`.sog` and derive its camera
- [`ply`](https://github.com/mdon/ply) — read the format trainers emit
- [`gltf`](https://github.com/mdon/gltf) — the other 3D interchange format
## Licence
MIT. PlayCanvas is MIT too, and is not redistributed here.