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)
|
||||
* - 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) {
|
||||
// só 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).
|
||||
if (!autoEnter) return;
|
||||
(async () => {
|
||||
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;
|
||||
// ✅ 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 {
|
||||
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}>
|
||||
{/* ✅ FIX PRINCIPAL: árvore CORRETA — Canvas por fora, XR por dentro */}
|
||||
<Canvas
|
||||
shadows
|
||||
gl={{ alpha: true, antialias: true, powerPreference: "high-performance" }}
|
||||
camera={{ position: [0, 1.5, 3], fov: 50 }}
|
||||
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); // transparente
|
||||
gl.xr.enabled = true;
|
||||
gl.setClearColor(0x000000, 0);
|
||||
// gl.xr.enabled agora é controlado pelo <XR>
|
||||
}}
|
||||
>
|
||||
<XR store={store}>
|
||||
<SceneContent locked={locked} resetSignal={resetSignal} />
|
||||
</Canvas>
|
||||
</XR>
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user