From 3172d08ce3725c2b83195f7658e534a418b5f21a Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 3 Oct 2026 09:00:42 +0530 Subject: [PATCH 01/10] build(website): remove the desktop WebXR emulator The IWER emulator only existed to fake a Meta Quest headset in a desktop tab, and VR is being dropped. Remove the emulator module, its startup hook, the iwer and @iwer/devui dependencies, and its guide. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 4 +- apps/website/package.json | 2 - apps/website/src/main.tsx | 9 +- apps/website/src/xr-emulator.test.ts | 55 -------- apps/website/src/xr-emulator.ts | 103 --------------- docs/deployment-security.md | 2 +- docs/webxr-emulation.md | 66 ---------- pnpm-lock.yaml | 183 --------------------------- pnpm-workspace.yaml | 2 - 9 files changed, 3 insertions(+), 423 deletions(-) delete mode 100644 apps/website/src/xr-emulator.test.ts delete mode 100644 apps/website/src/xr-emulator.ts delete mode 100644 docs/webxr-emulation.md diff --git a/README.md b/README.md index 26e28c5..5daefef 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,6 @@ The system diorama is where that discipline is most visible, because a picture o - **iPhone and Android AR:** The same immersive control prefers the established Meta Quest VR session, selects native `immersive-ar` on an AR-only phone, and uses Variant Launch's App Clip handoff on iPhone. AR presents the existing Babylon world at tabletop scale over camera passthrough, with hit-tested placement, drag repositioning, and pinch scaling — no GLB or USDZ export path. - **Direct Quest shortcuts:** The controller trigger can enter or exit immersive VR when the runtime exposes it. Explore and World Forge remain browser-only; no browser UI is captured or rendered inside VR. - **Adaptive rendering budget:** Separate desktop, mobile, and Quest profiles govern shader octaves, sphere tessellation, star count, texture detail, and render scale. Immersive sessions raise fixed foveation after three seconds below 62 FPS and relax it again above 70. -- **Desktop WebXR emulation:** An opt-in Immersive Web Emulation Runtime installs a synthetic Quest over `navigator.xr`, so the immersive path runs unmodified in a normal tab. - **Graceful degradation:** A six-hour planet cache, a twelve-hour star cache, and a bundled local profile keep the experience alive when NASA, SIMBAD, or the API is unreachable. ## Keyboard @@ -53,7 +52,6 @@ The system diorama is where that discipline is most visible, because a picture o - **Web:** React 19, Vite, Babylon.js 9 (WebGL2 + WebXR) - **API:** Hono on Node 24 - **Data sources:** NASA Exoplanet Archive TAP, SIMBAD TAP (CDS, Strasbourg), and NASA/JPL APIs -- **Immersive tooling:** IWER and `@iwer/devui` for desktop WebXR emulation - **Hosting:** Vercel static output plus a Vercel Function, with Analytics and Speed Insights ## Development @@ -119,7 +117,7 @@ vp run @exora/api#build WebXR requires a secure context. Localhost works for desktop development, but testing from a Quest on the local network needs HTTPS or a deployed origin. -To exercise the immersive VR flow without a headset, open ; `?xr=stereo` renders both eyes side by side and `?xr=off` returns to the native runtime. See the [desktop WebXR emulation guide](docs/webxr-emulation.md), use the [Meta Quest smoke-test checklist](docs/quest-testing.md) for headset validation and performance targets, and follow the [iPhone AR deployment and smoke-test guide](docs/iphone-ar.md) for Variant Launch configuration and real-device testing. +Use the [Meta Quest smoke-test checklist](docs/quest-testing.md) for headset validation and performance targets, and follow the [iPhone AR deployment and smoke-test guide](docs/iphone-ar.md) for Variant Launch configuration and real-device testing. ## Workspace diff --git a/apps/website/package.json b/apps/website/package.json index d567e68..b02b715 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -25,14 +25,12 @@ "react-dom": "catalog:" }, "devDependencies": { - "@iwer/devui": "catalog:", "@lhci/cli": "catalog:", "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", "@vitest/browser-playwright": "catalog:", - "iwer": "catalog:", "playwright": "catalog:", "typescript": "catalog:", "vite": "catalog:", diff --git a/apps/website/src/main.tsx b/apps/website/src/main.tsx index 52ab1cb..776c073 100644 --- a/apps/website/src/main.tsx +++ b/apps/website/src/main.tsx @@ -32,11 +32,4 @@ if (import.meta.env.PROD && "serviceWorker" in navigator) { }); } -if (import.meta.env.DEV || import.meta.env.VITE_XR_EMULATOR === "1") { - void import("./xr-emulator.ts").then(({ installXrEmulator, isXrEmulatorRequested }) => { - if (isXrEmulatorRequested()) void installXrEmulator().then(render); - else render(); - }); -} else { - render(); -} +render(); diff --git a/apps/website/src/xr-emulator.test.ts b/apps/website/src/xr-emulator.test.ts deleted file mode 100644 index 4645aea..0000000 --- a/apps/website/src/xr-emulator.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { expect, test } from "vite-plus/test"; -import { resolveXrEmulatorRequest } from "./xr-emulator.ts"; - -test("stays off without a request or stored preference", () => { - expect(resolveXrEmulatorRequest("", "unset")).toEqual({ - enabled: false, - headset: "quest2", - persist: "unset", - stereo: false, - }); -}); - -test("enables emulation from the query string", () => { - expect(resolveXrEmulatorRequest("?xr=emulate", "unset")).toEqual({ - enabled: true, - headset: "quest2", - persist: "enabled", - stereo: false, - }); - expect(resolveXrEmulatorRequest("?planet=HIP+65426+b&xr=on", "unset").enabled).toBe(true); -}); - -test("renders both eyes when stereo is requested", () => { - expect(resolveXrEmulatorRequest("?xr=stereo", "unset")).toEqual({ - enabled: true, - headset: "quest2", - persist: "enabled", - stereo: true, - }); - expect(resolveXrEmulatorRequest("?stereo", "unset").stereo).toBe(true); -}); - -test("keeps emulating across navigations once stored", () => { - expect(resolveXrEmulatorRequest("", "enabled").enabled).toBe(true); - expect(resolveXrEmulatorRequest("", "disabled").enabled).toBe(false); -}); - -test("returns to the native runtime on request", () => { - expect(resolveXrEmulatorRequest("?xr=off", "enabled")).toEqual({ - enabled: false, - headset: "quest2", - persist: "disabled", - stereo: false, - }); -}); - -test("emulates the newer headset only when it is asked for", () => { - expect(resolveXrEmulatorRequest("?xr=emulate", "unset").headset).toBe("quest2"); - expect(resolveXrEmulatorRequest("?xr=quest3", "unset")).toEqual({ - enabled: true, - headset: "quest3", - persist: "enabled", - stereo: false, - }); -}); diff --git a/apps/website/src/xr-emulator.ts b/apps/website/src/xr-emulator.ts deleted file mode 100644 index 872ed4d..0000000 --- a/apps/website/src/xr-emulator.ts +++ /dev/null @@ -1,103 +0,0 @@ -export type XrEmulatorPreference = "enabled" | "disabled" | "unset"; -export type XrEmulatorState = "off" | "installed" | "failed"; - -export type XrEmulatorHeadset = "quest2" | "quest3"; - -export interface XrEmulatorRequest { - enabled: boolean; - headset: XrEmulatorHeadset; - persist: XrEmulatorPreference; - stereo: boolean; -} - -const STORAGE_KEY = "exora:xr-emulator"; -const ENABLE_VALUES = new Set(["emulate", "emulated", "on", "1", "true"]); -const DISABLE_VALUES = new Set(["off", "0", "false", "native"]); -const QUEST3_VALUES = new Set(["quest3", "quest-3"]); - -export const resolveXrEmulatorRequest = ( - search: string, - storedPreference: XrEmulatorPreference, -): XrEmulatorRequest => { - const params = new URLSearchParams(search); - const requested = (params.get("xr") ?? "").trim().toLowerCase(); - const stereo = requested === "stereo" || params.get("stereo") !== null; - const headset: XrEmulatorHeadset = QUEST3_VALUES.has(requested) ? "quest3" : "quest2"; - - if (stereo || headset === "quest3" || ENABLE_VALUES.has(requested)) { - return { enabled: true, headset, persist: "enabled", stereo }; - } - - if (DISABLE_VALUES.has(requested)) { - return { enabled: false, headset, persist: "disabled", stereo: false }; - } - - return { - enabled: storedPreference === "enabled", - headset, - persist: "unset", - stereo: false, - }; -}; - -const readPreference = (): XrEmulatorPreference => { - try { - const stored = window.sessionStorage.getItem(STORAGE_KEY); - if (stored === "enabled" || stored === "disabled") return stored; - } catch {} - return "unset"; -}; - -const writePreference = (preference: XrEmulatorPreference): void => { - if (preference === "unset") return; - try { - window.sessionStorage.setItem(STORAGE_KEY, preference); - } catch {} -}; - -const EMULATOR_AVAILABLE = import.meta.env.DEV || import.meta.env.VITE_XR_EMULATOR === "1"; - -const request: XrEmulatorRequest = - !EMULATOR_AVAILABLE || typeof window === "undefined" - ? { enabled: false, headset: "quest2", persist: "unset", stereo: false } - : resolveXrEmulatorRequest(window.location.search, readPreference()); - -export const isXrEmulatorRequested = (): boolean => EMULATOR_AVAILABLE && request.enabled; - -let state: XrEmulatorState = "off"; - -const repairOffsetReferenceSpaces = (referenceSpace: unknown): void => { - const prototype = (referenceSpace as { prototype: Record }).prototype; - const spec = prototype.getOffsetReferenceSpace as (this: unknown, offset: unknown) => unknown; - prototype.getOffsetReferenceSpace = function patched(this: unknown, offset: unknown) { - const matrix = (offset as { matrix?: Float32Array } | null)?.matrix; - return spec.call(this, matrix ?? offset); - }; -}; - -export const installXrEmulator = async (): Promise => { - if (!EMULATOR_AVAILABLE || !request.enabled) return state; - - try { - const [{ XRDevice, XRReferenceSpace, metaQuest2, metaQuest3 }, { DevUI }] = await Promise.all([ - import("iwer"), - import("@iwer/devui"), - ]); - - const device = new XRDevice(request.headset === "quest3" ? metaQuest3 : metaQuest2, { - stereoEnabled: request.stereo, - }); - device.installRuntime({ forceInstall: true }); - repairOffsetReferenceSpaces(XRReferenceSpace); - device.installDevUI(DevUI); - state = "installed"; - } catch { - state = "failed"; - } - - return state; -}; - -if (EMULATOR_AVAILABLE && typeof window !== "undefined") { - writePreference(request.persist); -} diff --git a/docs/deployment-security.md b/docs/deployment-security.md index 3edf1e3..bde89bb 100644 --- a/docs/deployment-security.md +++ b/docs/deployment-security.md @@ -27,7 +27,7 @@ Vercel CLI back to the workspace dependencies. workers and media/images so decoder workers and browser-generated resources keep working. - Google Fonts is allowed only for stylesheets and fonts. Vercel Analytics and Speed Insights use same-origin `/_vercel` scripts and collection endpoints in production. -- Inline styles remain allowed because Babylon, the XR emulator, and React components set runtime +- Inline styles remain allowed because Babylon and React components set runtime element styles. This exception does not authorize inline scripts. - `frame-ancestors 'none'` and `X-Frame-Options: DENY` both prevent framing. Variant's supported Launch Card flow is a top-level navigation, not an iframe, so it remains available. diff --git a/docs/webxr-emulation.md b/docs/webxr-emulation.md deleted file mode 100644 index 462e698..0000000 --- a/docs/webxr-emulation.md +++ /dev/null @@ -1,66 +0,0 @@ -# Desktop WebXR emulation - -A regular browser reports no `immersive-vr` device, so the immersive flow normally only runs -on a headset. For debugging, Exora can install the [Immersive Web Emulation Runtime][iwer] -(IWER) over `navigator.xr`: a synthetic Meta Quest 3 device plus an on-screen rig for the -headset and both controllers. The application code is untouched — the same -`WebXRDefaultExperience` path, movement feature, and pointer selection run in the tab. - -## Enabling it - -Start the dev server and append `?xr=emulate`: - -```bash -vp run dev -``` - -Then open . - -| Query | Effect | -| ------------- | ---------------------------------------------------------- | -| `?xr=emulate` | Install the emulated runtime as a Quest 2 (also `on`, `1`) | -| `?xr=quest3` | Install it as a Quest 3 instead | -| `?xr=stereo` | Install it and render both eyes side by side | -| `?xr=off` | Return to the browser's native runtime | - -The default is a Quest 2 because that is the headset Exora targets and `deriveRenderQuality` -reads the model out of the user agent: emulating the newer headset would quietly exercise the -wrong rendering budget in the one place the budget is the point. - -The choice is remembered in `sessionStorage`, so in-app navigation keeps emulating until you -pass `?xr=off` or open a new tab. - -## Using the session - -Press **ENTER IMMERSIVE VR** in the footer. Session status reads `IMMERSIVE VR ACTIVE · -EMULATED` whenever the runtime is emulated, so an emulated run is never mistaken for headset -telemetry. The IWER panels then drive the device: - -- the toolbar resets the pose, locks the pointer for mouse-look, toggles controllers versus - hands, and exits the session; -- each controller panel sets position, rotation, thumbstick, trigger, grip, and face buttons. - -IWER also reports a Quest user agent, so `deriveRenderQuality` selects the `quest` tier and -the HUD reads `FPS · QUEST`. That makes the headset rendering budget testable on a desktop, -but the frame rate is the desktop GPU's — never quote it as a Quest measurement. Use the -[Meta Quest smoke-test checklist](quest-testing.md) for real performance validation. - -## What the emulator gets wrong - -IWER declares `XRReferenceSpace.getOffsetReferenceSpace` as taking a raw `mat4`, where the -specification passes an `XRRigidTransform`. Handed a conforming transform it clones sixteen -`undefined`s and produces an all-NaN offset, which silently discards the move. - -Moving the wearer is done by writing the XR camera's position, and Babylon turns exactly that -write into an offset reference space — so unpatched, every scene's `focusXrRig` is a no-op under -emulation and the wearer stands at the tracking origin in every destination, whatever the scene -asked for. `installXrEmulator` therefore repairs the method before the DevUI is installed. If a -future IWER release adopts the spec signature, the shim becomes a harmless pass-through and can -be dropped. - -## Builds - -Emulation is compiled out of production builds. It is available on the dev server, and in a -build made with `VITE_XR_EMULATOR=1` when a deployed preview needs the same treatment. - -[iwer]: https://github.com/meta-quest/immersive-web-emulation-runtime diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a10b6e5..fd013d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,9 +15,6 @@ catalogs: '@hono/node-server': specifier: ^2.1.1 version: 2.1.1 - '@iwer/devui': - specifier: ^2.3.0 - version: 2.3.0 '@lhci/cli': specifier: ^0.15.1 version: 0.15.1 @@ -45,9 +42,6 @@ catalogs: hono: specifier: ^4.13.5 version: 4.13.5 - iwer: - specifier: ^2.3.0 - version: 2.3.0 playwright: specifier: ^1.62.1 version: 1.62.1 @@ -144,9 +138,6 @@ importers: specifier: 'catalog:' version: 19.2.8(react@19.2.8) devDependencies: - '@iwer/devui': - specifier: 'catalog:' - version: 2.3.0(@types/react@19.2.18)(iwer@2.3.0)(use-sync-external-store@1.6.0(react@19.2.8)) '@lhci/cli': specifier: 'catalog:' version: 0.15.1(supports-color@5.5.0) @@ -165,9 +156,6 @@ importers: '@vitest/browser-playwright': specifier: 'catalog:' version: 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@7.0.2)(yaml@2.9.0))(playwright@1.62.1)(vitest@4.1.11) - iwer: - specifier: 'catalog:' - version: 2.3.0 playwright: specifier: 'catalog:' version: 1.62.1 @@ -241,12 +229,6 @@ packages: resolution: {integrity: sha512-0dEVyRLM/lG4gp1R/Ik5bfPl/1wX00xFwd5KcNH602tzBa09oF7pbTKETEhR1GjZ75K6OJnYFu8II2dyMhONMw==} engines: {node: '>=16'} - '@emotion/is-prop-valid@1.4.0': - resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} - - '@emotion/memoize@0.9.0': - resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} - '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} @@ -427,11 +409,6 @@ packages: peerDependencies: hono: ^4 - '@iwer/devui@2.3.0': - resolution: {integrity: sha512-UfBFR3qOYt/DUsLik20uF7Jn0NrNN2L0+uiDGJzuKXpXa/2btNOjOj1iSaTthz95MK5/O2HqqC+ymjkRMHi5UQ==} - peerDependencies: - iwer: ^2.3.0 - '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -734,12 +711,6 @@ packages: '@paulirish/trace_engine@0.0.53': resolution: {integrity: sha512-PUl/vlfo08Oj804VI5nDPeSk9vyslnBlVzDDwFt8SUVxY8+KdGMkra/vrXjEEHe8gb7+RqVTfOIlGw0nyrEelA==} - '@pmndrs/handle@6.6.30': - resolution: {integrity: sha512-KPutdaLpCNPZoqXX2HvlRc+cQMETXdHTF0nKqFHMrJrne34apqELRVig53lO469rJhheedsRVepfdyo4g9Rukw==} - - '@pmndrs/pointer-events@6.6.30': - resolution: {integrity: sha512-YD2jWdgEqqAWJNOOZ1WZMunUby8jwuQyOMk8zbeqC39R4nkZnGvu1Pa5EMGbV1zd2vZTxXDxYcAVxtQuhVwf3g==} - '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1763,9 +1734,6 @@ packages: resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} engines: {node: '>= 14'} - gl-matrix@3.4.4: - resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==} - glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -1872,9 +1840,6 @@ packages: isomorphic-fetch@3.0.0: resolution: {integrity: sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==} - iwer@2.3.0: - resolution: {integrity: sha512-+aY/BXVIjztCtS4F1hAO2rQy0P1/0JbJ6Jq5QHVscUBBlv8xJlTkkyeg+uSiD9RQ7i5B6k1Blpcx84FQaI7Myw==} - jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -2007,11 +1972,6 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} - lucide-react@1.31.0: - resolution: {integrity: sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -2446,25 +2406,6 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - styled-components@6.5.3: - resolution: {integrity: sha512-vAX79sfpmUerP9fsTTxoTrBDE0RuO4ahjInyWYoohNgqrdg63Ms4q6FJ/o2Fyity82NU3cujOT8Ewl9TThBdwg==} - engines: {node: '>= 16'} - peerDependencies: - css-to-react-native: '>= 3.2.0' - react: '>= 16.8.0' - react-dom: '>= 16.8.0' - react-native: '>= 0.68.0' - peerDependenciesMeta: - css-to-react-native: - optional: true - react-dom: - optional: true - react-native: - optional: true - - stylis@4.3.6: - resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} - supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -2487,9 +2428,6 @@ packages: third-party-web@0.30.0: resolution: {integrity: sha512-p+PfyL5U0ediGzvwPzMSdD9MnC8rHW6nWJJVLIQvqN7RsSqfaZ+7n4c9GILqH36EYR0eXB9CzAeCRnKFr/9uJg==} - three@0.184.0: - resolution: {integrity: sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==} - through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -2577,11 +2515,6 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - use-sync-external-store@1.6.0: - resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} @@ -2655,9 +2588,6 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - webxr-layers-polyfill@1.1.0: - resolution: {integrity: sha512-GqWE6IFlut8a1Lnh9t1RPnOXud1rZ7wLPvWp7mqTDOYtgorXqlNMhEnI9EqjU33grBx0v3jm0Oc13opkAdmgMQ==} - whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} @@ -2760,39 +2690,6 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} - zustand@4.5.7: - resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} - engines: {node: '>=12.7.0'} - peerDependencies: - '@types/react': '>=16.8' - immer: '>=9.0.6' - react: '>=16.8' - peerDependenciesMeta: - '@types/react': - optional: true - immer: - optional: true - react: - optional: true - - zustand@5.0.15: - resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==} - engines: {node: '>=12.20.0'} - peerDependencies: - '@types/react': '>=18.0.0' - immer: '>=9.0.6' - react: '>=18.0.0' - use-sync-external-store: '>=1.2.0' - peerDependenciesMeta: - '@types/react': - optional: true - immer: - optional: true - react: - optional: true - use-sync-external-store: - optional: true - snapshots: '@babel/code-frame@7.29.7': @@ -2817,12 +2714,6 @@ snapshots: '@edge-runtime/primitives': 4.1.0 optional: true - '@emotion/is-prop-valid@1.4.0': - dependencies: - '@emotion/memoize': 0.9.0 - - '@emotion/memoize@0.9.0': {} - '@esbuild/aix-ppc64@0.28.2': optional: true @@ -2933,24 +2824,6 @@ snapshots: dependencies: hono: 4.13.5 - '@iwer/devui@2.3.0(@types/react@19.2.18)(iwer@2.3.0)(use-sync-external-store@1.6.0(react@19.2.8))': - dependencies: - '@pmndrs/handle': 6.6.30(@types/react@19.2.18)(react@19.2.8) - '@pmndrs/pointer-events': 6.6.30 - iwer: 2.3.0 - lucide-react: 1.31.0(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - styled-components: 6.5.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - three: 0.184.0 - zustand: 5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) - transitivePeerDependencies: - - '@types/react' - - css-to-react-native - - immer - - react-native - - use-sync-external-store - '@jridgewell/sourcemap-codec@1.5.5': {} '@lhci/cli@0.15.1(supports-color@5.5.0)': @@ -3141,17 +3014,6 @@ snapshots: legacy-javascript: 0.0.1 third-party-web: 0.30.0 - '@pmndrs/handle@6.6.30(@types/react@19.2.18)(react@19.2.8)': - dependencies: - '@pmndrs/pointer-events': 6.6.30 - zustand: 4.5.7(@types/react@19.2.18)(react@19.2.8) - transitivePeerDependencies: - - '@types/react' - - immer - - react - - '@pmndrs/pointer-events@6.6.30': {} - '@polka/url@1.0.0-next.29': {} '@puppeteer/browsers@2.13.2(supports-color@5.5.0)': @@ -4035,8 +3897,6 @@ snapshots: transitivePeerDependencies: - supports-color - gl-matrix@3.4.4: {} - glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -4149,11 +4009,6 @@ snapshots: transitivePeerDependencies: - encoding - iwer@2.3.0: - dependencies: - gl-matrix: 3.4.4 - webxr-layers-polyfill: 1.1.0 - jiti@2.7.0: optional: true @@ -4293,10 +4148,6 @@ snapshots: lru-cache@7.18.3: {} - lucide-react@1.31.0(react@19.2.8): - dependencies: - react: 19.2.8 - lz-string@1.5.0: {} magic-string@0.30.21: @@ -4754,17 +4605,6 @@ snapshots: dependencies: ansi-regex: 5.0.1 - styled-components@6.5.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8): - dependencies: - '@emotion/is-prop-valid': 1.4.0 - csstype: 3.2.3 - react: 19.2.8 - stylis: 4.3.6 - optionalDependencies: - react-dom: 19.2.8(react@19.2.8) - - stylis@4.3.6: {} - supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -4808,8 +4648,6 @@ snapshots: third-party-web@0.30.0: {} - three@0.184.0: {} - through@2.3.8: {} tinybench@2.9.0: {} @@ -4897,10 +4735,6 @@ snapshots: unpipe@1.0.0: {} - use-sync-external-store@1.6.0(react@19.2.8): - dependencies: - react: 19.2.8 - utils-merge@1.0.1: {} uuid@8.3.2: {} @@ -5001,10 +4835,6 @@ snapshots: webidl-conversions@3.0.1: {} - webxr-layers-polyfill@1.1.0: - dependencies: - gl-matrix: 3.4.4 - whatwg-fetch@3.6.20: {} whatwg-url@5.0.0: @@ -5129,16 +4959,3 @@ snapshots: zod@3.25.76: {} zod@4.4.3: {} - - zustand@4.5.7(@types/react@19.2.18)(react@19.2.8): - dependencies: - use-sync-external-store: 1.6.0(react@19.2.8) - optionalDependencies: - '@types/react': 19.2.18 - react: 19.2.8 - - zustand@5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)): - optionalDependencies: - '@types/react': 19.2.18 - react: 19.2.8 - use-sync-external-store: 1.6.0(react@19.2.8) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 29e7ae4..9c210b3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,7 +9,6 @@ catalog: "@babylonjs/core": ^9.23.0 "@fontsource-variable/archivo": ^5.3.0 "@hono/node-server": ^2.1.1 - "@iwer/devui": ^2.3.0 "@lhci/cli": ^0.15.1 "@types/node": ^24.13.3 "@types/react": ^19.2.18 @@ -20,7 +19,6 @@ catalog: # Vite+ requires optional browser providers to match its bundled Vitest exactly. "@vitest/browser-playwright": 4.1.11 hono: ^4.13.5 - iwer: ^2.3.0 playwright: ^1.62.1 react: ^19.2.8 react-dom: ^19.2.8 From 8d2053ef2951bb3f44470b1aaecaea69a10fcc05 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 3 Oct 2026 09:01:28 +0530 Subject: [PATCH 02/10] refactor(website): drop Quest controller input The scene host bound Quest controller buttons, thumbstick movement, and hand tracking so a headset could toggle VR and step onto a planet's surface. None of that applies to phone AR. Remove the controller bindings, the movement and hand-tracking features, and the planet's controller action. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 1 - apps/website/src/planet-scene.ts | 6 -- apps/website/src/scene-host.ts | 107 +------------------ apps/website/src/xr-controller-input.test.ts | 19 ---- apps/website/src/xr-controller-input.ts | 23 ---- apps/website/src/xr-runtime.ts | 3 - 6 files changed, 1 insertion(+), 158 deletions(-) delete mode 100644 apps/website/src/xr-controller-input.test.ts delete mode 100644 apps/website/src/xr-controller-input.ts diff --git a/README.md b/README.md index 5daefef..fbe3001 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,6 @@ The system diorama is where that discipline is most visible, because a picture o - **World Forge:** A seeded, reproducible builder for procedural planets, custom stars, and black holes, using the same recipe engines as the catalogs. - **Persistent immersive session:** The engine, scene, camera, and WebXR session outlive the active destination, so entering and leaving VR does not rebuild the viewing context. - **iPhone and Android AR:** The same immersive control prefers the established Meta Quest VR session, selects native `immersive-ar` on an AR-only phone, and uses Variant Launch's App Clip handoff on iPhone. AR presents the existing Babylon world at tabletop scale over camera passthrough, with hit-tested placement, drag repositioning, and pinch scaling — no GLB or USDZ export path. -- **Direct Quest shortcuts:** The controller trigger can enter or exit immersive VR when the runtime exposes it. Explore and World Forge remain browser-only; no browser UI is captured or rendered inside VR. - **Adaptive rendering budget:** Separate desktop, mobile, and Quest profiles govern shader octaves, sphere tessellation, star count, texture detail, and render scale. Immersive sessions raise fixed foveation after three seconds below 62 FPS and relax it again above 70. - **Graceful degradation:** A six-hour planet cache, a twelve-hour star cache, and a bundled local profile keep the experience alive when NASA, SIMBAD, or the API is unreachable. diff --git a/apps/website/src/planet-scene.ts b/apps/website/src/planet-scene.ts index 6726185..e08709a 100644 --- a/apps/website/src/planet-scene.ts +++ b/apps/website/src/planet-scene.ts @@ -2002,12 +2002,6 @@ export const createPlanetWorld = ( onViewModeChange("transition"); }; - planet.metadata = { - ...planet.metadata, - exoraXrPrimaryAction: () => { - if (host.isInXr() && viewState === "orbit") applyXrView(true, false); - }, - }; const renderObserver = scene.onBeforeRenderObservable.add(() => { const isInXr = host.isInXr(); const realDeltaSeconds = engine.getDeltaTime() / 1_000; diff --git a/apps/website/src/scene-host.ts b/apps/website/src/scene-host.ts index 6e14bc9..4fc1e4f 100644 --- a/apps/website/src/scene-host.ts +++ b/apps/website/src/scene-host.ts @@ -1,18 +1,13 @@ import { ArcRotateCamera } from "@babylonjs/core/Cameras/arcRotateCamera.js"; import "@babylonjs/core/Culling/ray.js"; -import { Ray } from "@babylonjs/core/Culling/ray.js"; import type { Engine } from "@babylonjs/core/Engines/engine.js"; import { Color3 } from "@babylonjs/core/Maths/math.color.js"; -import { Vector3 } from "@babylonjs/core/Maths/math.vector.js"; import { StandardMaterial } from "@babylonjs/core/Materials/standardMaterial.js"; import { Mesh } from "@babylonjs/core/Meshes/mesh.js"; import { MeshBuilder } from "@babylonjs/core/Meshes/meshBuilder.js"; import "@babylonjs/core/Meshes/instancedMesh.js"; import { Scene } from "@babylonjs/core/scene.js"; import type { WebXRCamera } from "@babylonjs/core/XR/webXRCamera.js"; -import type { WebXRAbstractMotionController } from "@babylonjs/core/XR/motionController/webXRAbstractMotionController.js"; -import type { WebXRControllerComponent } from "@babylonjs/core/XR/motionController/webXRControllerComponent.js"; -import type { WebXRInputSource } from "@babylonjs/core/XR/webXRInputSource.js"; import type { WebXRDefaultExperience } from "@babylonjs/core/XR/webXRDefaultExperience.js"; import { createArPresentation } from "./ar-presentation.ts"; import { @@ -38,7 +33,6 @@ import { TRAVEL_RECALL_MS, type TravelPhase, } from "./travel-transition.ts"; -import { advanceXrButtonPressGate, xrControllerAction } from "./xr-controller-input.ts"; import { createSceneMountSlot } from "./scene-mount.ts"; import { createSceneHostRegistry } from "./scene-host-registry.ts"; import { createPersistentScene, resetPersistentScene } from "./scene-lifecycle.ts"; @@ -51,7 +45,6 @@ import type * as XrRuntime from "./xr-runtime.ts"; export type { XrStatus } from "./scene-xr-integration.ts"; // One host owns the WebGL context across every destination and WebXR session. -const XR_MOVE_SPEED = 2.2; const VEIL_FADE_SECONDS = 0.22; export interface MountedWorld { @@ -157,9 +150,6 @@ const createSceneHost = (canvas: HTMLCanvasElement): SceneHost => { let xr: WebXRDefaultExperience | null = null; let xrRuntime: typeof XrRuntime | null = null; let xrInitialization: Promise | null = null; - const boundXrControllers = new WeakSet(); - const boundXrMotionControllers = new WeakSet(); - const xrImmersiveButtonArmed = new WeakMap(); let mountToken = 0; let sessionFoveation = profile.xrFixedFoveation; let qualitySampleSeconds = 0; @@ -179,18 +169,6 @@ const createSceneHost = (canvas: HTMLCanvasElement): SceneHost => { sessionManager.fixedFoveation = next; }; - const xrPrimaryRay = new Ray(Vector3.Zero(), Vector3.Forward(), 100); - const activateXrPrimary = (controller: WebXRInputSource): void => { - if (!isInXr || activeImmersiveMode !== "vr") return; - controller.getWorldPointerRayToRef(xrPrimaryRay); - const pick = scene.pickWithRay(xrPrimaryRay); - const metadata = pick?.pickedMesh?.metadata as - | { exoraXrPrimaryAction?: () => void } - | null - | undefined; - metadata?.exoraXrPrimaryAction?.(); - }; - scene.onBeforeRenderObservable.add(() => { const deltaSeconds = Math.min(engine.getDeltaTime() / 1_000, 0.05); @@ -438,12 +416,11 @@ const createSceneHost = (canvas: HTMLCanvasElement): SceneHost => { xrRuntime = runtime; const createdXr = await runtime.WebXRDefaultExperience.CreateAsync(scene, { disableDefaultUI: true, + disableHandTracking: true, disableNearInteraction: true, disablePointerSelection: true, disableTeleportation: true, - handSupportOptions: { handMeshes: { disableDefaultMeshes: true } }, inputOptions: { doNotLoadControllerMeshes: true }, - optionalFeatures: ["hand-tracking"], outputCanvasOptions: { canvasOptions: { alpha: true, @@ -460,88 +437,6 @@ const createSceneHost = (canvas: HTMLCanvasElement): SceneHost => { } xr = createdXr; - createdXr.baseExperience.featuresManager.enableFeature( - runtime.WebXRFeatureName.MOVEMENT, - "latest", - { - movementEnabled: true, - movementOrientationFollowsController: false, - movementOrientationFollowsViewerPose: true, - movementSpeed: XR_MOVE_SPEED, - movementThreshold: 0.16, - rotationEnabled: true, - rotationSpeed: 0.42, - rotationThreshold: 0.18, - xrInput: createdXr.input, - }, - ); - let changingVr = false; - const toggleVr = async (): Promise => { - if (changingVr) return; - changingVr = true; - try { - if (isInXr) { - if (activeImmersiveMode === "vr") { - await createdXr.baseExperience.sessionManager.exitXRAsync(); - } - return; - } - if (!xrIntegration.isVrSupported()) return; - activeImmersiveMode = "vr"; - await createdXr.baseExperience.enterXRAsync( - "immersive-vr", - "local-floor", - createdXr.renderTarget, - ); - } catch (error) { - activeImmersiveMode = null; - xrIntegration.markReady(); - console.error("[xr] controller VR toggle failed", error); - } finally { - changingVr = false; - } - }; - const bindXrMotionController = ( - controller: WebXRInputSource, - motionController: WebXRAbstractMotionController, - ): void => { - if (boundXrMotionControllers.has(motionController)) return; - boundXrMotionControllers.add(motionController); - for (const id of motionController.getComponentIds()) { - const component = motionController.getComponent(id); - if (!component) continue; - const action = xrControllerAction(id); - if (action !== "immersive" && action !== "primary") continue; - if (action === "immersive") xrImmersiveButtonArmed.set(component, !component.pressed); - component.onButtonStateChangedObservable.add((changed) => { - const pressed = changed.changes.pressed?.current; - if (id === "a-button" || id === "x-button") { - if (pressed === true) activateXrPrimary(controller); - return; - } - if (action === "immersive" && pressed !== undefined) { - const gate = advanceXrButtonPressGate( - xrImmersiveButtonArmed.get(component) ?? false, - pressed, - ); - xrImmersiveButtonArmed.set(component, gate.armed); - if (gate.activate) void toggleVr(); - return; - } - }); - } - }; - const bindXrController = (controller: WebXRInputSource): void => { - if (boundXrControllers.has(controller)) return; - boundXrControllers.add(controller); - const motionController = controller.motionController; - if (motionController) bindXrMotionController(controller, motionController); - controller.onMotionControllerInitObservable.add((initializedMotionController) => - bindXrMotionController(controller, initializedMotionController), - ); - }; - for (const controller of createdXr.input.controllers) bindXrController(controller); - createdXr.input.onControllerAddedObservable.add(bindXrController); createdXr.baseExperience.onInitialXRPoseSetObservable.add(() => { if (activeImmersiveMode === "vr") worldMount.current?.focusXrRig(true); }); diff --git a/apps/website/src/xr-controller-input.test.ts b/apps/website/src/xr-controller-input.test.ts deleted file mode 100644 index 321398f..0000000 --- a/apps/website/src/xr-controller-input.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { expect, test } from "vite-plus/test"; -import { advanceXrButtonPressGate, xrControllerAction } from "./xr-controller-input.ts"; - -test("ignores a trigger inherited from VR entry until it is released", () => { - expect(advanceXrButtonPressGate(false, true)).toEqual({ activate: false, armed: false }); - expect(advanceXrButtonPressGate(false, false)).toEqual({ activate: false, armed: true }); - expect(advanceXrButtonPressGate(true, true)).toEqual({ activate: true, armed: false }); -}); - -test("maps only controller input needed for immersive viewing", () => { - expect(xrControllerAction("a-button")).toBe("primary"); - expect(xrControllerAction("x-button")).toBe("primary"); - expect(xrControllerAction("xr-standard-trigger")).toBe("immersive"); - expect(xrControllerAction("b-button")).toBeNull(); - expect(xrControllerAction("y-button")).toBeNull(); - expect(xrControllerAction("xr-standard-squeeze")).toBeNull(); - expect(xrControllerAction("menu")).toBeNull(); - expect(xrControllerAction("xr-standard-thumbstick")).toBeNull(); -}); diff --git a/apps/website/src/xr-controller-input.ts b/apps/website/src/xr-controller-input.ts deleted file mode 100644 index f6f72d3..0000000 --- a/apps/website/src/xr-controller-input.ts +++ /dev/null @@ -1,23 +0,0 @@ -export type XrControllerAction = "immersive" | "primary" | null; - -export interface XrButtonPressGate { - activate: boolean; - armed: boolean; -} - -export const advanceXrButtonPressGate = (armed: boolean, pressed: boolean): XrButtonPressGate => { - if (!pressed) return { activate: false, armed: true }; - return { activate: armed, armed: false }; -}; - -export const xrControllerAction = (componentId: string): XrControllerAction => { - switch (componentId) { - case "a-button": - case "x-button": - return "primary"; - case "xr-standard-trigger": - return "immersive"; - default: - return null; - } -}; diff --git a/apps/website/src/xr-runtime.ts b/apps/website/src/xr-runtime.ts index 06eb667..181e079 100644 --- a/apps/website/src/xr-runtime.ts +++ b/apps/website/src/xr-runtime.ts @@ -1,7 +1,4 @@ export { WebXRDefaultExperience } from "@babylonjs/core/XR/webXRDefaultExperience.js"; -import "@babylonjs/core/XR/features/WebXRControllerMovement.js"; -import "@babylonjs/core/XR/features/WebXRControllerTeleportation.js"; -import "@babylonjs/core/XR/features/WebXRHandTracking.js"; import "@babylonjs/core/XR/features/WebXRDOMOverlay.js"; import "@babylonjs/core/XR/features/WebXRHitTest.js"; export { WebXRFeatureName } from "@babylonjs/core/XR/webXRFeaturesManager.js"; From 1e67bef8243dc79c9c47ea297a1dd10df0ac605e Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 3 Oct 2026 09:02:52 +0530 Subject: [PATCH 03/10] refactor(website): enter only immersive AR sessions The immersive control preferred a Meta Quest VR session whenever the device offered one, which kept a second session mode alive in the integration, the scene host, and the top bar. Only probe for immersive-ar, drop the VR destination and its status, and track whether an AR session is active instead of which mode is. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 4 +-- apps/website/src/App.browser.test.tsx | 7 ++-- .../components/ExplorationActions.test.tsx | 1 - apps/website/src/components/shell/TopBar.tsx | 7 ++-- apps/website/src/renderer-smoke.test.ts | 1 - apps/website/src/scene-host.ts | 36 ++++--------------- apps/website/src/scene-xr-integration.test.ts | 13 +++---- apps/website/src/scene-xr-integration.ts | 18 +++------- apps/website/src/variant-launch.test.ts | 19 ++++------ apps/website/src/variant-launch.ts | 11 ++---- 10 files changed, 32 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index fbe3001..244c60f 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,8 @@ The system diorama is where that discipline is most visible, because a picture o - **Surface vistas built from geology:** Standing on a world means standing in its landform provinces — crater-saturated highlands, dune seas, canyon systems, lava fields, fractured ice, folded ranges — mixed per world and blended across a patch that runs to a real horizon. For a Solar System body the provinces, palette, crater density, relief and sky are stated from mission science rather than inferred: Io has no impact craters because nothing on it survives long enough to keep one, Venus has almost none, Europa's relief is a fraction of a rocky planet's, and the Moon's sky is black at noon. Sunlight is baked per vertex into the terrain and everything standing on it, so a low sun throws real shadows, and each world is exposed the way a camera would expose for it. - **One resolved star implementation:** Photosphere with multi-scale convection, a supergranular magnetic network, limb-brightened faculae, deterministic starspots, limb darkening, corona, and glare — shared by the star scene and by every host star hanging in a planet's sky. - **World Forge:** A seeded, reproducible builder for procedural planets, custom stars, and black holes, using the same recipe engines as the catalogs. -- **Persistent immersive session:** The engine, scene, camera, and WebXR session outlive the active destination, so entering and leaving VR does not rebuild the viewing context. -- **iPhone and Android AR:** The same immersive control prefers the established Meta Quest VR session, selects native `immersive-ar` on an AR-only phone, and uses Variant Launch's App Clip handoff on iPhone. AR presents the existing Babylon world at tabletop scale over camera passthrough, with hit-tested placement, drag repositioning, and pinch scaling — no GLB or USDZ export path. +- **Persistent immersive session:** The engine, scene, camera, and WebXR session outlive the active destination, so entering and leaving AR does not rebuild the viewing context. +- **iPhone and Android AR:** The immersive control selects native `immersive-ar` on a phone that supports it and uses Variant Launch's App Clip handoff on iPhone. AR presents the existing Babylon world at tabletop scale over camera passthrough, with hit-tested placement, drag repositioning, and pinch scaling — no GLB or USDZ export path. - **Adaptive rendering budget:** Separate desktop, mobile, and Quest profiles govern shader octaves, sphere tessellation, star count, texture detail, and render scale. Immersive sessions raise fixed foveation after three seconds below 62 FPS and relax it again above 70. - **Graceful degradation:** A six-hour planet cache, a twelve-hour star cache, and a bundled local profile keep the experience alive when NASA, SIMBAD, or the API is unreachable. diff --git a/apps/website/src/App.browser.test.tsx b/apps/website/src/App.browser.test.tsx index e2042c2..a990a84 100644 --- a/apps/website/src/App.browser.test.tsx +++ b/apps/website/src/App.browser.test.tsx @@ -41,7 +41,6 @@ vi.mock("./scene-host.ts", () => { getFps: () => 60, isArSupported: () => false, isInXr: () => insideHeadset, - isVrSupported: () => false, prefersReducedMotion: () => false, mountWorld: async (build: () => unknown) => build(), onRendererStatus: (listener: (status: string) => void) => { @@ -53,7 +52,7 @@ vi.mock("./scene-host.ts", () => { return () => undefined; }, onXrStatus: (listener: (status: string) => void) => { - listener("ready-vr"); + listener("ready-ar"); return () => undefined; }, profile: { hardwareScalingLevel: 1, tier: "desktop" }, @@ -1120,7 +1119,7 @@ desktopTest( ); desktopTest( - "VR presents the active destination without a console and exits to the same browser view", + "AR presents the active destination without a console and exits to the same browser view", async () => { stubArchive(); mountApp(); @@ -1128,7 +1127,7 @@ desktopTest( await expect.element(destination).toBeVisible(); const destinationName = destination.element().textContent; - await userEvent.click(page.getByRole("button", { name: "Enter VR" })); + await userEvent.click(page.getByRole("button", { name: "View in AR" })); expect(page.getByRole("dialog")).not.toBeInTheDocument(); stubbedHost().setInXr(false); diff --git a/apps/website/src/components/ExplorationActions.test.tsx b/apps/website/src/components/ExplorationActions.test.tsx index 32af5fd..9931d58 100644 --- a/apps/website/src/components/ExplorationActions.test.tsx +++ b/apps/website/src/components/ExplorationActions.test.tsx @@ -105,7 +105,6 @@ test("the star view offers the same controls under the same names", () => { }); test.each([ - ["ready-vr", "Enter VR"], ["ready-ar", "View in AR"], ["ready-ar-launch", "View in AR"], ] as const)("the immersive control appears once %s is ready", (status, label) => { diff --git a/apps/website/src/components/shell/TopBar.tsx b/apps/website/src/components/shell/TopBar.tsx index 5e11501..c9e740f 100644 --- a/apps/website/src/components/shell/TopBar.tsx +++ b/apps/website/src/components/shell/TopBar.tsx @@ -10,10 +10,9 @@ const XR_LABELS: Partial> = { entering: "Entering…", "ready-ar": "View in AR", "ready-ar-launch": "View in AR", - "ready-vr": "Enter VR", }; -const XR_READY = new Set(["ready-ar", "ready-ar-launch", "ready-vr"]); +const XR_READY = new Set(["ready-ar", "ready-ar-launch"]); interface TopBarProps { host: SceneHost | null; @@ -22,8 +21,8 @@ interface TopBarProps { /* * Everything a view can be asked to do, in one row: go somewhere by name, browse, clear the view, - * or step into it with a headset. The immersive control only appears once the device can honour - * it — a disabled "not available" button is a promise the page cannot keep. + * or place it in the room around you. The immersive control only appears once the device can + * honour it — a disabled "not available" button is a promise the page cannot keep. */ export const TopBar = ({ host, xrStatus }: TopBarProps) => { const { chromeHidden, openDiscover, openPalette, toggleChrome } = useChrome(); diff --git a/apps/website/src/renderer-smoke.test.ts b/apps/website/src/renderer-smoke.test.ts index 1f22593..b559f46 100644 --- a/apps/website/src/renderer-smoke.test.ts +++ b/apps/website/src/renderer-smoke.test.ts @@ -181,7 +181,6 @@ const createHarness = ( isInXr: () => insideHeadset, prefersReducedMotion: () => false, onTravelPhase: () => () => undefined, - isVrSupported: () => false, xrCamera: () => null, } as unknown as SceneHost; diff --git a/apps/website/src/scene-host.ts b/apps/website/src/scene-host.ts index 4fc1e4f..a414bdb 100644 --- a/apps/website/src/scene-host.ts +++ b/apps/website/src/scene-host.ts @@ -38,7 +38,7 @@ import { createSceneHostRegistry } from "./scene-host-registry.ts"; import { createPersistentScene, resetPersistentScene } from "./scene-lifecycle.ts"; import { createRenderLifecycle } from "./scene-render-lifecycle.ts"; import { createXrIntegration, type XrStatus } from "./scene-xr-integration.ts"; -import { getVariantLaunchUrl, onVariantLaunchReady, type ImmersiveMode } from "./variant-launch.ts"; +import { getVariantLaunchUrl, onVariantLaunchReady } from "./variant-launch.ts"; import { VIRTUAL_BACKGROUND_LAYER_MASK } from "./virtual-background.ts"; import type * as XrRuntime from "./xr-runtime.ts"; @@ -68,7 +68,6 @@ export interface SceneHost { getFps: () => number; isArSupported: () => boolean; isInXr: () => boolean; - isVrSupported: () => boolean; onTravelPhase: (listener: (phase: TravelPhase) => void) => () => void; mountWorld: ( build: () => Promise | World, @@ -120,7 +119,6 @@ const createSceneHost = (canvas: HTMLCanvasElement): SceneHost => { let veilAlpha = 0; let veilTarget = 0; let settleVeil: (() => void) | null = null; - let rigAwaitingWorld = false; const resolveVeil = (): void => { const settle = settleVeil; @@ -144,7 +142,7 @@ const createSceneHost = (canvas: HTMLCanvasElement): SceneHost => { }); }; - let activeImmersiveMode: ImmersiveMode | null = null; + let arActive = false; let xrCameraLayerMask = 0x0fff_ffff; let disposed = false; let xr: WebXRDefaultExperience | null = null; @@ -172,11 +170,6 @@ const createSceneHost = (canvas: HTMLCanvasElement): SceneHost => { scene.onBeforeRenderObservable.add(() => { const deltaSeconds = Math.min(engine.getDeltaTime() / 1_000, 0.05); - if (rigAwaitingWorld && isInXr && activeImmersiveMode === "vr") { - rigAwaitingWorld = false; - worldMount.current?.focusXrRig(false); - } - if (veilAlpha !== veilTarget || veil.isEnabled()) { const step = deltaSeconds / VEIL_FADE_SECONDS; veilAlpha = @@ -401,7 +394,6 @@ const createSceneHost = (canvas: HTMLCanvasElement): SceneHost => { if (!world) return null; arPresentation.setWorld(worldMount.scope?.presentation ?? null); - rigAwaitingWorld = isInXr && activeImmersiveMode === "vr"; void fadeVeil(0); arriveAtWorld(token); if (!renderLifecycle.isRunning) renderLifecycle.renderFrame(); @@ -437,10 +429,6 @@ const createSceneHost = (canvas: HTMLCanvasElement): SceneHost => { } xr = createdXr; - createdXr.baseExperience.onInitialXRPoseSetObservable.add(() => { - if (activeImmersiveMode === "vr") worldMount.current?.focusXrRig(true); - }); - createdXr.baseExperience.onStateChangedObservable.add((state) => { if (disposed) return; if (state === runtime.WebXRState.ENTERING_XR) xrIntegration.markEntering(); @@ -454,16 +442,14 @@ const createSceneHost = (canvas: HTMLCanvasElement): SceneHost => { xrIntegration.markInXr(); } if (state === runtime.WebXRState.NOT_IN_XR) { - const endedMode = activeImmersiveMode; isInXr = false; if (renderLifecycle.suspensionCount > 0) renderLifecycle.stop(); - rigAwaitingWorld = false; veilTarget = 0; veilAlpha = 0; veilMaterial.alpha = 0; veil.setEnabled(false); resolveVeil(); - if (endedMode === "ar") { + if (arActive) { arPresentation.end(); createdXr.baseExperience.camera.layerMask = xrCameraLayerMask; createdXr.baseExperience.featuresManager.disableFeature( @@ -473,7 +459,7 @@ const createSceneHost = (canvas: HTMLCanvasElement): SceneHost => { runtime.WebXRFeatureName.DOM_OVERLAY, ); } - activeImmersiveMode = null; + arActive = false; worldMount.current?.restoreDesktopView(); xrIntegration.markReady(); } @@ -506,7 +492,6 @@ const createSceneHost = (canvas: HTMLCanvasElement): SceneHost => { getFps: () => engine.getFps(), isArSupported: xrIntegration.isArSupported, isInXr: () => isInXr, - isVrSupported: xrIntegration.isVrSupported, mountWorld, suspendRendering: renderLifecycle.suspend, xrCamera: () => xr?.baseExperience.camera ?? null, @@ -528,16 +513,7 @@ const createSceneHost = (canvas: HTMLCanvasElement): SceneHost => { const runtime = xrRuntime; if (!readyXr || !runtime) return; - activeImmersiveMode = destination.mode; - if (destination.mode === "vr") { - await readyXr.baseExperience.enterXRAsync( - "immersive-vr", - "local-floor", - readyXr.renderTarget, - ); - return; - } - + arActive = true; const features = readyXr.baseExperience.featuresManager; try { const hitTest = features.enableFeature( @@ -573,7 +549,7 @@ const createSceneHost = (canvas: HTMLCanvasElement): SceneHost => { readyXr.baseExperience.camera.layerMask = xrCameraLayerMask; features.disableFeature(runtime.WebXRFeatureName.HIT_TEST); features.disableFeature(runtime.WebXRFeatureName.DOM_OVERLAY); - activeImmersiveMode = null; + arActive = false; xrIntegration.markReady(); throw error; } diff --git a/apps/website/src/scene-xr-integration.test.ts b/apps/website/src/scene-xr-integration.test.ts index 6b08eec..577e2a6 100644 --- a/apps/website/src/scene-xr-integration.test.ts +++ b/apps/website/src/scene-xr-integration.test.ts @@ -6,7 +6,7 @@ const settle = async (): Promise => { await Promise.resolve(); }; -test("prefers native VR and keeps session status stable during capability refresh", async () => { +test("prefers native AR and keeps session status stable during capability refresh", async () => { let launchReady = (): void => undefined; const integration = createXrIntegration({ getLaunchUrl: () => "https://example.test/ar", @@ -14,14 +14,14 @@ test("prefers native VR and keeps session status stable during capability refres launchReady = listener; return vi.fn(); }, - xrSystem: () => ({ isSessionSupported: (mode) => Promise.resolve(mode === "immersive-vr") }), + xrSystem: () => ({ isSessionSupported: (mode) => Promise.resolve(mode === "immersive-ar") }), }); const statuses: string[] = []; integration.onStatus((status) => statuses.push(status)); await settle(); - expect(integration.destination?.mode).toBe("vr"); - expect(statuses.at(-1)).toBe("ready-vr"); + expect(integration.destination).toEqual({ launchUrl: null }); + expect(statuses.at(-1)).toBe("ready-ar"); integration.markInXr(); launchReady(); await settle(); @@ -37,10 +37,7 @@ test("uses the AR launch handoff and releases its readiness listener", async () }); await settle(); - expect(integration.destination).toEqual({ - launchUrl: "https://example.test/ar", - mode: "ar", - }); + expect(integration.destination).toEqual({ launchUrl: "https://example.test/ar" }); integration.dispose(); expect(stopWatching).toHaveBeenCalledOnce(); }); diff --git a/apps/website/src/scene-xr-integration.ts b/apps/website/src/scene-xr-integration.ts index 2a72254..a7c0d46 100644 --- a/apps/website/src/scene-xr-integration.ts +++ b/apps/website/src/scene-xr-integration.ts @@ -6,11 +6,10 @@ export type XrStatus = | "in-xr" | "ready-ar" | "ready-ar-launch" - | "ready-vr" | "unavailable"; interface XrSystemLike { - isSessionSupported: (mode: "immersive-ar" | "immersive-vr") => Promise; + isSessionSupported: (mode: "immersive-ar") => Promise; } export interface XrIntegrationOptions { @@ -23,7 +22,6 @@ export interface XrIntegration { readonly destination: ImmersiveDestination; dispose: () => void; isArSupported: () => boolean; - isVrSupported: () => boolean; markEntering: () => void; markInXr: () => void; markReady: () => void; @@ -32,7 +30,6 @@ export interface XrIntegration { const statusFor = (destination: ImmersiveDestination): XrStatus => { if (!destination) return "unavailable"; - if (destination.mode === "vr") return "ready-vr"; return destination.launchUrl ? "ready-ar-launch" : "ready-ar"; }; @@ -46,10 +43,8 @@ export const createXrIntegration = ({ let destination: ImmersiveDestination = chooseImmersiveDestination({ ar: false, launchUrl: getLaunchUrl(), - vr: false, }); let status: XrStatus = "checking"; - let vrSupported = false; const listeners = new Set<(status: XrStatus) => void>(); const setStatus = (next: XrStatus): void => { @@ -61,17 +56,13 @@ export const createXrIntegration = ({ const refresh = async (): Promise => { const system = xrSystem(); - [arSupported, vrSupported] = system - ? await Promise.all([ - system.isSessionSupported("immersive-ar").catch(() => false), - system.isSessionSupported("immersive-vr").catch(() => false), - ]) - : [false, false]; + arSupported = system + ? await system.isSessionSupported("immersive-ar").catch(() => false) + : false; if (disposed) return; destination = chooseImmersiveDestination({ ar: arSupported, launchUrl: getLaunchUrl(), - vr: vrSupported, }); if (status !== "entering" && status !== "in-xr") markReady(); }; @@ -90,7 +81,6 @@ export const createXrIntegration = ({ listeners.clear(); }, isArSupported: () => arSupported, - isVrSupported: () => vrSupported, markEntering: () => setStatus("entering"), markInXr: () => setStatus("in-xr"), markReady, diff --git a/apps/website/src/variant-launch.test.ts b/apps/website/src/variant-launch.test.ts index 9290a75..bed9d98 100644 --- a/apps/website/src/variant-launch.test.ts +++ b/apps/website/src/variant-launch.test.ts @@ -1,25 +1,18 @@ import { expect, test } from "vite-plus/test"; import { chooseImmersiveDestination } from "./variant-launch.ts"; -test("Quest keeps immersive VR when a device reports both modes", () => { - expect( - chooseImmersiveDestination({ ar: true, launchUrl: "https://launch.example", vr: true }), - ).toEqual({ launchUrl: null, mode: "vr" }); -}); - -test("an AR-only phone enters immersive AR directly", () => { - expect(chooseImmersiveDestination({ ar: true, launchUrl: null, vr: false })).toEqual({ +test("a phone with native WebXR enters immersive AR directly", () => { + expect(chooseImmersiveDestination({ ar: true, launchUrl: "https://launch.example" })).toEqual({ launchUrl: null, - mode: "ar", }); }); test("an iPhone without native WebXR uses its Variant Launch Card", () => { - expect( - chooseImmersiveDestination({ ar: false, launchUrl: "https://launch.example", vr: false }), - ).toEqual({ launchUrl: "https://launch.example", mode: "ar" }); + expect(chooseImmersiveDestination({ ar: false, launchUrl: "https://launch.example" })).toEqual({ + launchUrl: "https://launch.example", + }); }); test("a device with neither mode keeps the unavailable fallback", () => { - expect(chooseImmersiveDestination({ ar: false, launchUrl: null, vr: false })).toBeNull(); + expect(chooseImmersiveDestination({ ar: false, launchUrl: null })).toBeNull(); }); diff --git a/apps/website/src/variant-launch.ts b/apps/website/src/variant-launch.ts index 1ab1fa3..8ce4cf1 100644 --- a/apps/website/src/variant-launch.ts +++ b/apps/website/src/variant-launch.ts @@ -1,5 +1,3 @@ -export type ImmersiveMode = "ar" | "vr"; - interface VariantLaunchSdk { getLaunchUrl: (targetUrl: string) => string; } @@ -20,19 +18,16 @@ declare global { export interface ImmersiveSupport { ar: boolean; launchUrl: string | null; - vr: boolean; } -export type ImmersiveDestination = { launchUrl: string | null; mode: ImmersiveMode } | null; +export type ImmersiveDestination = { launchUrl: string | null } | null; export const chooseImmersiveDestination = ({ ar, launchUrl, - vr, }: ImmersiveSupport): ImmersiveDestination => { - if (vr) return { launchUrl: null, mode: "vr" }; - if (ar) return { launchUrl: null, mode: "ar" }; - return launchUrl ? { launchUrl, mode: "ar" } : null; + if (ar) return { launchUrl: null }; + return launchUrl ? { launchUrl } : null; }; export const getVariantLaunchUrl = (): string | null => { From 732ce0857c8c75ca20aebdf643c3a94586cb31e9 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 3 Oct 2026 09:03:32 +0530 Subject: [PATCH 04/10] refactor(website): stop placing a VR camera rig in each scene Every destination carried a focusXrRig hook that stood a headset wearer at a fixed spot, and the planet walked that rig over its surface with the keyboard. AR places the world on a real surface instead, so remove the rig hooks, their standing positions, and the host's XR camera accessor. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/website/src/App.browser.test.tsx | 2 -- apps/website/src/black-hole-scene.ts | 14 -------- apps/website/src/planet-scene.ts | 48 ++++--------------------- apps/website/src/renderer-smoke.test.ts | 1 - apps/website/src/scene-host.ts | 4 --- apps/website/src/solar-region-scene.ts | 7 ---- apps/website/src/star-scene.ts | 10 ------ apps/website/src/subsystem-scene.ts | 8 ----- apps/website/src/system-scene.ts | 15 -------- 9 files changed, 7 insertions(+), 102 deletions(-) diff --git a/apps/website/src/App.browser.test.tsx b/apps/website/src/App.browser.test.tsx index a990a84..c0ae085 100644 --- a/apps/website/src/App.browser.test.tsx +++ b/apps/website/src/App.browser.test.tsx @@ -20,7 +20,6 @@ const planetSceneStub = vi.hoisted(() => ({ const mountedWorld = () => ({ dispose: () => undefined, - focusXrRig: () => undefined, restoreDesktopView: () => undefined, setEphemeris: () => undefined, setEphemerisTime: () => undefined, @@ -71,7 +70,6 @@ vi.mock("./scene-host.ts", () => { }; }, scene: null, - xrCamera: () => null, }; return { acquireSceneHost: () => host, recreateSceneHost: () => host }; diff --git a/apps/website/src/black-hole-scene.ts b/apps/website/src/black-hole-scene.ts index 2687940..a105c5f 100644 --- a/apps/website/src/black-hole-scene.ts +++ b/apps/website/src/black-hole-scene.ts @@ -13,7 +13,6 @@ import type { MountedWorld, SceneHost } from "./scene-host.ts"; import { createStarfield } from "./star-visuals.ts"; const BLACK_HOLE_POSITION = new Vector3(0, 0.7, 7.5); -const XR_BLACK_HOLE_STAND = new Vector3(0, 0, -10); const COMPANION_OFFSET = new Vector3(-7.8, 2.6, 0.4); const SHADOW_RADIUS = 2.125; @@ -122,20 +121,7 @@ export const createBlackHoleWorld = ( }); const firstFrameObserver = scene.onAfterRenderObservable.addOnce(onFirstFrame); - const placeXrCamera = (initial: boolean): void => { - const rig = host.xrCamera(); - if (!rig) return; - const headOffset = initial ? 0 : rig.realWorldHeight; - rig.position.set( - XR_BLACK_HOLE_STAND.x, - XR_BLACK_HOLE_STAND.y + headOffset, - XR_BLACK_HOLE_STAND.z, - ); - rig.setTarget(BLACK_HOLE_POSITION); - }; - return { - focusXrRig: placeXrCamera, restoreDesktopView: () => camera.attachControl(canvas, true), dispose: () => { scene.onBeforeRenderObservable.remove(renderObserver); diff --git a/apps/website/src/planet-scene.ts b/apps/website/src/planet-scene.ts index e08709a..db2fa56 100644 --- a/apps/website/src/planet-scene.ts +++ b/apps/website/src/planet-scene.ts @@ -35,7 +35,6 @@ import { } from "./travel-transition.ts"; const PLANET_POSITION = new Vector3(0, 1.35, 9.5); -const XR_ORBIT_STAND = new Vector3(0, 0, -7.4); const LIGHT_DIRECTION = new Vector3(-0.82, 0.3, -0.38).normalize(); const DESKTOP_MOVE_SPEED = 5.2; const SURFACE_GROUND_ORIGIN_Z = 18; @@ -50,7 +49,6 @@ const SURFACE_RESTING_RADIUS = 12.8; const ORBIT_CLIMB_RADIUS = 21.6; const ORBIT_ENTRY_RADIUS = 10.6; const ORBIT_RETURN_RADIUS = 12.2; -const XR_SURFACE_STAND = new Vector3(0, 0, 12); const SURFACE_RESTING_BETA = 1.37; const SURFACE_FIELD_OF_VIEW = 1.02; const ORBIT_FIELD_OF_VIEW = 0.8; @@ -2013,6 +2011,7 @@ export const createPlanetWorld = ( const movementZ = Number(pressedMovementKeys.has("KeyW")) - Number(pressedMovementKeys.has("KeyS")); if ( + !isInXr && (movementX !== 0 || movementZ !== 0) && viewState !== "entering" && viewState !== "leaving" @@ -2030,23 +2029,13 @@ export const createPlanetWorld = ( .normalize() .scaleInPlace(DESKTOP_MOVE_SPEED * deltaSeconds); - if (isInXr) { - activeCamera.position.addInPlace(movementDelta); - const rig = host.xrCamera(); - if (rig && viewState === "surface") { - const standing = - surfaceEnvironment.groundHeightAt(rig.position.x, rig.position.z) + rig.realWorldHeight; - rig.position.y += (standing - rig.position.y) * Math.min(1, deltaSeconds * 6); - } + camera.target.addInPlace(movementDelta); + if (viewState === "surface") { + camera.target.x = Math.min(55, Math.max(-55, camera.target.x)); + camera.target.z = Math.min(76, Math.max(-40, camera.target.z)); + surfaceTarget.copyFrom(camera.target); } else { - camera.target.addInPlace(movementDelta); - if (viewState === "surface") { - camera.target.x = Math.min(55, Math.max(-55, camera.target.x)); - camera.target.z = Math.min(76, Math.max(-40, camera.target.z)); - surfaceTarget.copyFrom(camera.target); - } else { - orbitTarget.copyFrom(camera.target); - } + orbitTarget.copyFrom(camera.target); } } @@ -2131,20 +2120,6 @@ export const createPlanetWorld = ( } }); - const placeXrCamera = (surface: boolean, initial: boolean): void => { - const rig = host.xrCamera(); - if (!rig) return; - const headOffset = initial ? 0 : rig.realWorldHeight; - if (surface) { - const groundY = surfaceEnvironment.groundHeightAt(XR_SURFACE_STAND.x, XR_SURFACE_STAND.z); - rig.position.set(XR_SURFACE_STAND.x, groundY + headOffset, XR_SURFACE_STAND.z); - rig.setTarget(new Vector3(XR_SURFACE_STAND.x, groundY + 1.4, XR_SURFACE_STAND.z + 18)); - } else { - rig.position.set(XR_ORBIT_STAND.x, XR_ORBIT_STAND.y + headOffset, XR_ORBIT_STAND.z); - rig.setTarget(PLANET_POSITION); - } - }; - const syncDesktopCamera = (surface: boolean): void => { camera.fov = surface ? SURFACE_FIELD_OF_VIEW : ORBIT_FIELD_OF_VIEW; camera.lowerRadiusLimit = surface ? 7.5 : 10.5; @@ -2158,17 +2133,8 @@ export const createPlanetWorld = ( camera.attachControl(canvas, true); }; - const applyXrView = (surface: boolean, initial: boolean): void => { - viewState = surface ? "surface" : "orbit"; - viewTransitionSeconds = 0; - applyViewEnvironment(surface); - placeXrCamera(surface, initial); - onViewModeChange(surface ? "surface" : "orbit"); - }; - return { farthestView: () => (viewState === "surface" ? SURFACE_DEPARTURE_RADIUS : undefined), - focusXrRig: (initial) => applyXrView(viewState === "surface", initial), restoreDesktopView: () => syncDesktopCamera(viewState === "surface"), dispose: () => { camera.fov = ORBIT_FIELD_OF_VIEW; diff --git a/apps/website/src/renderer-smoke.test.ts b/apps/website/src/renderer-smoke.test.ts index b559f46..247758b 100644 --- a/apps/website/src/renderer-smoke.test.ts +++ b/apps/website/src/renderer-smoke.test.ts @@ -181,7 +181,6 @@ const createHarness = ( isInXr: () => insideHeadset, prefersReducedMotion: () => false, onTravelPhase: () => () => undefined, - xrCamera: () => null, } as unknown as SceneHost; return { engine, host, scene }; diff --git a/apps/website/src/scene-host.ts b/apps/website/src/scene-host.ts index a414bdb..6749aeb 100644 --- a/apps/website/src/scene-host.ts +++ b/apps/website/src/scene-host.ts @@ -7,7 +7,6 @@ import { Mesh } from "@babylonjs/core/Meshes/mesh.js"; import { MeshBuilder } from "@babylonjs/core/Meshes/meshBuilder.js"; import "@babylonjs/core/Meshes/instancedMesh.js"; import { Scene } from "@babylonjs/core/scene.js"; -import type { WebXRCamera } from "@babylonjs/core/XR/webXRCamera.js"; import type { WebXRDefaultExperience } from "@babylonjs/core/XR/webXRDefaultExperience.js"; import { createArPresentation } from "./ar-presentation.ts"; import { @@ -50,7 +49,6 @@ const VEIL_FADE_SECONDS = 0.22; export interface MountedWorld { farthestView?: () => number | undefined; dispose: () => void; - focusXrRig: (initial: boolean) => void; restoreDesktopView: () => void; } @@ -76,7 +74,6 @@ export interface SceneHost { onRendererStatus: (listener: (status: RendererStatus) => void) => () => void; prefersReducedMotion: () => boolean; suspendRendering: () => () => void; - xrCamera: () => WebXRCamera | null; } const createSceneHost = (canvas: HTMLCanvasElement): SceneHost => { @@ -494,7 +491,6 @@ const createSceneHost = (canvas: HTMLCanvasElement): SceneHost => { isInXr: () => isInXr, mountWorld, suspendRendering: renderLifecycle.suspend, - xrCamera: () => xr?.baseExperience.camera ?? null, onXrStatus: xrIntegration.onStatus, onRendererStatus: renderLifecycle.onStatus, onTravelPhase: (listener) => { diff --git a/apps/website/src/solar-region-scene.ts b/apps/website/src/solar-region-scene.ts index 1569258..6896449 100644 --- a/apps/website/src/solar-region-scene.ts +++ b/apps/website/src/solar-region-scene.ts @@ -193,15 +193,8 @@ export const createSolarRegionWorld = ( }); const firstFrameObserver = scene.onAfterRenderObservable.addOnce(onFirstFrame); - const placeXrCamera = (initial: boolean): void => { - const rig = host.xrCamera(); - if (!rig) return; - rig.position.set(0, initial ? 0 : rig.realWorldHeight, -17); - rig.setTarget(Vector3.Zero()); - }; return { farthestView: () => camera.upperRadiusLimit ?? undefined, - focusXrRig: placeXrCamera, restoreDesktopView: () => camera.attachControl(canvas, true), dispose: () => { scene.onBeforeRenderObservable.remove(renderObserver); diff --git a/apps/website/src/star-scene.ts b/apps/website/src/star-scene.ts index 8c422ee..d719870 100644 --- a/apps/website/src/star-scene.ts +++ b/apps/website/src/star-scene.ts @@ -10,7 +10,6 @@ import { skyViewpointFrom } from "./sky-catalog.ts"; import { createStarfield } from "./star-visuals.ts"; const STAR_POSITION = new Vector3(0, 0.8, 7.5); -const XR_STAR_STAND = new Vector3(0, 0, -9); interface StarWorldOptions { onFirstFrame: () => void; @@ -78,16 +77,7 @@ export const createStarWorld = ( const firstFrameObserver = scene.onAfterRenderObservable.addOnce(onFirstFrame); - const placeXrCamera = (initial: boolean): void => { - const rig = host.xrCamera(); - if (!rig) return; - const headOffset = initial ? 0 : rig.realWorldHeight; - rig.position.set(XR_STAR_STAND.x, XR_STAR_STAND.y + headOffset, XR_STAR_STAND.z); - rig.setTarget(STAR_POSITION); - }; - return { - focusXrRig: placeXrCamera, restoreDesktopView: () => camera.attachControl(canvas, true), dispose: () => { scene.onBeforeRenderObservable.remove(renderObserver); diff --git a/apps/website/src/subsystem-scene.ts b/apps/website/src/subsystem-scene.ts index 69908ff..6b96585 100644 --- a/apps/website/src/subsystem-scene.ts +++ b/apps/website/src/subsystem-scene.ts @@ -431,16 +431,8 @@ export const createSubsystemWorld = ( }); const firstFrameObserver = scene.onAfterRenderObservable.addOnce(onFirstFrame); - const placeXrCamera = (initial: boolean): void => { - const rig = host.xrCamera(); - if (!rig) return; - rig.position.set(0, initial ? 0 : rig.realWorldHeight, -outerOrbit * 1.55); - rig.setTarget(Vector3.Zero()); - }; - return { farthestView: () => camera.upperRadiusLimit ?? undefined, - focusXrRig: placeXrCamera, restoreDesktopView: () => camera.attachControl(canvas, true), dispose: () => { scene.onBeforeRenderObservable.remove(renderObserver); diff --git a/apps/website/src/system-scene.ts b/apps/website/src/system-scene.ts index 8241d0f..eff881d 100644 --- a/apps/website/src/system-scene.ts +++ b/apps/website/src/system-scene.ts @@ -40,9 +40,6 @@ import { } from "./system-layout.ts"; const SYSTEM_CENTRE = new Vector3(0, 1.4, 0); -const XR_SYSTEM_STAND = new Vector3(0, 0, -16.5); -const XR_SYSTEM_ELEVATION_RADIANS = (14 * Math.PI) / 180; -const NOMINAL_EYE_HEIGHT = 1.6; const ORBIT_THICKNESS = 0.011; const HABITABLE_ZONE_COLOR = new Color3(0.36, 0.86, 0.58); const HABITABLE_ZONE_ALPHA = 0.16; @@ -497,19 +494,7 @@ export const createSystemWorld = ( const firstFrameObserver = scene.onAfterRenderObservable.addOnce(onFirstFrame); - const placeXrCamera = (initial: boolean, elevation = XR_SYSTEM_ELEVATION_RADIANS): void => { - const rig = host.xrCamera(); - if (!rig) return; - const headOffset = initial ? 0 : rig.realWorldHeight; - const standoff = Math.max(XR_SYSTEM_STAND.z, -(outerReach + 3.5)); - const eyeHeight = SYSTEM_CENTRE.y + Math.abs(standoff - SYSTEM_CENTRE.z) * Math.tan(elevation); - const deckY = eyeHeight - NOMINAL_EYE_HEIGHT + headOffset; - rig.position.set(XR_SYSTEM_STAND.x, deckY, standoff); - rig.setTarget(new Vector3(XR_SYSTEM_STAND.x, deckY, SYSTEM_CENTRE.z)); - }; - return { - focusXrRig: placeXrCamera, layout, onSightlineCrossing: (listener) => { crossingListeners.add(listener); From d8f672113abff8a9ce7a45e28107723bf9024ec6 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 3 Oct 2026 09:04:17 +0530 Subject: [PATCH 05/10] refactor(website): remove the in-headset travel veil A black sphere faded in around the wearer's head while a VR session travelled between destinations. AR has no way to travel mid-session, so the veil never shows. Remove the sphere and its fade from the scene host. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/website/src/scene-host.ts | 75 +--------------------------------- 1 file changed, 1 insertion(+), 74 deletions(-) diff --git a/apps/website/src/scene-host.ts b/apps/website/src/scene-host.ts index 6749aeb..8db9328 100644 --- a/apps/website/src/scene-host.ts +++ b/apps/website/src/scene-host.ts @@ -1,10 +1,6 @@ import { ArcRotateCamera } from "@babylonjs/core/Cameras/arcRotateCamera.js"; import "@babylonjs/core/Culling/ray.js"; import type { Engine } from "@babylonjs/core/Engines/engine.js"; -import { Color3 } from "@babylonjs/core/Maths/math.color.js"; -import { StandardMaterial } from "@babylonjs/core/Materials/standardMaterial.js"; -import { Mesh } from "@babylonjs/core/Meshes/mesh.js"; -import { MeshBuilder } from "@babylonjs/core/Meshes/meshBuilder.js"; import "@babylonjs/core/Meshes/instancedMesh.js"; import { Scene } from "@babylonjs/core/scene.js"; import type { WebXRDefaultExperience } from "@babylonjs/core/XR/webXRDefaultExperience.js"; @@ -44,7 +40,6 @@ import type * as XrRuntime from "./xr-runtime.ts"; export type { XrStatus } from "./scene-xr-integration.ts"; // One host owns the WebGL context across every destination and WebXR session. -const VEIL_FADE_SECONDS = 0.22; export interface MountedWorld { farthestView?: () => number | undefined; @@ -92,53 +87,8 @@ const createSceneHost = (canvas: HTMLCanvasElement): SceneHost => { let isInXr = false; - const veilMaterial = new StandardMaterial("travelVeilMaterial", scene); - veilMaterial.disableLighting = true; - veilMaterial.diffuseColor = Color3.Black(); - veilMaterial.emissiveColor = Color3.Black(); - veilMaterial.specularColor = Color3.Black(); - veilMaterial.disableDepthWrite = true; - veilMaterial.alpha = 0; - const veil = MeshBuilder.CreateSphere( - "travelVeil", - { diameter: 0.8, segments: 10, sideOrientation: Mesh.BACKSIDE }, - scene, - ); - veil.material = veilMaterial; - veil.isPickable = false; - veil.applyFog = false; - veil.alwaysSelectAsActiveMesh = true; - veil.renderingGroupId = 3; - veil.setEnabled(false); - const arPresentation = createArPresentation(scene); - let veilAlpha = 0; - let veilTarget = 0; - let settleVeil: (() => void) | null = null; - - const resolveVeil = (): void => { - const settle = settleVeil; - settleVeil = null; - settle?.(); - }; - - const fadeVeil = async (target: number): Promise => { - if (!isInXr) { - veilTarget = 0; - veilAlpha = 0; - veilMaterial.alpha = 0; - veil.setEnabled(false); - return; - } - resolveVeil(); - veilTarget = target; - if (veilAlpha === veilTarget) return; - await new Promise((resolve) => { - settleVeil = resolve; - }); - }; - let arActive = false; let xrCameraLayerMask = 0x0fff_ffff; let disposed = false; @@ -167,19 +117,6 @@ const createSceneHost = (canvas: HTMLCanvasElement): SceneHost => { scene.onBeforeRenderObservable.add(() => { const deltaSeconds = Math.min(engine.getDeltaTime() / 1_000, 0.05); - if (veilAlpha !== veilTarget || veil.isEnabled()) { - const step = deltaSeconds / VEIL_FADE_SECONDS; - veilAlpha = - veilTarget > veilAlpha - ? Math.min(veilTarget, veilAlpha + step) - : Math.max(veilTarget, veilAlpha - step); - veilMaterial.alpha = veilAlpha; - veil.setEnabled(veilAlpha > 0.001); - const eye = scene.activeCamera?.globalPosition; - if (eye) veil.position.copyFrom(eye); - if (veilAlpha === veilTarget) resolveVeil(); - } - qualitySampleSeconds += deltaSeconds; if (qualitySampleSeconds >= 3) { qualitySampleSeconds = 0; @@ -370,17 +307,13 @@ const createSceneHost = (canvas: HTMLCanvasElement): SceneHost => { build: () => Promise | World, ): Promise => { const token = (mountToken += 1); - if (worldMount.current) { - await fadeVeil(1); - await departFromWorld(); - } + if (worldMount.current) await departFromWorld(); if (token !== mountToken || disposed) return null; let world: World | null; try { world = await worldMount.replace(build, () => token === mountToken && !disposed); } catch (error) { - void fadeVeil(0); endGlide(null, false); departure = null; travelOrigin = null; @@ -391,7 +324,6 @@ const createSceneHost = (canvas: HTMLCanvasElement): SceneHost => { if (!world) return null; arPresentation.setWorld(worldMount.scope?.presentation ?? null); - void fadeVeil(0); arriveAtWorld(token); if (!renderLifecycle.isRunning) renderLifecycle.renderFrame(); engine.performanceMonitor.reset(); @@ -441,11 +373,6 @@ const createSceneHost = (canvas: HTMLCanvasElement): SceneHost => { if (state === runtime.WebXRState.NOT_IN_XR) { isInXr = false; if (renderLifecycle.suspensionCount > 0) renderLifecycle.stop(); - veilTarget = 0; - veilAlpha = 0; - veilMaterial.alpha = 0; - veil.setEnabled(false); - resolveVeil(); if (arActive) { arPresentation.end(); createdXr.baseExperience.camera.layerMask = xrCameraLayerMask; From a571a3c452f2be74be1748e8d0695dc8234d09d2 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 3 Oct 2026 09:04:36 +0530 Subject: [PATCH 06/10] refactor(website): present the immersive control as an AR action The immersive button still carried an enter-vr test id and a VR headset icon even though it now only places the world in AR. Rename the test id to enter-ar and draw a cube in place of the headset. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/website/src/App.browser.test.tsx | 2 +- apps/website/src/components/ExplorationActions.test.tsx | 4 ++-- apps/website/src/components/shell/TopBar.tsx | 4 ++-- apps/website/src/components/ui/Icon.tsx | 4 +--- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/website/src/App.browser.test.tsx b/apps/website/src/App.browser.test.tsx index c0ae085..134b46a 100644 --- a/apps/website/src/App.browser.test.tsx +++ b/apps/website/src/App.browser.test.tsx @@ -1217,7 +1217,7 @@ test("the navigation deck keeps an even gap between every control", async () => .querySelector('[data-testid="clear-view"]')! .getBoundingClientRect(); const xr = document - .querySelector('[data-testid="enter-vr"]')! + .querySelector('[data-testid="enter-ar"]')! .getBoundingClientRect(); const firstGap = clearView.left - discover.right; const secondGap = xr.left - clearView.right; diff --git a/apps/website/src/components/ExplorationActions.test.tsx b/apps/website/src/components/ExplorationActions.test.tsx index 9931d58..0096f36 100644 --- a/apps/website/src/components/ExplorationActions.test.tsx +++ b/apps/website/src/components/ExplorationActions.test.tsx @@ -109,7 +109,7 @@ test.each([ ["ready-ar-launch", "View in AR"], ] as const)("the immersive control appears once %s is ready", (status, label) => { const markup = topBarMarkup(status); - const immersive = deckButtons(markup).find((button) => button.includes('data-testid="enter-vr"')); + const immersive = deckButtons(markup).find((button) => button.includes('data-testid="enter-ar"')); expect(deckNames(markup)).toContain(label); expect(immersive).toBeDefined(); @@ -119,7 +119,7 @@ test.each([ test.each(["checking", "unavailable", "in-xr"] as const)( "the immersive control stays away while XR is %s", (status) => { - expect(topBarMarkup(status)).not.toContain('data-testid="enter-vr"'); + expect(topBarMarkup(status)).not.toContain('data-testid="enter-ar"'); }, ); diff --git a/apps/website/src/components/shell/TopBar.tsx b/apps/website/src/components/shell/TopBar.tsx index c9e740f..8464623 100644 --- a/apps/website/src/components/shell/TopBar.tsx +++ b/apps/website/src/components/shell/TopBar.tsx @@ -75,8 +75,8 @@ export const TopBar = ({ host, xrStatus }: TopBarProps) => { {xrLabel ? (