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, with src, camera, height, autorotate, background, dpr_cap, pause_offscreen and interactive.
  • A LiveView hook, shipped in priv/static/splat_viewer.js and importable as a node module from deps/splat_viewer.
  • SplatViewer.Camera — a viewpoint, accepting either the struct splat_tools produces 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#resizeCanvas takes CSS pixels and sets canvas.style, applying the device's own maxPixelRatio to 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 with overflow: hidden and 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 recent AppBase constructor, so two hooks mounting in the same tick gave the first one's entities to the second one's app — and addComponent returned null against systems that did not exist yet.
  • Teardown during load threw and leaked the canvas. this.app was assigned before init(), and AppBase#destroy() reads graphicsDevice.canvas immediately — so the throw aborted the rest of dispose(), out of LiveView's destroyed().
  • Auto-framing was dead. gsplat.instance is always null in 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.
  • :background accepted colours the renderer cannot read. #fff rendered blue and every named colour rendered black, because Color#fromString is parseInt on the hex digits. Now hex only, with shorthand expanded.
  • One drag put a degenerate camera permanently at NaNMath.min/max do not filter a NaN. Guarded in the viewer, and Camera now 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/0 was 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_offscreen and forced the state to ready even from error.
  • applySize reallocated the drawing buffer on every call, which during a drag-resize is dozens of multi-megabyte reallocations a second.
  • :height was interpolated into the same style attribute as :background without 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 ready state, 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. :background is 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.