storysplat-viewer 2.9.14 → 2.9.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +640 -447
- package/dist/index.esm.js +1 -1
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/storysplat-viewer.bundled.umd.js +1 -1
- package/dist/storysplat-viewer.bundled.umd.js.map +1 -1
- package/dist/storysplat-viewer.umd.js +1 -1
- package/dist/storysplat-viewer.umd.js.map +1 -1
- package/dist/types/transformers/sceneToConfig.d.ts +2 -0
- package/dist/types/types/index.d.ts +11 -2
- package/package.json +1 -1
- package/docs/USAGE_GUIDE.md +0 -1509
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# StorySplat Viewer
|
|
2
2
|
|
|
3
|
-
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, and multiple camera modes.
|
|
3
|
+
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.
|
|
4
4
|
|
|
5
5
|
## Table of Contents
|
|
6
6
|
|
|
@@ -13,13 +13,25 @@ A powerful npm package for embedding and interacting with 3D Gaussian Splatting
|
|
|
13
13
|
- [API Reference](#api-reference)
|
|
14
14
|
- [Controlling the Viewer](#controlling-the-viewer)
|
|
15
15
|
- [Configuration Options](#configuration-options)
|
|
16
|
+
- [Scene Data Format](#scene-data-format)
|
|
17
|
+
- [LOD Streaming](#lod-streaming)
|
|
18
|
+
- [4DGS Frame Sequences](#4dgs-frame-sequences)
|
|
16
19
|
- [Viewer Theme Customization](#viewer-theme-customization)
|
|
17
20
|
- [Splat Relighting](#splat-relighting)
|
|
18
21
|
- [Internationalization (i18n)](#internationalization-i18n)
|
|
19
22
|
- [Events](#events)
|
|
23
|
+
- [Portals (Scene-to-Scene Navigation)](#portals-scene-to-scene-navigation)
|
|
24
|
+
- [Audio Emitters](#audio-emitters)
|
|
25
|
+
- [HTML Meshes](#html-meshes)
|
|
26
|
+
- [Voxel Collision](#voxel-collision)
|
|
20
27
|
- [React Integration](#react-integration)
|
|
28
|
+
- [Native App Integration](#native-app-integration)
|
|
21
29
|
- [Analytics & Tracking](#analytics--tracking)
|
|
30
|
+
- [Standalone HTML Generation](#standalone-html-generation)
|
|
31
|
+
- [Error Handling](#error-handling)
|
|
32
|
+
- [Exports](#exports)
|
|
22
33
|
- [Troubleshooting](#troubleshooting)
|
|
34
|
+
- [Support](#support)
|
|
23
35
|
|
|
24
36
|
## Installation
|
|
25
37
|
|
|
@@ -114,31 +126,6 @@ const viewer = createViewer(
|
|
|
114
126
|
|
|
115
127
|
> **Note:** When using `createViewer` with self-hosted scenes, views and bandwidth are not tracked. Use `createViewerFromSceneId` for StorySplat-hosted scenes to get analytics.
|
|
116
128
|
|
|
117
|
-
### Scene Data Format
|
|
118
|
-
|
|
119
|
-
When using `createViewer()` with self-hosted JSON, the scene data should match the format exported by the StorySplat editor. Key fields:
|
|
120
|
-
|
|
121
|
-
| Field | Type | Description |
|
|
122
|
-
|-------|------|-------------|
|
|
123
|
-
| `loadedModelUrl` | `string` | URL to the .splat, .ply, or .ksplat file |
|
|
124
|
-
| `waypoints` | `array` | Camera path waypoints |
|
|
125
|
-
| `hotspots` | `array` | Interactive hotspot markers |
|
|
126
|
-
| `portals` | `array` | Scene-to-scene navigation portals |
|
|
127
|
-
| `activeSkyboxUrl` | `string` | URL to the skybox HDR/image file |
|
|
128
|
-
| `skyboxRotation` | `number` | Skybox rotation offset |
|
|
129
|
-
| `lights` | `array` | Scene lighting configuration |
|
|
130
|
-
| `particleSystems` | `array` | Particle effect systems |
|
|
131
|
-
| `customMeshes` | `array` | Imported 3D models |
|
|
132
|
-
| `uiColor` | `string` | Accent color for UI controls |
|
|
133
|
-
| `uiOptions` | `object` | UI visibility toggles and button labels |
|
|
134
|
-
| `uiOptions.viewerTheme` | `ViewerTheme` | Custom viewer theme overrides (colors, fonts, radii) |
|
|
135
|
-
| `defaultCameraMode` | `string` | Initial camera mode: `'tour'`, `'explore'`, or `'walk'` |
|
|
136
|
-
| `revealStyle` | `string` | Reveal animation style: `'bloom'` or `'radial'` |
|
|
137
|
-
| `doubleTapMoveSpeed` | `number` | Auto-forward speed on double-tap in explore mode (default: 1.0) |
|
|
138
|
-
| `splatRelighting` | `object` | Splat relighting config (enabled, ambientColor, ambientIntensity, allowViewerToggle, viewerDefaultOn) |
|
|
139
|
-
|
|
140
|
-
The viewer's transform layer normalizes all field variations automatically, so JSON exported from any version of the editor will work.
|
|
141
|
-
|
|
142
129
|
## API Reference
|
|
143
130
|
|
|
144
131
|
### createViewerFromSceneId
|
|
@@ -230,7 +217,7 @@ interface ViewerInstance {
|
|
|
230
217
|
getWaypointCount: () => number;
|
|
231
218
|
|
|
232
219
|
// Camera
|
|
233
|
-
setCameraMode: (mode: 'tour' | 'explore') => void;
|
|
220
|
+
setCameraMode: (mode: 'tour' | 'explore' | 'walk') => void;
|
|
234
221
|
getCameraMode: () => string;
|
|
235
222
|
setExploreMode: (mode: 'orbit' | 'fly') => void;
|
|
236
223
|
setPosition: (x: number, y: number, z: number) => void;
|
|
@@ -253,6 +240,16 @@ interface ViewerInstance {
|
|
|
253
240
|
isShowingOriginalSplat: () => boolean;
|
|
254
241
|
getAdditionalSplats: () => Array<{ url: string; name?: string; waypointIndex: number; percentage: number }>;
|
|
255
242
|
|
|
243
|
+
// 4DGS Frame Sequence (only when frameSequence is configured)
|
|
244
|
+
isFrameSequencePlaying: () => boolean;
|
|
245
|
+
setFrame: (index: number) => void;
|
|
246
|
+
getCurrentFrame: () => number;
|
|
247
|
+
getTotalFrames: () => number;
|
|
248
|
+
setFps: (fps: number) => void;
|
|
249
|
+
getFps: () => number;
|
|
250
|
+
getFrameProgress: () => number;
|
|
251
|
+
setFrameProgress: (progress: number) => void;
|
|
252
|
+
|
|
256
253
|
// Audio
|
|
257
254
|
muteAll: () => void;
|
|
258
255
|
unmuteAll: () => void;
|
|
@@ -279,6 +276,112 @@ interface ViewerInstance {
|
|
|
279
276
|
}
|
|
280
277
|
```
|
|
281
278
|
|
|
279
|
+
## Controlling the Viewer
|
|
280
|
+
|
|
281
|
+
The viewer instance provides methods to control playback, navigation, and camera programmatically. This is useful for building custom UI controls outside the viewer.
|
|
282
|
+
|
|
283
|
+
### External Control Example
|
|
284
|
+
|
|
285
|
+
```html
|
|
286
|
+
<div id="viewer" style="width: 100%; height: 500px;"></div>
|
|
287
|
+
|
|
288
|
+
<div id="controls">
|
|
289
|
+
<button id="prev">Previous</button>
|
|
290
|
+
<button id="play">Play</button>
|
|
291
|
+
<button id="pause">Pause</button>
|
|
292
|
+
<button id="next">Next</button>
|
|
293
|
+
<span id="waypoint-info"></span>
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
<script type="module">
|
|
297
|
+
import { createViewerFromSceneId } from 'storysplat-viewer';
|
|
298
|
+
|
|
299
|
+
const viewer = await createViewerFromSceneId(
|
|
300
|
+
document.getElementById('viewer'),
|
|
301
|
+
'YOUR_SCENE_ID'
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
// Navigation
|
|
305
|
+
document.getElementById('prev').onclick = () => viewer.prevWaypoint();
|
|
306
|
+
document.getElementById('next').onclick = () => viewer.nextWaypoint();
|
|
307
|
+
|
|
308
|
+
// Playback
|
|
309
|
+
document.getElementById('play').onclick = () => viewer.play();
|
|
310
|
+
document.getElementById('pause').onclick = () => viewer.pause();
|
|
311
|
+
|
|
312
|
+
// Update waypoint display
|
|
313
|
+
viewer.on('waypointChange', ({ index }) => {
|
|
314
|
+
const total = viewer.getWaypointCount();
|
|
315
|
+
document.getElementById('waypoint-info').textContent =
|
|
316
|
+
`Waypoint ${index + 1} of ${total}`;
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// Jump to specific waypoint
|
|
320
|
+
function goToWaypoint(index) {
|
|
321
|
+
viewer.goToWaypoint(index);
|
|
322
|
+
}
|
|
323
|
+
</script>
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
### Available Control Methods
|
|
327
|
+
|
|
328
|
+
| Method | Description |
|
|
329
|
+
|--------|-------------|
|
|
330
|
+
| **Navigation** | |
|
|
331
|
+
| `viewer.goToWaypoint(index)` | Jump to specific waypoint (0-indexed) |
|
|
332
|
+
| `viewer.nextWaypoint()` | Go to next waypoint |
|
|
333
|
+
| `viewer.prevWaypoint()` | Go to previous waypoint |
|
|
334
|
+
| `viewer.getCurrentWaypointIndex()` | Get current waypoint index |
|
|
335
|
+
| `viewer.getWaypointCount()` | Get total number of waypoints |
|
|
336
|
+
| **Camera** | |
|
|
337
|
+
| `viewer.setCameraMode(mode)` | Switch mode: `'tour'`, `'explore'`, or `'walk'` |
|
|
338
|
+
| `viewer.getCameraMode()` | Get current camera mode |
|
|
339
|
+
| `viewer.setExploreMode(mode)` | Switch explore sub-mode: `'orbit'` or `'fly'` |
|
|
340
|
+
| `viewer.setPosition(x, y, z)` | Set camera position |
|
|
341
|
+
| `viewer.setRotation(x, y, z)` | Set camera rotation (Euler angles) |
|
|
342
|
+
| `viewer.getPosition()` | Get current camera position |
|
|
343
|
+
| `viewer.getRotation()` | Get current camera rotation |
|
|
344
|
+
| **Playback / Progress** | |
|
|
345
|
+
| `viewer.play()` | Start auto-playing through waypoints |
|
|
346
|
+
| `viewer.pause()` | Pause auto-play |
|
|
347
|
+
| `viewer.stop()` | Stop and reset to first waypoint |
|
|
348
|
+
| `viewer.isPlaying()` | Check if currently auto-playing |
|
|
349
|
+
| `viewer.setProgress(progress)` | Set scroll progress (0-1) |
|
|
350
|
+
| `viewer.getProgress()` | Get current scroll progress (0-1) |
|
|
351
|
+
| **Splat Management** | |
|
|
352
|
+
| `viewer.goToSplat(url)` | Swap to a different splat file at runtime |
|
|
353
|
+
| `viewer.goToOriginalSplat()` | Switch back to the original splat |
|
|
354
|
+
| `viewer.getCurrentSplatUrl()` | Get the currently loaded splat URL |
|
|
355
|
+
| `viewer.isShowingOriginalSplat()` | Check if showing the original splat |
|
|
356
|
+
| `viewer.getAdditionalSplats()` | Get the list of additional splat swap points |
|
|
357
|
+
| **4DGS Frame Sequence** | |
|
|
358
|
+
| `viewer.isFrameSequencePlaying()` | Check if frame sequence is playing |
|
|
359
|
+
| `viewer.setFrame(index)` | Jump to specific frame |
|
|
360
|
+
| `viewer.getCurrentFrame()` | Get current frame index |
|
|
361
|
+
| `viewer.getTotalFrames()` | Get total frame count |
|
|
362
|
+
| `viewer.setFps(fps)` | Set playback FPS |
|
|
363
|
+
| `viewer.getFps()` | Get current FPS |
|
|
364
|
+
| `viewer.getFrameProgress()` | Get frame progress (0-1) |
|
|
365
|
+
| `viewer.setFrameProgress(progress)` | Set frame progress (0-1) |
|
|
366
|
+
| **Audio** | |
|
|
367
|
+
| `viewer.muteAll()` | Mute all audio |
|
|
368
|
+
| `viewer.unmuteAll()` | Unmute all audio |
|
|
369
|
+
| `viewer.isMuted()` | Check if audio is muted |
|
|
370
|
+
| **Hotspots** | |
|
|
371
|
+
| `viewer.getHotspots()` | Get all hotspot data (id, title, type, position) |
|
|
372
|
+
| `viewer.triggerHotspot(id)` | Programmatically open a hotspot popup |
|
|
373
|
+
| `viewer.closeHotspot()` | Close the currently open hotspot popup |
|
|
374
|
+
| **Portals** | |
|
|
375
|
+
| `viewer.navigateToScene(sceneId)` | Navigate to a linked scene via portal |
|
|
376
|
+
| **Lifecycle** | |
|
|
377
|
+
| `viewer.resize()` | Recalculate canvas size (call after container resize) |
|
|
378
|
+
| `viewer.destroy()` | Clean up and remove viewer |
|
|
379
|
+
| **UI** | |
|
|
380
|
+
| `viewer.setButtonLabels(labels)` | Update UI text labels at runtime (see [i18n](#internationalization-i18n)) |
|
|
381
|
+
| **Events** | |
|
|
382
|
+
| `viewer.on(event, callback)` | Register event listener |
|
|
383
|
+
| `viewer.off(event, callback)` | Remove event listener |
|
|
384
|
+
|
|
282
385
|
## Configuration Options
|
|
283
386
|
|
|
284
387
|
### ViewerOptions
|
|
@@ -304,6 +407,16 @@ interface ViewerOptions {
|
|
|
304
407
|
lazyLoadThumbnail?: string;
|
|
305
408
|
lazyLoadThumbnailType?: 'image' | 'video' | 'gif'; // Thumbnail media type
|
|
306
409
|
lazyLoadButtonText?: string;
|
|
410
|
+
|
|
411
|
+
// Allow parent page CSS to affect the viewer
|
|
412
|
+
allowParentStyles?: boolean; // Default: false
|
|
413
|
+
|
|
414
|
+
// Manual analytics (alternative to createViewerFromSceneId auto-tracking)
|
|
415
|
+
analytics?: {
|
|
416
|
+
sceneId: string;
|
|
417
|
+
ownerId: string;
|
|
418
|
+
baseUrl?: string;
|
|
419
|
+
};
|
|
307
420
|
}
|
|
308
421
|
```
|
|
309
422
|
|
|
@@ -330,6 +443,166 @@ const viewer = createViewer(container, sceneData, {
|
|
|
330
443
|
});
|
|
331
444
|
```
|
|
332
445
|
|
|
446
|
+
## Scene Data Format
|
|
447
|
+
|
|
448
|
+
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.
|
|
449
|
+
|
|
450
|
+
### Core Fields
|
|
451
|
+
|
|
452
|
+
| Field | Type | Description |
|
|
453
|
+
|-------|------|-------------|
|
|
454
|
+
| `loadedModelUrl` | `string` | URL to the .splat, .ply, or .ksplat file |
|
|
455
|
+
| `sogModelUrl` / `sogUrl` | `string` | SOG compressed format URL (~95% smaller) |
|
|
456
|
+
| `compressedPlyUrl` | `string` | Compressed PLY format URL (~50% smaller) |
|
|
457
|
+
| `lodMetaUrl` | `string` | LOD streaming meta file URL (see [LOD Streaming](#lod-streaming)) |
|
|
458
|
+
| `waypoints` | `array` | Camera path waypoints |
|
|
459
|
+
| `hotspots` | `array` | Interactive hotspot markers |
|
|
460
|
+
| `portals` | `array` | Scene-to-scene navigation portals |
|
|
461
|
+
| `audioEmitters` | `array` | Spatial audio sources (see [Audio Emitters](#audio-emitters)) |
|
|
462
|
+
| `htmlMeshes` | `array` | HTML-in-3D texture panels (see [HTML Meshes](#html-meshes)) |
|
|
463
|
+
| `particleSystems` | `array` | Particle effect systems |
|
|
464
|
+
| `customMeshes` | `array` | Imported 3D models (.glb/.gltf) |
|
|
465
|
+
| `collisionMeshesData` | `array` | Primitive collision shapes |
|
|
466
|
+
| `voxelCollisionUrl` | `string` | Voxel octree collision data URL (see [Voxel Collision](#voxel-collision)) |
|
|
467
|
+
| `additionalSplats` | `array` | Splat swap points along the waypoint path |
|
|
468
|
+
|
|
469
|
+
### Visual & Environment
|
|
470
|
+
|
|
471
|
+
| Field | Type | Description |
|
|
472
|
+
|-------|------|-------------|
|
|
473
|
+
| `activeSkyboxUrl` | `string` | URL to the skybox HDR/image file |
|
|
474
|
+
| `skyboxRotation` | `number` | Skybox rotation offset in radians |
|
|
475
|
+
| `lights` | `array` | Scene lighting configuration |
|
|
476
|
+
| `backgroundColor` | `string` | Background color (hex) |
|
|
477
|
+
| `splatRelighting` | `object` | Splat relighting config (see [Splat Relighting](#splat-relighting)) |
|
|
478
|
+
|
|
479
|
+
### Camera & Navigation
|
|
480
|
+
|
|
481
|
+
| Field | Type | Description |
|
|
482
|
+
|-------|------|-------------|
|
|
483
|
+
| `defaultCameraMode` | `string` | Initial camera mode: `'tour'`, `'explore'`, or `'walk'` |
|
|
484
|
+
| `orbitCameraSettings` | `object` | Initial orbit camera position + pivot: `{ cameraPosition: {x,y,z}, pivotPoint: {x,y,z} }` |
|
|
485
|
+
| `doubleTapMoveSpeed` | `number` | Auto-forward speed on double-tap in explore mode (default: 1.0) |
|
|
486
|
+
| `refocusTapMode` | `string` | Camera refocus trigger: `'single'` or `'double'` (default: `'single'`) |
|
|
487
|
+
| `headBobEnabled` | `boolean` | Enable head bob in walk mode (default: true) |
|
|
488
|
+
| `lodSettings` | `object` | LOD display settings (see [LOD Streaming](#lod-streaming)) |
|
|
489
|
+
|
|
490
|
+
### UI & Appearance
|
|
491
|
+
|
|
492
|
+
| Field | Type | Description |
|
|
493
|
+
|-------|------|-------------|
|
|
494
|
+
| `uiColor` | `string` | Accent color for UI controls |
|
|
495
|
+
| `uiOptions` | `object` | UI visibility toggles and button labels |
|
|
496
|
+
| `uiOptions.viewerTheme` | `ViewerTheme` | Custom viewer theme overrides (see [Theme Customization](#viewer-theme-customization)) |
|
|
497
|
+
| `uiOptions.buttonLabels` | `ButtonLabels` | UI text overrides (see [i18n](#internationalization-i18n)) |
|
|
498
|
+
| `revealStyle` | `string` | Reveal animation: `'bloom'` or `'radial'` |
|
|
499
|
+
| `swapTransitionType` | `string` | Splat swap transition: `'scanline'` or `'dissolve'` (default: `'dissolve'`) |
|
|
500
|
+
|
|
501
|
+
### Model URL Priority
|
|
502
|
+
|
|
503
|
+
The viewer loads model files in this priority order:
|
|
504
|
+
|
|
505
|
+
1. `lodMetaUrl` — LOD streaming (best quality, progressive loading)
|
|
506
|
+
2. `sogModelUrl` / `sogUrl` — SOG compressed (~95% compression)
|
|
507
|
+
3. `compressedPlyUrl` — Compressed PLY (~50% compression)
|
|
508
|
+
4. `loadedModelUrl` — Original upload
|
|
509
|
+
5. `splatUrl` — Legacy fallback
|
|
510
|
+
|
|
511
|
+
## LOD Streaming
|
|
512
|
+
|
|
513
|
+
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.
|
|
514
|
+
|
|
515
|
+
### How It Works
|
|
516
|
+
|
|
517
|
+
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.
|
|
518
|
+
|
|
519
|
+
### LOD Settings
|
|
520
|
+
|
|
521
|
+
Configure LOD display behavior via `lodSettings` in scene data:
|
|
522
|
+
|
|
523
|
+
```javascript
|
|
524
|
+
const sceneData = {
|
|
525
|
+
lodMetaUrl: '/path/to/lod-meta.json',
|
|
526
|
+
lodSettings: {
|
|
527
|
+
preset: 'auto', // 'auto' | 'desktop-max' | 'desktop' | 'mobile-max' | 'mobile' | 'custom'
|
|
528
|
+
// Custom overrides (only used when preset is 'custom'):
|
|
529
|
+
lodDistances: [10, 20, 40, 80, 160], // 5 distance thresholds
|
|
530
|
+
lodRangeMin: 0, // Min LOD range (0-5)
|
|
531
|
+
lodRangeMax: 5, // Max LOD range (0-5)
|
|
532
|
+
// Per-device overrides when preset is 'auto':
|
|
533
|
+
mobilePreset: 'mobile', // Preset for mobile devices
|
|
534
|
+
desktopPreset: 'desktop', // Preset for desktop devices
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
| Preset | Description |
|
|
540
|
+
|--------|-------------|
|
|
541
|
+
| `auto` | Automatically selects based on device type |
|
|
542
|
+
| `desktop-max` | Maximum quality for desktop |
|
|
543
|
+
| `desktop` | Balanced quality for desktop |
|
|
544
|
+
| `mobile-max` | Maximum quality for mobile |
|
|
545
|
+
| `mobile` | Balanced quality for mobile (recommended for mobile) |
|
|
546
|
+
| `custom` | Use custom `lodDistances` and `lodRange` values |
|
|
547
|
+
|
|
548
|
+
## 4DGS Frame Sequences
|
|
549
|
+
|
|
550
|
+
4DGS (4D Gaussian Splatting) enables playback of frame-by-frame splat animations — like video but in 3D.
|
|
551
|
+
|
|
552
|
+
### Configuration
|
|
553
|
+
|
|
554
|
+
Add a `frameSequence` config to your scene data:
|
|
555
|
+
|
|
556
|
+
```javascript
|
|
557
|
+
const sceneData = {
|
|
558
|
+
loadedModelUrl: '/frames/frame_000.ply', // First frame (also used as static fallback)
|
|
559
|
+
frameSequence: {
|
|
560
|
+
frameUrls: [
|
|
561
|
+
'/frames/frame_000.ply',
|
|
562
|
+
'/frames/frame_001.ply',
|
|
563
|
+
'/frames/frame_002.ply',
|
|
564
|
+
// ... up to N frames
|
|
565
|
+
],
|
|
566
|
+
fps: 24, // Playback FPS (default: 24)
|
|
567
|
+
loop: true, // Loop playback (default: true)
|
|
568
|
+
preloadCount: 10, // Frames to preload ahead (default: 10)
|
|
569
|
+
autoplay: true, // Auto-play immediately (default: false)
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
### Controlling Frame Playback
|
|
575
|
+
|
|
576
|
+
```javascript
|
|
577
|
+
const viewer = createViewer(container, sceneData);
|
|
578
|
+
|
|
579
|
+
// Frame navigation
|
|
580
|
+
viewer.setFrame(0); // Jump to first frame
|
|
581
|
+
viewer.getCurrentFrame(); // Get current frame index
|
|
582
|
+
viewer.getTotalFrames(); // Get total frame count
|
|
583
|
+
viewer.isFrameSequencePlaying(); // Check if playing
|
|
584
|
+
|
|
585
|
+
// Playback speed
|
|
586
|
+
viewer.setFps(30); // Change FPS
|
|
587
|
+
viewer.getFps(); // Get current FPS
|
|
588
|
+
|
|
589
|
+
// Progress (0-1)
|
|
590
|
+
viewer.setFrameProgress(0.5); // Jump to 50% through sequence
|
|
591
|
+
viewer.getFrameProgress(); // Get current progress
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
### 4DGS Events
|
|
595
|
+
|
|
596
|
+
```javascript
|
|
597
|
+
viewer.on('frameChange', ({ frame, total }) => {
|
|
598
|
+
console.log(`Frame ${frame} of ${total}`);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
viewer.on('frameComplete', () => {
|
|
602
|
+
console.log('Frame sequence finished (non-looping)');
|
|
603
|
+
});
|
|
604
|
+
```
|
|
605
|
+
|
|
333
606
|
## Viewer Theme Customization
|
|
334
607
|
|
|
335
608
|
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.
|
|
@@ -362,8 +635,8 @@ const viewer = createViewer(container, sceneData);
|
|
|
362
635
|
|
|
363
636
|
| Category | Properties |
|
|
364
637
|
|----------|-----------|
|
|
365
|
-
| **Colors** (
|
|
366
|
-
| **Typography** (
|
|
638
|
+
| **Colors** (20) | `globalTextColor`, `buttonBg`, `buttonHoverBg`, `buttonTextColor`, `popupBg`, `popupTextColor`, `popupCloseBtnColor`, `popupLinkBtnColor`, `preloaderBg`, `preloaderTextColor`, `infoBannerBg`, `dropdownBg`, `watermarkBg`, `helpPanelBg`, `errorPopupBg`, `errorTitleColor`, `lazyLoadBg`, `joystickBaseColor`, `joystickThumbColor`, `portalPopupBg` |
|
|
639
|
+
| **Typography** (9) | `fontFamily`, `buttonFontSize`, `popupTitleFontSize`, `popupContentFontSize`, `infoBannerTitleFontSize`, `infoBannerContentFontSize`, `progressFontSize`, `modeBtnFontSize`, `watermarkFontSize` |
|
|
367
640
|
| **Border Radii** (6) | `buttonBorderRadius`, `popupBorderRadius`, `dropdownBorderRadius`, `helpPanelBorderRadius`, `errorPopupBorderRadius`, `lazyLoadBtnBorderRadius` |
|
|
368
641
|
| **Other** (3) | `dropdownBlur`, `progressBarHeight`, `preloaderBarHeight` |
|
|
369
642
|
|
|
@@ -398,6 +671,10 @@ const sceneData = {
|
|
|
398
671
|
ambientIntensity: 0.8, // 0-1, ambient fill strength
|
|
399
672
|
allowViewerToggle: true, // Show toggle button in viewer UI
|
|
400
673
|
viewerDefaultOn: false, // Start with relighting off for end users
|
|
674
|
+
shadowsEnabled: false, // Enable shadow casting
|
|
675
|
+
shadowIntensity: 0.5, // Shadow strength (0-1)
|
|
676
|
+
shadowGroundY: 0, // Ground plane Y position for shadows
|
|
677
|
+
shadowPlaneScale: 10, // Shadow receiving plane size
|
|
401
678
|
}
|
|
402
679
|
};
|
|
403
680
|
```
|
|
@@ -542,6 +819,11 @@ viewer.on('progressUpdate', ({ progress, index }) => {
|
|
|
542
819
|
console.log(`Progress: ${(progress * 100).toFixed(0)}%, waypoint ${index}`);
|
|
543
820
|
});
|
|
544
821
|
|
|
822
|
+
// Hotspot clicked
|
|
823
|
+
viewer.on('hotspotClick', ({ hotspot }) => {
|
|
824
|
+
console.log(`Hotspot clicked: ${hotspot.id} - ${hotspot.title}`);
|
|
825
|
+
});
|
|
826
|
+
|
|
545
827
|
// XR sessions
|
|
546
828
|
viewer.on('xrStart', ({ type }) => console.log(`Entered ${type}`));
|
|
547
829
|
viewer.on('xrEnd', () => console.log('Exited XR'));
|
|
@@ -551,6 +833,14 @@ viewer.on('splatChange', ({ url, isOriginal }) => {
|
|
|
551
833
|
console.log(`Splat changed: ${url} (original: ${isOriginal})`);
|
|
552
834
|
});
|
|
553
835
|
|
|
836
|
+
// 4DGS frame sequence
|
|
837
|
+
viewer.on('frameChange', ({ frame, total }) => {
|
|
838
|
+
console.log(`Frame ${frame} of ${total}`);
|
|
839
|
+
});
|
|
840
|
+
viewer.on('frameComplete', () => {
|
|
841
|
+
console.log('Frame sequence complete');
|
|
842
|
+
});
|
|
843
|
+
|
|
554
844
|
// Portal activated
|
|
555
845
|
viewer.on('portalActivated', ({ sceneId }) => {
|
|
556
846
|
console.log(`Navigating to scene: ${sceneId}`);
|
|
@@ -565,260 +855,256 @@ viewer.on('warning', ({ type, message }) => {
|
|
|
565
855
|
});
|
|
566
856
|
```
|
|
567
857
|
|
|
568
|
-
|
|
858
|
+
### All Events Reference
|
|
569
859
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
860
|
+
| Event | Data | Description |
|
|
861
|
+
|-------|------|-------------|
|
|
862
|
+
| `ready` | — | Viewer initialized |
|
|
863
|
+
| `loaded` | — | Scene fully loaded |
|
|
864
|
+
| `progress` | `{ loaded, total, percent }` | Loading progress |
|
|
865
|
+
| `waypointChange` | `{ index, waypoint }` | Waypoint changed |
|
|
866
|
+
| `playbackStart` | — | Auto-play started |
|
|
867
|
+
| `playbackStop` | — | Auto-play paused/stopped |
|
|
868
|
+
| `playbackComplete` | — | Tour reached the end |
|
|
869
|
+
| `modeChange` | `{ mode }` | Camera mode changed |
|
|
870
|
+
| `progressUpdate` | `{ progress, index }` | Scroll progress updated |
|
|
871
|
+
| `hotspotClick` | `{ hotspot }` | Hotspot clicked |
|
|
872
|
+
| `xrStart` | `{ type }` | Entered VR/AR |
|
|
873
|
+
| `xrEnd` | — | Exited VR/AR |
|
|
874
|
+
| `splatChange` | `{ url, isOriginal }` | Splat file swapped |
|
|
875
|
+
| `frameChange` | `{ frame, total }` | 4DGS frame changed |
|
|
876
|
+
| `frameComplete` | — | 4DGS sequence finished |
|
|
877
|
+
| `portalActivated` | `{ portalId, targetSceneId, targetSceneName }` | Portal triggered |
|
|
878
|
+
| `portalNavigationStart` | `{ targetSceneId }` | Portal navigation beginning |
|
|
879
|
+
| `portalNavigationComplete` | `{ sceneId }` | Portal navigation complete |
|
|
880
|
+
| `portalNavigationError` | `{ error, targetSceneId }` | Portal navigation failed |
|
|
881
|
+
| `error` | `Error` | Viewer error |
|
|
882
|
+
| `warning` | `{ type, message }` | Non-fatal warning |
|
|
573
883
|
|
|
574
|
-
|
|
575
|
-
const containerRef = useRef(null);
|
|
576
|
-
const viewerRef = useRef(null);
|
|
884
|
+
## Portals (Scene-to-Scene Navigation)
|
|
577
885
|
|
|
578
|
-
|
|
579
|
-
let mounted = true;
|
|
886
|
+
Portals allow users to navigate between multiple 3D scenes by clicking or walking near portal markers.
|
|
580
887
|
|
|
581
|
-
|
|
582
|
-
if (!containerRef.current) return;
|
|
888
|
+
### How Portals Work
|
|
583
889
|
|
|
584
|
-
|
|
585
|
-
const viewer = await createViewerFromSceneId(
|
|
586
|
-
containerRef.current,
|
|
587
|
-
sceneId
|
|
588
|
-
);
|
|
890
|
+
Portals are configured in the scene JSON with a `targetSceneId`:
|
|
589
891
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
892
|
+
```json
|
|
893
|
+
{
|
|
894
|
+
"portals": [
|
|
895
|
+
{
|
|
896
|
+
"id": "portal-1",
|
|
897
|
+
"targetSceneId": "abc123xyz",
|
|
898
|
+
"targetSceneName": "Campervan Interior",
|
|
899
|
+
"targetSceneThumbnail": "https://example.com/thumb.jpg",
|
|
900
|
+
"position": { "x": 2, "y": 0, "z": -3 },
|
|
901
|
+
"type": "sphere",
|
|
902
|
+
"activationMode": "click",
|
|
903
|
+
"confirmNavigation": true,
|
|
904
|
+
"menuOnly": false,
|
|
905
|
+
"menuPath": "Building A/Floor 1"
|
|
598
906
|
}
|
|
907
|
+
]
|
|
908
|
+
}
|
|
909
|
+
```
|
|
599
910
|
|
|
600
|
-
|
|
911
|
+
### Portal Fields
|
|
601
912
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
913
|
+
| Field | Type | Description |
|
|
914
|
+
|-------|------|-------------|
|
|
915
|
+
| `id` | `string` | Unique portal identifier |
|
|
916
|
+
| `targetSceneId` | `string` | Scene ID to navigate to |
|
|
917
|
+
| `targetSceneName` | `string` | Display name for the target scene |
|
|
918
|
+
| `targetSceneThumbnail` | `string` | Thumbnail URL for portal preview |
|
|
919
|
+
| `position` | `{x, y, z}` | 3D position of the portal marker |
|
|
920
|
+
| `type` | `string` | Portal mesh shape (`'sphere'`, `'cube'`, etc.) |
|
|
921
|
+
| `activationMode` | `string` | `'click'` or `'proximity'` |
|
|
922
|
+
| `confirmNavigation` | `boolean` | Show confirmation dialog before navigating |
|
|
923
|
+
| `menuOnly` | `boolean` | If true, portal appears only in the scene menu, not as a 3D mesh |
|
|
924
|
+
| `menuPath` | `string` | Folder path for menu organization (e.g., `"Building A/Floor 1"`) |
|
|
607
925
|
|
|
608
|
-
|
|
609
|
-
<div
|
|
610
|
-
ref={containerRef}
|
|
611
|
-
style={{ width: '100%', height: '500px' }}
|
|
612
|
-
/>
|
|
613
|
-
);
|
|
614
|
-
}
|
|
926
|
+
### Self-Hosted Portal Navigation
|
|
615
927
|
|
|
616
|
-
|
|
617
|
-
```
|
|
928
|
+
By default, portals use StorySplat scene IDs and fetch from `discover.storysplat.com`. For fully self-hosted portals, intercept the `portalActivated` event:
|
|
618
929
|
|
|
619
|
-
|
|
930
|
+
```javascript
|
|
931
|
+
import { createViewer } from 'storysplat-viewer';
|
|
620
932
|
|
|
621
|
-
|
|
933
|
+
const SCENES = {
|
|
934
|
+
'campervan': '/scenes/campervan/scene.json',
|
|
935
|
+
'cabin': '/scenes/cabin/scene.json',
|
|
936
|
+
};
|
|
622
937
|
|
|
623
|
-
|
|
624
|
-
import {
|
|
625
|
-
createViewerFromSceneId,
|
|
626
|
-
SceneNotFoundError,
|
|
627
|
-
SceneApiError
|
|
628
|
-
} from 'storysplat-viewer';
|
|
938
|
+
let currentViewer = null;
|
|
629
939
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
} catch (error) {
|
|
633
|
-
if (error instanceof SceneNotFoundError) {
|
|
634
|
-
console.error('Scene not found or not public');
|
|
635
|
-
} else if (error instanceof SceneApiError) {
|
|
636
|
-
console.error(`API error (${error.statusCode}): ${error.message}`);
|
|
637
|
-
} else {
|
|
638
|
-
console.error('Unknown error:', error);
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
```
|
|
940
|
+
async function loadScene(sceneId) {
|
|
941
|
+
if (currentViewer) currentViewer.destroy();
|
|
642
942
|
|
|
643
|
-
|
|
943
|
+
const sceneData = await fetch(SCENES[sceneId]).then(r => r.json());
|
|
944
|
+
currentViewer = await createViewer(container, sceneData);
|
|
644
945
|
|
|
645
|
-
|
|
946
|
+
currentViewer.on('portalActivated', (data) => {
|
|
947
|
+
loadScene(data.targetSceneId);
|
|
948
|
+
});
|
|
949
|
+
}
|
|
646
950
|
|
|
647
|
-
|
|
951
|
+
loadScene('campervan');
|
|
952
|
+
```
|
|
648
953
|
|
|
649
|
-
|
|
650
|
-
<div id="viewer" style="width: 100%; height: 500px;"></div>
|
|
954
|
+
The `targetSceneId` is just a string you define — it doesn't need to exist on StorySplat. Map it to your own file paths.
|
|
651
955
|
|
|
652
|
-
|
|
653
|
-
<button id="prev">Previous</button>
|
|
654
|
-
<button id="play">Play</button>
|
|
655
|
-
<button id="pause">Pause</button>
|
|
656
|
-
<button id="next">Next</button>
|
|
657
|
-
<span id="waypoint-info"></span>
|
|
658
|
-
</div>
|
|
956
|
+
### Portal Events
|
|
659
957
|
|
|
660
|
-
|
|
661
|
-
|
|
958
|
+
| Event | Description | Data |
|
|
959
|
+
|-------|-------------|------|
|
|
960
|
+
| `portalActivated` | Portal clicked or proximity triggered | `{ portalId, targetSceneId, targetSceneName }` |
|
|
961
|
+
| `portalNavigationStart` | Navigation beginning | `{ targetSceneId }` |
|
|
962
|
+
| `portalNavigationComplete` | New scene loaded | `{ sceneId }` |
|
|
963
|
+
| `portalNavigationError` | Navigation failed | `{ error, targetSceneId }` |
|
|
662
964
|
|
|
663
|
-
|
|
664
|
-
document.getElementById('viewer'),
|
|
665
|
-
'YOUR_SCENE_ID'
|
|
666
|
-
);
|
|
965
|
+
## Audio Emitters
|
|
667
966
|
|
|
668
|
-
|
|
669
|
-
document.getElementById('prev').onclick = () => viewer.prevWaypoint();
|
|
670
|
-
document.getElementById('next').onclick = () => viewer.nextWaypoint();
|
|
967
|
+
Audio emitters are standalone spatial audio sources positioned in 3D space. They support distance-based attenuation and spatialization.
|
|
671
968
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
969
|
+
```json
|
|
970
|
+
{
|
|
971
|
+
"audioEmitters": [
|
|
972
|
+
{
|
|
973
|
+
"id": "birds",
|
|
974
|
+
"name": "Bird Sounds",
|
|
975
|
+
"url": "https://example.com/birds.mp3",
|
|
976
|
+
"position": { "x": 5, "y": 2, "z": -3 },
|
|
977
|
+
"volume": 0.8,
|
|
978
|
+
"loop": true,
|
|
979
|
+
"autoplay": true,
|
|
980
|
+
"spatialSound": true,
|
|
981
|
+
"distanceModel": "inverse",
|
|
982
|
+
"maxDistance": 50,
|
|
983
|
+
"refDistance": 1,
|
|
984
|
+
"rolloffFactor": 1,
|
|
985
|
+
"enabled": true
|
|
986
|
+
}
|
|
987
|
+
]
|
|
686
988
|
}
|
|
687
|
-
</script>
|
|
688
989
|
```
|
|
689
990
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
|
693
|
-
|
|
694
|
-
|
|
|
695
|
-
| `
|
|
696
|
-
| `
|
|
697
|
-
| `
|
|
698
|
-
| `
|
|
699
|
-
| `
|
|
700
|
-
|
|
|
701
|
-
| `
|
|
702
|
-
| `
|
|
703
|
-
| `
|
|
704
|
-
| `
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
| `viewer.play()` | Start auto-playing through waypoints |
|
|
710
|
-
| `viewer.pause()` | Pause auto-play |
|
|
711
|
-
| `viewer.stop()` | Stop and reset to first waypoint |
|
|
712
|
-
| `viewer.isPlaying()` | Check if currently auto-playing |
|
|
713
|
-
| `viewer.setProgress(progress)` | Set scroll progress (0-1) |
|
|
714
|
-
| `viewer.getProgress()` | Get current scroll progress (0-1) |
|
|
715
|
-
| **Splat Management** | |
|
|
716
|
-
| `viewer.goToSplat(url)` | Swap to a different splat file at runtime |
|
|
717
|
-
| `viewer.goToOriginalSplat()` | Switch back to the original splat |
|
|
718
|
-
| `viewer.getCurrentSplatUrl()` | Get the currently loaded splat URL |
|
|
719
|
-
| `viewer.isShowingOriginalSplat()` | Check if showing the original splat |
|
|
720
|
-
| `viewer.getAdditionalSplats()` | Get the list of additional splat swap points |
|
|
721
|
-
| **Audio** | |
|
|
722
|
-
| `viewer.muteAll()` | Mute all audio |
|
|
723
|
-
| `viewer.unmuteAll()` | Unmute all audio |
|
|
724
|
-
| `viewer.isMuted()` | Check if audio is muted |
|
|
725
|
-
| **Hotspots** | |
|
|
726
|
-
| `viewer.getHotspots()` | Get all hotspot data (id, title, type, position) |
|
|
727
|
-
| `viewer.triggerHotspot(id)` | Programmatically open a hotspot popup |
|
|
728
|
-
| `viewer.closeHotspot()` | Close the currently open hotspot popup |
|
|
729
|
-
| **Portals** | |
|
|
730
|
-
| `viewer.navigateToScene(sceneId)` | Navigate to a linked scene via portal |
|
|
731
|
-
| **Lifecycle** | |
|
|
732
|
-
| `viewer.resize()` | Recalculate canvas size (call after container resize) |
|
|
733
|
-
| `viewer.destroy()` | Clean up and remove viewer |
|
|
734
|
-
| **UI** | |
|
|
735
|
-
| `viewer.setButtonLabels(labels)` | Update UI text labels at runtime (see [i18n](#internationalization-i18n)) |
|
|
736
|
-
|
|
737
|
-
## Analytics & Tracking
|
|
991
|
+
| Field | Type | Default | Description |
|
|
992
|
+
|-------|------|---------|-------------|
|
|
993
|
+
| `id` | `string` | — | Unique identifier |
|
|
994
|
+
| `name` | `string` | — | Display name |
|
|
995
|
+
| `url` | `string` | — | Audio file URL |
|
|
996
|
+
| `position` | `{x, y, z}` | `{0,0,0}` | 3D position |
|
|
997
|
+
| `volume` | `number` | `1` | Volume (0-1) |
|
|
998
|
+
| `loop` | `boolean` | `false` | Loop playback |
|
|
999
|
+
| `autoplay` | `boolean` | `false` | Play automatically on load |
|
|
1000
|
+
| `spatialSound` | `boolean` | `true` | Enable 3D spatial audio |
|
|
1001
|
+
| `distanceModel` | `string` | `'inverse'` | `'inverse'`, `'linear'`, or `'exponential'` |
|
|
1002
|
+
| `maxDistance` | `number` | `100` | Maximum hearing distance |
|
|
1003
|
+
| `refDistance` | `number` | `1` | Distance at which volume is 100% |
|
|
1004
|
+
| `rolloffFactor` | `number` | `1` | How quickly volume decreases with distance |
|
|
1005
|
+
| `enabled` | `boolean` | `true` | Whether this emitter is active |
|
|
1006
|
+
|
|
1007
|
+
## HTML Meshes
|
|
1008
|
+
|
|
1009
|
+
HTML Meshes render HTML content as textures on 3D planes in the scene. They support text, images, iframes, CSS styling, and interactive elements.
|
|
738
1010
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
## Comparison: Scene ID vs JSON File
|
|
1011
|
+
```json
|
|
1012
|
+
{
|
|
1013
|
+
"htmlMeshes": [
|
|
1014
|
+
{
|
|
1015
|
+
"id": "info-panel",
|
|
1016
|
+
"name": "Info Panel",
|
|
1017
|
+
"htmlContent": "<h2>Welcome</h2><p>This is a 3D info panel</p>",
|
|
1018
|
+
"position": { "x": 0, "y": 2, "z": -5 },
|
|
1019
|
+
"rotation": { "x": 0, "y": 0, "z": 0 },
|
|
1020
|
+
"scale": { "x": 2, "y": 1.5, "z": 1 },
|
|
1021
|
+
"width": 512,
|
|
1022
|
+
"height": 384,
|
|
1023
|
+
"billboard": false,
|
|
1024
|
+
"cssStyles": "background: rgba(0,0,0,0.8); color: white; padding: 20px; font-family: sans-serif;",
|
|
1025
|
+
"visible": true
|
|
1026
|
+
}
|
|
1027
|
+
]
|
|
1028
|
+
}
|
|
1029
|
+
```
|
|
760
1030
|
|
|
761
|
-
|
|
|
762
|
-
|
|
763
|
-
|
|
|
764
|
-
|
|
|
1031
|
+
| Field | Type | Description |
|
|
1032
|
+
|-------|------|-------------|
|
|
1033
|
+
| `htmlContent` | `string` | HTML to render as texture |
|
|
1034
|
+
| `position` / `rotation` / `scale` | `{x,y,z}` | 3D transform |
|
|
1035
|
+
| `width` / `height` | `number` | Texture resolution in pixels |
|
|
1036
|
+
| `billboard` | `boolean` | Always face the camera |
|
|
1037
|
+
| `cssStyles` | `string` | CSS to apply to the HTML content |
|
|
1038
|
+
| `visible` | `boolean` | Whether the mesh is visible |
|
|
765
1039
|
|
|
766
|
-
##
|
|
1040
|
+
## Voxel Collision
|
|
767
1041
|
|
|
768
|
-
|
|
1042
|
+
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.
|
|
769
1043
|
|
|
770
|
-
|
|
1044
|
+
### Scene Data
|
|
771
1045
|
|
|
772
|
-
```
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
height: 500px; /* Must have explicit height */
|
|
1046
|
+
```json
|
|
1047
|
+
{
|
|
1048
|
+
"voxelCollisionUrl": "https://example.com/scene.voxel.json"
|
|
776
1049
|
}
|
|
777
1050
|
```
|
|
778
1051
|
|
|
779
|
-
|
|
1052
|
+
The voxel collision system requires two co-located files:
|
|
1053
|
+
- `.voxel.json` — Metadata (bounds, resolution, leaf count)
|
|
1054
|
+
- `.voxel.bin` — Binary octree data (nodes + leaf data)
|
|
780
1055
|
|
|
781
|
-
|
|
1056
|
+
Both primitive collision meshes and voxel collision coexist — primitives are checked first, then the octree.
|
|
782
1057
|
|
|
783
|
-
|
|
1058
|
+
## React Integration
|
|
784
1059
|
|
|
785
|
-
|
|
1060
|
+
```jsx
|
|
1061
|
+
import { useEffect, useRef } from 'react';
|
|
1062
|
+
import { createViewerFromSceneId } from 'storysplat-viewer';
|
|
786
1063
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
viewer.destroy();
|
|
791
|
-
});
|
|
1064
|
+
function StorySplatViewer({ sceneId }) {
|
|
1065
|
+
const containerRef = useRef(null);
|
|
1066
|
+
const viewerRef = useRef(null);
|
|
792
1067
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
viewer?.destroy();
|
|
796
|
-
};
|
|
797
|
-
```
|
|
1068
|
+
useEffect(() => {
|
|
1069
|
+
let mounted = true;
|
|
798
1070
|
|
|
799
|
-
|
|
1071
|
+
async function initViewer() {
|
|
1072
|
+
if (!containerRef.current) return;
|
|
800
1073
|
|
|
801
|
-
|
|
1074
|
+
try {
|
|
1075
|
+
const viewer = await createViewerFromSceneId(
|
|
1076
|
+
containerRef.current,
|
|
1077
|
+
sceneId
|
|
1078
|
+
);
|
|
802
1079
|
|
|
803
|
-
|
|
1080
|
+
if (mounted) {
|
|
1081
|
+
viewerRef.current = viewer;
|
|
1082
|
+
} else {
|
|
1083
|
+
viewer.destroy();
|
|
1084
|
+
}
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
console.error('Failed to create viewer:', error);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
804
1089
|
|
|
805
|
-
|
|
806
|
-
// vite.config.ts
|
|
807
|
-
export default {
|
|
808
|
-
build: {
|
|
809
|
-
target: 'es2020', // Must be es2016+ (NOT es2015)
|
|
810
|
-
}
|
|
811
|
-
};
|
|
812
|
-
```
|
|
1090
|
+
initViewer();
|
|
813
1091
|
|
|
814
|
-
|
|
1092
|
+
return () => {
|
|
1093
|
+
mounted = false;
|
|
1094
|
+
viewerRef.current?.destroy();
|
|
1095
|
+
};
|
|
1096
|
+
}, [sceneId]);
|
|
815
1097
|
|
|
816
|
-
|
|
1098
|
+
return (
|
|
1099
|
+
<div
|
|
1100
|
+
ref={containerRef}
|
|
1101
|
+
style={{ width: '100%', height: '500px' }}
|
|
1102
|
+
/>
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
817
1105
|
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
- Modern JavaScript (ES2016+)
|
|
821
|
-
- Recommended browsers: Chrome 80+, Firefox 75+, Safari 13+, Edge 80+
|
|
1106
|
+
export default StorySplatViewer;
|
|
1107
|
+
```
|
|
822
1108
|
|
|
823
1109
|
## Native App Integration
|
|
824
1110
|
|
|
@@ -932,39 +1218,64 @@ For simple web integration, use an iframe:
|
|
|
932
1218
|
</iframe>
|
|
933
1219
|
```
|
|
934
1220
|
|
|
935
|
-
###
|
|
1221
|
+
### CDN Options
|
|
936
1222
|
|
|
937
|
-
|
|
1223
|
+
The viewer is available on multiple CDNs. **We recommend the bundled build for simplicity** — it includes PlayCanvas, so you only need one script:
|
|
1224
|
+
|
|
1225
|
+
```html
|
|
1226
|
+
<!-- RECOMMENDED: Bundled build (includes PlayCanvas - single script!) -->
|
|
1227
|
+
<script src="https://cdn.jsdelivr.net/npm/storysplat-viewer@2/dist/storysplat-viewer.bundled.umd.js"></script>
|
|
1228
|
+
|
|
1229
|
+
<!-- Alternative: Separate scripts (if you need a specific PlayCanvas version) -->
|
|
1230
|
+
<script src="https://cdn.jsdelivr.net/npm/playcanvas@2.14.3/build/playcanvas.min.js"></script>
|
|
1231
|
+
<script src="https://cdn.jsdelivr.net/npm/storysplat-viewer@2/dist/storysplat-viewer.umd.js"></script>
|
|
1232
|
+
|
|
1233
|
+
<!-- unpkg alternative -->
|
|
1234
|
+
<script src="https://unpkg.com/storysplat-viewer@2/dist/storysplat-viewer.bundled.umd.js"></script>
|
|
1235
|
+
```
|
|
1236
|
+
|
|
1237
|
+
### Self-Hosting the Bundle
|
|
938
1238
|
|
|
939
|
-
1. Download the UMD bundle from npm:
|
|
940
1239
|
```bash
|
|
941
1240
|
npm pack storysplat-viewer
|
|
942
1241
|
# Extract storysplat-viewer-x.x.x.tgz
|
|
943
|
-
# Copy dist/storysplat-viewer.umd.js to your server
|
|
1242
|
+
# Copy dist/storysplat-viewer.bundled.umd.js to your server
|
|
944
1243
|
```
|
|
945
1244
|
|
|
946
|
-
2. Reference your self-hosted bundle:
|
|
947
1245
|
```html
|
|
948
|
-
<script src="/path/to/storysplat-viewer.umd.js"></script>
|
|
1246
|
+
<script src="/path/to/storysplat-viewer.bundled.umd.js"></script>
|
|
949
1247
|
```
|
|
950
1248
|
|
|
951
|
-
|
|
1249
|
+
## Analytics & Tracking
|
|
952
1250
|
|
|
953
|
-
|
|
1251
|
+
When using `createViewerFromSceneId`, the viewer automatically tracks:
|
|
954
1252
|
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
<script src="https://cdn.jsdelivr.net/npm/storysplat-viewer@2/dist/storysplat-viewer.bundled.umd.js"></script>
|
|
1253
|
+
- **Views**: Each time a scene is loaded
|
|
1254
|
+
- **Bandwidth**: Data transferred when loading StorySplat-hosted files
|
|
958
1255
|
|
|
959
|
-
|
|
960
|
-
<script src="https://cdn.jsdelivr.net/npm/playcanvas@2.14.3/build/playcanvas.min.js"></script>
|
|
961
|
-
<script src="https://cdn.jsdelivr.net/npm/storysplat-viewer@2/dist/storysplat-viewer.umd.js"></script>
|
|
1256
|
+
This data appears in your StorySplat admin dashboard.
|
|
962
1257
|
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1258
|
+
### What Gets Tracked
|
|
1259
|
+
|
|
1260
|
+
| Scenario | Views | Bandwidth |
|
|
1261
|
+
|----------|-------|-----------|
|
|
1262
|
+
| `createViewerFromSceneId` with StorySplat-hosted files | Yes | Yes |
|
|
1263
|
+
| `createViewerFromSceneId` with self-hosted splat files | Yes | No |
|
|
1264
|
+
| `createViewer` (self-hosted scenes) | No | No |
|
|
1265
|
+
| iframe embed | Yes | Yes |
|
|
1266
|
+
|
|
1267
|
+
### Privacy Note
|
|
1268
|
+
|
|
1269
|
+
Tracking only occurs for scenes loaded via `createViewerFromSceneId`. If you use `createViewer` with your own scene data, no data is sent to StorySplat servers.
|
|
966
1270
|
|
|
967
|
-
###
|
|
1271
|
+
### Comparison: Scene ID vs JSON File
|
|
1272
|
+
|
|
1273
|
+
| Approach | Best For | Pros | Cons |
|
|
1274
|
+
|----------|----------|------|------|
|
|
1275
|
+
| **Scene ID** | Live content, CMS | Always latest, automatic tracking, no redeploy | Needs internet, API dependency |
|
|
1276
|
+
| **JSON File** | Self-hosted, offline | Full control, git-tracked, no external dependencies | Manual updates, no analytics |
|
|
1277
|
+
|
|
1278
|
+
## Standalone HTML Generation
|
|
968
1279
|
|
|
969
1280
|
Generate standalone HTML files programmatically:
|
|
970
1281
|
|
|
@@ -988,7 +1299,7 @@ import fs from 'fs';
|
|
|
988
1299
|
fs.writeFileSync('scene.html', html);
|
|
989
1300
|
```
|
|
990
1301
|
|
|
991
|
-
|
|
1302
|
+
### GenerateHTMLOptions
|
|
992
1303
|
|
|
993
1304
|
```typescript
|
|
994
1305
|
interface GenerateHTMLOptions {
|
|
@@ -1003,208 +1314,32 @@ interface GenerateHTMLOptions {
|
|
|
1003
1314
|
}
|
|
1004
1315
|
```
|
|
1005
1316
|
|
|
1006
|
-
##
|
|
1007
|
-
|
|
1008
|
-
Portals allow users to navigate between multiple 3D scenes by clicking or walking near portal markers.
|
|
1009
|
-
|
|
1010
|
-
### How Portals Work
|
|
1011
|
-
|
|
1012
|
-
Portals are configured in the scene JSON with a `targetSceneId`:
|
|
1013
|
-
|
|
1014
|
-
```json
|
|
1015
|
-
{
|
|
1016
|
-
"portals": [
|
|
1017
|
-
{
|
|
1018
|
-
"id": "portal-1",
|
|
1019
|
-
"targetSceneId": "abc123xyz",
|
|
1020
|
-
"targetSceneName": "Campervan Interior",
|
|
1021
|
-
"position": { "x": 2, "y": 0, "z": -3 },
|
|
1022
|
-
"type": "sphere",
|
|
1023
|
-
"activationMode": "click"
|
|
1024
|
-
}
|
|
1025
|
-
]
|
|
1026
|
-
}
|
|
1027
|
-
```
|
|
1028
|
-
|
|
1029
|
-
### Portal Paths for Self-Hosted Scenes
|
|
1030
|
-
|
|
1031
|
-
**Important:** By default, portals use StorySplat scene IDs and fetch from `discover.storysplat.com`. For fully self-hosted portals, you need to intercept the `portalActivated` event and implement custom navigation.
|
|
1032
|
-
|
|
1033
|
-
#### Self-Hosted Portal Example
|
|
1034
|
-
|
|
1035
|
-
```javascript
|
|
1036
|
-
import { createViewer } from 'storysplat-viewer';
|
|
1037
|
-
|
|
1038
|
-
// Your self-hosted scene folder structure:
|
|
1039
|
-
// /scenes/
|
|
1040
|
-
// ├── scene_campervan/
|
|
1041
|
-
// │ └── scene.json
|
|
1042
|
-
// ├── scene_cabin/
|
|
1043
|
-
// │ └── scene.json
|
|
1044
|
-
// └── scene_garden/
|
|
1045
|
-
// └── scene.json
|
|
1046
|
-
|
|
1047
|
-
// Load initial scene
|
|
1048
|
-
const initialScene = await fetch('/scenes/scene_campervan/scene.json').then(r => r.json());
|
|
1049
|
-
let viewer = await createViewer(container, initialScene);
|
|
1050
|
-
|
|
1051
|
-
// Handle portal navigation with custom paths
|
|
1052
|
-
viewer.on('portalActivated', async (data) => {
|
|
1053
|
-
console.log('Portal clicked:', data.targetSceneId);
|
|
1054
|
-
|
|
1055
|
-
// Map scene IDs to your folder paths
|
|
1056
|
-
const scenePaths = {
|
|
1057
|
-
'cabin-scene-id': '/scenes/scene_cabin/scene.json',
|
|
1058
|
-
'garden-scene-id': '/scenes/scene_garden/scene.json',
|
|
1059
|
-
// Or use the scene ID directly as folder name:
|
|
1060
|
-
// [data.targetSceneId]: `/scenes/${data.targetSceneId}/scene.json`
|
|
1061
|
-
};
|
|
1062
|
-
|
|
1063
|
-
const scenePath = scenePaths[data.targetSceneId];
|
|
1064
|
-
if (!scenePath) {
|
|
1065
|
-
console.error('Unknown scene:', data.targetSceneId);
|
|
1066
|
-
return;
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
// Fetch from YOUR server
|
|
1070
|
-
const newSceneData = await fetch(scenePath).then(r => r.json());
|
|
1071
|
-
|
|
1072
|
-
// Dispose old viewer and create new one
|
|
1073
|
-
viewer.destroy();
|
|
1074
|
-
viewer = await createViewer(container, newSceneData);
|
|
1075
|
-
|
|
1076
|
-
// Re-attach portal handler for the new scene
|
|
1077
|
-
viewer.on('portalActivated', arguments.callee);
|
|
1078
|
-
});
|
|
1079
|
-
```
|
|
1080
|
-
|
|
1081
|
-
#### Using Relative Paths
|
|
1082
|
-
|
|
1083
|
-
If you want to use the `targetSceneId` directly as a folder name:
|
|
1084
|
-
|
|
1085
|
-
```javascript
|
|
1086
|
-
viewer.on('portalActivated', async (data) => {
|
|
1087
|
-
// targetSceneId becomes the folder name
|
|
1088
|
-
const scenePath = `/scenes/${data.targetSceneId}/scene.json`;
|
|
1089
|
-
|
|
1090
|
-
const newSceneData = await fetch(scenePath).then(r => r.json());
|
|
1091
|
-
viewer.destroy();
|
|
1092
|
-
viewer = await createViewer(container, newSceneData);
|
|
1093
|
-
});
|
|
1094
|
-
```
|
|
1095
|
-
|
|
1096
|
-
Then structure your folders to match:
|
|
1097
|
-
```
|
|
1098
|
-
/scenes/
|
|
1099
|
-
├── campervan/ ← targetSceneId: "campervan"
|
|
1100
|
-
│ └── scene.json
|
|
1101
|
-
├── cabin/ ← targetSceneId: "cabin"
|
|
1102
|
-
│ └── scene.json
|
|
1103
|
-
```
|
|
1104
|
-
|
|
1105
|
-
#### Fully Self-Hosted (No StorySplat at All)
|
|
1106
|
-
|
|
1107
|
-
If both scenes are created locally and **never uploaded to StorySplat**, you have full control over the `targetSceneId` values. They don't need to be real StorySplat IDs—use any string identifier you want:
|
|
1108
|
-
|
|
1109
|
-
**Step 1: Edit your scene.json files to add portals with custom IDs**
|
|
1110
|
-
|
|
1111
|
-
```json
|
|
1112
|
-
// scenes/campervan/scene.json
|
|
1113
|
-
{
|
|
1114
|
-
"name": "Campervan Tour",
|
|
1115
|
-
"portals": [
|
|
1116
|
-
{
|
|
1117
|
-
"id": "portal-1",
|
|
1118
|
-
"targetSceneId": "cabin",
|
|
1119
|
-
"targetSceneName": "Mountain Cabin",
|
|
1120
|
-
"position": { "x": 2, "y": 0, "z": -3 },
|
|
1121
|
-
"type": "sphere",
|
|
1122
|
-
"activationMode": "click"
|
|
1123
|
-
}
|
|
1124
|
-
]
|
|
1125
|
-
}
|
|
1126
|
-
```
|
|
1127
|
-
|
|
1128
|
-
```json
|
|
1129
|
-
// scenes/cabin/scene.json
|
|
1130
|
-
{
|
|
1131
|
-
"name": "Mountain Cabin",
|
|
1132
|
-
"portals": [
|
|
1133
|
-
{
|
|
1134
|
-
"id": "portal-back",
|
|
1135
|
-
"targetSceneId": "campervan",
|
|
1136
|
-
"targetSceneName": "Back to Campervan",
|
|
1137
|
-
"position": { "x": -1, "y": 0, "z": 2 },
|
|
1138
|
-
"type": "sphere",
|
|
1139
|
-
"activationMode": "click"
|
|
1140
|
-
}
|
|
1141
|
-
]
|
|
1142
|
-
}
|
|
1143
|
-
```
|
|
1317
|
+
## Error Handling
|
|
1144
1318
|
|
|
1145
|
-
|
|
1319
|
+
The package exports error classes for specific error handling:
|
|
1146
1320
|
|
|
1147
1321
|
```javascript
|
|
1148
|
-
import {
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
const SCENES = {
|
|
1154
|
-
'campervan': '/scenes/campervan/scene.json',
|
|
1155
|
-
'cabin': '/scenes/cabin/scene.json',
|
|
1156
|
-
'garden': '/scenes/garden/scene.json'
|
|
1157
|
-
};
|
|
1158
|
-
|
|
1159
|
-
let currentViewer = null;
|
|
1322
|
+
import {
|
|
1323
|
+
createViewerFromSceneId,
|
|
1324
|
+
SceneNotFoundError,
|
|
1325
|
+
SceneApiError
|
|
1326
|
+
} from 'storysplat-viewer';
|
|
1160
1327
|
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1328
|
+
try {
|
|
1329
|
+
const viewer = await createViewerFromSceneId(container, sceneId);
|
|
1330
|
+
} catch (error) {
|
|
1331
|
+
if (error instanceof SceneNotFoundError) {
|
|
1332
|
+
console.error('Scene not found or not public');
|
|
1333
|
+
} else if (error instanceof SceneApiError) {
|
|
1334
|
+
console.error(`API error (${error.statusCode}): ${error.message}`);
|
|
1335
|
+
} else {
|
|
1336
|
+
console.error('Unknown error:', error);
|
|
1165
1337
|
}
|
|
1166
|
-
|
|
1167
|
-
// Fetch scene from YOUR server
|
|
1168
|
-
const sceneData = await fetch(SCENES[sceneId]).then(r => r.json());
|
|
1169
|
-
|
|
1170
|
-
// Create viewer
|
|
1171
|
-
currentViewer = await createViewer(container, sceneData);
|
|
1172
|
-
|
|
1173
|
-
// Handle portal clicks - loads target scene from your server
|
|
1174
|
-
currentViewer.on('portalActivated', (data) => {
|
|
1175
|
-
loadScene(data.targetSceneId);
|
|
1176
|
-
});
|
|
1177
1338
|
}
|
|
1178
|
-
|
|
1179
|
-
// Start with initial scene
|
|
1180
|
-
loadScene('campervan');
|
|
1181
1339
|
```
|
|
1182
1340
|
|
|
1183
|
-
**Key point:** The `targetSceneId` is just a string you define. It doesn't need to exist on StorySplat—you map it to your own file paths.
|
|
1184
|
-
|
|
1185
|
-
#### Portal Events
|
|
1186
|
-
|
|
1187
|
-
| Event | Description | Data |
|
|
1188
|
-
|-------|-------------|------|
|
|
1189
|
-
| `portalActivated` | Portal clicked or proximity triggered | `{ portalId, targetSceneId, targetSceneName }` |
|
|
1190
|
-
| `portalNavigationStart` | Navigation beginning | `{ targetSceneId }` |
|
|
1191
|
-
| `portalNavigationComplete` | New scene loaded | `{ sceneId }` |
|
|
1192
|
-
| `portalNavigationError` | Navigation failed | `{ error, targetSceneId }` |
|
|
1193
|
-
|
|
1194
|
-
### Q: Where does the viewer look for scene files?
|
|
1195
|
-
|
|
1196
|
-
**A: It depends on how you load scenes:**
|
|
1197
|
-
|
|
1198
|
-
- **`createViewerFromSceneId()`** - Fetches from `discover.storysplat.com/api/scene/{id}`
|
|
1199
|
-
- **`createViewer(sceneData)`** - Uses the data object you pass in directly (no fetch)
|
|
1200
|
-
- **Splat file URLs in scene JSON** - Can be absolute URLs or relative to your page
|
|
1201
|
-
|
|
1202
|
-
The viewer itself has no "base path" concept - splat file paths in your scene JSON should be absolute URLs or relative to your HTML page, not to the viewer JS file.
|
|
1203
|
-
|
|
1204
1341
|
## Exports
|
|
1205
1342
|
|
|
1206
|
-
The package exports the following types and utilities:
|
|
1207
|
-
|
|
1208
1343
|
```typescript
|
|
1209
1344
|
// Core viewer functions
|
|
1210
1345
|
import {
|
|
@@ -1238,13 +1373,71 @@ import {
|
|
|
1238
1373
|
getGsplatRelightingClass,
|
|
1239
1374
|
SceneNotFoundError,
|
|
1240
1375
|
SceneApiError,
|
|
1376
|
+
BUILD_VERSION,
|
|
1241
1377
|
} from 'storysplat-viewer';
|
|
1242
1378
|
```
|
|
1243
1379
|
|
|
1380
|
+
## Troubleshooting
|
|
1381
|
+
|
|
1382
|
+
### Container Needs Dimensions
|
|
1383
|
+
|
|
1384
|
+
The container element must have explicit dimensions:
|
|
1385
|
+
|
|
1386
|
+
```css
|
|
1387
|
+
#viewer {
|
|
1388
|
+
width: 100%;
|
|
1389
|
+
height: 500px; /* Must have explicit height */
|
|
1390
|
+
}
|
|
1391
|
+
```
|
|
1392
|
+
|
|
1393
|
+
### CORS Issues
|
|
1394
|
+
|
|
1395
|
+
If loading splats from a different domain, ensure the server has proper CORS headers or use a proxy.
|
|
1396
|
+
|
|
1397
|
+
### Memory Cleanup
|
|
1398
|
+
|
|
1399
|
+
Always destroy the viewer when unmounting:
|
|
1400
|
+
|
|
1401
|
+
```javascript
|
|
1402
|
+
// Vanilla JS
|
|
1403
|
+
window.addEventListener('beforeunload', () => {
|
|
1404
|
+
viewer.destroy();
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
// React useEffect cleanup
|
|
1408
|
+
return () => {
|
|
1409
|
+
viewer?.destroy();
|
|
1410
|
+
};
|
|
1411
|
+
```
|
|
1412
|
+
|
|
1413
|
+
### Build Target Must Be ES2016+
|
|
1414
|
+
|
|
1415
|
+
If you bundle `storysplat-viewer` with Vite, esbuild, or another bundler, your build target **must be `es2016` or higher** (we recommend `es2020`).
|
|
1416
|
+
|
|
1417
|
+
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`).
|
|
1418
|
+
|
|
1419
|
+
```js
|
|
1420
|
+
// vite.config.ts
|
|
1421
|
+
export default {
|
|
1422
|
+
build: {
|
|
1423
|
+
target: 'es2020', // Must be es2016+ (NOT es2015)
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1426
|
+
```
|
|
1427
|
+
|
|
1428
|
+
This only affects production builds — development servers typically don't minify and won't trigger the issue.
|
|
1429
|
+
|
|
1430
|
+
### Browser Compatibility
|
|
1431
|
+
|
|
1432
|
+
StorySplat Viewer requires:
|
|
1433
|
+
- WebGL 2.0 support
|
|
1434
|
+
- Modern JavaScript (ES2016+)
|
|
1435
|
+
- Recommended browsers: Chrome 80+, Firefox 75+, Safari 13+, Edge 80+
|
|
1436
|
+
|
|
1244
1437
|
## Support
|
|
1245
1438
|
|
|
1246
1439
|
- GitHub Issues: [github.com/SonnyC56/storysplat-viewer/issues](https://github.com/SonnyC56/storysplat-viewer/issues)
|
|
1247
|
-
- Documentation: [
|
|
1440
|
+
- Documentation: [discover.storysplat.com/docs](https://discover.storysplat.com/docs)
|
|
1248
1441
|
|
|
1249
1442
|
## License
|
|
1250
1443
|
|