Fix Meta Quest 3 VR locomotion and physics drift
This commit is contained in:
+1
-1
@@ -112,7 +112,7 @@ export default function App() {
|
|||||||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }}
|
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }}
|
||||||
>
|
>
|
||||||
<XR store={store}>
|
<XR store={store}>
|
||||||
<XRScene onEnterVR={handleEnterVR} onEnterAR={handleEnterAR} />
|
<XRScene />
|
||||||
</XR>
|
</XR>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+102
-134
@@ -24,149 +24,123 @@ export function InteractiveCube({
|
|||||||
locked,
|
locked,
|
||||||
onFaceSelect,
|
onFaceSelect,
|
||||||
resetSignal,
|
resetSignal,
|
||||||
tableY = 0.625, // Height of table top
|
tableY = 0.625,
|
||||||
tableBounds = { minX: -1, maxX: 1, minZ: -1.5, maxZ: -0.5 },
|
tableBounds = { minX: -1, maxX: 1, minZ: -1.5, maxZ: -0.5 },
|
||||||
}: InteractiveCubeProps) {
|
}: InteractiveCubeProps) {
|
||||||
const meshRef = useRef<THREE.Group>(null);
|
const meshRef = useRef<THREE.Group>(null);
|
||||||
const session = useXR((s) => s.session);
|
const session = useXR((s) => s.session);
|
||||||
|
|
||||||
// Cube state & transform
|
// Physics state
|
||||||
const pos = useRef(new THREE.Vector3(...initialPosition));
|
const pos = useRef(new THREE.Vector3(...initialPosition));
|
||||||
const vel = useRef(new THREE.Vector3(0, 0, 0));
|
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 angVel = useRef(new THREE.Vector3(0, 0, 0));
|
||||||
const scale = useRef(1);
|
const scale = useRef(1);
|
||||||
|
|
||||||
// Interaction tracking
|
// Hover & selection
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const [hoveredFace, setHoveredFace] = useState<FaceId | null>(null);
|
const [hoveredFace, setHoveredFace] = useState<FaceId | null>(null);
|
||||||
const [selectedFace, setSelectedFace] = useState<FaceId | null>(null);
|
const [selectedFace, setSelectedFace] = useState<FaceId | null>(null);
|
||||||
|
|
||||||
// Grab state
|
// Grab state - using ray to prevent feedback loop drift
|
||||||
const isGrabbed = useRef(false);
|
const isGrabbed = useRef(false);
|
||||||
const grabControllerId = useRef<any>(null);
|
const grabPointerId = useRef<number | null>(null);
|
||||||
const prevControllerPos = useRef(new THREE.Vector3());
|
const grabDistance = useRef<number>(0);
|
||||||
const grabOffsetPos = useRef(new THREE.Vector3());
|
|
||||||
|
|
||||||
// Two-hand scale/rotate state
|
const previousRayPos = useRef(new THREE.Vector3());
|
||||||
const secondGrabControllerId = useRef<any>(null);
|
|
||||||
const initialHandDist = useRef<number>(1);
|
|
||||||
const initialCubeScale = useRef<number>(1);
|
|
||||||
|
|
||||||
// Reset physics signal
|
// Reset signal listener
|
||||||
const lastResetSignal = useRef(resetSignal);
|
const lastResetSignal = useRef(resetSignal);
|
||||||
if (resetSignal !== lastResetSignal.current) {
|
if (resetSignal !== lastResetSignal.current) {
|
||||||
lastResetSignal.current = resetSignal;
|
lastResetSignal.current = resetSignal;
|
||||||
pos.current.set(...initialPosition);
|
pos.current.set(...initialPosition);
|
||||||
vel.current.set(0, 0, 0);
|
vel.current.set(0, 0, 0);
|
||||||
angVel.current.set(0, 0, 0);
|
angVel.current.set(0, 0, 0);
|
||||||
rot.current.set(0, 0, 0);
|
rot.current.identity();
|
||||||
scale.current = 1;
|
scale.current = 1;
|
||||||
|
isGrabbed.current = false;
|
||||||
setSelectedFace(null);
|
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 onPointerDown = (e: ThreeEvent<PointerEvent>) => {
|
||||||
const handlePointerDown = (e: ThreeEvent<PointerEvent>) => {
|
if (locked || isGrabbed.current) return;
|
||||||
if (locked) return;
|
|
||||||
e.stopPropagation();
|
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();
|
soundFx.playGrab();
|
||||||
triggerHaptic(session, 'any', 0.8, 60);
|
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<PointerEvent>) => {
|
const onPointerMove = (e: ThreeEvent<PointerEvent>) => {
|
||||||
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<MouseEvent>) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (locked) return;
|
if (locked) return;
|
||||||
setSelectedFace(face);
|
if (isGrabbed.current && e.pointerId === grabPointerId.current) {
|
||||||
soundFx.playSnap();
|
e.stopPropagation();
|
||||||
triggerHaptic(session, 'any', 0.6, 50);
|
|
||||||
if (onFaceSelect) onFaceSelect(face);
|
// 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<PointerEvent>) => {
|
||||||
|
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) => {
|
useFrame((_, delta) => {
|
||||||
if (!meshRef.current) return;
|
if (!meshRef.current) return;
|
||||||
|
const dt = Math.min(delta, 0.05);
|
||||||
|
|
||||||
const clampedDelta = Math.min(delta, 0.05);
|
if (!isGrabbed.current && !locked) {
|
||||||
|
// Physics Simulation
|
||||||
if (isGrabbed.current) {
|
|
||||||
// Calculate controller movement velocity
|
|
||||||
// Mesh is updated via event points or target tracking
|
|
||||||
} else if (!locked) {
|
|
||||||
// PHYSICS SIMULATION
|
|
||||||
const gravity = -9.81;
|
const gravity = -9.81;
|
||||||
const currentScale = scale.current * size;
|
const currentScale = scale.current * size;
|
||||||
const halfSize = currentScale / 2;
|
const halfSize = currentScale / 2;
|
||||||
|
|
||||||
// Apply Gravity & Damping
|
vel.current.y += gravity * dt;
|
||||||
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);
|
|
||||||
|
|
||||||
// Integrate Position & Rotation
|
// Air resistance
|
||||||
pos.current.addScaledVector(vel.current, clampedDelta);
|
vel.current.x *= 1 - 0.5 * dt;
|
||||||
rot.current.x += angVel.current.x * clampedDelta;
|
vel.current.z *= 1 - 0.5 * dt;
|
||||||
rot.current.y += angVel.current.y * clampedDelta;
|
angVel.current.multiplyScalar(1 - 1.2 * dt);
|
||||||
rot.current.z += angVel.current.z * clampedDelta;
|
|
||||||
|
|
||||||
// Table Collision Check
|
// 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);
|
||||||
|
|
||||||
|
// Collision detection (Table and Floor)
|
||||||
const isOnTable =
|
const isOnTable =
|
||||||
pos.current.x >= tableBounds.minX &&
|
pos.current.x >= tableBounds.minX &&
|
||||||
pos.current.x <= tableBounds.maxX &&
|
pos.current.x <= tableBounds.maxX &&
|
||||||
@@ -178,25 +152,24 @@ export function InteractiveCube({
|
|||||||
if (pos.current.y <= surfaceY) {
|
if (pos.current.y <= surfaceY) {
|
||||||
pos.current.y = surfaceY;
|
pos.current.y = surfaceY;
|
||||||
|
|
||||||
// Bounce / Restitution
|
|
||||||
if (Math.abs(vel.current.y) > 0.4) {
|
if (Math.abs(vel.current.y) > 0.4) {
|
||||||
soundFx.playImpact(Math.abs(vel.current.y));
|
soundFx.playImpact(Math.abs(vel.current.y));
|
||||||
triggerHaptic(session, 'any', Math.min(Math.abs(vel.current.y) * 0.2, 0.8), 30);
|
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 {
|
} else {
|
||||||
vel.current.y = 0;
|
vel.current.y = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Friction on surface
|
// Friction
|
||||||
vel.current.x *= 0.82;
|
vel.current.x *= 0.82;
|
||||||
vel.current.z *= 0.82;
|
vel.current.z *= 0.82;
|
||||||
angVel.current.multiplyScalar(0.75);
|
angVel.current.multiplyScalar(0.75);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply transform to 3D object
|
// Apply transform
|
||||||
meshRef.current.position.copy(pos.current);
|
meshRef.current.position.copy(pos.current);
|
||||||
meshRef.current.rotation.copy(rot.current);
|
meshRef.current.setRotationFromQuaternion(rot.current);
|
||||||
meshRef.current.scale.setScalar(scale.current);
|
meshRef.current.scale.setScalar(scale.current);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,49 +187,53 @@ export function InteractiveCube({
|
|||||||
ref={meshRef}
|
ref={meshRef}
|
||||||
onPointerOver={(e) => {
|
onPointerOver={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setHovered(true);
|
if (!isGrabbed.current) {
|
||||||
soundFx.playHover();
|
setHovered(true);
|
||||||
triggerHaptic(session, 'any', 0.2, 20);
|
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);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onPointerOut={() => {
|
||||||
|
if (!isGrabbed.current) {
|
||||||
|
setHovered(false);
|
||||||
|
setHoveredFace(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerCancel={onPointerUp}
|
||||||
|
onPointerMissed={onPointerUp}
|
||||||
>
|
>
|
||||||
{/* Base Cube Body */}
|
|
||||||
<mesh castShadow receiveShadow>
|
<mesh castShadow receiveShadow>
|
||||||
<boxGeometry args={[size, size, size]} />
|
<boxGeometry args={[size, size, size]} />
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
color={hovered ? '#fbbf24' : '#334155'}
|
color={hovered || isGrabbed.current ? '#fbbf24' : '#334155'}
|
||||||
metalness={0.4}
|
metalness={0.4}
|
||||||
roughness={0.3}
|
roughness={0.3}
|
||||||
wireframe={locked}
|
wireframe={locked}
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{/* Faces with colors, labels and snapping feedback */}
|
|
||||||
{faces.map((f) => {
|
{faces.map((f) => {
|
||||||
const isSelected = selectedFace === f.id;
|
const isSelected = selectedFace === f.id;
|
||||||
const isFaceHovered = hoveredFace === f.id;
|
const isFaceHovered = hoveredFace === f.id && !isGrabbed.current;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group key={f.id} position={f.pos} rotation={f.rot}>
|
<group key={f.id} position={f.pos} rotation={f.rot}>
|
||||||
<mesh
|
<mesh
|
||||||
onPointerOver={(e) => {
|
onPointerOver={(e) => {
|
||||||
e.stopPropagation();
|
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)}
|
|
||||||
>
|
>
|
||||||
<planeGeometry args={[size * 0.94, size * 0.94]} />
|
<planeGeometry args={[size * 0.94, size * 0.94]} />
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
@@ -268,7 +245,6 @@ export function InteractiveCube({
|
|||||||
opacity={0.9}
|
opacity={0.9}
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* Face Label Text */}
|
|
||||||
<Text
|
<Text
|
||||||
position={[0, 0, 0.002]}
|
position={[0, 0, 0.002]}
|
||||||
fontSize={size * 0.28}
|
fontSize={size * 0.28}
|
||||||
@@ -281,14 +257,6 @@ export function InteractiveCube({
|
|||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Selection Glow Box Indicator */}
|
|
||||||
{selectedFace && (
|
|
||||||
<mesh>
|
|
||||||
<boxGeometry args={[size * 1.05, size * 1.05, size * 1.05]} />
|
|
||||||
<meshBasicMaterial color="#38bdf8" wireframe transparent opacity={0.6} />
|
|
||||||
</mesh>
|
|
||||||
)}
|
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef, type MutableRefObject } from 'react';
|
||||||
import { useFrame, useThree } from '@react-three/fiber';
|
import { useFrame, useThree } from '@react-three/fiber';
|
||||||
import { useXR } from '@react-three/xr';
|
import { useXR } from '@react-three/xr';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
@@ -21,7 +21,11 @@ export function triggerHaptic(session: any, hand: 'left' | 'right' | 'any' = 'an
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuestXRLocomotion() {
|
interface LocomotionProps {
|
||||||
|
worldRef: MutableRefObject<THREE.Group | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuestXRLocomotion({ worldRef }: LocomotionProps) {
|
||||||
const { camera } = useThree();
|
const { camera } = useThree();
|
||||||
const session = useXR((s) => s.session);
|
const session = useXR((s) => s.session);
|
||||||
const snapCooldownRef = useRef(0);
|
const snapCooldownRef = useRef(0);
|
||||||
@@ -31,9 +35,8 @@ export function QuestXRLocomotion() {
|
|||||||
const sideVector = useRef(new THREE.Vector3());
|
const sideVector = useRef(new THREE.Vector3());
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
if (!session) return;
|
if (!session || !worldRef.current) return;
|
||||||
|
|
||||||
// Get input sources from WebXR session
|
|
||||||
const inputSources = session.inputSources;
|
const inputSources = session.inputSources;
|
||||||
if (!inputSources) return;
|
if (!inputSources) return;
|
||||||
|
|
||||||
@@ -41,18 +44,22 @@ export function QuestXRLocomotion() {
|
|||||||
|
|
||||||
for (const source of inputSources as any) {
|
for (const source of inputSources as any) {
|
||||||
const gamepad = source.gamepad;
|
const gamepad = source.gamepad;
|
||||||
if (!gamepad || !gamepad.axes || gamepad.axes.length < 2) continue;
|
if (!gamepad || !gamepad.axes) continue;
|
||||||
|
|
||||||
const handedness = source.handedness;
|
const handedness = source.handedness;
|
||||||
|
|
||||||
// Axis 0 = Thumbstick X, Axis 1 = Thumbstick Y (Standard WebXR Gamepad mapping)
|
// Quest 3 axes mapping:
|
||||||
const axisX = Math.abs(gamepad.axes[2] ?? gamepad.axes[0]) > STICK_DEADZONE ? (gamepad.axes[2] ?? gamepad.axes[0]) : 0;
|
// axes[0], axes[1] or axes[2], axes[3] depending on the browser version
|
||||||
const axisY = Math.abs(gamepad.axes[3] ?? gamepad.axes[1]) > STICK_DEADZONE ? (gamepad.axes[3] ?? gamepad.axes[1]) : 0;
|
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') {
|
if (handedness === 'left') {
|
||||||
// Left Controller -> Smooth Locomotion
|
// Left Controller -> Smooth Locomotion
|
||||||
if (axisX !== 0 || axisY !== 0) {
|
if (axisX !== 0 || axisY !== 0) {
|
||||||
// Get camera yaw angle
|
// Calculate movement relative to headset orientation
|
||||||
camera.getWorldDirection(forwardVector.current);
|
camera.getWorldDirection(forwardVector.current);
|
||||||
forwardVector.current.y = 0;
|
forwardVector.current.y = 0;
|
||||||
forwardVector.current.normalize();
|
forwardVector.current.normalize();
|
||||||
@@ -60,20 +67,26 @@ export function QuestXRLocomotion() {
|
|||||||
sideVector.current.crossVectors(camera.up, forwardVector.current).normalize();
|
sideVector.current.crossVectors(camera.up, forwardVector.current).normalize();
|
||||||
|
|
||||||
moveVector.current.set(0, 0, 0);
|
moveVector.current.set(0, 0, 0);
|
||||||
moveVector.current.addScaledVector(forwardVector.current, -axisY * MOVE_SPEED * delta);
|
|
||||||
moveVector.current.addScaledVector(sideVector.current, -axisX * MOVE_SPEED * delta);
|
|
||||||
|
|
||||||
camera.position.add(moveVector.current);
|
// 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);
|
||||||
|
|
||||||
|
worldRef.current.position.add(moveVector.current);
|
||||||
}
|
}
|
||||||
} else if (handedness === 'right') {
|
} else if (handedness === 'right') {
|
||||||
// Right Controller -> Snap Turning
|
// Right Controller -> Snap Turning
|
||||||
if (now > snapCooldownRef.current && Math.abs(axisX) > 0.6) {
|
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
|
// To turn the player right, we rotate the world LEFT around the player's current position
|
||||||
const rotationMatrix = new THREE.Matrix4().makeRotationY(turnDirection * SNAP_TURN_ANGLE);
|
const playerPos = camera.position.clone();
|
||||||
camera.position.applyMatrix4(rotationMatrix);
|
|
||||||
camera.rotation.y += turnDirection * SNAP_TURN_ANGLE;
|
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);
|
triggerHaptic(session, 'right', 0.4, 40);
|
||||||
snapCooldownRef.current = now + SNAP_COOLDOWN_MS;
|
snapCooldownRef.current = now + SNAP_COOLDOWN_MS;
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ export interface SpatialHUDProps {
|
|||||||
locked: boolean;
|
locked: boolean;
|
||||||
onToggleLock: () => void;
|
onToggleLock: () => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
onEnterVR: () => void;
|
|
||||||
onEnterAR: () => void;
|
|
||||||
alignmentStep: string;
|
alignmentStep: string;
|
||||||
isAR: boolean;
|
isAR: boolean;
|
||||||
}
|
}
|
||||||
@@ -17,14 +15,11 @@ export function SpatialHUD({
|
|||||||
locked,
|
locked,
|
||||||
onToggleLock,
|
onToggleLock,
|
||||||
onReset,
|
onReset,
|
||||||
onEnterVR,
|
|
||||||
onEnterAR,
|
|
||||||
alignmentStep,
|
alignmentStep,
|
||||||
isAR,
|
isAR,
|
||||||
}: SpatialHUDProps) {
|
}: SpatialHUDProps) {
|
||||||
return (
|
return (
|
||||||
<group position={position}>
|
<group position={position}>
|
||||||
{/* HUD Header Title */}
|
|
||||||
<Text
|
<Text
|
||||||
position={[0, 0.45, 0]}
|
position={[0, 0.45, 0]}
|
||||||
fontSize={0.14}
|
fontSize={0.14}
|
||||||
@@ -37,7 +32,6 @@ export function SpatialHUD({
|
|||||||
LABORATÓRIO XR — META QUEST 3
|
LABORATÓRIO XR — META QUEST 3
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Alignment Instructions Message */}
|
|
||||||
{alignmentStep && (
|
{alignmentStep && (
|
||||||
<group position={[0, 0.25, 0]}>
|
<group position={[0, 0.25, 0]}>
|
||||||
<mesh position={[0, 0, -0.005]}>
|
<mesh position={[0, 0, -0.005]}>
|
||||||
@@ -56,7 +50,6 @@ export function SpatialHUD({
|
|||||||
</group>
|
</group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Spatial Control Card Panel */}
|
|
||||||
<mesh position={[0, -0.1, -0.01]}>
|
<mesh position={[0, -0.1, -0.01]}>
|
||||||
<planeGeometry args={[1.8, 0.5]} />
|
<planeGeometry args={[1.8, 0.5]} />
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
@@ -68,10 +61,8 @@ export function SpatialHUD({
|
|||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{/* Action Buttons Row */}
|
|
||||||
{/* 1. Lock/Unlock Physics Button */}
|
|
||||||
<group
|
<group
|
||||||
position={[-0.55, -0.1, 0.01]}
|
position={[-0.45, -0.1, 0.01]}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
soundFx.playHover();
|
soundFx.playHover();
|
||||||
@@ -79,7 +70,7 @@ export function SpatialHUD({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<mesh>
|
<mesh>
|
||||||
<boxGeometry args={[0.45, 0.14, 0.02]} />
|
<boxGeometry args={[0.55, 0.14, 0.02]} />
|
||||||
<meshStandardMaterial color={locked ? '#ef4444' : '#22c55e'} metalness={0.5} roughness={0.3} />
|
<meshStandardMaterial color={locked ? '#ef4444' : '#22c55e'} metalness={0.5} roughness={0.3} />
|
||||||
</mesh>
|
</mesh>
|
||||||
<Text position={[0, 0, 0.015]} fontSize={0.065} color="white" anchorX="center" anchorY="middle">
|
<Text position={[0, 0, 0.015]} fontSize={0.065} color="white" anchorX="center" anchorY="middle">
|
||||||
@@ -87,9 +78,8 @@ export function SpatialHUD({
|
|||||||
</Text>
|
</Text>
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
{/* 2. Reset Position Button */}
|
|
||||||
<group
|
<group
|
||||||
position={[0, -0.1, 0.01]}
|
position={[0.45, -0.1, 0.01]}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
soundFx.playSnap();
|
soundFx.playSnap();
|
||||||
@@ -97,34 +87,14 @@ export function SpatialHUD({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<mesh>
|
<mesh>
|
||||||
<boxGeometry args={[0.45, 0.14, 0.02]} />
|
<boxGeometry args={[0.55, 0.14, 0.02]} />
|
||||||
<meshStandardMaterial color="#3b82f6" metalness={0.5} roughness={0.3} />
|
<meshStandardMaterial color="#3b82f6" metalness={0.5} roughness={0.3} />
|
||||||
</mesh>
|
</mesh>
|
||||||
<Text position={[0, 0, 0.015]} fontSize={0.065} color="white" anchorX="center" anchorY="middle">
|
<Text position={[0, 0, 0.015]} fontSize={0.065} color="white" anchorX="center" anchorY="middle">
|
||||||
🔄 RESETAR CUBO
|
🔄 RESETAR MUNDO
|
||||||
</Text>
|
</Text>
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
{/* 3. AR / VR Switcher Button */}
|
|
||||||
<group
|
|
||||||
position={[0.55, -0.1, 0.01]}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
soundFx.playHover();
|
|
||||||
if (isAR) onEnterVR();
|
|
||||||
else onEnterAR();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<mesh>
|
|
||||||
<boxGeometry args={[0.45, 0.14, 0.02]} />
|
|
||||||
<meshStandardMaterial color="#8b5cf6" metalness={0.5} roughness={0.3} />
|
|
||||||
</mesh>
|
|
||||||
<Text position={[0, 0, 0.015]} fontSize={0.065} color="white" anchorX="center" anchorY="middle">
|
|
||||||
{isAR ? '🥽 MODO VR (3D)' : '📷 PASSTHROUGH AR'}
|
|
||||||
</Text>
|
|
||||||
</group>
|
|
||||||
|
|
||||||
{/* Status Footer info */}
|
|
||||||
<Text
|
<Text
|
||||||
position={[0, -0.28, 0.01]}
|
position={[0, -0.28, 0.01]}
|
||||||
fontSize={0.045}
|
fontSize={0.045}
|
||||||
@@ -132,7 +102,7 @@ export function SpatialHUD({
|
|||||||
anchorX="center"
|
anchorX="center"
|
||||||
anchorY="middle"
|
anchorY="middle"
|
||||||
>
|
>
|
||||||
Controles: Gatilho/Grip (Agarrar/Arremessar) • Joystick Esq (Mover) • Joystick Dir (Girar 45°)
|
{isAR ? 'MODO: PASSTHROUGH AR ATIVO' : 'MODO: VR IMERSIVO'} • Joysticks Movem e Giram
|
||||||
</Text>
|
</Text>
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
|
|||||||
+63
-66
@@ -1,23 +1,21 @@
|
|||||||
import { useState, Suspense } from 'react';
|
import { useState, Suspense, useRef } from 'react';
|
||||||
import { useXR } from '@react-three/xr';
|
import { useXR } from '@react-three/xr';
|
||||||
import { Environment, Grid } from '@react-three/drei';
|
import { Environment, Grid } from '@react-three/drei';
|
||||||
|
import * as THREE from 'three';
|
||||||
import { WorkbenchTable } from './WorkbenchTable';
|
import { WorkbenchTable } from './WorkbenchTable';
|
||||||
import { InteractiveCube, type FaceId } from './InteractiveCube';
|
import { InteractiveCube, type FaceId } from './InteractiveCube';
|
||||||
import { SpatialHUD } from './SpatialHUD';
|
import { SpatialHUD } from './SpatialHUD';
|
||||||
import { QuestXRLocomotion } from './QuestXRLocomotion';
|
import { QuestXRLocomotion } from './QuestXRLocomotion';
|
||||||
|
|
||||||
export interface XRSceneProps {
|
export function XRScene() {
|
||||||
onEnterVR: () => void;
|
|
||||||
onEnterAR: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function XRScene({ onEnterVR, onEnterAR }: XRSceneProps) {
|
|
||||||
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');
|
||||||
|
|
||||||
const [locked, setLocked] = useState(false);
|
const [locked, setLocked] = useState(false);
|
||||||
const [resetSignal, setResetSignal] = useState(0);
|
const [resetSignal, setResetSignal] = useState(0);
|
||||||
|
|
||||||
|
const worldRef = useRef<THREE.Group>(null);
|
||||||
|
|
||||||
// Alignment tracking
|
// Alignment tracking
|
||||||
const [selectedCubeFace, setSelectedCubeFace] = useState<FaceId | null>(null);
|
const [selectedCubeFace, setSelectedCubeFace] = useState<FaceId | null>(null);
|
||||||
const [alignmentStep, setAlignmentStep] = useState<string>(
|
const [alignmentStep, setAlignmentStep] = useState<string>(
|
||||||
@@ -41,10 +39,8 @@ export function XRScene({ onEnterVR, onEnterAR }: XRSceneProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* VR Joystick Locomotion & Snap Turn */}
|
<QuestXRLocomotion worldRef={worldRef} />
|
||||||
<QuestXRLocomotion />
|
|
||||||
|
|
||||||
{/* Dynamic Background Fog & Color for VR Mode */}
|
|
||||||
{!isAR && (
|
{!isAR && (
|
||||||
<>
|
<>
|
||||||
<color attach="background" args={['#090d16']} />
|
<color attach="background" args={['#090d16']} />
|
||||||
@@ -52,67 +48,68 @@ export function XRScene({ onEnterVR, onEnterAR }: XRSceneProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Lighting Setup */}
|
{/* World Group that moves inversely to simulate player movement */}
|
||||||
<ambientLight intensity={isAR ? 1.4 : 0.6} />
|
<group ref={worldRef}>
|
||||||
<directionalLight
|
<ambientLight intensity={isAR ? 1.4 : 0.6} />
|
||||||
position={[4, 6, 4]}
|
<directionalLight
|
||||||
intensity={isAR ? 1.6 : 1.4}
|
position={[4, 6, 4]}
|
||||||
castShadow
|
intensity={isAR ? 1.6 : 1.4}
|
||||||
shadow-mapSize={[2048, 2048]}
|
castShadow
|
||||||
shadow-bias={-0.0001}
|
shadow-mapSize={[2048, 2048]}
|
||||||
/>
|
shadow-bias={-0.0001}
|
||||||
<pointLight position={[-3, 4, -2]} intensity={0.5} color="#38bdf8" />
|
/>
|
||||||
<pointLight position={[3, 4, -2]} intensity={0.5} color="#f59e0b" />
|
<pointLight position={[-3, 4, -2]} intensity={0.5} color="#38bdf8" />
|
||||||
|
<pointLight position={[3, 4, -2]} intensity={0.5} color="#f59e0b" />
|
||||||
|
|
||||||
{/* Grid Floor */}
|
<Grid
|
||||||
<Grid
|
position={[0, -0.01, 0]}
|
||||||
position={[0, -0.01, 0]}
|
args={[20, 20]}
|
||||||
args={[20, 20]}
|
cellSize={0.25}
|
||||||
cellSize={0.25}
|
cellThickness={0.6}
|
||||||
cellThickness={0.6}
|
cellColor="#334155"
|
||||||
cellColor="#334155"
|
sectionSize={1}
|
||||||
sectionSize={1}
|
sectionThickness={1.2}
|
||||||
sectionThickness={1.2}
|
sectionColor="#0284c7"
|
||||||
sectionColor="#0284c7"
|
fadeDistance={10}
|
||||||
fadeDistance={10}
|
fadeStrength={1.5}
|
||||||
fadeStrength={1.5}
|
infiniteGrid
|
||||||
infiniteGrid
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Environment Reflections */}
|
<Suspense fallback={null}>
|
||||||
<Suspense fallback={null}>
|
<Environment preset="city" />
|
||||||
<Environment preset="city" />
|
</Suspense>
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
{/* Workbench Table */}
|
<WorkbenchTable
|
||||||
<WorkbenchTable
|
position={[0, 0, -1]}
|
||||||
position={[0, 0, -1]}
|
onSurfaceClick={handleSurfaceSelect}
|
||||||
onSurfaceClick={handleSurfaceSelect}
|
onEdgeClick={(edge) => setAlignmentStep(`Aresta selecionada: ${edge}`)}
|
||||||
onEdgeClick={(edge) => setAlignmentStep(`Aresta selecionada: ${edge}`)}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Interactive Physics Cube */}
|
<InteractiveCube
|
||||||
<InteractiveCube
|
position={[0, 1.2, -1]}
|
||||||
position={[0, 1.2, -1]}
|
size={0.3}
|
||||||
size={0.3}
|
locked={locked}
|
||||||
locked={locked}
|
onFaceSelect={handleFaceSelect}
|
||||||
onFaceSelect={handleFaceSelect}
|
resetSignal={resetSignal}
|
||||||
resetSignal={resetSignal}
|
tableY={0.625}
|
||||||
tableY={0.625}
|
tableBounds={{ minX: -1, maxX: 1, minZ: -1.5, maxZ: -0.5 }}
|
||||||
tableBounds={{ minX: -1, maxX: 1, minZ: -1.5, maxZ: -0.5 }}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Floating Spatial HUD */}
|
<SpatialHUD
|
||||||
<SpatialHUD
|
position={[0, 1.85, -1.7]}
|
||||||
position={[0, 1.85, -1.7]}
|
locked={locked}
|
||||||
locked={locked}
|
onToggleLock={() => setLocked((l) => !l)}
|
||||||
onToggleLock={() => setLocked((l) => !l)}
|
onReset={() => {
|
||||||
onReset={() => setResetSignal((s) => s + 1)}
|
setResetSignal((s) => s + 1);
|
||||||
onEnterVR={onEnterVR}
|
if (worldRef.current) {
|
||||||
onEnterAR={onEnterAR}
|
worldRef.current.position.set(0, 0, 0);
|
||||||
alignmentStep={alignmentStep}
|
worldRef.current.rotation.set(0, 0, 0);
|
||||||
isAR={isAR}
|
}
|
||||||
/>
|
}}
|
||||||
|
alignmentStep={alignmentStep}
|
||||||
|
isAR={isAR}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user