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
+71 -89
View File
@@ -24,12 +24,15 @@ import { Suspense } from "react";
/**
* ============================================================
* QUEST CUBE LAB — Fase 2.5 (PHYSICS HLA-LIKE)
* - Física real com Rapier3D (gravidade, colisões, impulso)
* - Cubo é um RigidBody dinâmico (cai, bate, joga)
* - Mesa é RigidBody estático (cubo pousa em cima)
* - Pega o cubo com a mão virtual do Quest 3
* - Joga o cubo com impulso (igual Half-Life: Alyx)
* - Mantém sistema 2-cliques FASE 4 (contato + direção)
* FIX v4 — AR Passthrough funcional em Meta Quest 3
*
* Mudanças principais vs. versão anterior (3f01190):
* 1. ÁRVORE: <Canvas><XR>...</XR></Canvas> (era <XR><Canvas>)
* — <XR> chama useThree() e useFrame() e PRECISA estar dentro do Canvas
* 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
// ===================================================================
// (Implementado abaixo, depois dos imports do Rapier)
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
// Cubo com física rígida
type CubeProps = {
position: [number, number, number];
locked: boolean;
@@ -116,15 +116,12 @@ const PhysicsCube = ({ position, locked, onFaceClick, resetSignal }: CubeProps)
linearDamping={0.1}
angularDamping={0.3}
>
{/* collider gerado pra casar com a geometria do cubo */}
<CuboidCollider args={[CUBE_SIZE / 2, CUBE_SIZE / 2, CUBE_SIZE / 2]} />
{/* 6 faces coloridas — HLA-like aesthetic */}
<mesh castShadow receiveShadow onClick={faceHandlers("x+")}>
<boxGeometry args={[CUBE_SIZE, CUBE_SIZE, CUBE_SIZE]} />
<meshStandardMaterial color="#ef4444" />
</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: "#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 = {
onSurfaceClick: () => void;
@@ -157,7 +154,6 @@ type TableProps = {
const PhysicsTable = ({ onSurfaceClick, onEdgeClick }: TableProps) => {
return (
<group>
{/* Tampo superior (onde o cubo pousa) */}
<RigidBody type="fixed" position={[0, 0.6, 0]}>
<CuboidCollider args={[1, 0.025, 1]} />
<mesh
@@ -173,7 +169,6 @@ const PhysicsTable = ({ onSurfaceClick, onEdgeClick }: TableProps) => {
</mesh>
</RigidBody>
{/* Prateleira do meio (segunda superfície) */}
<RigidBody type="fixed" position={[0, 0.3, 0]}>
<CuboidCollider args={[0.6, 0.02, 0.6]} />
<mesh
@@ -189,7 +184,6 @@ const PhysicsTable = ({ onSurfaceClick, onEdgeClick }: TableProps) => {
</mesh>
</RigidBody>
{/* 4 pernas */}
{[
[-0.95, 0.3, -0.95],
[0.95, 0.3, -0.95],
@@ -205,7 +199,6 @@ const PhysicsTable = ({ onSurfaceClick, onEdgeClick }: TableProps) => {
</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] },
@@ -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 AlignmentState = {
@@ -275,7 +268,6 @@ const PhysicsScene = ({
const onEdge = () => {
if (alignPhase === "WAIT_EDGE") {
setAlign({ ...align, edge: "norte" });
// Após os 2 cliques, reseta pra próximo round
setTimeout(() => {
setAlignPhase("IDLE");
setAlign({ contactFace: null, surface: null, directionFace: null, edge: null });
@@ -287,7 +279,6 @@ const PhysicsScene = ({
<>
<ARHudMessages alignPhase={alignPhase} />
<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]}>
<CuboidCollider args={[10, 0.01, 10]} />
<mesh receiveShadow rotation={[-Math.PI / 2, 0, 0]}>
@@ -296,7 +287,6 @@ const PhysicsScene = ({
</mesh>
</RigidBody>
{/* O cubo dinâmico */}
<PhysicsCube
position={[0, 1.5, 0]}
locked={locked}
@@ -304,7 +294,6 @@ const PhysicsScene = ({
resetSignal={resetSignal}
/>
{/* A mesa — pega cliques pra 2-click system */}
<PhysicsTable onSurfaceClick={onSurface} onEdgeClick={onEdge} />
</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 }) => {
// Detecta modo AR para ajustar iluminação e fundo
const session = useXR((s) => s.session);
const isAR = !!(session && (session as any).environmentBlendMode === "additive");
@@ -331,7 +334,6 @@ const SceneContent = ({ locked, resetSignal }: { locked: boolean; resetSignal: n
shadow-mapSize={[1024, 1024]}
/>
{/* Grid estilo HLA — funciona em ambos os modos */}
<Grid
position={[0, 0.001, 0]}
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
// ===================================================================
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
// GAMEPAD HANDLER
// ===================================================================
const GamepadHandler = () => {
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 rsy = Math.abs(pad.axes[3]) > STICK_DEADZONE ? pad.axes[3] : 0;
if (lsx || lsy || rsx || rsy) {
// log pra debug — controle real via Cubo físico
// log para debug
}
}
raf = requestAnimationFrame(tick);
@@ -429,43 +415,33 @@ export default function QuestCubeLab() {
const [autoEnter, setAutoEnter] = useState(false);
useEffect(() => {
if (autoEnter) {
// Meta Quest 3 suporta immersive-ar (Passthrough AR). Tenta primeiro.
// Fallback pra immersive-vr se AR não disponível (óculos sem passthrough).
(async () => {
if (!autoEnter) return;
(async () => {
try {
// ✅ FIX: usa a API do store (createXRStore.enterAR) em vez de chamar
// navigator.xr.requestSession cru — alinha com o resto do projeto
await store.enterAR();
} catch (err) {
console.warn("[Lab] Falha em enterAR, tentando enterVR:", err);
try {
if ('xr' in navigator) {
const xr = (navigator as any).xr;
const supported: boolean = await xr.isSessionSupported('immersive-ar').catch(() => false);
if (supported) {
const sessionInit: XRSessionInit = {
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 {
await enterFn.call(ctrl, 'immersive-vr' as any);
} catch {
// ignore
}
} catch {
// qualquer erro, tenta o enter padrão
try {
await Promise.resolve(store.enterXR?.());
} catch {
// ignore
}
await store.enterVR();
} catch (err2) {
console.error("[Lab] Falha em enterVR também:", err2);
}
})();
}
}
})();
}, [autoEnter]);
// ✅ FIX: cleanup ao desmontar (issue pmndrs/xr#490)
useEffect(() => {
return () => {
const session = store.getState().session;
if (session) {
try { session.end(); } catch {}
}
};
}, []);
const reset = () => {
setResetSignal((n) => n + 1);
};
@@ -525,20 +501,26 @@ export default function QuestCubeLab() {
Rapier Physics ativo Half-Life: Alyx-like
</div>
{/* Canvas com XR — gl.alpha:true permite passthrough por trás */}
<XR store={store}>
<Canvas
shadows
gl={{ alpha: true, antialias: true, powerPreference: "high-performance" }}
camera={{ position: [0, 1.5, 3], fov: 50 }}
onCreated={({ gl }) => {
gl.setClearColor(0x000000, 0); // transparente
gl.xr.enabled = true;
}}
>
{/* ✅ FIX PRINCIPAL: árvore CORRETA — Canvas por fora, XR por dentro */}
<Canvas
shadows
gl={{
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 }) => {
gl.setClearColor(0x000000, 0);
// gl.xr.enabled agora é controlado pelo <XR>
}}
>
<XR store={store}>
<SceneContent locked={locked} resetSignal={resetSignal} />
</Canvas>
</XR>
</XR>
</Canvas>
</div>
);
}