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:
+61
-79
@@ -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) {
|
||||||
// só 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user