storysplat-viewer
v2.10.17
Published
PlayCanvas-based 3D viewer for StorySplat scenes - HTML export & dynamic embedding
Maintainers
Readme
StorySplat Viewer
An npm package for embedding and interacting with 3D Gaussian Splatting scenes in web applications. StorySplat Viewer supports .ply, .sog, native Niantic/Scaniverse .spz (v4 / NGSP; the StorySplat editor converts the older gzip v1-v3 generation to SOG on import), and .glb/.gltf containers with KHR_gaussian_splatting, plus waypoints, multi-block hotspots, portals, scene menus, splat swaps, audio, particles, LOD streaming, 4DGS frame sequences, mirror/water planes, and multiple camera modes.
Raw .splat and legacy gzip SPZ files require editor conversion to SOG before loading in this runtime.
SPZ uses PlayCanvas's official parser and an embedded ZSTD decoder by default, so npm/CDN and self-hosted viewers need no extra decoder files. Hosts that already serve the decoder can pass ViewerOptions.zstdWasm with glueUrl and wasmUrl; a preconfigured PlayCanvas ZstdDecoderModule takes precedence.
Table of Contents
- Installation
- Quick Start
- Demos & Guides
- Loading Scenes
- API Reference
- Controlling the Viewer
- Configuration Options
- Scene Data Format
- LOD Streaming
- Splat Swap
- 4DGS Frame Sequences
- Viewer Theme Customization
- Splat Relighting
- Internationalization (i18n)
- Events
- Portals (Scene-to-Scene Navigation)
- Audio Emitters
- HTML Meshes
- Voxel Collision
- Mirror Planes
- Water Planes
- Measurements
- Post-Processing
- Custom Menu Links
- Entity Animations
- Guided Narration
- Custom Scripts
- 8th Wall AR
- React Integration
- Native App Integration
- Analytics & Tracking
- Standalone HTML Generation
- Error Handling
- Exports
- Troubleshooting
- Support
Installation
npm install storysplat-viewer playcanvasOr using yarn:
yarn add storysplat-viewer playcanvasQuick Start
Await creation to obtain the API; it does not wait for all assets. Tour playback/navigation require Tour mode and a waypoint path (at least two waypoints for autoplay). Destroy instances when removing their host, including instances that finish loading after a component unmount.
import { createViewerFromSceneId } from 'storysplat-viewer';
// Create a viewer from your StorySplat scene ID
const viewer = await createViewerFromSceneId(
document.getElementById('viewer'),
'YOUR_SCENE_ID'
);
viewer.on('error', error => console.error('Scene loading failed:', error));
// Call from your own control when you want to start a tour.
function startTour() {
if (viewer.getWaypointCount() < 2) return;
viewer.setCameraMode('tour');
viewer.play();
}
// Listen for events
viewer.on('ready', () => console.log('Viewer ready!'));Demos & Guides
The package includes interactive HTML demos in the demo/ folder:
| File | Description |
|------|-------------|
| demo/embedding-guide.html | Interactive guide covering ways to embed StorySplat scenes — iframe, script tag, npm package (React/Vue/Svelte), self-hosted export, and more. Includes a live playground with CSS customization controls. |
| demo/api-controls-demo.html | Interactive demo of the viewer API — playback controls, waypoint navigation, events, and configuration examples. |
Open these files with a local server (e.g., npx serve .) to try them out.
Loading Scenes
There are two ways to load scenes into the viewer:
Option 1: From Scene ID (Recommended)
Best for: Fetching an accessible hosted scene at initialization. Save edits in StorySplat and reload the viewer to fetch them.
import { createViewerFromSceneId } from 'storysplat-viewer';
// Scene ID from your StorySplat dashboard
const viewer = await createViewerFromSceneId(
document.getElementById('viewer'),
'YOUR_SCENE_ID'
);The scene is fetched from the StorySplat API on initialization or navigation; an open viewer is not subscribed to editor changes. Built-in visitor analytics and separate bandwidth accounting depend on configuration and privacy settings. See Analytics & Tracking.
Getting your Scene ID:
- Open your scene in the StorySplat editor
- Click "Upload" or "Update"
- Copy the Scene ID from the "Developer Integration" section
Option 2: From JSON File (Self-Hosted)
Best for: Scenes where you supply the configuration. Tracking is configurable; it is not implied solely by where the JSON is hosted.
import { createViewer } from 'storysplat-viewer';
import sceneConfig from './my-scene.json'; // Downloaded from editor
const viewer = await createViewer(
document.getElementById('viewer'),
sceneConfig
);Downloading scene JSON:
- Open your scene in the StorySplat editor
- Click "Export" or "Upload"
- In the "Developer Integration" section, click "Download Scene JSON"
- Save the file in your project
Note:
createViewercan report analytics when configured. UsedisableAnalytics: trueto disable its built-in session, Google-tag and bandwidth reporting; authored external content has separate behavior.
API Reference
createViewerFromSceneId
Create a viewer by fetching scene data from the StorySplat API.
async function createViewerFromSceneId(
container: HTMLElement,
sceneId: string,
options?: ViewerFromSceneIdOptions
): Promise<ViewerInstance>Parameters:
container- HTML element to render the viewer intosceneId- Your StorySplat scene IDoptions- Optional viewer configuration
Selected options (the interface also extends ViewerOptions):
interface ViewerFromSceneIdOptions {
// API configuration
baseUrl?: string; // Default: 'https://discover.storysplat.com'
apiKey?: string; // Forwarded as a Bearer header; not a supported private-scene access grant
// Viewer options
autoPlay?: boolean;
showUI?: boolean;
backgroundColor?: string; // Currently ignored here; use the scene backgroundColor field.
revealEffect?: 'fast' | 'medium' | 'slow' | 'none';
revealStyle?: 'bloom' | 'radial';
lazyLoad?: boolean;
lazyLoadThumbnail?: string;
lazyLoadButtonText?: string;
}createViewer
Create a viewer from scene data object. Use this for self-hosted scenes.
function createViewer(
container: HTMLElement,
scene: SceneData,
options?: ViewerOptions
): Promise<ViewerInstance>Note:
createViewerreturns a promise for the instance. Asset loading continues separately and can reporterrorevents; handle both those events and creation rejection.
fetchSceneMeta
Fetch scene metadata without creating a viewer (useful for previews).
async function fetchSceneMeta(
sceneId: string,
options?: { baseUrl?: string; apiKey?: string }
): Promise<{
name: string;
description: string;
thumbnailUrl: string;
userName: string;
userSlug: string;
views: number;
tags: string[];
category?: string;
createdAt: string | null;
}>ViewerInstance
Import the public interfaces from the installed package instead of copying declarations into your application:
import type { ViewerInstance, ViewerOptions, SceneData } from 'storysplat-viewer';The complete definitions are in src/types/index.ts; runtime methods are assembled in createViewer.ts. The web API reference describes method requirements and event payloads, including capture, panorama, entity animation, FPV, diagnostics and on-demand rendering. Frame-sequence methods are optional in the public type; check them before calling from TypeScript.
Controlling the Viewer
The viewer instance provides methods to control playback, navigation, and camera programmatically. This is useful for building custom UI controls outside the viewer.
External Control Example
This module example requires a bundler or import map that resolves the package import. Controls are for a scene with a waypoint tour; add your own loading/error UI.
<div id="viewer" style="width: 100%; height: 500px;"></div>
<div id="controls">
<button id="prev">Previous</button>
<button id="play">Play</button>
<button id="pause">Pause</button>
<button id="next">Next</button>
<span id="waypoint-info"></span>
</div>
<script type="module">
import { createViewerFromSceneId } from 'storysplat-viewer';
const viewer = await createViewerFromSceneId(
document.getElementById('viewer'),
'YOUR_SCENE_ID'
);
// Navigation
document.getElementById('prev').onclick = () => { viewer.setCameraMode('tour'); viewer.prevWaypoint(); };
document.getElementById('next').onclick = () => { viewer.setCameraMode('tour'); viewer.nextWaypoint(); };
// Playback
document.getElementById('play').onclick = () => {
if (viewer.getWaypointCount() < 2) return;
viewer.setCameraMode('tour');
viewer.play();
};
document.getElementById('pause').onclick = () => viewer.pause();
// Update waypoint display
viewer.on('waypointChange', ({ index }) => {
const total = viewer.getWaypointCount();
document.getElementById('waypoint-info').textContent =
`Waypoint ${index + 1} of ${total}`;
});
// Jump to specific waypoint
function goToWaypoint(index) {
if (index < 0 || index >= viewer.getWaypointCount()) return;
viewer.setCameraMode('tour');
viewer.goToWaypoint(index);
}
</script>Available Control Methods
| Method | Description |
|--------|-------------|
| Navigation | |
| viewer.goToWaypoint(index) | Jump to specific waypoint (0-indexed) |
| viewer.nextWaypoint() | Go to next waypoint |
| viewer.prevWaypoint() | Go to previous waypoint |
| viewer.getCurrentWaypointIndex() | Get current waypoint index |
| viewer.getWaypointCount() | Get total number of waypoints |
| Camera | |
| viewer.setCameraMode(mode) | Switch mode: 'tour', 'explore', or 'walk' |
| viewer.getCameraMode() | Get current camera mode |
| viewer.setExploreMode(mode) | Switch explore sub-mode: 'orbit', 'fly' or 'walk' |
| viewer.setPosition(x, y, z) | Set camera position in Explore mode; throws otherwise |
| viewer.setRotation(x, y, z) | Set rotation in degrees in Explore mode; throws otherwise |
| viewer.getPosition() | Get current camera position |
| viewer.getRotation() | Get current camera rotation |
| viewer.getCameraPose() | Get the camera pose: position, quaternion rotation and vertical FOV (degrees), in PlayCanvas world space |
| viewer.holdCameraPose(pose?) | Freeze the active camera driver, optionally moving the camera to pose first (e.g. under an image overlay) |
| viewer.releaseCameraPose() | Undo holdCameraPose: tour eases back onto the path; walk/explore return to the pre-hold pose |
| Offline Capture (video export) | |
| viewer.getPathPoseAtProgress(progress) | Deterministic tour-path pose at timeline progress (0-1), or null with fewer than 2 waypoints |
| viewer.getPathRenderInfo() | { waypointCount, loopMode, defaultDurationSec } for sizing a render |
| viewer.createPathPoseFollower() | Damped follower that reproduces the live tour's camera easing at a fixed frame time |
| viewer.beginFrameCapture({ width, height, hideMarkers?, fps? }) | Start a fixed-resolution capture; await session.renderFrame(pose, { progress? }) then read session.canvas in the same task; session.end() restores the viewer (including the splat on screen). hideMarkers (default true) hides hotspot/portal markers and leader lines; custom models and video hotspot planes stay. Splat swaps follow the frames, see below. While it runs, capturePhoto, captureWide and holdCameraPose throw |
| Playback / Progress | |
| viewer.play() | Start frame playback when configured, otherwise Tour autoplay (requires Tour mode and ≥2 waypoints). Starts immediately, also while a scene's initial autoplay waits for detail (autoPlayWaitForFullDetail) |
| viewer.pause() | Pause auto-play (also cancels an initial autoplay that is still waiting for detail) |
| viewer.stop() | Stop and reset to first waypoint |
| viewer.isPlaying() | Check if currently auto-playing |
| viewer.setProgress(progress) | Set scroll progress (0-1) |
| viewer.getProgress() | Get current scroll progress (0-1) |
| Splat Management | |
| viewer.goToSplat(url) | Swap to an additional-splat URL or the original URL; arbitrary URLs are rejected |
| viewer.goToOriginalSplat() | Switch back to the original splat |
| viewer.getCurrentSplatUrl() | Get the currently loaded splat URL |
| viewer.isShowingOriginalSplat() | Check if showing the original splat |
| viewer.getAdditionalSplats() | Get the list of additional splat swap points |
| viewer.isCompareMode() | Check whether before/after compare mode is active |
| viewer.getComparePosition() | Get compare slider position (0-1) |
| viewer.setComparePosition(position) | Set compare slider position (0-1) |
| 4DGS Frame Sequence | |
| viewer.isFrameSequencePlaying() | Check if frame sequence is playing |
| viewer.setFrame(index) | Request a zero-based frame asynchronously |
| viewer.getCurrentFrame() | Get current frame index |
| viewer.getTotalFrames() | Get total frame count |
| viewer.setFps(fps) | Set playback FPS |
| viewer.getFps() | Get current FPS |
| viewer.getFrameProgress() | Get frame progress (0-1) |
| viewer.setFrameProgress(progress) | Set frame progress (0-1) |
| Audio | |
| viewer.muteAll() | Mute all audio |
| viewer.unmuteAll() | Unmute all audio |
| viewer.isMuted() | Check if audio is muted |
| Hotspots | |
| viewer.getHotspots() | Get all hotspot data (id, title, type, position) |
| viewer.triggerHotspot(id) | Programmatically open a hotspot (its popup, the hotspotClick event and its linked actions) |
| viewer.closeHotspot() | Close the currently open hotspot popup |
| viewer.runLinkedAction(action) | Run one linked action, e.g. { id: 'a', target: { kind: 'hotspot', id: 'sign' }, action: 'playVideo' }; resolves when it settled |
| Overlays | |
| viewer.showOverlay(id) | Show an overlay by id regardless of its triggers (false for an unknown id) |
| viewer.hideOverlay(id) | Hide an overlay; suppressed until its trigger condition ends and restarts |
| viewer.getVisibleOverlays() | Ids of the overlays currently on screen |
| Portals | |
| viewer.navigateToScene(sceneId) | Navigate to a linked scene via portal |
| Lifecycle | |
| viewer.resize() | Recalculate canvas size (call after container resize) |
| viewer.destroy() | Clean up and remove viewer |
| UI | |
| viewer.setButtonLabels(labels) | Update UI text labels at runtime (see i18n) |
| Events | |
| viewer.on(event, callback) | Register event listener |
| viewer.off(event, callback) | Remove event listener |
Configuration Options
ViewerOptions
Selected public options are shown below; see the imported ViewerOptions type for the full interface.
interface ViewerOptions {
// Template style
template?: 'standard' | 'minimal' | 'pro';
// Playback
autoPlay?: boolean;
// UI
showUI?: boolean;
backgroundColor?: string; // Currently ignored here; use the scene backgroundColor field.
// Loading animation
revealEffect?: 'fast' | 'medium' | 'slow' | 'none';
revealStyle?: 'bloom' | 'radial'; // 'bloom' = burst with overshoot, 'radial' = classic dot wave
// Lazy loading
lazyLoad?: boolean;
lazyLoadThumbnail?: string;
lazyLoadThumbnailType?: 'image' | 'video' | 'gif'; // Thumbnail media type
lazyLoadButtonText?: string;
// Allow parent page CSS to affect the viewer
allowParentStyles?: boolean; // Default: false
// Manual analytics (alternative to createViewerFromSceneId auto-tracking)
analytics?: {
sceneId: string;
ownerId: string;
baseUrl?: string;
};
// Host-app version string (e.g. the StorySplat editor's release). Surfaced in
// the F3 performance HUD's "versions" section. Optional; unset for standalone scenes.
appVersion?: string;
}Lazy Loading
Show a thumbnail with a start button before loading the full viewer:
const viewer = await createViewerFromSceneId(container, sceneId, {
lazyLoad: true,
lazyLoadThumbnail: '/preview.jpg', // Optional custom thumbnail
lazyLoadButtonText: 'Start Tour' // Default: "Start Experience"
});Reveal Effects
Control how the splat appears when loaded:
const viewer = await createViewer(container, sceneData, {
revealEffect: 'medium', // 'fast' | 'medium' | 'slow' | 'none'
revealStyle: 'bloom' // 'bloom' (burst with overshoot) | 'radial' (classic dot wave)
});Scene Data Format
When using createViewer() with self-hosted JSON, the scene data should match the format exported by the StorySplat editor. The transform layer handles specific legacy aliases; it is not a guarantee of compatibility with every historical export. Test the JSON with the runtime version you deploy.
Core Fields
| Field | Type | Description |
|-------|------|-------------|
| loadedModelUrl | string | URL to a runtime-compatible model; convert raw .splat and legacy SPZ before use |
| sogModelUrl / sogUrl | string | SOG compressed format URL; size reduction depends on source/settings |
| compressedPlyUrl | string | Compressed PLY format URL; size depends on source/settings |
| lodMetaUrl | string | LOD streaming meta file URL (see LOD Streaming) |
| mobileLoadedModelUrl / mobileSplatUrl | string | Optional original-format mobile splat URL |
| mobileSogModelUrl / mobileSogUrl | string | Optional mobile SOG URL |
| mobileCompressedPlyUrl | string | Optional mobile compressed PLY URL |
| mobileLodMetaUrl | string | Optional mobile LOD streaming meta file URL |
| waypoints | array | Camera path waypoints |
| hotspots | array | Interactive hotspot markers |
| portals | array | Scene-to-scene navigation portals |
| overlays | array | Trigger-driven information panels (OverlayData: title, contentBlocks, layout 'drawer' | 'floating', anchor, width, dismissible, autoHideMs, triggers) |
| audioEmitters | array | Spatial audio sources (see Audio Emitters) |
| htmlMeshes | array | DOM/CSS 3D panels (see HTML Meshes) |
| particleSystems | array | Particle effect systems |
| customMeshes | array | Imported 3D models (.glb/.gltf) |
| collisionMeshesData | array | Primitive collision shapes |
| voxelCollisionUrl | string | Voxel octree collision data URL (see Voxel Collision) |
| additionalSplats | array | Splat swap points for chain, compare, or menu-driven swaps |
Visual & Environment
| Field | Type | Description |
|-------|------|-------------|
| activeSkyboxUrl | string | URL to the skybox HDR/image file |
| skyboxRotation | number | Skybox rotation offset in radians |
| lights | array | Scene lighting configuration |
| backgroundColor | string | Background color (hex) |
| splatRelighting | object | Splat relighting config (see Splat Relighting) |
Camera & Navigation
| Field | Type | Description |
|-------|------|-------------|
| defaultCameraMode | string | Initial camera mode: 'tour', 'explore', or 'walk' |
| orbitCameraSettings | object | Initial orbit camera position + pivot: { cameraPosition: {x,y,z}, pivotPoint: {x,y,z} } |
| doubleTapMoveSpeed | number | Auto-forward speed on double-tap in explore mode (default: 1.0) |
| refocusTapMode | string | Camera refocus trigger: 'single' or 'double' (default: 'single') |
| headBobEnabled | boolean | Enable head bob in walk mode (default: true) |
| playerHeight | number | Walk-mode eye height above the ground, in metres |
| playerWidth | number | Walk-mode player body width in metres: the diameter of the walker's horizontal collision body. Lower it if the player can't fit through doorways (default: 0.6; clamped to 0.1–2) |
| lodSettings | object | LOD display settings (see LOD Streaming) |
| autoPlayEnabled | boolean | Start the tour automatically once the loader closes (default: false) |
| autoPlayWaitForFullDetail | boolean | With autoPlayEnabled: the loader still closes at its usual point, but the tour does not start moving until levels of detail have finished streaming for the opening view. "Finished" means the engine's pending LOD downloads (the frame:ready loadingCount) have stayed at 0 for 0.8 s, at this device's LOD budget, not every octree node. Meanwhile a centred addingMoreDetail status shows; after 5 s a startTourAnyway button starts the tour at once. viewer.play() and the play button always start immediately. Next/previous, mode switches, FPV, staging photos and frame capture cancel the pending start, as they pause a playing tour. Scrolling and drag-to-look do not cancel it. The tour starts anyway after 60 s. Applies in the published viewer to LOD scenes (lodMetaUrl) only; single-file scenes, 4DGS sequences and the editor start as before (default: false) |
UI & Appearance
| Field | Type | Description |
|-------|------|-------------|
| uiColor | string | Accent color for UI controls |
| uiOptions | object | UI visibility toggles and button labels |
| uiOptions.viewerTheme | ViewerTheme | Custom viewer theme overrides (see Theme Customization) |
| uiOptions.buttonLabels | ButtonLabels | UI text overrides (see i18n) |
| uiOptions.sceneMenuLinks | SceneMenuLink[] | Custom links and menu-driven splat swap entries |
| uiOptions.sceneMenuStyle | "classic" or "sidebar" | Scene menu layout style |
| uiOptions.hideSceneMenu | boolean | Hide the scene navigation menu button entirely |
| uiOptions.showSceneTitle | boolean | Show the current scene's title as subtle white text in the bottom-left corner (matches the "Adding detail…" LOD indicator style). Also adds a "Current Space" header at the top of the scene menu |
| uiOptions.sceneTitleShowOnHover | boolean | When showSceneTitle is on: reveal the title only while hovering the bottom-left corner (default false = always shown) |
| revealStyle | string | Reveal animation: 'bloom' or 'radial' |
| swapTransitionType | string | Splat swap transition: 'scanline' or 'dissolve' (default: 'dissolve') |
| splatSwapMode | string | Splat swap mode: 'chain', 'compare', or 'menu' |
Hotspot Content Blocks
Hotspots can use the legacy single-content fields or an ordered contentBlocks array. When contentBlocks is present and non-empty, the popup renders those blocks instead of the legacy single-content path.
{
"hotspots": [
{
"id": "hotspot-1",
"title": "Product Details",
"position": { "x": 0, "y": 1, "z": 0 },
"contentBlocks": [
{ "id": "text-1", "kind": "text", "text": "Material notes and dimensions." },
{ "id": "gallery-1", "kind": "image", "urls": ["/front.jpg", "/detail.jpg"], "layout": "carousel" },
{ "id": "pdf-1", "kind": "pdf", "url": "/spec-sheet.pdf", "name": "Spec sheet" }
]
}
]
}Supported block kinds are text, image, video, link, pdf, iframe, and model. Image blocks can render as grid, carousel, or stacked galleries.
Linked Actions
A hotspot (hotspots[].linkedActions) or a 3D model (customMeshes[].interaction.linkedActions) can drive a different hotspot or model when it is activated, in addition to its own behaviour (popup, teleport, its own media). The classic case is a video sign with a PNG label under it, where clicking the label starts the sign's video:
{
"hotspots": [
{ "id": "sign", "type": "video", "videoUrl": "/sign.mp4", "mediaTriggerMode": "click", "position": { "x": 0, "y": 1.2, "z": 0 } },
{
"id": "label",
"type": "image",
"imageUrl": "/play-label.png",
"activationMode": "none",
"position": { "x": 0, "y": 0.6, "z": 0 },
"linkedActions": [
{ "id": "link-1", "target": { "kind": "hotspot", "id": "sign" }, "action": "playVideo" }
]
}
],
"customMeshes": [
{
"id": "robot",
"modelUrl": "/robot.glb",
"position": { "x": 2, "y": 0, "z": 0 },
"interaction": {
"linkedActions": [
{ "id": "link-2", "target": { "kind": "hotspot", "id": "sign" }, "action": "toggleVideo" }
]
}
}
]
}| Target kind | action | Effect |
|---|---|---|
| hotspot | playVideo, pauseVideo, toggleVideo, restartVideo | The target's video (any marker whose runtime entity carries a video element) |
| hotspot | openPopup | Opens the target's popup as if it was clicked (also when its own activation is none) |
| hotspot | playAudio, stopAudio, toggleAudio | The target's attached audio; toggle and resume follow its audioClickBehavior |
| model | playAnimation, stopAnimation, toggleAnimation | The GLB's animations, played like the model's own animation trigger (same clips and looping); stop pauses in place |
- When they run: hotspots on click/tap (
activationModeclick,noneor unset) or when the pointer enters ahoverhotspot; models on click, or on hover-in wheninteraction.activationModeishover.viewer.triggerHotspot(id)runs them too. The editor viewport runs them the same way. - No cascades: an action never runs the target's own linked actions, so two sources pointing at each other can't loop. Unknown, deleted or incapable targets are skipped and logged once.
- Sound: a play started by a click runs inside that gesture, so sound is allowed; if the browser still blocks it the video plays muted.
playVideounmutes an autoplay video that is playing muted, like clicking it does (once the visitor has interacted with the page). - Automatic triggers: a linked play or pause holds the target's own
proximity,scrollorautoplaytrigger until that trigger's condition next changes. A proximity video played from outside its range keeps playing until the visitor walks in and back out (thenpauseOnLeaveProximityapplies); a scroll/autoplay video paused by a link stays paused until it leaves and re-enters its visible range. - One popup at a time: if the clicked source has popup content of its own, its popup is the one left open.
Stacked Markers
A hotspot or portal with type: "stack" is one marker built from several media layers, drawn top to bottom in markerStack order — for example a looping video sign with a PNG title label tucked under it. Every layer is centred on the marker's vertical axis (plus its offsetX) at its media's aspect ratio, and the stack is centred on the marker's position. The stack shares the marker's position, rotation, scale, billboard, opacity (static or animated), visibilityRange, leader line and action: a click on any layer activates the hotspot or portal once.
{
"hotspots": [
{
"id": "sign",
"type": "stack",
"position": { "x": 0, "y": 1.5, "z": 2 },
"activationMode": "click",
"mediaTriggerMode": "autoplay",
"videoMuted": true,
"markerStackSpacing": -0.1,
"markerStack": [
{ "id": "video", "kind": "video", "url": "/media/sign.webm", "width": 1.6, "videoBackupUrl": "/media/sign.mp4" },
{ "id": "label", "kind": "image", "url": "/media/title.png", "width": 1.2 }
]
}
]
}| Field | Type | Description |
|-------|------|-------------|
| markerStack | MarkerLayer[] | Layers, top to bottom. Layers with an unknown kind or no url are skipped; at most 16 are drawn. A stack with no drawable layer renders the default sphere |
| markerStackSpacing | number | Gap between layers in marker units (default 0). Negative values overlap layers; a gap never lifts a layer above the one before it. Later layers are drawn in front |
| MarkerLayer.id | string | Stable layer id |
| MarkerLayer.kind | 'image' \| 'video' \| 'gif' | Layer media. An image layer whose URL is a GIF animates like a gif layer |
| MarkerLayer.url | string | Media URL |
| MarkerLayer.width | number | Width in marker units (1 = an image marker at scale 1; default 1). Height follows the media's aspect ratio |
| MarkerLayer.offsetX | number | Horizontal offset in marker units (default 0) |
| MarkerLayer.videoBackupUrl, webmHasAlpha, useIOSVideoAlphaMethod, forceIOSVideoAlphaMethodForAllDevices, iosMainVideoUrl, alphaMaskVideoUrl | | Video layers: same meaning as the video hotspot fields of the same name |
Only the first video layer is used; it is the marker's video (entity.videoElement). On a hotspot it plays exactly like a video hotspot, driven by the hotspot's mediaTriggerMode, videoLoop, videoMuted, proximityDistance and pauseOnLeaveProximity (click toggles, the "Tap to Start" prompt sits on the video layer). On a portal it autoplays muted and loops, like a video portal. Clean captures (capturePhoto({ hideEntities: true }), photo mode, rendered video) keep a hotspot stack that has a video layer — a sign is scene content, labels included — and hide image/GIF-only stacks and every portal stack like other markers. Viewer versions before stacked markers render a type: "stack" hotspot or portal as the default sphere with the same action.
Model URL Priority
The viewer loads model files in this priority order:
lodMetaUrl— LOD streaming, when enabled and a supported LOD set is suppliedsogModelUrl/sogUrl— SOG compressedcompressedPlyUrl— Compressed PLYloadedModelUrl— Original uploadsplatUrl— Legacy fallback
On mobile devices, or when the editor forces mobile preview, the viewer resolves the mobile URL set as a group. Mobile detection covers mobile user agents plus iPads running iPadOS 13+, which report a desktop Macintosh user agent and are recognized instead by navigator.platform === 'MacIntel' with multi-touch support. If any mobile URL exists, the viewer uses only mobileLodMetaUrl, mobileSogModelUrl / mobileSogUrl, mobileCompressedPlyUrl, and mobileLoadedModelUrl / mobileSplatUrl for that priority cascade. If no mobile URL exists, it uses the desktop set. It does not mix desktop and mobile slots in the same load attempt.
LOD Streaming
LOD (Level-of-Detail) streaming loads spatial chunks at detail levels selected by camera distance and the configured splat budget. This can reduce initial downloads; visible quality and loading time depend on the LOD set, network and device.
How It Works
The lodMetaUrl field points to a lod-meta.json file that references chunk files. Each chunk contains texture data (means, scales, quats, spherical harmonics) at different detail levels.
LOD Settings
Configure LOD display behavior via lodSettings in scene data:
const sceneData = {
lodMetaUrl: '/path/to/lod-meta.json',
lodSettings: {
preset: 'auto', // 'auto' | 'desktop-max' | 'desktop' | 'mobile-max' | 'mobile' | 'custom'
// Custom overrides (only used when preset is 'custom'):
lodBaseDistance: 15,
lodMultiplier: 2,
splatBudget: 2000000,
lodRangeMin: 0,
lodRangeMax: 10,
// Per-device overrides when preset is 'auto':
mobilePreset: 'mobile', // Preset for mobile devices
desktopPreset: 'desktop', // Preset for desktop devices
}
};| Preset | Splat budget | Description |
|--------|--------------|-------------|
| auto | — | Picks a preset from device type, core count and memory |
| desktop-max | 8,000,000 | Desktop auto-selection: ≥8 cores and ≥8 GB reported memory, or ≥8 cores when memory is unavailable |
| desktop | 4,000,000 | Other desktops and laptops |
| mobile-max | 3,000,000 | High-end phones and multi-core iPads |
| mobile | 2,000,000 | Other phones (recommended for mobile) |
| custom | your value | Use custom splatBudget, lodBaseDistance, lodMultiplier |
The budget guides the engine’s selected splat detail; it is not a strict memory ceiling; it demotes far nodes to coarser LOD levels first. It can never demote a node below the coarsest level in the LOD set, so a set whose coarsest level is larger than the budget renders the whole scene at that level. Generate LOD sets whose coarsest level is well under the mobile budget (a few hundred thousand splats).
Nodes behind the camera keep their distance-based LOD (lodBehindPenalty 1). Demoting them
saves some memory but re-downloads their fine chunks every time the camera turns around.
Performance mode
An end-user toggle for slow devices: it halves the render resolution cap (never below device
pixel ratio 1.0) and the splat budget (an unlimited budget becomes 3,000,000). Viewers can switch it from the help panel; it is stored
per browser in localStorage under storysplat.performanceMode, forced with ?perf=1 or
?perf=0, and starts on for phones reporting ≤ 4 cores or ≤ 4 GB.
viewer.setPerformanceMode(true);
viewer.getPerformanceMode(); // true
viewer.on('performanceModeChange', (enabled) => { /* ... */ });Creators can start every phone and tablet visitor in performance mode with
uiOptions.forceMobilePerformanceMode: true (the "Force performance mode on mobile & tablet"
checkbox in the editor's theme panel). It beats the visitor's stored preference so a heavy scene
always opens light on an unknown device; the help-panel toggle and ?perf=0 still work.
Render resolution is capped at device pixel ratio 1.5 on mobile and 2.5 on desktop.
On-demand rendering
By default the viewer only renders a frame when something on screen can have changed: recent input, camera movement, a playing tour, LOD streaming, a transition or reveal effect, any enabled script / particle system / model animation, a node that moved, or an explicit request (resize, capture, performance-mode toggle). While idle it still renders once a second as a safety net. Scenes with features that animate on their own (weather, audio-reactive, particles, water, video/GIF/animated hotspots and portals, 4DGS frame sequences, animated HTML meshes, entity animations, custom scripts, model animations) automatically keep the every-frame loop, and so do XR/AR sessions and the editor.
Turn it off per scene with uiOptions.onDemandRendering: false, per page load with
?render=always, or at runtime:
viewer.requestRender(); // draw one more frame (see below)
viewer.setOnDemandRendering(false); // render every frame
viewer.getOnDemandRendering(); // true while on-demand rendering is active?render=ondemand forces it on for testing even when the scene has animated features.
The viewer detects the camera, node transforms (including entity animations), enabled scripts,
model animations, playing videos and GIFs, LOD streaming and input on its own. If you mutate
something none of those cover — a material colour on an entity you own, say — call
viewer.requestRender() afterwards to draw one more frame.
Splat Swap
Splat swap lets a scene replace the active splat at runtime while keeping the same camera, hotspots, menu, and UI shell.
Modes
| Mode | Description |
|------|-------------|
| chain | Swap along the tour by waypoint index or percentage trigger |
| compare | Load a before/after splat with an interactive comparison slider |
| menu | Disable automatic triggers and swap only when a scene-menu item targets a splat |
Scene Data
{
"splatSwapMode": "menu",
"swapTransitionType": "dissolve",
"additionalSplats": [
{
"url": "/splats/daytime.ply",
"name": "Daytime",
"waypointIndex": -1,
"percentage": -1,
"sogModelUrl": "/splats/daytime.sog",
"compressedPlyUrl": "/splats/daytime.compressed.ply",
"mobileUrl": "/splats/daytime-mobile.ply",
"mobileSogModelUrl": "/splats/daytime-mobile.sog",
"mobileCompressedPlyUrl": "/splats/daytime-mobile.compressed.ply",
"defaultExploreMode": "orbit"
}
],
"uiOptions": {
"sceneMenuLinks": [
{
"id": "swap-daytime",
"label": "Daytime",
"url": "#",
"swapAction": "splat-swap",
"targetSplatUrl": "/splats/daytime.ply"
}
]
}
}Each swap point can carry desktop optimized assets (sogModelUrl, compressedPlyUrl) plus optional mobile variants (mobileUrl, mobileSogModelUrl, mobileCompressedPlyUrl). Swap splats resolve the selected desktop or mobile set with LOD manifest (lodMetaUrl / mobileLodMetaUrl) > SOG > compressed PLY > original priority, in every swap mode. An LOD swap (for example an LCC2 export) streams with its own LODs; a chain or menu swap between two LOD splats (the primary counts when it streams LODs) cuts instead of dissolving.
Collision per swap. While a swap splat shows, walk and fly collide with the primary splat's collision by default. Set collision: 'own' with collisionMeshUrl (a .collision.glb) and/or voxelCollisionUrl (a .voxel.json) to use the swap's own collision, bound to its transform; collisionSystem picks between them (default: mesh when set). collision: 'none' switches splat collision off while the swap shows. Custom collision primitives apply throughout.
defaultExploreMode changes the explore sub-mode when a swap occurs while already exploring. It does not switch Tour mode into Explore. The saved preloadAhead field is ignored; the runtime preloads one next swap.
Swap by proximity. In chain and menu modes a swap point can carry proximityArea, a region trigger in editor scene coordinates (the same shape as an overlay's region trigger). The splat shows while the camera is inside it, in any camera mode; leave waypointIndex and percentage at -1. On leaving, the splat from before returns (in chain mode, whatever the tour position calls for) unless hideOnExit is false: then the splat stays, and chain swaps carry on once the tour position calls for a different splat. When areas overlap, the one entered last wins. A goToSplat / goToOriginalSplat call made inside an area wins: its splat stays after leaving, and the area swaps again on the next entry. An area whose splat fails to load is not retried until the camera enters it again. Area swaps do not run live in the editor.
Swaps in captured video. During beginFrameCapture (and the editor's Render Video, through prepareCaptureFrame) splat swaps follow the captured frames, not the live camera, and nothing depends on network or render speed:
- Each
renderFrame(pose, frame?)first evaluates proximity areas atpose. Whenframe.progressis given — the tour timeline progress the pose came from (getPathPoseAtProgress(progress)) — waypoint / percentage swaps, return-to-original markers, per-swap skyboxes and transforms are applied for it, exactly as the live tour would. Leaveprogressout for poses that are not on the tour: only areas apply. - A frame that starts a swap waits until the new splat (and its skybox) has loaded and sorted, so the swap shows on the frame where the camera crosses its trigger.
- Dissolve / wipe transitions advance by
1 / fpsper frame (FrameCaptureOptions.fps, default 30), so they span as many frames as they last seconds live. - The first frame shows its swap state directly, without a transition.
end()puts back the splat and skybox that were on screen before the capture.
const session = viewer.beginFrameCapture({ width: 1920, height: 1080, fps: 30 });
for (let i = 0; i < frames; i++) {
const progress = i / (frames - 1);
await session.renderFrame(viewer.getPathPoseAtProgress(progress), { progress });
encoder.encode(new VideoFrame(session.canvas, { timestamp: (i * 1e6) / 30 }));
}
session.end();{
"url": "/splats/kitchen.sog",
"name": "Kitchen",
"waypointIndex": -1,
"percentage": -1,
"proximityArea": { "type": "region", "shape": "box", "position": { "x": 2, "y": 0, "z": -4 }, "size": { "x": 5, "y": 3, "z": 4 } }
}4DGS Frame Sequences
Creation and loaded do not wait for every frame to download. setFrame/setFrameProgress request a frame asynchronously; seeking to an already-loading frame may not display it immediately. Observe frameChange, whose index is zero-based. play/pause/stop control the configured sequence, but isPlaying() reports tour state; use isFrameSequencePlaying() for frame playback.
4DGS (4D Gaussian Splatting) enables playback of frame-by-frame splat animations — like video but in 3D.
Configuration
Add a frameSequence config to your scene data:
const sceneData = {
loadedModelUrl: '/frames/frame_000.ply', // First frame (also used as static fallback)
frameSequence: {
frameUrls: [
'/frames/frame_000.ply',
'/frames/frame_001.ply',
'/frames/frame_002.ply',
// ... up to N frames
],
fps: 24, // Playback FPS (default: 24)
loop: true, // Loop playback (default: true)
preloadCount: 10, // Frames to preload ahead (default: 10)
autoplay: true, // Auto-play immediately (default: false)
}
};Controlling Frame Playback
const viewer = await createViewer(container, sceneData);
// Frame navigation
viewer.setFrame(0); // Jump to first frame
viewer.getCurrentFrame(); // Get current frame index
viewer.getTotalFrames(); // Get total frame count
viewer.isFrameSequencePlaying(); // Check if playing
// Playback speed
viewer.setFps(30); // Change FPS
viewer.getFps(); // Get current FPS
// Progress (0-1)
viewer.setFrameProgress(0.5); // Jump to 50% through sequence
viewer.getFrameProgress(); // Get current progress4DGS Events
viewer.on('frameChange', (frame, total) => {
console.log(`Frame ${frame} of ${total}`);
});
viewer.on('frameComplete', () => {
console.log('Frame sequence finished (non-looping)');
});Viewer Theme Customization
The viewer supports deep UI customization via the ViewerTheme interface. You can override colors, typography, border radii, popup alignment, share-modal styling, and editor-authored layout positions — either via scene data or programmatically.
Setting a Theme via Scene Data
const sceneData = await fetch('/scene.json').then(r => r.json());
sceneData.uiOptions = {
...sceneData.uiOptions,
viewerTheme: {
buttonBg: 'rgba(20, 20, 40, 0.9)',
buttonTextColor: '#e0e0ff',
popupBg: 'rgba(10, 10, 30, 0.95)',
popupTextColor: '#ffffff',
popupTextAlign: 'center',
preloaderBg: '#0a0a1e',
joystickBaseColor: 'rgba(100, 100, 255, 0.3)',
joystickThumbColor: 'rgba(150, 150, 255, 0.6)',
buttonBorderRadius: '12px',
buttonFontSize: '13px',
fontFamily: '"Inter", sans-serif',
}
};
const viewer = await createViewer(container, sceneData);Available Theme Properties
| Category | Properties |
|----------|-----------|
| Colors (40) | globalTextColor, buttonBg, buttonHoverBg, buttonTextColor, popupBg, popupTextColor, popupCloseBtnColor, popupLinkBtnColor, preloaderBg, preloaderTextColor, infoBannerBg, dropdownBg, watermarkBg, helpPanelBg, errorPopupBg, errorTitleColor, lazyLoadBg, joystickBaseColor, joystickThumbColor, portalPopupBg, shareButtonBg, shareButtonHoverBg, shareButtonIconColor, shareModalBackdropBg, shareModalBg, shareModalBorderColor, shareModalTextColor, shareModalTitleColor, shareModalCloseColor, shareModalPreviewBg, shareModalSocialButtonBg, shareModalSocialButtonHoverBg, shareModalSocialButtonTextColor, shareModalActionButtonBg, shareModalActionButtonHoverBg, shareModalActionButtonTextColor, shareModalPrimaryButtonBg, shareModalPrimaryButtonHoverBg, shareModalPrimaryButtonTextColor, sceneMenuLinkColor |
| Typography & Text (50) | fontFamily, buttonFontSize, buttonFontWeight, popupTitleFontSize, popupTitleFontWeight, popupTitleFontFamily, popupTitleLetterSpacing, popupContentFontSize, popupContentFontWeight, popupContentFontFamily, popupContentLetterSpacing, popupContentLineHeight, popupContentOpacity, popupTextAlign, infoBannerTitleFontSize, infoBannerContentFontSize, infoBannerTitleFontFamily, infoBannerTitleFontWeight, infoBannerTitleLetterSpacing, infoBannerTitleColor, infoBannerContentFontFamily, infoBannerContentFontWeight, infoBannerContentLetterSpacing, infoBannerContentLineHeight, infoBannerContentOpacity, infoBannerContentColor, progressFontSize, progressFontWeight, progressTextColor, progressFontFamily, progressLetterSpacing, modeBtnFontSize, modeBtnFontWeight, watermarkFontSize, watermarkFontWeight, sceneMenuFontSize, sceneMenuFontWeight, waypointListFontSize, waypointListFontWeight, portalPopupTitleFontSize, portalPopupTitleFontWeight, portalPopupBtnFontSize, portalPopupBtnFontWeight, shareModalFontFamily, shareModalTitleFontSize, shareModalTitleFontWeight, shareModalActionFontSize, shareModalActionFontWeight, shareModalSocialFontSize, shareModalSocialFontWeight |
| Border Radii (10) | buttonBorderRadius, popupBorderRadius, dropdownBorderRadius, helpPanelBorderRadius, errorPopupBorderRadius, lazyLoadBtnBorderRadius, shareButtonBorderRadius, shareModalBorderRadius, shareModalPreviewBorderRadius, shareModalButtonBorderRadius |
| Effects, Sizing & Layout (5) | dropdownBlur, progressBarHeight, preloaderBarHeight, shareModalBackdropBlur, uiLayout |
All properties are optional — unspecified values fall back to built-in defaults.
Custom Element Placement (uiLayout)
uiLayout moves individual UI elements off their default positions:
uiLayout: {
// Applied at viewports >= 769px wide
elements: { hotspotPopup: { anchor: 'top-left', offsetX: 24, offsetY: 32 } },
// Applied at viewports <= 768px wide
mobile: { hotspotPopup: { anchor: 'bottom-center', offsetX: 0, offsetY: 16 } },
}Placements are emitted as width-bucketed CSS — elements inside @media (min-width: 769px), mobile inside
@media (max-width: 768px). The buckets are viewport-based rather than device-based, so a tablet in portrait, a
split-screen window, or a narrow iframe embed uses the mobile bucket whatever the device is.
The hotspot popup is the one exception to bucket isolation: when mobile.hotspotPopup is unset it inherits
elements.hotspotPopup, with offsets clamped to min(<offset>, calc(100% - 80px)) so a placement tuned on a wide
screen stays reachable on a phone. Setting mobile.hotspotPopup explicitly overrides the inheritance and applies
your offsets unclamped. Without this inheritance a popup positioned only for desktop falls back to the built-in
centered position on narrow viewports.
Generating Styles Programmatically
import { generateViewerStyles } from 'storysplat-viewer';
const css = generateViewerStyles({
buttonBg: 'rgba(0, 0, 0, 0.8)',
buttonTextColor: '#fff',
});
// Inject into your page
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);Splat Relighting
Scene lights can dynamically illuminate Gaussian Splats for dramatic lighting effects. Configure relighting in scene data:
const sceneData = {
// ... other scene config ...
splatRelighting: {
enabled: true,
ambientColor: '#ffffff', // Ambient light color for unlit areas
ambientIntensity: 0.8, // 0-1, ambient fill strength
allowViewerToggle: true, // Show toggle button in viewer UI
viewerDefaultOn: false, // Start with relighting off for end users
shadowsEnabled: false, // Enable shadow casting
shadowIntensity: 0.5, // Shadow strength (0-1)
shadowGroundY: 0, // Ground plane Y position for shadows
shadowPlaneScale: 10, // Shadow receiving plane size
}
};When allowViewerToggle is true, end users see a toggle button in the viewer to switch relighting on/off.
Internationalization (i18n)
ButtonLabels customizes the listed viewer labels. Some runtime text is hard-coded, so these overrides do not provide complete localization.
At Creation Time
Set labels via uiOptions.buttonLabels in your scene data:
const sceneData = await fetch('/scene.json').then(r => r.json());
// Override labels before creating the viewer
sceneData.uiOptions = {
...sceneData.uiOptions,
buttonLabels: {
tour: 'Recorrido',
explore: 'Explorar',
walk: 'Caminar',
next: 'Siguiente',
previous: 'Anterior',
waypoints: 'Puntos',
close: 'Cerrar',
loading: 'Cargando...',
}
};
const viewer = await createViewer(container, sceneData);At Runtime
Update labels at any time with setButtonLabels():
const viewer = await createViewerFromSceneId(container, sceneId);
// Switch to Spanish
viewer.setButtonLabels({
tour: 'Recorrido',
explore: 'Explorar',
walk: 'Caminar',
next: 'Siguiente',
previous: 'Anterior',
close: 'Cerrar',
loading: 'Cargando...',
});
// Only update specific labels (others keep their current values)
viewer.setButtonLabels({ next: 'Suivant', previous: 'Pr\u00e9c\u00e9dent' });Available Labels
These are label defaults, not a specification of camera bindings. The runtime builds some help instructions from the active camera mode/settings; see camera controls.
| Key | Default | Description |
|-----|---------|-------------|
| tour | "Tour" | Tour mode button |
| explore | "Explore" | Explore mode button |
| walk | "Walk" | Walk mode button |
| orbit | "Orbit" | Orbit sub-mode button |
| fly | "Fly" | Fly sub-mode button |
| next | "Next" | Next waypoint button |
| previous | "Prev" | Previous waypoint button |
| startExperience | "Start Experience" | Start experience button |
| fullscreen | "Fullscreen" | Fullscreen button aria-label |
| mute / unmute | "Mute" / "Unmute" | Audio toggle |
| waypoints | "Waypoints" | Waypoint dropdown label |
| close | "Close" | Hotspot popup close button |
| yes / cancel | "Yes" / "Cancel" | Portal confirmation buttons |
| switchScenes | "Switch scenes?" | Portal default prompt |
| hotspotDefaultTitle | "Hotspot" | Fallback hotspot title |
| openExternalLink | "Open External Link" | Hotspot link button text |
| vr / ar | "VR" / "AR" | XR button labels |
| exitVr / exitAr | "Exit VR" / "Exit AR" | XR exit labels |
| loading | "Loading..." | Loading progress text |
| loadingScene | "Loading {name}..." | Portal scene loading ({name} replaced) |
| helpTitle | "Controls & Help" | Help panel title |
| helpCameraModes | "Camera Modes:" | Help section header |
| helpTourDesc | "Follow predefined path" | Help tour description |
| helpExploreDesc | "Free movement" | Help explore description |
| helpWalkDesc | "First-person walking" | Help walk description |
| helpTourControls | "Controls:" | Help panel - tour controls heading |
| helpTourScroll | "Scroll to navigate" | Help panel - tour scroll instruction |
| helpTourDrag | "Drag to look around" | Help panel - tour drag instruction |
| helpExploreControls | "Controls:" | Help panel - explore controls heading |
| helpExploreLMB | "Left click + drag to orbit" | Help panel - explore LMB |
| helpExploreRMB | "Right click + drag to pan" | Help panel - explore RMB |
| helpExploreWASD | "WASD to move" | Help panel - explore WASD |
| helpExploreShift | "Shift to speed up" | Help panel - explore shift |
| helpExploreScroll | "Scroll to zoom" | Help panel - explore scroll |
| helpExploreDblClick | "Double-click to auto-move" | Help panel - explore double-click |
| helpWalkControls | "Controls:" | Help panel - walk controls heading |
| helpWalkClick | "Click to start" | Help panel - walk click |
| helpWalkWASD | "WASD to walk" | Help panel - walk WASD |
| helpWalkMouse | "Mouse to look" | Help panel - walk mouse |
| helpWalkShift | "Shift to run" | Help panel - walk shift |
| helpWalkSpace | "Space to jump" | Help panel - walk space |
| errorWebGLTitle | "Unable to Initialize 3D Graphics" | WebGL error heading |
| errorWebGLMessage | "Your browser or device..." | WebGL error body |
| percentageFormat | "{n}%" | Progress percentage format |
| addingMoreDetail | "Adding in more detail" | Status while autoplay waits for levels of detail (autoPlayWaitForFullDetail) |
| startTourAnyway | "Start the tour anyways" | Button that starts the tour without waiting for the remaining detail |
The last two keys are typed on ViewerButtonLabels (which extends ButtonLabels); uiOptions.buttonLabels and setButtonLabels() accept every key in this table.
Events
Events are not replayed to late subscribers. Initialize your UI from getters, then listen for changes. ready means initialization, and loaded means the initial loading stage—not that every LOD chunk, frame or media asset is ready. Payloads such as loading text, waypoint metadata and bandwidth fields vary by emission path.
// Viewer is ready
viewer.on('ready', () => {
console.log('Viewer initialized');
});
// Scene loaded
viewer.on('loaded', () => {
console.log('Initial loading stage completed; streaming/media may continue.');
});
// Loading progress
viewer.on('progress', ({ progress, text }) => {
console.log(`Loading ${Math.round(progress * 100)}%: ${text}`);
});
// Waypoint changed
viewer.on('waypointChange', ({ index, waypoint }) => {
console.log(`Now at waypoint ${index}: ${waypoint.name}`);
});
// Playback state
viewer.on('playbackStart', () => console.log('Playing'));
viewer.on('playbackStop', () => console.log('Stopped'));
viewer.on('playbackComplete', () => console.log('Tour finished'));
// Camera mode
viewer.on('modeChange', ({ mode }) => {
console.log(`Camera mode: ${mode}`);
});
// Scroll progress
viewer.on('progressUpdate', ({ progress, index }) => {
console.log(`Progress: ${(progress * 100).toFixed(0)}%, waypoint ${index}`);
});
// Hotspot clicked
viewer.on('hotspotClick', ({ hotspot }) => {
console.log(`Hotspot clicked: ${hotspot.id} - ${hotspot.title}`);
});
// XR sessions
viewer.on('xrStart', ({ type }) => console.log(`Entered ${type}`));
viewer.on('xrEnd', () => console.log('Exited XR'));
// Splat swap
viewer.on('splatChange', ({ url, isOriginal }) => {
console.log(`Splat changed: ${url} (original: ${isOriginal})`);
});
// 4DGS frame sequence
viewer.on('frameChange', (frame, total) => {
console.log(`Frame ${frame} of ${total}`);
});
viewer.on('frameComplete', () => {
console.log('Frame sequence complete');
});
// Errors and warnings
viewer.on('error', (error) => {
console.error('Viewer error:', error);
});
viewer.on('warning', ({ type, message }) => {
console.warn(`Warning [${type}]: ${message}`);
});Registering a portalActivated listener overrides built-in navigation, even if the listener only logs the event. Leave it unregistered to use built-in hosted navigation. For custom navigation, see Self-Hosted Portal Navigation; calling viewer.navigateToScene() inside that listener emits the same event again and recurses.
Selected Events Reference
| Event | Data | Description |
|-------|------|-------------|
| ready | — | Viewer initialized |
| loaded | { bandwidthUsed, isStorySplatHosted } | Initial scene-loading stage; 4DGS emits before its initial frame assets finish |
| progress | { progress, text } | Loading progress |
| waypointChange | { index, waypoint, prevIndex, cameraMode } | Waypoint changed |
| playbackStart | — | Auto-play started |
| playbackStop | — | Auto-play paused/stopped |
| playbackComplete | — | Tour reached the end |
| modeChange | { mode } | Camera mode changed |
| progressUpdate | { progress, index } | Scroll progress updated |
| hotspotClick | { hotspot } | Hotspot clicked |
| overlayShow | { overlay } | An overlay appeared |
| overlayHide | { overlay } | An overlay was closed or hidden |
| xrStart | { type } | Entered VR/AR |
| xrEnd | — | Exited VR/AR |
| splatChange | { url, isOriginal } | Splat file swapped |
| frameChange | (frame, total) | 4DGS frame changed |
| frameComplete | — | 4DGS sequence finished |
| portalActivated | { portalId, targetSceneId, targetSceneName } | Portal triggered |
| portalClick | { portal } | Portal clicked in editor mode |
| panoramaModeChange | { enabled } | 360 panorama portal mode changed |
| error | Error | Viewer error |
| warning | { type, message } | Non-fatal warning |
Portals (Scene-to-Scene Navigation)
Portals allow users to navigate between multiple 3D scenes by clicking or walking near portal markers.
How Portals Work
Portals are configured in the scene JSON with a targetSceneId:
{
"portals": [
{
"id": "portal-1",
"targetSceneId": "abc123xyz",
"targetSceneName": "Campervan Interior",
"targetSceneThumbnail": "https://example.com/thumb.jpg",
"position": { "x": 2, "y": 0, "z": -3 },
"type": "sphere",
"activationMode": "click",
"confirmNavigation": true,
"menuOnly": false,
"showInMenu": true,
"menuPath": "Building A/Floor 1",
"menuOrder": 10
}
]
}Portal Fields
| Field | Type | Description |
|-------|------|-------------|
| id | string | Unique portal identifier |
| targetSceneId | string | Scene ID to navigate to |
| targetSceneName | string | Display name for the target scene |
| targetSceneThumbnail | string | Thumbnail URL for portal preview |
| position | {x, y, z} | 3D position of the portal marker |
| type | string | Portal marker: 'sphere', 'image', 'video', 'gif', 'stack' (see Stacked Markers), 'plane' (invisible trigger) or 'panorama' |
| markerStack / markerStackSpacing | MarkerLayer[] / number | Layers of a 'stack' portal (see Stacked Markers) |
| activationMode | string | 'click' or 'proximity' |
| confirmNavigation | boolean | Show confirmation dialog before navigating |
| menuOnly | boolean | If true, portal appears only in the scene menu, not as a 3D mesh |
| showInMenu | boolean | Set to false to keep the 3D marker but omit this portal from the scene menu |
| menuPath | string | Folder path for menu organization (e.g., "Building A/Floor 1") |
| menuOrder | number | Shared sort key across portals and custom menu links |
Self-Hosted Portal Navigation
By default, portals use StorySplat scene IDs and fetch from discover.storysplat.com. For self-hosted portals, intercept the portalActivated event. Any external listener overrides default navigation; the handler must perform the navigation itself. This minimal example serializes loads and checks the map/response:
import { createViewer } from 'storysplat-viewer';
const SCENES = {
'campervan': '/scenes/campervan/scene.json',
'cabin': '/scenes/cabin/scene.json',
};
let currentViewer = null;
let loading = false;
async function loadScene(sceneId) {
if (loading) return;
const url = SCENES[sceneId];
if (!url) throw new Error('Unknown scene ID');
loading = true;
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`Scene request failed: ${response.status}`);
const data = await response.json();
currentViewer?.destroy();
currentViewer = null;
currentViewer = await createViewer(container, data, { disableAnalytics: true });
currentViewer.on('error', console.error);
currentViewer.on('portalActivated', ({ targetSceneId }) => {
loadScene(targetSceneId).catch(console.error);
});
} finally {
loading = false;
}
}
loadScene('campervan').catch(console.error);The targetSceneId is just a string you define — it doesn't need to exist on StorySplat. Map it to your own file paths.
Portal Events
| Event | Description | Data |
|-------|-------------|------|
| portalActivated | Portal clicked or proximity triggered | { portalId, targetSceneId, targetSceneName } |
| portalClick | Portal clicked in editor mode | { portal } |
| panoramaModeChange | 360 panorama portal mode changed | { enabled } |
Audio Emitters
Audio emitters are standalone spatial audio sources positioned in 3D space. They support distance-based attenuation and spatialization.
{
"audioEmitters": [
{
"id": "birds",
"name": "Bird Sounds",
"url": "https://example.com/birds.mp3",
"position": { "x": 5, "y": 2, "z": -3 },
"volume": 0.8,
"loop": true,
"autoplay": true,
"spatialSound": true,
"distanceModel": "inverse",
"maxDistance": 50,
"refDistance": 1,
"rolloffFactor": 1,
"enabled": true
}
]
}| Field | Type | Default | Description |
|-------|------|---------|-------------|
| id | string | — | Unique identifier |
| name | string | — | Display name |
| url | string | — | Audio file URL |
| position | {x, y, z} | {0,0,0} | 3D position |
| volume | number | 1 | Volume (0-1) |
| loop | boolean | false | Loop playback |
| autoplay | boolean | false | Play automatically on load |
| spatialSound | boolean | true | Enable 3D spatial audio |
| distanceModel | string | 'inverse' | 'inverse', 'linear', or 'exponential' |
| maxDistance | number | 100 | Linear-model distance cap; not a silence cutoff for inverse/exponential falloff |
| refDistance | number | 1 | Distance at which volume is 100% |
| rolloffFactor | number | 1 | How quickly volume decreases with distance |
| enabled | boolean | true | Whether this emitter is active |
HTML Meshes
The current renderer ignores legacy fitStrategy and background-color fields; style the content itself. The editor preview does not pass billboard ranges through its HTML mesh mapping, so verify those ranges in the published viewer.
HTML Meshes project live DOM/CSS3D panels into the scene. They support text, images, iframes, forms, CSS styling, and interactive elements.
Because HTML meshes are DOM overlays composited above the WebGL canvas, they always render in front of the splats instead of participating in 3D depth/occlusion. Their opacity is locked to 1 at runtime so embedded pages and forms remain readable; use custom GLB/GLTF meshes when you need depth-tested or partially occluded geometry.
{
"htmlMeshes": [
{
"id": "info-panel",
"name": "Info Panel",
"htmlContent": "<div style=\"background:#222;color:white;padding:20px\"><h2>Welcome</h2><p>This is a 3D info panel</p></div>",
"position": { "x": 0, "y": 2, "z": -5 },
"rotation": { "x": 0, "y": 0, "z": 0 },
"scale": { "x": 2, "y": 1.5, "z": 1 },
"width": 512,
"height": 384,
"billboard": false,
"visible": true
}
]
}| Field | Type | Description |
|-------|------|-------------|
| htmlContent | string | HTML content for the live DOM panel |
| position / rotation / scale | {x,y,z} | 3D transform |
| width / height | number | DOM panel dimensions in pixels |
| billboard | boolean | Always face the camera |
| css | string | CSS inserted in a style element for HTML content; cssStyles is not consumed. |
| visible | boolean | Whether the mesh is visible |
Voxel Collision
Voxel collision approximates splat geometry in an octree for ground detection and movement blocking. Accuracy depends on resolution, opacity cutoff, carving and alignment; visible geometry is not automatically an exact collision surface.
Scene Data
{
"voxelCollisionUrl": "https://example.com/scene.voxel.json"
}The voxel collision system requires two co-located files:
.voxel.json— Metadata (bounds, res
