Add CloudXR VR streaming support for PICO 4 Ultra (Early Access)

Replaces manual H264/TCP stereo streaming with NVIDIA CloudXR for
higher-quality stereoscopic rendering and lower latency.

Changes:
- teleop_xr_agent.py: add --cloudxr flag (enables Isaac Sim XR mode,
  disables manual StreamingManager)
- deps/cloudxr/: NVIDIA CloudXR.js SDK (Early Access) with Isaac Lab
  teleop React web client
- deps/cloudxr/Dockerfile.wss.proxy: HAProxy WSS proxy for PICO 4 Ultra
  HTTPS mode (routes wss://48322 → ws://49100)
- deps/cloudxr/isaac/webpack.dev.js: disable file watching to avoid
  EMFILE errors with large node_modules
- deps/cloudxr/INSTALL.md: full setup guide

Usage:
  # Start CloudXR Runtime + Isaac Lab
  cd ~/IsaacLab && ./docker/container.py start \
      --files docker-compose.cloudxr-runtime.patch.yaml \
      --env-file .env.cloudxr-runtime

  # Run teleop with CloudXR
  ~/IsaacLab/isaaclab.sh -p teleop_xr_agent.py \
      --task Isaac-MindRobot-2i-DualArm-IK-Abs-v0 --cloudxr

  # Serve web client
  cd deps/cloudxr/isaac && npm run dev-server:https
This commit is contained in:
2026-03-26 14:29:03 +08:00
parent eef7ff838d
commit 623e05f250
133 changed files with 24869 additions and 2 deletions

555
deps/cloudxr/react/src/App.tsx vendored Normal file
View File

@@ -0,0 +1,555 @@
/**
* App.tsx - Main CloudXR React Application
*
* This is the root component of the CloudXR React example application. It sets up:
* - WebXR session management and XR store configuration
* - CloudXR server configuration (IP, port, stream settings)
* - UI state management (connection status, session state)
* - Integration between CloudXR rendering component and UI components
* - Entry point for AR/VR experiences with CloudXR streaming
*
* The app integrates with the HTML interface which provides a "CONNECT" button
* to enter AR mode and displays the CloudXR UI with controls for teleop actions
* and disconnect when in XR mode.
*/
import { checkCapabilities } from '@helpers/BrowserCapabilities';
import { loadIWERIfNeeded } from '@helpers/LoadIWER';
import { overridePressureObserver } from '@helpers/overridePressureObserver';
import * as CloudXR from '@nvidia/cloudxr';
import { Environment } from '@react-three/drei';
import { Canvas } from '@react-three/fiber';
import { setPreferredColorScheme } from '@react-three/uikit';
import { XR, createXRStore, noEvents, PointerEvents, XROrigin, useXR } from '@react-three/xr';
import { useState, useMemo, useEffect, useRef } from 'react';
import { CloudXR2DUI } from './CloudXR2DUI';
import CloudXRComponent from './CloudXRComponent';
import CloudXR3DUI from './CloudXRUI';
// Override PressureObserver early to catch errors from buggy browser implementations
overridePressureObserver();
const store = createXRStore({
foveation: 0,
emulate: false, // Disable IWER emulation from react in favor of custom iwer loading function
// Configure WebXR input profiles to use local assets
// Use relative path from current page location
baseAssetPath: `${new URL('.', window.location).href}npm/@webxr-input-profiles/assets@${process.env.WEBXR_ASSETS_VERSION}/dist/profiles/`,
hand: {
model: false, // Disable hand models but keep pointer functionality
},
});
setPreferredColorScheme('dark');
const START_TELEOP_COMMAND = {
type: 'teleop_command',
message: {
command: 'start teleop',
},
} as const;
// Environment component like controller-test
function NonAREnvironment() {
// Use local HDR file instead of preset so client doesn't need to download it from CDN
return (
<Environment
blur={0.2}
background={false}
environmentIntensity={2}
files="assets/hdri/potsdamer_platz_1k.hdr"
/>
);
}
function App() {
const COUNTDOWN_MAX_SECONDS = 9;
const COUNTDOWN_STORAGE_KEY = 'cxr.react.countdownSeconds';
// 2D UI management
const [cloudXR2DUI, setCloudXR2DUI] = useState<CloudXR2DUI | null>(null);
// IWER loading state
const [iwerLoaded, setIwerLoaded] = useState(false);
// Capability state management
const [capabilitiesValid, setCapabilitiesValid] = useState(false);
const capabilitiesCheckedRef = useRef(false);
// Connection state management
const [isConnected, setIsConnected] = useState(false);
// Session status management
const [sessionStatus, setSessionStatus] = useState('Disconnected');
// Error message management
const [errorMessage, setErrorMessage] = useState('');
// CloudXR session reference
const [cloudXRSession, setCloudXRSession] = useState<CloudXR.Session | null>(null);
// XR mode state for UI visibility
const [isXRMode, setIsXRMode] = useState(false);
// Server address being used for connection
const [serverAddress, setServerAddress] = useState<string>('');
// Teleop countdown and state
const [isCountingDown, setIsCountingDown] = useState(false);
const [countdownRemaining, setCountdownRemaining] = useState(0);
const [isTeleopRunning, setIsTeleopRunning] = useState(false);
const countdownTimerRef = useRef<number | null>(null);
const [countdownDuration, setCountdownDuration] = useState<number>(() => {
try {
const saved = localStorage.getItem(COUNTDOWN_STORAGE_KEY);
if (saved != null) {
const value = parseInt(saved, 10);
if (!isNaN(value)) {
return Math.min(COUNTDOWN_MAX_SECONDS, Math.max(0, value));
}
}
} catch (_) {}
return 3;
});
// Persist countdown duration on change
useEffect(() => {
try {
localStorage.setItem(COUNTDOWN_STORAGE_KEY, String(countdownDuration));
} catch (_) {}
}, [countdownDuration]);
// Load IWER first (must happen before anything else)
// Note: React Three Fiber's emulation is disabled (emulate: false) to avoid conflicts
useEffect(() => {
const loadIWER = async () => {
const { supportsImmersive, iwerLoaded: wasIwerLoaded } = await loadIWERIfNeeded();
if (!supportsImmersive) {
setErrorMessage('Immersive mode not supported');
setIwerLoaded(false);
setCapabilitiesValid(false);
capabilitiesCheckedRef.current = false; // Reset check flag on failure
return;
}
// IWER loaded successfully, now we can proceed with capability checks
setIwerLoaded(true);
// Store whether IWER was loaded for status message display later
if (wasIwerLoaded) {
sessionStorage.setItem('iwerWasLoaded', 'true');
}
};
loadIWER();
}, []);
// Update button state when IWER fails and UI becomes ready
useEffect(() => {
if (cloudXR2DUI && !iwerLoaded && !capabilitiesValid) {
cloudXR2DUI.setStartButtonState(true, 'CONNECT (immersive mode not supported)');
}
}, [cloudXR2DUI, iwerLoaded, capabilitiesValid]);
// Check capabilities once CloudXR2DUI is ready and IWER is loaded
useEffect(() => {
const checkCapabilitiesOnce = async () => {
if (!cloudXR2DUI || !iwerLoaded) {
return;
}
// Guard: only check capabilities once
if (capabilitiesCheckedRef.current) {
return;
}
capabilitiesCheckedRef.current = true;
// Disable button and show checking status
cloudXR2DUI.setStartButtonState(true, 'CONNECT (checking capabilities)');
let result: { success: boolean; failures: string[]; warnings: string[] } = {
success: false,
failures: [],
warnings: [],
};
try {
result = await checkCapabilities();
} catch (error) {
cloudXR2DUI.showStatus(`Capability check error: ${error}`, 'error');
setCapabilitiesValid(false);
cloudXR2DUI.setStartButtonState(true, 'CONNECT (capability check failed)');
capabilitiesCheckedRef.current = false; // Reset on error for potential retry
return;
}
if (!result.success) {
cloudXR2DUI.showStatus(
'Browser does not meet required capabilities:\n' + result.failures.join('\n'),
'error'
);
setCapabilitiesValid(false);
cloudXR2DUI.setStartButtonState(true, 'CONNECT (capability check failed)');
capabilitiesCheckedRef.current = false; // Reset on failure for potential retry
return;
}
// Show final status message with IWER info if applicable
const iwerWasLoaded = sessionStorage.getItem('iwerWasLoaded') === 'true';
if (result.warnings.length > 0) {
cloudXR2DUI.showStatus('Performance notice:\n' + result.warnings.join('\n'), 'info');
} else if (iwerWasLoaded) {
// Include IWER status in the final success message
cloudXR2DUI.showStatus(
'CloudXR.js SDK is supported. Ready to connect!\nUsing IWER (Immersive Web Emulator Runtime) - Emulating Meta Quest 3.',
'info'
);
} else {
cloudXR2DUI.showStatus('CloudXR.js SDK is supported. Ready to connect!', 'success');
}
setCapabilitiesValid(true);
cloudXR2DUI.setStartButtonState(false, 'CONNECT');
};
checkCapabilitiesOnce();
}, [cloudXR2DUI, iwerLoaded]);
// Track config changes to trigger re-renders when form values change
const [configVersion, setConfigVersion] = useState(0);
// Initialize CloudXR2DUI
useEffect(() => {
// Create and initialize the 2D UI manager
const ui = new CloudXR2DUI(() => {
// Callback when configuration changes
setConfigVersion(v => v + 1);
});
ui.initialize();
ui.setupConnectButtonHandler(
async () => {
// Start XR session
if (ui.getConfiguration().immersiveMode === 'ar') {
await store.enterAR();
} else if (ui.getConfiguration().immersiveMode === 'vr') {
await store.enterVR();
} else {
setErrorMessage('Unrecognized immersive mode');
}
},
(error: Error) => {
setErrorMessage(`Failed to start XR session: ${error}`);
}
);
setCloudXR2DUI(ui);
// Cleanup function
return () => {
if (ui) {
ui.cleanup();
}
};
}, []);
// Update HTML error message display when error state changes
useEffect(() => {
if (cloudXR2DUI) {
if (errorMessage) {
cloudXR2DUI.showError(errorMessage);
} else {
cloudXR2DUI.hideError();
}
}
}, [errorMessage, cloudXR2DUI]);
// Listen for XR session state changes to update button and UI visibility
useEffect(() => {
const handleXRStateChange = () => {
const xrState = store.getState();
if (xrState.mode === 'immersive-ar' || xrState.mode === 'immersive-vr') {
// XR session is active
setIsXRMode(true);
if (cloudXR2DUI) {
cloudXR2DUI.setStartButtonState(true, 'CONNECT (XR session active)');
}
} else {
// XR session ended
setIsXRMode(false);
if (cloudXR2DUI) {
cloudXR2DUI.setStartButtonState(false, 'CONNECT');
}
if (xrState.error) {
setErrorMessage(`XR session error: ${xrState.error}`);
}
}
};
// Subscribe to XR state changes
const unsubscribe = store.subscribe(handleXRStateChange);
// Cleanup
return () => {
unsubscribe();
setIsXRMode(false);
};
}, [cloudXR2DUI]);
// CloudXR status change handler
const handleStatusChange = (connected: boolean, status: string) => {
setIsConnected(connected);
setSessionStatus(status);
};
// UI Event Handlers
const handleStartTeleop = () => {
console.log('Start Teleop pressed');
if (!cloudXRSession) {
console.error('CloudXR session not available');
return;
}
if (isCountingDown || isTeleopRunning) {
return;
}
// Begin countdown before starting teleop (immediately if 0)
if (countdownDuration <= 0) {
setIsCountingDown(false);
setCountdownRemaining(0);
try {
cloudXRSession.sendServerMessage(START_TELEOP_COMMAND);
console.log('Start teleop command sent');
setIsTeleopRunning(true);
} catch (error) {
console.error('Failed to send teleop command:', error);
setIsTeleopRunning(false);
}
return;
}
setIsCountingDown(true);
setCountdownRemaining(countdownDuration);
countdownTimerRef.current = window.setInterval(() => {
setCountdownRemaining(prev => {
if (prev <= 1) {
// Countdown finished
if (countdownTimerRef.current !== null) {
clearInterval(countdownTimerRef.current);
countdownTimerRef.current = null;
}
setIsCountingDown(false);
// Send start teleop command
try {
cloudXRSession.sendServerMessage(START_TELEOP_COMMAND);
console.log('Start teleop command sent');
setIsTeleopRunning(true);
} catch (error) {
console.error('Failed to send teleop command:', error);
setIsTeleopRunning(false);
}
return 0;
}
return prev - 1;
});
}, 1000);
};
const handleStopTeleop = () => {
console.log('Stop Teleop pressed');
// If countdown is active, cancel it and reset state
if (isCountingDown) {
if (countdownTimerRef.current !== null) {
clearInterval(countdownTimerRef.current);
countdownTimerRef.current = null;
}
setIsCountingDown(false);
setCountdownRemaining(0);
return;
}
if (!cloudXRSession) {
console.error('CloudXR session not available');
return;
}
// Send stop teleop command
const teleopCommand = {
type: 'teleop_command',
message: {
command: 'stop teleop',
},
};
try {
cloudXRSession.sendServerMessage(teleopCommand);
console.log('Stop teleop command sent');
setIsTeleopRunning(false);
} catch (error) {
console.error('Failed to send teleop command:', error);
}
};
const handleResetTeleop = () => {
console.log('Reset Teleop pressed');
// Cancel any active countdown
if (countdownTimerRef.current !== null) {
clearInterval(countdownTimerRef.current);
countdownTimerRef.current = null;
}
setIsCountingDown(false);
setCountdownRemaining(0);
if (!cloudXRSession) {
console.error('CloudXR session not available');
return;
}
// Send stop teleop command first
const stopCommand = {
type: 'teleop_command',
message: {
command: 'stop teleop',
},
};
// Send reset teleop command
const resetCommand = {
type: 'teleop_command',
message: {
command: 'reset teleop',
},
};
try {
cloudXRSession.sendServerMessage(stopCommand);
console.log('Stop teleop command sent');
cloudXRSession.sendServerMessage(resetCommand);
console.log('Reset teleop command sent');
setIsTeleopRunning(false);
} catch (error) {
console.error('Failed to send teleop commands:', error);
}
};
const handleDisconnect = () => {
console.log('Disconnect pressed');
// Cleanup countdown state on disconnect
if (countdownTimerRef.current !== null) {
clearInterval(countdownTimerRef.current);
countdownTimerRef.current = null;
}
setIsCountingDown(false);
setCountdownRemaining(0);
setIsTeleopRunning(false);
const xrState = store.getState();
const session = xrState.session;
if (session) {
session.end().catch((err: unknown) => {
setErrorMessage(
`Failed to end XR session: ${err instanceof Error ? err.message : String(err)}`
);
});
}
};
// Countdown configuration handlers (0-5 seconds)
const handleIncreaseCountdown = () => {
if (isCountingDown) return;
setCountdownDuration(prev => Math.min(COUNTDOWN_MAX_SECONDS, prev + 1));
};
const handleDecreaseCountdown = () => {
if (isCountingDown) return;
setCountdownDuration(prev => Math.max(0, prev - 1));
};
// Memo config based on configVersion (manual dependency tracker incremented on config changes)
// eslint-disable-next-line react-hooks/exhaustive-deps
const config = useMemo(
() => (cloudXR2DUI ? cloudXR2DUI.getConfiguration() : null),
[cloudXR2DUI, configVersion]
);
// Sync XR mode state to body class for CSS styling
useEffect(() => {
if (isXRMode) {
document.body.classList.add('xr-mode');
} else {
document.body.classList.remove('xr-mode');
}
return () => {
document.body.classList.remove('xr-mode');
};
}, [isXRMode]);
return (
<>
<Canvas
events={noEvents}
style={{
background: '#000',
width: '100vw',
height: '100vh',
position: 'fixed',
top: 0,
left: 0,
zIndex: -1,
}}
gl={{
preserveDrawingBuffer: true, // Keep buffer for custom rendering
antialias: true,
}}
camera={{ position: [0, 0, 0.65] }}
onWheel={e => {
e.preventDefault();
}}
>
<PointerEvents batchEvents={false} />
<XR store={store}>
<NonAREnvironment />
<XROrigin />
{cloudXR2DUI && config && (
<>
<CloudXRComponent
config={config}
onStatusChange={handleStatusChange}
onError={error => {
if (cloudXR2DUI) {
cloudXR2DUI.showError(error);
}
}}
onSessionReady={setCloudXRSession}
onServerAddress={setServerAddress}
/>
<CloudXR3DUI
onStartTeleop={handleStartTeleop}
onStopTeleop={handleStopTeleop}
onDisconnect={handleDisconnect}
onResetTeleop={handleResetTeleop}
serverAddress={serverAddress || config.serverIP}
sessionStatus={sessionStatus}
playLabel={
isTeleopRunning
? 'Running'
: isCountingDown
? `Starting in ${countdownRemaining} sec...`
: 'Play'
}
playDisabled={isCountingDown || isTeleopRunning}
countdownSeconds={countdownDuration}
onCountdownIncrease={handleIncreaseCountdown}
onCountdownDecrease={handleDecreaseCountdown}
countdownDisabled={isCountingDown}
position={[0, 1.6, -1.8]}
rotation={[0, 0, 0]}
/>
</>
)}
</XR>
</Canvas>
</>
);
}
export default App;

438
deps/cloudxr/react/src/CloudXR2DUI.tsx vendored Normal file
View File

@@ -0,0 +1,438 @@
/**
* CloudXR2DUI.tsx - CloudXR 2D User Interface Management
*
* This class handles all the HTML form interactions, localStorage persistence,
* and form validation for the CloudXR React example. It follows the same pattern
* as the simple example's CloudXRWebUI class, providing a clean separation
* between UI management and React component logic.
*
* Features:
* - Form field management and localStorage persistence
* - Proxy configuration based on protocol
* - Form validation and default value handling
* - Event listener management
* - Error handling and logging
*/
import { CloudXRConfig, enableLocalStorage, setupCertificateAcceptanceLink } from '@helpers/utils';
/**
* 2D UI Management for CloudXR React Example
* Handles the main user interface for CloudXR streaming, including form management,
* localStorage persistence, and user interaction controls.
*/
export class CloudXR2DUI {
/** Button to initiate XR streaming session */
private startButton!: HTMLButtonElement;
/** Input field for the CloudXR server IP address */
private serverIpInput!: HTMLInputElement;
/** Input field for the CloudXR server port number */
private portInput!: HTMLInputElement;
/** Input field for proxy URL configuration */
private proxyUrlInput!: HTMLInputElement;
/** Dropdown to select between AR and VR immersive modes */
private immersiveSelect!: HTMLSelectElement;
/** Dropdown to select device frame rate (FPS) */
private deviceFrameRateSelect!: HTMLSelectElement;
/** Dropdown to select max streaming bitrate (Mbps) */
private maxStreamingBitrateMbpsSelect!: HTMLSelectElement;
/** Input field for per-eye width configuration */
private perEyeWidthInput!: HTMLInputElement;
/** Input field for per-eye height configuration */
private perEyeHeightInput!: HTMLInputElement;
/** Dropdown to select server backend type */
private serverTypeSelect!: HTMLSelectElement;
/** Dropdown to select application type */
private appSelect!: HTMLSelectElement;
/** Dropdown to select reference space for XR tracking */
private referenceSpaceSelect!: HTMLSelectElement;
/** Input for XR reference space X offset (cm) */
private xrOffsetXInput!: HTMLInputElement;
/** Input for XR reference space Y offset (cm) */
private xrOffsetYInput!: HTMLInputElement;
/** Input for XR reference space Z offset (cm) */
private xrOffsetZInput!: HTMLInputElement;
/** Text element displaying proxy configuration help */
private proxyDefaultText!: HTMLElement;
/** Error message box element */
private errorMessageBox!: HTMLElement;
/** Error message text element */
private errorMessageText!: HTMLElement;
/** Certificate acceptance link container */
private certAcceptanceLink!: HTMLElement;
/** Certificate acceptance link anchor */
private certLink!: HTMLAnchorElement;
/** Flag to track if the 2D UI has been initialized */
private initialized: boolean = false;
/** Current form configuration state */
private currentConfiguration: CloudXRConfig;
/** Callback function for configuration changes */
private onConfigurationChange: ((config: CloudXRConfig) => void) | null = null;
/** Connect button click handler for cleanup */
private handleConnectClick: ((event: Event) => void) | null = null;
/** Array to store all event listeners for proper cleanup */
private eventListeners: Array<{
element: HTMLElement;
event: string;
handler: EventListener;
}> = [];
/** Cleanup function for certificate acceptance link */
private certLinkCleanup: (() => void) | null = null;
/**
* Creates a new CloudXR2DUI instance
* @param onConfigurationChange - Callback function called when configuration changes
*/
constructor(onConfigurationChange?: (config: CloudXRConfig) => void) {
this.onConfigurationChange = onConfigurationChange || null;
this.currentConfiguration = this.getDefaultConfiguration();
}
/**
* Initializes the CloudXR2DUI with all necessary components and event handlers
*/
public initialize(): void {
if (this.initialized) {
return;
}
try {
this.initializeElements();
this.setupLocalStorage();
this.setupProxyConfiguration();
this.setupEventListeners();
this.updateConfiguration();
this.setStartButtonState(false, 'CONNECT');
this.initialized = true;
} catch (error) {
// Continue with default values if initialization fails
this.showError(`Failed to initialize CloudXR2DUI: ${error}`);
}
}
/**
* Initializes all DOM element references by their IDs
* Throws an error if any required element is not found
*/
private initializeElements(): void {
this.startButton = this.getElement<HTMLButtonElement>('startButton');
this.serverIpInput = this.getElement<HTMLInputElement>('serverIpInput');
this.portInput = this.getElement<HTMLInputElement>('portInput');
this.proxyUrlInput = this.getElement<HTMLInputElement>('proxyUrl');
this.immersiveSelect = this.getElement<HTMLSelectElement>('immersive');
this.deviceFrameRateSelect = this.getElement<HTMLSelectElement>('deviceFrameRate');
this.maxStreamingBitrateMbpsSelect =
this.getElement<HTMLSelectElement>('maxStreamingBitrateMbps');
this.perEyeWidthInput = this.getElement<HTMLInputElement>('perEyeWidth');
this.perEyeHeightInput = this.getElement<HTMLInputElement>('perEyeHeight');
this.serverTypeSelect = this.getElement<HTMLSelectElement>('serverType');
this.appSelect = this.getElement<HTMLSelectElement>('app');
this.referenceSpaceSelect = this.getElement<HTMLSelectElement>('referenceSpace');
this.xrOffsetXInput = this.getElement<HTMLInputElement>('xrOffsetX');
this.xrOffsetYInput = this.getElement<HTMLInputElement>('xrOffsetY');
this.xrOffsetZInput = this.getElement<HTMLInputElement>('xrOffsetZ');
this.proxyDefaultText = this.getElement<HTMLElement>('proxyDefaultText');
this.errorMessageBox = this.getElement<HTMLElement>('errorMessageBox');
this.errorMessageText = this.getElement<HTMLElement>('errorMessageText');
this.certAcceptanceLink = this.getElement<HTMLElement>('certAcceptanceLink');
this.certLink = this.getElement<HTMLAnchorElement>('certLink');
}
/**
* Gets a DOM element by ID with type safety
* @param id - The element ID to find
* @returns The found element with the specified type
* @throws Error if element is not found
*/
private getElement<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id) as T;
if (!element) {
throw new Error(`Element with id '${id}' not found`);
}
return element;
}
/**
* Gets the default configuration values
* @returns Default configuration object
*/
private getDefaultConfiguration(): CloudXRConfig {
const useSecure = typeof window !== 'undefined' ? window.location.protocol === 'https:' : false;
// Default port: HTTP → 49100, HTTPS without proxy → 48322, HTTPS with proxy → 443
const defaultPort = useSecure ? 48322 : 49100;
return {
serverIP: '127.0.0.1',
port: defaultPort,
useSecureConnection: useSecure,
perEyeWidth: 2048,
perEyeHeight: 1792,
deviceFrameRate: 90,
maxStreamingBitrateMbps: 150,
immersiveMode: 'ar',
app: 'generic',
serverType: 'manual',
proxyUrl: '',
referenceSpaceType: 'auto',
};
}
/**
* Enables localStorage persistence for form inputs
* Automatically saves and restores user preferences
*/
private setupLocalStorage(): void {
enableLocalStorage(this.serverTypeSelect, 'serverType');
enableLocalStorage(this.serverIpInput, 'serverIp');
enableLocalStorage(this.portInput, 'port');
enableLocalStorage(this.perEyeWidthInput, 'perEyeWidth');
enableLocalStorage(this.perEyeHeightInput, 'perEyeHeight');
enableLocalStorage(this.proxyUrlInput, 'proxyUrl');
enableLocalStorage(this.deviceFrameRateSelect, 'deviceFrameRate');
enableLocalStorage(this.maxStreamingBitrateMbpsSelect, 'maxStreamingBitrateMbps');
enableLocalStorage(this.immersiveSelect, 'immersiveMode');
enableLocalStorage(this.appSelect, 'app');
enableLocalStorage(this.referenceSpaceSelect, 'referenceSpace');
enableLocalStorage(this.xrOffsetXInput, 'xrOffsetX');
enableLocalStorage(this.xrOffsetYInput, 'xrOffsetY');
enableLocalStorage(this.xrOffsetZInput, 'xrOffsetZ');
}
/**
* Configures proxy settings based on the current protocol (HTTP/HTTPS)
* Sets appropriate placeholders and help text for port and proxy URL inputs
*/
private setupProxyConfiguration(): void {
// Update port placeholder based on protocol
if (window.location.protocol === 'https:') {
this.portInput.placeholder = 'Port (default: 48322, or 443 if proxy URL set)';
} else {
this.portInput.placeholder = 'Port (default: 49100)';
}
// Set default text and placeholder based on protocol
if (window.location.protocol === 'https:') {
this.proxyDefaultText.textContent =
'Optional: Leave empty for direct WSS connection, or provide URL for proxy routing (e.g., https://proxy.example.com/)';
this.proxyUrlInput.placeholder = '';
} else {
this.proxyDefaultText.textContent = 'Not needed for HTTP - uses direct WS connection';
this.proxyUrlInput.placeholder = '';
}
}
/**
* Sets up event listeners for form input changes
* Handles both input and change events for better compatibility
*/
private setupEventListeners(): void {
// Update configuration when form inputs change
const updateConfig = () => this.updateConfiguration();
// Helper function to add listeners and store them for cleanup
const addListener = (element: HTMLElement, event: string, handler: EventListener) => {
element.addEventListener(event, handler);
this.eventListeners.push({ element, event, handler });
};
// Add event listeners for all form fields
addListener(this.serverTypeSelect, 'change', updateConfig);
addListener(this.serverIpInput, 'input', updateConfig);
addListener(this.serverIpInput, 'change', updateConfig);
addListener(this.portInput, 'input', updateConfig);
addListener(this.portInput, 'change', updateConfig);
addListener(this.perEyeWidthInput, 'input', updateConfig);
addListener(this.perEyeWidthInput, 'change', updateConfig);
addListener(this.perEyeHeightInput, 'input', updateConfig);
addListener(this.perEyeHeightInput, 'change', updateConfig);
addListener(this.deviceFrameRateSelect, 'change', updateConfig);
addListener(this.maxStreamingBitrateMbpsSelect, 'change', updateConfig);
addListener(this.immersiveSelect, 'change', updateConfig);
addListener(this.appSelect, 'change', updateConfig);
addListener(this.referenceSpaceSelect, 'change', updateConfig);
addListener(this.xrOffsetXInput, 'input', updateConfig);
addListener(this.xrOffsetXInput, 'change', updateConfig);
addListener(this.xrOffsetYInput, 'input', updateConfig);
addListener(this.xrOffsetYInput, 'change', updateConfig);
addListener(this.xrOffsetZInput, 'input', updateConfig);
addListener(this.xrOffsetZInput, 'change', updateConfig);
addListener(this.proxyUrlInput, 'input', updateConfig);
addListener(this.proxyUrlInput, 'change', updateConfig);
// Set up certificate acceptance link and store cleanup function
this.certLinkCleanup = setupCertificateAcceptanceLink(
this.serverIpInput,
this.portInput,
this.proxyUrlInput,
this.certAcceptanceLink,
this.certLink
);
}
/**
* Updates the current configuration from form values
* Calls the configuration change callback if provided
*/
private updateConfiguration(): void {
const useSecure = this.getDefaultConfiguration().useSecureConnection;
const portValue = parseInt(this.portInput.value);
const hasProxy = this.proxyUrlInput.value.trim().length > 0;
// Smart default port based on connection type and proxy usage
let defaultPort = 49100; // HTTP default
if (useSecure) {
defaultPort = hasProxy ? 443 : 48322; // HTTPS with proxy → 443, HTTPS without → 48322
}
const newConfiguration: CloudXRConfig = {
serverIP: this.serverIpInput.value || this.getDefaultConfiguration().serverIP,
port: portValue || defaultPort,
useSecureConnection: useSecure,
perEyeWidth:
parseInt(this.perEyeWidthInput.value) || this.getDefaultConfiguration().perEyeWidth,
perEyeHeight:
parseInt(this.perEyeHeightInput.value) || this.getDefaultConfiguration().perEyeHeight,
deviceFrameRate:
parseInt(this.deviceFrameRateSelect.value) ||
this.getDefaultConfiguration().deviceFrameRate,
maxStreamingBitrateMbps:
parseInt(this.maxStreamingBitrateMbpsSelect.value) ||
this.getDefaultConfiguration().maxStreamingBitrateMbps,
immersiveMode:
(this.immersiveSelect.value as 'ar' | 'vr') || this.getDefaultConfiguration().immersiveMode,
app: this.appSelect.value || this.getDefaultConfiguration().app,
serverType: this.serverTypeSelect.value || this.getDefaultConfiguration().serverType,
proxyUrl: this.proxyUrlInput.value || this.getDefaultConfiguration().proxyUrl,
referenceSpaceType:
(this.referenceSpaceSelect.value as 'auto' | 'local-floor' | 'local' | 'viewer') ||
this.getDefaultConfiguration().referenceSpaceType,
// Convert cm from UI into meters for config (respect 0; if invalid, use 0)
xrOffsetX: (() => {
const v = parseFloat(this.xrOffsetXInput.value);
return Number.isFinite(v) ? v / 100 : 0;
})(),
xrOffsetY: (() => {
const v = parseFloat(this.xrOffsetYInput.value);
return Number.isFinite(v) ? v / 100 : 0;
})(),
xrOffsetZ: (() => {
const v = parseFloat(this.xrOffsetZInput.value);
return Number.isFinite(v) ? v / 100 : 0;
})(),
};
this.currentConfiguration = newConfiguration;
// Call the configuration change callback if provided
if (this.onConfigurationChange) {
this.onConfigurationChange(newConfiguration);
}
}
/**
* Gets the current configuration
* @returns Current configuration object
*/
public getConfiguration(): CloudXRConfig {
return { ...this.currentConfiguration };
}
/**
* Sets the start button state
* @param disabled - Whether the button should be disabled
* @param text - Text to display on the button
*/
public setStartButtonState(disabled: boolean, text: string): void {
if (this.startButton) {
this.startButton.disabled = disabled;
this.startButton.innerHTML = text;
}
}
/**
* Sets up the connect button click handler
* @param onConnect - Function to call when connect button is clicked
* @param onError - Function to call when an error occurs
*/
public setupConnectButtonHandler(
onConnect: () => Promise<void>,
onError: (error: Error) => void
): void {
if (this.startButton) {
// Remove any existing listener
if (this.handleConnectClick) {
this.startButton.removeEventListener('click', this.handleConnectClick);
}
// Create new handler
this.handleConnectClick = async () => {
// Disable button during XR session
this.setStartButtonState(true, 'CONNECT (starting XR session...)');
try {
await onConnect();
} catch (error) {
this.setStartButtonState(false, 'CONNECT');
onError(error as Error);
}
};
// Add the new listener
this.startButton.addEventListener('click', this.handleConnectClick);
}
}
/**
* Shows a status message in the UI with a specific type
* @param message - Message to display
* @param type - Message type: 'success', 'error', or 'info'
*/
public showStatus(message: string, type: 'success' | 'error' | 'info'): void {
if (this.errorMessageText && this.errorMessageBox) {
this.errorMessageText.textContent = message;
this.errorMessageBox.className = `error-message-box show ${type}`;
}
console[type === 'error' ? 'error' : 'info'](message);
}
/**
* Shows an error message in the UI
* @param message - Error message to display
*/
public showError(message: string): void {
this.showStatus(message, 'error');
}
/**
* Hides the error message
*/
public hideError(): void {
if (this.errorMessageBox) {
this.errorMessageBox.classList.remove('show');
}
}
/**
* Cleans up event listeners and resources
* Should be called when the component unmounts
*/
public cleanup(): void {
// Remove all stored event listeners
this.eventListeners.forEach(({ element, event, handler }) => {
element.removeEventListener(event, handler);
});
this.eventListeners = [];
// Remove CONNECT button listener
if (this.startButton && this.handleConnectClick) {
this.startButton.removeEventListener('click', this.handleConnectClick);
this.handleConnectClick = null;
}
// Clean up certificate acceptance link listeners
if (this.certLinkCleanup) {
this.certLinkCleanup();
this.certLinkCleanup = null;
}
}
}

