diff --git a/examples/examples.js b/examples/examples.js
index 2a94fef6fca..ad70300ba83 100644
--- a/examples/examples.js
+++ b/examples/examples.js
@@ -101,6 +101,7 @@ var categories = [
'ar-hit-test',
'vr-basic',
'vr-controllers',
+ 'vr-hands',
'vr-movement',
'xr-picking'
]
diff --git a/examples/xr/vr-hands.html b/examples/xr/vr-hands.html
new file mode 100644
index 00000000000..fc42b604068
--- /dev/null
+++ b/examples/xr/vr-hands.html
@@ -0,0 +1,229 @@
+
+
+
+ PlayCanvas VR Hands
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/xr/xr-finger.js b/src/xr/xr-finger.js
new file mode 100644
index 00000000000..0ce7d0ef7f6
--- /dev/null
+++ b/src/xr/xr-finger.js
@@ -0,0 +1,45 @@
+/**
+ * @class
+ * @name pc.XrFinger
+ * @classdesc Represents finger with related joints and index
+ * @description Represents finger with related joints and index
+ * @param {number} index - Index of a finger
+ * @param {pc.XrHand} hand - Hand that finger relates to
+ * @property {number} index Index of a finger, numeration is: thumb, index, middle, ring, little
+ * @property {pc.XrHand} hand Hand that finger relates to
+ * @property {pc.XrJoint[]} joints List of joints that relates to this finger, starting from joint closest to wrist all the way to the tip of a finger
+ * @property {pc.XrJoint|null} tip Tip of a finger, or null if not available
+ */
+function XrFinger(index, hand) {
+ this._index = index;
+ this._hand = hand;
+ this._hand._fingers.push(this);
+ this._joints = [];
+ this._tip = null;
+}
+
+Object.defineProperty(XrFinger.prototype, 'index', {
+ get: function () {
+ return this._index;
+ }
+});
+
+Object.defineProperty(XrFinger.prototype, 'hand', {
+ get: function () {
+ return this._hand;
+ }
+});
+
+Object.defineProperty(XrFinger.prototype, 'joints', {
+ get: function () {
+ return this._joints;
+ }
+});
+
+Object.defineProperty(XrFinger.prototype, 'tip', {
+ get: function () {
+ return this._tip;
+ }
+});
+
+export { XrFinger };
diff --git a/src/xr/xr-hand.js b/src/xr/xr-hand.js
new file mode 100644
index 00000000000..9f58bf94104
--- /dev/null
+++ b/src/xr/xr-hand.js
@@ -0,0 +1,216 @@
+import { EventHandler } from '../core/event-handler.js';
+
+import { XRHAND_LEFT } from './constants.js';
+
+import { XrFinger } from './xr-finger.js';
+import { XrJoint } from './xr-joint.js';
+
+import { Vec3 } from '../math/vec3.js';
+
+var fingerJointIds = [];
+
+var vecA = new Vec3();
+var vecB = new Vec3();
+var vecC = new Vec3();
+
+if (window.XRHand) {
+ fingerJointIds = [
+ [XRHand.THUMB_METACARPAL, XRHand.THUMB_PHALANX_PROXIMAL, XRHand.THUMB_PHALANX_DISTAL, XRHand.THUMB_PHALANX_TIP],
+ [XRHand.INDEX_METACARPAL, XRHand.INDEX_PHALANX_PROXIMAL, XRHand.INDEX_PHALANX_INTERMEDIATE, XRHand.INDEX_PHALANX_DISTAL, XRHand.INDEX_PHALANX_TIP],
+ [XRHand.MIDDLE_METACARPAL, XRHand.MIDDLE_PHALANX_PROXIMAL, XRHand.MIDDLE_PHALANX_INTERMEDIATE, XRHand.MIDDLE_PHALANX_DISTAL, XRHand.MIDDLE_PHALANX_TIP],
+ [XRHand.RING_METACARPAL, XRHand.RING_PHALANX_PROXIMAL, XRHand.RING_PHALANX_INTERMEDIATE, XRHand.RING_PHALANX_DISTAL, XRHand.RING_PHALANX_TIP],
+ [XRHand.LITTLE_METACARPAL, XRHand.LITTLE_PHALANX_PROXIMAL, XRHand.LITTLE_PHALANX_INTERMEDIATE, XRHand.LITTLE_PHALANX_DISTAL, XRHand.LITTLE_PHALANX_TIP]
+ ];
+}
+
+/**
+ * @class
+ * @name pc.XrHand
+ * @classdesc Represents a hand with fingers and joints
+ * @description Represents a hand with fingers and joints
+ * @param {pc.XrInputSource} inputSource - Input Source that hand is related to
+ * @property {pc.XrFinger[]} fingers List of fingers of a hand
+ * @property {pc.XrJoint[]} joints List of joints of hand
+ * @property {pc.XrJoint[]} tips List of joints that are tips of a fingers
+ * @property {pc.XrJoint|null} wrist Wrist of a hand, or null if it is not available by WebXR underlying system
+ * @property {boolean} tracking True if tracking is available, otherwise tracking might be lost
+ */
+function XrHand(inputSource) {
+ EventHandler.call(this);
+
+ var xrHand = inputSource._xrInputSource.hand;
+
+ this._manager = inputSource._manager;
+ this._inputSource = inputSource;
+
+ this._tracking = false;
+
+ this._fingers = [];
+ this._joints = [];
+ this._jointsById = {};
+ this._tips = [];
+
+ this._wrist = null;
+
+ if (xrHand[XRHand.WRIST])
+ this._wrist = new XrJoint(0, XRHand.WRIST, this, null);
+
+ for (var f = 0; f < fingerJointIds.length; f++) {
+ var finger = new XrFinger(f, this);
+
+ for (var j = 0; j < fingerJointIds[f].length; j++) {
+ var jointId = fingerJointIds[f][j];
+ if (! xrHand[jointId]) continue;
+ new XrJoint(j, jointId, this, finger);
+ }
+ }
+}
+XrHand.prototype = Object.create(EventHandler.prototype);
+XrHand.prototype.constructor = XrHand;
+
+/**
+ * @event
+ * @name pc.XrHand#tracking
+ * @description Fired when tracking becomes available.
+ */
+
+ /**
+ * @event
+ * @name pc.XrHand#trackinglost
+ * @description Fired when tracking is lost.
+ */
+
+XrHand.prototype.update = function (frame) {
+ var xrInputSource = this._inputSource._xrInputSource;
+
+ // joints
+ for (var j = 0; j < this._joints.length; j++) {
+ var joint = this._joints[j];
+ var jointSpace = xrInputSource.hand[joint._id];
+ if (jointSpace) {
+ var pose = frame.getJointPose(jointSpace, this._manager._referenceSpace);
+ if (pose) {
+ joint.update(pose);
+
+ if (joint.wrist && ! this._tracking) {
+ this._tracking = true;
+ this.fire('tracking');
+ }
+ } else if (joint.wrist) {
+ // lost tracking
+
+ if (this._tracking) {
+ this._tracking = false;
+ this.fire('trackinglost');
+ }
+ break;
+ }
+ }
+ }
+
+ var j1 = this._jointsById[XRHand.THUMB_METACARPAL];
+ var j4 = this._jointsById[XRHand.THUMB_PHALANX_TIP];
+ var j6 = this._jointsById[XRHand.INDEX_PHALANX_PROXIMAL];
+ var j9 = this._jointsById[XRHand.INDEX_PHALANX_TIP];
+ var j16 = this._jointsById[XRHand.RING_PHALANX_PROXIMAL];
+ var j21 = this._jointsById[XRHand.LITTLE_PHALANX_PROXIMAL];
+
+ // ray
+ if (j1 && j4 && j6 && j9 && j16 && j21) {
+ this._inputSource._dirtyRay = true;
+
+ // ray origin
+ // get point between thumb tip and index tip
+ this._inputSource._rayLocal.origin.lerp(j4._localPosition, j9._localPosition, 0.5);
+
+ // ray direction
+ var jointL = j1;
+ var jointR = j21;
+
+ if (this._inputSource.handedness === XRHAND_LEFT) {
+ var t = jointL;
+ jointL = jointR;
+ jointR = t;
+ }
+
+ // (A) calculate normal vector between 3 joints: wrist, thumb metacarpal, little phalanx proximal
+ vecA.sub2(jointL._localPosition, this._wrist._localPosition);
+ vecB.sub2(jointR._localPosition, this._wrist._localPosition);
+ vecC.cross(vecA, vecB).normalize();
+
+ // get point between: index phalanx proximal and right phalanx proximal
+ vecA.lerp(j6._localPosition, j16._localPosition, 0.5);
+ // (B) get vector between that point and a wrist
+ vecA.sub(this._wrist._localPosition).normalize();
+
+ // mix normal vector (A) with hand directional vector (B)
+ this._inputSource._rayLocal.direction.lerp(vecC, vecA, 0.5).normalize();
+ }
+
+ // emulate select events by touching thumb tip and index tips
+ if (j4 && j9) {
+ vecA.copy(j4._localPosition);
+ var d = vecA.distance(j9._localPosition);
+
+ if (d < 0.015) { // 15 mm
+ if (! this._inputSource._selecting) {
+ this._inputSource._selecting = true;
+ this._inputSource.fire('selectstart');
+ this._manager.input.fire('selectstart', this._inputSource);
+ }
+ } else {
+ if (this._inputSource._selecting) {
+ this._inputSource._selecting = false;
+
+ this._inputSource.fire('select');
+ this._manager.input.fire('select', this._inputSource);
+
+ this._inputSource.fire('selectend');
+ this._manager.input.fire('selectend', this._inputSource);
+ }
+ }
+ }
+};
+
+/**
+ * @function
+ * @name pc.XrHand#getJointById
+ * @description Returns joint by XRHand id from list in specs: https://immersive-web.github.io/webxr-hand-input/
+ * @param {number} id - id of a joint based on specs ID's in XRHand: https://immersive-web.github.io/webxr-hand-input/
+ * @returns {pc.XrJoint|null} Joint or null if not available
+ */
+XrHand.prototype.getJointById = function (id) {
+ return this._jointsById[id] || null;
+};
+
+Object.defineProperty(XrHand.prototype, 'fingers', {
+ get: function () {
+ return this._fingers;
+ }
+});
+
+Object.defineProperty(XrHand.prototype, 'joints', {
+ get: function () {
+ return this._joints;
+ }
+});
+
+Object.defineProperty(XrHand.prototype, 'tips', {
+ get: function () {
+ return this._tips;
+ }
+});
+
+Object.defineProperty(XrHand.prototype, 'wrist', {
+ get: function () {
+ return this._wrist;
+ }
+});
+
+Object.defineProperty(XrHand.prototype, 'tracking', {
+ get: function () {
+ return this._tracking;
+ }
+});
+
+export { XrHand };
diff --git a/src/xr/xr-input-source.js b/src/xr/xr-input-source.js
index e3d6376d8ec..7aa6fb5ad96 100644
--- a/src/xr/xr-input-source.js
+++ b/src/xr/xr-input-source.js
@@ -6,6 +6,8 @@ import { Vec3 } from '../math/vec3.js';
import { Ray } from '../shape/ray.js';
+import { XrHand } from './xr-hand.js';
+
var quat = new Quat();
var ids = 0;
@@ -33,6 +35,7 @@ var ids = 0;
*
* @property {string[]} profiles List of input profile names indicating both the prefered visual representation and behavior of the input source.
* @property {boolean} grip If input source can be held, then it will have node with its world transformation, that can be used to position and rotate virtual joystics based on it.
+ * @property {pc.XrHand|null} hand If input source is a tracked hand, then it will point to {@link pc.XrHand} otherwise it is null.
* @property {Gamepad|null} gamepad If input source has buttons, triggers, thumbstick or touchpad, then this object provides access to its states.
* @property {boolean} selecting True if input source is in active primary action between selectstart and selectend events.
* @property {boolean} elementInput Set to true to allow input source to interact with Element components. Defaults to true.
@@ -50,6 +53,10 @@ function XrInputSource(manager, xrInputSource) {
this._ray = new Ray();
this._rayLocal = new Ray();
this._grip = false;
+ this._hand = null;
+
+ if (xrInputSource.hand)
+ this._hand = new XrHand(this);
this._localTransform = null;
this._worldTransform = null;
@@ -145,34 +152,39 @@ XrInputSource.prototype.constructor = XrInputSource;
*/
XrInputSource.prototype.update = function (frame) {
- var targetRayPose = frame.getPose(this._xrInputSource.targetRaySpace, this._manager._referenceSpace);
- if (! targetRayPose) return;
-
- // grip
- if (this._xrInputSource.gripSpace) {
- var gripPose = frame.getPose(this._xrInputSource.gripSpace, this._manager._referenceSpace);
- if (gripPose) {
- if (! this._grip) {
- this._grip = true;
-
- this._localTransform = new Mat4();
- this._worldTransform = new Mat4();
-
- this._localPosition = new Vec3();
- this._localRotation = new Quat();
+ // hand
+ if (this._hand) {
+ this._hand.update(frame);
+ } else {
+ // grip
+ if (this._xrInputSource.gripSpace) {
+ var gripPose = frame.getPose(this._xrInputSource.gripSpace, this._manager._referenceSpace);
+ if (gripPose) {
+ if (! this._grip) {
+ this._grip = true;
+
+ this._localTransform = new Mat4();
+ this._worldTransform = new Mat4();
+
+ this._localPosition = new Vec3();
+ this._localRotation = new Quat();
+ }
+ this._dirtyLocal = true;
+ this._localPosition.copy(gripPose.transform.position);
+ this._localRotation.copy(gripPose.transform.orientation);
}
- this._dirtyLocal = true;
- this._localPosition.copy(gripPose.transform.position);
- this._localRotation.copy(gripPose.transform.orientation);
}
- }
- // ray
- this._dirtyRay = true;
- this._rayLocal.origin.copy(targetRayPose.transform.position);
- this._rayLocal.direction.set(0, 0, -1);
- quat.copy(targetRayPose.transform.orientation);
- quat.transformVector(this._rayLocal.direction, this._rayLocal.direction);
+ // ray
+ var targetRayPose = frame.getPose(this._xrInputSource.targetRaySpace, this._manager._referenceSpace);
+ if (targetRayPose) {
+ this._dirtyRay = true;
+ this._rayLocal.origin.copy(targetRayPose.transform.position);
+ this._rayLocal.direction.set(0, 0, -1);
+ quat.copy(targetRayPose.transform.orientation);
+ quat.transformVector(this._rayLocal.direction, this._rayLocal.direction);
+ }
+ }
};
XrInputSource.prototype._updateTransforms = function () {
@@ -187,11 +199,7 @@ XrInputSource.prototype._updateTransforms = function () {
var parent = this._manager.camera.parent;
if (parent) {
dirty = dirty || parent._dirtyLocal || parent._dirtyWorld;
-
- if (dirty) {
- var parentTransform = this._manager.camera.parent.getWorldTransform();
- this._worldTransform.mul2(parentTransform, this._localTransform);
- }
+ if (dirty) this._worldTransform.mul2(parent.getWorldTransform(), this._localTransform);
} else {
this._worldTransform.copy(this._localTransform);
}
@@ -398,6 +406,12 @@ Object.defineProperty(XrInputSource.prototype, 'grip', {
}
});
+Object.defineProperty(XrInputSource.prototype, 'hand', {
+ get: function () {
+ return this._hand;
+ }
+});
+
Object.defineProperty(XrInputSource.prototype, 'gamepad', {
get: function () {
return this._xrInputSource.gamepad || null;
diff --git a/src/xr/xr-joint.js b/src/xr/xr-joint.js
new file mode 100644
index 00000000000..a8989368745
--- /dev/null
+++ b/src/xr/xr-joint.js
@@ -0,0 +1,156 @@
+import { Mat4 } from '../math/mat4.js';
+import { Quat } from '../math/quat.js';
+import { Vec3 } from '../math/vec3.js';
+
+var tipJointIds = window.XRHand ? [
+ XRHand.THUMB_PHALANX_TIP,
+ XRHand.INDEX_PHALANX_TIP,
+ XRHand.MIDDLE_PHALANX_TIP,
+ XRHand.RING_PHALANX_TIP,
+ XRHand.LITTLE_PHALANX_TIP
+] : [];
+
+var tipJointIdsIndex = {};
+
+for (var i = 0; i < tipJointIds.length; i++) {
+ tipJointIdsIndex[tipJointIds[i]] = true;
+}
+
+/**
+ * @class
+ * @name pc.XrJoint
+ * @classdesc Represents joint of a finger
+ * @description Represents joint of a finger
+ * @param {number} index - Index of a joint within a finger
+ * @param {number} id - Id of a joint based on XRHand specs
+ * @param {pc.XrHand} hand - Hand that joint relates to
+ * @param {pc.XrFinger} [finger] - Finger that joint is related to, can be null in case of wrist joint
+ * @property {number} index Index of a joint within a finger, starting from 0 (root of a finger) all the way to tip of the finger
+ * @property {pc.XrHand} hand Hand that joint relates to
+ * @property {pc.XrFinger|null} finger Finger that joint relates to
+ * @property {boolean} wrist True if joint is a wrist
+ * @property {boolean} tip True if joint is a tip of a finger
+ * @property {number} radius The radius of a joint, which is a distance from joint to the edge of a skin
+ */
+function XrJoint(index, id, hand, finger) {
+ this._index = index;
+ this._id = id;
+
+ this._hand = hand;
+ this._hand._joints.push(this);
+ this._hand._jointsById[id] = this;
+
+ this._finger = finger || null;
+ if (this._finger) this._finger._joints.push(this);
+
+ this._wrist = id === XRHand.WRIST;
+ if (this._wrist) this._hand._wrist = this;
+
+ this._tip = this._finger && !! tipJointIdsIndex[id];
+ if (this._tip) {
+ this._hand._tips.push(this);
+ if (this._finger) this._finger._tip = this;
+ }
+
+ this._radius = null;
+
+ this._localTransform = new Mat4();
+ this._worldTransform = new Mat4();
+
+ this._localPosition = new Vec3();
+ this._localRotation = new Quat();
+
+ this._position = new Vec3();
+ this._rotation = new Quat();
+
+ this._dirtyLocal = true;
+}
+
+XrJoint.prototype.update = function (pose) {
+ this._dirtyLocal = true;
+ this._radius = pose.radius;
+ this._localPosition.copy(pose.transform.position);
+ this._localRotation.copy(pose.transform.orientation);
+};
+
+XrJoint.prototype._updateTransforms = function () {
+ var dirty;
+
+ if (this._dirtyLocal) {
+ dirty = true;
+ this._dirtyLocal = false;
+ this._localTransform.setTRS(this._localPosition, this._localRotation, Vec3.ONE);
+ }
+
+ var manager = this._hand._manager;
+ var parent = manager.camera.parent;
+
+ if (parent) {
+ dirty = dirty || parent._dirtyLocal || parent._dirtyWorld;
+ if (dirty) this._worldTransform.mul2(parent.getWorldTransform(), this._localTransform);
+ } else {
+ this._worldTransform.copy(this._localTransform);
+ }
+};
+
+/**
+ * @function
+ * @name pc.XrJoint#getPosition
+ * @description Get the world space position of a joint
+ * @returns {pc.Vec3} The world space position of a joint
+ */
+XrJoint.prototype.getPosition = function () {
+ this._updateTransforms();
+ this._worldTransform.getTranslation(this._position);
+ return this._position;
+};
+
+/**
+ * @function
+ * @name pc.XrJoint#getRotation
+ * @description Get the world space rotation of a joint
+ * @returns {pc.Quat} The world space rotation of a joint
+ */
+XrJoint.prototype.getRotation = function () {
+ this._updateTransforms();
+ this._rotation.setFromMat4(this._worldTransform);
+ return this._rotation;
+};
+
+Object.defineProperty(XrJoint.prototype, 'index', {
+ get: function () {
+ return this._index;
+ }
+});
+
+Object.defineProperty(XrJoint.prototype, 'hand', {
+ get: function () {
+ return this._hand;
+ }
+});
+
+Object.defineProperty(XrJoint.prototype, 'finger', {
+ get: function () {
+ return this._finger;
+ }
+});
+
+Object.defineProperty(XrJoint.prototype, 'wrist', {
+ get: function () {
+ return this._wrist;
+ }
+});
+
+Object.defineProperty(XrJoint.prototype, 'tip', {
+ get: function () {
+ return this._tip;
+ }
+});
+
+Object.defineProperty(XrJoint.prototype, 'radius', {
+ get: function () {
+ return this._radius || 0.005;
+ }
+});
+
+export { XrJoint };
diff --git a/src/xr/xr-manager.js b/src/xr/xr-manager.js
index fd0af7717f3..d286a06a698 100644
--- a/src/xr/xr-manager.js
+++ b/src/xr/xr-manager.js
@@ -193,6 +193,9 @@ XrManager.prototype.start = function (camera, type, spaceType, callback) {
if (type === XRTYPE_AR)
optionalFeatures.push('light-estimation');
+ if (type === XRTYPE_VR)
+ optionalFeatures.push('hand-tracking');
+
navigator.xr.requestSession(type, {
requiredFeatures: [spaceType],
optionalFeatures: optionalFeatures