Here’s the full script for my Hand Physics sample. I’ll try to get this integrated into the main VR sample scene soon for an upcoming release. In the meantime, you basically just need to set up the following script similar to the existing hand visualizer (put it under the CameraOffset
object in your XR Rig).
The demo I was building this for was a “sphere fountain” where I just created another simple script to constantly spawn a “waterfall” of spheres with rigidbodies, and the user could “scoop” up a handful of them or let them “trickle” through their fingers, hence it was important to include colliders for each individual joint. You could greatly cut down on the complexity here (both in terms of code and runtime efficiency) by just deleting all the finger joint stuff. You could start by just deleting the m_JointPhysicsPrefab
field and keep pulling on the thread by fixing up compile errors due to missing references. Or just give it an empty prefab and pay for some wasted CPU cycles.
It’s up to you whether or not you want to add rendering visuals to these objects, but note that the default capsule will not adjust its height/radius without applying a scale, and that will mess up the physics collider. I recommend handling visuals completely separately from this script, since the collider locations may also not match up 1:1 with hand position. One of the “benefits” of this approach is that the hand will stop when it encounters a static collider, even though the user’s real hand has passed through it, and you may want your visual affordances to behave differently.
Note that the script calls for two new prefabs:
m_JointPhysicsPrefab
is an object with a Z-aligned capsule collider whose radius more or less matches to the user’s hand size. You may want to get fancy and allow users to change this, or you may want to just strip out all the finger joint stuff and simply use the logic for the palm. The length of the capsules is adjusted dynamically by the script. You should leave the center at 0,0,0.
m_PalmPhysicsPrefab
is an object with a box collider shaped roughly to the size/aspect ratio of the palm. The offset from the wrist to this collider is also “baked into” this object. I found it was possible to just use collider offsets to get this good enough for my use case, but you should also be able to add the collider as a child of the prefab offset to account for more control over rotation/translation as needed.
And without further ado, the script:
// Requires hands package and VisionOSHandExtensions which is only compiled for visionOS and Editor
#if INCLUDE_UNITY_XR_HANDS && (UNITY_VISIONOS || UNITY_EDITOR)
using System.Collections.Generic;
using UnityEngine.XR.Hands;
#endif
namespace UnityEngine.XR.VisionOS.Samples.URP
{
public class HandPhysics : MonoBehaviour
{
[SerializeField]
GameObject m_JointPhysicsPrefab;
[SerializeField]
GameObject m_PalmPhysicsPrefab;
#if INCLUDE_UNITY_XR_HANDS && (UNITY_VISIONOS || UNITY_EDITOR)
XRHandSubsystem m_Subsystem;
HandGameObjects m_LeftHandGameObjects;
HandGameObjects m_RightHandGameObjects;
float m_JointRadius;
XRHandSubsystem.UpdateType m_UpdateType = XRHandSubsystem.UpdateType.Fixed;
static readonly List<XRHandSubsystem> k_SubsystemsReuse = new();
public void SetUpdateType(XRHandSubsystem.UpdateType updateType)
{
m_UpdateType = updateType;
}
void Awake()
{
m_JointRadius = m_JointPhysicsPrefab.GetComponent<CapsuleCollider>().radius;
}
protected void OnEnable()
{
if (m_Subsystem == null)
return;
UpdateRenderingVisibility(m_LeftHandGameObjects, m_Subsystem.leftHand.isTracked);
UpdateRenderingVisibility(m_RightHandGameObjects, m_Subsystem.rightHand.isTracked);
}
protected void OnDisable()
{
if (m_Subsystem != null)
UnsubscribeSubsystem();
UpdateRenderingVisibility(m_LeftHandGameObjects, false);
UpdateRenderingVisibility(m_RightHandGameObjects, false);
}
void UnsubscribeSubsystem()
{
m_Subsystem.trackingAcquired -= OnTrackingAcquired;
m_Subsystem.trackingLost -= OnTrackingLost;
m_Subsystem.updatedHands -= OnUpdatedHands;
m_Subsystem = null;
}
protected void OnDestroy()
{
if (m_LeftHandGameObjects != null)
{
m_LeftHandGameObjects.OnDestroy();
m_LeftHandGameObjects = null;
}
if (m_RightHandGameObjects != null)
{
m_RightHandGameObjects.OnDestroy();
m_RightHandGameObjects = null;
}
}
protected void Update()
{
if (m_Subsystem != null)
{
if (m_Subsystem.running)
return;
UnsubscribeSubsystem();
UpdateRenderingVisibility(m_LeftHandGameObjects, false);
UpdateRenderingVisibility(m_RightHandGameObjects, false);
return;
}
SubsystemManager.GetSubsystems(k_SubsystemsReuse);
for (var i = 0; i < k_SubsystemsReuse.Count; ++i)
{
var handSubsystem = k_SubsystemsReuse[i];
if (handSubsystem.running)
{
UnsubscribeHandSubsystem();
m_Subsystem = handSubsystem;
break;
}
}
if (m_Subsystem == null)
return;
if (m_LeftHandGameObjects == null)
{
m_LeftHandGameObjects = new HandGameObjects(
Handedness.Left,
transform,
m_JointPhysicsPrefab,
m_PalmPhysicsPrefab);
}
if (m_RightHandGameObjects == null)
{
m_RightHandGameObjects = new HandGameObjects(
Handedness.Right,
transform,
m_JointPhysicsPrefab,
m_PalmPhysicsPrefab);
}
UpdateRenderingVisibility(m_LeftHandGameObjects, m_Subsystem.leftHand.isTracked);
UpdateRenderingVisibility(m_RightHandGameObjects, m_Subsystem.rightHand.isTracked);
SubscribeHandSubsystem();
}
void SubscribeHandSubsystem()
{
if (m_Subsystem == null)
return;
m_Subsystem.trackingAcquired += OnTrackingAcquired;
m_Subsystem.trackingLost += OnTrackingLost;
m_Subsystem.updatedHands += OnUpdatedHands;
}
void UnsubscribeHandSubsystem()
{
if (m_Subsystem == null)
return;
m_Subsystem.trackingAcquired -= OnTrackingAcquired;
m_Subsystem.trackingLost -= OnTrackingLost;
m_Subsystem.updatedHands -= OnUpdatedHands;
}
static void UpdateRenderingVisibility(HandGameObjects handGameObjects, bool isTracked)
{
if (handGameObjects == null)
return;
handGameObjects.SetHandActive(isTracked);
}
void OnTrackingAcquired(XRHand hand)
{
switch (hand.handedness)
{
case Handedness.Left:
UpdateRenderingVisibility(m_LeftHandGameObjects, true);
break;
case Handedness.Right:
UpdateRenderingVisibility(m_RightHandGameObjects, true);
break;
}
}
void OnTrackingLost(XRHand hand)
{
switch (hand.handedness)
{
case Handedness.Left:
UpdateRenderingVisibility(m_LeftHandGameObjects, false);
break;
case Handedness.Right:
UpdateRenderingVisibility(m_RightHandGameObjects, false);
break;
}
}
void OnUpdatedHands(XRHandSubsystem subsystem, XRHandSubsystem.UpdateSuccessFlags updateSuccessFlags, XRHandSubsystem.UpdateType updateType)
{
// We have no game logic depending on the Transforms, so early out here
// (add game logic before this return here, directly querying from
// subsystem.leftHand and subsystem.rightHand using GetJoint on each hand)
if (updateType != m_UpdateType)
return;
var thisTransform = transform;
m_LeftHandGameObjects.UpdateJoints(
subsystem.leftHand,
thisTransform,
m_JointRadius,
(updateSuccessFlags & XRHandSubsystem.UpdateSuccessFlags.LeftHandJoints) != 0,
m_UpdateType);
m_RightHandGameObjects.UpdateJoints(
subsystem.rightHand,
thisTransform,
m_JointRadius,
(updateSuccessFlags & XRHandSubsystem.UpdateSuccessFlags.RightHandJoints) != 0,
m_UpdateType);
}
class HandGameObjects
{
GameObject m_JointsParent;
readonly Rigidbody[] m_JointRigidBodies = new Rigidbody[XRHandJointID.EndMarker.ToIndex()];
readonly CapsuleCollider[] m_JointCapsules = new CapsuleCollider[XRHandJointID.EndMarker.ToIndex()];
readonly bool[] m_JointHasBeenUpdated = new bool[XRHandJointID.EndMarker.ToIndex()];
public HandGameObjects(
Handedness handedness,
Transform parent,
GameObject jointPhysicsPrefab,
GameObject palmPhysicsPrefab)
{
void AssignJoint(
XRHandJointID jointID,
Transform drawJointsParent)
{
var jointIndex = jointID.ToIndex();
var jointName = jointID.ToString();
if (jointID == XRHandJointID.Wrist)
{
var palmPhysicsObject = Instantiate(palmPhysicsPrefab, drawJointsParent, false);
palmPhysicsObject.name = jointName;
m_JointRigidBodies[jointIndex] = palmPhysicsObject.GetComponent<Rigidbody>();
return;
}
var jointVisualsObject = Instantiate(jointPhysicsPrefab, drawJointsParent, false);
jointVisualsObject.name = $"{jointName}";
var jointRigidBody = jointVisualsObject.GetComponent<Rigidbody>();
var jointCapsule = jointVisualsObject.GetComponent<CapsuleCollider>();
m_JointRigidBodies[jointIndex] = jointRigidBody;
m_JointCapsules[jointIndex] = jointCapsule;
}
m_JointsParent = new GameObject();
var parentTransform = m_JointsParent.transform;
parentTransform.parent = parent;
parentTransform.localPosition = Vector3.zero;
parentTransform.localRotation = Quaternion.identity;
m_JointsParent.name = $"{handedness} Hand physics";
AssignJoint(XRHandJointID.Wrist, parentTransform);
for (var fingerIndex = (int)XRHandFingerID.Thumb;
fingerIndex <= (int)XRHandFingerID.Little;
++fingerIndex)
{
var fingerId = (XRHandFingerID)fingerIndex;
// Ignore fingertips (capsule from distal extends to tip)
var jointIndexBack = fingerId.GetBackJointID().ToIndex() - 1;
// Ignore metacarpals (covered by palm collider)
var jointIndexFront = fingerId.GetFrontJointID().ToIndex() + 1;
for (var jointIndex = jointIndexFront;
jointIndex <= jointIndexBack;
++jointIndex)
{
AssignJoint(XRHandJointIDUtility.FromIndex(jointIndex), parentTransform);
}
}
}
public void OnDestroy()
{
var length = m_JointRigidBodies.Length;
for (var jointIndex = 0; jointIndex < length; ++jointIndex)
{
var visuals = m_JointRigidBodies[jointIndex];
if (visuals == null)
continue;
Destroy(visuals.gameObject);
m_JointRigidBodies[jointIndex] = default;
}
Destroy(m_JointsParent);
m_JointsParent = null;
}
public void SetHandActive(bool isActive)
{
m_JointsParent.SetActive(isActive);
}
public void UpdateJoints(
XRHand hand,
Transform rootTransform,
float jointRadius,
bool areJointsTracked,
XRHandSubsystem.UpdateType updateType)
{
if (!areJointsTracked)
return;
UpdateJoint(hand.GetJoint(XRHandJointID.Wrist), default, rootTransform, jointRadius, updateType);
for (var fingerIndex = (int)XRHandFingerID.Thumb;
fingerIndex <= (int)XRHandFingerID.Little;
++fingerIndex)
{
var fingerId = (XRHandFingerID)fingerIndex;
// Ignore fingertips (capsule from distal extends to tip)
var jointIndexBack = fingerId.GetBackJointID().ToIndex() - 1;
// Ignore metacarpals (covered by palm collider)
var jointIndexFront = fingerId.GetFrontJointID().ToIndex() + 1;
for (var jointIndex = jointIndexFront;
jointIndex <= jointIndexBack;
++jointIndex)
{
var joint = hand.GetJoint(XRHandJointIDUtility.FromIndex(jointIndex));
var nextJoint = hand.GetJoint(XRHandJointIDUtility.FromIndex(jointIndex + 1));
UpdateJoint(joint, nextJoint, rootTransform, jointRadius, updateType);
}
}
}
static readonly Vector3 k_WristOffset = new Vector3(0, 0, 0.05f);
void UpdateJoint(
XRHandJoint joint,
XRHandJoint nextJoint,
Transform rootTransform,
float jointRadius,
XRHandSubsystem.UpdateType updateType)
{
if (joint.id == XRHandJointID.Invalid)
return;
var jointIndex = joint.id.ToIndex();
var rigidBody = m_JointRigidBodies[jointIndex];
if (!joint.TryGetPose(out var pose))
{
rigidBody.gameObject.SetActive(false);
return;
}
Vector3 offset;
var targetRotation = rootTransform.rotation * pose.rotation;
var targetPosition = rootTransform.TransformPoint(pose.position);
if (nextJoint.TryGetPose(out var nextPose))
{
var nextPosition = rootTransform.TransformPoint(nextPose.position);
var jointLength = Vector3.Distance(nextPosition, targetPosition);
offset = Vector3.forward * jointLength * 0.5f;
var capsule = m_JointCapsules[jointIndex];
capsule.height = jointLength - jointRadius;
//capsule.center = Vector3.forward * (jointLength * 0.5f);
}
else
{
// Palm collider has a fixed offset from the wrist
offset = k_WristOffset;
}
// Adjust actual position to be in the center
targetPosition += targetRotation * offset;
if (updateType == XRHandSubsystem.UpdateType.Fixed)
{
var hasBeenUpdated = m_JointHasBeenUpdated[jointIndex];
if (!hasBeenUpdated)
{
m_JointHasBeenUpdated[jointIndex] = true;
rigidBody.position = targetPosition;
rigidBody.rotation = targetRotation;
}
else
{
var fixedDeltaInverse = 1 / Time.fixedDeltaTime;
rigidBody.linearVelocity = (targetPosition - rigidBody.position) * fixedDeltaInverse;
rigidBody.rotation = targetRotation;
}
}
else
{
rigidBody.transform.SetLocalPositionAndRotation(pose.position, pose.rotation);
}
}
}
#endif
}
}
P.S. Your mileage may vary here… I don’t have this in a working state anymore so I’m just pasting in the final “check-in” state of this script I could find. As I said I’ll try to turn this into a real sample in the coming weeks, since I think it’s kind of a fun thing to play around with, if nothing else.
Hope this helps. Good luck!