All notable changes to this project are documented here. The format follows Keep a Changelog, and this project adheres to Semantic Versioning.

[0.1.1] - 2026-08-21

Fixed

  • WebGPU is no longer requested without the shader compilers it needs. PlayCanvas's WebGPU backend requires glslangUrl and twgslUrl — two assets this package does not ship and cannot guess the location of. Asking for it without them made the engine log a TypeError on every mount, in every browser with a partial WebGPU stack, before quietly falling back to WebGL2. It worked, and it was noise in someone else's console on a page they did not write. WebGL2 is now the default; supply :glslang_url and :twgsl_url to opt back in.

Documented

  • app.start() is deliberately never called — it would run PlayCanvas's own tick loop alongside this one and render every frame twice. The consequence, which was not written down anywhere, is that the engine's framerender event never fires, so an LOD or octree splat would never stream. A plain .sog is unaffected.

[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.