View File

@@ -0,0 +1,288 @@
/**
* CloudXRComponent.tsx - CloudXR WebXR Integration Component
*
* This component handles the core CloudXR streaming functionality and WebXR integration.
* It manages:
* - CloudXR session lifecycle (creation, connection, disconnection, cleanup)
* - WebXR session event handling (sessionstart, sessionend)
* - WebGL state management and render target preservation
* - Frame-by-frame rendering loop with pose tracking and stream rendering
* - Server configuration and connection parameters
* - Status reporting back to parent components
*
* The component accepts configuration via props and communicates status changes
* and disconnect requests through callback props. It integrates with Three.js
* and React Three Fiber for WebXR rendering while preserving WebGL state
* for CloudXR's custom rendering pipeline.
*/
import { getConnectionConfig, ConnectionConfiguration, CloudXRConfig } from '@helpers/utils';
import { bindGL } from '@helpers/WebGLStateBinding';
import * as CloudXR from '@nvidia/cloudxr';
import { useThree, useFrame } from '@react-three/fiber';
import { useXR } from '@react-three/xr';
import { useRef, useEffect } from 'react';
import type { WebGLRenderer } from 'three';
interface CloudXRComponentProps {
config: CloudXRConfig;
onStatusChange?: (isConnected: boolean, status: string) => void;
onError?: (error: string) => void;
onSessionReady?: (session: CloudXR.Session | null) => void;
onServerAddress?: (address: string) => void;
}
// React component that integrates CloudXR with Three.js/WebXR
// This component handles the CloudXR session lifecycle and render loop
export default function CloudXRComponent({
config,
onStatusChange,
onError,
onSessionReady,
onServerAddress,
}: CloudXRComponentProps) {
const threeRenderer: WebGLRenderer = useThree().gl;
const { session } = useXR();
// React reference to the CloudXR session that persists across re-renders.
const cxrSessionRef = useRef<CloudXR.Session | null>(null);
// Disable Three.js so it doesn't clear the framebuffer after CloudXR renders.
threeRenderer.autoClear = false;
// Access Three.js WebXRManager and WebGL context.
const gl: WebGL2RenderingContext = threeRenderer.getContext() as WebGL2RenderingContext;
const trackedGL = bindGL(gl);
// Set up event listeners in useEffect to add them only once
useEffect(() => {
const webXRManager = threeRenderer.xr;
if (webXRManager) {
const handleSessionStart = async () => {
// 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[] = [
'local-floor',
'local',
'viewer',
'unbounded',
];
for (const t of fallbacks) {
try {
referenceSpace = await xrSession.requestReferenceSpace(t);
if (referenceSpace) break;
} catch (_) {}
}
} else {
try {
referenceSpace = await xrSession.requestReferenceSpace(
config.referenceSpaceType as XRReferenceSpaceType
);
} catch (error) {
console.error(
`Failed to request reference space '${config.referenceSpaceType}':`,
error
);
}
}
}
} catch (error) {
console.error('Failed to request XR reference space:', error);
referenceSpace = null;
}
if (!referenceSpace) {
// As a last resort, fall back to WebXRManager's current reference space
referenceSpace = webXRManager.getReferenceSpace();
}
if (referenceSpace) {
// Ensure that the session is not already created.
if (cxrSessionRef.current) {
console.error('CloudXR session already exists');
return;
}
const glBinding = webXRManager.getBinding();
if (!glBinding) {
console.warn('No WebGL binding found');
}
// Apply proxy configuration logic
let connectionConfig: ConnectionConfiguration;
try {
connectionConfig = getConnectionConfig(config.serverIP, config.port, config.proxyUrl);
onServerAddress?.(connectionConfig.serverIP);
} catch (error) {
onStatusChange?.(false, 'Configuration Error');
onError?.(`Proxy configuration failed: ${error}`);
return;
}
// Apply XR offset if provided in config (meters)
const offsetX = config.xrOffsetX || 0;
const offsetY = config.xrOffsetY || 0;
const offsetZ = config.xrOffsetZ || 0;
if (offsetX !== 0 || offsetY !== 0 || offsetZ !== 0) {
const offsetTransform = new XRRigidTransform(
{ x: offsetX, y: offsetY, z: offsetZ },
{ x: 0, y: 0, z: 0, w: 1 }
);
referenceSpace = referenceSpace.getOffsetReferenceSpace(offsetTransform);
}
// Fill in CloudXR session options.
const cloudXROptions: CloudXR.SessionOptions = {
serverAddress: connectionConfig.serverIP,
serverPort: connectionConfig.port,
useSecureConnection: connectionConfig.useSecureConnection,
perEyeWidth: config.perEyeWidth,
perEyeHeight: config.perEyeHeight,
gl: gl,
referenceSpace: referenceSpace,
deviceFrameRate: config.deviceFrameRate,
maxStreamingBitrateKbps: config.maxStreamingBitrateMbps * 1000, // Convert Mbps to Kbps
glBinding: glBinding,
telemetry: {
enabled: true,
appInfo: {
version: '6.0.0-beta',
product: 'CloudXR.js React Example',
},
},
};
// Store the render target and key GL bindings to restore after CloudXR rendering
const cloudXRDelegates: CloudXR.SessionDelegates = {
onWebGLStateChangeBegin: () => {
// Save the current render target before CloudXR changes state
trackedGL.save();
},
onWebGLStateChangeEnd: () => {
// Restore the tracked GL state to the state before CloudXR rendering.
trackedGL.restore();
},
onStreamStarted: () => {
console.debug('CloudXR stream started');
onStatusChange?.(true, 'Connected');
},
onStreamStopped: (error?: Error) => {
if (error) {
onStatusChange?.(false, 'Error');
onError?.(`CloudXR session stopped with error: ${error.message}`);
} else {
console.debug('CloudXR session stopped');
onStatusChange?.(false, 'Disconnected');
}
// Clear the session reference
cxrSessionRef.current = null;
onSessionReady?.(null);
},
};
// Create the CloudXR session.
let cxrSession: CloudXR.Session;
try {
cxrSession = CloudXR.createSession(cloudXROptions, cloudXRDelegates);
} catch (error) {
onStatusChange?.(false, 'Session Creation Failed');
onError?.(`Failed to create CloudXR session: ${error}`);
return;
}
// Store the session in the ref so it persists across re-renders
cxrSessionRef.current = cxrSession;
// Notify parent that session is ready
onSessionReady?.(cxrSession);
// Start session (synchronous call that initiates connection)
try {
cxrSession.connect();
console.log('CloudXR session connect initiated');
// Note: The session will transition to Connected state via the onStreamStarted callback
// Use cxrSession.state to check if streaming has actually started
} catch (error) {
onStatusChange?.(false, 'Connection Failed');
// Report error via callback
onError?.('Failed to connect CloudXR session');
// Clean up the failed session
cxrSessionRef.current = null;
}
}
};
const handleSessionEnd = () => {
if (cxrSessionRef.current) {
cxrSessionRef.current.disconnect();
cxrSessionRef.current = null;
onSessionReady?.(null);
}
};
// Add start+end session event listeners to the WebXRManager.
webXRManager.addEventListener('sessionstart', handleSessionStart);
webXRManager.addEventListener('sessionend', handleSessionEnd);
// Cleanup function to remove listeners
return () => {
webXRManager.removeEventListener('sessionstart', handleSessionStart);
webXRManager.removeEventListener('sessionend', handleSessionEnd);
};
}
}, [threeRenderer, config]); // Re-register handlers when renderer or config changes
// Custom render loop - runs every frame
useFrame((state, delta) => {
const webXRManager = threeRenderer.xr;
if (webXRManager.isPresenting && session) {
// Access the current WebXR XRFrame
const xrFrame = state.gl.xr.getFrame();
if (xrFrame) {
// Get THREE WebXRManager from the the useFrame state.
const webXRManager = state.gl.xr;
if (!cxrSessionRef || !cxrSessionRef.current) {
console.debug('Skipping frame, no session yet');
// Clear the framebuffer as we've set autoClear to false.
threeRenderer.clear();
return;
}
// Get session from reference.
const cxrSession: CloudXR.Session = cxrSessionRef.current;
// If the CloudXR session is not connected, skip the frame.
if (cxrSession.state !== CloudXR.SessionState.Connected) {
console.debug('Skipping frame, session not connected, state:', cxrSession.state);
// Clear the framebuffer as we've set autoClear to false.
threeRenderer.clear();
return;
}
// Get timestamp from useFrame state and convert to milliseconds.
const timestamp: DOMHighResTimeStamp = state.clock.elapsedTime * 1000;
// Send the tracking state (including viewer pose and hand/controller data) to the server, this will trigger server-side rendering for frame.
cxrSession.sendTrackingStateToServer(timestamp, xrFrame);
// Get the WebXR layer from THREE WebXRManager.
let layer: XRWebGLLayer = webXRManager.getBaseLayer() as XRWebGLLayer;
// Render the current streamed CloudXR frame (not the frame that was just sent to the server).
cxrSession.render(timestamp, xrFrame, layer);
}
}
}, -1000);
return null;
}

