First Web Platform test using the WebXR Test API.
https://github.com/immersive-web/webxr-test-api
Change-Id: Ic741d23bf0607726d9a938f08f7964a5f9c957d9
Reviewed-on: https://chromium-review.googlesource.com/1070778
Reviewed-by: Robert Ma <robertma@chromium.org>
Reviewed-by: Daniel Cheng <dcheng@chromium.org>
Reviewed-by: David Dorwin <ddorwin@chromium.org>
Reviewed-by: Brandon Jones <bajones@chromium.org>
Reviewed-by: Reilly Grant <reillyg@chromium.org>
Commit-Queue: Anna Offenwanger <offenwanger@chromium.org>
Cr-Commit-Position: refs/heads/master@{#576869}
diff --git a/resources/chromium/webxr-test.js b/resources/chromium/webxr-test.js
new file mode 100644
index 0000000..74be1f0
--- /dev/null
+++ b/resources/chromium/webxr-test.js
@@ -0,0 +1,383 @@
+'use strict';
+
+// This polyfill library implements the WebXR Test API as specified here:
+// https://github.com/immersive-web/webxr-test-api
+
+class ChromeXRTest {
+ constructor() {
+ this.mockVRService_ = new MockVRService(mojo.frameInterfaces);
+ }
+
+ simulateDeviceConnection(init_params) {
+ return Promise.resolve(this.mockVRService_.addDevice(init_params));
+ }
+
+ simulateUserActivation(callback) {
+ return new Promise(resolve => {
+ let button = document.createElement('button');
+ button.textContent = 'click to continue test';
+ button.style.display = 'block';
+ button.style.fontSize = '20px';
+ button.style.padding = '10px';
+ button.onclick = () => {
+ resolve(callback());
+ document.body.removeChild(button);
+ };
+ document.body.appendChild(button);
+ test_driver.click(button);
+ });
+ }
+}
+
+// Mocking class definitions
+class MockVRService {
+ constructor() {
+ this.bindingSet_ = new mojo.BindingSet(device.mojom.VRService);
+ this.devices_ = [];
+
+ this.interceptor_ =
+ new MojoInterfaceInterceptor(device.mojom.VRService.name);
+ this.interceptor_.oninterfacerequest = e =>
+ this.bindingSet_.addBinding(this, e.handle);
+ this.interceptor_.start();
+ }
+
+ // Test methods
+ addDevice(fakeDeviceInit) {
+ let device = new MockDevice(fakeDeviceInit, this);
+ this.devices_.push(device);
+
+ return device;
+ }
+
+ // VRService implementation.
+ setClient(client) {
+ this.client_ = client;
+ for (let i = 0; i < this.devices_.length; i++) {
+ this.devices_[i].notifyClientOfDisplay();
+ }
+
+ return Promise.resolve();
+ }
+}
+
+// Implements both VRDisplayHost and VRMagicWindowProvider. Maintains a mock for
+// VRPresentationProvider.
+class MockDevice {
+ constructor(fakeDeviceInit, service) {
+ this.displayClient_ = new device.mojom.VRDisplayClientPtr();
+ this.presentation_provider_ = new MockVRPresentationProvider();
+
+ this.service_ = service;
+
+ this.framesOfReference = {};
+
+ if (fakeDeviceInit.supportsImmersive) {
+ this.displayInfo_ = this.getImmersiveDisplayInfo();
+ } else {
+ this.displayInfo_ = this.getNonImmersiveDisplayInfo();
+ }
+
+ if (service.client_) {
+ this.notifyClientOfDisplay();
+ }
+ }
+
+ // Functions for setup.
+ // This function calls to the backend to add this device to the list.
+ notifyClientOfDisplay() {
+ let displayPtr = new device.mojom.VRDisplayHostPtr();
+ let displayRequest = mojo.makeRequest(displayPtr);
+ let displayBinding =
+ new mojo.Binding(device.mojom.VRDisplayHost, this, displayRequest);
+
+ let clientRequest = mojo.makeRequest(this.displayClient_);
+ this.service_.client_.onDisplayConnected(
+ displayPtr, clientRequest, this.displayInfo_);
+ }
+
+ // Test methods.
+ setXRPresentationFrameData(poseMatrix, views) {
+ if (poseMatrix == null) {
+ this.presentation_provider_.pose_ = null;
+ } else {
+ this.presentation_provider_.setPoseFromMatrix(poseMatrix);
+ }
+
+ if (views) {
+ let changed = false;
+ for (let i = 0; i < views.length; i++) {
+ if (views[i].eye == 'left') {
+ this.displayInfo_.leftEye = this.getEye(views[i]);
+ changed = true;
+ } else if (views[i].eye == 'right') {
+ this.displayInfo_.rightEye = this.getEye(views[i]);
+ changed = true;
+ }
+ }
+
+ if (changed) {
+ this.displayClient_.onChanged(this.displayInfo_);
+ }
+ }
+ }
+
+ getNonImmersiveDisplayInfo() {
+ let displayInfo = this.getImmersiveDisplayInfo();
+
+ displayInfo.capabilities.canPresent = false;
+ displayInfo.leftEye = null;
+ displayInfo.rightEye = null;
+
+ return displayInfo;
+ }
+
+ // Function to generate some valid display information for the device.
+ getImmersiveDisplayInfo() {
+ return {
+ displayName: 'FakeDevice',
+ capabilities: {
+ hasPosition: false,
+ hasExternalDisplay: false,
+ canPresent: true,
+ maxLayers: 1
+ },
+ stageParameters: null,
+ leftEye: {
+ fieldOfView: {
+ upDegrees: 48.316,
+ downDegrees: 50.099,
+ leftDegrees: 50.899,
+ rightDegrees: 35.197
+ },
+ offset: [-0.032, 0, 0],
+ renderWidth: 20,
+ renderHeight: 20
+ },
+ rightEye: {
+ fieldOfView: {
+ upDegrees: 48.316,
+ downDegrees: 50.099,
+ leftDegrees: 50.899,
+ rightDegrees: 35.197
+ },
+ offset: [0.032, 0, 0],
+ renderWidth: 20,
+ renderHeight: 20
+ },
+ webxrDefaultFramebufferScale: 0.7,
+ };
+ }
+
+ // This function converts between the matrix provided by the WebXR test API
+ // and the internal data representation.
+ getEye(fakeXRViewInit) {
+ let m = fakeXRViewInit.projectionMatrix;
+
+ function toDegrees(tan) {
+ return Math.atan(tan) * 180 / Math.PI;
+ }
+
+ let xScale = m[0];
+ let yScale = m[5];
+ let near = m[14] / (m[10] - 1);
+ let far = m[14] / (m[10] - 1);
+ let leftTan = (1 - m[8]) / m[0];
+ let rightTan = (1 + m[8]) / m[0];
+ let upTan = (1 + m[9]) / m[5];
+ let downTan = (1 - m[9]) / m[5];
+
+ return {
+ fieldOfView: {
+ upDegrees: toDegrees(upTan),
+ downDegrees: toDegrees(downTan),
+ leftDegrees: toDegrees(leftTan),
+ rightDegrees: toDegrees(rightTan)
+ },
+ offset: [0, 0, 0],
+ renderWidth: 20,
+ renderHeight: 20
+ };
+ }
+
+ // Mojo function implementations.
+
+ // VRMagicWindowProvider implementation.
+
+ getFrameData() {
+ // Convert current document time to monotonic time.
+ let now = window.performance.now() / 1000.0;
+ let diff = now - internals.monotonicTimeToZeroBasedDocumentTime(now);
+ now += diff;
+ now *= 1000000;
+
+ return Promise.resolve({
+ frameData: {
+ pose: this.presentation_provider_.pose_,
+ bufferHolder: null,
+ bufferSize: {},
+ timeDelta: [],
+ projectionMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
+ }
+ });
+ }
+
+ updateSessionGeometry(frame_size, display_rotation) {
+ // This function must exist to ensure that calls to it do not crash, but we
+ // do not have any use for this data at present.
+ }
+
+ // VRDisplayHost implementation.
+
+ requestSession(sessionOptions, was_activation) {
+ return this.supportsSession(sessionOptions).then((result) => {
+ // The JavaScript bindings convert c_style_names to camelCase names.
+ let options = new device.mojom.VRDisplayFrameTransportOptions();
+ options.transportMethod =
+ device.mojom.VRDisplayFrameTransportMethod.SUBMIT_AS_MAILBOX_HOLDER;
+ options.waitForTransferNotification = true;
+ options.waitForRenderNotification = true;
+
+ let connection;
+ if (result.supportsSession) {
+ connection = {
+ clientRequest: this.presentation_provider_.getClientRequest(),
+ provider: this.presentation_provider_.bindProvider(sessionOptions),
+ transportOptions: options
+ };
+
+ let magicWindowPtr = new device.mojom.VRMagicWindowProviderPtr();
+ let magicWindowRequest = mojo.makeRequest(magicWindowPtr);
+ let magicWindowBinding = new mojo.Binding(
+ device.mojom.VRMagicWindowProvider, this, magicWindowRequest);
+
+ return Promise.resolve({
+ session:
+ {connection: connection, magicWindowProvider: magicWindowPtr}
+ });
+ } else {
+ return Promise.resolve({session: null});
+ }
+ });
+ }
+
+ supportsSession(options) {
+ return Promise.resolve({
+ supportsSession:
+ !options.exclusive || this.displayInfo_.capabilities.canPresent
+ });
+ };
+}
+
+class MockVRPresentationProvider {
+ constructor() {
+ this.binding_ = new mojo.Binding(device.mojom.VRPresentationProvider, this);
+ this.pose_ = null;
+ this.next_frame_id_ = 0;
+ this.submit_frame_count_ = 0;
+ this.missing_frame_count_ = 0;
+ }
+
+ bindProvider(request) {
+ let providerPtr = new device.mojom.VRPresentationProviderPtr();
+ let providerRequest = mojo.makeRequest(providerPtr);
+
+ this.binding_.close();
+
+ this.binding_ = new mojo.Binding(
+ device.mojom.VRPresentationProvider, this, providerRequest);
+
+ return providerPtr;
+ }
+
+ getClientRequest() {
+ this.submitFrameClient_ = new device.mojom.VRSubmitFrameClientPtr();
+ return mojo.makeRequest(this.submitFrameClient_);
+ }
+
+ setPoseFromMatrix(poseMatrix) {
+ this.pose_ = {
+ orientation: null,
+ position: null,
+ angularVelocity: null,
+ linearVelocity: null,
+ angularAcceleration: null,
+ linearAcceleration: null,
+ inputState: null,
+ poseIndex: 0
+ };
+
+ let pose = this.poseFromMatrix(poseMatrix);
+ for (let field in pose) {
+ if (this.pose_.hasOwnProperty(field)) {
+ this.pose_[field] = pose[field];
+ }
+ }
+ }
+
+ poseFromMatrix(m) {
+ let orientation = [];
+
+ let m00 = m[0];
+ let m11 = m[5];
+ let m22 = m[10];
+ // The max( 0, ... ) is just a safeguard against rounding error.
+ orientation[3] = Math.sqrt(Math.max(0, 1 + m00 + m11 + m22)) / 2;
+ orientation[0] = Math.sqrt(Math.max(0, 1 + m00 - m11 - m22)) / 2;
+ orientation[1] = Math.sqrt(Math.max(0, 1 - m00 + m11 - m22)) / 2;
+ orientation[2] = Math.sqrt(Math.max(0, 1 - m00 - m11 + m22)) / 2;
+
+ let position = [];
+ position[0] = m[12];
+ position[1] = m[13];
+ position[2] = m[14];
+
+ return {
+ orientation, position
+ }
+ }
+
+ // VRPresentationProvider mojo implementation
+ submitFrameMissing(frameId, mailboxHolder, timeWaited) {
+ this.missing_frame_count_++;
+ }
+
+ submitFrame(frameId, mailboxHolder, timeWaited) {
+ this.submit_frame_count_++;
+
+ // Trigger the submit completion callbacks here. WARNING: The
+ // Javascript-based mojo mocks are *not* re-entrant. It's OK to
+ // wait for these notifications on the next frame, but waiting
+ // within the current frame would never finish since the incoming
+ // calls would be queued until the current execution context finishes.
+ this.submitFrameClient_.onSubmitFrameTransferred(true);
+ this.submitFrameClient_.onSubmitFrameRendered();
+ }
+
+ getFrameData() {
+ if (this.pose_) {
+ this.pose_.poseIndex++;
+ }
+
+ // Convert current document time to monotonic time.
+ let now = window.performance.now() / 1000.0;
+ let diff = now - internals.monotonicTimeToZeroBasedDocumentTime(now);
+ now += diff;
+ now *= 1000000;
+
+ return Promise.resolve({
+ frameData: {
+ pose: this.pose_,
+ timeDelta: {
+ microseconds: now,
+ },
+ frameId: this.next_frame_id_++,
+ projectionMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
+ bufferHolder: null,
+ bufferSize: {}
+ }
+ });
+ }
+}
+
+let XRTest = new ChromeXRTest();
\ No newline at end of file
diff --git a/resources/chromium/webxr-test.js.headers b/resources/chromium/webxr-test.js.headers
new file mode 100644
index 0000000..6805c32
--- /dev/null
+++ b/resources/chromium/webxr-test.js.headers
@@ -0,0 +1 @@
+Content-Type: text/javascript; charset=utf-8
diff --git a/webxr/resources/webxr_util.js b/webxr/resources/webxr_util.js
index 5adf0a2..eaa7887 100644
--- a/webxr/resources/webxr_util.js
+++ b/webxr/resources/webxr_util.js
@@ -1,3 +1,25 @@
+// These tests rely on the User Agent providing an implementation of the
+// WebXR Testing API (https://github.com/immersive-web/webxr-test-api).
+//
+// In Chromium-based browsers, this implementation is provided by a JavaScript
+// shim in order to reduce the amount of test-only code shipped to users. To
+// enable these tests the browser must be run with these options:
+//
+// --enable-blink-features=MojoJS,MojoJSTest
+
+function xr_promise_test(func, name, properties) {
+ promise_test(async (t) => {
+ // Perform any required test setup:
+
+ if (window.XRTest === undefined) {
+ // Chrome setup
+ await loadChromiumResources;
+ }
+
+ return func(t);
+ }, name, properties);
+}
+
// This functions calls a callback with each API object as specified
// by https://immersive-web.github.io/webxr/spec/latest/, allowing
// checks to be made on all ojects.
@@ -25,4 +47,32 @@
callback(window.XRStageBoundsPoint, 'XRStageBoundsPoint');
callback(window.XRSessionEvent, 'XRSessionEvent');
callback(window.XRCoordinateSystemEvent, 'XRCoordinateSystemEvent');
-}
\ No newline at end of file
+}
+
+// Code for loading test api in chromium.
+let loadChromiumResources = Promise.resolve().then(() => {
+ if (!MojoInterfaceInterceptor) {
+ // Do nothing on non-Chromium-based browsers or when the Mojo bindings are
+ // not present in the global namespace.
+ return;
+ }
+
+ let chain = Promise.resolve();
+ ['/gen/layout_test_data/mojo/public/js/mojo_bindings.js',
+ '/gen/ui/gfx/geometry/mojo/geometry.mojom.js',
+ '/gen/mojo/public/mojom/base/time.mojom.js',
+ '/gen/device/vr/public/mojom/vr_service.mojom.js',
+ '/resources/chromium/webxr-test.js', '/resources/testdriver.js',
+ '/resources/testdriver-vendor.js',
+ ].forEach(path => {
+ let script = document.createElement('script');
+ script.src = path;
+ script.async = false;
+ chain = chain.then(() => new Promise(resolve => {
+ script.onload = () => resolve();
+ }));
+ document.head.appendChild(script);
+ });
+
+ return chain;
+});
\ No newline at end of file
diff --git a/webxr/xrSession_exclusive_requestAnimationFrame.https.html b/webxr/xrSession_exclusive_requestAnimationFrame.https.html
new file mode 100644
index 0000000..010ab0b
--- /dev/null
+++ b/webxr/xrSession_exclusive_requestAnimationFrame.https.html
@@ -0,0 +1,91 @@
+<!DOCTYPE html>
+<body>
+ <script src=/resources/testharness.js></script>
+ <script src=/resources/testharnessreport.js></script>
+ <script src="resources/webxr_util.js"></script>
+ <canvas id="webgl-canvas"></canvas>
+
+ <script>
+
+ const identityMatrix = new Float32Array([
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1
+ ]);
+
+ const rightFakeXRViewInit =
+ {eye:"right", projectionMatrix: identityMatrix, viewMatrix: identityMatrix};
+
+ const leftFakeXRViewInit =
+ {eye:"left", projectionMatrix: identityMatrix, viewMatrix: identityMatrix};
+
+ const immersiveFakeXRDeviceInit = { supportsImmersive:true };
+
+ const webglCanvas = document.getElementById('webgl-canvas');
+ let gl = webglCanvas.getContext('webgl', { alpha: false, antialias: false });
+
+ let testDevice;
+ let testDeviceController;
+ let testSession;
+
+ xr_promise_test(
+ (t) => XRTest.simulateDeviceConnection(immersiveFakeXRDeviceInit)
+ .then((controller) => {
+ testDeviceController = controller;
+ return navigator.xr.requestDevice();
+ })
+ .then((device) => {
+ testDevice = device;
+ return gl.setCompatibleXRDevice(device);
+ })
+ .then(() => new Promise((resolve, reject) => {
+ // Perform the session request in a user gesture.
+ XRTest.simulateUserActivation(() => {
+ testDevice.requestSession({ immersive: true })
+ .then((session) => {
+ testSession = session;
+ return session.requestFrameOfReference('eye-level');
+ })
+ .then((frameOfRef) => {
+ // Session must have a baseLayer or frame requests will be ignored.
+ testSession.baseLayer = new XRWebGLLayer(testSession, gl);
+
+ function onFrame(time, xrFrame) {
+ assert_true(xrFrame instanceof XRFrame);
+
+ assert_not_equals(xrFrame.views, null);
+ assert_equals(xrFrame.views.length, 2);
+
+ let devicePose = xrFrame.getDevicePose(frameOfRef);
+
+ assert_not_equals(devicePose, null);
+ for(let i = 0; i < identityMatrix.length; i++) {
+ assert_equals(devicePose.poseModelMatrix[i], identityMatrix[i]);
+ }
+
+ assert_not_equals(devicePose.getViewMatrix(xrFrame.views[0]), null);
+ assert_equals(devicePose.getViewMatrix(xrFrame.views[0]).length, 16);
+ assert_not_equals(devicePose.getViewMatrix(xrFrame.views[1]), null);
+ assert_equals(devicePose.getViewMatrix(xrFrame.views[1]).length, 16);
+
+ // Test does not complete until the returned promise resolves.
+ resolve();
+ }
+
+ testDeviceController.setXRPresentationFrameData(
+ identityMatrix,
+ [rightFakeXRViewInit, leftFakeXRViewInit]
+ );
+
+ testSession.requestAnimationFrame(onFrame);
+ }).catch((err) => {
+ reject("Session was rejected with error: "+err);
+ });
+ });
+ })
+ ),
+ "RequestAnimationFrame resolves with good data"
+ );
+ </script>
+</body>