xr.ts

examples/spark/js/xr.ts · raw · open the example · all examples · site home

Exports: xrBackendsetupXr

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 };
}