Selaa lähdekoodia

Merge branch 'master' of https://github.com/BabylonJS/Babylon.js

David `Deltakosh` Catuhe 5 vuotta sitten
vanhempi
commit
69f6340bfa

+ 48 - 49
Playground/src/components/examplesComponent.tsx

@@ -1,5 +1,5 @@
 import * as React from "react";
-import { GlobalState } from '../globalState';
+import { GlobalState } from "../globalState";
 
 require("../scss/examples.scss");
 
@@ -7,7 +7,7 @@ interface IExamplesComponentProps {
     globalState: GlobalState;
 }
 
-export class ExamplesComponent extends React.Component<IExamplesComponentProps, {filter: string}> {  
+export class ExamplesComponent extends React.Component<IExamplesComponentProps, { filter: string }> {
     private _state = "removed";
     private _rootRef: React.RefObject<HTMLDivElement>;
     private _scripts: {
@@ -19,13 +19,13 @@ export class ExamplesComponent extends React.Component<IExamplesComponentProps,
             PGID: string;
             description: string;
         }[];
-    }[];  
-  
+    }[];
+
     public constructor(props: IExamplesComponentProps) {
         super(props);
         this._loadScripts();
 
-        this.state = {filter: ""};
+        this.state = { filter: "" };
         this._rootRef = React.createRef();
 
         this.props.globalState.onExamplesDisplayChangedObservable.add(() => {
@@ -40,18 +40,18 @@ export class ExamplesComponent extends React.Component<IExamplesComponentProps,
                 this._state = "";
                 setTimeout(() => {
                     this._rootRef.current!.classList.add("removed");
-                }, 200)
+                }, 200);
             }
         });
-    }  
+    }
 
     private _loadScripts() {
         var xhr = new XMLHttpRequest();
 
         if (this.props.globalState.language === "JS") {
-            xhr.open('GET', 'https://raw.githubusercontent.com/BabylonJS/Documentation/master/examples/list.json', true);
+            xhr.open("GET", "https://raw.githubusercontent.com/BabylonJS/Documentation/master/examples/list.json", true);
         } else {
-            xhr.open('GET', 'https://raw.githubusercontent.com/BabylonJS/Documentation/master/examples/list_ts.json', true);
+            xhr.open("GET", "https://raw.githubusercontent.com/BabylonJS/Documentation/master/examples/list_ts.json", true);
         }
 
         xhr.onreadystatechange = () => {
@@ -66,7 +66,7 @@ export class ExamplesComponent extends React.Component<IExamplesComponentProps,
                         return 1;
                     });
 
-                    this._scripts.forEach(s => {
+                    this._scripts.forEach((s) => {
                         s.samples.sort((a, b) => {
                             if (a.title < b.title) {
                                 return -1;
@@ -78,12 +78,11 @@ export class ExamplesComponent extends React.Component<IExamplesComponentProps,
                     this.forceUpdate();
                 }
             }
-        }
+        };
 
         xhr.send(null);
     }
 
-
     private _onLoadPG(id: string) {
         this.props.globalState.onLoadRequiredObservable.notifyObservers(id);
 
@@ -101,46 +100,46 @@ export class ExamplesComponent extends React.Component<IExamplesComponentProps,
             <div id="examples" className={this._state} ref={this._rootRef}>
                 <div id="examples-header">Examples</div>
                 <div id="examples-filter">
-                    <input id="examples-filter-text" type="text" placeholder="Filter examples" value={this.state.filter} onChange={evt => {
-                        this.setState({filter: evt.target.value});
-                    }}/>
+                    <input
+                        id="examples-filter-text"
+                        type="text"
+                        placeholder="Filter examples"
+                        value={this.state.filter}
+                        onChange={(evt) => {
+                            this.setState({ filter: evt.target.value });
+                        }}
+                    />
                 </div>
                 <div id="examples-list">
-                    {
-                        this._scripts.map(s => {
-                            let active = s.samples.filter(ss => {
-                                return !this.state.filter 
-                                    || ss.title.toLowerCase().indexOf(this.state.filter.toLowerCase()) !== -1
-                                    || ss.description.toLowerCase().indexOf(this.state.filter.toLowerCase()) !== -1
-                            });
-
-                            if (active.length === 0) {
-                                return null;
-                            }
+                    {this._scripts.map((s) => {
+                        let active = s.samples.filter((ss) => {
+                            return !this.state.filter || ss.title.toLowerCase().indexOf(this.state.filter.toLowerCase()) !== -1 || ss.description.toLowerCase().indexOf(this.state.filter.toLowerCase()) !== -1;
+                        });
+
+                        if (active.length === 0) {
+                            return null;
+                        }
 
-                            return(
-                                <div key={s.title} className="example-category">
-                                    <div className="example-category-title">
-                                        {s.title}
-                                    </div>
-                                    {
-                                        active.map(ss => {
-                                            return (
-                                                <div className="example" key={ss.title} onClick={() => this._onLoadPG(ss.PGID)}>
-                                                    <img src={ss.icon.replace("icons", "https://doc.babylonjs.com/examples/icons")}/>
-                                                    <div className="example-title">{ss.title}</div>
-                                                    <div className="example-description">{ss.description}</div>
-                                                    <a className="example-link" href={ss.doc} target="_blank">Documentation</a>
-                                                </div>
-                                            )
-                                        })
-                                    }
-                                </div>
-                            )
-                        })
-                    }
+                        return (
+                            <div key={s.title} className="example-category">
+                                <div className="example-category-title">{s.title}</div>
+                                {active.map((ss) => {
+                                    return (
+                                        <div className="example" key={ss.title} onClick={() => this._onLoadPG(ss.PGID)}>
+                                            <img src={ss.icon.replace("icons", "https://doc.babylonjs.com/examples/icons")} />
+                                            <div className="example-title">{ss.title}</div>
+                                            <div className="example-description">{ss.description}</div>
+                                            <a className="example-link" href={ss.doc} target="_blank">
+                                                Documentation
+                                            </a>
+                                        </div>
+                                    );
+                                })}
+                            </div>
+                        );
+                    })}
                 </div>
             </div>
-        )
+        );
     }
