Bug 1621802 [wpt PR 22200] - WebXR - hit test - add WPTs for input-source-based hit test, a=testonly
authorPiotr Bialecki <bialpio@chromium.org>
Sat, 14 Mar 2020 11:28:25 +0000
changeset 518838 917205af08c0d042325797003bc9051e96aa8159
parent 518837 ea93b29b3730ecb9638b270bfd4348028bde8b96
child 518839 5508fdda7dd9399556c7f503b25e34d9bdc121fc
push id37217
push userccoroiu@mozilla.com
push dateSun, 15 Mar 2020 21:37:59 +0000
treeherdermozilla-central@f9fc9427476e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerstestonly
bugs1621802, 22200, 2015182, 749867
milestone76.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1621802 [wpt PR 22200] - WebXR - hit test - add WPTs for input-source-based hit test, a=testonly Automatic update from web-platform-tests WebXR - hit test - add WPTs for input-source-based hit test - add WPT tests for hit test based off of input sources - reduce float point precision threshold in Chrome's mock implementation - minor refactor in webxr-test.js Changes: Change-Id: I4385ae38cc5f731d69981ad86cdadcd8ab078b40 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2015182 Commit-Queue: Piotr Bialecki <bialpio@chromium.org> Reviewed-by: Alexander Cooper <alcooper@chromium.org> Cr-Commit-Position: refs/heads/master@{#749867} -- wpt-commits: dca4a8fe363d03216a7f4bc00218e0f1456a4e83 wpt-pr: 22200
testing/web-platform/tests/resources/chromium/webxr-test-math-helper.js
testing/web-platform/tests/resources/chromium/webxr-test.js
testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_inputSources.https.html
testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_refSpaces.https.html
--- a/testing/web-platform/tests/resources/chromium/webxr-test-math-helper.js
+++ b/testing/web-platform/tests/resources/chromium/webxr-test-math-helper.js
@@ -214,9 +214,30 @@ class XRMathHelper {
       invDet * det3x3(m00, m02, m03, m10, m12, m13, m20, m22, m23),
       invDet * det3x3(m00, m01, m03, m10, m11, m13, m20, m21, m23),
       invDet * det3x3(m00, m01, m02, m10, m11, m12, m20, m21, m22),
     ];
 
     // Actual inverse is `1/det * transposed(comatrix)`:
     return XRMathHelper.transpose(result2);
   }
+
+  static mul4x4(m1, m2) {
+    if (m1 == null || m2 == null) {
+      return null;
+    }
+
+    const result = Array(16);
+
+    for (let row = 0; row < 4; row++) {
+      for (let col = 0; col < 4; col++) {
+        result[4 * col + row] = 0;
+        for(let i = 0; i < 4; i++) {
+          result[4 * col + row] += m1[4 * i + row] * m2[4 * col + i];
+        }
+      }
+    }
+
+    return result;
+  }
 }
