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
- 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).
- 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.
- 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:
- 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.
- 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.
Why
@displayxr/inline3drenders Gaussian splats through one backend, Spark (three.js). Measured on the gallery's photo-lifted.sogassets (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/lodwere inert on our assets, and it does not readlod-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-transformwrites multi-LOD chunked SOG +lod-meta.json), thesupersplat-viewerplayer and the SuperSplat editor. The SOG container is already our interchange format, and the maintainer (slimbuck) is receptive to ourcamerablock 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
@displayxr/inline3d, behind the same handle API (rig waterfall from the SOGcamerablock,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_weaveis engine-agnostic: it takes the canvas atlas).camerablock insplat-transform(writer preserves/accepts it, reader/MetaV2, spec §2/§5, a CLI option to seed it from the trainingcameras.json), engine exposesmeta.camera, and a DXR display path (N-view render + weave submit when available) insupersplat-viewerso published players work on 3D displays without our SDK in the page.Phases
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), howXrManagermust stay OFF, engine footprint next to three.js,lod-meta.jsonschema. Output: a design note indocs/with a go/no-go on WebGPU and on vendoring vs npm.addSplat(…, { engine: 'playcanvas' })(default stays Spark): hosts an engineApplicationon 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_applyTransformtrap). 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.splat-transformlod 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 thecamerablock on the chunked container (check it surviveswrite-sog/LOD output — today a re-encode drops unknown keys).displayxr-demo-gaussiansplatreads the base LOD of a Streamed SOG as a budgeted load (≈500–600 k) instead of the full sheet.playcanvas/splat-transform:camerablock preserved on re-encode + accepted by the reader + spec text +--camera-from cameras.json[:index]; (b)playcanvas/engine: exposemeta.camera(and use it as the initial camera when present); (c)playcanvas/supersplat-viewer: DXR display mode (feature-detect, render N views, submit toXR_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
camerablock survives asplat-transformre-encode.Non-goals
Replacing Spark; changing the SOG geometry planes; anything requiring a private fork of PlayCanvas.
Refs
playcanvas/splat-transform#319 ·
@displayxr/inline3d1.7.0 (perf presets, camera block waterfall,setFocus/pick) · displayxr-demo-gaussiansplat #117/#118/#120 (measurements, camera rig, fit) · gallerySpatialView.tsx+src/lib/spatialView/camera.ts(the reference camera model).P5 — Automatic 3D for existing PlayCanvas sites (investigation → prototype)
Two tiers, cheapest first:
immersive-vr), and the device ownsXRView.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.app.xr(XrManager); usability only depends onnavigator.xr, which our browser provides. A browser-injected content script locatespc.Application.getApplication(), takes the active camera and callsapp.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
camerablock 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 hostedsupersplat-viewer) is TIER 1.supersplat-viewer1.32.0 (playcanvas ^2.22.1) ships a WebXR path:src/xr.tsgates a VR/AR button onxr.isAvailable('immersive-vr' | 'immersive-ar')and starts withxr.start(camera, 'immersive-vr', 'local-floor', …);viewer.tsreconfigures the camera onapp.xr.on('start'). So in the DisplayXR Browser the page needs no changes: our WebXR device must (a) advertiseimmersive-vr, (b) support thelocal-floorreference space, (c) work with PlayCanvasXrManageron 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.