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
16 changes: 12 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, FrameRateSession } from '../../src/config/frameRate';

/**
* Props for the CloudXRComponent.
Expand Down Expand Up @@ -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[] = [
Expand Down Expand Up @@ -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,
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
154 changes: 154 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,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<FrameRateLogger> => ({
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<void>(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<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();
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);
});
});
Loading
Loading