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:
555
deps/cloudxr/react/src/App.tsx
vendored
Normal file
555
deps/cloudxr/react/src/App.tsx
vendored
Normal 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
438
deps/cloudxr/react/src/CloudXR2DUI.tsx
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
288
deps/cloudxr/react/src/CloudXRComponent.tsx
vendored
Normal file
288
deps/cloudxr/react/src/CloudXRComponent.tsx
vendored
Normal 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
218
deps/cloudxr/react/src/CloudXRUI.tsx
vendored
Normal 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
576
deps/cloudxr/react/src/index.html
vendored
Normal 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
27
deps/cloudxr/react/src/index.tsx
vendored
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user