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:
@@ -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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user