218
deps/cloudxr/react/src/CloudXRUI.tsx vendored Normal file
View File

@@ -0,0 +1,218 @@
/**
* CloudXRUI.tsx - CloudXR User Interface Component
*
* This component renders the in-VR user interface for the CloudXR application using
* React Three UIKit. It provides:
* - CloudXR branding and title display
* - Server connection information and status display
* - Interactive control buttons (Start Teleop, Reset Teleop, Disconnect)
* - Responsive button layout with hover effects
* - Integration with parent component event handlers
* - Configurable position and rotation in world space for flexible UI placement
*
* The UI is positioned in 3D space and designed for VR/AR interaction with
* visual feedback and clear button labeling. All interactions are passed
* back to the parent component through callback props.
*/
import { Container, Text, Image } from '@react-three/uikit';
import { Button } from '@react-three/uikit-default';
import React from 'react';
interface CloudXRUIProps {
onStartTeleop?: () => void;
onDisconnect?: () => void;
onResetTeleop?: () => void;
serverAddress?: string;
sessionStatus?: string;
playLabel?: string;
playDisabled?: boolean;
countdownSeconds?: number;
onCountdownIncrease?: () => void;
onCountdownDecrease?: () => void;
countdownDisabled?: boolean;
position?: [number, number, number];
rotation?: [number, number, number];
}
export default function CloudXR3DUI({
onStartTeleop,
onDisconnect,
onResetTeleop,
serverAddress = '127.0.0.1',
sessionStatus = 'Disconnected',
playLabel = 'Play',
playDisabled = false,
countdownSeconds,
onCountdownIncrease,
onCountdownDecrease,
countdownDisabled = false,
position = [1.8, 1.75, -1.3],
rotation = [0, -0.3, 0],
}: CloudXRUIProps) {
return (
<group position={position} rotation={rotation}>
<Container
pixelSize={0.001}
width={1920}
height={1584}
alignItems="center"
justifyContent="center"
pointerEvents="auto"
padding={40}
sizeX={3}
sizeY={2.475}
>
<Container
width={1600}
height={900}
backgroundColor="rgba(40, 40, 40, 0.85)"
borderRadius={20}
padding={60}
paddingBottom={80}
alignItems="center"
justifyContent="center"
flexDirection="column"
gap={36}
>
{/* Title */}
<Text fontSize={96} fontWeight="bold" color="white" textAlign="center">
Controls
</Text>
{/* Server Info */}
<Text fontSize={48} color="white" textAlign="center" marginBottom={24}>
Server address: {serverAddress}
</Text>
<Text fontSize={48} color="white" textAlign="center" marginBottom={48}>
Session status: {sessionStatus}
</Text>
{/* Countdown Config Row */}
<Container flexDirection="row" gap={24} alignItems="center" justifyContent="center">
<Text fontSize={40} color="white">
Countdown
</Text>
<Button
onClick={onCountdownDecrease}
variant="default"
width={105}
height={105}
borderRadius={52.5}
backgroundColor="rgba(220, 220, 220, 0.9)"
disabled={countdownDisabled}
>
<Text fontSize={48} color="black" fontWeight="bold">
-
</Text>
</Button>
<Container
width={180}
height={105}
alignItems="center"
justifyContent="center"
backgroundColor="rgba(255,255,255,0.9)"
borderRadius={12}
>
<Text fontSize={56} color="black">
{countdownSeconds}s
</Text>
</Container>
<Button
onClick={onCountdownIncrease}
variant="default"
width={105}
height={105}
borderRadius={52.5}
backgroundColor="rgba(220, 220, 220, 0.9)"
disabled={countdownDisabled}
>
<Text fontSize={48} color="black" fontWeight="bold">
+
</Text>
</Button>
</Container>
{/* Button Grid */}
<Container
flexDirection="column"
gap={60}
alignItems="center"
justifyContent="center"
width="100%"
>
{/* Start/reset row*/}
<Container flexDirection="row" gap={60} justifyContent="center">
<Button
onClick={onStartTeleop}
variant="default"
width={480}
height={120}
borderRadius={40}
backgroundColor="rgba(220, 220, 220, 0.9)"
hover={{
backgroundColor: 'rgba(100, 150, 255, 1)',
borderColor: 'white',
borderWidth: 2,
}}
disabled={playDisabled}
>
<Container flexDirection="row" alignItems="center" gap={12}>
{playLabel === 'Play' && <Image src="./play-circle.svg" width={60} height={60} />}
<Text fontSize={48} color="black" fontWeight="medium">
{playLabel}
</Text>
</Container>
</Button>
<Button
onClick={onResetTeleop}
variant="default"
width={480}
height={120}
borderRadius={40}
backgroundColor="rgba(220, 220, 220, 0.9)"
hover={{
backgroundColor: 'rgba(100, 150, 255, 1)',
borderColor: 'white',
borderWidth: 2,
}}
>
<Container flexDirection="row" alignItems="center" gap={12}>
<Image src="./arrow-uturn-left.svg" width={60} height={60} />
<Text fontSize={48} color="black" fontWeight="medium">
Reset
</Text>
</Container>
</Button>
</Container>
{/* Bottom Row */}
<Container flexDirection="row" justifyContent="center">
<Button
onClick={onDisconnect}
variant="destructive"
width={330}
height={105}
borderRadius={35}
backgroundColor="rgba(255, 150, 150, 0.9)"
hover={{
backgroundColor: 'rgba(255, 50, 50, 1)',
borderColor: 'white',
borderWidth: 2,
}}
>
<Container flexDirection="row" alignItems="center" gap={12}>
<Image src="./arrow-left-start-on-rectangle.svg" width={60} height={60} />
<Text fontSize={40} color="black" fontWeight="medium">
Disconnect
</Text>
</Container>
</Button>
</Container>
</Container>
</Container>
</Container>
</group>
);
}

576
deps/cloudxr/react/src/index.html vendored Normal file
View File

@@ -0,0 +1,576 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="robots" content="noindex, nofollow">
<meta name="description" content="NVIDIA CloudXR.js React Three Fiber Example for VR/AR streaming">
<title>NVIDIA CloudXR.js React Three Fiber Example</title>
<link rel="icon" type="image/x-icon" href="favicon.ico">
<style>
:root {
--primary-green: #76b900;
--border-color: #e0e0e0;
--text-main: #111;
--background-main: #fff;
--input-radius: 0px;
--input-border: 2px solid var(--border-color);
}
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
background: var(--background-main);
color: var(--text-main);
touch-action: manipulation;
-webkit-font-smoothing: antialiased;
}
@supports (padding: max(0px)) {
body {
padding: max(0px, env(safe-area-inset-top)) max(0px, env(safe-area-inset-right)) max(0px, env(safe-area-inset-bottom)) max(0px, env(safe-area-inset-left));
}
}
/* Top Banner */
.top-banner {
width: 100vw;
height: 8px;
background: var(--primary-green);
margin: 0;
padding: 0;
}
/* Header */
header {
background: var(--background-main);
padding: 16px 0 16px 24px;
min-height: unset;
box-shadow: none;
border-bottom: 1px solid var(--border-color);
}
h1 {
font-size: 1.5rem;
font-weight: 600;
}
main {
display: flex;
height: calc(100vh - 64px);
}
aside {
width: 540px;
min-width: 400px;
padding: 32px 24px 24px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-color);
overflow-y: auto;
}
.settings-title {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 24px;
}
/* Inputs and Selectors */
.ui-input,
select {
width: 100%;
min-width: 300px;
padding: 16px 12px;
font-size: 1rem;
border: var(--input-border);
border-radius: var(--input-radius);
background: var(--background-main);
min-height: 48px;
}
.ui-input {
margin-bottom: 16px;
}
.ui-input:focus,
select:focus {
outline: none;
border-color: var(--primary-green);
box-shadow: 0 0 0 3px rgba(118, 185, 0, 0.2);
}
.ui-input::placeholder {
color: #999;
}
.settings-table {
width: 100%;
margin-bottom: 24px;
border-collapse: collapse;
}
.settings-table td {
padding: 12px 0;
vertical-align: middle;
}
.settings-table label {
font-weight: 600;
display: inline-block;
min-width: 140px;
font-size: 1rem;
}
.settings-table select {
width: 200px;
padding: 12px 8px;
cursor: pointer;
}
button {
background: var(--primary-green);
color: #fff;
border: none;
border-radius: var(--input-radius);
padding: 16px 24px;
font-size: 1.1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.3s ease;
min-height: 56px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
button:hover {
background: #8ac800;
}
button:disabled {
background: #666;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.start-button {
margin-top: 16px;
width: auto;
font-size: 1rem;
padding: 12px 20px;
color: #000;
}
section {
flex: 1;
padding: 48px 32px 32px;
background: #f8f8f8;
overflow-y: auto;
}
.debug-title {
font-weight: 700;
margin-bottom: 16px;
font-size: 1.3rem;
}
.debug-list {
margin: 0;
padding: 0;
list-style: none;
color: #666;
}
.debug-list li {
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
font-size: 0.95rem;
}
.debug-list li:last-child {
border-bottom: none;
}
@media (max-width: 768px) {
main {
flex-direction: column;
height: auto;
min-height: calc(100vh - 64px);
}
html,
body {
overflow: auto;
}
aside {
width: 100%;
min-width: auto;
border-right: none;
border-bottom: 1px solid #404040;
}
section {
padding: 24px 16px;
}
header {
font-size: 1.1rem;
padding: 12px 16px;
}
.settings-table select {
width: 100%;
max-width: 200px;
}
}
@media (prefers-contrast: high) {
body,
aside,
.ui-input,
select {
background: var(--background-main);
color: #000;
}
aside {
border-color: #000;
}
section {
background: #f0f0f0;
}
.ui-input,
select {
border-color: #000;
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
.ui-input:focus-visible,
select:focus-visible,
button:focus-visible {
outline: 3px solid var(--primary-green);
outline-offset: 2px;
}
.input-label {
display: block;
font-size: 0.92rem;
color: #444;
margin-bottom: 5px;
margin-top: 10px;
font-weight: 500;
}
.input-label:first-child {
margin-top: 0;
}
/* Console Logs Section */
.console-logs-section {
margin-top: 24px;
}
.console-logs-textarea {
height: 300px;
font-family: 'Courier New', monospace;
resize: vertical;
overflow-y: auto;
}
.console-logs-buttons {
margin-top: 8px;
}
.console-logs-button {
background: #666;
font-size: 0.9rem;
padding: 8px 16px;
margin-right: 8px;
}
.console-logs-button:last-child {
margin-right: 0;
}
/* Configuration Section */
.config-section {
margin-top: 24px;
}
.config-input {
margin-bottom: 8px;
}
.config-text {
font-size: 0.85rem;
color: #666;
margin-bottom: 16px;
}
/* Exit Button */
.exit-button {
position: fixed;
top: 20px;
right: 20px;
background: #ff3300cc;
color: white;
border: none;
border-radius: 5px;
padding: 10px 15px;
font-weight: bold;
font-size: 16px;
cursor: pointer;
transition: all 0.3s ease;
display: none;
z-index: 10000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.exit-button:hover {
background: #ff3300;
transform: scale(1.05);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
/* Error Message Box */
.error-message-box {
display: none;
background: #ffebee;
border: 2px solid #f44336;
border-radius: 4px;
padding: 12px 16px;
margin: 16px 0;
color: #c62828;
font-size: 0.9rem;
font-weight: 500;
line-height: 1.4;
}
.error-message-box.show {
display: block;
}
/* Success state - green */
.error-message-box.success {
background: #e8f5e9;
border-color: var(--primary-green);
color: #2e7d32;
}
/* Info state - light blue */
.error-message-box.info {
background: #e3f2fd;
border-color: #2196f3;
color: #1565c0;
}
/* Error state - red (default) */
.error-message-box.error {
background: #ffebee;
border-color: #f44336;
color: #c62828;
}
.error-message-box .error-icon {
display: inline-block;
margin-right: 8px;
font-weight: bold;
}
/* Certificate Acceptance Link */
.cert-acceptance-link {
background: #e3f2fd;
border: 2px solid #2196f3;
border-radius: 4px;
padding: 12px 16px;
margin: 8px 0 16px 0;
font-size: 0.9rem;
line-height: 1.4;
}
.cert-acceptance-link .cert-icon {
display: inline-block;
margin-right: 8px;
}
.cert-acceptance-link a {
color: #1565c0;
text-decoration: none;
font-weight: 600;
border-bottom: 1px solid #1565c0;
}
.cert-acceptance-link a:hover {
color: #0d47a1;
border-bottom-color: #0d47a1;
}
/* Hide 2D UI when in XR mode */
body.xr-mode #2d-ui {
display: none;
}
</style>
</head>
<body>
<div id="2d-ui">
<div class="top-banner"></div>
<header>
<h1>NVIDIA CloudXR.js React Three Fiber Example</h1>
</header>
<main>
<aside>
<button id="startButton" class="start-button" type="button" aria-label="Connect"
disabled>CONNECT</button>
<div id="errorMessageBox" class="error-message-box" role="alert" aria-live="polite">
<span class="error-icon"></span>
<span id="errorMessageText"></span>
</div>
<h2 class="settings-title">Settings</h2>
<form id="settingsForm">
<label for="serverType" class="input-label">Select Server Backend</label>
<select id="serverType" class="ui-input">
<option value="manual">Manual Input IP:Port</option>
<option value="nvcf" disabled>NVCF</option>
</select>
<div id="manualFields">
<label for="serverIpInput" class="input-label">Server IP</label>
<input id="serverIpInput" class="ui-input" type="text" placeholder="Server IP"
spellcheck="false">
<label for="portInput" class="input-label">Port</label>
<input id="portInput" class="ui-input" type="number" placeholder="Port (default: 49100)"
spellcheck="false" min="1" max="65535">
<div id="certAcceptanceLink" class="cert-acceptance-link" style="display: none;">
<span class="cert-icon">🔒</span>
<a id="certLink" href="#" target="_blank" rel="noopener noreferrer"></a>
</div>
</div>
<label for="app" class="input-label">Application</label>
<select id="app" name="app" class="ui-input" aria-label="Select application">
<option value="generic">Generic Client</option>
<!-- TODO: Add other applications here -->
</select>
<label for="immersive" class="input-label">Immersive Mode</label>
<select id="immersive" name="immersive" class="ui-input" aria-label="Select immersive mode">
<option value="ar" selected>AR Immersive</option>
<option value="vr">VR Immersive</option>
</select>
</form>
</aside>
<section>
<h3 class="debug-title">Debug Settings</h3>
<div class="config-section">
<label class="input-label">Proxy Configuration</label>
<label for="proxyUrl" class="input-label">Proxy URL</label>
<input id="proxyUrl" class="ui-input config-input" type="text" placeholder="" spellcheck="false" autocapitalize="off">
<div class="config-text">
<span id="proxyDefaultText"></span>
</div>
</div>
<div class="config-section">
<label class="input-label">Frame Rate Configuration</label>
<label for="deviceFrameRate" class="input-label">Device Frame Rate</label>
<select id="deviceFrameRate" class="ui-input config-input">
<option value="72">72 FPS</option>
<option value="90" selected>90 FPS</option>
<option value="120">120 FPS</option>
</select>
<div class="config-text">
Select the target device frame rate for the XR session
</div>
</div>
<div class="config-section">
<label class="input-label">Maximum Streaming Bitrate</label>
<label for="maxStreamingBitrateMbps" class="input-label">Megabits per second</label>
<select id="maxStreamingBitrateMbps" class="ui-input config-input">
<option value="80">80 Mbps</option>
<option value="100">100 Mbps</option>
<option value="120">120 Mbps</option>
<option value="150" selected>150 Mbps</option>
<option value="180">180 Mbps</option>
<option value="200">200 Mbps</option>
</select>
<div class="config-text">
Select the maximum streaming bitrate (in Megabits per second) for the XR session
</div>
</div>
<div class="config-section">
<label class="input-label">Stream Resolution Configuration</label>
<label for="perEyeWidth" class="input-label">Per-Eye Width (default: 2048)</label>
<input id="perEyeWidth" class="ui-input config-input" type="number"
placeholder="Per-eye width in pixels" spellcheck="false" value="2048">
<label for="perEyeHeight" class="input-label">Per-Eye Height (default: 1792)</label>
<input id="perEyeHeight" class="ui-input config-input" type="number"
placeholder="Per-eye height in pixels" spellcheck="false" value="1792">
<div class="config-text">
Configure the per-eye resolution. Width and height must be multiples of 16.
</div>
</div>
<div class="config-section">
<label for="referenceSpace" class="input-label">Reference Space</label>
<select id="referenceSpace" name="referenceSpace" class="ui-input" aria-label="Select reference space">
<option value="auto">Auto (local-floor preferred)</option>
<option value="local-floor">local-floor</option>
<option value="local" selected>local</option>
<option value="unbounded">unbounded</option>
<option value="viewer">viewer</option>
</select>
<div class="config-text">
Select the preferred reference space for XR tracking. "Auto" uses fallback logic (local-floor → local → viewer). Other options will attempt to use the specified space only.
</div>
<label class="input-label">XR Reference Space Offset</label>
<label for="xrOffsetX" class="input-label">X Offset (centimeters): Horizontal</label>
<input id="xrOffsetX" class="ui-input config-input" type="number" placeholder="X offset in centimeters"
spellcheck="false" value="0" step="1" min="-1000" max="1000">
<label for="xrOffsetY" class="input-label">Y Offset (centimeters): Vertical</label>
<input id="xrOffsetY" class="ui-input config-input" type="number" placeholder="Y offset in centimeters"
spellcheck="false" value="-155" step="1" min="-1000" max="1000">
<label for="xrOffsetZ" class="input-label">Z Offset (centimeters): Depth (forward/backward)</label>
<input id="xrOffsetZ" class="ui-input config-input" type="number" placeholder="Z offset in centimeters"
spellcheck="false" value="10" step="1" min="-1000" max="1000">
<div class="config-text">
Configure the XR reference space offset in centimeters. These values will be applied to the reference space transform to adjust the origin position of the XR experience.
</div>
</div>
</section>
</main>
</div>
<div id="3d-ui"></div>
</body>
</html>

27
deps/cloudxr/react/src/index.tsx vendored Normal file
View File

@@ -0,0 +1,27 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
// Start the React app immediately in the 3d-ui container
function startApp() {
const reactContainer = document.getElementById('3d-ui');
if (reactContainer) {
const root = ReactDOM.createRoot(reactContainer);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
} else {
console.error('3d-ui container not found');
}
}
// Initialize the app when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startApp);
} else {
startApp();
}