Skip to content

Epic: PlayCanvas engine as a second splat backend (unified gsplat + LOD streaming), Streamed SOG in the pipeline, and upstream contributions #36

Description

@dfattal

Why

@displayxr/inline3d renders Gaussian splats through one backend, Spark (three.js). Measured on the gallery's photo-lifted .sog assets (1,179,648 gaussians, SH0), Spark is coverage-bound: 50 % / 25 % decimation changes frame time by ~0 %, the bit-exact quad-extent trick has nothing to cut (Spark already draws at √8σ), and the only headroom is lossy overdraw knobs (−11…−22 %). It also has no LOD/streaming path we can use (enableLod/lod were inert on our assets, and it does not read lod-meta.json).

PlayCanvas has, all MIT: the engine's unified gsplat system with LOD streaming and a per-frame splat budget balancer (src/scene/gsplat-unified/: octree, lod-table, budget-balancer), Streamed SOG (splat-transform writes multi-LOD chunked SOG + lod-meta.json), the supersplat-viewer player and the SuperSplat editor. The SOG container is already our interchange format, and the maintainer (slimbuck) is receptive to our camera block proposal (playcanvas/splat-transform#319).

A splat budget is exactly the lever our measurements say matters (native: ~half the frame is per-gaussian; a ~600 k budget is visually free on photo lifts, 25 % breaks). Large captured scenes (rooms, venues) are where streaming + budget pay and where Spark's coverage cost hurts most.

Goal

  1. PlayCanvas engine as a second splat backend in @displayxr/inline3d, behind the same handle API (rig waterfall from the SOG camera block, setFocus/pick, focus gestures, perf presets), rendering N views from the SDK's eye poses into the tile canvas so the DisplayXR Browser weaves it unchanged (XR_DXR_weave is engine-agnostic: it takes the canvas atlas).
  2. Streamed SOG through the pipeline: producer (gallery worker) emits it for large scenes; the adapter streams with a frame budget tuned for two views on a 3D display.
  3. Upstream contributions, as small human-authored PRs from David's account: the camera block in splat-transform (writer preserves/accepts it, reader/MetaV2, spec §2/§5, a CLI option to seed it from the training cameras.json), engine exposes meta.camera, and a DXR display path (N-view render + weave submit when available) in supersplat-viewer so published players work on 3D displays without our SDK in the page.

Phases

  • P0 — Investigation (read-only, 1–2 days). Engine ≥ 2.x gsplat-unified API surface (GSplatComponent, unified/octree/lod-table/budget-balancer, lodUpdateInterval-style params), multi-camera render into viewports / render targets of one canvas, WebGL2 vs WebGPU in the DisplayXR Browser (Chromium M150 fork, D3D11 weave), how XrManager must stay OFF, engine footprint next to three.js, lod-meta.json schema. Output: a design note in docs/ with a go/no-go on WebGPU and on vendoring vs npm.
  • P1 — Adapter. addSplat(…, { engine: 'playcanvas' }) (default stays Spark): hosts an engine Application on the tile canvas, one camera per view, consumes the SDK's rig (display/camera, eye poses, off-axis frusta, focus.point = orbit centre = pivot = convergence), reuses the shared SOG reader (inline3d-sog.js), maps perf presets to engine equivalents, owns its pivot the way 1.7 does (see the _applyTransform trap). Parity gates: rest view MAE vs the source left photo on the gallery bench asset (Spark scores 3.05/255); interleaved-config perf table on the M1 (GPU timer query) at 720p/1080p; woven check on the Windows DisplayXR Browser box.
  • P2 — Streamed SOG. Worker option to emit Streamed SOG (splat-transform lod pyramid) for large scenes; gallery/show serve it; adapter streams with a 2-view frame budget; measure on a large captured scene (not a photo lift) vs the flat SOG. Keep the camera block on the chunked container (check it survives write-sog/LOD output — today a re-encode drops unknown keys).
  • P3 — Native viewer (optional). displayxr-demo-gaussiansplat reads the base LOD of a Streamed SOG as a budgeted load (≈500–600 k) instead of the full sheet.
  • P4 — Upstream. (a) playcanvas/splat-transform: camera block preserved on re-encode + accepted by the reader + spec text + --camera-from cameras.json[:index]; (b) playcanvas/engine: expose meta.camera (and use it as the initial camera when present); (c) playcanvas/supersplat-viewer: DXR display mode (feature-detect, render N views, submit to XR_DXR_weave), or the minimal embed hook if they prefer. Continue on #319; one PR per repo; no AI trailers on upstream commits.

Exit criteria

  • Gallery Spatial View can switch backends with a query flag; rest view within 1 MAE point of Spark on the bench asset; no visible regression on the wall.
  • A large captured scene streams and holds the frame budget on the Windows DisplayXR Browser box.
  • Upstream PRs opened (merged is a bonus); camera block survives a splat-transform re-encode.

Non-goals

Replacing Spark; changing the SOG geometry planes; anything requiring a private fork of PlayCanvas.

Refs

playcanvas/splat-transform#319 · @displayxr/inline3d 1.7.0 (perf presets, camera block waterfall, setFocus/pick) · displayxr-demo-gaussiansplat #117/#118/#120 (measurements, camera rig, fit) · gallery SpatialView.tsx + src/lib/spatialView/camera.ts (the reference camera model).

P5 — Automatic 3D for existing PlayCanvas sites (investigation → prototype)

Two tiers, cheapest first:

  1. Pages that already support WebXR (PlayCanvas apps with XR enabled; likely SuperSplat's published viewer — verify): the DisplayXR Browser already exposes the runtime as a WebXR device (immersive-vr), and the device owns XRView.projectionMatrix, so the page renders off-axis, screen-converged views without knowing it. Missing piece: a browser-side "View in 3D" affordance that starts the session with a synthetic user activation instead of waiting for the page's VR button. Engine-agnostic, no injection.
  2. PlayCanvas pages that never enabled XR: every app constructs app.xr (XrManager); usability only depends on navigator.xr, which our browser provides. A browser-injected content script locates pc.Application.getApplication(), takes the active camera and calls app.xr.start(camera, 'immersive-vr', 'local'); the engine's own XR path renders both eyes through our projections. Engine-specific; needs a per-site kill switch + allowlist; expect breakage on custom render passes, post-effects, screen-space UI, camera controllers fighting the pose.

Open questions (same ones the SOG camera block answers for content): which rig by default (first-person → camera rig at world scale; product viewer → display rig fitted to the window — heuristic on camera target distance), and the scale/convergence default when the page declares nothing. Generic WebGL interception for non-XR, non-PlayCanvas pages is out of scope (the browser cannot know the camera).

Verified 2026-09-22 — superspl.at/scene/<id> (the hosted supersplat-viewer) is TIER 1. supersplat-viewer 1.32.0 (playcanvas ^2.22.1) ships a WebXR path: src/xr.ts gates a VR/AR button on xr.isAvailable('immersive-vr' | 'immersive-ar') and starts with xr.start(camera, 'immersive-vr', 'local-floor', …); viewer.ts reconfigures the camera on app.xr.on('start'). So in the DisplayXR Browser the page needs no changes: our WebXR device must (a) advertise immersive-vr, (b) support the local-floor reference space, (c) work with PlayCanvas XrManager on WebGL2; then the browser adds the auto-enter affordance. P5 milestone 1 = open a superspl.at scene on the Windows box, press its VR button, confirm a woven stereo view; then auto-enter. The hosted assets (…/splat/<id>/v2/{scene.sog,settings.json}) are 403 from S3 — hosted-only, which is exactly why "no SDK in the page" matters.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions