storysplat-viewer
v2.9.35
Published
PlayCanvas-based 3D viewer for StorySplat scenes - HTML export & dynamic embedding
Maintainers
Readme
StorySplat Viewer
A powerful npm package for embedding and interacting with 3D Gaussian Splatting scenes in web applications. StorySplat Viewer provides a complete solution for displaying, navigating, and interacting with .splat, .ply, and .sog files with built-in support for waypoints, hotspots, portals, audio, particles, LOD streaming, 4DGS frame sequences, and multiple camera modes.
Table of Contents
- Installation
- Quick Start
- Demos & Guides
- Loading Scenes
- API Reference
- Controlling the Viewer
- Configuration Options
- Scene Data Format
- LOD Streaming
- 4DGS Frame Sequences
- Viewer Theme Customization
- Splat Relighting
- Internationalization (i18n)
- Events
- Portals (Scene-to-Scene Navigation)
- Audio Emitters
- HTML Meshes
- Voxel Collision
- Mirror Planes
- Measurements
- Post-Processing
- Custom Menu Links
- Entity Animations
- React Integration
- Native App Integration
- Analytics & Tracking
- Standalone HTML Generation
- Error Handling
- Exports
- Troubleshooting
- Support
Installation
npm install storysplat-viewerOr using yarn:
yarn add storysplat-viewerQuick Start
import { createViewerFromSceneId } from 'storysplat-viewer';
// Create a viewer from your StorySplat scene ID
const viewer = await createViewerFromSceneId(
document.getElementById('viewer'),
'YOUR_SCENE_ID'
);
// Control playback
viewer.play();
viewer.pause();
// Navigate between waypoints
viewer.nextWaypoint();
viewer.goToWaypoint(2);
// 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 all 5 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 all configuration options. |
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: Most use cases. Live content that updates when you edit in the StorySplat editor. Includes automatic view and bandwidth tracking.
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 and always reflects your latest changes. Views and bandwidth are automatically tracked for analytics.
Getting your Scene ID:
- Open your scene in the StorySplat editor
- Click "Upload" or "Update"
- Copy the Scene ID from the "Developer Export" section
Option 2: From JSON File (Self-Hosted)
Best for: Self-hosted scenes where you want full control over the scene data. No tracking.
import { createViewer } from 'storysplat-viewer';
import sceneConfig from './my-scene.json'; // Downloaded from editor
const viewer = createViewer(
document.getElementById('viewer'),
sceneConfig
);Downloading scene JSON:
- Open your scene in the StorySplat editor
- Click "Export" or "Upload"
- In the "Developer Export" section, click "Download Scene JSON"
- Save the file in your project
Note: When using
createViewerwith self-hosted scenes, views and bandwidth are not tracked. UsecreateViewerFromSceneIdfor StorySplat-hosted scenes to get analytics.
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
Options:
interface ViewerFromSceneIdOptions {
// API configuration
baseUrl?: string; // Default: 'https://discover.storysplat.com'
apiKey?: string; // For private scenes (future feature)
// Viewer options
autoPlay?: boolean;
showUI?: boolean;
backgroundColor?: string;
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
): ViewerInstanceNote:
createViewerdoes not track views or bandwidth. For StorySplat-hosted scenes, usecreateViewerFromSceneIdinstead.
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
The viewer instance returned by all create functions.
interface ViewerInstance {
// PlayCanvas app reference
app: any;
canvas: HTMLCanvasElement;
// Navigation
goToWaypoint: (index: number) => void;
nextWaypoint: () => void;
prevWaypoint: () => void;
getCurrentWaypointIndex: () => number;
getWaypointCount: () => number;
// Camera
setCameraMode: (mode: 'tour' | 'explore' | 'walk') => void;
getCameraMode: () => string;
setExploreMode: (mode: 'orbit' | 'fly') => void;
setPosition: (x: number, y: number, z: number) => void;
setRotation: (x: number, y: number, z: number) => void;
getPosition: () => { x: number; y: number; z: number };
getRotation: () => { x: number; y: number; z: number };
// Playback / Progress
play: () => void;
pause: () => void;
stop: () => void;
isPlaying: () => boolean;
setProgress: (progress: number) => void;
getProgress: () => number;
// Splat management
goToSplat: (url: string) => Promise<void>;
goToOriginalSplat: () => void;
getCurrentSplatUrl: () => string;
isShowingOriginalSplat: () => boolean;
getAdditionalSplats: () => Array<{ url: string; name?: string; waypointIndex: number; percentage: number }>;
// 4DGS Frame Sequence (only when frameSequence is configured)
isFrameSequencePlaying: () => boolean;
setFrame: (index: number) => void;
getCurrentFrame: () => number;
getTotalFrames: () => number;
setFps: (fps: number) => void;
getFps: () => number;
getFrameProgress: () => number;
setFrameProgress: (progress: number) => void;
// Audio
muteAll: () => void;
unmuteAll: () => void;
isMuted: () => boolean;
// Hotspots
getHotspots: () => Array<{ id: string; title?: string; type: string; position: { x: number; y: number; z: number } }>;
triggerHotspot: (id: string) => void;
closeHotspot: () => void;
// Portals
navigateToScene: (sceneId: string) => Promise<void>;
// Lifecycle
destroy: () => void;
resize: () => void;
// UI customization
setButtonLabels: (labels: Partial<ButtonLabels>) => void;
// Events
on: (event: ViewerEvent, callback: (...args: any[]) => void) => void;
off: (event: ViewerEvent, callback: (...args: any[]) => void) => void;
}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
<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.prevWaypoint();
document.getElementById('next').onclick = () => viewer.nextWaypoint();
// Playback
document.getElementById('play').onclick = () => 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) {
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' or 'fly' |
| viewer.setPosition(x, y, z) | Set camera position |
| viewer.setRotation(x, y, z) | Set camera rotation (Euler angles) |
| viewer.getPosition() | Get current camera position |
| viewer.getRotation() | Get current camera rotation |
| Playback / Progress | |
| viewer.play() | Start auto-playing through waypoints |
| viewer.pause() | Pause auto-play |
| 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 a different splat file at runtime |
| 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 |
| 4DGS Frame Sequence | |
| viewer.isFrameSequencePlaying() | Check if frame sequence is playing |
| viewer.setFrame(index) | Jump to specific frame |
| 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 popup |
| viewer.closeHotspot() | Close the currently open hotspot popup |
| 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
interface ViewerOptions {
// Template style
template?: 'standard' | 'minimal' | 'pro';
// Playback
autoPlay?: boolean;
// UI
showUI?: boolean;
backgroundColor?: string;
// 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;
};
}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 = 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 viewer's transform layer normalizes all field variations automatically, so JSON exported from any version of the editor will work.
Core Fields
| Field | Type | Description |
|-------|------|-------------|
| loadedModelUrl | string | URL to the .splat, .ply, or .ksplat file |
| sogModelUrl / sogUrl | string | SOG compressed format URL (~95% smaller) |
| compressedPlyUrl | string | Compressed PLY format URL (~50% smaller) |
| lodMetaUrl | string | LOD streaming meta file URL (see LOD Streaming) |
| waypoints | array | Camera path waypoints |
| hotspots | array | Interactive hotspot markers |
| portals | array | Scene-to-scene navigation portals |
| audioEmitters | array | Spatial audio sources (see Audio Emitters) |
| htmlMeshes | array | HTML-in-3D texture 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 along the waypoint path |
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) |
| lodSettings | object | LOD display settings (see LOD Streaming) |
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) |
| revealStyle | string | Reveal animation: 'bloom' or 'radial' |
| swapTransitionType | string | Splat swap transition: 'scanline' or 'dissolve' (default: 'dissolve') |
Model URL Priority
The viewer loads model files in this priority order:
lodMetaUrl— LOD streaming (best quality, progressive loading)sogModelUrl/sogUrl— SOG compressed (~95% compression)compressedPlyUrl— Compressed PLY (~50% compression)loadedModelUrl— Original uploadsplatUrl— Legacy fallback
LOD Streaming
LOD (Level-of-Detail) streaming progressively loads splat data in chunks, starting with low-detail and refining to full quality. This dramatically improves initial load times for large scenes.
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'):
lodDistances: [10, 20, 40, 80, 160], // 5 distance thresholds
lodRangeMin: 0, // Min LOD range (0-5)
lodRangeMax: 5, // Max LOD range (0-5)
// Per-device overrides when preset is 'auto':
mobilePreset: 'mobile', // Preset for mobile devices
desktopPreset: 'desktop', // Preset for desktop devices
}
};| Preset | Description |
|--------|-------------|
| auto | Automatically selects based on device type |
| desktop-max | Maximum quality for desktop |
| desktop | Balanced quality for desktop |
| mobile-max | Maximum quality for mobile |
| mobile | Balanced quality for mobile (recommended for mobile) |
| custom | Use custom lodDistances and lodRange values |
4DGS Frame Sequences
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 = 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 CSS customization via the ViewerTheme interface. You can override colors, font sizes, border radii, and more — 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',
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 = createViewer(container, sceneData);Available Theme Properties
| Category | Properties |
|----------|-----------|
| Colors (20) | globalTextColor, buttonBg, buttonHoverBg, buttonTextColor, popupBg, popupTextColor, popupCloseBtnColor, popupLinkBtnColor, preloaderBg, preloaderTextColor, infoBannerBg, dropdownBg, watermarkBg, helpPanelBg, errorPopupBg, errorTitleColor, lazyLoadBg, joystickBaseColor, joystickThumbColor, portalPopupBg |
| Typography (9) | fontFamily, buttonFontSize, popupTitleFontSize, popupContentFontSize, infoBannerTitleFontSize, infoBannerContentFontSize, progressFontSize, modeBtnFontSize, watermarkFontSize |
| Border Radii (6) | buttonBorderRadius, popupBorderRadius, dropdownBorderRadius, helpPanelBorderRadius, errorPopupBorderRadius, lazyLoadBtnBorderRadius |
| Other (3) | dropdownBlur, progressBarHeight, preloaderBarHeight |
All properties are optional — unspecified values fall back to built-in defaults.
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)
All user-facing UI text can be customized via ButtonLabels. This enables full translation or label renaming.
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 = 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
| 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 |
Events
// Viewer is ready
viewer.on('ready', () => {
console.log('Viewer initialized');
});
// Scene loaded
viewer.on('loaded', () => {
console.log('Scene fully loaded');
});
// Loading progress
viewer.on('progress', ({ loaded, total, percent }) => {
console.log(`Loading: ${percent}%`);
});
// 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');
});
// Portal activated
viewer.on('portalActivated', ({ sceneId }) => {
console.log(`Navigating to scene: ${sceneId}`);
});
// Errors and warnings
viewer.on('error', (error) => {
console.error('Viewer error:', error);
});
viewer.on('warning', ({ type, message }) => {
console.warn(`Warning [${type}]: ${message}`);
});All Events Reference
| Event | Data | Description |
|-------|------|-------------|
| ready | — | Viewer initialized |
| loaded | — | Scene fully loaded |
| progress | { loaded, total, percent } | Loading progress |
| waypointChange | { index, waypoint } | 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 |
| 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 |
| portalNavigationStart | { targetSceneId } | Portal navigation beginning |
| portalNavigationComplete | { sceneId } | Portal navigation complete |
| portalNavigationError | { error, targetSceneId } | Portal navigation failed |
| 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,
"menuPath": "Building A/Floor 1"
}
]
}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 mesh shape ('sphere', 'cube', etc.) |
| 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 |
| menuPath | string | Folder path for menu organization (e.g., "Building A/Floor 1") |
Self-Hosted Portal Navigation
By default, portals use StorySplat scene IDs and fetch from discover.storysplat.com. For fully self-hosted portals, intercept the portalActivated event:
import { createViewer } from 'storysplat-viewer';
const SCENES = {
'campervan': '/scenes/campervan/scene.json',
'cabin': '/scenes/cabin/scene.json',
};
let currentViewer = null;
async function loadScene(sceneId) {
if (currentViewer) currentViewer.destroy();
const sceneData = await fetch(SCENES[sceneId]).then(r => r.json());
currentViewer = await createViewer(container, sceneData);
currentViewer.on('portalActivated', (data) => {
loadScene(data.targetSceneId);
});
}
loadScene('campervan');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 } |
| portalNavigationStart | Navigation beginning | { targetSceneId } |
| portalNavigationComplete | New scene loaded | { sceneId } |
| portalNavigationError | Navigation failed | { error, targetSceneId } |
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 | Maximum hearing distance |
| 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
HTML Meshes render HTML content as textures on 3D planes in the scene. They support text, images, iframes, CSS styling, and interactive elements.
{
"htmlMeshes": [
{
"id": "info-panel",
"name": "Info Panel",
"htmlContent": "<h2>Welcome</h2><p>This is a 3D info panel</p>",
"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,
"cssStyles": "background: rgba(0,0,0,0.8); color: white; padding: 20px; font-family: sans-serif;",
"visible": true
}
]
}| Field | Type | Description |
|-------|------|-------------|
| htmlContent | string | HTML to render as texture |
| position / rotation / scale | {x,y,z} | 3D transform |
| width / height | number | Texture resolution in pixels |
| billboard | boolean | Always face the camera |
| cssStyles | string | CSS to apply to the HTML content |
| visible | boolean | Whether the mesh is visible |
Voxel Collision
Voxel collision provides high-fidelity collision detection derived directly from splat geometry. Instead of placing primitive collision shapes manually, the system voxelizes the splat into an octree structure for accurate ground detection and movement blocking in walk mode.
Scene Data
{
"voxelCollisionUrl": "https://example.com/scene.voxel.json"
}The voxel collision system requires two co-located files:
.voxel.json— Metadata (bounds, resolution, leaf count).voxel.bin— Binary octree data (nodes + leaf data)
Both primitive collision meshes and voxel collision coexist — primitives are checked first, then the octree.
Mirror Planes
Mirror planes add reflective surfaces to scenes, useful for replacing broken mirrors in Gaussian Splat scans with real-time reflections.
Scene Data
{
"mirrors": [
{
"id": "mirror-1",
"position": { "x": 0, "y": 1.5, "z": -3 },
"rotation": { "x": 0, "y": 0, "z": 0 },
"scale": { "x": 2, "y": 2, "z": 1 },
"resolution": 0.5,
"intensity": 0.8,
"tint": "#ffffff"
}
]
}| Property | Type | Description |
|----------|------|-------------|
| position | Vector3 | World position of the mirror |
| rotation | Vector3 | Euler rotation in radians |
| scale | Vector3 | Dimensions of the mirror plane |
| resolution | number | Reflection quality (0.25–1.0, lower = better performance) |
| intensity | number | Reflection strength (0 = invisible, 1 = full) |
| tint | string | Hex color overlay applied to reflections |
Up to 3 mirrors are supported per scene.
Measurements
The measurement system allows viewers to measure real-world distances in 3D scenes using a calibrated scale.
Scene Data
{
"measurement": {
"enabled": true,
"scaleRatio": 0.0254,
"unit": "meters",
"color": "#00ff00",
"measurements": [
{
"id": "m1",
"start": { "x": 0, "y": 0, "z": 0 },
"end": { "x": 1, "y": 0, "z": 0 },
"label": "Wall width"
}
]
}
}| Property | Type | Description |
|----------|------|-------------|
| enabled | boolean | Whether measurement mode is available |
| scaleRatio | number | Scene units to real-world units multiplier |
| unit | string | Display unit: "meters", "centimeters", "feet", or "inches" |
| color | string | Hex color for measurement visualization |
| measurements | array | Pre-defined measurements with start/end points and labels |
Post-Processing
Post-processing applies real-time color grading adjustments in the viewer.
Scene Data
{
"postProcessing": {
"colorEnhance": {
"enabled": true,
"shadows": 0.5,
"highlights": -0.3,
"vibrance": 0.2,
"midtones": 0.1,
"dehaze": 0.15
}
}
}| Property | Type | Range | Description |
|----------|------|-------|-------------|
| enabled | boolean | — | Master toggle for color enhancement |
| shadows | number | -3 to +3 | Brighten or darken dark areas |
| highlights | number | -3 to +3 | Adjust bright areas |
| vibrance | number | -1 to +1 | Increase or decrease color saturation |
| midtones | number | -1 to +1 | Adjust mid-range tones |
| dehaze | number | -1 to +1 | Remove or add atmospheric haze |
Custom Menu Links
Add external links to the viewer's navigation menu alongside portals.
Scene Data
{
"menuLinks": [
{
"id": "link-1",
"label": "Book Now",
"url": "https://example.com/book",
"icon": "calendar",
"menuPath": "Resources",
"openInNewTab": true
}
]
}| Property | Type | Description |
|----------|------|-------------|
| label | string | Display text for the link |
| url | string | Target URL (http/https) |
| icon | string | Icon type: "link", "map", "calendar", "phone", "cart", "globe", "download" |
| menuPath | string | Folder path for menu organization (e.g., "Resources/Help") |
| openInNewTab | boolean | Whether to open in a new browser tab (default: true) |
Menu links share the folder system with portals, so links and portals can be organized in the same folder hierarchy.
Entity Animations
Keyframe-based animation system for any scene entity (hotspots, portals, lights, particles, meshes, mirrors).
Scene Data
{
"entityAnimations": [
{
"entityId": "hotspot-1",
"entityType": "hotspot",
"timelineMode": "independent",
"duration": 3,
"playbackMode": "loop",
"autoplay": true,
"delay": 0,
"tracks": [
{
"property": "position.y",
"keyframes": [
{ "time": 0, "value": 1, "easing": "ease-in-out" },
{ "time": 1, "value": 2, "easing": "ease-in-out" },
{ "time": 2, "value": 1, "easing": "linear" }
]
}
]
}
]
}Timeline Modes
| Mode | Description |
|------|-------------|
| independent | Time-based playback with configurable duration, delay, and loop mode |
| scroll | Animation driven by camera tour scroll progress (0–100%) |
Playback Modes (independent only)
| Mode | Description |
|------|-------------|
| once | Play once and stop |
| loop | Restart from beginning when complete |
| pingpong | Reverse direction at each end |
Easing Functions
linear, ease-in, ease-out, ease-in-out
Animatable Properties
| Category | Properties |
|----------|------------|
| Position | position.x, position.y, position.z |
| Rotation | rotation.x, rotation.y, rotation.z |
| Scale | scale.x, scale.y, scale.z |
| Particles | rate, lifetime, speed, scale, gravity, opacity, rotationSpeed |
React Integration
import { useEffect, useRef } from 'react';
import { createViewerFromSceneId } from 'storysplat-viewer';
function StorySplatViewer({ sceneId }) {
const containerRef = useRef(null);
const viewerRef = useRef(null);
useEffect(() => {
let mounted = true;
async function initViewer() {
if (!containerRef.current) return;
try {
const viewer = await createViewerFromSceneId(
containerRef.current,
sceneId
);
if (mounted) {
viewerRef.current = viewer;
} else {
viewer.destroy();
}
} catch (error) {
console.error('Failed to create viewer:', error);
}
}
initViewer();
return () => {
mounted = false;
viewerRef.current?.destroy();
};
}, [sceneId]);
return (
<div
ref={containerRef}
style={{ width: '100%', height: '500px' }}
/>
);
}
export default StorySplatViewer;Native App Integration
StorySplat Viewer can be embedded in native mobile apps using WebView components.
React Native
import { WebView } from 'react-native-webview';
function StorySplatViewer({ sceneId }) {
const html = `
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<style>
* { margin: 0; padding: 0; }
html, body, #app { width: 100%; height: 100%; }
</style>
</head>
<body>
<div id="app"></div>
<script src="https://cdn.jsdelivr.net/npm/storysplat-viewer@2/dist/storysplat-viewer.bundled.umd.js"></script>
<script>
StorySplatViewer.createViewerFromSceneId(
document.getElementById('app'),
'${sceneId}'
);
</script>
</body>
</html>
`;
return (
<WebView
source={{ html }}
style={{ flex: 1 }}
allowsInlineMediaPlayback
mediaPlaybackRequiresUserAction={false}
/>
);
}Flutter
import 'package:webview_flutter/webview_flutter.dart';
class StorySplatViewer extends StatefulWidget {
final String sceneId;
const StorySplatViewer({required this.sceneId});
@override
_StorySplatViewerState createState() => _StorySplatViewerState();
}
class _StorySplatViewerState extends State<StorySplatViewer> {
late final WebViewController controller;
@override
void initState() {
super.initState();
controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..loadHtmlString(_getHtml());
}
String _getHtml() {
return '''
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>* { margin: 0; } html, body, #app { width: 100%; height: 100%; }</style>
</head>
<body>
<div id="app"></div>
<script src="https://cdn.jsdelivr.net/npm/storysplat-viewer@2/dist/storysplat-viewer.bundled.umd.js"></script>
<script>
StorySplatViewer.createViewerFromSceneId(
document.getElementById('app'),
'${widget.sceneId}'
);
</script>
</body>
</html>
''';
}
@override
Widget build(BuildContext context) {
return WebViewWidget(controller: controller);
}
}iframe Embedding
For simple web integration, use an iframe:
<iframe
src="https://discover.storysplat.com/api/v2-html/YOUR_SCENE_ID"
width="100%"
height="500"
frameborder="0"
allow="accelerometer; gyroscope; xr-spatial-tracking"
allowfullscreen>
</iframe>CDN Options
The viewer is available on multiple CDNs. We recommend the bundled build for simplicity — it includes PlayCanvas, so you only need one script:
<!-- RECOMMENDED: Bundled build (includes PlayCanvas - single script!) -->
<script src="https://cdn.jsdelivr.net/npm/storysplat-viewer@2/dist/storysplat-viewer.bundled.umd.js"></script>
<!-- Alternative: Separate scripts (if you need a specific PlayCanvas version) -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/playcanvas.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/storysplat-viewer@2/dist/storysplat-viewer.umd.js"></script>
<!-- unpkg alternative -->
<script src="https://unpkg.com/storysplat-viewer@2/dist/storysplat-viewer.bundled.umd.js"></script>Self-Hosting the Bundle
npm pack storysplat-viewer
# Extract storysplat-viewer-x.x.x.tgz
# Copy dist/storysplat-viewer.bundled.umd.js to your server<script src="/path/to/storysplat-viewer.bundled.umd.js"></script>Analytics & Tracking
When using createViewerFromSceneId, the viewer automatically tracks:
- Views: Each time a scene is loaded
- Bandwidth: Data transferred when loading StorySplat-hosted files
This data appears in your StorySplat admin dashboard.
What Gets Tracked
| Scenario | Views | Bandwidth |
|----------|-------|-----------|
| createViewerFromSceneId with StorySplat-hosted files | Yes | Yes |
| createViewerFromSceneId with self-hosted splat files | Yes | No |
| createViewer (self-hosted scenes) | No | No |
| iframe embed | Yes | Yes |
Privacy Note
Tracking only occurs for scenes loaded via createViewerFromSceneId. If you use createViewer with your own scene data, no data is sent to StorySplat servers.
Comparison: Scene ID vs JSON File
| Approach | Best For | Pros | Cons | |----------|----------|------|------| | Scene ID | Live content, CMS | Always latest, automatic tracking, no redeploy | Needs internet, API dependency | | JSON File | Self-hosted, offline | Full control, git-tracked, no external dependencies | Manual updates, no analytics |
Standalone HTML Generation
Generate standalone HTML files programmatically:
import { generateHTML, generateHTMLFromUrl } from 'storysplat-viewer';
// From scene data object
const html = generateHTML(sceneData, {
title: 'My Scene',
description: 'An interactive 3D experience',
});
// From scene JSON URL
const html = await generateHTMLFromUrl(
'https://example.com/scene.json',
{ title: 'My Scene' }
);
// Save to file (Node.js)
import fs from 'fs';
fs.writeFileSync('scene.html', html);GenerateHTMLOptions
interface GenerateHTMLOptions {
cdnUrl?: string; // Custom CDN URL for the viewer bundle (default: unpkg)
title?: string; // HTML page title (default: scene name)
description?: string; // Meta description for SEO
faviconUrl?: string; // Favicon URL
customCSS?: string; // Custom CSS to inject into the page
minify?: boolean; // Minify the output HTML
lazyLoad?: boolean; // Show thumbnail + start button before loading
lazyLoadButtonText?: string; // Custom text for the lazy load button
}Error Handling
The package exports error classes for specific error handling:
import {
createViewerFromSceneId,
SceneNotFoundError,
SceneApiError
} from 'storysplat-viewer';
try {
const viewer = await createViewerFromSceneId(container, sceneId);
} catch (error) {
if (error instanceof SceneNotFoundError) {
console.error('Scene not found or not public');
} else if (error instanceof SceneApiError) {
console.error(`API error (${error.statusCode}): ${error.message}`);
} else {
console.error('Unknown error:', error);
}
}Exports
// Core viewer functions
import {
createViewer,
createViewerFromSceneId,
createViewerFromUrl,
fetchSceneMeta,
generateHTML,
generateHTMLFromUrl,
} from 'storysplat-viewer';
// Types
import type {
ViewerInstance,
ViewerOptions,
SceneData,
ButtonLabels,
ViewerTheme,
SplatRelightingConfig,
RevealPreset,
RevealPresetConfig,
} from 'storysplat-viewer';
// Utilities
import {
DEFAULT_BUTTON_LABELS,
generateViewerStyles,
REVEAL_PRESETS,
getRevealPreset,
GsplatRelighting,
getGsplatRelightingClass,
SceneNotFoundError,
SceneApiError,
BUILD_VERSION,
} from 'storysplat-viewer';Troubleshooting
Container Needs Dimensions
The container element must have explicit dimensions:
#viewer {
width: 100%;
height: 500px; /* Must have explicit height */
}CORS Issues
If loading splats from a different domain, ensure the server has proper CORS headers or use a proxy.
Memory Cleanup
Always destroy the viewer when unmounting:
// Vanilla JS
window.addEventListener('beforeunload', () => {
viewer.destroy();
});
// React useEffect cleanup
return () => {
viewer?.destroy();
};Build Target Must Be ES2016+
If you bundle storysplat-viewer with Vite, esbuild, or another bundler, your build target must be es2016 or higher (we recommend es2020).
PlayCanvas (the 3D engine) serializes its gsplat sort worker via Function.toString() into a blob URL. If your bundler targets es2015, it will extract the ** (exponentiation) operator into a module-scoped helper function. That helper doesn't exist inside the worker's isolated scope, causing a ReferenceError at runtime (e.g., ss is not defined).
// vite.config.ts
export default {
build: {
target: 'es2020', // Must be es2016+ (NOT es2015)
}
};This only affects production builds — development servers typically don't minify and won't trigger the issue.
Browser Compatibility
StorySplat Viewer requires:
- WebGL 2.0 support
- Modern JavaScript (ES2016+)
- Recommended browsers: Chrome 80+, Firefox 75+, Safari 13+, Edge 80+
Support
- GitHub Issues: github.com/SonnyC56/storysplat-viewer/issues
- Documentation: discover.storysplat.com/docs
License
MIT License - see LICENSE file for details
