xr.ts
examples/spark/js/xr.ts · raw · open the example · all examples · site home
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92
// WebXR for the ports, after /build/examples/xr/index.html (../../xr/main.ts):
// WebGPU XR where the browser has the WebXR / WebGPU binding (GPU sort every
// frame), else the WebGL2 backend's XR (CPU sort through one view for the
// head). An "Enter VR" button, and the emulator's offered
// session. three swaps in the XR camera while presenting.
import type { WebGPURenderer } from "three/webgpu";
import { getGpuCaps, type CameraControls, type GpuBackend, type SplatRenderer } from "@sparkjsdev/radix";
// The backend for an XR page: ?gpu= when given
export function xrBackend(): GpuBackend {
const param = new URLSearchParams(location.search).get("gpu");
if (param === "webgpu" || param === "webgpu-gl") {
return param;
}
return "XRGPUBinding" in globalThis && navigator.gpu ? "webgpu" : "webgpu-gl";
}
// An emulated XRWebGLLayer (IWER) has a null framebuffer; three's WebGL
// state keys draw buffers by framebuffer in a WeakMap, which takes no null
function patchNullXrFramebuffer(renderer: WebGPURenderer) {
type State = { currentDrawbuffers: WeakMap<object, number[]> | { get(key: object | null): number[] | undefined; set(key: object | null, value: number[]): unknown } };
const state = (renderer.backend as unknown as { state?: State }).state;
if (!state?.currentDrawbuffers) {
return;
}
const map = state.currentDrawbuffers as WeakMap<object, number[]>;
const nullKey = {};
state.currentDrawbuffers = {
get: (key: object | null) => map.get(key ?? nullKey),
set(key: object | null, value: number[]) {
map.set(key ?? nullKey, value);
return this;
},
};
}
// Sets up sessions for `splatRenderers`; optionalFeatures are added to the
// backend's required ones. The controls' XR sticks move the rig: the
// controlled object's parent unless controls.rig is set. Returns
// { button, session() }.
export function setupXr(renderer: WebGPURenderer, splatRenderers: SplatRenderer[], controls: CameraControls, { optionalFeatures = [], framebufferScale = 1, onSession, onEnd }: {
optionalFeatures?: string[];
framebufferScale?: number;
onSession?: (session: XRSession) => void;
onEnd?: () => void;
} = {}) {
const webgpu = getGpuCaps(renderer).backend === "webgpu";
renderer.xr.setReferenceSpaceType("local-floor");
patchNullXrFramebuffer(renderer);
controls.xr = renderer.xr;
for (const splatRenderer of splatRenderers) {
// The desktop camera and the XR cameras are one viewpoint
splatRenderer.view = splatRenderer.viewFor("head");
}
const init = () => ({ requiredFeatures: webgpu ? ["webgpu", "local-floor"] : ["local-floor"], optionalFeatures });
let session: XRSession | undefined;
const button = document.createElement("button");
button.textContent = "Enter VR";
button.style.cssText = "position:fixed;bottom:40px;left:50%;transform:translateX(-50%);padding:10px 24px;font-size:16px;border-radius:6px;border:1px solid #888;background:#222;color:#fff;cursor:pointer";
button.hidden = !navigator.xr;
document.body.append(button);
async function start(next: XRSession) {
renderer.xr.setFramebufferScaleFactor(framebufferScale);
next.addEventListener("end", () => {
session = undefined;
button.hidden = false;
onEnd?.();
offer();
});
await renderer.xr.setSession(next);
session = next;
button.hidden = true;
onSession?.(next);
}
function offer() {
const xr = navigator.xr as (XRSystem & { offerSession?: (mode: XRSessionMode, init: XRSessionInit) => Promise<XRSession> }) | undefined;
xr?.offerSession?.("immersive-vr", init()).then(start).catch((error) => console.warn("offerSession", error));
}
button.addEventListener("click", async () => {
try {
await start(await navigator.xr!.requestSession("immersive-vr", init()));
} catch (error) {
button.textContent = `No VR session: ${(error as Error).message}`;
}
});
offer();
return { button, session: () => session };
}