spark 2.1.0 splat viewer tab with OS-fused gyro look-around

WebView-hosted Spark renderer (butterfly.spz demo scene), fixed camera
position, rotation driven by expo-sensors DeviceMotion (ROTATION_VECTOR /
CoreMotion xMagneticNorthZVertical) via injectJavaScript; web fallback
uses deviceorientation in an iframe. Tap to re-center yaw.
This commit is contained in:
Lars Nolden
2026-08-11 18:05:17 +02:00
parent f63f0fd576
commit b463af35d0
9 changed files with 580 additions and 31 deletions
+117
View File
@@ -0,0 +1,117 @@
import { DeviceMotion } from 'expo-sensors';
import { useEffect, useRef, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { rotationScript, SPLAT_VIEWER_HTML, type ViewerMessage } from './viewer-html';
const SENSOR_UPDATE_INTERVAL_MS = 1000 / 60;
/**
* Native (iOS/Android) splat viewer host.
*
* Renders the Spark viewer page in a WebView and streams the OS's fused
* device attitude (expo-sensors `DeviceMotion.rotation`: ROTATION_VECTOR on
* Android, CoreMotion attitude on iOS — gyro+accel+mag on both) into the
* page via `injectJavaScript`. Camera position stays fixed; only rotation
* is live.
*/
export function SplatViewer() {
const webViewRef = useRef<WebView>(null);
const [status, setStatus] = useState('Loading Spark renderer…');
useEffect(() => {
let subscription: { remove(): void } | undefined;
let cancelled = false;
(async () => {
const available = await DeviceMotion.isAvailableAsync();
if (cancelled) return;
if (!available) {
setStatus('Motion sensors unavailable — showing a static view.');
return;
}
DeviceMotion.setUpdateInterval(SENSOR_UPDATE_INTERVAL_MS);
subscription = DeviceMotion.addListener(({ rotation, orientation }) => {
if (!rotation) return;
const script = rotationScript({
alpha: rotation.alpha,
beta: rotation.beta,
gamma: rotation.gamma,
orientation: orientation ?? 0,
});
if (script) webViewRef.current?.injectJavaScript(script);
});
})();
return () => {
cancelled = true;
subscription?.remove();
};
}, []);
const onMessage = (event: WebViewMessageEvent) => {
let message: ViewerMessage;
try {
message = JSON.parse(event.nativeEvent.data);
} catch {
return;
}
switch (message.type) {
case 'progress':
setStatus(`Downloading scene… ${Math.round((100 * message.loaded) / message.total)}%`);
break;
case 'ready':
setStatus(`${message.numSplats.toLocaleString()} splats — move the phone to look around, tap to re-center.`);
break;
case 'error':
setStatus(`Viewer error: ${message.message}`);
break;
case 'stats':
break;
}
};
return (
<View style={styles.container}>
<WebView
ref={webViewRef}
source={{ html: SPLAT_VIEWER_HTML }}
originWhitelist={['*']}
onMessage={onMessage}
scrollEnabled={false}
bounces={false}
overScrollMode="never"
setBuiltInZoomControls={false}
webviewDebuggingEnabled={__DEV__}
style={styles.webview}
/>
<Text style={styles.status}>{status}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
webview: {
flex: 1,
backgroundColor: '#000',
},
status: {
position: 'absolute',
bottom: 12,
left: 12,
right: 12,
color: '#fff',
fontSize: 12,
textAlign: 'center',
backgroundColor: 'rgba(0,0,0,0.55)',
borderRadius: 8,
paddingVertical: 6,
paddingHorizontal: 10,
overflow: 'hidden',
},
});
+153
View File
@@ -0,0 +1,153 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { SPLAT_VIEWER_HTML, type ViewerMessage } from './viewer-html';
// expo-sensors' DeviceMotion web shim does not emit `rotation`, so on web we
// read the browser's own OS-fused attitude directly. Chrome/Android exposes
// the magnetometer-referenced fusion on `deviceorientationabsolute`; other
// browsers (incl. iOS Safari, which fuses via CoreMotion) use
// `deviceorientation`.
const ORIENTATION_EVENT =
typeof window !== 'undefined' && 'ondeviceorientationabsolute' in window
? 'deviceorientationabsolute'
: 'deviceorientation';
// iOS Safari 13+ gates motion events behind a user-gesture permission prompt.
const orientationEventStatics =
typeof DeviceOrientationEvent !== 'undefined'
? (DeviceOrientationEvent as unknown as { requestPermission?: () => Promise<'granted' | 'denied'> })
: {};
const DEG2RAD = Math.PI / 180;
/**
* Web splat viewer host: same Spark viewer page as native, hosted in an
* iframe and fed rotation samples via postMessage. Camera position is fixed.
*/
export function SplatViewer() {
const frameRef = useRef<HTMLIFrameElement>(null);
const [status, setStatus] = useState('Loading Spark renderer…');
const [needsPermission, setNeedsPermission] = useState(false);
const onOrientation = useCallback((event: DeviceOrientationEvent) => {
if (event.alpha == null || event.beta == null || event.gamma == null) return;
frameRef.current?.contentWindow?.postMessage(
{
type: 'rotation',
alpha: event.alpha * DEG2RAD,
beta: event.beta * DEG2RAD,
gamma: event.gamma * DEG2RAD,
orientation: (typeof screen !== 'undefined' && screen.orientation?.angle) || 0,
},
'*',
);
}, []);
useEffect(() => {
const onViewerMessage = (event: MessageEvent) => {
if (event.source !== frameRef.current?.contentWindow) return;
const message = event.data as ViewerMessage;
switch (message?.type) {
case 'progress':
setStatus(`Downloading scene… ${Math.round((100 * message.loaded) / message.total)}%`);
break;
case 'ready':
setStatus(`${message.numSplats.toLocaleString()} splats — rotate the device to look around, tap to re-center.`);
break;
case 'error':
setStatus(`Viewer error: ${message.message}`);
break;
}
};
window.addEventListener('message', onViewerMessage);
const listener = onOrientation as EventListener;
if (typeof orientationEventStatics.requestPermission === 'function') {
setNeedsPermission(true);
} else {
window.addEventListener(ORIENTATION_EVENT, listener);
}
return () => {
window.removeEventListener('message', onViewerMessage);
window.removeEventListener(ORIENTATION_EVENT, listener);
};
}, [onOrientation]);
const requestMotionAccess = useCallback(async () => {
try {
const response = await orientationEventStatics.requestPermission?.();
if (response === 'granted') {
window.addEventListener(ORIENTATION_EVENT, onOrientation as EventListener);
setNeedsPermission(false);
} else {
setStatus('Motion access denied — showing a static view.');
setNeedsPermission(false);
}
} catch (error) {
setStatus(`Motion permission failed: ${error instanceof Error ? error.message : error}`);
}
}, [onOrientation]);
return (
<View style={styles.container}>
<iframe
ref={frameRef}
title="Spark splat viewer"
srcDoc={SPLAT_VIEWER_HTML}
style={iframeStyle}
allow="accelerometer; gyroscope; magnetometer"
/>
{needsPermission && (
<Pressable style={styles.permissionButton} onPress={requestMotionAccess}>
<Text style={styles.permissionLabel}>Enable motion control</Text>
</Pressable>
)}
<Text style={styles.status}>{status}</Text>
</View>
);
}
const iframeStyle: React.CSSProperties = {
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
border: 0,
background: '#000',
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
permissionButton: {
position: 'absolute',
top: '45%',
alignSelf: 'center',
backgroundColor: 'rgba(10, 126, 164, 0.9)',
borderRadius: 10,
paddingVertical: 12,
paddingHorizontal: 20,
},
permissionLabel: {
color: '#fff',
fontSize: 15,
fontWeight: '600',
},
status: {
position: 'absolute',
bottom: 12,
left: 12,
right: 12,
color: '#fff',
fontSize: 12,
textAlign: 'center',
backgroundColor: 'rgba(0,0,0,0.55)',
borderRadius: 8,
paddingVertical: 6,
paddingHorizontal: 10,
overflow: 'hidden',
},
});
+226
View File
@@ -0,0 +1,226 @@
/**
* Self-contained Spark (sparkjs.dev) Gaussian-splat viewer page.
*
* Hosted inside `react-native-webview` on iOS/Android and inside an <iframe>
* on web, per the WebView-renderer architecture in prototyping-session.md.
* The page renders a fixed-position camera whose *rotation* is driven by the
* host through a single message contract:
*
* - native host: `webViewRef.injectJavaScript(rotationScript(sample))`
* - web host: `iframe.contentWindow.postMessage({ type: 'rotation', ...sample }, '*')`
*
* A rotation sample is the W3C device-orientation triple (alpha/beta/gamma,
* intrinsic Z-X'-Y'', in RADIANS) plus the screen-orientation angle in
* degrees (0 | 90 | 180 | -90). Both expo-sensors `DeviceMotion.rotation`
* (Android ROTATION_VECTOR, iOS CoreMotion attitude — the OS's fused
* gyro+accel+mag estimate on both) and the browser's `deviceorientation`
* event (degrees, converted by the web host) decompose the same way, so one
* reconstruction works for every source.
*
* The page reports back `{ type: 'ready' | 'error' | 'stats', ... }` via
* `window.ReactNativeWebView.postMessage` (native) or `parent.postMessage`
* (web).
*/
/** Spark's flagship publicly hosted demo scene (~4 MB .spz). */
export const SPLAT_SCENE_URL = 'https://sparkjs.dev/assets/splats/butterfly.spz';
/** Latest Spark release (2.1.0) and its pinned three.js peer. */
const SPARK_MODULE_URL = 'https://cdn.jsdelivr.net/npm/@sparkjsdev/spark@2.1.0/dist/spark.module.js';
const THREE_MODULE_URL = 'https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js';
const THREE_ADDONS_URL = 'https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/';
export interface RotationSample {
/** Rotation around Z, radians (W3C convention). */
alpha: number;
/** Rotation around X, radians. */
beta: number;
/** Rotation around Y, radians. */
gamma: number;
/** Screen orientation angle in degrees: 0, 90, 180 or -90. */
orientation: number;
}
/** Message posted by the viewer page to its host. */
export type ViewerMessage =
| { type: 'ready'; numSplats: number }
| { type: 'progress'; loaded: number; total: number }
| { type: 'stats'; fps: number }
| { type: 'error'; message: string };
/**
* JS snippet for `WebView.injectJavaScript` pushing one rotation sample.
* Values are interpolated as plain number literals; non-finite samples are
* dropped so we never inject `NaN`/`undefined` into the page.
*/
export function rotationScript({ alpha, beta, gamma, orientation }: RotationSample): string | null {
if (!Number.isFinite(alpha) || !Number.isFinite(beta) || !Number.isFinite(gamma)) {
return null;
}
return `window.__setRotation&&window.__setRotation(${alpha},${beta},${gamma},${orientation || 0});true;`;
}
export const SPLAT_VIEWER_HTML = /* html */ `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #000; }
canvas { display: block; }
#hud {
position: fixed; top: max(8px, env(safe-area-inset-top)); left: 8px;
color: #8f8; font: 11px/1.5 ui-monospace, monospace;
background: rgba(0, 0, 0, 0.45); padding: 4px 7px; border-radius: 4px;
pointer-events: none; white-space: pre; z-index: 1;
}
</style>
<script type="importmap">
{
"imports": {
"three": "${THREE_MODULE_URL}",
"three/addons/": "${THREE_ADDONS_URL}",
"@sparkjsdev/spark": "${SPARK_MODULE_URL}"
}
}
</script>
</head>
<body>
<div id="hud">loading spark 2.1.0…</div>
<script>
// Host bridge + error funnel. Plain script so it runs even if the module
// graph below fails to fetch/parse.
(function () {
window.__emit = function (msg) {
if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {
window.ReactNativeWebView.postMessage(JSON.stringify(msg));
} else if (window.parent !== window) {
window.parent.postMessage(msg, '*');
}
};
window.addEventListener('error', function (e) {
window.__emit({ type: 'error', message: String((e.error && e.error.message) || e.message) });
});
window.addEventListener('unhandledrejection', function (e) {
window.__emit({ type: 'error', message: String((e.reason && e.reason.message) || e.reason) });
});
})();
</script>
<script type="module">
import * as THREE from 'three';
import { SparkRenderer, SplatMesh } from '@sparkjsdev/spark';
const SPLAT_URL = '${SPLAT_SCENE_URL}';
const hud = document.getElementById('hud');
const emit = window.__emit;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.01, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: false, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
const spark = new SparkRenderer({ renderer });
scene.add(spark);
// Camera position is FIXED (origin); only orientation is live. The splat is
// placed in front of the initial view, flipped 180° about X because 3DGS
// captures are Y-down (as in Spark's own hello-world).
const splat = new SplatMesh({
url: SPLAT_URL,
onProgress: (e) => {
if (e.lengthComputable) {
hud.textContent = 'downloading scene… ' + Math.round((100 * e.loaded) / e.total) + '%';
emit({ type: 'progress', loaded: e.loaded, total: e.total });
}
},
});
splat.quaternion.set(1, 0, 0, 0);
splat.position.set(0, 0, -2.7);
scene.add(splat);
let numSplats = 0;
splat.initialized
.then((mesh) => {
numSplats = mesh.numSplats;
emit({ type: 'ready', numSplats });
})
.catch((err) => emit({ type: 'error', message: 'splat load failed: ' + (err && err.message ? err.message : err) }));
// ---- Device attitude -> camera quaternion --------------------------------
// Standard W3C mapping (as in three.js DeviceOrientationControls):
// world is Y-up, the camera looks out of the BACK of the device.
const euler = new THREE.Euler();
const qTmp = new THREE.Quaternion();
const deviceQ = new THREE.Quaternion();
const yawFix = new THREE.Quaternion();
const forward = new THREE.Vector3();
const Y_AXIS = new THREE.Vector3(0, 1, 0);
const Z_AXIS = new THREE.Vector3(0, 0, 1);
const Q_CAMERA = new THREE.Quaternion(-Math.SQRT1_2, 0, 0, Math.SQRT1_2); // -90° about X
const DEG2RAD = Math.PI / 180;
let hasPose = false;
let needRecenter = true;
// alpha/beta/gamma in radians, screenDeg in degrees.
function setRotation(alpha, beta, gamma, screenDeg) {
if (!Number.isFinite(alpha) || !Number.isFinite(beta) || !Number.isFinite(gamma)) return;
euler.set(beta, alpha, -gamma, 'YXZ');
deviceQ.setFromEuler(euler);
deviceQ.multiply(Q_CAMERA);
deviceQ.multiply(qTmp.setFromAxisAngle(Z_AXIS, -(screenDeg || 0) * DEG2RAD));
if (needRecenter) {
// Attitude yaw is referenced to magnetic north (OS fusion); re-zero it
// so the first sample looks straight at the scene. Pitch/roll stay
// gravity-true. Skipped while the camera points near straight up/down,
// where heading is ill-defined.
forward.set(0, 0, -1).applyQuaternion(deviceQ);
if (forward.x * forward.x + forward.z * forward.z > 0.01) {
yawFix.setFromAxisAngle(Y_AXIS, Math.atan2(forward.x, -forward.z));
needRecenter = false;
}
}
camera.quaternion.copy(yawFix).multiply(deviceQ);
hasPose = true;
}
// Native host entry point (WebView.injectJavaScript).
window.__setRotation = setRotation;
// Web host entry point (iframe postMessage).
window.addEventListener('message', (e) => {
const d = e.data;
if (d && d.type === 'rotation') setRotation(d.alpha, d.beta, d.gamma, d.orientation);
});
// Tap anywhere to re-center the view on the scene.
window.addEventListener('pointerdown', () => { needRecenter = true; });
window.addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
// ---- Render loop + HUD ---------------------------------------------------
let frames = 0;
let lastHud = performance.now();
renderer.setAnimationLoop(() => {
renderer.render(scene, camera);
frames += 1;
const now = performance.now();
if (now - lastHud >= 1000) {
if (numSplats > 0) {
hud.textContent =
numSplats.toLocaleString() + ' splats | ' + frames + ' fps | ' +
(hasPose ? 'gyro live — tap to re-center' : 'waiting for gyro…');
}
emit({ type: 'stats', fps: frames });
frames = 0;
lastHud = now;
}
});
</script>
</body>
</html>`;
+1
View File
@@ -18,6 +18,7 @@ const MAPPING = {
'paperplane.fill': 'send',
'chevron.left.forwardslash.chevron.right': 'code',
'chevron.right': 'chevron-right',
'rotate.3d': 'view-in-ar',
} as IconMapping;
/**