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,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>`;
|
||||
Reference in New Issue
Block a user