diff --git a/demos/3dgs-walkthrough/index.html b/demos/3dgs-walkthrough/index.html
new file mode 100644
index 0000000..2483db0
--- /dev/null
+++ b/demos/3dgs-walkthrough/index.html
@@ -0,0 +1,30 @@
+
+
+
+ 3DGS Scene Walkthrough
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/3dgs-walkthrough/main.js b/demos/3dgs-walkthrough/main.js
new file mode 100644
index 0000000..a608496
--- /dev/null
+++ b/demos/3dgs-walkthrough/main.js
@@ -0,0 +1,208 @@
+import {LongSelectHandler} from 'xrblocks/addons/ui/LongSelectHandler.js';
+
+import {SplatMesh, SparkRenderer} from '@sparkjsdev/spark';
+import * as THREE from 'three';
+import * as xb from 'xrblocks';
+
+const PROPRIETARY_ASSETS_BASE_URL =
+ 'https://cdn.jsdelivr.net/gh/xrblocks/proprietary-assets@main/';
+
+const SPLAT_ASSETS = [
+ {
+ url: PROPRIETARY_ASSETS_BASE_URL + '3dgs_scenes/nyc.spz',
+ scale: new THREE.Vector3(1.3, 1.3, 1.3),
+ position: new THREE.Vector3(0, -0.15, 0),
+ quaternion: new THREE.Quaternion(1, 0, 0, 0),
+ },
+ {
+ url: PROPRIETARY_ASSETS_BASE_URL + '3dgs_scenes/alameda.spz',
+ scale: new THREE.Vector3(1.3, 1.3, 1.3),
+ position: new THREE.Vector3(0, 0, 0),
+ quaternion: new THREE.Quaternion(1, 0, 0, 0),
+ },
+];
+
+const FADE_DURATION_S = 1.0; // seconds
+const MOVE_SPEED = 0.05;
+
+function easeInOutSine(x) {
+ return -(Math.cos(Math.PI * x) - 1) / 2;
+}
+
+const forward = new THREE.Vector3();
+const right = new THREE.Vector3();
+const moveDirection = new THREE.Vector3();
+
+/**
+ * An XR-Blocks demo that displays room-scale 3DGS models, allowing smooth
+ * transitions via number keys (1, 2) or a 1.5 s long-pinch.
+ */
+class WalkthroughManager extends xb.Script {
+ async init() {
+ this.add(new THREE.HemisphereLight(0xffffff, 0x666666, 3));
+
+ // Load all splat meshes in parallel.
+ this.splatMeshes = await Promise.all(
+ SPLAT_ASSETS.map(async (asset) => {
+ const mesh = new SplatMesh({url: asset.url});
+ await mesh.initialized;
+ mesh.position.copy(asset.position);
+ mesh.quaternion.copy(asset.quaternion);
+ mesh.scale.copy(asset.scale);
+ return mesh;
+ })
+ );
+
+ // Create a SparkRenderer for gaussian splat rendering and register it so
+ // the simulator can toggle encodeLinear for correct color space.
+ const sparkRenderer = new SparkRenderer({
+ renderer: xb.core.renderer,
+ maxStdDev: Math.sqrt(5),
+ });
+ xb.core.registry.register(new xb.SparkRendererHolder(sparkRenderer));
+ xb.add(sparkRenderer);
+
+ // Show the first splat.
+ this.currentIndex = 0;
+ xb.add(this.splatMeshes[this.currentIndex]);
+
+ // fadeProgress tracks animation time: null = idle, 0‥FADE_DURATION_S =
+ // fading out, FADE_DURATION_S‥2×FADE_DURATION_S = fading in.
+ this.fadeProgress = null;
+ this.nextIndex = null;
+
+ // Locomotion state.
+ this.locomotionOffset = new THREE.Vector3();
+ this.baseReferenceSpace = null;
+ this.keys = {w: false, a: false, s: false, d: false};
+
+ document.addEventListener('keydown', this.onKeyDown.bind(this));
+ document.addEventListener('keyup', this.onKeyUp.bind(this));
+
+ xb.add(
+ new LongSelectHandler(this.cycleSplat.bind(this), {
+ triggerDelay: 1500,
+ triggerCooldownDuration: 1500,
+ })
+ );
+ }
+
+ /** Starts a crossfade to the next splat (wrapping around). */
+ cycleSplat() {
+ if (this.fadeProgress !== null) return;
+ this.nextIndex = (this.currentIndex + 1) % this.splatMeshes.length;
+ this.fadeProgress = 0;
+ }
+
+ onKeyDown(event) {
+ const key = event.key.toLowerCase();
+ if (key in this.keys) this.keys[key] = true;
+
+ // Number key → jump to that splat (1-indexed).
+ const idx = parseInt(key, 10) - 1;
+ if (
+ idx >= 0 &&
+ idx < this.splatMeshes.length &&
+ idx !== this.currentIndex &&
+ this.fadeProgress === null
+ ) {
+ this.nextIndex = idx;
+ this.fadeProgress = 0;
+ }
+ }
+
+ onKeyUp(event) {
+ const key = event.key.toLowerCase();
+ if (key in this.keys) this.keys[key] = false;
+ }
+
+ onXRSessionEnded() {
+ super.onXRSessionEnded();
+ this.baseReferenceSpace = null;
+ this.locomotionOffset.set(0, 0, 0);
+ }
+
+ update() {
+ super.update();
+ const dt = xb.getDeltaTime();
+
+ this.updateFade(dt);
+ this.updateLocomotion();
+ }
+
+ /** Handles the fade-out → fade-in crossfade between splats. */
+ updateFade(dt) {
+ if (this.fadeProgress === null) return;
+
+ this.fadeProgress += dt;
+ const currentMesh = this.splatMeshes[this.currentIndex];
+
+ if (this.fadeProgress < FADE_DURATION_S) {
+ // Fading out the current splat.
+ currentMesh.opacity =
+ 1 - easeInOutSine(this.fadeProgress / FADE_DURATION_S);
+ } else if (this.fadeProgress < 2 * FADE_DURATION_S) {
+ // Swap on the first frame of the fade-in phase.
+ if (currentMesh.parent) {
+ xb.scene.remove(currentMesh);
+ this.currentIndex = this.nextIndex;
+ const nextMesh = this.splatMeshes[this.currentIndex];
+ nextMesh.opacity = 0;
+ xb.add(nextMesh);
+ }
+ // Fading in the new splat.
+ const inProgress =
+ (this.fadeProgress - FADE_DURATION_S) / FADE_DURATION_S;
+ this.splatMeshes[this.currentIndex].opacity = easeInOutSine(inProgress);
+ } else {
+ // Fade complete.
+ this.splatMeshes[this.currentIndex].opacity = 1;
+ this.fadeProgress = null;
+ this.nextIndex = null;
+ }
+ }
+
+ /** WASD locomotion via XR reference space offset. */
+ updateLocomotion() {
+ const xr = xb.core.renderer?.xr;
+ if (!xr?.isPresenting) return;
+
+ const camera = xr.getCamera();
+ if (!camera) return;
+
+ camera.getWorldDirection(forward);
+ forward.y = 0;
+ forward.normalize();
+ right.crossVectors(forward, THREE.Object3D.DEFAULT_UP).normalize();
+
+ moveDirection.set(0, 0, 0);
+ if (this.keys.w) moveDirection.add(forward);
+ if (this.keys.s) moveDirection.sub(forward);
+ if (this.keys.a) moveDirection.sub(right);
+ if (this.keys.d) moveDirection.add(right);
+ if (moveDirection.lengthSq() === 0) return;
+ moveDirection.normalize();
+
+ if (!this.baseReferenceSpace) {
+ this.baseReferenceSpace = xr.getReferenceSpace();
+ }
+
+ this.locomotionOffset.addScaledVector(moveDirection, -MOVE_SPEED);
+ const transform = new XRRigidTransform(this.locomotionOffset);
+ xr.setReferenceSpace(
+ this.baseReferenceSpace.getOffsetReferenceSpace(transform)
+ );
+ }
+}
+
+document.addEventListener('DOMContentLoaded', function () {
+ const options = new xb.Options();
+ options.reticles.enabled = false;
+ options.hands.enabled = true;
+ options.hands.visualization = true;
+ options.hands.visualizeMeshes = true;
+ options.simulator.scenePath = null; // Prevent simulator scene from loading.
+
+ xb.add(new WalkthroughManager());
+ xb.init(options);
+});
diff --git a/docs/docs/samples/22-3DGS-Walkthrough.mdx b/docs/docs/samples/22-3DGS-Walkthrough.mdx
new file mode 100644
index 0000000..ff1044f
--- /dev/null
+++ b/docs/docs/samples/22-3DGS-Walkthrough.mdx
@@ -0,0 +1,15 @@
+---
+id: 3DGS-Walkthrough
+title: 3DGS Walkthrough
+hide_title: true
+breadcrumbs: false
+pagination_next: null
+pagination_prev: null
+---
+
+import {SamplesIFrame} from './SamplesIFrame';
+
+
diff --git a/docs/sidebars.ts b/docs/sidebars.ts
index 8d5cbae..f1fa88b 100644
--- a/docs/sidebars.ts
+++ b/docs/sidebars.ts
@@ -84,6 +84,7 @@ const sidebars: SidebarsConfig = {
'samples/Math-Tutor',
'samples/AI-Simulator',
'samples/Virtual-Screens',
+ 'samples/3DGS-Walkthrough',
],
},
],
diff --git a/src/xrblocks.ts b/src/xrblocks.ts
index f2e480c..084cd52 100644
--- a/src/xrblocks.ts
+++ b/src/xrblocks.ts
@@ -109,6 +109,7 @@ export * from './utils/ModelLoader';
export * from './utils/ObjectPlacement';
export * from './utils/RotationUtils';
export * from './utils/SceneGraphUtils';
+export * from './utils/SparkRendererHolder';
export * from './utils/Types';
export * from './utils/utils';
export * from './ux/DragManager';