+
+XRMathHelper.EPSILON = 0.001;
--- a/testing/web-platform/tests/resources/chromium/webxr-test.js
+++ b/testing/web-platform/tests/resources/chromium/webxr-test.js
@@ -854,17 +854,17 @@ class MockRuntime {
     const edge_AB = sub(point_B, point_A);
     const edge_AC = sub(point_C, point_A);
 
     const normal = normalize(cross(edge_AB, edge_AC));
 
     const numerator = dot(sub(point_A, origin), normal);
     const denominator = dot(direction, normal);
 
-    if (Math.abs(denominator) < 0.0001) {
+    if (Math.abs(denominator) < XRMathHelper.EPSILON) {
       // Planes are nearly parallel - there's either infinitely many intersection points or 0.
       // Both cases signify a "no hit" for us.
       return null;
     } else {
       // Single intersection point between the infinite plane and the line (*not* ray).
       // Need to calculate the hit test matrix taking into account the face vertices.
       const distance = numerator / denominator;
       if (distance < 0) {
@@ -873,23 +873,23 @@ class MockRuntime {
       } else {
         const intersection_point = add(origin, mul(distance, direction));
         // Since we are treating the face as a solid, flip the normal so that its
         // half-space will contain the ray origin.
         const y_axis = denominator > 0 ? neg(normal) : normal;
 
         let z_axis = null;
         const cos_direction_and_y_axis = dot(direction, y_axis);
-        if (Math.abs(cos_direction_and_y_axis) > 0.9999) {
+        if (Math.abs(cos_direction_and_y_axis) > (1 - XRMathHelper.EPSILON)) {
           // Ray and the hit test normal are co-linear - try using the 'up' or 'right' vector's projection on the face plane as the Z axis.
           // Note: this edge case is currently not covered by the spec.
           const up = {x: 0.0, y: 1.0, z: 0.0, w: 0.0};
-          const right = {x:1.0, y: 0.0, z: 0.0, w: 0.0};
+          const right = {x: 1.0, y: 0.0, z: 0.0, w: 0.0};
 
-          z_axis = Math.abs(dot(up, y_axis)) > 0.9999
+          z_axis = Math.abs(dot(up, y_axis)) > (1 - XRMathHelper.EPSILON)
                         ? sub(up, mul(dot(right, y_axis), y_axis))  // `up is also co-linear with hit test normal, use `right`
                         : sub(up, mul(dot(up, y_axis), y_axis));    // `up` is not co-linear with hit test normal, use it
         } else {
           // Project the ray direction onto the plane, negate it and use as a Z axis.
           z_axis = neg(sub(direction, mul(cos_direction_and_y_axis, y_axis))); // Z should point towards the ray origin, not away.
         }
 
         const x_axis = normalize(cross(y_axis, z_axis));
@@ -927,57 +927,83 @@ class MockRuntime {
         hitResult.hitMatrix.matrix[14] = intersection_point.z;
         hitResult.hitMatrix.matrix[15] = 1;
 
         return hitResult;
       }
     }
   }
 
+  _getMojoFromInputSource(mojo_from_viewer, input_source) {
+    if(input_source.target_ray_mode_ === 'gaze') {  // XRTargetRayMode::GAZING
+      // If the pointer origin is gaze, then the result is
+      // just mojo_from_viewer.
+      return mojo_from_viewer;
+    } else if(input_source.target_ray_mode_ === 'tracked-pointer') {  // XRTargetRayMode:::POINTING
+      // If the pointer origin is tracked-pointer, the result is just
+      // mojo_from_input*input_from_pointer.
+      return XRMathHelper.mul4x4(
+        input_source.mojo_from_input_.matrix,
+        input_source.input_from_pointer_.matrix);
+    } else if(input_source.target_ray_mode_ === 'screen') { // XRTargetRayMode::TAPPING
+      // If the pointer origin is screen, the input_from_pointer is
+      // equivalent to viewer_from_pointer and the result is
+      // mojo_from_viewer*viewer_from_pointer.
+      return XRMathHelper.mul4x4(
+        mojo_from_viewer,
+        input_source.input_from_pointer_.matrix);
+    } else {
+      return null
+    }
+  }
+
   _getMojoFromNativeOrigin(nativeOriginInformation) {
     const identity = function() {
       return [
         1, 0, 0, 0,
         0, 1, 0, 0,
         0, 0, 1, 0,
         0, 0, 0, 1
       ];
     };
 
+    const transform = {
+      position: [
+        this.pose_.position.x,
+        this.pose_.position.y,
+        this.pose_.position.z],
+      orientation: [
+        this.pose_.orientation.x,
+        this.pose_.orientation.y,
+        this.pose_.orientation.z,
+        this.pose_.orientation.w],
+    };
+
+    const mojo_from_viewer = getMatrixFromTransform(transform)
+
     if (nativeOriginInformation.$tag == device.mojom.XRNativeOriginInformation.Tags.inputSourceId) {
       if (!this.input_sources_.has(nativeOriginInformation.inputSourceId)) {
         return null;
       } else {
         const inputSource = this.input_sources_.get(nativeOriginInformation.inputSourceId);
-        return inputSource.mojo_from_input_.matrix;
+        return this._getMojoFromInputSource(mojo_from_viewer, inputSource);
       }
     } else if (nativeOriginInformation.$tag == device.mojom.XRNativeOriginInformation.Tags.referenceSpaceCategory) {
       switch (nativeOriginInformation.referenceSpaceCategory) {
         case device.mojom.XRReferenceSpaceCategory.LOCAL:
           return identity();
         case device.mojom.XRReferenceSpaceCategory.LOCAL_FLOOR:
           if (this.stageParameters_ == null || this.stageParameters_.standingTransform == null) {
             console.warn("Standing transform not available.");
             return null;
           }
           // this.stageParameters_.standingTransform = floor_from_mojo aka native_origin_from_mojo
           return XRMathHelper.inverse(this.stageParameters_.standingTransform.matrix);
         case device.mojom.XRReferenceSpaceCategory.VIEWER:
-          const transform = {
-            position: [
-              this.pose_.position.x,
-              this.pose_.position.y,
-              this.pose_.position.z],
-            orientation: [
-              this.pose_.orientation.x,
-              this.pose_.orientation.y,
-              this.pose_.orientation.z,
-              this.pose_.orientation.w],
-          };
-          return getMatrixFromTransform(transform);  // this.pose_ = mojo_from_viewer
+          return mojo_from_viewer;
         case device.mojom.XRReferenceSpaceCategory.BOUNDED_FLOOR:
           return null;
         case device.mojom.XRReferenceSpaceCategory.UNBOUNDED:
           return null;
         default:
           throw new TypeError("Unrecognized XRReferenceSpaceCategory!");
       }
     } else {
@@ -994,16 +1020,21 @@ class MockXRSessionMetricsRecorder {
 }
 
 class MockXRInputSource {
   constructor(fakeInputSourceInit, id, pairedDevice) {
     this.source_id_ = id;
     this.pairedDevice_ = pairedDevice;
     this.handedness_ = fakeInputSourceInit.handedness;
     this.target_ray_mode_ = fakeInputSourceInit.targetRayMode;
+
+    if(fakeInputSourceInit.pointerOrigin == null) {
+      throw new TypeError("FakeXRInputSourceInit.pointerOrigin is required.");
+    }
+
     this.setPointerOrigin(fakeInputSourceInit.pointerOrigin);
     this.setProfiles(fakeInputSourceInit.profiles);
 
     this.primary_input_pressed_ = false;
     if (fakeInputSourceInit.selectionStarted != null) {
       this.primary_input_pressed_ = fakeInputSourceInit.selectionStarted;
     }
 
new file mode 100644
--- /dev/null
+++ b/testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_inputSources.https.html
@@ -0,0 +1,171 @@
+<!DOCTYPE html>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="../resources/webxr_util.js"></script>
+<script src="../resources/webxr_test_asserts.js"></script>
+<script src="../resources/webxr_test_constants.js"></script>
+<script src="../resources/webxr_test_constants_fake_world.js"></script>
+<canvas />
+
+<script>
+
+// 1m above world origin.
+const VIEWER_ORIGIN_TRANSFORM = {
+  position: [0, 1, 0],
+  orientation: [0, 0, 0, 1],
+};
+
+// 0.25m above world origin.
+const FLOOR_ORIGIN_TRANSFORM = {
+  position: [0, -0.25, 0],
+  orientation: [0, 0, 0, 1],
+};
+
+const SCREEN_POINTER_TRANSFORM = {
+    position: [0, 0, 0],      // middle of the screen
+    orientation: [0, 0, 0, 1] // forward-facing
+};
+
+const screen_controller_init = {
+    handedness: "none",
+    targetRayMode: "screen",
+    pointerOrigin: SCREEN_POINTER_TRANSFORM,  // aka input_from_pointer
+    profiles: ["generic-touchscreen",]
+};
+
+const fakeDeviceInitParams = {
+  supportedModes: ["immersive-ar"],
+  views: VALID_VIEWS,
+  floorOrigin: FLOOR_ORIGIN_TRANSFORM,    // aka floor_from_mojo
+  viewerOrigin: VIEWER_ORIGIN_TRANSFORM,  // aka mojo_from_viewer
+  supportedFeatures: ALL_FEATURES,
+  world: createFakeWorld(5.0, 2.0, 5.0),  // see webxr_test_constants_fake_world.js for details
+};
+
+// Generates a test function given the parameters for the hit test.
+// |ray| - ray that will be used to subscribe to hit test.
+// |expectedPoses| - array of expected pose objects. The poses should be expressed in local space.
+//                   Null entries in the array mean that the given entry will not be validated.
+// |inputFromPointer| - input from pointer transform that will be used as the input source's
+//                      inputFromPointer (aka pointer origin) in subsequent rAF.
+// |nextFrameExpectedPoses| - array of expected pose objects. The poses should be expressed in local space.
+//                            Null entries in the array mean that the given entry will not be validated.
+let testFunctionGenerator = function(ray, expectedPoses, inputFromPointer, nextFrameExpectedPoses) {
+  const testFunction = function(session, fakeDeviceController, t) {
+    return session.requestReferenceSpace('local').then((localRefSpace) => new Promise((resolve, reject) => {
+
+      const input_source_controller = fakeDeviceController.simulateInputSourceConnection(screen_controller_init);
+
+      session.requestAnimationFrame((time, frame) => {
+        t.step(() => {
+          assert_equals(session.inputSources.length, 1);
+        });
+
+        const input_source = session.inputSources[0];
+        const hitTestOptionsInit = {
+          space: input_source.targetRaySpace,
+          offsetRay: ray,
+        };
+
+        session.requestHitTestSource(hitTestOptionsInit).then((hitTestSource) => {
+          t.step(() => {
+            assert_not_equals(hitTestSource, null);
+          });
+
+          // We got a hit test source, now get the results in subsequent rAFcb:
+          session.requestAnimationFrame((time, frame) => {
+            const results = frame.getHitTestResults(hitTestSource);
+
+            t.step(() => {
+              assert_equals(results.length, expectedPoses.length);
+              for(const [index, expectedPose] of expectedPoses.entries()) {
+                const pose = results[index].getPose(localRefSpace);
+                assert_true(pose != null, "Each hit test result should have a pose in local space");
+                if(expectedPose != null) {
+                  assert_transform_approx_equals(pose.transform, expectedPose, FLOAT_EPSILON, "before-move-pose: ");
+                }
+              }
+            });
+
+            input_source_controller.setPointerOrigin(inputFromPointer, false);
+
+            session.requestAnimationFrame((time, frame) => {
+              const results = frame.getHitTestResults(hitTestSource);
+
+              t.step(() => {
+                assert_equals(results.length, nextFrameExpectedPoses.length);
+                for(const [index, expectedPose] of nextFrameExpectedPoses.entries()) {
+                  const pose = results[index].getPose(localRefSpace);
+                  assert_true(pose != null, "Each hit test result should have a pose in local space");
+                  if(expectedPose != null) {
+                    assert_transform_approx_equals(pose.transform, expectedPose, FLOAT_EPSILON, "after-move-pose: ");
+                  }
+                }
+              });
+
+              resolve();
+            });
+          });
+        });
+      });
+    }));
+  };
+
+  return testFunction;
+};
+
+
+// Pose of the first expected hit test result - straight ahead of the input source, viewer-facing.
+const pose_1 = {
+  position: {x: 0.0, y: 1.0, z: -2.5, w: 1.0},
+  orientation: {x: 0.0, y: -0.707, z: -0.707, w: 0.0},
+    // Hit test API will set Y axis to the surface normal at the intersection point,
+    // Z axis towards the ray origin and X axis to cross product of Y axis & Z axis.
+    // If the surface normal and Z axis would be parallel, the hit test API
+    // will attempt to use `up` vector ([0, 1, 0]) as the Z axis, and if it so happens that Z axis
+    // and the surface normal would still be parallel, it will use the `right` vector ([1, 0, 0]) as the Z axis.
+    // In this particular case, `up` vector will work so the resulting pose.orientation
+    // becomes a rotation around [0, 1, 1] vector by 180 degrees.
+};
+
+xr_session_promise_test("Ensures subscription to hit test works with an XRSpace from input source - no move",
+  testFunctionGenerator(new XRRay(), [pose_1], SCREEN_POINTER_TRANSFORM, [pose_1]),
+  fakeDeviceInitParams,
+  'immersive-ar', { 'requiredFeatures': ['hit-test'] });
+
+const moved_pointer_transform_1 = {
+  position: [0, 0, 0],      // middle of the screen
+  orientation: [ 0.707, 0, 0, 0.707 ] // 90 degrees around X axis = facing up
+};
+
+xr_session_promise_test("Ensures subscription to hit test works with an XRSpace from input source - after move - no results",
+  testFunctionGenerator(new XRRay(), [pose_1], moved_pointer_transform_1, []),
+  fakeDeviceInitParams,
+  'immersive-ar', { 'requiredFeatures': ['hit-test'] });
+
+const pose_2 = {
+  position: {x: -1.443, y: 1.0, z: -2.5, w: 1.0},
+    // Intersection point will be on the same height as the viewer, on the front
+    // wall. Distance from the front wall to viewer is 2.5m, and we are rotating
+    // to the left, so X coordinate of the intersection point will be negative
+    // & equal to -2.5 * tan(30 deg) ~= 1.443m.
+  orientation: {x: 0.5, y: 0.5, z: 0.5, w: 0.5 },
+    // See comment for pose_1.orientation for details.
+    // In this case, the hit test pose will have Y axis facing towards world's
+    // positive Z axis ([0,0,1]), Z axis to the right ([1,0,0]) and X axis
+    // towards world's Y axis ([0,1,0]).
+    // This is equivalent to the rotation around [1, 1, 1] vector by 120 degrees.
+};
+
+const moved_pointer_transform_2 = {
+  position: [0, 0, 0],      // middle of the screen
+  orientation: [ 0, 0.2588, 0, 0.9659 ] // 30 degrees around Y axis = to the left,
+                                        // creating 30-60-90 triangle with the front wall
+};
+
+xr_session_promise_test("Ensures subscription to hit test works with an XRSpace from input source - after move - 1 result",
+  testFunctionGenerator(new XRRay(), [pose_1], moved_pointer_transform_2, [pose_2]),
+  fakeDeviceInitParams,
+  'immersive-ar', { 'requiredFeatures': ['hit-test'] });
+
+</script>
--- a/testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_refSpaces.https.html
+++ b/testing/web-platform/tests/webxr/hit-test/ar_hittest_subscription_refSpaces.https.html
@@ -59,17 +59,17 @@ let testFunctionGenerator = function(ray
 
         const requestAnimationFrameCallback = function(time, frame) {
           const hitTestResults = frame.getHitTestResults(hitTestSource);
 
           t.step(() => {
             assert_equals(hitTestResults.length, expectedPoses.length, "Results length should match expected results length");
             for(const [index, expectedPose] of expectedPoses.entries()) {
               const pose = hitTestResults[index].getPose(localRefSpace);
-              assert_true(pose != null, "Each hit test result should have a pose in viewer space");
+              assert_true(pose != null, "Each hit test result should have a pose in local space");
               if(expectedPose != null) {
                 assert_transform_approx_equals(pose.transform, expectedPose);
               }
             }
           });
 
           resolve();
         };