feat: improve hand grab targeting

This commit is contained in:
Tom Boullay
2026-04-29 11:40:17 +02:00
parent 7958b2c62a
commit 90bd216efe
3 changed files with 82 additions and 21 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ export function DebugPerf(): React.JSX.Element | null {
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<Perf position="bottom-right" /> <Perf position="top-right" />
</Suspense> </Suspense>
); );
} }
+79 -18
View File
@@ -51,17 +51,71 @@ const _holdTarget = new THREE.Vector3();
const _currentPos = new THREE.Vector3(); const _currentPos = new THREE.Vector3();
const _velocity = new THREE.Vector3(); const _velocity = new THREE.Vector3();
const _handNdc = new THREE.Vector3(); const _handNdc = new THREE.Vector3();
const _handHitNdc = new THREE.Vector3();
const _handDirection = new THREE.Vector3(); const _handDirection = new THREE.Vector3();
const _handHitDirection = new THREE.Vector3();
const _cameraPos = new THREE.Vector3(); const _cameraPos = new THREE.Vector3();
const _objectPos = new THREE.Vector3(); const _objectPos = new THREE.Vector3();
const _handRaycaster = new THREE.Raycaster(); const _handRaycaster = new THREE.Raycaster();
function getHandAnchorPoint(hand: HandTrackingHand): HandTrackingLandmark { const HAND_GRAB_SCREEN_RADIUS = 0.04;
return hand.landmarks.reduce<HandTrackingLandmark>( const HAND_DEPTH_SENSITIVITY = 4;
(lowestPoint, landmark) => const HAND_HIT_OFFSETS: Array<[number, number]> = [
landmark.y > lowestPoint.y ? landmark : lowestPoint, [0, 0],
{ x: hand.x, y: hand.y, z: hand.z }, [HAND_GRAB_SCREEN_RADIUS, 0],
); [-HAND_GRAB_SCREEN_RADIUS, 0],
[0, HAND_GRAB_SCREEN_RADIUS],
[0, -HAND_GRAB_SCREEN_RADIUS],
];
function getHandCenterPoint(hand: HandTrackingHand): HandTrackingLandmark {
const landmarks = hand.landmarks ?? [];
if (landmarks.length === 0) {
return { x: hand.x, y: hand.y, z: hand.z };
}
let minX = landmarks[0].x;
let maxX = landmarks[0].x;
let minY = landmarks[0].y;
let maxY = landmarks[0].y;
landmarks.forEach((landmark) => {
minX = Math.min(minX, landmark.x);
maxX = Math.max(maxX, landmark.x);
minY = Math.min(minY, landmark.y);
maxY = Math.max(maxY, landmark.y);
});
return {
x: (minX + maxX) / 2,
y: (minY + maxY) / 2,
z: hand.z,
};
}
function getHandHit(
group: THREE.Group | null,
camera: THREE.Camera,
cameraPos: THREE.Vector3,
handCenter: HandTrackingLandmark,
): THREE.Intersection | null {
if (!group) return null;
const baseX = (1 - handCenter.x) * 2 - 1;
const baseY = -handCenter.y * 2 + 1;
for (const [offsetX, offsetY] of HAND_HIT_OFFSETS) {
_handHitNdc.set(baseX + offsetX, baseY + offsetY, 0.5);
_handHitNdc.unproject(camera);
_handHitDirection.subVectors(_handHitNdc, cameraPos).normalize();
_handRaycaster.set(cameraPos, _handHitDirection);
_handRaycaster.far = INTERACTION_RADIUS;
const hits = _handRaycaster.intersectObject(group, true);
if (hits.length > 0) return hits[0];
}
return null;
} }
export function GrabbableObject({ export function GrabbableObject({
@@ -78,6 +132,7 @@ export function GrabbableObject({
const isHolding = useRef(false); const isHolding = useRef(false);
const isHandHolding = useRef(false); const isHandHolding = useRef(false);
const handHoldDistance = useRef<number | null>(null); const handHoldDistance = useRef<number | null>(null);
const handHoldStartZ = useRef<number | null>(null);
useDebugFolder("GrabbableObject", (folder) => { useDebugFolder("GrabbableObject", (folder) => {
folder folder
@@ -120,40 +175,46 @@ export function GrabbableObject({
_currentPos.set(t.x, t.y, t.z); _currentPos.set(t.x, t.y, t.z);
if (fistHand) { if (fistHand) {
const handAnchor = getHandAnchorPoint(fistHand); const handCenter = getHandCenterPoint(fistHand);
_handNdc.set((1 - handAnchor.x) * 2 - 1, -handAnchor.y * 2 + 1, 0.5); _handNdc.set((1 - handCenter.x) * 2 - 1, -handCenter.y * 2 + 1, 0.5);
_handNdc.unproject(camera); _handNdc.unproject(camera);
camera.getWorldPosition(_cameraPos); camera.getWorldPosition(_cameraPos);
_handDirection.subVectors(_handNdc, _cameraPos).normalize(); _handDirection.subVectors(_handNdc, _cameraPos).normalize();
if (!isHandHolding.current) { if (!isHandHolding.current) {
_objectPos.copy(_currentPos); _objectPos.copy(_currentPos);
_handRaycaster.set(_cameraPos, _handDirection);
_handRaycaster.far = INTERACTION_RADIUS;
const isObjectInRange = const isObjectInRange =
_cameraPos.distanceTo(_objectPos) <= INTERACTION_RADIUS; _cameraPos.distanceTo(_objectPos) <= INTERACTION_RADIUS;
const hits = groupRef.current const hit = isObjectInRange
? _handRaycaster.intersectObject(groupRef.current, true) ? getHandHit(groupRef.current, camera, _cameraPos, handCenter)
: [];
isHandHolding.current = isObjectInRange && hits.length > 0;
handHoldDistance.current = isHandHolding.current
? hits[0].distance
: null; : null;
isHandHolding.current = Boolean(hit);
handHoldDistance.current = hit?.distance ?? null;
handHoldStartZ.current = hit ? fistHand.z : null;
InteractionManager.getInstance().setHandHolding(isHandHolding.current); InteractionManager.getInstance().setHandHolding(isHandHolding.current);
} }
} else { } else {
isHandHolding.current = false; isHandHolding.current = false;
handHoldDistance.current = null; handHoldDistance.current = null;
handHoldStartZ.current = null;
InteractionManager.getInstance().setHandHolding(false); InteractionManager.getInstance().setHandHolding(false);
} }
if (!isHolding.current && !isHandHolding.current) return; if (!isHolding.current && !isHandHolding.current) return;
if (fistHand && isHandHolding.current) { if (fistHand && isHandHolding.current) {
const holdDistance = handHoldDistance.current ?? params.holdDistance; const depthOffset =
handHoldStartZ.current === null
? 0
: (fistHand.z - handHoldStartZ.current) * HAND_DEPTH_SENSITIVITY;
const holdDistance = THREE.MathUtils.clamp(
(handHoldDistance.current ?? params.holdDistance) + depthOffset,
GRAB_HOLD_DISTANCE_MIN,
GRAB_HOLD_DISTANCE_MAX,
);
_holdTarget _holdTarget
.copy(_cameraPos) .copy(_cameraPos)
+2 -2
View File
@@ -393,8 +393,8 @@ canvas {
.hand-tracking-overlay { .hand-tracking-overlay {
position: fixed; position: fixed;
right: 16px; top: 16px;
bottom: 132px; left: 16px;
z-index: 20; z-index: 20;
display: flex; display: flex;
flex-direction: column; flex-direction: column;