Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions deps/cloudxr/webxr_client/helpers/react/CloudXRComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } from '../../src/config/frameRate';

/**
* Props for the CloudXRComponent.
Expand Down Expand Up @@ -150,13 +151,18 @@ export default function CloudXRComponent({

if (webXRManager) {
const handleSessionStart = async () => {
const xrSession = webXRManager.getSession();

// 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, 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[] = [
Expand Down Expand Up @@ -243,7 +249,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,
Expand Down
20 changes: 3 additions & 17 deletions deps/cloudxr/webxr_client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 && {
Expand Down Expand Up @@ -378,23 +381,6 @@ function App() {
} else {
setErrorMessage('Unrecognized immersive mode');
}
store.setFrameRate((supportedFrameRates: ArrayLike<number>): 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) => {
Expand Down
166 changes: 166 additions & 0 deletions deps/cloudxr/webxr_client/src/config/frameRate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/*
* 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 } from './frameRate';

const logger = (): jest.Mocked<FrameRateLogger> => ({
info: jest.fn(),
warn: jest.fn(),
});

describe('applyTargetFrameRate', () => {
it('applies a supported rate and returns the effective rate', async () => {
// @ts-expect-error partial XRSession fake; only the frame-rate members are exercised.
const session: XRSession = {
frameRate: 90,
supportedFrameRates: new Float32Array([72, 90, 120]),
updateTargetFrameRate: jest.fn(async rate => {
// @ts-expect-error the fake mutates the readonly attribute to emulate the browser.
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 () => {});
// @ts-expect-error partial XRSession fake; only the frame-rate members are exercised.
const session: XRSession = {
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 () => {
// @ts-expect-error partial XRSession fake; only the frame-rate members are exercised.
const session: XRSession = { frameRate: 90 };

await expect(applyTargetFrameRate(session, 72, logger())).resolves.toBe(90);
});

it('falls back to the current rate when the update is rejected', async () => {
// @ts-expect-error partial XRSession fake; only the frame-rate members are exercised.
const session: XRSession = {
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<void>(resolve => {
releaseUpdate = resolve;
});
// @ts-expect-error partial XRSession fake; only the frame-rate members are exercised.
const session: XRSession = {
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);
// @ts-expect-error the fake mutates the readonly attribute to emulate the browser.
session.frameRate = 72;
releaseUpdate();
await expect(applying).resolves.toBe(72);
});

it('does not block CloudXR when the update never settles', async () => {
const log = logger();
// @ts-expect-error partial XRSession fake; only the frame-rate members are exercised.
const session: XRSession = {
frameRate: 90,
supportedFrameRates: new Float32Array([72, 90]),
updateTargetFrameRate: jest.fn(() => new Promise<void>(() => {})),
};

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();
// @ts-expect-error partial XRSession fake; only the frame-rate members are exercised.
const session: XRSession = {
frameRate: 90,
supportedFrameRates: new Float32Array([72, 90]),
updateTargetFrameRate: jest.fn(async () => {
setTimeout(() => {
// @ts-expect-error the fake mutates the readonly attribute to emulate the browser.
session.frameRate = 72;
listeners.forEach(listener => listener());
}, 5);
}),
addEventListener: jest.fn((_type: string, listener: () => void) => {
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();
// @ts-expect-error partial XRSession fake; only the frame-rate members are exercised.
const session: XRSession = {
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 () => {
// @ts-expect-error partial XRSession fake; only the frame-rate members are exercised.
const session: XRSession = {
supportedFrameRates: new Float32Array([72, 90]),
updateTargetFrameRate: jest.fn(async () => {}),
};

await expect(applyTargetFrameRate(session, 72, logger())).resolves.toBe(72);
});
});
Loading
Loading