npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

storysplat-viewer

v2.10.17

Published

PlayCanvas-based 3D viewer for StorySplat scenes - HTML export & dynamic embedding

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

npm install storysplat-viewer playcanvas

Or using yarn:

yarn add storysplat-viewer playcanvas

Quick 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:

  1. Open your scene in the StorySplat editor
  2. Click "Upload" or "Update"
  3. 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:

  1. Open your scene in the StorySplat editor
  2. Click "Export" or "Upload"
  3. In the "Developer Integration" section, click "Download Scene JSON"
  4. Save the file in your project

Note: createViewer can report analytics when configured. Use disableAnalytics: true to 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 into
  • sceneId - Your StorySplat scene ID
  • options - 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: createViewer returns a promise for the instance. Asset loading continues separately and can report error events; 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 (activationMode click, none or unset) or when the pointer enters a hover hotspot; models on click, or on hover-in when interaction.activationMode is hover. 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. playVideo unmutes 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, scroll or autoplay trigger 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 (then pauseOnLeaveProximity applies); 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:

  1. lodMetaUrl — LOD streaming, when enabled and a supported LOD set is supplied
  2. sogModelUrl / sogUrl — SOG compressed
  3. compressedPlyUrl — Compressed PLY
  4. loadedModelUrl — Original upload
  5. splatUrl — 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 at pose. When frame.progress is 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. Leave progress out 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 / fps per 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 progress

4DGS 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