All notable changes to this project are documented here. The format follows Keep a Changelog, and this project adheres to Semantic Versioning.
[0.1.0] - 2026-08-21
Initial release.
Added
<.splat_viewer />— a Phoenix component rendering a Gaussian splat, withsrc,camera,height,autorotate,background,dpr_cap,pause_offscreenandinteractive.- A LiveView hook, shipped in
priv/static/splat_viewer.jsand importable as a node module fromdeps/splat_viewer. SplatViewer.Camera— a viewpoint, accepting either the structsplat_toolsproduces or the JSON that comes back out of a database column.
Fixed — first review pass
Found by running the hook against real PlayCanvas 2.21.4 in headless Chrome. The package's own stub suite passed 17/17 throughout, and could not see any of the first four — the stub was wrong in the same direction as the code, which is its own lesson.
- The device-pixel-ratio cap broke every HiDPI display.
AppBase#resizeCanvastakes CSS pixels and setscanvas.style, applying the device's ownmaxPixelRatioto get the backing store. Passing it DPR-multiplied numbers set the canvas's CSS size to the backing-store number, so the canvas overflowed a container withoverflow: hiddenand the visitor saw a cropped corner of the scene, upscaled. The headline feature did the opposite of what it claimed. - Two viewers on one page broke the first one.
new pc.Entity(name)falls back to a module-global app set by the most recentAppBaseconstructor, so two hooks mounting in the same tick gave the first one's entities to the second one's app — andaddComponentreturnednullagainst systems that did not exist yet. - Teardown during load threw and leaked the canvas.
this.appwas assigned beforeinit(), andAppBase#destroy()readsgraphicsDevice.canvasimmediately — so the throw aborted the rest ofdispose(), out of LiveView'sdestroyed(). - Auto-framing was dead.
gsplat.instanceis alwaysnullin unified mode, which is the 2.x default, so the documented bounding-box fallback silently framed the origin at radius one and every viewer without a stored camera pointed at empty space. :backgroundaccepted colours the renderer cannot read.#fffrendered blue and every named colour rendered black, becauseColor#fromStringisparseInton the hex digits. Now hex only, with shorthand expanded.- One drag put a degenerate camera permanently at NaN —
Math.min/maxdo not filter a NaN. Guarded in the viewer, andCameranow refuses a pose whose position equals its target. - The engine cache ignored the URL, so a second viewer pointed elsewhere silently received the first one's engine and reported itself ready.
engine_path/0was wired to nothing. The config was documented in two places and had no effect; only the JS global worked.- The status element was removed on first success, so no later message — the context-lost notice, a failed rebuild — could ever appear. It is hidden now.
- Restoring a WebGL context ignored
pause_offscreenand forced the state to ready even from error. applySizereallocated the drawing buffer on every call, which during a drag-resize is dozens of multi-megabyte reallocations a second.:heightwas interpolated into the same style attribute as:backgroundwithout a guard, so the one defence was on one of the two values.updated()cleared the flag in-flight builds were watching, so a stale build carried on and overwrote the new one's observers. Replaced with a build token.- A browser with neither WebGL2 nor WebGPU got a null device and a
readystate, showing a blank box with no error.
Design notes
- The engine is not bundled. PlayCanvas is about two megabytes; a Hex package should neither ship that nor pin which copy an application uses. It is imported at runtime from a configurable URL.
- Options are validated in Elixir. A bad camera or a bad colour raises at
render time with a message naming the problem. The same mistake caught in
the hook is a console warning nobody reads, on a page showing an empty box.
:backgroundis interpolated into a style attribute, so it is checked against a colour grammar rather than trusted. - The lifecycle is the substance. Teardown, container-based resizing,
capped device pixel ratio, WebGL context loss, pausing off-screen, and
surviving LiveView re-renders — each is covered by the hook's own test
suite, which runs against a stub DOM under
mix test.