diff --git a/deps/cloudxr/webxr_client/helpers/react/CloudXRComponent.tsx b/deps/cloudxr/webxr_client/helpers/react/CloudXRComponent.tsx index 6b9eea500..61f355966 100644 --- a/deps/cloudxr/webxr_client/helpers/react/CloudXRComponent.tsx +++ b/deps/cloudxr/webxr_client/helpers/react/CloudXRComponent.tsx @@ -42,6 +42,7 @@ import { useThree, useFrame } from '@react-three/fiber'; import { useXR } from '@react-three/xr'; import { useRef, useEffect } from 'react'; import type { WebGLRenderer } from 'three'; +import { applyTargetFrameRate, FrameRateSession } from '../../src/config/frameRate'; /** * Props for the CloudXRComponent. @@ -150,13 +151,20 @@ export default function CloudXRComponent({ if (webXRManager) { const handleSessionStart = async () => { + const xrSession: XRSession | null = (webXRManager as any).getSession + ? (webXRManager as any).getSession() + : null; + + // CloudXR must advertise the rate actually used by the headset. Wait for WebXR to + // apply the configured rate before creating the CloudXR session to avoid a pacing race. + const effectiveDeviceFrameRate = xrSession + ? await applyTargetFrameRate(xrSession as XRSession & FrameRateSession, config.deviceFrameRate) + : config.deviceFrameRate; + // Explicitly request the desired reference space from the XRSession to avoid // inheriting a default 'local-floor' space that could stack with UI offsets. let referenceSpace: XRReferenceSpace | null = null; try { - const xrSession: XRSession | null = (webXRManager as any).getSession - ? (webXRManager as any).getSession() - : null; if (xrSession) { if (config.referenceSpaceType === 'auto') { const fallbacks: XRReferenceSpaceType[] = [ @@ -243,7 +251,7 @@ export default function CloudXRComponent({ codec: config.codec, gl: gl, referenceSpace: referenceSpace, - deviceFrameRate: config.deviceFrameRate, + deviceFrameRate: effectiveDeviceFrameRate, maxStreamingBitrateKbps: config.maxStreamingBitrateMbps * 1000, // Convert Mbps to Kbps enablePoseSmoothing: config.enablePoseSmoothing, posePredictionFactor: config.posePredictionFactor, diff --git a/deps/cloudxr/webxr_client/src/App.tsx b/deps/cloudxr/webxr_client/src/App.tsx index fdcc1cdb7..7562b2744 100644 --- a/deps/cloudxr/webxr_client/src/App.tsx +++ b/deps/cloudxr/webxr_client/src/App.tsx @@ -300,6 +300,9 @@ function App() { createXRStore({ emulate: false, // Disable IWER emulation from react in favor of custom iwer loading function foveation: xrFoveation, + // CloudXRComponent applies the configured rate before CloudXR negotiates the stream. + // Disabling the store's automatic "high" preference avoids racing that negotiation. + frameRate: false, frameBufferScaling: xrFrameBufferScaling, // Use local WebXR input profile assets only when bundled (optional build without assets) ...(process.env.WEBXR_ASSETS_VERSION && { @@ -378,23 +381,6 @@ function App() { } else { setErrorMessage('Unrecognized immersive mode'); } - store.setFrameRate((supportedFrameRates: ArrayLike): number | false => { - let frameRate = ui.getConfiguration().deviceFrameRate; - let found = false; - for (let i = 0; i < supportedFrameRates.length; ++i) { - if (supportedFrameRates[i] === frameRate) { - found = true; - break; - } - } - if (found) { - console.info('Changed frame rate to', frameRate); - return frameRate; - } else { - console.error('Failed to change frame rate to', frameRate); - return false; - } - }); }; ui.setupConnectButtonHandler(doConnect, (error: Error) => { diff --git a/deps/cloudxr/webxr_client/src/config/frameRate.test.ts b/deps/cloudxr/webxr_client/src/config/frameRate.test.ts new file mode 100644 index 000000000..33c3f8606 --- /dev/null +++ b/deps/cloudxr/webxr_client/src/config/frameRate.test.ts @@ -0,0 +1,154 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { applyTargetFrameRate, FrameRateLogger, FrameRateSession } from './frameRate'; + +const logger = (): jest.Mocked => ({ + info: jest.fn(), + warn: jest.fn(), +}); + +describe('applyTargetFrameRate', () => { + it('applies a supported rate and returns the effective rate', async () => { + const session: FrameRateSession = { + frameRate: 90, + supportedFrameRates: new Float32Array([72, 90, 120]), + updateTargetFrameRate: jest.fn(async rate => { + session.frameRate = rate; + }), + }; + + await expect(applyTargetFrameRate(session, 72, logger())).resolves.toBe(72); + expect(session.updateTargetFrameRate).toHaveBeenCalledWith(72); + }); + + it('does not request an unsupported rate', async () => { + const updateTargetFrameRate = jest.fn(async () => {}); + const session: FrameRateSession = { + frameRate: 90, + supportedFrameRates: new Float32Array([90, 120]), + updateTargetFrameRate, + }; + + await expect(applyTargetFrameRate(session, 72, logger())).resolves.toBe(90); + expect(updateTargetFrameRate).not.toHaveBeenCalled(); + }); + + it('uses the current rate when the update API is unavailable', async () => { + const session: FrameRateSession = { frameRate: 90 }; + + await expect(applyTargetFrameRate(session, 72, logger())).resolves.toBe(90); + }); + + it('falls back to the current rate when the update is rejected', async () => { + const session: FrameRateSession = { + frameRate: 90, + supportedFrameRates: new Float32Array([72, 90]), + updateTargetFrameRate: jest.fn(async () => { + throw new Error('headset rejected rate'); + }), + }; + + await expect(applyTargetFrameRate(session, 72, logger())).resolves.toBe(90); + }); + + it('does not resolve until the headset update has completed', async () => { + let releaseUpdate!: () => void; + const update = new Promise(resolve => { + releaseUpdate = resolve; + }); + const session: FrameRateSession = { + frameRate: 90, + supportedFrameRates: new Float32Array([72, 90]), + updateTargetFrameRate: jest.fn(() => update), + }; + let completed = false; + const applying = applyTargetFrameRate(session, 72, logger()).then(value => { + completed = true; + return value; + }); + + await Promise.resolve(); + expect(completed).toBe(false); + session.frameRate = 72; + releaseUpdate(); + await expect(applying).resolves.toBe(72); + }); + + it('does not block CloudXR when the update never settles', async () => { + const log = logger(); + const session: FrameRateSession = { + frameRate: 90, + supportedFrameRates: new Float32Array([72, 90]), + updateTargetFrameRate: jest.fn(() => new Promise(() => {})), + }; + + await expect( + applyTargetFrameRate(session, 72, log, { updateTimeoutMs: 20 }) + ).resolves.toBe(90); + expect(log.warn).toHaveBeenCalled(); + }); + + it('waits for frameratechange when the attribute updates late', async () => { + const listeners: Array<() => void> = []; + const removeEventListener = jest.fn(); + const session: FrameRateSession = { + frameRate: 90, + supportedFrameRates: new Float32Array([72, 90]), + updateTargetFrameRate: jest.fn(async () => { + setTimeout(() => { + session.frameRate = 72; + listeners.forEach(listener => listener()); + }, 5); + }), + addEventListener: jest.fn((_type, listener) => { + listeners.push(listener); + }), + removeEventListener, + }; + + await expect( + applyTargetFrameRate(session, 72, logger(), { rateSettleGraceMs: 200 }) + ).resolves.toBe(72); + expect(removeEventListener).toHaveBeenCalledWith('frameratechange', expect.any(Function)); + }); + + it('advertises the accepted target even when the attribute lags past the grace', async () => { + const log = logger(); + const session: FrameRateSession = { + frameRate: 90, + supportedFrameRates: new Float32Array([72, 90]), + updateTargetFrameRate: jest.fn(async () => {}), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + + await expect( + applyTargetFrameRate(session, 72, log, { rateSettleGraceMs: 10 }) + ).resolves.toBe(72); + expect(log.warn).toHaveBeenCalled(); + }); + + it('trusts the accepted request when the browser does not expose frameRate', async () => { + const session: FrameRateSession = { + supportedFrameRates: new Float32Array([72, 90]), + updateTargetFrameRate: jest.fn(async () => {}), + }; + + await expect(applyTargetFrameRate(session, 72, logger())).resolves.toBe(72); + }); +}); diff --git a/deps/cloudxr/webxr_client/src/config/frameRate.ts b/deps/cloudxr/webxr_client/src/config/frameRate.ts new file mode 100644 index 000000000..f0b5427b4 --- /dev/null +++ b/deps/cloudxr/webxr_client/src/config/frameRate.ts @@ -0,0 +1,184 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** The optional WebXR frame-rate members used by supported headsets. */ +export interface FrameRateSession { + frameRate?: number; + supportedFrameRates?: ArrayLike; + updateTargetFrameRate?: (rate: number) => Promise; + addEventListener?: (type: string, listener: () => void) => void; + removeEventListener?: (type: string, listener: () => void) => void; +} + +export interface FrameRateLogger { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; +} + +export interface ApplyTargetFrameRateOptions { + /** Give up waiting for updateTargetFrameRate() after this long, without failing the session. */ + updateTimeoutMs?: number; + /** After the update resolves, wait at most this long for session.frameRate to report the new rate. */ + rateSettleGraceMs?: number; +} + +const DEFAULT_UPDATE_TIMEOUT_MS = 2000; +const DEFAULT_RATE_SETTLE_GRACE_MS = 500; + +function isFinitePositive(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +function containsFrameRate(values: ArrayLike | undefined, target: number): boolean { + if (!values) return false; + for (let index = 0; index < values.length; index += 1) { + if (values[index] === target) return true; + } + return false; +} + +type UpdateOutcome = + | { kind: 'updated' } + | { kind: 'timeout' } + | { kind: 'rejected'; error: unknown }; + +function raceUpdateAgainstTimeout(update: Promise, timeoutMs: number): Promise { + return new Promise(resolve => { + const timer = setTimeout(() => resolve({ kind: 'timeout' }), timeoutMs); + update.then( + () => { + clearTimeout(timer); + resolve({ kind: 'updated' }); + }, + (error: unknown) => { + clearTimeout(timer); + resolve({ kind: 'rejected', error }); + } + ); + }); +} + +/** + * The WebXR spec allows session.frameRate to keep its old value when + * updateTargetFrameRate() resolves; the attribute change is only observable via a + * later "frameratechange" event. Wait briefly for it so a stale value is not + * advertised to CloudXR. + */ +function waitForReportedFrameRate( + session: FrameRateSession, + targetFrameRate: number, + graceMs: number +): Promise { + if (!session.addEventListener) { + return Promise.resolve(); + } + return new Promise(resolve => { + let done = false; + let timer: ReturnType | undefined; + const finish = () => { + if (done) return; + done = true; + if (timer !== undefined) clearTimeout(timer); + session.removeEventListener?.('frameratechange', onChange); + resolve(); + }; + const onChange = () => { + if (session.frameRate === targetFrameRate) finish(); + }; + session.addEventListener('frameratechange', onChange); + timer = setTimeout(finish, graceMs); + if (session.frameRate === targetFrameRate) finish(); + }); +} + +/** + * Apply a headset frame rate and wait for the browser before CloudXR negotiates its stream. + * Returns the effective rate to advertise to CloudXR, falling back without aborting the session. + * A hung updateTargetFrameRate() must not block CloudXR session creation, so the wait is bounded. + */ +export async function applyTargetFrameRate( + session: FrameRateSession, + targetFrameRate: number, + logger: FrameRateLogger = console, + options: ApplyTargetFrameRateOptions = {} +): Promise { + const updateTimeoutMs = options.updateTimeoutMs ?? DEFAULT_UPDATE_TIMEOUT_MS; + const rateSettleGraceMs = options.rateSettleGraceMs ?? DEFAULT_RATE_SETTLE_GRACE_MS; + const currentFrameRate = isFinitePositive(session.frameRate) ? session.frameRate : undefined; + + if (!isFinitePositive(targetFrameRate)) { + logger.warn('Ignoring invalid target WebXR frame rate:', targetFrameRate); + return currentFrameRate ?? targetFrameRate; + } + + if (!session.updateTargetFrameRate || !session.supportedFrameRates) { + logger.warn( + 'WebXR target frame-rate API is unavailable; using the current/default frame rate:', + currentFrameRate ?? targetFrameRate + ); + return currentFrameRate ?? targetFrameRate; + } + + if (!containsFrameRate(session.supportedFrameRates, targetFrameRate)) { + logger.warn( + 'Requested WebXR frame rate is not supported by this device:', + targetFrameRate + ); + return currentFrameRate ?? targetFrameRate; + } + + let update: Promise; + try { + update = session.updateTargetFrameRate(targetFrameRate); + } catch (error) { + logger.warn('Failed to apply the requested WebXR frame rate:', targetFrameRate, error); + return currentFrameRate ?? targetFrameRate; + } + + const outcome = await raceUpdateAgainstTimeout(update, updateTimeoutMs); + + if (outcome.kind === 'timeout') { + logger.warn( + `updateTargetFrameRate(${targetFrameRate}) did not settle within ${updateTimeoutMs} ms; ` + + 'continuing so CloudXR negotiation is not blocked. Advertising the known rate:', + currentFrameRate ?? targetFrameRate + ); + return currentFrameRate ?? targetFrameRate; + } + + if (outcome.kind === 'rejected') { + logger.warn('Failed to apply the requested WebXR frame rate:', targetFrameRate, outcome.error); + return currentFrameRate ?? targetFrameRate; + } + + if (session.frameRate !== undefined && session.frameRate !== targetFrameRate) { + await waitForReportedFrameRate(session, targetFrameRate, rateSettleGraceMs); + } + + // A resolved updateTargetFrameRate() means the device accepted the rate change. + // session.frameRate may lag behind the acceptance (it only settles with a later + // frameratechange event), so the accepted target is the honest rate to advertise. + if (session.frameRate !== undefined && session.frameRate !== targetFrameRate) { + logger.warn( + `Browser still reports ${session.frameRate} Hz after accepting ${targetFrameRate} Hz; ` + + 'advertising the accepted target to CloudXR.' + ); + } else { + logger.info('WebXR frame rate applied before CloudXR negotiation:', targetFrameRate); + } + return targetFrameRate; +}