@baba_hadjsaid/3dgsviewer
v1.2.4
Published
Embeddable 3D Gaussian Splatting viewer (PlayCanvas) with a React component, a framework-free core and a feature plug-in API
Maintainers
Readme
@baba_hadjsaid/3dgsviewer
Embeddable 3D Gaussian Splatting viewer. PlayCanvas under the hood, a React component on top, and a feature plug-in API for everything optional.
Reads .ply, .compressed.ply, .sog and .meta.json from any URL —
http(s):, blob: or a custom protocol — and streams level-of-detail
("Streamed SOG") scenes from a lod-meta.json URL.
Install
npm install @baba_hadjsaid/3dgsviewer playcanvasplaycanvas is a peer dependency — the viewer must share the host app's copy,
or you end up with two WebGL contexts. react / react-dom are optional peers,
needed only for the <GaussianSplatViewer> component.
The viewer creates its gsplat component with unified: false. That is required,
not stylistic: under PlayCanvas's unified renderer component.material is
null, so there is no per-instance material for features to touch and the
reveal intro silently does nothing.
The one exception is streamed LOD. PlayCanvas renders an octree
(lod-meta.json) only under the unified renderer, so those scenes get
unified: true, skip the reveal intro, and stream their chunk units after the
scene is ready. The viewer sets app.scene.gsplat.lodUnderfillLimit to
levels - 1, so the coarsest resident level shows while finer ones load, and
by default sizes the splat budget to the model's full detail (see
splatBudget). The URL's file name must be exactly lod-meta.json; chunk URLs
resolve next to it, and its query string is not carried over to them.
Use
import { GaussianSplatViewer } from '@baba_hadjsaid/3dgsviewer';
import '@baba_hadjsaid/3dgsviewer/styles.css';
<GaussianSplatViewer src="/scenes/room.ply" />Without React, drive the core directly. It needs a root element containing a
[data-viewer-element="canvas"] canvas (it appends one if there isn't):
import { createViewer } from '@baba_hadjsaid/3dgsviewer/viewer';
const viewer = createViewer({ root: document.querySelector('#stage'), src: url });
await viewer.ready;
viewer.destroy();fullScreen
One switch picks between the two ways to present a scene. It defaults to true,
so existing code keeps the complete experience.
| | fullScreen: true (default) | fullScreen: false |
|---|---|---|
| Title / help panel | yes | — |
| View toolbar, Axes + BBox toggles | yes | — |
| FPS readout | yes | — |
| Spinner, progress bar, status text | yes | — |
| Reveal intro animation | yes | — |
| Orbit camera path (P) | yes | — |
| Keyboard (arrows, WASD/QE, P) | yes | — |
| Gamepad | yes | — |
| Drag-and-drop a file onto the viewer | yes | — |
| Mouse: rotate / zoom / pan | yes | yes |
| Touch: one-finger rotate, two-finger pan+zoom | yes | yes |
| Auto-framing of the loaded scene | yes | yes |
| onReady / onError | yes | yes |
<GaussianSplatViewer src={url} fullScreen={false} />Minimal mode renders the model and nothing else — no chrome, no motion, no
keyboard focus. Loading and error UI is yours to draw: onError still fires,
and the built-in status text is suppressed rather than hidden, so nothing writes
into a DOM node you did not ask for.
Touch stays on because it is the direct-manipulation equivalent of the mouse, not a separate control scheme. If you want it off, say so and it becomes its own flag.
An explicit features, cameraPath, overlays or loadingEffect option
always wins over the mode's defaults — so you can run minimal chrome with the
orbit path if that is what you want:
<GaussianSplatViewer src={url} fullScreen={false} cameraPath={createOrbitCameraPath()} />Options
Both the component's props and createViewer(...) take the same names.
| Option | Meaning |
|---|---|
| src | Scene URL. Required. |
| format | 'ply' | 'compressed.ply' | 'sog' | 'meta.json' | 'lod-meta.json' — for extensionless URLs (blob:, custom protocols). |
| splatBudget | Streamed LOD only: global splat budget (app.scene.gsplat.splatBudget). Default 'auto': the model's full detail (its level-0 splat count plus the engine's per-chunk padding), capped at 4,000,000 on desktops, 2,000,000 at 4 GB navigator.deviceMemory and 1,000,000 on touch-only or ≤ 2 GB devices. A number sets it; 0 keeps the engine's 1,000,000, which draws a large model's far part at half or a quarter of its splats. |
| lodMaxConcurrentLoads | Streamed LOD only: chunk files downloaded at once. Default 6 (the engine's is 2). |
| lodRangeMin / lodRangeMax | Streamed LOD only: finest / coarsest level the component may use. |
| stats (React: showStats) | A panel of what is on screen: splats drawn, FPS, and for a streamed LOD the full-detail splat count, the splat budget (flagged when it limits detail) and which detail levels are in use. Default true (not shown in embedded mode). A host may supply its own <dl data-viewer-element="stats">. Replaces the former "Controls" help panel. |
| lodProgress | Streamed LOD only: show a bar and "Loading detail… n of m parts" across the top while chunks the current view needs are downloading. Default true. A host may place its own element with data-viewer-element="lod-progress" (same inner structure) instead of the one the viewer adds. |
| cameraFrustumsVisible | Whether the training-camera pyramids are shown when the scene opens. Default false: hidden until the Cameras toggle is clicked. (showCameraFrustums: false removes the feature and its toggle altogether.) |
| fullScreen | See above. Default true. |
| initialCameraPose | Starting camera; skips auto-framing. |
| root | Core only — the element to mount into. |
| onReady(:{app, camera}) / onError(err) | Lifecycle callbacks. |
| features | Full replacement for the default feature list. |
| cameraPath / overlays / loadingEffect | Override one slot; false disables it. |
| revealEffect, revealDurationMs, revealEpsilon, revealExponentMin, revealExponentMax | Reveal intro tuning. |
| imuWebSocketUrl | Optional WebSocket feeding live IMU camera orientation. |
| title, showTitle, className, style | Component only, full mode only. (showHelp is gone: the Controls panel became the stats panel.) |
Features
src/features/feature-api.js documents the contract. A feature touches the
scene only through the SceneApi object — never viewer internals, never another
feature — so the orbit path, the bbox/axes overlays and the reveal intro can all
be swapped for your own:
import { createViewer } from '@baba_hadjsaid/3dgsviewer/viewer';
import { createOrbitCameraPath } from '@baba_hadjsaid/3dgsviewer/features';
createViewer({ root, src, features: [createOrbitCameraPath({ durationMs: 20000 }), myEffect()] });Published form
dist/ ships precompiled ESM with sourcemaps: the JSX is already transpiled,
the module tree is preserved (so subpath imports stay real modules and the
package tree-shakes), and react, react-dom and playcanvas stay external.
No consumer needs to transpile node_modules, whatever bundler they use.
src/ ships too, so the sourcemaps resolve to readable code.
Build it locally with npm run build; npm run check then verifies the built
output, and prepublishOnly runs both so a hand-typed npm publish cannot skip
them.
Releasing
Push to master and CI does the rest: it bundles the package, runs the
contract test, bumps the version, tags it, publishes to npm and cuts a GitHub
release. The commit message picks the bump level.
| Commit message contains | Result |
|---|---|
| (nothing special) | patch — 1.0.0 → 1.0.1 |
| [minor] | minor — 1.0.0 → 1.1.0 |
| [major] | major — 1.0.0 → 2.0.0 |
| [skip release] | no publish |
The bump commit is written back as chore(release): v1.0.1 [skip ci], so it
cannot retrigger the workflow. Only changes under src/** or to package.json
start a release — README and workflow edits alone do not.
You can also trigger it by hand from the Actions tab (Release → Run workflow) and choose the level there.
Publishing uses npm trusted publishing (OIDC): no npm token is stored in this repo. GitHub mints a short-lived credential per run, which also attaches signed provenance to the npm page.
Locally, npm run build && npm run check runs the exact gate CI runs.
