diff --git a/src/components/three/XRBroadcastMirror.tsx b/src/components/three/XRBroadcastMirror.tsx new file mode 100644 index 0000000..142cca3 --- /dev/null +++ b/src/components/three/XRBroadcastMirror.tsx @@ -0,0 +1,119 @@ +import { useEffect, useRef } from 'react'; +import { useFrame, useThree } from '@react-three/fiber'; +import * as THREE from 'three'; +import { setBroadcastSource } from '@/lib/webrtc/broadcastSource'; + +interface XRBroadcastMirrorProps { + enabled: boolean; + width?: number; + height?: number; + /** Solid background color used in place of real passthrough (privacy + clarity). */ + backgroundColor?: string; +} + +/** + * Renders the current scene to an OFFSCREEN opaque canvas every frame so a + * MediaStream can capture it. The main XR canvas uses alpha:true for + * passthrough — viewers receiving captureStream() of that would see only + * the 3D content over transparency. This mirror gives them a usable picture. + */ +export function XRBroadcastMirror({ + enabled, + width = 1280, + height = 720, + backgroundColor = '#1a1a1a', +}: XRBroadcastMirrorProps) { + const { gl, scene } = useThree(); + const canvasRef = useRef(null); + const rendererRef = useRef(null); + const mirrorCamRef = useRef(null); + + // Create / dispose the offscreen renderer + useEffect(() => { + if (!enabled) return; + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + canvasRef.current = canvas; + + const renderer = new THREE.WebGLRenderer({ + canvas, + alpha: false, + antialias: true, + powerPreference: 'high-performance', + preserveDrawingBuffer: false, + }); + renderer.setPixelRatio(1); + renderer.setSize(width, height, false); + renderer.setClearColor(new THREE.Color(backgroundColor), 1); + renderer.outputColorSpace = THREE.SRGBColorSpace; + rendererRef.current = renderer; + + mirrorCamRef.current = new THREE.PerspectiveCamera(70, width / height, 0.01, 100); + + setBroadcastSource(canvas); + + return () => { + setBroadcastSource(null); + renderer.dispose(); + rendererRef.current = null; + canvasRef.current = null; + mirrorCamRef.current = null; + }; + }, [enabled, width, height, backgroundColor]); + + // Per-frame mirror render (throttled to ~24 fps to save GPU) + const lastTime = useRef(0); + useFrame(() => { + if (!enabled) return; + const renderer = rendererRef.current; + const mirrorCam = mirrorCamRef.current; + if (!renderer || !mirrorCam) return; + + const now = performance.now(); + if (now - lastTime.current < 42) return; // ~24 fps cap + lastTime.current = now; + + // Pick the best available camera: XR camera if in session, else main camera + let srcCam: THREE.Camera | null = null; + const xrCam = (gl.xr as unknown as { isPresenting?: boolean; getCamera?: () => THREE.ArrayCamera }).getCamera?.(); + const isPresenting = (gl.xr as unknown as { isPresenting?: boolean }).isPresenting; + if (isPresenting && xrCam && (xrCam as THREE.ArrayCamera).cameras?.length) { + srcCam = (xrCam as THREE.ArrayCamera).cameras[0]; // left eye + } + + if (srcCam instanceof THREE.PerspectiveCamera) { + mirrorCam.fov = srcCam.fov; + mirrorCam.near = srcCam.near; + mirrorCam.far = srcCam.far; + } + if (srcCam) { + srcCam.updateMatrixWorld(true); + mirrorCam.position.setFromMatrixPosition(srcCam.matrixWorld); + mirrorCam.quaternion.setFromRotationMatrix(srcCam.matrixWorld); + } else { + // Fallback: use the main R3F camera (desktop preview) + const main = (gl as unknown as { __mainCam?: THREE.Camera }).__mainCam; + if (main) { + main.updateMatrixWorld(true); + mirrorCam.position.setFromMatrixPosition(main.matrixWorld); + mirrorCam.quaternion.setFromRotationMatrix(main.matrixWorld); + if (main instanceof THREE.PerspectiveCamera) { + mirrorCam.fov = main.fov; + mirrorCam.near = main.near; + mirrorCam.far = main.far; + } + } + } + mirrorCam.aspect = width / height; + mirrorCam.updateProjectionMatrix(); + + try { + renderer.render(scene, mirrorCam); + } catch (e) { + console.warn('[XRBroadcastMirror] render failed', e); + } + }); + + return null; +} diff --git a/src/components/three/XRPanel3D.tsx b/src/components/three/XRPanel3D.tsx new file mode 100644 index 0000000..248fe8f --- /dev/null +++ b/src/components/three/XRPanel3D.tsx @@ -0,0 +1,103 @@ +import { useState, ReactNode } from 'react'; +import { Text } from '@react-three/drei'; +import * as THREE from 'three'; + +interface XR3DButtonProps { + position?: [number, number, number]; + size?: [number, number]; + label: string; + icon?: string; + active?: boolean; + disabled?: boolean; + onClick?: () => void; + color?: string; + fontSize?: number; +} + +/** Flat 3D button suitable for in-XR HUD. Reacts to controller raycast. */ +export function XR3DButton({ + position = [0, 0, 0], + size = [0.08, 0.025], + label, + icon, + active = false, + disabled = false, + onClick, + color, + fontSize = 0.008, +}: XR3DButtonProps) { + const [hovered, setHovered] = useState(false); + const [w, h] = size; + + const bgColor = disabled + ? '#222' + : active + ? (color ?? '#3b82f6') + : hovered + ? '#444' + : '#1a1a1a'; + const fgColor = disabled ? '#555' : active ? '#ffffff' : '#e5e7eb'; + const borderColor = active ? (color ?? '#3b82f6') : hovered ? '#666' : '#333'; + + return ( + { e.stopPropagation(); if (!disabled) setHovered(true); }} + onPointerOut={(e) => { e.stopPropagation(); setHovered(false); }} + onClick={(e) => { e.stopPropagation(); if (!disabled && onClick) onClick(); }} + > + {/* Border */} + + + + + {/* Background */} + + + + + + {icon ? `${icon} ${label}` : label} + + + ); +} + +interface XR3DPanelProps { + position?: [number, number, number]; + size?: [number, number]; + color?: string; + opacity?: number; + children?: ReactNode; +} + +/** Background panel for grouping XR3DButtons. */ +export function XR3DPanel({ + position = [0, 0, 0], + size = [0.4, 0.3], + color = '#0a0a0a', + opacity = 0.85, + children, +}: XR3DPanelProps) { + const [w, h] = size; + return ( + + + + + + + + + + {children} + + ); +} diff --git a/src/lib/webrtc/broadcastSource.ts b/src/lib/webrtc/broadcastSource.ts new file mode 100644 index 0000000..163b2cd --- /dev/null +++ b/src/lib/webrtc/broadcastSource.ts @@ -0,0 +1,20 @@ +/** + * Lightweight registry that lets the XR mirror expose its offscreen canvas + * as the preferred broadcast source. ShareButton checks here first. + */ +let activeCanvas: HTMLCanvasElement | null = null; +const listeners = new Set<(c: HTMLCanvasElement | null) => void>(); + +export function setBroadcastSource(c: HTMLCanvasElement | null) { + activeCanvas = c; + listeners.forEach((cb) => cb(c)); +} + +export function getBroadcastSource(): HTMLCanvasElement | null { + return activeCanvas; +} + +export function onBroadcastSourceChange(cb: (c: HTMLCanvasElement | null) => void) { + listeners.add(cb); + return () => listeners.delete(cb); +}