webxr-test.js (81998B)
1 import * as vrMojom from '/gen/device/vr/public/mojom/vr_service.mojom.m.js'; 2 import * as xrSessionMojom from '/gen/device/vr/public/mojom/xr_session.mojom.m.js'; 3 import {GamepadHand, GamepadMapping} from '/gen/device/gamepad/public/mojom/gamepad.mojom.m.js'; 4 5 // This polyfill library implements the WebXR Test API as specified here: 6 // https://github.com/immersive-web/webxr-test-api 7 8 const defaultMojoFromStage = { 9 data: { matrix: [1, 0, 0, 0, 10 0, 1, 0, 0, 11 0, 0, 1, 0, 12 0, -1.65, 0, 1] } 13 }; 14 const default_stage_parameters = { 15 mojoFromStage: defaultMojoFromStage, 16 bounds: null 17 }; 18 19 const default_framebuffer_scale = 0.7; 20 21 function getMatrixFromTransform(transform) { 22 const x = transform.orientation[0]; 23 const y = transform.orientation[1]; 24 const z = transform.orientation[2]; 25 const w = transform.orientation[3]; 26 27 const m11 = 1.0 - 2.0 * (y * y + z * z); 28 const m21 = 2.0 * (x * y + z * w); 29 const m31 = 2.0 * (x * z - y * w); 30 31 const m12 = 2.0 * (x * y - z * w); 32 const m22 = 1.0 - 2.0 * (x * x + z * z); 33 const m32 = 2.0 * (y * z + x * w); 34 35 const m13 = 2.0 * (x * z + y * w); 36 const m23 = 2.0 * (y * z - x * w); 37 const m33 = 1.0 - 2.0 * (x * x + y * y); 38 39 const m14 = transform.position[0]; 40 const m24 = transform.position[1]; 41 const m34 = transform.position[2]; 42 43 // Column-major linearized order is expected. 44 return [m11, m21, m31, 0, 45 m12, m22, m32, 0, 46 m13, m23, m33, 0, 47 m14, m24, m34, 1]; 48 } 49 50 function getPoseFromTransform(transform) { 51 const [px, py, pz] = transform.position; 52 const [ox, oy, oz, ow] = transform.orientation; 53 return { 54 position: {x: px, y: py, z: pz}, 55 orientation: {x: ox, y: oy, z: oz, w: ow}, 56 }; 57 } 58 59 function composeGFXTransform(fakeTransformInit) { 60 return {data: {matrix: getMatrixFromTransform(fakeTransformInit)}}; 61 } 62 63 // Value equality for camera image init objects - they must contain `width` & 64 // `height` properties and may contain `pixels` property. 65 function isSameCameraImageInit(rhs, lhs) { 66 return lhs.width === rhs.width && lhs.height === rhs.height && lhs.pixels === rhs.pixels; 67 } 68 69 class ChromeXRTest { 70 constructor() { 71 this.mockVRService_ = new MockVRService(); 72 } 73 74 // WebXR Test API 75 simulateDeviceConnection(init_params) { 76 return Promise.resolve(this.mockVRService_._addRuntime(init_params)); 77 } 78 79 disconnectAllDevices() { 80 this.mockVRService_._removeAllRuntimes(); 81 return Promise.resolve(); 82 } 83 84 simulateUserActivation(callback) { 85 if (window.top !== window) { 86 // test_driver.click only works for the toplevel frame. This alternate 87 // Chrome-specific method is sufficient for starting an XR session in an 88 // iframe, and is used in platform-specific tests. 89 // 90 // TODO(https://github.com/web-platform-tests/wpt/issues/20282): use 91 // a cross-platform method if available. 92 xr_debug('simulateUserActivation', 'use eventSender'); 93 document.addEventListener('click', callback); 94 eventSender.mouseMoveTo(0, 0); 95 eventSender.mouseDown(); 96 eventSender.mouseUp(); 97 document.removeEventListener('click', callback); 98 return; 99 } 100 const button = document.createElement('button'); 101 button.textContent = 'click to continue test'; 102 button.style.display = 'block'; 103 button.style.fontSize = '20px'; 104 button.style.padding = '10px'; 105 button.onclick = () => { 106 callback(); 107 document.body.removeChild(button); 108 }; 109 document.body.appendChild(button); 110 test_driver.click(button); 111 } 112 113 // Helper method leveraged by chrome-specific setups. 114 Debug(name, msg) { 115 console.log(new Date().toISOString() + ' DEBUG[' + name + '] ' + msg); 116 } 117 } 118 119 // Mocking class definitions 120 121 // Mock service implements the VRService mojo interface. 122 class MockVRService { 123 constructor() { 124 this.receiver_ = new vrMojom.VRServiceReceiver(this); 125 this.runtimes_ = []; 126 127 this.interceptor_ = 128 new MojoInterfaceInterceptor(vrMojom.VRService.$interfaceName); 129 this.interceptor_.oninterfacerequest = 130 e => this.receiver_.$.bindHandle(e.handle); 131 this.interceptor_.start(); 132 } 133 134 // WebXR Test API Implementation Helpers 135 _addRuntime(fakeDeviceInit) { 136 const runtime = new MockRuntime(fakeDeviceInit, this); 137 this.runtimes_.push(runtime); 138 139 if (this.client_) { 140 this.client_.onDeviceChanged(); 141 } 142 143 return runtime; 144 } 145 146 _removeAllRuntimes() { 147 if (this.client_) { 148 this.client_.onDeviceChanged(); 149 } 150 151 this.runtimes_ = []; 152 } 153 154 _removeRuntime(device) { 155 const index = this.runtimes_.indexOf(device); 156 if (index >= 0) { 157 this.runtimes_.splice(index, 1); 158 if (this.client_) { 159 this.client_.onDeviceChanged(); 160 } 161 } 162 } 163 164 // VRService overrides 165 setClient(client) { 166 if (this.client_) { 167 throw new Error("setClient should only be called once"); 168 } 169 170 this.client_ = client; 171 } 172 173 requestSession(sessionOptions) { 174 const requests = []; 175 // Request a session from all the runtimes. 176 for (let i = 0; i < this.runtimes_.length; i++) { 177 requests[i] = this.runtimes_[i]._requestRuntimeSession(sessionOptions); 178 } 179 180 return Promise.all(requests).then((results) => { 181 // Find and return the first successful result. 182 for (let i = 0; i < results.length; i++) { 183 if (results[i].session) { 184 // Construct a dummy metrics recorder 185 const metricsRecorderPtr = new vrMojom.XRSessionMetricsRecorderRemote(); 186 metricsRecorderPtr.$.bindNewPipeAndPassReceiver().handle.close(); 187 188 const success = { 189 session: results[i].session, 190 metricsRecorder: metricsRecorderPtr, 191 }; 192 193 return {result: {success}}; 194 } 195 } 196 197 // If there were no successful results, returns a null session. 198 return { 199 result: {failureReason: xrSessionMojom.RequestSessionError.NO_RUNTIME_FOUND} 200 }; 201 }); 202 } 203 204 supportsSession(sessionOptions) { 205 const requests = []; 206 // Check supports on all the runtimes. 207 for (let i = 0; i < this.runtimes_.length; i++) { 208 requests[i] = this.runtimes_[i]._runtimeSupportsSession(sessionOptions); 209 } 210 211 return Promise.all(requests).then((results) => { 212 // Find and return the first successful result. 213 for (let i = 0; i < results.length; i++) { 214 if (results[i].supportsSession) { 215 return results[i]; 216 } 217 } 218 219 // If there were no successful results, returns false. 220 return {supportsSession: false}; 221 }); 222 } 223 224 exitPresent() { 225 return Promise.resolve(); 226 } 227 228 setFramesThrottled(throttled) { 229 this.setFramesThrottledImpl(throttled); 230 } 231 232 // We cannot override the mojom interceptors via the prototype; so this method 233 // and the above indirection exist to allow overrides by internal code. 234 setFramesThrottledImpl(throttled) {} 235 236 // Only handles asynchronous calls to makeXrCompatible. Synchronous calls are 237 // not supported in Javascript. 238 makeXrCompatible() { 239 if (this.runtimes_.length == 0) { 240 return { 241 xrCompatibleResult: vrMojom.XrCompatibleResult.kNoDeviceAvailable 242 }; 243 } 244 return {xrCompatibleResult: vrMojom.XrCompatibleResult.kAlreadyCompatible}; 245 } 246 } 247 248 class FakeXRAnchorController { 249 constructor() { 250 // Private properties. 251 this.device_ = null; 252 this.id_ = null; 253 this.dirty_ = true; 254 255 // Properties backing up public attributes / methods. 256 this.deleted_ = false; 257 this.paused_ = false; 258 this.anchorOrigin_ = XRMathHelper.identity(); 259 } 260 261 // WebXR Test API (Anchors Extension) 262 get deleted() { 263 return this.deleted_; 264 } 265 266 pauseTracking() { 267 if(!this.paused_) { 268 this.paused_ = true; 269 this.dirty_ = true; 270 } 271 } 272 273 resumeTracking() { 274 if(this.paused_) { 275 this.paused_ = false; 276 this.dirty_ = true; 277 } 278 } 279 280 stopTracking() { 281 if(!this.deleted_) { 282 this.device_._deleteAnchorController(this.id_); 283 284 this.deleted_ = true; 285 this.dirty_ = true; 286 } 287 } 288 289 setAnchorOrigin(anchorOrigin) { 290 this.anchorOrigin_ = getMatrixFromTransform(anchorOrigin); 291 this.dirty_ = true; 292 } 293 294 // Internal implementation: 295 set id(value) { 296 this.id_ = value; 297 } 298 299 set device(value) { 300 this.device_ = value; 301 } 302 303 get dirty() { 304 return this.dirty_; 305 } 306 307 get paused() { 308 return this.paused_; 309 } 310 311 _markProcessed() { 312 this.dirty_ = false; 313 } 314 315 _getAnchorOrigin() { 316 return this.anchorOrigin_; 317 } 318 } 319 320 // Implements XRFrameDataProvider and XRPresentationProvider. Maintains a mock 321 // for XRPresentationProvider. Implements FakeXRDevice test API. 322 class MockRuntime { 323 // Mapping from string feature names to the corresponding mojo types. 324 // This is exposed as a member for extensibility. 325 static _featureToMojoMap = { 326 'viewer': xrSessionMojom.XRSessionFeature.REF_SPACE_VIEWER, 327 'local': xrSessionMojom.XRSessionFeature.REF_SPACE_LOCAL, 328 'local-floor': xrSessionMojom.XRSessionFeature.REF_SPACE_LOCAL_FLOOR, 329 'bounded-floor': xrSessionMojom.XRSessionFeature.REF_SPACE_BOUNDED_FLOOR, 330 'unbounded': xrSessionMojom.XRSessionFeature.REF_SPACE_UNBOUNDED, 331 'hit-test': xrSessionMojom.XRSessionFeature.HIT_TEST, 332 'dom-overlay': xrSessionMojom.XRSessionFeature.DOM_OVERLAY, 333 'light-estimation': xrSessionMojom.XRSessionFeature.LIGHT_ESTIMATION, 334 'anchors': xrSessionMojom.XRSessionFeature.ANCHORS, 335 'depth-sensing': xrSessionMojom.XRSessionFeature.DEPTH, 336 'secondary-views': xrSessionMojom.XRSessionFeature.SECONDARY_VIEWS, 337 'camera-access': xrSessionMojom.XRSessionFeature.CAMERA_ACCESS, 338 'layers': xrSessionMojom.XRSessionFeature.LAYERS, 339 }; 340 341 static _sessionModeToMojoMap = { 342 "inline": xrSessionMojom.XRSessionMode.kInline, 343 "immersive-vr": xrSessionMojom.XRSessionMode.kImmersiveVr, 344 "immersive-ar": xrSessionMojom.XRSessionMode.kImmersiveAr, 345 }; 346 347 static _environmentBlendModeToMojoMap = { 348 "opaque": vrMojom.XREnvironmentBlendMode.kOpaque, 349 "alpha-blend": vrMojom.XREnvironmentBlendMode.kAlphaBlend, 350 "additive": vrMojom.XREnvironmentBlendMode.kAdditive, 351 }; 352 353 static _interactionModeToMojoMap = { 354 "screen-space": vrMojom.XRInteractionMode.kScreenSpace, 355 "world-space": vrMojom.XRInteractionMode.kWorldSpace, 356 }; 357 358 static _depthTypeToMojoMap = { 359 "raw": xrSessionMojom.XRDepthType.kRawDepth, 360 "smooth": xrSessionMojom.XRDepthType.kSmoothDepth, 361 }; 362 363 static _depthUsageToMojoMap = { 364 "cpu-optimized": xrSessionMojom.XRDepthUsage.kCPUOptimized, 365 "gpu-optimized": xrSessionMojom.XRDepthUsage.kGPUOptimized, 366 }; 367 368 static _depthDataFormatToMojoMap = { 369 "luminance-alpha": xrSessionMojom.XRDepthDataFormat.kLuminanceAlpha, 370 "float32": xrSessionMojom.XRDepthDataFormat.kFloat32, 371 "unsigned-short": xrSessionMojom.XRDepthDataFormat.kUnsignedShort, 372 }; 373 374 375 constructor(fakeDeviceInit, service) { 376 this.sessionClient_ = null; 377 this.presentation_provider_ = new MockXRPresentationProvider(); 378 379 this.pose_ = null; 380 this.next_frame_id_ = 0; 381 this.bounds_ = null; 382 this.send_mojo_space_reset_ = false; 383 this.stageParameters_ = null; 384 this.stageParametersId_ = 1; 385 this.nextVisibilityMaskId_ = 1; 386 387 this.service_ = service; 388 389 this.framesOfReference = {}; 390 391 this.input_sources_ = new Map(); 392 this.next_input_source_index_ = 1; 393 394 // Currently active hit test subscriptons. 395 this.hitTestSubscriptions_ = new Map(); 396 // Currently active transient hit test subscriptions. 397 this.transientHitTestSubscriptions_ = new Map(); 398 // ID of the next subscription to be assigned. 399 this.next_hit_test_id_ = 1n; 400 401 this.anchor_controllers_ = new Map(); 402 // ID of the next anchor to be assigned. 403 this.next_anchor_id_ = 1n; 404 // Anchor creation callback (initially null, can be set by tests). 405 this.anchor_creation_callback_ = null; 406 407 this.depthSensingData_ = null; 408 this.depthSensingDataDirty_ = false; 409 410 let supportedModes = []; 411 if (fakeDeviceInit.supportedModes) { 412 supportedModes = fakeDeviceInit.supportedModes.slice(); 413 if (fakeDeviceInit.supportedModes.length === 0) { 414 supportedModes = ["inline"]; 415 } 416 } else { 417 // Back-compat mode. 418 console.warn("Please use `supportedModes` to signal which modes are supported by this device."); 419 if (fakeDeviceInit.supportsImmersive == null) { 420 throw new TypeError("'supportsImmersive' must be set"); 421 } 422 423 supportedModes = ["inline"]; 424 if (fakeDeviceInit.supportsImmersive) { 425 supportedModes.push("immersive-vr"); 426 } 427 } 428 429 this.supportedModes_ = this._convertModesToEnum(supportedModes); 430 if (this.supportedModes_.length == 0) { 431 console.error("Device has empty supported modes array!"); 432 throw new InvalidStateError(); 433 } 434 435 if (fakeDeviceInit.viewerOrigin != null) { 436 this.setViewerOrigin(fakeDeviceInit.viewerOrigin); 437 } 438 439 if (fakeDeviceInit.floorOrigin != null) { 440 this.setFloorOrigin(fakeDeviceInit.floorOrigin); 441 } 442 443 if (fakeDeviceInit.world) { 444 this.setWorld(fakeDeviceInit.world); 445 } 446 447 if (fakeDeviceInit.depthSensingData) { 448 this.setDepthSensingData(fakeDeviceInit.depthSensingData); 449 } 450 451 this.defaultFramebufferScale_ = default_framebuffer_scale; 452 this.enviromentBlendMode_ = this._convertBlendModeToEnum(fakeDeviceInit.environmentBlendMode); 453 this.interactionMode_ = this._convertInteractionModeToEnum(fakeDeviceInit.interactionMode); 454 455 // This appropriately handles if the coordinates are null 456 this.setBoundsGeometry(fakeDeviceInit.boundsCoordinates); 457 458 this.setViews(fakeDeviceInit.views, fakeDeviceInit.secondaryViews); 459 460 this._setDepthSupport(fakeDeviceInit.depthSupport || {}); 461 462 // Need to support webVR which doesn't have a notion of features 463 this._setFeatures(fakeDeviceInit.supportedFeatures || []); 464 } 465 466 // WebXR Test API 467 setViews(primaryViews, secondaryViews) { 468 this.cameraImage_ = null; 469 this.primaryViews_ = []; 470 this.secondaryViews_ = []; 471 let xOffset = 0; 472 if (primaryViews) { 473 this.primaryViews_ = []; 474 xOffset = this._setViews(primaryViews, xOffset, this.primaryViews_); 475 const cameraImage = this._findCameraImage(primaryViews); 476 477 if (cameraImage) { 478 this.cameraImage_ = cameraImage; 479 } 480 } 481 482 if (secondaryViews) { 483 this.secondaryViews_ = []; 484 this._setViews(secondaryViews, xOffset, this.secondaryViews_); 485 const cameraImage = this._findCameraImage(secondaryViews); 486 487 if (cameraImage) { 488 if (!isSameCameraImageInit(this.cameraImage_, cameraImage)) { 489 throw new Error("If present, camera resolutions on each view must match each other!" 490 + " Secondary views' camera doesn't match primary views."); 491 } 492 493 this.cameraImage_ = cameraImage; 494 } 495 } 496 } 497 498 disconnect() { 499 this.service_._removeRuntime(this); 500 this.presentation_provider_._close(); 501 if (this.sessionClient_) { 502 this.sessionClient_.$.close(); 503 this.sessionClient_ = null; 504 } 505 506 return Promise.resolve(); 507 } 508 509 setViewerOrigin(origin, emulatedPosition = false) { 510 const p = origin.position; 511 const q = origin.orientation; 512 this.pose_ = { 513 orientation: { x: q[0], y: q[1], z: q[2], w: q[3] }, 514 position: { x: p[0], y: p[1], z: p[2] }, 515 emulatedPosition: emulatedPosition, 516 angularVelocity: null, 517 linearVelocity: null, 518 angularAcceleration: null, 519 linearAcceleration: null, 520 inputState: null, 521 poseIndex: 0 522 }; 523 } 524 525 clearViewerOrigin() { 526 this.pose_ = null; 527 } 528 529 setFloorOrigin(floorOrigin) { 530 if (!this.stageParameters_) { 531 this.stageParameters_ = default_stage_parameters; 532 this.stageParameters_.bounds = this.bounds_; 533 } 534 535 // floorOrigin is passed in as mojoFromStage. 536 this.stageParameters_.mojoFromStage = 537 {data: {matrix: getMatrixFromTransform(floorOrigin)}}; 538 539 this._onStageParametersUpdated(); 540 } 541 542 clearFloorOrigin() { 543 if (this.stageParameters_) { 544 this.stageParameters_ = null; 545 this._onStageParametersUpdated(); 546 } 547 } 548 549 setBoundsGeometry(bounds) { 550 if (bounds == null) { 551 this.bounds_ = null; 552 } else if (bounds.length < 3) { 553 throw new Error("Bounds must have a length of at least 3"); 554 } else { 555 this.bounds_ = bounds; 556 } 557 558 // We can only set bounds if we have stageParameters set; otherwise, we 559 // don't know the transform from local space to bounds space. 560 // We'll cache the bounds so that they can be set in the future if the 561 // floorLevel transform is set, but we won't update them just yet. 562 if (this.stageParameters_) { 563 this.stageParameters_.bounds = this.bounds_; 564 this._onStageParametersUpdated(); 565 } 566 } 567 568 simulateResetPose() { 569 this.send_mojo_space_reset_ = true; 570 } 571 572 simulateVisibilityChange(visibilityState) { 573 let mojoState = null; 574 switch (visibilityState) { 575 case "visible": 576 mojoState = vrMojom.XRVisibilityState.VISIBLE; 577 break; 578 case "visible-blurred": 579 mojoState = vrMojom.XRVisibilityState.VISIBLE_BLURRED; 580 break; 581 case "hidden": 582 mojoState = vrMojom.XRVisibilityState.HIDDEN; 583 break; 584 } 585 if (mojoState && this.sessionClient_) { 586 this.sessionClient_.onVisibilityStateChanged(mojoState); 587 } 588 } 589 590 simulateInputSourceConnection(fakeInputSourceInit) { 591 const index = this.next_input_source_index_; 592 this.next_input_source_index_++; 593 594 const source = new MockXRInputSource(fakeInputSourceInit, index, this); 595 this.input_sources_.set(index, source); 596 return source; 597 } 598 599 // WebXR Test API Hit Test extensions 600 setWorld(world) { 601 this.world_ = world; 602 } 603 604 clearWorld() { 605 this.world_ = null; 606 } 607 608 // WebXR Test API Anchor extensions 609 setAnchorCreationCallback(callback) { 610 this.anchor_creation_callback_ = callback; 611 } 612 613 setHitTestSourceCreationCallback(callback) { 614 this.hit_test_source_creation_callback_ = callback; 615 } 616 617 // WebXR Test API Lighting estimation extensions 618 setLightEstimate(fakeXrLightEstimateInit) { 619 if (!fakeXrLightEstimateInit.sphericalHarmonicsCoefficients) { 620 throw new TypeError("sphericalHarmonicsCoefficients must be set"); 621 } 622 623 if (fakeXrLightEstimateInit.sphericalHarmonicsCoefficients.length != 27) { 624 throw new TypeError("Must supply all 27 sphericalHarmonicsCoefficients"); 625 } 626 627 if (fakeXrLightEstimateInit.primaryLightDirection && fakeXrLightEstimateInit.primaryLightDirection.w != 0) { 628 throw new TypeError("W component of primaryLightDirection must be 0"); 629 } 630 631 if (fakeXrLightEstimateInit.primaryLightIntensity && fakeXrLightEstimateInit.primaryLightIntensity.w != 1) { 632 throw new TypeError("W component of primaryLightIntensity must be 1"); 633 } 634 635 // If the primaryLightDirection or primaryLightIntensity aren't set, we need to set them 636 // to the defaults that the spec expects. ArCore will either give us everything or nothing, 637 // so these aren't nullable on the mojom. 638 if (!fakeXrLightEstimateInit.primaryLightDirection) { 639 fakeXrLightEstimateInit.primaryLightDirection = { x: 0.0, y: 1.0, z: 0.0, w: 0.0 }; 640 } 641 642 if (!fakeXrLightEstimateInit.primaryLightIntensity) { 643 fakeXrLightEstimateInit.primaryLightIntensity = { x: 0.0, y: 0.0, z: 0.0, w: 1.0 }; 644 } 645 646 let c = fakeXrLightEstimateInit.sphericalHarmonicsCoefficients; 647 648 this.light_estimate_ = { 649 lightProbe: { 650 // XRSphereicalHarmonics 651 sphericalHarmonics: { 652 coefficients: [ 653 { red: c[0], green: c[1], blue: c[2] }, 654 { red: c[3], green: c[4], blue: c[5] }, 655 { red: c[6], green: c[7], blue: c[8] }, 656 { red: c[9], green: c[10], blue: c[11] }, 657 { red: c[12], green: c[13], blue: c[14] }, 658 { red: c[15], green: c[16], blue: c[17] }, 659 { red: c[18], green: c[19], blue: c[20] }, 660 { red: c[21], green: c[22], blue: c[23] }, 661 { red: c[24], green: c[25], blue: c[26] } 662 ] 663 }, 664 // Vector3dF 665 mainLightDirection: { 666 x: fakeXrLightEstimateInit.primaryLightDirection.x, 667 y: fakeXrLightEstimateInit.primaryLightDirection.y, 668 z: fakeXrLightEstimateInit.primaryLightDirection.z 669 }, 670 // RgbTupleF32 671 mainLightIntensity: { 672 red: fakeXrLightEstimateInit.primaryLightIntensity.x, 673 green: fakeXrLightEstimateInit.primaryLightIntensity.y, 674 blue: fakeXrLightEstimateInit.primaryLightIntensity.z 675 } 676 } 677 } 678 } 679 680 // WebXR Test API depth Sensing Extensions 681 _setDepthSupport(depthSupport) { 682 this.depthSupport_ = {}; 683 684 this.depthSupport_.depthTypes = []; 685 for (const type of (depthSupport.depthTypes || [])) { 686 this.depthSupport_.depthTypes.push(MockRuntime._depthTypeToMojoMap[type]); 687 } 688 689 this.depthSupport_.depthFormats = []; 690 for (const format of (depthSupport.depthFormats || [])) { 691 this.depthSupport_.depthFormats.push(MockRuntime._depthDataFormatToMojoMap[format]); 692 } 693 694 this.depthSupport_.depthUsages = []; 695 for (const usage of (depthSupport.depthUsages || [])) { 696 // Because chrome doesn't support gpu-optimized for any devices at present 697 // avoid "false positive" WPTs by indicating that we don't support 698 // gpu-optimized. 699 if (usage === "gpu-optimized") { 700 continue; 701 } 702 703 this.depthSupport_.depthUsages.push(MockRuntime._depthUsageToMojoMap[usage]); 704 } 705 } 706 707 setDepthSensingData(depthSensingData) { 708 for(const key of ["depthData", "normDepthBufferFromNormView", "rawValueToMeters", "width", "height"]) { 709 if(!(key in depthSensingData)) { 710 throw new TypeError("Required key not present. Key: " + key); 711 } 712 } 713 714 if(depthSensingData.depthData != null) { 715 // Create new object w/ properties based on the depthSensingData, but 716 // convert the FakeXRRigidTransformInit into a transformation matrix object. 717 this.depthSensingData_ = Object.assign({}, 718 depthSensingData, { 719 normDepthBufferFromNormView: composeGFXTransform(depthSensingData.normDepthBufferFromNormView), 720 }); 721 } else { 722 throw new TypeError("`depthData` is not set"); 723 } 724 725 this.depthSensingDataDirty_ = true; 726 } 727 728 clearDepthSensingData() { 729 this.depthSensingData_ = null; 730 this.depthSensingDataDirty_ = true; 731 } 732 733 // Internal Implementation/Helper Methods 734 _convertModeToEnum(sessionMode) { 735 if (sessionMode in MockRuntime._sessionModeToMojoMap) { 736 return MockRuntime._sessionModeToMojoMap[sessionMode]; 737 } 738 739 throw new TypeError("Unrecognized value for XRSessionMode enum: " + sessionMode); 740 } 741 742 _convertModesToEnum(sessionModes) { 743 return sessionModes.map(mode => this._convertModeToEnum(mode)); 744 } 745 746 _convertBlendModeToEnum(blendMode) { 747 if (blendMode in MockRuntime._environmentBlendModeToMojoMap) { 748 return MockRuntime._environmentBlendModeToMojoMap[blendMode]; 749 } else { 750 if (this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) { 751 return vrMojom.XREnvironmentBlendMode.kAdditive; 752 } else if (this.supportedModes_.includes( 753 xrSessionMojom.XRSessionMode.kImmersiveVr)) { 754 return vrMojom.XREnvironmentBlendMode.kOpaque; 755 } 756 } 757 } 758 759 _convertInteractionModeToEnum(interactionMode) { 760 if (interactionMode in MockRuntime._interactionModeToMojoMap) { 761 return MockRuntime._interactionModeToMojoMap[interactionMode]; 762 } else { 763 return vrMojom.XRInteractionMode.kWorldSpace; 764 } 765 } 766 767 _setViews(deviceViews, xOffset, views) { 768 for (let i = 0; i < deviceViews.length; i++) { 769 views[i] = this._getView(deviceViews[i], xOffset); 770 xOffset += deviceViews[i].resolution.width; 771 } 772 773 return xOffset; 774 } 775 776 _findCameraImage(views) { 777 const viewWithCamera = views.find(view => view.cameraImageInit); 778 if (viewWithCamera) { 779 //If we have one view with a camera resolution, all views should have the same camera resolution. 780 const allViewsHaveSameCamera = views.every( 781 view => isSameCameraImageInit(view.cameraImageInit, viewWithCamera.cameraImageInit)); 782 783 if (!allViewsHaveSameCamera) { 784 throw new Error("If present, camera resolutions on each view must match each other!"); 785 } 786 787 return viewWithCamera.cameraImageInit; 788 } 789 790 return null; 791 } 792 793 _onStageParametersUpdated() { 794 // Indicate for the frame loop that the stage parameters have been updated. 795 this.stageParametersId_++; 796 } 797 798 _getDefaultViews() { 799 if (this.primaryViews_) { 800 return this.primaryViews_; 801 } 802 803 const viewport_size = 20; 804 return [{ 805 eye: vrMojom.XREye.kLeft, 806 geometry: { 807 fieldOfView: { 808 upDegrees: 48.316, 809 downDegrees: 50.099, 810 leftDegrees: 50.899, 811 rightDegrees: 35.197 812 }, 813 mojoFromView: this._getMojoFromViewerWithOffset(composeGFXTransform({ 814 position: [-0.032, 0, 0], 815 orientation: [0, 0, 0, 1] 816 })) 817 }, 818 viewport: { x: 0, y: 0, width: viewport_size, height: viewport_size } 819 }, 820 { 821 eye: vrMojom.XREye.kRight, 822 geometry: { 823 fieldOfView: { 824 upDegrees: 48.316, 825 downDegrees: 50.099, 826 leftDegrees: 50.899, 827 rightDegrees: 35.197 828 }, 829 mojoFromView: this._getMojoFromViewerWithOffset(composeGFXTransform({ 830 position: [0.032, 0, 0], 831 orientation: [0, 0, 0, 1] 832 })) 833 }, 834 viewport: { x: viewport_size, y: 0, width: viewport_size, height: viewport_size } 835 }]; 836 } 837 838 _getFovFromProjectionMatrix(projectionMatrix) { 839 const m = projectionMatrix; 840 841 function toDegrees(tan) { 842 return Math.atan(tan) * 180 / Math.PI; 843 } 844 845 const leftTan = (1 - m[8]) / m[0]; 846 const rightTan = (1 + m[8]) / m[0]; 847 const upTan = (1 + m[9]) / m[5]; 848 const downTan = (1 - m[9]) / m[5]; 849 850 return { 851 upDegrees: toDegrees(upTan), 852 downDegrees: toDegrees(downTan), 853 leftDegrees: toDegrees(leftTan), 854 rightDegrees: toDegrees(rightTan) 855 }; 856 } 857 858 // This function converts between the matrix provided by the WebXR test API 859 // and the internal data representation. 860 _getView(fakeXRViewInit, xOffset) { 861 let fov = null; 862 863 if (fakeXRViewInit.fieldOfView) { 864 fov = { 865 upDegrees: fakeXRViewInit.fieldOfView.upDegrees, 866 downDegrees: fakeXRViewInit.fieldOfView.downDegrees, 867 leftDegrees: fakeXRViewInit.fieldOfView.leftDegrees, 868 rightDegrees: fakeXRViewInit.fieldOfView.rightDegrees 869 }; 870 } else { 871 fov = this._getFovFromProjectionMatrix(fakeXRViewInit.projectionMatrix); 872 } 873 874 let viewEye = vrMojom.XREye.kNone; 875 // The eye passed in corresponds to the values in the WebXR spec, which are 876 // the strings "none", "left", and "right". They should be converted to the 877 // corresponding values of XREye in vr_service.mojom. 878 switch(fakeXRViewInit.eye) { 879 case "none": 880 viewEye = vrMojom.XREye.kNone; 881 break; 882 case "left": 883 viewEye = vrMojom.XREye.kLeft; 884 break; 885 case "right": 886 viewEye = vrMojom.XREye.kRight; 887 break; 888 } 889 890 let visibilityMask = null; 891 if (fakeXRViewInit.visibilityMask) { 892 let maskInit = fakeXRViewInit.visibilityMask; 893 visibilityMask = { 894 unvalidatedIndices: maskInit.indices, 895 vertices: [] 896 }; 897 for (let i = 0; i + 1 < maskInit.vertices.length; i+= 2) { 898 visibilityMask.vertices.push( { x: maskInit.vertices[i], y: maskInit.vertices[i+1]}); 899 } 900 } 901 902 return { 903 eye: viewEye, 904 geometry: { 905 fieldOfView: fov, 906 mojoFromView: this._getMojoFromViewerWithOffset(composeGFXTransform(fakeXRViewInit.viewOffset)), 907 // Mojo will ignore extra members, we stash the raw projection matrix 908 // here for ease of use with the depth extensions. 909 projectionMatrix: fakeXRViewInit.projectionMatrix, 910 }, 911 viewport: { 912 x: xOffset, 913 y: 0, 914 width: fakeXRViewInit.resolution.width, 915 height: fakeXRViewInit.resolution.height 916 }, 917 isFirstPersonObserver: fakeXRViewInit.isFirstPersonObserver ? true : false, 918 viewOffset: composeGFXTransform(fakeXRViewInit.viewOffset), 919 visibilityMask: visibilityMask, 920 visibilityMaskId: { idValue : this.nextVisibilityMaskId_++ } 921 }; 922 923 } 924 925 _setFeatures(supportedFeatures) { 926 function convertFeatureToMojom(feature) { 927 if (feature in MockRuntime._featureToMojoMap) { 928 return MockRuntime._featureToMojoMap[feature]; 929 } else { 930 return xrSessionMojom.XRSessionFeature.INVALID; 931 } 932 } 933 934 this.supportedFeatures_ = []; 935 936 for (let i = 0; i < supportedFeatures.length; i++) { 937 const feature = convertFeatureToMojom(supportedFeatures[i]); 938 if (feature !== xrSessionMojom.XRSessionFeature.INVALID) { 939 this.supportedFeatures_.push(feature); 940 } 941 } 942 } 943 944 // These methods are intended to be used by MockXRInputSource only. 945 _addInputSource(source) { 946 if (!this.input_sources_.has(source.source_id_)) { 947 this.input_sources_.set(source.source_id_, source); 948 } 949 } 950 951 _removeInputSource(source) { 952 this.input_sources_.delete(source.source_id_); 953 } 954 955 // These methods are intended to be used by FakeXRAnchorController only. 956 _deleteAnchorController(controllerId) { 957 this.anchor_controllers_.delete(controllerId); 958 } 959 960 // Extension point for non-standard modules. 961 _injectAdditionalFrameData(options, frameData) { 962 } 963 964 // Mojo function implementations. 965 966 // XRFrameDataProvider implementation. 967 getFrameData(options) { 968 return new Promise((resolve) => { 969 970 const populatePose = () => { 971 const mojo_space_reset = this.send_mojo_space_reset_; 972 this.send_mojo_space_reset_ = false; 973 974 if (this.pose_) { 975 this.pose_.poseIndex++; 976 } 977 978 // Setting the input_state to null tests a slightly different path than 979 // the browser tests where if the last input source is removed, the device 980 // code always sends up an empty array, but it's also valid mojom to send 981 // up a null array. 982 let input_state = null; 983 if (this.input_sources_.size > 0) { 984 input_state = []; 985 for (const input_source of this.input_sources_.values()) { 986 input_state.push(input_source._getInputSourceState()); 987 } 988 } 989 990 let frame_views = this.primaryViews_; 991 for (let i = 0; i < this.primaryViews_.length; i++) { 992 this.primaryViews_[i].geometry.mojoFromView = 993 this._getMojoFromViewerWithOffset(this.primaryViews_[i].viewOffset); 994 } 995 if (this.enabledFeatures_.includes(xrSessionMojom.XRSessionFeature.SECONDARY_VIEWS)) { 996 for (let i = 0; i < this.secondaryViews_.length; i++) { 997 this.secondaryViews_[i].geometry.mojoFromView = 998 this._getMojoFromViewerWithOffset(this.secondaryViews_[i].viewOffset); 999 } 1000 1001 frame_views = frame_views.concat(this.secondaryViews_); 1002 } 1003 1004 const frameData = { 1005 renderInfo: { 1006 frameId: this.next_frame_id_, 1007 mojoFromViewer: this.pose_, 1008 views: frame_views 1009 }, 1010 mojoSpaceReset: mojo_space_reset, 1011 inputState: input_state, 1012 timeDelta: { 1013 // window.performance.now() is in milliseconds, so convert to microseconds. 1014 microseconds: BigInt(Math.floor(window.performance.now() * 1000)), 1015 }, 1016 bufferHolder: null, 1017 cameraImageSize: this.cameraImage_ ? { 1018 width: this.cameraImage_.width, 1019 height: this.cameraImage_.height 1020 } : null, 1021 renderingTimeRatio: 0, 1022 stageParameters: this.stageParameters_, 1023 stageParametersId: this.stageParametersId_, 1024 lightEstimationData: this.light_estimate_ 1025 }; 1026 1027 this.next_frame_id_++; 1028 1029 this._calculateHitTestResults(frameData); 1030 1031 this._calculateAnchorInformation(frameData); 1032 1033 if (options.depthActive) { 1034 this._calculateDepthInformation(frameData); 1035 } 1036 1037 this._injectAdditionalFrameData(options, frameData); 1038 1039 resolve({frameData}); 1040 }; 1041 1042 if(this.sessionOptions_.mode == xrSessionMojom.XRSessionMode.kInline) { 1043 // Inline sessions should not have a delay introduced since it causes them 1044 // to miss a vsync blink-side and delays propagation of changes that happened 1045 // within a rAFcb by one frame (e.g. setViewerOrigin() calls would take 2 frames 1046 // to propagate). 1047 populatePose(); 1048 } else { 1049 // For immerive sessions, add additional delay to allow for anchor creation 1050 // promises to run. 1051 setTimeout(populatePose, 3); // note: according to MDN, the timeout is not exact 1052 } 1053 }); 1054 } 1055 1056 getEnvironmentIntegrationProvider(environmentProviderRequest) { 1057 if (this.environmentProviderReceiver_) { 1058 this.environmentProviderReceiver_.$.close(); 1059 } 1060 this.environmentProviderReceiver_ = 1061 new vrMojom.XREnvironmentIntegrationProviderReceiver(this); 1062 this.environmentProviderReceiver_.$.bindHandle( 1063 environmentProviderRequest.handle); 1064 } 1065 1066 // XREnvironmentIntegrationProvider implementation: 1067 subscribeToHitTest(nativeOriginInformation, entityTypes, ray) { 1068 if (!this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) { 1069 // Reject outside of AR. 1070 return Promise.resolve({ 1071 subscriptionId : null 1072 }); 1073 } 1074 1075 if (!this._nativeOriginKnown(nativeOriginInformation)) { 1076 return Promise.resolve({ 1077 subscriptionId : null 1078 }); 1079 } 1080 1081 // Reserve the id for hit test source: 1082 const id = this.next_hit_test_id_++; 1083 const hitTestParameters = { isTransient: false, profileName: null }; 1084 const controller = new FakeXRHitTestSourceController(id); 1085 1086 1087 return this._shouldHitTestSourceCreationSucceed(hitTestParameters, controller) 1088 .then((succeeded) => { 1089 if(succeeded) { 1090 // Store the subscription information as-is (including controller): 1091 this.hitTestSubscriptions_.set(id, { nativeOriginInformation, entityTypes, ray, controller }); 1092 1093 return Promise.resolve({ 1094 subscriptionId : { idValue : id } 1095 }); 1096 } else { 1097 return Promise.resolve({ 1098 subscriptionId : null 1099 }); 1100 } 1101 }); 1102 } 1103 1104 subscribeToHitTestForTransientInput(profileName, entityTypes, ray){ 1105 if (!this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) { 1106 // Reject outside of AR. 1107 return Promise.resolve({ 1108 subscriptionId : null 1109 }); 1110 } 1111 1112 const id = this.next_hit_test_id_++; 1113 const hitTestParameters = { isTransient: true, profileName: profileName }; 1114 const controller = new FakeXRHitTestSourceController(id); 1115 1116 // Check if we have hit test source creation callback. 1117 // If yes, ask it if the hit test source creation should succeed. 1118 // If no, for back-compat, assume the hit test source creation succeeded. 1119 return this._shouldHitTestSourceCreationSucceed(hitTestParameters, controller) 1120 .then((succeeded) => { 1121 if(succeeded) { 1122 // Store the subscription information as-is (including controller): 1123 this.transientHitTestSubscriptions_.set(id, { profileName, entityTypes, ray, controller }); 1124 1125 return Promise.resolve({ 1126 subscriptionId : { idValue : id } 1127 }); 1128 } else { 1129 return Promise.resolve({ 1130 subscriptionId : null 1131 }); 1132 } 1133 }); 1134 } 1135 1136 unsubscribeFromHitTest(subscriptionId) { 1137 let id = subscriptionId.idValue; 1138 let controller = null; 1139 if(this.transientHitTestSubscriptions_.has(id)){ 1140 controller = this.transientHitTestSubscriptions_.get(id).controller; 1141 this.transientHitTestSubscriptions_.delete(id); 1142 } else if(this.hitTestSubscriptions_.has(id)){ 1143 controller = this.hitTestSubscriptions_.get(id).controller; 1144 this.hitTestSubscriptions_.delete(id); 1145 } 1146 1147 if(controller) { 1148 controller.deleted = true; 1149 } 1150 } 1151 1152 createAnchor(nativeOriginInformation, nativeOriginFromAnchor, planeId) { 1153 return new Promise((resolve) => { 1154 if(this.anchor_creation_callback_ == null) { 1155 resolve({ 1156 anchorId : null 1157 }); 1158 1159 return; 1160 } 1161 1162 const mojoFromNativeOrigin = this._getMojoFromNativeOrigin(nativeOriginInformation); 1163 if(mojoFromNativeOrigin == null) { 1164 resolve({ 1165 anchorId : null 1166 }); 1167 1168 return; 1169 } 1170 1171 const mojoFromAnchor = XRMathHelper.mul4x4(mojoFromNativeOrigin, nativeOriginFromAnchor); 1172 1173 const anchorCreationParameters = { 1174 requestedAnchorOrigin: mojoFromAnchor, 1175 isAttachedToEntity: false, 1176 }; 1177 1178 const anchorController = new FakeXRAnchorController(); 1179 1180 this.anchor_creation_callback_(anchorCreationParameters, anchorController) 1181 .then((result) => { 1182 if(result) { 1183 // If the test allowed the anchor creation, 1184 // store the anchor controller & return success. 1185 1186 const anchor_id = this.next_anchor_id_; 1187 this.next_anchor_id_++; 1188 1189 this.anchor_controllers_.set(anchor_id, anchorController); 1190 anchorController.device = this; 1191 anchorController.id = anchor_id; 1192 1193 resolve({ 1194 anchorId : { 1195 idValue: anchor_id 1196 } 1197 }); 1198 } else { 1199 // The test has rejected anchor creation. 1200 resolve({ 1201 anchorId : null 1202 }); 1203 } 1204 }) 1205 .catch(() => { 1206 // The test threw an error, treat anchor creation as failed. 1207 resolve({ 1208 anchorId : null 1209 }); 1210 }); 1211 }); 1212 } 1213 1214 detachAnchor(anchorId) {} 1215 1216 // Utility function 1217 _requestRuntimeSession(sessionOptions) { 1218 return this._runtimeSupportsSession(sessionOptions).then((result) => { 1219 // The JavaScript bindings convert c_style_names to camelCase names. 1220 const options = { 1221 transportMethod: 1222 vrMojom.XRPresentationTransportMethod.SUBMIT_AS_MAILBOX_HOLDER, 1223 waitForTransferNotification: true, 1224 waitForRenderNotification: true, 1225 waitForGpuFence: false, 1226 }; 1227 1228 let submit_frame_sink; 1229 if (result.supportsSession) { 1230 submit_frame_sink = { 1231 clientReceiver: this.presentation_provider_._getClientReceiver(), 1232 provider: this.presentation_provider_._bindProvider(sessionOptions), 1233 transportOptions: options 1234 }; 1235 1236 const dataProviderPtr = new vrMojom.XRFrameDataProviderRemote(); 1237 this.dataProviderReceiver_ = 1238 new vrMojom.XRFrameDataProviderReceiver(this); 1239 this.dataProviderReceiver_.$.bindHandle( 1240 dataProviderPtr.$.bindNewPipeAndPassReceiver().handle); 1241 this.sessionOptions_ = sessionOptions; 1242 1243 this.sessionClient_ = new vrMojom.XRSessionClientRemote(); 1244 const clientReceiver = this.sessionClient_.$.bindNewPipeAndPassReceiver(); 1245 1246 const enabled_features = []; 1247 for (let i = 0; i < sessionOptions.requiredFeatures.length; i++) { 1248 const feature = sessionOptions.requiredFeatures[i]; 1249 if (this._maybeEnableFeature(feature, sessionOptions)) { 1250 enabled_features.push(feature); 1251 } else { 1252 return Promise.resolve({session: null}); 1253 } 1254 } 1255 1256 for (let i =0; i < sessionOptions.optionalFeatures.length; i++) { 1257 const feature = sessionOptions.optionalFeatures[i]; 1258 if (this._maybeEnableFeature(feature, sessionOptions)) { 1259 enabled_features.push(feature); 1260 } 1261 } 1262 1263 this.enabledFeatures_ = enabled_features; 1264 1265 return Promise.resolve({ 1266 session: { 1267 submitFrameSink: submit_frame_sink, 1268 dataProvider: dataProviderPtr, 1269 clientReceiver: clientReceiver, 1270 enabledFeatures: enabled_features, 1271 deviceConfig: { 1272 defaultFramebufferScale: this.defaultFramebufferScale_, 1273 supportsViewportScaling: true, 1274 // If depth was not enabled above, this should be null. 1275 depthConfiguration: this.depthConfiguration_, 1276 views: this._getDefaultViews(), 1277 }, 1278 enviromentBlendMode: this.enviromentBlendMode_, 1279 interactionMode: this.interactionMode_ 1280 } 1281 }); 1282 } else { 1283 return Promise.resolve({session: null}); 1284 } 1285 }); 1286 } 1287 1288 _runtimeSupportsSession(options) { 1289 let result = this.supportedModes_.includes(options.mode); 1290 return Promise.resolve({ 1291 supportsSession: result, 1292 }); 1293 } 1294 1295 _tryGetDepthConfig(options) { 1296 if (!options.depthOptions) { 1297 return null; 1298 } 1299 1300 // At present, there are only two depth usages, and we only support CPU. 1301 if (options.depthOptions.usagePreferences.length !== 0 && 1302 !options.depthOptions.usagePreferences.includes( 1303 xrSessionMojom.XRDepthUsage.kCPUOptimized)) { 1304 return null; 1305 } 1306 const selectedUsage = xrSessionMojom.XRDepthUsage.kCPUOptimized; 1307 1308 let selectedFormat = null; 1309 if (options.depthOptions.dataFormatPreferences.length === 0) { 1310 selectedFormat = this.depthSupport_.depthFormats.length === 0 ? 1311 xrSessionMojom.XRDepthDataFormat.kLuminanceAlpha : this.depthSupport_.depthFormats[0]; 1312 } else { 1313 for (const dataFormatRequest of options.depthOptions.dataFormatPreferences) { 1314 if (this.depthSupport_.depthFormats.length === 0 || 1315 this.depthSupport_.depthFormats.includes(dataFormatRequest)) { 1316 selectedFormat = dataFormatRequest; 1317 break; 1318 } 1319 } 1320 } 1321 1322 if (selectedFormat === null) { 1323 return null; 1324 } 1325 1326 // Default to our first supported depth type. If it's empty (meaning all), 1327 // then just default to raw. 1328 let selectedDepthType = this.depthSupport_.depthTypes.length === 0 ? 1329 xrSessionMojom.XRDepthType.kRawDepth : this.depthSupport_.depthTypes[0]; 1330 // Try to set the depthType to the earliest requested one if it's supported. 1331 for (const depthTypeRequest of options.depthOptions.depthTypeRequest) { 1332 if (this.depthSupport_.depthTypes.length === 0 || 1333 this.depthSupport_.depthTypes.includes(depthTypeRequest)) { 1334 selectedDepthType = depthTypeRequest; 1335 break; 1336 } 1337 } 1338 1339 return { 1340 depthUsage: selectedUsage, 1341 depthDataFormat: selectedFormat, 1342 depthType: selectedDepthType, 1343 }; 1344 } 1345 1346 _maybeEnableFeature(feature, options) { 1347 if (this.supportedFeatures_.indexOf(feature) === -1) { 1348 return false; 1349 } 1350 1351 switch (feature) { 1352 case xrSessionMojom.XRSessionFeature.DEPTH: 1353 this.depthConfiguration_ = this._tryGetDepthConfig(options); 1354 this.matchDepthView_ = options.depthOptions && options.depthOptions.matchDepthView; 1355 return this.depthConfiguration_ != null; 1356 default: 1357 return true; 1358 } 1359 } 1360 1361 // Private functions - utilities: 1362 _nativeOriginKnown(nativeOriginInformation){ 1363 1364 if (nativeOriginInformation.inputSourceSpaceInfo !== undefined) { 1365 if (!this.input_sources_.has(nativeOriginInformation.inputSourceSpaceInfo.inputSourceId)) { 1366 // Unknown input source. 1367 return false; 1368 } 1369 1370 return true; 1371 } else if (nativeOriginInformation.referenceSpaceType !== undefined) { 1372 // Bounded_floor & unbounded ref spaces are not yet supported for AR: 1373 if (nativeOriginInformation.referenceSpaceType == vrMojom.XRReferenceSpaceType.kUnbounded 1374 || nativeOriginInformation.referenceSpaceType == vrMojom.XRReferenceSpaceType.kBoundedFloor) { 1375 return false; 1376 } 1377 1378 return true; 1379 } else { 1380 // Planes and anchors are not yet supported by the mock interface. 1381 return false; 1382 } 1383 } 1384 1385 // Private functions - anchors implementation: 1386 1387 // Modifies passed in frameData to add anchor information. 1388 _calculateAnchorInformation(frameData) { 1389 if (!this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) { 1390 return; 1391 } 1392 1393 frameData.anchorsData = {allAnchorsIds: [], updatedAnchorsData: []}; 1394 for(const [id, controller] of this.anchor_controllers_) { 1395 frameData.anchorsData.allAnchorsIds.push({ idValue : id }); 1396 1397 // Send the entire anchor data over if there was a change since last GetFrameData(). 1398 if(controller.dirty) { 1399 const anchorData = { id : { idValue : id }}; 1400 if(!controller.paused) { 1401 anchorData.mojoFromAnchor = getPoseFromTransform( 1402 XRMathHelper.decomposeRigidTransform( 1403 controller._getAnchorOrigin())); 1404 } 1405 1406 controller._markProcessed(); 1407 1408 frameData.anchorsData.updatedAnchorsData.push(anchorData); 1409 } 1410 } 1411 } 1412 1413 // Private functions - depth sensing implementation: 1414 1415 /** 1416 * Helper to get a TypedArray view for a given depth format. 1417 * @param {ArrayBuffer} buffer The ArrayBuffer. 1418 * @param {xrSessionMojom.XRDepthDataFormat} format The depth format. 1419 * @return {Uint16Array|Float32Array} A typed array view. 1420 */ 1421 static _getTypedArrayForFormat(buffer, format) { 1422 if (format === xrSessionMojom.XRDepthDataFormat.kFloat32) { 1423 return new Float32Array(buffer); 1424 } else { // "luminance-alpha" or "unsigned-short" 1425 return new Uint16Array(buffer); 1426 } 1427 } 1428 1429 /** 1430 * Helper to get a TypedArray for the given depth format. 1431 * @param {xrSessionMojom.XRDepthDataFormat} format - The Depth format 1432 * @param {number} size - The size of the array to be created. 1433 * @return {Uint16Array|Float32Array} A typed array view. 1434 */ 1435 static _getEmptyTypedArrayForFormat(format, size) { 1436 if (format === xrSessionMojom.XRDepthDataFormat.kFloat32) { 1437 return new Float32Array(size).fill(0.0); 1438 } else { // "luminance-alpha" or "unsigned-short" (Uint16) 1439 return new Uint16Array(size).fill(0); 1440 } 1441 } 1442 1443 /** 1444 * Reprojects depth data from a source view to a target view. 1445 * The returned array will be the same width/height, but will be returned as a 1446 * Uint8Array of the targetFormat (essentially a byte array that can be sent 1447 * across mojo), so the overall returned size may be different. 1448 * 1449 * @param {ArrayBuffer} sourceDepthArrayBuffer - Raw depth data for the source. 1450 * @param {number} width - Width of the depth data. 1451 * @param {number} height - Height of the depth data. 1452 * @param {xrSessionMojom.XRDepthDataFormat} sourceFormatEnum - Format of the source depth data. 1453 * @param {Float32Array} sourceClipFromSourceView - Projection matrix for the source view. 1454 * @param {Float32Array} mojoFromSourceView- Matrix of the transform for the source view. 1455 * @param {xrSessionMojom.XRDepthDataFormat} targetFormatEnum - Format of the target depth data. 1456 * @param {Float32Array} targetClipFromTargetView - Projection matrix for the target view. 1457 * @param {Float32Array} mojoFromTargetView - Matrix of the transform for the target view. 1458 * @return {Uint8Array | null} The reprojected depth data as an Uint8Array, or null on matrix error. 1459 */ 1460 static copyDepthData( 1461 sourceDepthArrayBuffer, width, height, sourceFormatEnum, 1462 sourceClipFromSourceView, mojoFromSourceView, 1463 targetFormatEnum, 1464 targetClipFromTargetView, mojoFromTargetView) { 1465 1466 const targetViewFromTargetClip = XRMathHelper.inverse(targetClipFromTargetView); 1467 const sourceViewFromMojo = XRMathHelper.inverse(mojoFromSourceView); 1468 1469 // Check if any matrices were not supplied or matrix inversions failed. 1470 if (!targetViewFromTargetClip || !sourceViewFromMojo || !mojoFromTargetView) { 1471 return null; 1472 } 1473 1474 // Build the full transformation from Target Clip space to Source Clip space. 1475 const mojoFromTargetClip = XRMathHelper.mul4x4(mojoFromTargetView, targetViewFromTargetClip); 1476 if (!mojoFromTargetClip) return null; 1477 1478 const sourceViewFromTargetClip = XRMathHelper.mul4x4(sourceViewFromMojo, mojoFromTargetClip); 1479 if (!sourceViewFromTargetClip) return null; 1480 1481 const sourceClipFromTargetClip = XRMathHelper.mul4x4(sourceClipFromSourceView, sourceViewFromTargetClip); 1482 if (!sourceClipFromTargetClip) return null; 1483 1484 const sourceTypedArray = MockRuntime._getTypedArrayForFormat(sourceDepthArrayBuffer, sourceFormatEnum); 1485 let internalTargetDepthTypedArray = MockRuntime._getEmptyTypedArrayForFormat(targetFormatEnum, width * height); 1486 1487 // Iterate over target pixels (Backward Mapping) 1488 for (let ty = 0; ty < height; ++ty) { 1489 for (let tx = 0; tx < width; ++tx) { 1490 // Convert target pixel (tx, ty) to target NDC coordinates 1491 const u_tgt_pixel = (tx + 0.5) / width; // u in [0, 1], Y-down from top-left 1492 const v_tgt_pixel = (ty + 0.5) / height; // v in [0, 1], Y-down from top-left 1493 1494 const ndc_x_tgt = u_tgt_pixel * 2.0 - 1.0; // NDC X in [-1, 1] 1495 const ndc_y_tgt = 1.0 - v_tgt_pixel * 2.0; // NDC Y in [-1, 1], Y-up 1496 1497 // Define a point on the near plane in target clip space 1498 const P_clip_tgt = { x: ndc_x_tgt, y: ndc_y_tgt, z: -1.0, w: 1.0 }; 1499 1500 // Transform this point to source clip space 1501 const P_clip_src = XRMathHelper.transform_by_matrix(sourceClipFromTargetClip, P_clip_tgt); 1502 1503 // Homogenize to get source NDC coordinates 1504 if (Math.abs(P_clip_src.w) < XRMathHelper.EPSILON) { 1505 internalTargetDepthTypedArray[ty * width + tx] = 0; // Cannot project 1506 continue; 1507 } 1508 const ndc_x_src = P_clip_src.x / P_clip_src.w; 1509 const ndc_y_src = P_clip_src.y / P_clip_src.w; 1510 1511 // Convert source NDC to source pixel coordinates 1512 const u_src_pixel = (ndc_x_src + 1.0) / 2.0; 1513 const v_src_pixel = (1.0 - ndc_y_src) / 2.0; // Convert source NDC Y-up to pixel Y-down 1514 1515 const sx = Math.floor(u_src_pixel * width); 1516 const sy = Math.floor(v_src_pixel * height); 1517 1518 let target_raw_depth = 0; // Default to 0 (no data) 1519 1520 // Check if the calculated source pixel is within bounds 1521 if (sx >= 0 && sx < width && sy >= 0 && sy < height) { 1522 const source_raw_value = sourceTypedArray[sy * width + sx]; 1523 1524 let isValidSourceDepth = false; 1525 if (sourceFormatEnum === xrSessionMojom.XRDepthDataFormat.kFloat32) { 1526 if (source_raw_value > 0 && isFinite(source_raw_value)) { 1527 isValidSourceDepth = true; 1528 } 1529 } else { // Uint16 source 1530 if (source_raw_value > 0) { 1531 isValidSourceDepth = true; 1532 } 1533 } 1534 1535 if (isValidSourceDepth) { 1536 if (targetFormatEnum === xrSessionMojom.XRDepthDataFormat.kFloat32) { 1537 target_raw_depth = source_raw_value; 1538 } else { 1539 // Clamp to the valid range for Uint16 1540 target_raw_depth = Math.max(0, Math.min(0xFFFF, Math.round(source_raw_value))); 1541 } 1542 } 1543 } 1544 1545 // If not in bounds or source depth invalid, target_raw_depth remains 0. 1546 internalTargetDepthTypedArray[ty * width + tx] = target_raw_depth; 1547 } 1548 } 1549 1550 return new Uint8Array(internalTargetDepthTypedArray.buffer); 1551 } 1552 1553 _getDepthPixelData(depthGeometry) { 1554 if (!this.matchDepthView_ || !depthGeometry) { 1555 return { bytes: this.depthSensingData_.depthData }; 1556 } 1557 1558 const sourceProjectionMatrix = depthGeometry.projectionMatrix; 1559 const sourceViewOffset = depthGeometry.mojoFromView; 1560 if (!sourceProjectionMatrix || !sourceViewOffset) { 1561 return { bytes: this.depthSensingData_.depthData }; 1562 } 1563 1564 if (this.primaryViews_.length === 0) { 1565 return { bytes: this.depthSensingData_.depthData }; 1566 } 1567 1568 const targetView = this.primaryViews_[0]; 1569 const targetProjectionMatrix = targetView.geometry.projectionMatrix; 1570 const targetViewOffset = targetView.geometry.mojoFromView; 1571 if (!targetProjectionMatrix || !targetViewOffset) { 1572 return { bytes: this.depthSensingData_.depthData }; 1573 } 1574 1575 return { bytes: MockRuntime.copyDepthData( 1576 this.depthSensingData_.depthData, 1577 this.depthSensingData_.width, 1578 this.depthSensingData_.height, 1579 MockRuntime._depthDataFormatToMojoMap[this.depthSensingData_.depthFormat], 1580 sourceProjectionMatrix, 1581 sourceViewOffset.data.matrix, 1582 this.depthConfiguration_.depthDataFormat, 1583 targetProjectionMatrix, 1584 targetViewOffset.data.matrix 1585 )}; 1586 } 1587 1588 // Modifies passed in frameData to add anchor information. 1589 _calculateDepthInformation(frameData) { 1590 if (!this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) { 1591 return; 1592 } 1593 1594 if (!this.enabledFeatures_.includes(xrSessionMojom.XRSessionFeature.DEPTH)) { 1595 return; 1596 } 1597 1598 let newDepthData; 1599 1600 // If we don't have a current depth data, we'll return null 1601 // (i.e. no data is not a valid data, so it cannot be "StillValid"). 1602 if (this.depthSensingData_ == null) { 1603 newDepthData = null; 1604 } else if(!this.depthSensingDataDirty_) { 1605 newDepthData = { dataStillValid: {}}; 1606 } else { 1607 let viewGeometry = null; 1608 const projectionMatrix = this.depthSensingData_.projectionMatrix; 1609 const viewOffset = this.depthSensingData_.viewOffset; 1610 1611 if (projectionMatrix && viewOffset) { 1612 const fov = this._getFovFromProjectionMatrix(projectionMatrix); 1613 1614 viewGeometry = { 1615 fieldOfView: fov, 1616 mojoFromView: this._getMojoFromViewerWithOffset(composeGFXTransform(viewOffset)), 1617 // Convenience member for `_getDepthPixelData` 1618 projectionMatrix: projectionMatrix 1619 }; 1620 } 1621 1622 newDepthData = { 1623 updatedDepthData: { 1624 timeDelta: frameData.timeDelta, 1625 normTextureFromNormView: this.depthSensingData_.normDepthBufferFromNormView, 1626 rawValueToMeters: this.depthSensingData_.rawValueToMeters, 1627 size: { width: this.depthSensingData_.width, height: this.depthSensingData_.height }, 1628 pixelData: this._getDepthPixelData(viewGeometry), 1629 viewGeometry: this.matchDepthView_ ? null : viewGeometry 1630 } 1631 }; 1632 } 1633 1634 for (let i = 0; i < this.primaryViews_.length; i++) { 1635 this.primaryViews_[i].depthData = newDepthData; 1636 } 1637 if (this.enabledFeatures_.includes(xrSessionMojom.XRSessionFeature.SECONDARY_VIEWS)) { 1638 for (let i = 0; i < this.secondaryViews_.length; i++) { 1639 this.secondaryViews_[i].depthData = newDepthData; 1640 } 1641 } 1642 1643 this.depthSensingDataDirty_ = false; 1644 } 1645 1646 // Private functions - hit test implementation: 1647 1648 // Returns a Promise<bool> that signifies whether hit test source creation should succeed. 1649 // If we have a hit test source creation callback installed, invoke it and return its result. 1650 // If it's not installed, for back-compat just return a promise that resolves to true. 1651 _shouldHitTestSourceCreationSucceed(hitTestParameters, controller) { 1652 if(this.hit_test_source_creation_callback_) { 1653 return this.hit_test_source_creation_callback_(hitTestParameters, controller); 1654 } else { 1655 return Promise.resolve(true); 1656 } 1657 } 1658 1659 // Modifies passed in frameData to add hit test results. 1660 _calculateHitTestResults(frameData) { 1661 if (!this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) { 1662 return; 1663 } 1664 1665 frameData.hitTestSubscriptionResults = {results: [], 1666 transientInputResults: []}; 1667 if (!this.world_) { 1668 return; 1669 } 1670 1671 // Non-transient hit test: 1672 for (const [id, subscription] of this.hitTestSubscriptions_) { 1673 const mojo_from_native_origin = this._getMojoFromNativeOrigin(subscription.nativeOriginInformation); 1674 if (!mojo_from_native_origin) continue; 1675 1676 const [mojo_ray_origin, mojo_ray_direction] = this._transformRayToMojoSpace( 1677 subscription.ray, 1678 mojo_from_native_origin 1679 ); 1680 1681 const results = this._hitTestWorld(mojo_ray_origin, mojo_ray_direction, subscription.entityTypes); 1682 frameData.hitTestSubscriptionResults.results.push( 1683 {subscriptionId: { idValue: id }, hitTestResults: results}); 1684 } 1685 1686 // Transient hit test: 1687 const mojo_from_viewer = this._getMojoFromViewer(); 1688 1689 for (const [id, subscription] of this.transientHitTestSubscriptions_) { 1690 const result = {subscriptionId: { idValue: id }, 1691 inputSourceIdToHitTestResults: new Map()}; 1692 1693 // Find all input sources that match the profile name: 1694 const matching_input_sources = Array.from(this.input_sources_.values()) 1695 .filter(input_source => input_source.profiles_.includes(subscription.profileName)); 1696 1697 for (const input_source of matching_input_sources) { 1698 const mojo_from_native_origin = input_source._getMojoFromInputSource(mojo_from_viewer); 1699 1700 const [mojo_ray_origin, mojo_ray_direction] = this._transformRayToMojoSpace( 1701 subscription.ray, 1702 mojo_from_native_origin 1703 ); 1704 1705 const results = this._hitTestWorld(mojo_ray_origin, mojo_ray_direction, subscription.entityTypes); 1706 1707 result.inputSourceIdToHitTestResults.set(input_source.source_id_, results); 1708 } 1709 1710 frameData.hitTestSubscriptionResults.transientInputResults.push(result); 1711 } 1712 } 1713 1714 // Returns 2-element array [origin, direction] of a ray in mojo space. 1715 // |ray| is expressed relative to native origin. 1716 _transformRayToMojoSpace(ray, mojo_from_native_origin) { 1717 const ray_origin = { 1718 x: ray.origin.x, 1719 y: ray.origin.y, 1720 z: ray.origin.z, 1721 w: 1 1722 }; 1723 const ray_direction = { 1724 x: ray.direction.x, 1725 y: ray.direction.y, 1726 z: ray.direction.z, 1727 w: 0 1728 }; 1729 1730 const mojo_ray_origin = XRMathHelper.transform_by_matrix( 1731 mojo_from_native_origin, 1732 ray_origin); 1733 const mojo_ray_direction = XRMathHelper.transform_by_matrix( 1734 mojo_from_native_origin, 1735 ray_direction); 1736 1737 return [mojo_ray_origin, mojo_ray_direction]; 1738 } 1739 1740 // Hit tests the passed in ray (expressed as origin and direction) against the mocked world data. 1741 _hitTestWorld(origin, direction, entityTypes) { 1742 let result = []; 1743 1744 for (const region of this.world_.hitTestRegions) { 1745 const partial_result = this._hitTestRegion( 1746 region, 1747 origin, direction, 1748 entityTypes); 1749 1750 result = result.concat(partial_result); 1751 } 1752 1753 return result.sort((lhs, rhs) => lhs.distance - rhs.distance).map((hitTest) => { 1754 delete hitTest.distance; 1755 return hitTest; 1756 }); 1757 } 1758 1759 // Hit tests the passed in ray (expressed as origin and direction) against world region. 1760 // |entityTypes| is a set of FakeXRRegionTypes. 1761 // |region| is FakeXRRegion. 1762 // Returns array of XRHitResults, each entry will be decorated with the distance from the ray origin (along the ray). 1763 _hitTestRegion(region, origin, direction, entityTypes) { 1764 const regionNameToMojoEnum = { 1765 "point": vrMojom.EntityTypeForHitTest.POINT, 1766 "plane": vrMojom.EntityTypeForHitTest.PLANE, 1767 "mesh":null 1768 }; 1769 1770 if (!entityTypes.includes(regionNameToMojoEnum[region.type])) { 1771 return []; 1772 } 1773 1774 const result = []; 1775 for (const face of region.faces) { 1776 const maybe_hit = this._hitTestFace(face, origin, direction); 1777 if (maybe_hit) { 1778 result.push(maybe_hit); 1779 } 1780 } 1781 1782 // The results should be sorted by distance and there should be no 2 entries with 1783 // the same distance from ray origin - that would mean they are the same point. 1784 // This situation is possible when a ray intersects the region through an edge shared 1785 // by 2 faces. 1786 return result.sort((lhs, rhs) => lhs.distance - rhs.distance) 1787 .filter((val, index, array) => index === 0 || val.distance !== array[index - 1].distance); 1788 } 1789 1790 // Hit tests the passed in ray (expressed as origin and direction) against a single face. 1791 // |face|, |origin|, and |direction| are specified in world (aka mojo) coordinates. 1792 // |face| is an array of DOMPointInits. 1793 // Returns null if the face does not intersect with the ray, otherwise the result is 1794 // an XRHitResult with matrix describing the pose of the intersection point. 1795 _hitTestFace(face, origin, direction) { 1796 const add = XRMathHelper.add; 1797 const sub = XRMathHelper.sub; 1798 const mul = XRMathHelper.mul; 1799 const normalize = XRMathHelper.normalize; 1800 const dot = XRMathHelper.dot; 1801 const cross = XRMathHelper.cross; 1802 const neg = XRMathHelper.neg; 1803 1804 //1. Calculate plane normal in world coordinates. 1805 const point_A = face.vertices[0]; 1806 const point_B = face.vertices[1]; 1807 const point_C = face.vertices[2]; 1808 1809 const edge_AB = sub(point_B, point_A); 1810 const edge_AC = sub(point_C, point_A); 1811 1812 const normal = normalize(cross(edge_AB, edge_AC)); 1813 1814 const numerator = dot(sub(point_A, origin), normal); 1815 const denominator = dot(direction, normal); 1816 1817 if (Math.abs(denominator) < XRMathHelper.EPSILON) { 1818 // Planes are nearly parallel - there's either infinitely many intersection points or 0. 1819 // Both cases signify a "no hit" for us. 1820 return null; 1821 } else { 1822 // Single intersection point between the infinite plane and the line (*not* ray). 1823 // Need to calculate the hit test matrix taking into account the face vertices. 1824 const distance = numerator / denominator; 1825 if (distance < 0) { 1826 // Line - plane intersection exists, but not the half-line - plane does not. 1827 return null; 1828 } else { 1829 const intersection_point = add(origin, mul(distance, direction)); 1830 // Since we are treating the face as a solid, flip the normal so that its 1831 // half-space will contain the ray origin. 1832 const y_axis = denominator > 0 ? neg(normal) : normal; 1833 1834 let z_axis = null; 1835 const cos_direction_and_y_axis = dot(direction, y_axis); 1836 if (Math.abs(cos_direction_and_y_axis) > (1 - XRMathHelper.EPSILON)) { 1837 // 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. 1838 // Note: this edge case is currently not covered by the spec. 1839 const up = {x: 0.0, y: 1.0, z: 0.0, w: 0.0}; 1840 const right = {x: 1.0, y: 0.0, z: 0.0, w: 0.0}; 1841 1842 z_axis = Math.abs(dot(up, y_axis)) > (1 - XRMathHelper.EPSILON) 1843 ? sub(up, mul(dot(right, y_axis), y_axis)) // `up is also co-linear with hit test normal, use `right` 1844 : sub(up, mul(dot(up, y_axis), y_axis)); // `up` is not co-linear with hit test normal, use it 1845 } else { 1846 // Project the ray direction onto the plane, negate it and use as a Z axis. 1847 z_axis = neg(sub(direction, mul(cos_direction_and_y_axis, y_axis))); // Z should point towards the ray origin, not away. 1848 } 1849 1850 z_axis = normalize(z_axis); 1851 const x_axis = normalize(cross(y_axis, z_axis)); 1852 1853 // Filter out the points not in polygon. 1854 if (!XRMathHelper.pointInFace(intersection_point, face)) { 1855 return null; 1856 } 1857 1858 const hitResult = {}; 1859 hitResult.distance = distance; // Extend the object with additional information used by higher layers. 1860 // It will not be serialized over mojom. 1861 1862 const matrix = new Array(16); 1863 1864 matrix[0] = x_axis.x; 1865 matrix[1] = x_axis.y; 1866 matrix[2] = x_axis.z; 1867 matrix[3] = 0; 1868 1869 matrix[4] = y_axis.x; 1870 matrix[5] = y_axis.y; 1871 matrix[6] = y_axis.z; 1872 matrix[7] = 0; 1873 1874 matrix[8] = z_axis.x; 1875 matrix[9] = z_axis.y; 1876 matrix[10] = z_axis.z; 1877 matrix[11] = 0; 1878 1879 matrix[12] = intersection_point.x; 1880 matrix[13] = intersection_point.y; 1881 matrix[14] = intersection_point.z; 1882 matrix[15] = 1; 1883 1884 hitResult.mojoFromResult = getPoseFromTransform( 1885 XRMathHelper.decomposeRigidTransform(matrix)); 1886 return hitResult; 1887 } 1888 } 1889 } 1890 1891 _getMojoFromViewer() { 1892 if (!this.pose_) { 1893 return XRMathHelper.identity(); 1894 } 1895 const transform = { 1896 position: [ 1897 this.pose_.position.x, 1898 this.pose_.position.y, 1899 this.pose_.position.z], 1900 orientation: [ 1901 this.pose_.orientation.x, 1902 this.pose_.orientation.y, 1903 this.pose_.orientation.z, 1904 this.pose_.orientation.w], 1905 }; 1906 1907 return getMatrixFromTransform(transform); 1908 } 1909 1910 _getMojoFromViewerWithOffset(viewOffset) { 1911 return {data: { matrix: XRMathHelper.mul4x4(this._getMojoFromViewer(), viewOffset.data.matrix) }}; 1912 } 1913 1914 _getMojoFromNativeOrigin(nativeOriginInformation) { 1915 const mojo_from_viewer = this._getMojoFromViewer(); 1916 1917 if (nativeOriginInformation.inputSourceSpaceInfo !== undefined) { 1918 if (!this.input_sources_.has(nativeOriginInformation.inputSourceSpaceInfo.inputSourceId)) { 1919 return null; 1920 } else { 1921 const inputSource = this.input_sources_.get(nativeOriginInformation.inputSourceSpaceInfo.inputSourceId); 1922 return inputSource._getMojoFromInputSource(mojo_from_viewer); 1923 } 1924 } else if (nativeOriginInformation.referenceSpaceType !== undefined) { 1925 switch (nativeOriginInformation.referenceSpaceType) { 1926 case vrMojom.XRReferenceSpaceType.kLocal: 1927 return XRMathHelper.identity(); 1928 case vrMojom.XRReferenceSpaceType.kLocalFloor: 1929 if (this.stageParameters_ == null || this.stageParameters_.mojoFromStage == null) { 1930 console.warn("Standing transform not available."); 1931 return null; 1932 } 1933 return this.stageParameters_.mojoFromStage.data.matrix; 1934 case vrMojom.XRReferenceSpaceType.kViewer: 1935 return mojo_from_viewer; 1936 case vrMojom.XRReferenceSpaceType.kBoundedFloor: 1937 return null; 1938 case vrMojom.XRReferenceSpaceType.kUnbounded: 1939 return null; 1940 default: 1941 throw new TypeError("Unrecognized XRReferenceSpaceType!"); 1942 } 1943 } else { 1944 // Anchors & planes are not yet supported for hit test. 1945 return null; 1946 } 1947 } 1948 } 1949 1950 class MockXRInputSource { 1951 constructor(fakeInputSourceInit, id, pairedDevice) { 1952 this.source_id_ = id; 1953 this.pairedDevice_ = pairedDevice; 1954 this.handedness_ = fakeInputSourceInit.handedness; 1955 this.target_ray_mode_ = fakeInputSourceInit.targetRayMode; 1956 1957 if (fakeInputSourceInit.pointerOrigin == null) { 1958 throw new TypeError("FakeXRInputSourceInit.pointerOrigin is required."); 1959 } 1960 1961 this.setPointerOrigin(fakeInputSourceInit.pointerOrigin); 1962 this.setProfiles(fakeInputSourceInit.profiles); 1963 1964 this.primary_input_pressed_ = false; 1965 if (fakeInputSourceInit.selectionStarted != null) { 1966 this.primary_input_pressed_ = fakeInputSourceInit.selectionStarted; 1967 } 1968 1969 this.primary_input_clicked_ = false; 1970 if (fakeInputSourceInit.selectionClicked != null) { 1971 this.primary_input_clicked_ = fakeInputSourceInit.selectionClicked; 1972 } 1973 1974 this.primary_squeeze_pressed_ = false; 1975 this.primary_squeeze_clicked_ = false; 1976 1977 this.mojo_from_input_ = null; 1978 if (fakeInputSourceInit.gripOrigin != null) { 1979 this.setGripOrigin(fakeInputSourceInit.gripOrigin); 1980 } 1981 1982 // This properly handles if supportedButtons were not specified. 1983 this.setSupportedButtons(fakeInputSourceInit.supportedButtons); 1984 1985 this.emulated_position_ = false; 1986 this.desc_dirty_ = true; 1987 } 1988 1989 // WebXR Test API 1990 setHandedness(handedness) { 1991 if (this.handedness_ != handedness) { 1992 this.desc_dirty_ = true; 1993 this.handedness_ = handedness; 1994 } 1995 } 1996 1997 setTargetRayMode(targetRayMode) { 1998 if (this.target_ray_mode_ != targetRayMode) { 1999 this.desc_dirty_ = true; 2000 this.target_ray_mode_ = targetRayMode; 2001 } 2002 } 2003 2004 setProfiles(profiles) { 2005 this.desc_dirty_ = true; 2006 this.profiles_ = profiles; 2007 } 2008 2009 setGripOrigin(transform, emulatedPosition = false) { 2010 // grip_origin was renamed to mojo_from_input in mojo 2011 this.mojo_from_input_ = composeGFXTransform(transform); 2012 this.emulated_position_ = emulatedPosition; 2013 2014 // Technically, setting the grip shouldn't make the description dirty, but 2015 // the webxr-test-api sets our pointer as mojoFromPointer; however, we only 2016 // support it across mojom as inputFromPointer, so we need to recalculate it 2017 // whenever the grip moves. 2018 this.desc_dirty_ = true; 2019 } 2020 2021 clearGripOrigin() { 2022 // grip_origin was renamed to mojo_from_input in mojo 2023 if (this.mojo_from_input_ != null) { 2024 this.mojo_from_input_ = null; 2025 this.emulated_position_ = false; 2026 this.desc_dirty_ = true; 2027 } 2028 } 2029 2030 setPointerOrigin(transform, emulatedPosition = false) { 2031 // pointer_origin is mojo_from_pointer. 2032 this.desc_dirty_ = true; 2033 this.mojo_from_pointer_ = composeGFXTransform(transform); 2034 this.emulated_position_ = emulatedPosition; 2035 } 2036 2037 disconnect() { 2038 this.pairedDevice_._removeInputSource(this); 2039 } 2040 2041 reconnect() { 2042 this.pairedDevice_._addInputSource(this); 2043 } 2044 2045 startSelection() { 2046 this.primary_input_pressed_ = true; 2047 if (this.gamepad_) { 2048 this.gamepad_.buttons[0].pressed = true; 2049 this.gamepad_.buttons[0].touched = true; 2050 } 2051 } 2052 2053 endSelection() { 2054 if (!this.primary_input_pressed_) { 2055 throw new Error("Attempted to end selection which was not started"); 2056 } 2057 2058 this.primary_input_pressed_ = false; 2059 this.primary_input_clicked_ = true; 2060 2061 if (this.gamepad_) { 2062 this.gamepad_.buttons[0].pressed = false; 2063 this.gamepad_.buttons[0].touched = false; 2064 } 2065 } 2066 2067 simulateSelect() { 2068 this.primary_input_clicked_ = true; 2069 } 2070 2071 setSupportedButtons(supportedButtons) { 2072 this.gamepad_ = null; 2073 this.supported_buttons_ = []; 2074 2075 // If there are no supported buttons, we can stop now. 2076 if (supportedButtons == null || supportedButtons.length < 1) { 2077 return; 2078 } 2079 2080 const supported_button_map = {}; 2081 this.gamepad_ = this._getEmptyGamepad(); 2082 for (let i = 0; i < supportedButtons.length; i++) { 2083 const buttonType = supportedButtons[i].buttonType; 2084 this.supported_buttons_.push(buttonType); 2085 supported_button_map[buttonType] = supportedButtons[i]; 2086 } 2087 2088 // Let's start by building the button state in order of priority: 2089 // Primary button is index 0. 2090 this.gamepad_.buttons.push({ 2091 pressed: this.primary_input_pressed_, 2092 touched: this.primary_input_pressed_, 2093 value: this.primary_input_pressed_ ? 1.0 : 0.0 2094 }); 2095 2096 // Now add the rest of our buttons 2097 this._addGamepadButton(supported_button_map['grip']); 2098 this._addGamepadButton(supported_button_map['touchpad']); 2099 this._addGamepadButton(supported_button_map['thumbstick']); 2100 this._addGamepadButton(supported_button_map['optional-button']); 2101 this._addGamepadButton(supported_button_map['optional-thumbstick']); 2102 2103 // Finally, back-fill placeholder buttons/axes 2104 for (let i = 0; i < this.gamepad_.buttons.length; i++) { 2105 if (this.gamepad_.buttons[i] == null) { 2106 this.gamepad_.buttons[i] = { 2107 pressed: false, 2108 touched: false, 2109 value: 0 2110 }; 2111 } 2112 } 2113 2114 for (let i=0; i < this.gamepad_.axes.length; i++) { 2115 if (this.gamepad_.axes[i] == null) { 2116 this.gamepad_.axes[i] = 0; 2117 } 2118 } 2119 } 2120 2121 updateButtonState(buttonState) { 2122 if (this.supported_buttons_.indexOf(buttonState.buttonType) == -1) { 2123 throw new Error("Tried to update state on an unsupported button"); 2124 } 2125 2126 const buttonIndex = this._getButtonIndex(buttonState.buttonType); 2127 const axesStartIndex = this._getAxesStartIndex(buttonState.buttonType); 2128 2129 if (buttonIndex == -1) { 2130 throw new Error("Unknown Button Type!"); 2131 } 2132 2133 // is this a 'squeeze' button? 2134 if (buttonIndex === this._getButtonIndex('grip')) { 2135 // squeeze 2136 if (buttonState.pressed) { 2137 this.primary_squeeze_pressed_ = true; 2138 } else if (this.gamepad_.buttons[buttonIndex].pressed) { 2139 this.primary_squeeze_clicked_ = true; 2140 this.primary_squeeze_pressed_ = false; 2141 } else { 2142 this.primary_squeeze_clicked_ = false; 2143 this.primary_squeeze_pressed_ = false; 2144 } 2145 } 2146 2147 this.gamepad_.buttons[buttonIndex].pressed = buttonState.pressed; 2148 this.gamepad_.buttons[buttonIndex].touched = buttonState.touched; 2149 this.gamepad_.buttons[buttonIndex].value = buttonState.pressedValue; 2150 2151 if (axesStartIndex != -1) { 2152 this.gamepad_.axes[axesStartIndex] = buttonState.xValue == null ? 0.0 : buttonState.xValue; 2153 this.gamepad_.axes[axesStartIndex + 1] = buttonState.yValue == null ? 0.0 : buttonState.yValue; 2154 } 2155 } 2156 2157 // DOM Overlay Extensions 2158 setOverlayPointerPosition(x, y) { 2159 this.overlay_pointer_position_ = {x: x, y: y}; 2160 } 2161 2162 // Helpers for Mojom 2163 _getInputSourceState() { 2164 const input_state = {}; 2165 2166 input_state.sourceId = this.source_id_; 2167 input_state.isAuxiliary = false; 2168 2169 input_state.primaryInputPressed = this.primary_input_pressed_; 2170 input_state.primaryInputClicked = this.primary_input_clicked_; 2171 2172 input_state.primarySqueezePressed = this.primary_squeeze_pressed_; 2173 input_state.primarySqueezeClicked = this.primary_squeeze_clicked_; 2174 // Setting the input source's "clicked" state should generate one "select" 2175 // event. Reset the input value to prevent it from continuously generating 2176 // events. 2177 this.primary_input_clicked_ = false; 2178 // Setting the input source's "clicked" state should generate one "squeeze" 2179 // event. Reset the input value to prevent it from continuously generating 2180 // events. 2181 this.primary_squeeze_clicked_ = false; 2182 2183 input_state.mojoFromInput = this.mojo_from_input_; 2184 2185 input_state.gamepad = this.gamepad_; 2186 2187 input_state.emulatedPosition = this.emulated_position_; 2188 2189 if (this.desc_dirty_) { 2190 const input_desc = {}; 2191 2192 switch (this.target_ray_mode_) { 2193 case 'gaze': 2194 input_desc.targetRayMode = vrMojom.XRTargetRayMode.GAZING; 2195 break; 2196 case 'tracked-pointer': 2197 input_desc.targetRayMode = vrMojom.XRTargetRayMode.POINTING; 2198 break; 2199 case 'screen': 2200 input_desc.targetRayMode = vrMojom.XRTargetRayMode.TAPPING; 2201 break; 2202 default: 2203 throw new Error('Unhandled target ray mode ' + this.target_ray_mode_); 2204 } 2205 2206 switch (this.handedness_) { 2207 case 'left': 2208 input_desc.handedness = vrMojom.XRHandedness.LEFT; 2209 break; 2210 case 'right': 2211 input_desc.handedness = vrMojom.XRHandedness.RIGHT; 2212 break; 2213 default: 2214 input_desc.handedness = vrMojom.XRHandedness.NONE; 2215 break; 2216 } 2217 2218 // Mojo requires us to send the pointerOrigin as relative to the grip 2219 // space. If we don't have a grip space, we'll just assume that there 2220 // is a grip at identity. This allows tests to simulate controllers that 2221 // are really just a pointer with no tracked grip, though we will end up 2222 // exposing that grip space. 2223 let mojo_from_input = XRMathHelper.identity(); 2224 switch (this.target_ray_mode_) { 2225 case 'gaze': 2226 case 'screen': 2227 // For gaze and screen space, we won't have a mojo_from_input; however 2228 // the "input" position is just the viewer, so use mojo_from_viewer. 2229 mojo_from_input = this.pairedDevice_._getMojoFromViewer(); 2230 break; 2231 case 'tracked-pointer': 2232 // If we have a tracked grip position (e.g. mojo_from_input), then use 2233 // that. If we don't, then we'll just set the pointer offset directly, 2234 // using identity as set above. 2235 if (this.mojo_from_input_) { 2236 mojo_from_input = this.mojo_from_input_.data.matrix; 2237 } 2238 break; 2239 default: 2240 throw new Error('Unhandled target ray mode ' + this.target_ray_mode_); 2241 } 2242 2243 // To convert mojo_from_pointer to input_from_pointer, we need: 2244 // input_from_pointer = input_from_mojo * mojo_from_pointer 2245 // Since we store mojo_from_input, we need to invert it here before 2246 // multiplying. 2247 let input_from_mojo = XRMathHelper.inverse(mojo_from_input); 2248 input_desc.inputFromPointer = {}; 2249 input_desc.inputFromPointer.data = { 2250 matrix : XRMathHelper.mul4x4(input_from_mojo, 2251 this.mojo_from_pointer_.data.matrix)}; 2252 2253 input_desc.profiles = this.profiles_; 2254 2255 input_state.description = input_desc; 2256 2257 this.desc_dirty_ = false; 2258 } 2259 2260 // Pointer data for DOM Overlay, set by setOverlayPointerPosition() 2261 if (this.overlay_pointer_position_) { 2262 input_state.overlayPointerPosition = this.overlay_pointer_position_; 2263 this.overlay_pointer_position_ = null; 2264 } 2265 2266 return input_state; 2267 } 2268 2269 _getEmptyGamepad() { 2270 // Mojo complains if some of the properties on Gamepad are null, so set 2271 // everything to reasonable defaults that tests can override. 2272 const gamepad = { 2273 connected: true, 2274 id: [], 2275 timestamp: 0n, 2276 axes: [], 2277 buttons: [], 2278 touchEvents: [], 2279 mapping: GamepadMapping.GamepadMappingStandard, 2280 displayId: 0, 2281 }; 2282 2283 switch (this.handedness_) { 2284 case 'left': 2285 gamepad.hand = GamepadHand.GamepadHandLeft; 2286 break; 2287 case 'right': 2288 gamepad.hand = GamepadHand.GamepadHandRight; 2289 break; 2290 default: 2291 gamepad.hand = GamepadHand.GamepadHandNone; 2292 break; 2293 } 2294 2295 return gamepad; 2296 } 2297 2298 _addGamepadButton(buttonState) { 2299 if (buttonState == null) { 2300 return; 2301 } 2302 2303 const buttonIndex = this._getButtonIndex(buttonState.buttonType); 2304 const axesStartIndex = this._getAxesStartIndex(buttonState.buttonType); 2305 2306 if (buttonIndex == -1) { 2307 throw new Error("Unknown Button Type!"); 2308 } 2309 2310 this.gamepad_.buttons[buttonIndex] = { 2311 pressed: buttonState.pressed, 2312 touched: buttonState.touched, 2313 value: buttonState.pressedValue 2314 }; 2315 2316 // Add x/y value if supported. 2317 if (axesStartIndex != -1) { 2318 this.gamepad_.axes[axesStartIndex] = (buttonState.xValue == null ? 0.0 : buttonSate.xValue); 2319 this.gamepad_.axes[axesStartIndex + 1] = (buttonState.yValue == null ? 0.0 : buttonSate.yValue); 2320 } 2321 } 2322 2323 // General Helper methods 2324 _getButtonIndex(buttonType) { 2325 switch (buttonType) { 2326 case 'grip': 2327 return 1; 2328 case 'touchpad': 2329 return 2; 2330 case 'thumbstick': 2331 return 3; 2332 case 'optional-button': 2333 return 4; 2334 case 'optional-thumbstick': 2335 return 5; 2336 default: 2337 return -1; 2338 } 2339 } 2340 2341 _getAxesStartIndex(buttonType) { 2342 switch (buttonType) { 2343 case 'touchpad': 2344 return 0; 2345 case 'thumbstick': 2346 return 2; 2347 case 'optional-thumbstick': 2348 return 4; 2349 default: 2350 return -1; 2351 } 2352 } 2353 2354 _getMojoFromInputSource(mojo_from_viewer) { 2355 return this.mojo_from_pointer_.data.matrix; 2356 } 2357 } 2358 2359 // Mojo helper classes 2360 class FakeXRHitTestSourceController { 2361 constructor(id) { 2362 this.id_ = id; 2363 this.deleted_ = false; 2364 } 2365 2366 get deleted() { 2367 return this.deleted_; 2368 } 2369 2370 // Internal setter: 2371 set deleted(value) { 2372 this.deleted_ = value; 2373 } 2374 } 2375 2376 class MockXRPresentationProvider { 2377 constructor() { 2378 this.receiver_ = null; 2379 this.submit_frame_count_ = 0; 2380 this.missing_frame_count_ = 0; 2381 } 2382 2383 _bindProvider() { 2384 const provider = new vrMojom.XRPresentationProviderRemote(); 2385 2386 if (this.receiver_) { 2387 this.receiver_.$.close(); 2388 } 2389 this.receiver_ = new vrMojom.XRPresentationProviderReceiver(this); 2390 this.receiver_.$.bindHandle(provider.$.bindNewPipeAndPassReceiver().handle); 2391 return provider; 2392 } 2393 2394 _getClientReceiver() { 2395 this.submitFrameClient_ = new vrMojom.XRPresentationClientRemote(); 2396 return this.submitFrameClient_.$.bindNewPipeAndPassReceiver(); 2397 } 2398 2399 // XRPresentationProvider mojo implementation 2400 updateLayerBounds(frameId, leftBounds, rightBounds, sourceSize) {} 2401 2402 submitFrameMissing(frameId, mailboxHolder, timeWaited) { 2403 this.missing_frame_count_++; 2404 } 2405 2406 submitFrame(frameId, mailboxHolder, timeWaited) { 2407 this.submit_frame_count_++; 2408 2409 // Trigger the submit completion callbacks here. WARNING: The 2410 // Javascript-based mojo mocks are *not* re-entrant. It's OK to 2411 // wait for these notifications on the next frame, but waiting 2412 // within the current frame would never finish since the incoming 2413 // calls would be queued until the current execution context finishes. 2414 this.submitFrameClient_.onSubmitFrameTransferred(true); 2415 this.submitFrameClient_.onSubmitFrameRendered(); 2416 } 2417 2418 submitFrameWithTextureHandle(frameId, texture, syncToken) {} 2419 2420 submitFrameDrawnIntoTexture(frameId, layer_ids, syncToken, timeWaited) {} 2421 2422 // Utility methods 2423 _close() { 2424 if (this.receiver_) { 2425 this.receiver_.$.close(); 2426 } 2427 } 2428 } 2429 2430 // Export these into the global object as a side effect of importing this 2431 // module. 2432 self.ChromeXRTest = ChromeXRTest; 2433 self.MockRuntime = MockRuntime; 2434 self.MockVRService = MockVRService; 2435 self.SubscribeToHitTestResult = vrMojom.SubscribeToHitTestResult; 2436 2437 navigator.xr.test = new ChromeXRTest();