Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
@@ -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<HTMLCanvasElement | null>(null);
|
||||||
|
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
|
||||||
|
const mirrorCamRef = useRef<THREE.PerspectiveCamera | null>(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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<group
|
||||||
|
position={position}
|
||||||
|
onPointerOver={(e) => { e.stopPropagation(); if (!disabled) setHovered(true); }}
|
||||||
|
onPointerOut={(e) => { e.stopPropagation(); setHovered(false); }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); if (!disabled && onClick) onClick(); }}
|
||||||
|
>
|
||||||
|
{/* Border */}
|
||||||
|
<mesh position={[0, 0, -0.0005]}>
|
||||||
|
<planeGeometry args={[w + 0.002, h + 0.002]} />
|
||||||
|
<meshBasicMaterial color={borderColor} transparent opacity={0.95} />
|
||||||
|
</mesh>
|
||||||
|
{/* Background */}
|
||||||
|
<mesh>
|
||||||
|
<planeGeometry args={[w, h]} />
|
||||||
|
<meshBasicMaterial color={bgColor} transparent opacity={0.92} side={THREE.DoubleSide} />
|
||||||
|
</mesh>
|
||||||
|
<Text
|
||||||
|
position={[0, 0, 0.001]}
|
||||||
|
fontSize={fontSize}
|
||||||
|
color={fgColor}
|
||||||
|
anchorX="center"
|
||||||
|
anchorY="middle"
|
||||||
|
maxWidth={w * 0.95}
|
||||||
|
>
|
||||||
|
{icon ? `${icon} ${label}` : label}
|
||||||
|
</Text>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<group position={position}>
|
||||||
|
<mesh position={[0, 0, -0.001]}>
|
||||||
|
<planeGeometry args={[w + 0.004, h + 0.004]} />
|
||||||
|
<meshBasicMaterial color="#3b82f6" transparent opacity={0.6} />
|
||||||
|
</mesh>
|
||||||
|
<mesh>
|
||||||
|
<planeGeometry args={[w, h]} />
|
||||||
|
<meshBasicMaterial color={color} transparent opacity={opacity} side={THREE.DoubleSide} />
|
||||||
|
</mesh>
|
||||||
|
{children}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user