From 32946b64f7416b142086b112a491760954271a8b Mon Sep 17 00:00:00 2001 From: Hermes Date: Wed, 5 Aug 2026 22:19:16 +0000 Subject: [PATCH] Fix Meta Quest 3 VR locomotion and physics drift --- src/App.tsx | 2 +- src/components/InteractiveCube.tsx | 236 ++++++++++++--------------- src/components/QuestXRLocomotion.tsx | 49 ++++-- src/components/SpatialHUD.tsx | 42 +---- src/components/XRScene.tsx | 129 +++++++-------- 5 files changed, 203 insertions(+), 255 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 4de025b..d4de623 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -112,7 +112,7 @@ export default function App() { style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }} > - + diff --git a/src/components/InteractiveCube.tsx b/src/components/InteractiveCube.tsx index 2023180..99e4380 100644 --- a/src/components/InteractiveCube.tsx +++ b/src/components/InteractiveCube.tsx @@ -24,149 +24,123 @@ export function InteractiveCube({ locked, onFaceSelect, resetSignal, - tableY = 0.625, // Height of table top + tableY = 0.625, tableBounds = { minX: -1, maxX: 1, minZ: -1.5, maxZ: -0.5 }, }: InteractiveCubeProps) { const meshRef = useRef(null); const session = useXR((s) => s.session); - // Cube state & transform + // Physics state const pos = useRef(new THREE.Vector3(...initialPosition)); const vel = useRef(new THREE.Vector3(0, 0, 0)); - const rot = useRef(new THREE.Euler(0, 0, 0)); + const rot = useRef(new THREE.Quaternion().identity()); const angVel = useRef(new THREE.Vector3(0, 0, 0)); const scale = useRef(1); - // Interaction tracking + // Hover & selection const [hovered, setHovered] = useState(false); const [hoveredFace, setHoveredFace] = useState(null); const [selectedFace, setSelectedFace] = useState(null); - // Grab state + // Grab state - using ray to prevent feedback loop drift const isGrabbed = useRef(false); - const grabControllerId = useRef(null); - const prevControllerPos = useRef(new THREE.Vector3()); - const grabOffsetPos = useRef(new THREE.Vector3()); + const grabPointerId = useRef(null); + const grabDistance = useRef(0); - // Two-hand scale/rotate state - const secondGrabControllerId = useRef(null); - const initialHandDist = useRef(1); - const initialCubeScale = useRef(1); + const previousRayPos = useRef(new THREE.Vector3()); - // Reset physics signal + // Reset signal listener const lastResetSignal = useRef(resetSignal); if (resetSignal !== lastResetSignal.current) { lastResetSignal.current = resetSignal; pos.current.set(...initialPosition); vel.current.set(0, 0, 0); angVel.current.set(0, 0, 0); - rot.current.set(0, 0, 0); + rot.current.identity(); scale.current = 1; + isGrabbed.current = false; setSelectedFace(null); - if (meshRef.current) { - meshRef.current.position.copy(pos.current); - meshRef.current.rotation.copy(rot.current); - meshRef.current.scale.setScalar(1); - } } - // Handle pointer hover / grab events (works natively with XR rays and hands) - const handlePointerDown = (e: ThreeEvent) => { - if (locked) return; + const onPointerDown = (e: ThreeEvent) => { + if (locked || isGrabbed.current) return; e.stopPropagation(); + + // Capture pointer to track dragging outside object bounds + (e.target as HTMLElement).setPointerCapture?.(e.pointerId); + + isGrabbed.current = true; + grabPointerId.current = e.pointerId; + + // Save the distance from the controller ray origin to the intersection point + grabDistance.current = e.distance; + + // Track velocity + previousRayPos.current.copy(e.point); + vel.current.set(0, 0, 0); + angVel.current.set(0, 0, 0); soundFx.playGrab(); triggerHaptic(session, 'any', 0.8, 60); - - const controller = (e as any).pointerId ?? 'primary'; - - if (!isGrabbed.current) { - isGrabbed.current = true; - grabControllerId.current = controller; - - if (e.point) { - grabOffsetPos.current.copy(pos.current).sub(e.point); - } else { - grabOffsetPos.current.set(0, 0, 0); - } - prevControllerPos.current.copy(e.point || pos.current); - vel.current.set(0, 0, 0); - angVel.current.set(0, 0, 0); - } else if (!secondGrabControllerId.current && controller !== grabControllerId.current) { - // Second hand grab -> Pinch scale / rotate mode - secondGrabControllerId.current = controller; - initialCubeScale.current = scale.current; - if (e.point) { - initialHandDist.current = prevControllerPos.current.distanceTo(e.point) || 0.3; - } - } }; - const handlePointerUp = (e: ThreeEvent) => { - e.stopPropagation(); - const controller = (e as any).pointerId ?? 'primary'; - - if (secondGrabControllerId.current === controller) { - secondGrabControllerId.current = null; - } else if (grabControllerId.current === controller) { - if (secondGrabControllerId.current) { - grabControllerId.current = secondGrabControllerId.current; - secondGrabControllerId.current = null; - } else { - isGrabbed.current = false; - grabControllerId.current = null; - - soundFx.playRelease(); - triggerHaptic(session, 'any', 0.4, 40); - - // Apply throw inertia boost - vel.current.multiplyScalar(1.2); - angVel.current.set( - (Math.random() - 0.5) * 4, - (Math.random() - 0.5) * 4, - (Math.random() - 0.5) * 4 - ); - } - } - }; - - const handleFaceClick = (face: FaceId, e: ThreeEvent) => { - e.stopPropagation(); + const onPointerMove = (e: ThreeEvent) => { if (locked) return; - setSelectedFace(face); - soundFx.playSnap(); - triggerHaptic(session, 'any', 0.6, 50); - if (onFaceSelect) onFaceSelect(face); + if (isGrabbed.current && e.pointerId === grabPointerId.current) { + e.stopPropagation(); + + // FIX: Use e.ray to calculate new position instead of e.point to avoid feedback drift + const ray = e.ray; + const targetPos = ray.origin.clone().add(ray.direction.clone().multiplyScalar(grabDistance.current)); + + // Calculate velocity for throwing + const dt = 0.016; // approximate frame time for velocity calc + vel.current.copy(targetPos).sub(pos.current).divideScalar(dt); + + pos.current.copy(targetPos); + } + }; + + const onPointerUp = (e: ThreeEvent) => { + if (isGrabbed.current && e.pointerId === grabPointerId.current) { + e.stopPropagation(); + (e.target as HTMLElement).releasePointerCapture?.(e.pointerId); + + isGrabbed.current = false; + grabPointerId.current = null; + + // Throw physics applied automatically via vel.current calculated in onPointerMove + soundFx.playRelease(); + triggerHaptic(session, 'any', 0.4, 40); + } }; - // Main frame update for Physics & Grabbing useFrame((_, delta) => { if (!meshRef.current) return; + const dt = Math.min(delta, 0.05); - const clampedDelta = Math.min(delta, 0.05); - - if (isGrabbed.current) { - // Calculate controller movement velocity - // Mesh is updated via event points or target tracking - } else if (!locked) { - // PHYSICS SIMULATION + if (!isGrabbed.current && !locked) { + // Physics Simulation const gravity = -9.81; const currentScale = scale.current * size; const halfSize = currentScale / 2; - // Apply Gravity & Damping - vel.current.y += gravity * clampedDelta; - vel.current.x *= 1 - 0.5 * clampedDelta; - vel.current.z *= 1 - 0.5 * clampedDelta; - angVel.current.multiplyScalar(1 - 1.2 * clampedDelta); + vel.current.y += gravity * dt; + + // Air resistance + vel.current.x *= 1 - 0.5 * dt; + vel.current.z *= 1 - 0.5 * dt; + angVel.current.multiplyScalar(1 - 1.2 * dt); - // Integrate Position & Rotation - pos.current.addScaledVector(vel.current, clampedDelta); - rot.current.x += angVel.current.x * clampedDelta; - rot.current.y += angVel.current.y * clampedDelta; - rot.current.z += angVel.current.z * clampedDelta; + // Integrate Position + pos.current.addScaledVector(vel.current, dt); + + // Integrate Rotation via quaternions + const w = angVel.current.clone().multiplyScalar(dt); + const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(w.x, w.y, w.z)); + rot.current.multiply(q); - // Table Collision Check + // Collision detection (Table and Floor) const isOnTable = pos.current.x >= tableBounds.minX && pos.current.x <= tableBounds.maxX && @@ -178,25 +152,24 @@ export function InteractiveCube({ if (pos.current.y <= surfaceY) { pos.current.y = surfaceY; - // Bounce / Restitution if (Math.abs(vel.current.y) > 0.4) { soundFx.playImpact(Math.abs(vel.current.y)); triggerHaptic(session, 'any', Math.min(Math.abs(vel.current.y) * 0.2, 0.8), 30); - vel.current.y = -vel.current.y * 0.35; // 35% restitution + vel.current.y = -vel.current.y * 0.35; // bounce } else { vel.current.y = 0; } - // Friction on surface + // Friction vel.current.x *= 0.82; vel.current.z *= 0.82; angVel.current.multiplyScalar(0.75); } } - // Apply transform to 3D object + // Apply transform meshRef.current.position.copy(pos.current); - meshRef.current.rotation.copy(rot.current); + meshRef.current.setRotationFromQuaternion(rot.current); meshRef.current.scale.setScalar(scale.current); }); @@ -214,49 +187,53 @@ export function InteractiveCube({ ref={meshRef} onPointerOver={(e) => { e.stopPropagation(); - setHovered(true); - soundFx.playHover(); - triggerHaptic(session, 'any', 0.2, 20); - }} - onPointerOut={() => { - setHovered(false); - setHoveredFace(null); - }} - onPointerDown={handlePointerDown} - onPointerUp={handlePointerUp} - onPointerMove={(e) => { - if (isGrabbed.current && e.point) { - const newPos = e.point.clone().add(grabOffsetPos.current); - const dt = 0.016; - vel.current.copy(newPos).sub(pos.current).divideScalar(dt); - pos.current.copy(newPos); + if (!isGrabbed.current) { + setHovered(true); + soundFx.playHover(); + triggerHaptic(session, 'any', 0.2, 20); } }} + onPointerOut={() => { + if (!isGrabbed.current) { + setHovered(false); + setHoveredFace(null); + } + }} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerCancel={onPointerUp} + onPointerMissed={onPointerUp} > - {/* Base Cube Body */} - {/* Faces with colors, labels and snapping feedback */} {faces.map((f) => { const isSelected = selectedFace === f.id; - const isFaceHovered = hoveredFace === f.id; + const isFaceHovered = hoveredFace === f.id && !isGrabbed.current; return ( { e.stopPropagation(); - setHoveredFace(f.id); + if (!isGrabbed.current) setHoveredFace(f.id); + }} + onClick={(e) => { + e.stopPropagation(); + if (locked || isGrabbed.current) return; + setSelectedFace(f.id); + soundFx.playSnap(); + triggerHaptic(session, 'any', 0.6, 50); + if (onFaceSelect) onFaceSelect(f.id); }} - onClick={(e) => handleFaceClick(f.id, e)} > - {/* Face Label Text */} ); })} - - {/* Selection Glow Box Indicator */} - {selectedFace && ( - - - - - )} ); } diff --git a/src/components/QuestXRLocomotion.tsx b/src/components/QuestXRLocomotion.tsx index 2a15587..e1490e3 100644 --- a/src/components/QuestXRLocomotion.tsx +++ b/src/components/QuestXRLocomotion.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { useRef, type MutableRefObject } from 'react'; import { useFrame, useThree } from '@react-three/fiber'; import { useXR } from '@react-three/xr'; import * as THREE from 'three'; @@ -21,7 +21,11 @@ export function triggerHaptic(session: any, hand: 'left' | 'right' | 'any' = 'an } catch {} } -export function QuestXRLocomotion() { +interface LocomotionProps { + worldRef: MutableRefObject; +} + +export function QuestXRLocomotion({ worldRef }: LocomotionProps) { const { camera } = useThree(); const session = useXR((s) => s.session); const snapCooldownRef = useRef(0); @@ -31,9 +35,8 @@ export function QuestXRLocomotion() { const sideVector = useRef(new THREE.Vector3()); useFrame((_, delta) => { - if (!session) return; + if (!session || !worldRef.current) return; - // Get input sources from WebXR session const inputSources = session.inputSources; if (!inputSources) return; @@ -41,39 +44,49 @@ export function QuestXRLocomotion() { for (const source of inputSources as any) { const gamepad = source.gamepad; - if (!gamepad || !gamepad.axes || gamepad.axes.length < 2) continue; + if (!gamepad || !gamepad.axes) continue; const handedness = source.handedness; - // Axis 0 = Thumbstick X, Axis 1 = Thumbstick Y (Standard WebXR Gamepad mapping) - const axisX = Math.abs(gamepad.axes[2] ?? gamepad.axes[0]) > STICK_DEADZONE ? (gamepad.axes[2] ?? gamepad.axes[0]) : 0; - const axisY = Math.abs(gamepad.axes[3] ?? gamepad.axes[1]) > STICK_DEADZONE ? (gamepad.axes[3] ?? gamepad.axes[1]) : 0; + // Quest 3 axes mapping: + // axes[0], axes[1] or axes[2], axes[3] depending on the browser version + const axisX = Math.abs(gamepad.axes[2]) > STICK_DEADZONE ? gamepad.axes[2] + : Math.abs(gamepad.axes[0]) > STICK_DEADZONE ? gamepad.axes[0] : 0; + + const axisY = Math.abs(gamepad.axes[3]) > STICK_DEADZONE ? gamepad.axes[3] + : Math.abs(gamepad.axes[1]) > STICK_DEADZONE ? gamepad.axes[1] : 0; if (handedness === 'left') { // Left Controller -> Smooth Locomotion if (axisX !== 0 || axisY !== 0) { - // Get camera yaw angle + // Calculate movement relative to headset orientation camera.getWorldDirection(forwardVector.current); forwardVector.current.y = 0; forwardVector.current.normalize(); - + sideVector.current.crossVectors(camera.up, forwardVector.current).normalize(); moveVector.current.set(0, 0, 0); - moveVector.current.addScaledVector(forwardVector.current, -axisY * MOVE_SPEED * delta); - moveVector.current.addScaledVector(sideVector.current, -axisX * MOVE_SPEED * delta); + + // Move the WORLD in the OPPOSITE direction of the joystick + moveVector.current.addScaledVector(forwardVector.current, axisY * MOVE_SPEED * delta); + moveVector.current.addScaledVector(sideVector.current, axisX * MOVE_SPEED * delta); - camera.position.add(moveVector.current); + worldRef.current.position.add(moveVector.current); } } else if (handedness === 'right') { // Right Controller -> Snap Turning if (now > snapCooldownRef.current && Math.abs(axisX) > 0.6) { - const turnDirection = axisX > 0 ? -1 : 1; // Right stick right = turn right + const turnDirection = axisX > 0 ? -1 : 1; - // Rotate camera around Y axis at current camera position - const rotationMatrix = new THREE.Matrix4().makeRotationY(turnDirection * SNAP_TURN_ANGLE); - camera.position.applyMatrix4(rotationMatrix); - camera.rotation.y += turnDirection * SNAP_TURN_ANGLE; + // To turn the player right, we rotate the world LEFT around the player's current position + const playerPos = camera.position.clone(); + + worldRef.current.position.sub(playerPos); + worldRef.current.position.applyAxisAngle(new THREE.Vector3(0, 1, 0), turnDirection * SNAP_TURN_ANGLE); + worldRef.current.position.add(playerPos); + + worldRef.current.rotation.y += turnDirection * SNAP_TURN_ANGLE; triggerHaptic(session, 'right', 0.4, 40); snapCooldownRef.current = now + SNAP_COOLDOWN_MS; diff --git a/src/components/SpatialHUD.tsx b/src/components/SpatialHUD.tsx index 71f86a9..59a35bc 100644 --- a/src/components/SpatialHUD.tsx +++ b/src/components/SpatialHUD.tsx @@ -6,8 +6,6 @@ export interface SpatialHUDProps { locked: boolean; onToggleLock: () => void; onReset: () => void; - onEnterVR: () => void; - onEnterAR: () => void; alignmentStep: string; isAR: boolean; } @@ -17,14 +15,11 @@ export function SpatialHUD({ locked, onToggleLock, onReset, - onEnterVR, - onEnterAR, alignmentStep, isAR, }: SpatialHUDProps) { return ( - {/* HUD Header Title */} - {/* Alignment Instructions Message */} {alignmentStep && ( @@ -56,7 +50,6 @@ export function SpatialHUD({ )} - {/* Main Spatial Control Card Panel */} - {/* Action Buttons Row */} - {/* 1. Lock/Unlock Physics Button */} { e.stopPropagation(); soundFx.playHover(); @@ -79,7 +70,7 @@ export function SpatialHUD({ }} > - + @@ -87,9 +78,8 @@ export function SpatialHUD({ - {/* 2. Reset Position Button */} { e.stopPropagation(); soundFx.playSnap(); @@ -97,34 +87,14 @@ export function SpatialHUD({ }} > - + - 🔄 RESETAR CUBO + 🔄 RESETAR MUNDO - {/* 3. AR / VR Switcher Button */} - { - e.stopPropagation(); - soundFx.playHover(); - if (isAR) onEnterVR(); - else onEnterAR(); - }} - > - - - - - - {isAR ? '🥽 MODO VR (3D)' : '📷 PASSTHROUGH AR'} - - - - {/* Status Footer info */} - Controles: Gatilho/Grip (Agarrar/Arremessar) • Joystick Esq (Mover) • Joystick Dir (Girar 45°) + {isAR ? 'MODO: PASSTHROUGH AR ATIVO' : 'MODO: VR IMERSIVO'} • Joysticks Movem e Giram ); diff --git a/src/components/XRScene.tsx b/src/components/XRScene.tsx index 5ed95fc..336e153 100644 --- a/src/components/XRScene.tsx +++ b/src/components/XRScene.tsx @@ -1,23 +1,21 @@ -import { useState, Suspense } from 'react'; +import { useState, Suspense, useRef } from 'react'; import { useXR } from '@react-three/xr'; import { Environment, Grid } from '@react-three/drei'; +import * as THREE from 'three'; import { WorkbenchTable } from './WorkbenchTable'; import { InteractiveCube, type FaceId } from './InteractiveCube'; import { SpatialHUD } from './SpatialHUD'; import { QuestXRLocomotion } from './QuestXRLocomotion'; -export interface XRSceneProps { - onEnterVR: () => void; - onEnterAR: () => void; -} - -export function XRScene({ onEnterVR, onEnterAR }: XRSceneProps) { +export function XRScene() { const session = useXR((s) => s.session); const isAR = !!(session && (session as any).environmentBlendMode === 'additive'); const [locked, setLocked] = useState(false); const [resetSignal, setResetSignal] = useState(0); + const worldRef = useRef(null); + // Alignment tracking const [selectedCubeFace, setSelectedCubeFace] = useState(null); const [alignmentStep, setAlignmentStep] = useState( @@ -41,10 +39,8 @@ export function XRScene({ onEnterVR, onEnterAR }: XRSceneProps) { return ( <> - {/* VR Joystick Locomotion & Snap Turn */} - + - {/* Dynamic Background Fog & Color for VR Mode */} {!isAR && ( <> @@ -52,67 +48,68 @@ export function XRScene({ onEnterVR, onEnterAR }: XRSceneProps) { )} - {/* Lighting Setup */} - - - - + {/* World Group that moves inversely to simulate player movement */} + + + + + - {/* Grid Floor */} - + - {/* Environment Reflections */} - - - + + + - {/* Workbench Table */} - setAlignmentStep(`Aresta selecionada: ${edge}`)} - /> + setAlignmentStep(`Aresta selecionada: ${edge}`)} + /> - {/* Interactive Physics Cube */} - + - {/* Floating Spatial HUD */} - setLocked((l) => !l)} - onReset={() => setResetSignal((s) => s + 1)} - onEnterVR={onEnterVR} - onEnterAR={onEnterAR} - alignmentStep={alignmentStep} - isAR={isAR} - /> + setLocked((l) => !l)} + onReset={() => { + setResetSignal((s) => s + 1); + if (worldRef.current) { + worldRef.current.position.set(0, 0, 0); + worldRef.current.rotation.set(0, 0, 0); + } + }} + alignmentStep={alignmentStep} + isAR={isAR} + /> + ); }