-}
+}

+ 24 - 18
Playground/src/tools/loadManager.ts

@@ -1,22 +1,22 @@
-import { GlobalState } from '../globalState';
-import { Utilities } from './utilities';
+import { GlobalState } from "../globalState";
+import { Utilities } from "./utilities";
 
 export class LoadManager {
     private _previousHash = "";
 
-    public constructor(public globalState: GlobalState) {  
-        // Check the url to prepopulate data        
+    public constructor(public globalState: GlobalState) {
+        // Check the url to prepopulate data
         this._checkHash();
         window.addEventListener("hashchange", () => this._checkHash());
 
-        globalState.onLoadRequiredObservable.add(id => {
+        globalState.onLoadRequiredObservable.add((id) => {
             globalState.onDisplayWaitRingObservable.notifyObservers(true);
             this._loadPlayground(id);
         });
     }
 
     private _cleanHash() {
-        var substr = location.hash[1]==='#' ? 2 : 1
+        var substr = location.hash[1] === "#" ? 2 : 1;
         var splits = decodeURIComponent(location.hash.substr(substr)).split("#");
 
         if (splits.length > 2) {
@@ -24,14 +24,14 @@ export class LoadManager {
         }
 
         location.hash = splits.join("#");
-    };
+    }
 
     private _checkHash() {
         let pgHash = "";
-        if (location.search && (!location.pathname  || location.pathname === '/') && !location.hash) {
+        if (location.search && (!location.pathname || location.pathname === "/") && !location.hash) {
             var query = Utilities.ParseQuery();
             if (query.pg) {
-                pgHash = "#" + query.pg + "#" + (query.revision || "0")
+                pgHash = "#" + query.pg + "#" + (query.revision || "0");
             }
         } else if (location.hash) {
             if (this._previousHash !== location.hash) {
@@ -52,27 +52,27 @@ export class LoadManager {
         if (pgHash) {
             var match = pgHash.match(/^(#[A-Za-z\d]*)(%23)([\d]+)$/);
             if (match) {
-                pgHash = match[1] + '#' + match[3];
+                pgHash = match[1] + "#" + match[3];
                 parent.location.hash = pgHash;
             }
             this._previousHash = pgHash;
             this._loadPlayground(pgHash.substr(1));
-        }        
+        }
     }
 
-    private _loadPlayground(id: string) {        
+    private _loadPlayground(id: string) {
         this.globalState.loadingCodeInProgress = true;
         try {
             var xmlHttp = new XMLHttpRequest();
             xmlHttp.onreadystatechange = () => {
                 if (xmlHttp.readyState === 4) {
                     if (xmlHttp.status === 200) {
-
                         if (xmlHttp.responseText.indexOf("class Playground") !== -1) {
                             if (this.globalState.language === "JS") {
                                 Utilities.SwitchLanguage("TS", this.globalState);
                             }
-                        } else { // If we're loading JS content and it's TS page
+                        } else {
+                            // If we're loading JS content and it's TS page
                             if (this.globalState.language === "TS") {
                                 Utilities.SwitchLanguage("JS", this.globalState);
                             }
@@ -100,20 +100,26 @@ export class LoadManager {
                         }
 
                         this.globalState.onCodeLoaded.notifyObservers(JSON.parse(snippet.jsonPayload).code.toString());
-                         
+
                         this.globalState.onMetadataUpdatedObservable.notifyObservers();
                     }
                 }
+            };
+
+            if (id[0] === "#") {
+                id = id.substr(1);
             }
 
             this.globalState.currentSnippetToken = id.split("#")[0];
-            if (!id.split("#")[1]) id += "#0";
+            if (!id.split("#")[1]) {
+                id += "#0";
+            }
 
-            xmlHttp.open("GET", this.globalState.SnippetServerUrl + "/" + id.replace("#", "/"));
+            xmlHttp.open("GET", this.globalState.SnippetServerUrl + "/" + id.replace(/#/g, "/"));
             xmlHttp.send();
         } catch (e) {
             this.globalState.loadingCodeInProgress = false;
             this.globalState.onCodeLoaded.notifyObservers("");
         }
     }
-}
+}

+ 3 - 1
dist/preview release/what's new.md

@@ -9,7 +9,7 @@
 - Reflection probes can now be used to give accurate shading with PBR ([CraigFeldpsar](https://github.com/craigfeldspar) and ([Sebavan](https://github.com/sebavan/)))
 - Added SubSurfaceScattering on PBR materials ([CraigFeldpsar](https://github.com/craigfeldspar) and ([Sebavan](https://github.com/sebavan/)))
 - Added editing of PBR materials, Post processes and Particle fragment shaders in the node material editor ([Popov72](https://github.com/Popov72))
-- Added Curve editor to manage selected entity's animations and edit animation groups in Inspector ([pixelspace](https://github.com/devpixelspace))
+- Added Curve editor to manage entity's animations and edit animation groups in Inspector ([pixelspace](https://github.com/devpixelspace))
 - Added support in `ShadowGenerator` for fast fake soft transparent shadows ([Popov72](https://github.com/Popov72))
 - Added support for **thin instances** for faster mesh instances. [Doc](https://doc.babylonjs.com/how_to/how_to_use_thininstances) ([Popov72](https://github.com/Popov72))
 
@@ -172,6 +172,8 @@
 - Optional camera gaze mode added to the pointer selection feature ([RaananW](https://github.com/RaananW))
 - Exposing feature points when running on top of BabylonNative ([Alex-MSFT](https://github.com/Alex-MSFT))
 - WebXR hit test can now define different entity type for the results ([#8687](https://github.com/BabylonJS/Babylon.js/issues/8687)) ([RaananW](https://github.com/RaananW))
+- Expose the overlay to which the XR Enter/Exit buttons are added to ([#8754](https://github.com/BabylonJS/Babylon.js/issues/8754)) ([RaananW](https://github.com/RaananW))
+- WebXR hand-tracking module is available, able to track hand-joints on selected devices including physics interactions ([RaananW](https://github.com/RaananW))
 
 ### Collisions
 

+ 4 - 4
src/Animations/animation.ts

@@ -1029,10 +1029,10 @@ export class Animation {
             switch (dataType) {
                 case Animation.ANIMATIONTYPE_FLOAT:
                     key.values = [animationKey.value];
-                    if (animationKey.inTangent) {
+                    if (animationKey.inTangent !== undefined) {
                         key.values.push(animationKey.inTangent);
                     }
-                    if (animationKey.outTangent) {
+                    if (animationKey.outTangent !== undefined) {
                         if (animationKey.inTangent === undefined) {
                             key.values.push(undefined);
                         }
@@ -1045,10 +1045,10 @@ export class Animation {
                 case Animation.ANIMATIONTYPE_COLOR3:
                 case Animation.ANIMATIONTYPE_COLOR4:
                     key.values = animationKey.value.asArray();
-                    if (animationKey.inTangent) {
+                    if (animationKey.inTangent != undefined) {
                         key.values.push(animationKey.inTangent.asArray());
                     }
-                    if (animationKey.outTangent) {
+                    if (animationKey.outTangent != undefined) {
                         if (animationKey.inTangent === undefined) {
                             key.values.push(undefined);
                         }

+ 1 - 1
src/Behaviors/Meshes/pointerDragBehavior.ts

@@ -246,8 +246,8 @@ export class PointerDragBehavior implements Behavior<AbstractMesh> {
      */
     public releaseDrag() {
         if (this.dragging) {
-            this.onDragEndObservable.notifyObservers({ dragPlanePoint: this.lastDragPosition, pointerId: this.currentDraggingPointerID });
             this.dragging = false;
+            this.onDragEndObservable.notifyObservers({ dragPlanePoint: this.lastDragPosition, pointerId: this.currentDraggingPointerID });
         }
 
         this.currentDraggingPointerID = -1;

+ 65 - 59
src/LibDeclarations/webxr.d.ts

@@ -1,56 +1,20 @@
-type XRSessionMode =
-    | "inline"
-    | "immersive-vr"
-    | "immersive-ar";
-
-type XRReferenceSpaceType =
-    | "viewer"
-    | "local"
-    | "local-floor"
-    | "bounded-floor"
-    | "unbounded";
-
-type XREnvironmentBlendMode =
-    | "opaque"
-    | "additive"
-    | "alpha-blend";
-
-type XRVisibilityState =
-    | "visible"
-    | "visible-blurred"
-    | "hidden";
-
-type XRHandedness =
-    | "none"
-    | "left"
-    | "right";
-
-type XRTargetRayMode =
-    | "gaze"
-    | "tracked-pointer"
-    | "screen";
-
-type XREye =
-    | "none"
-    | "left"
-    | "right";
-
-type XREventType =
-    | "devicechange"
-    | "visibilitychange"
-    | "end"
-    | "inputsourceschange"
-    | "select"
-    | "selectstart"
-    | "selectend"
-    | "squeeze"
-    | "squeezestart"
-    | "squeezeend"
-    | "reset";
-
-interface XRSpace extends EventTarget {
+type XRSessionMode = "inline" | "immersive-vr" | "immersive-ar";
 
-}
+type XRReferenceSpaceType = "viewer" | "local" | "local-floor" | "bounded-floor" | "unbounded";
+
+type XREnvironmentBlendMode = "opaque" | "additive" | "alpha-blend";
+
+type XRVisibilityState = "visible" | "visible-blurred" | "hidden";
+
+type XRHandedness = "none" | "left" | "right";
+
+type XRTargetRayMode = "gaze" | "tracked-pointer" | "screen";
+
+type XREye = "none" | "left" | "right";
+
+type XREventType = "devicechange" | "visibilitychange" | "end" | "inputsourceschange" | "select" | "selectstart" | "selectend" | "squeeze" | "squeezestart" | "squeezeend" | "reset";
+
+interface XRSpace extends EventTarget {}
 
 interface XRRenderState {
     depthNear?: number;
@@ -66,6 +30,7 @@ interface XRInputSource {
     gripSpace: XRSpace | undefined;
     gamepad: Gamepad | undefined;
     profiles: Array<string>;
+    hand: XRHand | undefined;
 }
 
 interface XRSessionInit {
@@ -91,9 +56,7 @@ interface XRSession {
     requestHitTest(ray: XRRay, referenceSpace: XRReferenceSpace): Promise<XRHitResult[]>;
 
     // legacy plane detection
-    updateWorldTrackingState(options: {
-        planeDetectionState?: { enabled: boolean; }
-    }): void;
+    updateWorldTrackingState(options: { planeDetectionState?: { enabled: boolean } }): void;
 }
 
 interface XRReferenceSpace extends XRSpace {
@@ -110,7 +73,7 @@ interface XRFrame {
     getPose(space: XRSpace, baseSpace: XRSpace): XRPose | undefined;
 
     // AR
-    getHitTestResults(hitTestSource: XRHitTestSource): Array<XRHitTestResult> ;
+    getHitTestResults(hitTestSource: XRHitTestSource): Array<XRHitTestResult>;
     getHitTestResultsForTransientInput(hitTestSource: XRTransientInputHitTestSource): Array<XRTransientInputHitTestResult>;
     // Anchors
     trackedAnchors?: XRAnchorSet;
@@ -119,6 +82,8 @@ interface XRFrame {
     worldInformation: {
         detectedPlanes?: XRPlaneSet;
     };
+    // Hand tracking
+    getJointPose(joint: XRJointSpace, baseSpace: XRSpace): XRJointPose;
 }
 
 interface XRViewerPose extends XRPose {
@@ -141,7 +106,7 @@ interface XRWebGLLayerOptions {
 
 declare var XRWebGLLayer: {
     prototype: XRWebGLLayer;
-    new(session: XRSession, context: WebGLRenderingContext | undefined, options?: XRWebGLLayerOptions): XRWebGLLayer;
+    new (session: XRSession, context: WebGLRenderingContext | undefined, options?: XRWebGLLayerOptions): XRWebGLLayer;
 };
 interface XRWebGLLayer {
     framebuffer: WebGLFramebuffer;
@@ -186,7 +151,7 @@ declare class XRRay {
 declare enum XRHitTestTrackableType {
     "point",
     "plane",
-    "mesh"
+    "mesh",
 }
 
 interface XRHitResult {
@@ -234,4 +199,45 @@ interface XRPlane {
     planeSpace: XRSpace;
     polygon: Array<DOMPointReadOnly>;
     lastChangedTime: number;
-}
+}
+
+interface XRJointSpace extends XRSpace {}
+
+interface XRJointPose extends XRPose {
+    radius: number | undefined;
+}
+
+declare class XRHand extends Array<XRJointSpace> {
+    readonly length: number;
+
+    static readonly WRIST = 0;
+
+    static readonly THUMB_METACARPAL = 1;
+    static readonly THUMB_PHALANX_PROXIMAL = 2;
+    static readonly THUMB_PHALANX_DISTAL = 3;
+    static readonly THUMB_PHALANX_TIP = 4;
+
+    static readonly INDEX_METACARPAL = 5;
+    static readonly INDEX_PHALANX_PROXIMAL = 6;
+    static readonly INDEX_PHALANX_INTERMEDIATE = 7;
+    static readonly INDEX_PHALANX_DISTAL = 8;
+    static readonly INDEX_PHALANX_TIP = 9;
+
+    static readonly MIDDLE_METACARPAL = 10;
+    static readonly MIDDLE_PHALANX_PROXIMAL = 11;
+    static readonly MIDDLE_PHALANX_INTERMEDIATE = 12;
+    static readonly MIDDLE_PHALANX_DISTAL = 13;
+    static readonly MIDDLE_PHALANX_TIP = 14;
+
+    static readonly RING_METACARPAL = 15;
+    static readonly RING_PHALANX_PROXIMAL = 16;
+    static readonly RING_PHALANX_INTERMEDIATE = 17;
+    static readonly RING_PHALANX_DISTAL = 18;
+    static readonly RING_PHALANX_TIP = 19;
+
+    static readonly LITTLE_METACARPAL = 20;
+    static readonly LITTLE_PHALANX_PROXIMAL = 21;
+    static readonly LITTLE_PHALANX_INTERMEDIATE = 22;
+    static readonly LITTLE_PHALANX_DISTAL = 23;
+    static readonly LITTLE_PHALANX_TIP = 24;
+}

+ 4 - 0
src/XR/features/WebXRControllerPointerSelection.ts

@@ -85,6 +85,10 @@ export class WebXRControllerPointerSelection extends WebXRAbstractFeature {
             // already attached
             return;
         }
+        // For now no support for hand-tracked input sources!
+        if (xrController.inputSource.hand && !xrController.inputSource.gamepad) {
+            return;
+        }
         // only support tracker pointer
         const { laserPointer, selectionMesh } = this._generateNewMeshPair(xrController.pointer);
 

+ 1 - 1
src/XR/features/WebXRControllerTeleportation.ts

@@ -343,7 +343,7 @@ export class WebXRMotionControllerTeleportation extends WebXRAbstractFeature {
             }
             targetMesh.rotationQuaternion = targetMesh.rotationQuaternion || new Quaternion();
             const controllerData = this._controllers[this._currentTeleportationControllerId];
-            if (controllerData.teleportationState.forward) {
+            if (controllerData && controllerData.teleportationState.forward) {
                 // set the rotation
                 Quaternion.RotationYawPitchRollToRef(controllerData.teleportationState.currentRotation + controllerData.teleportationState.baseRotation, 0, 0, targetMesh.rotationQuaternion);
                 // set the ray and position

+ 366 - 0
src/XR/features/WebXRHandTracking.ts

@@ -0,0 +1,366 @@
+import { WebXRAbstractFeature } from "./WebXRAbstractFeature";
+import { WebXRSessionManager } from "../webXRSessionManager";
+import { WebXRFeatureName } from "../webXRFeaturesManager";
+import { AbstractMesh } from "../../Meshes/abstractMesh";
+import { Mesh } from "../../Meshes/mesh";
+import { SphereBuilder } from "../../Meshes/Builders/sphereBuilder";
+import { WebXRInput } from "../webXRInput";
+import { WebXRInputSource } from "../webXRInputSource";
+import { Quaternion } from "../../Maths/math.vector";
+import { Nullable } from "../../types";
+import { PhysicsImpostor } from "../../Physics/physicsImpostor";
+import { WebXRFeaturesManager } from "../webXRFeaturesManager";
+import { IDisposable } from "../../scene";
+import { Observable } from "../../Misc/observable";
+
+/**
+ * Configuration interface for the hand tracking feature
+ */
+export interface IWebXRHandTrackingOptions {
+    /**
+     * The xrInput that will be used as source for new hands
+     */
+    xrInput: WebXRInput;
+
+    /**
+     * Configuration object for the joint meshes
+     */
+    jointMeshes?: {
+        /**
+         * Should the meshes created be invisible (defaults to false)
+         */
+        invisible?: boolean;
+        /**
+         * A source mesh to be used to create instances. Defaults to a sphere.
+         * This mesh will be the source for all other (25) meshes.
+         * It should have the general size of a single unit, as the instances will be scaled according to the provided radius
+         */
+        sourceMesh?: Mesh;
+        /**
+         * Should the source mesh stay visible. Defaults to false
+         */
+        keepOriginalVisible?: boolean;
+        /**
+         * Scale factor for all instances (defaults to 2)
+         */
+        scaleFactor?: number;
+        /**
+         * Should each instance have its own physics impostor
+         */
+        enablePhysics?: boolean;
+        /**
+         * If enabled, override default physics properties
+         */
+        physicsProps?: { friction?: number; restitution?: number; impostorType?: number };
+        /**
+         * For future use - a single hand-mesh that will be updated according to the XRHand data provided
+         */
+        handMesh?: AbstractMesh;
+    };
+}
+
+/**
+ * Parts of the hands divided to writs and finger names
+ */
+export const enum HandPart {
+    /**
+     * HandPart - Wrist
+     */
+    WRIST = "wrist",
+    /**
+     * HandPart - The THumb
+     */
+    THUMB = "thumb",
+    /**
+     * HandPart - Index finger
+     */
+    INDEX = "index",
+    /**
+     * HandPart - Middle finger
+     */
+    MIDDLE = "middle",
+    /**
+     * HandPart - Ring finger
+     */
+    RING = "ring",
+    /**
+     * HandPart - Little finger
+     */
+    LITTLE = "little",
+}
+
+/**
+ * Representing a single hand (with its corresponding native XRHand object)
+ */
+export class WebXRHand implements IDisposable {
+    /**
+     * Hand-parts definition (key is HandPart)
+     */
+    public static HandPartsDefinition: { [key: string]: number[] };
+
+    /**
+     * Populate the HandPartsDefinition object.
+     * This is called as a side effect since certain browsers don't have XRHand defined.
+     */
+    public static _PopulateHandPartsDefinition() {
+        if (typeof XRHand !== "undefined") {
+            WebXRHand.HandPartsDefinition = {
+                [HandPart.WRIST]: [XRHand.WRIST],
+                [HandPart.THUMB]: [XRHand.THUMB_METACARPAL, XRHand.THUMB_PHALANX_PROXIMAL, XRHand.THUMB_PHALANX_DISTAL, XRHand.THUMB_PHALANX_TIP],
+                [HandPart.INDEX]: [XRHand.INDEX_METACARPAL, XRHand.INDEX_PHALANX_PROXIMAL, XRHand.INDEX_PHALANX_INTERMEDIATE, XRHand.INDEX_PHALANX_DISTAL, XRHand.INDEX_PHALANX_TIP],
+                [HandPart.MIDDLE]: [XRHand.MIDDLE_METACARPAL, XRHand.MIDDLE_PHALANX_PROXIMAL, XRHand.MIDDLE_PHALANX_INTERMEDIATE, XRHand.MIDDLE_PHALANX_DISTAL, XRHand.MIDDLE_PHALANX_TIP],
+                [HandPart.RING]: [XRHand.RING_METACARPAL, XRHand.RING_PHALANX_PROXIMAL, XRHand.RING_PHALANX_INTERMEDIATE, XRHand.RING_PHALANX_DISTAL, XRHand.RING_PHALANX_TIP],
+                [HandPart.LITTLE]: [XRHand.LITTLE_METACARPAL, XRHand.LITTLE_PHALANX_PROXIMAL, XRHand.LITTLE_PHALANX_INTERMEDIATE, XRHand.LITTLE_PHALANX_DISTAL, XRHand.LITTLE_PHALANX_TIP],
+            };
+        }
+    }
+
+    /**
+     * Construct a new hand object
+     * @param xrController the controller to which the hand correlates
+     * @param trackedMeshes the meshes to be used to track the hand joints
+     */
+    constructor(
+        /** the controller to which the hand correlates */
+        public readonly xrController: WebXRInputSource,
+        /** the meshes to be used to track the hand joints */
+        public readonly trackedMeshes: AbstractMesh[]) {}
+
+    /**
+     * Update this hand from the latest xr frame
+     * @param xrFrame xrFrame to update from
+     * @param referenceSpace The current viewer reference space
+     * @param scaleFactor optional scale factor for the meshes
+     */
+    public updateFromXRFrame(xrFrame: XRFrame, referenceSpace: XRReferenceSpace, scaleFactor: number = 2) {
+        const hand = this.xrController.inputSource.hand as XRJointSpace[];
+        if (!hand) {
+            return;
+        }
+        this.trackedMeshes.forEach((mesh, idx) => {
+            const xrJoint = hand[idx];
+            if (xrJoint) {
+                let pose = xrFrame.getJointPose(xrJoint, referenceSpace);
+                if (!pose || !pose.transform) {
+                    return;
+                }
+                // get the transformation. can be done with matrix decomposition as well
+                const pos = pose.transform.position;
+                const orientation = pose.transform.orientation;
+                mesh.position.set(pos.x, pos.y, pos.z);
+                mesh.rotationQuaternion!.set(orientation.x, orientation.y, orientation.z, orientation.w);
+                // left handed system conversion
+                if (!mesh.getScene().useRightHandedSystem) {
+                    mesh.position.z *= -1;
+                    mesh.rotationQuaternion!.z *= -1;
+                    mesh.rotationQuaternion!.w *= -1;
+                }
+                // get the radius of the joint. In general it is static, but just in case it does change we update it on each frame.
+                const radius = (pose.radius || 0.008) * scaleFactor;
+                mesh.scaling.set(radius, radius, radius);
+            }
+        });
+    }
+
+    /**
+     * Get meshes of part of the hand
+     * @param part the part of hand to get
+     * @returns An array of meshes that correlate to the hand part requested
+     */
+    public getHandPartMeshes(part: HandPart): AbstractMesh[] {
+        return WebXRHand.HandPartsDefinition[part].map((idx) => this.trackedMeshes[idx]);
+    }
+
+    /**
+     * Dispose this Hand object
+     */
+    public dispose() {
+        this.trackedMeshes.forEach((mesh) => mesh.dispose());
+    }
+}
+
+// Populate the hand parts definition
+WebXRHand._PopulateHandPartsDefinition();
+
+/**
+ * WebXR Hand Joint tracking feature, available for selected browsers and devices
+ */
+export class WebXRHandTracking extends WebXRAbstractFeature {
+    private static _idCounter = 0;
+    /**
+     * The module's name
+     */
+    public static readonly Name = WebXRFeatureName.HAND_TRACKING;
+    /**
+     * The (Babylon) version of this module.
+     * This is an integer representing the implementation version.
+     * This number does not correspond to the WebXR specs version
+     */
+    public static readonly Version = 1;
+
+    /**
+     * This observable will notify registered observers when a new hand object was added and initialized
+     */
+    public onHandAddedObservable: Observable<WebXRHand> = new Observable();
+    /**
+     * This observable will notify its observers right before the hand object is disposed
+     */
+    public onHandRemovedObservable: Observable<WebXRHand> = new Observable();
+
+    private _hands: {
+        [controllerId: string]: {
+            id: number;
+            handObject: WebXRHand;
+        };
+    } = {};
+
+    /**
+     * Creates a new instance of the hit test feature
+     * @param _xrSessionManager an instance of WebXRSessionManager
+     * @param options options to use when constructing this feature
+     */
+    constructor(
+        _xrSessionManager: WebXRSessionManager,
+        /**
+         * options to use when constructing this feature
+         */
+        public readonly options: IWebXRHandTrackingOptions
+    ) {
+        super(_xrSessionManager);
+        this.xrNativeFeatureName = "hand-tracking";
+    }
+
+    /**
+     * Check if the needed objects are defined.
+     * This does not mean that the feature is enabled, but that the objects needed are well defined.
+     */
+    public isCompatible(): boolean {
+        return typeof XRHand !== "undefined";
+    }
+
+    /**
+     * attach this feature
+     * Will usually be called by the features manager
+     *
+     * @returns true if successful.
+     */
+    public attach(): boolean {
+        if (!super.attach()) {
+            return false;
+        }
+        this.options.xrInput.controllers.forEach(this._attachHand);
+        this._addNewAttachObserver(this.options.xrInput.onControllerAddedObservable, this._attachHand);
+        this._addNewAttachObserver(this.options.xrInput.onControllerRemovedObservable, (controller) => {
+            // REMOVE the controller
+            this._detachHand(controller.uniqueId);
+        });
+
+        return true;
+    }
+
+    /**
+     * detach this feature.
+     * Will usually be called by the features manager
+     *
+     * @returns true if successful.
+     */
+    public detach(): boolean {
+        if (!super.detach()) {
+            return false;
+        }
+
+        Object.keys(this._hands).forEach((controllerId) => {
+            this._detachHand(controllerId);
+        });
+
+        return true;
+    }
+
+    /**
+     * Dispose this feature and all of the resources attached
+     */
+    public dispose(): void {
+        super.dispose();
+        this.onHandAddedObservable.clear();
+    }
+
+    /**
+     * Get the hand object according to the controller id
+     * @param controllerId the controller id to which we want to get the hand
+     * @returns null if not found or the WebXRHand object if found
+     */
+    public getHandByControllerId(controllerId: string): Nullable<WebXRHand> {
+        return this._hands[controllerId]?.handObject || null;
+    }
+
+    /**
+     * Get a hand object according to the requested handedness
+     * @param handedness the handedness to request
+     * @returns null if not found or the WebXRHand object if found
+     */
+    public getHandByHandedness(handedness: XRHandedness): Nullable<WebXRHand> {
+        const handednesses = Object.keys(this._hands).map((key) => this._hands[key].handObject.xrController.inputSource.handedness);
+        const found = handednesses.indexOf(handedness);
+        if (found !== -1) {
+            return this._hands[found].handObject;
+        }
+        return null;
+    }
+
+    protected _onXRFrame(_xrFrame: XRFrame): void {
+        // iterate over the hands object
+        Object.keys(this._hands).forEach((id) => {
+            this._hands[id].handObject.updateFromXRFrame(_xrFrame, this._xrSessionManager.referenceSpace, this.options.jointMeshes?.scaleFactor);
+        });
+    }
+
+    private _attachHand = (xrController: WebXRInputSource) => {
+        if (!xrController.inputSource.hand || this._hands[xrController.uniqueId]) {
+            // already attached
+            return;
+        }
+
+        const hand = xrController.inputSource.hand;
+        const trackedMeshes: AbstractMesh[] = [];
+        const originalMesh = this.options.jointMeshes?.sourceMesh || SphereBuilder.CreateSphere("jointParent", { diameter: 1 });
+        originalMesh.isVisible = !!this.options.jointMeshes?.keepOriginalVisible;
+        for (let i = 0; i < hand.length; ++i) {
+            const newInstance = originalMesh.createInstance(`${xrController.uniqueId}-handJoint-${i}`);
+            newInstance.isPickable = false;
+            if (this.options.jointMeshes?.enablePhysics) {
+                const props = this.options.jointMeshes.physicsProps || {};
+                const type = props.impostorType !== undefined ? props.impostorType : PhysicsImpostor.SphereImpostor;
+                newInstance.physicsImpostor = new PhysicsImpostor(newInstance, type, { mass: 0, ...props });
+            }
+            newInstance.rotationQuaternion = new Quaternion();
+            trackedMeshes.push(newInstance);
+        }
+
+        const webxrHand = new WebXRHand(xrController, trackedMeshes);
+
+        // get two new meshes
+        this._hands[xrController.uniqueId] = {
+            handObject: webxrHand,
+            id: WebXRHandTracking._idCounter++,
+        };
+
+        this.onHandAddedObservable.notifyObservers(webxrHand);
+    };
+
+    private _detachHand(controllerId: string) {
+        if (this._hands[controllerId]) {
+            this.onHandRemovedObservable.notifyObservers(this._hands[controllerId].handObject);
+            this._hands[controllerId].handObject.dispose();
+        }
+    }
+}
+
+//register the plugin
+WebXRFeaturesManager.AddWebXRFeature(
+    WebXRHandTracking.Name,
+    (xrSessionManager, options) => {
+        return () => new WebXRHandTracking(xrSessionManager, options);
+    },
+    WebXRHandTracking.Version,
+    false
+);

+ 1 - 0
src/XR/features/index.ts

@@ -7,3 +7,4 @@ export * from "./WebXRControllerPointerSelection";
 export * from "./WebXRControllerPhysics";
 export * from "./WebXRHitTest";
 export * from "./WebXRFeaturePointSystem";
+export * from "./WebXRHandTracking";

+ 6 - 6
src/XR/motionController/webXRAbstractMotionController.ts

@@ -186,7 +186,7 @@ export interface IMotionControllerMeshMap {
     /**
      * The mesh that will be changed when axis value changes
      */
-    valueMesh: AbstractMesh;
+    valueMesh?: AbstractMesh;
 }
 
 /**
@@ -431,13 +431,13 @@ export abstract class WebXRAbstractMotionController implements IDisposable {
     }
 
     // Look through all children recursively. This will return null if no mesh exists with the given name.
-    protected _getChildByName(node: AbstractMesh, name: string): AbstractMesh {
-        return <AbstractMesh>node.getChildren((n) => n.name === name, false)[0];
+    protected _getChildByName(node: AbstractMesh, name: string): AbstractMesh | undefined {
+        return <AbstractMesh | undefined>node.getChildren((n) => n.name === name, false)[0];
     }
 
     // Look through only immediate children. This will return null if no mesh exists with the given name.
-    protected _getImmediateChildByName(node: AbstractMesh, name: string): AbstractMesh {
-        return <AbstractMesh>node.getChildren((n) => n.name == name, true)[0];
+    protected _getImmediateChildByName(node: AbstractMesh, name: string): AbstractMesh | undefined {
+        return <AbstractMesh | undefined>node.getChildren((n) => n.name == name, true)[0];
     }
 
     /**
@@ -447,7 +447,7 @@ export abstract class WebXRAbstractMotionController implements IDisposable {
      * @hidden
      */
     protected _lerpTransform(axisMap: IMotionControllerMeshMap, axisValue: number, fixValueCoordinates?: boolean): void {
-        if (!axisMap.minMesh || !axisMap.maxMesh) {
+        if (!axisMap.minMesh || !axisMap.maxMesh || !axisMap.valueMesh) {
             return;
         }
 

+ 6 - 3
src/XR/motionController/webXRProfiledMotionController.ts

@@ -17,7 +17,7 @@ import { Logger } from "../../Misc/logger";
 export class WebXRProfiledMotionController extends WebXRAbstractMotionController {
     private _buttonMeshMapping: {
         [buttonName: string]: {
-            mainMesh: AbstractMesh;
+            mainMesh?: AbstractMesh;
             states: {
                 [state: string]: IMotionControllerMeshMap;
             };
@@ -89,7 +89,7 @@ export class WebXRProfiledMotionController extends WebXRAbstractMotionController
                         );
                         dot.material = new StandardMaterial(visualResponseKey + "mat", this.scene);
                         (<StandardMaterial>dot.material).diffuseColor = Color3.Red();
-                        dot.parent = this._buttonMeshMapping[type].states[visualResponseKey].valueMesh;
+                        dot.parent = this._buttonMeshMapping[type].states[visualResponseKey].valueMesh || null;
                         dot.isVisible = false;
                         this._touchDots[visualResponseKey] = dot;
                     }
@@ -145,7 +145,10 @@ export class WebXRProfiledMotionController extends WebXRAbstractMotionController
                     this._lerpTransform(meshes.states[visualResponseKey], value, visResponse.componentProperty !== "button");
                 } else {
                     // visibility
-                    meshes.states[visualResponseKey].valueMesh.isVisible = component.touched || component.pressed;
+                    const valueMesh = meshes.states[visualResponseKey].valueMesh;
+                    if (valueMesh) {
+                        valueMesh.isVisible = component.touched || component.pressed;
+                    }
                     if (this._touchDots[visualResponseKey]) {
                         this._touchDots[visualResponseKey].isVisible = component.touched || component.pressed;
                     }

+ 11 - 7
src/XR/webXREnterExitUI.ts

@@ -69,7 +69,10 @@ export class WebXREnterExitUIOptions {
 export class WebXREnterExitUI implements IDisposable {
     private _activeButton: Nullable<WebXREnterExitUIButton> = null;
     private _buttons: Array<WebXREnterExitUIButton> = [];
-    private _overlay: HTMLDivElement;
+    /**
+     * The HTML Div Element to which buttons are added.
+     */
+    public readonly overlay: HTMLDivElement;
 
     /**
      * Fired every time the active button is changed.
@@ -90,8 +93,9 @@ export class WebXREnterExitUI implements IDisposable {
         /** version of the options passed to this UI */
         public options: WebXREnterExitUIOptions
     ) {
-        this._overlay = document.createElement("div");
-        this._overlay.style.cssText = "z-index:11;position: absolute; right: 20px;bottom: 50px;";
+        this.overlay = document.createElement("div");
+        this.overlay.classList.add('xr-button-overlay');
+        this.overlay.style.cssText = "z-index:11;position: absolute; right: 20px;bottom: 50px;";
 
         // if served over HTTP, warn people.
         // Hopefully the browsers will catch up
@@ -131,7 +135,7 @@ export class WebXREnterExitUI implements IDisposable {
 
         var renderCanvas = scene.getEngine().getInputElement();
         if (renderCanvas && renderCanvas.parentNode) {
-            renderCanvas.parentNode.appendChild(this._overlay);
+            renderCanvas.parentNode.appendChild(this.overlay);
             scene.onDisposeObservable.addOnce(() => {
                 this.dispose();
             });
@@ -158,7 +162,7 @@ export class WebXREnterExitUI implements IDisposable {
         return Promise.all(supportedPromises).then((results) => {
             results.forEach((supported, i) => {
                 if (supported) {
-                    ui._overlay.appendChild(ui._buttons[i].element);
+                    ui.overlay.appendChild(ui._buttons[i].element);
                     ui._buttons[i].element.onclick = async () => {
                         if (helper.state == WebXRState.IN_XR) {
                             await helper.exitXRAsync();
@@ -192,8 +196,8 @@ export class WebXREnterExitUI implements IDisposable {
      */
     public dispose() {
         var renderCanvas = this.scene.getEngine().getInputElement();
-        if (renderCanvas && renderCanvas.parentNode && renderCanvas.parentNode.contains(this._overlay)) {
-            renderCanvas.parentNode.removeChild(this._overlay);
+        if (renderCanvas && renderCanvas.parentNode && renderCanvas.parentNode.contains(this.overlay)) {
+            renderCanvas.parentNode.removeChild(this.overlay);
         }
         this.activeButtonChangedObservable.clear();
     }

+ 15 - 0
src/XR/webXRFeaturesManager.ts

@@ -43,6 +43,11 @@ export interface IWebXRFeature extends IDisposable {
      * The name of the native xr feature name, if applicable (like anchor, hit-test, or hand-tracking)
      */
     xrNativeFeatureName?: string;
+
+    /**
+     * A list of (Babylon WebXR) features this feature depends on
+     */
+    dependsOn?: string[];
 }
 
 /**
@@ -81,6 +86,10 @@ export class WebXRFeatureName {
      * The name of the feature points feature.
      */
     public static readonly FEATURE_POINTS = "xr-feature-points";
+    /**
+     * The name of the hand tracking feature.
+     */
+    public static readonly HAND_TRACKING = "xr-hand-tracking";
 }
 
 /**
@@ -312,6 +321,12 @@ export class WebXRFeaturesManager implements IDisposable {
         }
 
         const constructed = constructFunction();
+        if (constructed.dependsOn) {
+            const dependentsFound = constructed.dependsOn.every((featureName) => !!this._features[featureName]);
+            if (!dependentsFound) {
+                throw new Error(`Dependant features missing. Make sure the following features are enabled - ${constructed.dependsOn.join(", ")}`);
+            }
+        }
         if (constructed.isCompatible()) {
             this._features[name] = {
                 featureImplementation: constructed,

+ 6 - 0
src/XR/webXRInputSource.ts

@@ -40,6 +40,7 @@ export interface IWebXRControllerOptions {
 export class WebXRInputSource {
     private _tmpVector = new Vector3();
     private _uniqueId: string;
+    private _disposed = false;
 
     /**
      * Represents the part of the controller that is held. This may not exist if the controller is the head mounted display itself, if thats the case only the pointer from the head will be availible
@@ -116,6 +117,10 @@ export class WebXRInputSource {
                                 this.motionController.rootMesh.parent = this.grip || this.pointer;
                                 this.motionController.disableAnimation = !!this._options.disableMotionControllerAnimation;
                             }
+                            // make sure to dispose is the controller is already disposed
+                            if (this._disposed) {
+                                this.motionController?.dispose();
+                            }
                         });
                     }
                 },
@@ -148,6 +153,7 @@ export class WebXRInputSource {
         this.onMeshLoadedObservable.clear();
         this.onDisposeObservable.notifyObservers(this);
         this.onDisposeObservable.clear();
+        this._disposed = true;
     }
 
     /**