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