fix(lab): AR Passthrough - árvore JSX corrigida (<Canvas><XR>), ARBackground nunca bloqueia passthrough (issue #344)

- Árvore: <Canvas><XR>...</XR></Canvas> (era <XR><Canvas>) - <XR> chama useThree() e PRECISA estar dentro do Canvas
- gl.xr.enabled manual removido (controlado pelo <XR>)
- cleanup session.end() no unmount (issue #490)
- store.enterAR() em vez de navigator.xr.requestSession cru
- preserveDrawingBuffer:true idem ao XRSession.tsx que funciona
This commit is contained in:
Hermes
2026-08-05 00:32:39 +00:00
parent 3f01190559
commit 11659072e7
+61 -79
View File
@@ -24,12 +24,15 @@ import { Suspense } from "react";
/** /**
* ============================================================ * ============================================================
* QUEST CUBE LAB — Fase 2.5 (PHYSICS HLA-LIKE) * QUEST CUBE LAB — Fase 2.5 (PHYSICS HLA-LIKE)
* - Física real com Rapier3D (gravidade, colisões, impulso) * FIX v4 — AR Passthrough funcional em Meta Quest 3
* - Cubo é um RigidBody dinâmico (cai, bate, joga) *
* - Mesa é RigidBody estático (cubo pousa em cima) * Mudanças principais vs. versão anterior (3f01190):
* - Pega o cubo com a mão virtual do Quest 3 * 1. ÁRVORE: <Canvas><XR>...</XR></Canvas> (era <XR><Canvas>)
* - Joga o cubo com impulso (igual Half-Life: Alyx) * — <XR> chama useThree() e useFrame() e PRECISA estar dentro do Canvas
* - Mantém sistema 2-cliques FASE 4 (contato + direção) * 2. <color attach="background"> REMOVIDO em AR — opacificava passthrough (issue pmndrs/xr#344)
* 3. requiredFeatures removido — usa store.enterAR() que respeita config do xrStore
* 4. gl.xr.enabled manual removido — agora é controlado pelo <XR>
* 5. cleanup: session.end() ao desmontar
* ============================================================ * ============================================================
*/ */
@@ -74,11 +77,8 @@ const ARHudMessages = ({ alignPhase }: { alignPhase: AlignPhase }) => {
// =================================================================== // ===================================================================
// CUBO FÍSICO com Rapier — HLA-like // CUBO FÍSICO com Rapier — HLA-like
// =================================================================== // ===================================================================
// (Implementado abaixo, depois dos imports do Rapier)
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier"; import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
// Cubo com física rígida
type CubeProps = { type CubeProps = {
position: [number, number, number]; position: [number, number, number];
locked: boolean; locked: boolean;
@@ -116,15 +116,12 @@ const PhysicsCube = ({ position, locked, onFaceClick, resetSignal }: CubeProps)
linearDamping={0.1} linearDamping={0.1}
angularDamping={0.3} angularDamping={0.3}
> >
{/* collider gerado pra casar com a geometria do cubo */}
<CuboidCollider args={[CUBE_SIZE / 2, CUBE_SIZE / 2, CUBE_SIZE / 2]} /> <CuboidCollider args={[CUBE_SIZE / 2, CUBE_SIZE / 2, CUBE_SIZE / 2]} />
{/* 6 faces coloridas — HLA-like aesthetic */}
<mesh castShadow receiveShadow onClick={faceHandlers("x+")}> <mesh castShadow receiveShadow onClick={faceHandlers("x+")}>
<boxGeometry args={[CUBE_SIZE, CUBE_SIZE, CUBE_SIZE]} /> <boxGeometry args={[CUBE_SIZE, CUBE_SIZE, CUBE_SIZE]} />
<meshStandardMaterial color="#ef4444" /> <meshStandardMaterial color="#ef4444" />
</mesh> </mesh>
{/* marcação visual das faces usando overlays */}
{[ {[
{ pos: [CUBE_SIZE / 2 + 0.001, 0, 0], rot: [0, Math.PI / 2, 0], color: "#ef4444" }, { pos: [CUBE_SIZE / 2 + 0.001, 0, 0], rot: [0, Math.PI / 2, 0], color: "#ef4444" },
{ pos: [-CUBE_SIZE / 2 - 0.001, 0, 0], rot: [0, -Math.PI / 2, 0], color: "#22c55e" }, { pos: [-CUBE_SIZE / 2 - 0.001, 0, 0], rot: [0, -Math.PI / 2, 0], color: "#22c55e" },
@@ -147,7 +144,7 @@ const PhysicsCube = ({ position, locked, onFaceClick, resetSignal }: CubeProps)
}; };
// =================================================================== // ===================================================================
// MESA FÍSICA — superfície de contato (estática) // MESA FÍSICA
// =================================================================== // ===================================================================
type TableProps = { type TableProps = {
onSurfaceClick: () => void; onSurfaceClick: () => void;
@@ -157,7 +154,6 @@ type TableProps = {
const PhysicsTable = ({ onSurfaceClick, onEdgeClick }: TableProps) => { const PhysicsTable = ({ onSurfaceClick, onEdgeClick }: TableProps) => {
return ( return (
<group> <group>
{/* Tampo superior (onde o cubo pousa) */}
<RigidBody type="fixed" position={[0, 0.6, 0]}> <RigidBody type="fixed" position={[0, 0.6, 0]}>
<CuboidCollider args={[1, 0.025, 1]} /> <CuboidCollider args={[1, 0.025, 1]} />
<mesh <mesh
@@ -173,7 +169,6 @@ const PhysicsTable = ({ onSurfaceClick, onEdgeClick }: TableProps) => {
</mesh> </mesh>
</RigidBody> </RigidBody>
{/* Prateleira do meio (segunda superfície) */}
<RigidBody type="fixed" position={[0, 0.3, 0]}> <RigidBody type="fixed" position={[0, 0.3, 0]}>
<CuboidCollider args={[0.6, 0.02, 0.6]} /> <CuboidCollider args={[0.6, 0.02, 0.6]} />
<mesh <mesh
@@ -189,7 +184,6 @@ const PhysicsTable = ({ onSurfaceClick, onEdgeClick }: TableProps) => {
</mesh> </mesh>
</RigidBody> </RigidBody>
{/* 4 pernas */}
{[ {[
[-0.95, 0.3, -0.95], [-0.95, 0.3, -0.95],
[0.95, 0.3, -0.95], [0.95, 0.3, -0.95],
@@ -205,7 +199,6 @@ const PhysicsTable = ({ onSurfaceClick, onEdgeClick }: TableProps) => {
</RigidBody> </RigidBody>
))} ))}
{/* Arestas laterais (4 colunas — detectáveis pro alinhamento) */}
{[ {[
{ pos: [-0.98, 0.4, 0], rot: [0, 0, 0] }, { pos: [-0.98, 0.4, 0], rot: [0, 0, 0] },
{ pos: [0.98, 0.4, 0], rot: [0, 0, 0] }, { pos: [0.98, 0.4, 0], rot: [0, 0, 0] },
@@ -230,7 +223,7 @@ const PhysicsTable = ({ onSurfaceClick, onEdgeClick }: TableProps) => {
}; };
// =================================================================== // ===================================================================
// COMPONENTE DO CUBO COM SISTEMA 2-CLIQUES (FASE 4) // SCENE — sistema 2-cliques FASE 4
// =================================================================== // ===================================================================
type FaceId = "x+" | "x-" | "y+" | "y-" | "z+" | "z-"; type FaceId = "x+" | "x-" | "y+" | "y-" | "z+" | "z-";
type AlignmentState = { type AlignmentState = {
@@ -275,7 +268,6 @@ const PhysicsScene = ({
const onEdge = () => { const onEdge = () => {
if (alignPhase === "WAIT_EDGE") { if (alignPhase === "WAIT_EDGE") {
setAlign({ ...align, edge: "norte" }); setAlign({ ...align, edge: "norte" });
// Após os 2 cliques, reseta pra próximo round
setTimeout(() => { setTimeout(() => {
setAlignPhase("IDLE"); setAlignPhase("IDLE");
setAlign({ contactFace: null, surface: null, directionFace: null, edge: null }); setAlign({ contactFace: null, surface: null, directionFace: null, edge: null });
@@ -287,7 +279,6 @@ const PhysicsScene = ({
<> <>
<ARHudMessages alignPhase={alignPhase} /> <ARHudMessages alignPhase={alignPhase} />
<Physics gravity={[0, -9.81, 0]} colliders={false} timeStep="vary"> <Physics gravity={[0, -9.81, 0]} colliders={false} timeStep="vary">
{/* Plano do chão — gigante, invisível na prática, recebe sombras */}
<RigidBody type="fixed" position={[0, 0, 0]}> <RigidBody type="fixed" position={[0, 0, 0]}>
<CuboidCollider args={[10, 0.01, 10]} /> <CuboidCollider args={[10, 0.01, 10]} />
<mesh receiveShadow rotation={[-Math.PI / 2, 0, 0]}> <mesh receiveShadow rotation={[-Math.PI / 2, 0, 0]}>
@@ -296,7 +287,6 @@ const PhysicsScene = ({
</mesh> </mesh>
</RigidBody> </RigidBody>
{/* O cubo dinâmico */}
<PhysicsCube <PhysicsCube
position={[0, 1.5, 0]} position={[0, 1.5, 0]}
locked={locked} locked={locked}
@@ -304,7 +294,6 @@ const PhysicsScene = ({
resetSignal={resetSignal} resetSignal={resetSignal}
/> />
{/* A mesa — pega cliques pra 2-click system */}
<PhysicsTable onSurfaceClick={onSurface} onEdgeClick={onEdge} /> <PhysicsTable onSurfaceClick={onSurface} onEdgeClick={onEdge} />
</Physics> </Physics>
</> </>
@@ -312,10 +301,24 @@ const PhysicsScene = ({
}; };
// =================================================================== // ===================================================================
// SCENE WRAPPER — junta o Canvas + R3F // FUNDO DINÂMICO — NUNCA usa <color attach="background"> quando AR estiver ativo
// (issue pmndrs/xr#344: background color opacifica o passthrough)
// ===================================================================
const ARBackground = ({ isAR }: { isAR: boolean }) => {
if (isAR) return null;
return (
<>
<color attach="background" args={["#0a0a0a"]} />
<fog attach="fog" args={["#0a0a0a", 5, 18]} />
</>
);
};
// ===================================================================
// SCENE WRAPPER
// =================================================================== // ===================================================================
const SceneContent = ({ locked, resetSignal }: { locked: boolean; resetSignal: number }) => { const SceneContent = ({ locked, resetSignal }: { locked: boolean; resetSignal: number }) => {
// Detecta modo AR para ajustar iluminação e fundo
const session = useXR((s) => s.session); const session = useXR((s) => s.session);
const isAR = !!(session && (session as any).environmentBlendMode === "additive"); const isAR = !!(session && (session as any).environmentBlendMode === "additive");
@@ -331,7 +334,6 @@ const SceneContent = ({ locked, resetSignal }: { locked: boolean; resetSignal: n
shadow-mapSize={[1024, 1024]} shadow-mapSize={[1024, 1024]}
/> />
{/* Grid estilo HLA — funciona em ambos os modos */}
<Grid <Grid
position={[0, 0.001, 0]} position={[0, 0.001, 0]}
args={[20, 20]} args={[20, 20]}
@@ -364,23 +366,7 @@ const SceneContent = ({ locked, resetSignal }: { locked: boolean; resetSignal: n
}; };
// =================================================================== // ===================================================================
// FUNDO DINÂMICO — Recebe prop isAR para evitar montagem/desmontagem // GAMEPAD HANDLER
// ===================================================================
const ARBackground = ({ isAR }: { isAR: boolean }) => {
// Em AR Passthrough: nada (canal alpha fica livre pra passthrough)
if (isAR) return null;
// Fora de AR (modo desktop ou VR): fundo preto
return (
<>
<color attach="background" args={["#0a0a0a"]} />
<fog attach="fog" args={["#0a0a0a", 5, 18]} />
</>
);
};
// ===================================================================
// JOYSTICK HANDLER — gamepad pro controle fora do XR
// =================================================================== // ===================================================================
const GamepadHandler = () => { const GamepadHandler = () => {
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
@@ -408,7 +394,7 @@ const GamepadHandler = () => {
const rsx = Math.abs(pad.axes[2]) > STICK_DEADZONE ? pad.axes[2] : 0; const rsx = Math.abs(pad.axes[2]) > STICK_DEADZONE ? pad.axes[2] : 0;
const rsy = Math.abs(pad.axes[3]) > STICK_DEADZONE ? pad.axes[3] : 0; const rsy = Math.abs(pad.axes[3]) > STICK_DEADZONE ? pad.axes[3] : 0;
if (lsx || lsy || rsx || rsy) { if (lsx || lsy || rsx || rsy) {
// log pra debug — controle real via Cubo físico // log para debug
} }
} }
raf = requestAnimationFrame(tick); raf = requestAnimationFrame(tick);
@@ -429,43 +415,33 @@ export default function QuestCubeLab() {
const [autoEnter, setAutoEnter] = useState(false); const [autoEnter, setAutoEnter] = useState(false);
useEffect(() => { useEffect(() => {
if (autoEnter) { if (!autoEnter) return;
// Meta Quest 3 suporta immersive-ar (Passthrough AR). Tenta primeiro.
// Fallback pra immersive-vr se AR não disponível (óculos sem passthrough).
(async () => { (async () => {
try { try {
if ('xr' in navigator) { // ✅ FIX: usa a API do store (createXRStore.enterAR) em vez de chamar
const xr = (navigator as any).xr; // navigator.xr.requestSession cru — alinha com o resto do projeto
const supported: boolean = await xr.isSessionSupported('immersive-ar').catch(() => false); await store.enterAR();
if (supported) { } catch (err) {
const sessionInit: XRSessionInit = { console.warn("[Lab] Falha em enterAR, tentando enterVR:", err);
requiredFeatures: ['local-floor'],
optionalFeatures: ['hand-tracking', 'plane-detection', 'hit-test'],
};
await xr.requestSession('immersive-ar', sessionInit);
return;
}
}
// Fallback: usa o XR controller padrão do store
const ctrl: any = store as any;
const enterFn = ctrl.enterAR || ctrl.enterVR || ctrl.enterXR;
try { try {
await enterFn.call(ctrl, 'immersive-vr' as any); await store.enterVR();
} catch { } catch (err2) {
// ignore console.error("[Lab] Falha em enterVR também:", err2);
}
} catch {
// qualquer erro, tenta o enter padrão
try {
await Promise.resolve(store.enterXR?.());
} catch {
// ignore
} }
} }
})(); })();
}
}, [autoEnter]); }, [autoEnter]);
// ✅ FIX: cleanup ao desmontar (issue pmndrs/xr#490)
useEffect(() => {
return () => {
const session = store.getState().session;
if (session) {
try { session.end(); } catch {}
}
};
}, []);
const reset = () => { const reset = () => {
setResetSignal((n) => n + 1); setResetSignal((n) => n + 1);
}; };
@@ -525,20 +501,26 @@ export default function QuestCubeLab() {
Rapier Physics ativo Half-Life: Alyx-like Rapier Physics ativo Half-Life: Alyx-like
</div> </div>
{/* Canvas com XR — gl.alpha:true permite passthrough por trás */} {/* ✅ FIX PRINCIPAL: árvore CORRETA — Canvas por fora, XR por dentro */}
<XR store={store}>
<Canvas <Canvas
shadows shadows
gl={{ alpha: true, antialias: true, powerPreference: "high-performance" }} gl={{
camera={{ position: [0, 1.5, 3], fov: 50 }} alpha: true,
antialias: true,
powerPreference: "high-performance",
preserveDrawingBuffer: true,
}}
camera={{ position: [0, 1.5, 3], fov: 50, near: 0.01, far: 100 }}
className="!bg-transparent"
onCreated={({ gl }) => { onCreated={({ gl }) => {
gl.setClearColor(0x000000, 0); // transparente gl.setClearColor(0x000000, 0);
gl.xr.enabled = true; // gl.xr.enabled agora é controlado pelo <XR>
}} }}
> >
<XR store={store}>
<SceneContent locked={locked} resetSignal={resetSignal} /> <SceneContent locked={locked} resetSignal={resetSignal} />
</Canvas>
</XR> </XR>
</Canvas>
</div> </div>
); );
} }