| import * as vrMojom from '/gen/device/vr/public/mojom/vr_service.mojom.m.js'; |
| import * as xrSessionMojom from '/gen/device/vr/public/mojom/xr_session.mojom.m.js'; |
| import {GamepadHand, GamepadMapping} from '/gen/device/gamepad/public/mojom/gamepad.mojom.m.js'; |
| |
| // This polyfill library implements the WebXR Test API as specified here: |
| // https://github.com/immersive-web/webxr-test-api |
| |
| const defaultMojoFromStage = { |
| matrix: [1, 0, 0, 0, |
| 0, 1, 0, 0, |
| 0, 0, 1, 0, |
| 0, -1.65, 0, 1] |
| }; |
| const default_stage_parameters = { |
| mojoFromStage: defaultMojoFromStage, |
| bounds: null |
| }; |
| |
| const default_framebuffer_scale = 0.7; |
| |
| function getMatrixFromTransform(transform) { |
| const x = transform.orientation[0]; |
| const y = transform.orientation[1]; |
| const z = transform.orientation[2]; |
| const w = transform.orientation[3]; |
| |
| const m11 = 1.0 - 2.0 * (y * y + z * z); |
| const m21 = 2.0 * (x * y + z * w); |
| const m31 = 2.0 * (x * z - y * w); |
| |
| const m12 = 2.0 * (x * y - z * w); |
| const m22 = 1.0 - 2.0 * (x * x + z * z); |
| const m32 = 2.0 * (y * z + x * w); |
| |
| const m13 = 2.0 * (x * z + y * w); |
| const m23 = 2.0 * (y * z - x * w); |
| const m33 = 1.0 - 2.0 * (x * x + y * y); |
| |
| const m14 = transform.position[0]; |
| const m24 = transform.position[1]; |
| const m34 = transform.position[2]; |
| |
| // Column-major linearized order is expected. |
| return [m11, m21, m31, 0, |
| m12, m22, m32, 0, |
| m13, m23, m33, 0, |
| m14, m24, m34, 1]; |
| } |
| |
| function getPoseFromTransform(transform) { |
| const [px, py, pz] = transform.position; |
| const [ox, oy, oz, ow] = transform.orientation; |
| return { |
| position: {x: px, y: py, z: pz}, |
| orientation: {x: ox, y: oy, z: oz, w: ow}, |
| }; |
| } |
| |
| function composeGFXTransform(fakeTransformInit) { |
| return {matrix: getMatrixFromTransform(fakeTransformInit)}; |
| } |
| |
| // Value equality for camera image init objects - they must contain `width` & |
| // `height` properties and may contain `pixels` property. |
| function isSameCameraImageInit(rhs, lhs) { |
| return lhs.width === rhs.width && lhs.height === rhs.height && lhs.pixels === rhs.pixels; |
| } |
| |
| class ChromeXRTest { |
| constructor() { |
| this.mockVRService_ = new MockVRService(); |
| } |
| |
| // WebXR Test API |
| simulateDeviceConnection(init_params) { |
| return Promise.resolve(this.mockVRService_._addRuntime(init_params)); |
| } |
| |
| disconnectAllDevices() { |
| this.mockVRService_._removeAllRuntimes(); |
| return Promise.resolve(); |
| } |
| |
| simulateUserActivation(callback) { |
| if (window.top !== window) { |
| // test_driver.click only works for the toplevel frame. This alternate |
| // Chrome-specific method is sufficient for starting an XR session in an |
| // iframe, and is used in platform-specific tests. |
| // |
| // TODO(https://github.com/web-platform-tests/wpt/issues/20282): use |
| // a cross-platform method if available. |
| xr_debug('simulateUserActivation', 'use eventSender'); |
| document.addEventListener('click', callback); |
| eventSender.mouseMoveTo(0, 0); |
| eventSender.mouseDown(); |
| eventSender.mouseUp(); |
| document.removeEventListener('click', callback); |
| return; |
| } |
| const button = document.createElement('button'); |
| button.textContent = 'click to continue test'; |
| button.style.display = 'block'; |
| button.style.fontSize = '20px'; |
| button.style.padding = '10px'; |
| button.onclick = () => { |
| callback(); |
| document.body.removeChild(button); |
| }; |
| document.body.appendChild(button); |
| test_driver.click(button); |
| } |
| |
| // Helper method leveraged by chrome-specific setups. |
| Debug(name, msg) { |
| console.log(new Date().toISOString() + ' DEBUG[' + name + '] ' + msg); |
| } |
| } |
| |
| // Mocking class definitions |
| |
| // Mock service implements the VRService mojo interface. |
| class MockVRService { |
| constructor() { |
| this.receiver_ = new vrMojom.VRServiceReceiver(this); |
| this.runtimes_ = []; |
| |
| this.interceptor_ = |
| new MojoInterfaceInterceptor(vrMojom.VRService.$interfaceName); |
| this.interceptor_.oninterfacerequest = |
| e => this.receiver_.$.bindHandle(e.handle); |
| this.interceptor_.start(); |
| } |
| |
| // WebXR Test API Implementation Helpers |
| _addRuntime(fakeDeviceInit) { |
| const runtime = new MockRuntime(fakeDeviceInit, this); |
| this.runtimes_.push(runtime); |
| |
| if (this.client_) { |
| this.client_.onDeviceChanged(); |
| } |
| |
| return runtime; |
| } |
| |
| _removeAllRuntimes() { |
| if (this.client_) { |
| this.client_.onDeviceChanged(); |
| } |
| |
| this.runtimes_ = []; |
| } |
| |
| _removeRuntime(device) { |
| const index = this.runtimes_.indexOf(device); |
| if (index >= 0) { |
| this.runtimes_.splice(index, 1); |
| if (this.client_) { |
| this.client_.onDeviceChanged(); |
| } |
| } |
| } |
| |
| // VRService overrides |
| setClient(client) { |
| if (this.client_) { |
| throw new Error("setClient should only be called once"); |
| } |
| |
| this.client_ = client; |
| } |
| |
| requestSession(sessionOptions) { |
| const requests = []; |
| // Request a session from all the runtimes. |
| for (let i = 0; i < this.runtimes_.length; i++) { |
| requests[i] = this.runtimes_[i]._requestRuntimeSession(sessionOptions); |
| } |
| |
| return Promise.all(requests).then((results) => { |
| // Find and return the first successful result. |
| for (let i = 0; i < results.length; i++) { |
| if (results[i].session) { |
| // Construct a dummy metrics recorder |
| const metricsRecorderPtr = new vrMojom.XRSessionMetricsRecorderRemote(); |
| metricsRecorderPtr.$.bindNewPipeAndPassReceiver().handle.close(); |
| |
| const success = { |
| session: results[i].session, |
| metricsRecorder: metricsRecorderPtr, |
| }; |
| |
| return {result: {success}}; |
| } |
| } |
| |
| // If there were no successful results, returns a null session. |
| return { |
| result: {failureReason: xrSessionMojom.RequestSessionError.NO_RUNTIME_FOUND} |
| }; |
| }); |
| } |
| |
| supportsSession(sessionOptions) { |
| const requests = []; |
| // Check supports on all the runtimes. |
| for (let i = 0; i < this.runtimes_.length; i++) { |
| requests[i] = this.runtimes_[i]._runtimeSupportsSession(sessionOptions); |
| } |
| |
| return Promise.all(requests).then((results) => { |
| // Find and return the first successful result. |
| for (let i = 0; i < results.length; i++) { |
| if (results[i].supportsSession) { |
| return results[i]; |
| } |
| } |
| |
| // If there were no successful results, returns false. |
| return {supportsSession: false}; |
| }); |
| } |
| |
| exitPresent() { |
| return Promise.resolve(); |
| } |
| |
| setFramesThrottled(throttled) { |
| this.setFramesThrottledImpl(throttled); |
| } |
| |
| // We cannot override the mojom interceptors via the prototype; so this method |
| // and the above indirection exist to allow overrides by internal code. |
| setFramesThrottledImpl(throttled) {} |
| |
| // Only handles asynchronous calls to makeXrCompatible. Synchronous calls are |
| // not supported in Javascript. |
| makeXrCompatible() { |
| if (this.runtimes_.length == 0) { |
| return { |
| xrCompatibleResult: vrMojom.XrCompatibleResult.kNoDeviceAvailable |
| }; |
| } |
| return {xrCompatibleResult: vrMojom.XrCompatibleResult.kAlreadyCompatible}; |
| } |
| } |
| |
| class FakeXRAnchorController { |
| constructor() { |
| // Private properties. |
| this.device_ = null; |
| this.id_ = null; |
| this.dirty_ = true; |
| |
| // Properties backing up public attributes / methods. |
| this.deleted_ = false; |
| this.paused_ = false; |
| this.anchorOrigin_ = XRMathHelper.identity(); |
| } |
| |
| // WebXR Test API (Anchors Extension) |
| get deleted() { |
| return this.deleted_; |
| } |
| |
| pauseTracking() { |
| if(!this.paused_) { |
| this.paused_ = true; |
| this.dirty_ = true; |
| } |
| } |
| |
| resumeTracking() { |
| if(this.paused_) { |
| this.paused_ = false; |
| this.dirty_ = true; |
| } |
| } |
| |
| stopTracking() { |
| if(!this.deleted_) { |
| this.device_._deleteAnchorController(this.id_); |
| |
| this.deleted_ = true; |
| this.dirty_ = true; |
| } |
| } |
| |
| setAnchorOrigin(anchorOrigin) { |
| this.anchorOrigin_ = getMatrixFromTransform(anchorOrigin); |
| this.dirty_ = true; |
| } |
| |
| // Internal implementation: |
| set id(value) { |
| this.id_ = value; |
| } |
| |
| set device(value) { |
| this.device_ = value; |
| } |
| |
| get dirty() { |
| return this.dirty_; |
| } |
| |
| get paused() { |
| return this.paused_; |
| } |
| |
| _markProcessed() { |
| this.dirty_ = false; |
| } |
| |
| _getAnchorOrigin() { |
| return this.anchorOrigin_; |
| } |
| } |
| |
| // Implements XRFrameDataProvider and XRPresentationProvider. Maintains a mock |
| // for XRPresentationProvider. Implements FakeXRDevice test API. |
| class MockRuntime { |
| // Mapping from string feature names to the corresponding mojo types. |
| // This is exposed as a member for extensibility. |
| static _featureToMojoMap = { |
| 'viewer': xrSessionMojom.XRSessionFeature.REF_SPACE_VIEWER, |
| 'local': xrSessionMojom.XRSessionFeature.REF_SPACE_LOCAL, |
| 'local-floor': xrSessionMojom.XRSessionFeature.REF_SPACE_LOCAL_FLOOR, |
| 'bounded-floor': xrSessionMojom.XRSessionFeature.REF_SPACE_BOUNDED_FLOOR, |
| 'unbounded': xrSessionMojom.XRSessionFeature.REF_SPACE_UNBOUNDED, |
| 'hit-test': xrSessionMojom.XRSessionFeature.HIT_TEST, |
| 'dom-overlay': xrSessionMojom.XRSessionFeature.DOM_OVERLAY, |
| 'light-estimation': xrSessionMojom.XRSessionFeature.LIGHT_ESTIMATION, |
| 'anchors': xrSessionMojom.XRSessionFeature.ANCHORS, |
| 'depth-sensing': xrSessionMojom.XRSessionFeature.DEPTH, |
| 'secondary-views': xrSessionMojom.XRSessionFeature.SECONDARY_VIEWS, |
| 'camera-access': xrSessionMojom.XRSessionFeature.CAMERA_ACCESS, |
| 'layers': xrSessionMojom.XRSessionFeature.LAYERS, |
| }; |
| |
| static _sessionModeToMojoMap = { |
| "inline": xrSessionMojom.XRSessionMode.kInline, |
| "immersive-vr": xrSessionMojom.XRSessionMode.kImmersiveVr, |
| "immersive-ar": xrSessionMojom.XRSessionMode.kImmersiveAr, |
| }; |
| |
| static _environmentBlendModeToMojoMap = { |
| "opaque": vrMojom.XREnvironmentBlendMode.kOpaque, |
| "alpha-blend": vrMojom.XREnvironmentBlendMode.kAlphaBlend, |
| "additive": vrMojom.XREnvironmentBlendMode.kAdditive, |
| }; |
| |
| static _interactionModeToMojoMap = { |
| "screen-space": vrMojom.XRInteractionMode.kScreenSpace, |
| "world-space": vrMojom.XRInteractionMode.kWorldSpace, |
| }; |
| |
| static _depthTypeToMojoMap = { |
| "raw": xrSessionMojom.XRDepthType.kRawDepth, |
| "smooth": xrSessionMojom.XRDepthType.kSmoothDepth, |
| }; |
| |
| static _depthUsageToMojoMap = { |
| "cpu-optimized": xrSessionMojom.XRDepthUsage.kCPUOptimized, |
| "gpu-optimized": xrSessionMojom.XRDepthUsage.kGPUOptimized, |
| }; |
| |
| static _depthDataFormatToMojoMap = { |
| "luminance-alpha": xrSessionMojom.XRDepthDataFormat.kLuminanceAlpha, |
| "float32": xrSessionMojom.XRDepthDataFormat.kFloat32, |
| "unsigned-short": xrSessionMojom.XRDepthDataFormat.kUnsignedShort, |
| }; |
| |
| |
| constructor(fakeDeviceInit, service) { |
| this.sessionClient_ = null; |
| this.presentation_provider_ = new MockXRPresentationProvider(); |
| |
| this.pose_ = null; |
| this.next_frame_id_ = 0; |
| this.bounds_ = null; |
| this.send_mojo_space_reset_ = false; |
| this.stageParameters_ = null; |
| this.stageParametersId_ = 1; |
| |
| this.service_ = service; |
| |
| this.framesOfReference = {}; |
| |
| this.input_sources_ = new Map(); |
| this.next_input_source_index_ = 1; |
| |
| // Currently active hit test subscriptons. |
| this.hitTestSubscriptions_ = new Map(); |
| // Currently active transient hit test subscriptions. |
| this.transientHitTestSubscriptions_ = new Map(); |
| // ID of the next subscription to be assigned. |
| this.next_hit_test_id_ = 1n; |
| |
| this.anchor_controllers_ = new Map(); |
| // ID of the next anchor to be assigned. |
| this.next_anchor_id_ = 1n; |
| // Anchor creation callback (initially null, can be set by tests). |
| this.anchor_creation_callback_ = null; |
| |
| this.depthSensingData_ = null; |
| this.depthSensingDataDirty_ = false; |
| |
| let supportedModes = []; |
| if (fakeDeviceInit.supportedModes) { |
| supportedModes = fakeDeviceInit.supportedModes.slice(); |
| if (fakeDeviceInit.supportedModes.length === 0) { |
| supportedModes = ["inline"]; |
| } |
| } else { |
| // Back-compat mode. |
| console.warn("Please use `supportedModes` to signal which modes are supported by this device."); |
| if (fakeDeviceInit.supportsImmersive == null) { |
| throw new TypeError("'supportsImmersive' must be set"); |
| } |
| |
| supportedModes = ["inline"]; |
| if (fakeDeviceInit.supportsImmersive) { |
| supportedModes.push("immersive-vr"); |
| } |
| } |
| |
| this.supportedModes_ = this._convertModesToEnum(supportedModes); |
| if (this.supportedModes_.length == 0) { |
| console.error("Device has empty supported modes array!"); |
| throw new InvalidStateError(); |
| } |
| |
| if (fakeDeviceInit.viewerOrigin != null) { |
| this.setViewerOrigin(fakeDeviceInit.viewerOrigin); |
| } |
| |
| if (fakeDeviceInit.floorOrigin != null) { |
| this.setFloorOrigin(fakeDeviceInit.floorOrigin); |
| } |
| |
| if (fakeDeviceInit.world) { |
| this.setWorld(fakeDeviceInit.world); |
| } |
| |
| if (fakeDeviceInit.depthSensingData) { |
| this.setDepthSensingData(fakeDeviceInit.depthSensingData); |
| } |
| |
| this.defaultFramebufferScale_ = default_framebuffer_scale; |
| this.enviromentBlendMode_ = this._convertBlendModeToEnum(fakeDeviceInit.environmentBlendMode); |
| this.interactionMode_ = this._convertInteractionModeToEnum(fakeDeviceInit.interactionMode); |
| |
| // This appropriately handles if the coordinates are null |
| this.setBoundsGeometry(fakeDeviceInit.boundsCoordinates); |
| |
| this.setViews(fakeDeviceInit.views, fakeDeviceInit.secondaryViews); |
| |
| this._setDepthSupport(fakeDeviceInit.depthSupport || {}); |
| |
| // Need to support webVR which doesn't have a notion of features |
| this._setFeatures(fakeDeviceInit.supportedFeatures || []); |
| } |
| |
| // WebXR Test API |
| setViews(primaryViews, secondaryViews) { |
| this.cameraImage_ = null; |
| this.primaryViews_ = []; |
| this.secondaryViews_ = []; |
| let xOffset = 0; |
| if (primaryViews) { |
| this.primaryViews_ = []; |
| xOffset = this._setViews(primaryViews, xOffset, this.primaryViews_); |
| const cameraImage = this._findCameraImage(primaryViews); |
| |
| if (cameraImage) { |
| this.cameraImage_ = cameraImage; |
| } |
| } |
| |
| if (secondaryViews) { |
| this.secondaryViews_ = []; |
| this._setViews(secondaryViews, xOffset, this.secondaryViews_); |
| const cameraImage = this._findCameraImage(secondaryViews); |
| |
| if (cameraImage) { |
| if (!isSameCameraImageInit(this.cameraImage_, cameraImage)) { |
| throw new Error("If present, camera resolutions on each view must match each other!" |
| + " Secondary views' camera doesn't match primary views."); |
| } |
| |
| this.cameraImage_ = cameraImage; |
| } |
| } |
| } |
| |
| disconnect() { |
| this.service_._removeRuntime(this); |
| this.presentation_provider_._close(); |
| if (this.sessionClient_) { |
| this.sessionClient_.$.close(); |
| this.sessionClient_ = null; |
| } |
| |
| return Promise.resolve(); |
| } |
| |
| setViewerOrigin(origin, emulatedPosition = false) { |
| const p = origin.position; |
| const q = origin.orientation; |
| this.pose_ = { |
| orientation: { x: q[0], y: q[1], z: q[2], w: q[3] }, |
| position: { x: p[0], y: p[1], z: p[2] }, |
| emulatedPosition: emulatedPosition, |
| angularVelocity: null, |
| linearVelocity: null, |
| angularAcceleration: null, |
| linearAcceleration: null, |
| inputState: null, |
| poseIndex: 0 |
| }; |
| } |
| |
| clearViewerOrigin() { |
| this.pose_ = null; |
| } |
| |
| setFloorOrigin(floorOrigin) { |
| if (!this.stageParameters_) { |
| this.stageParameters_ = default_stage_parameters; |
| this.stageParameters_.bounds = this.bounds_; |
| } |
| |
| // floorOrigin is passed in as mojoFromStage. |
| this.stageParameters_.mojoFromStage = |
| {matrix: getMatrixFromTransform(floorOrigin)}; |
| |
| this._onStageParametersUpdated(); |
| } |
| |
| clearFloorOrigin() { |
| if (this.stageParameters_) { |
| this.stageParameters_ = null; |
| this._onStageParametersUpdated(); |
| } |
| } |
| |
| setBoundsGeometry(bounds) { |
| if (bounds == null) { |
| this.bounds_ = null; |
| } else if (bounds.length < 3) { |
| throw new Error("Bounds must have a length of at least 3"); |
| } else { |
| this.bounds_ = bounds; |
| } |
| |
| // We can only set bounds if we have stageParameters set; otherwise, we |
| // don't know the transform from local space to bounds space. |
| // We'll cache the bounds so that they can be set in the future if the |
| // floorLevel transform is set, but we won't update them just yet. |
| if (this.stageParameters_) { |
| this.stageParameters_.bounds = this.bounds_; |
| this._onStageParametersUpdated(); |
| } |
| } |
| |
| simulateResetPose() { |
| this.send_mojo_space_reset_ = true; |
| } |
| |
| simulateVisibilityChange(visibilityState) { |
| let mojoState = null; |
| switch (visibilityState) { |
| case "visible": |
| mojoState = vrMojom.XRVisibilityState.VISIBLE; |
| break; |
| case "visible-blurred": |
| mojoState = vrMojom.XRVisibilityState.VISIBLE_BLURRED; |
| break; |
| case "hidden": |
| mojoState = vrMojom.XRVisibilityState.HIDDEN; |
| break; |
| } |
| if (mojoState && this.sessionClient_) { |
| this.sessionClient_.onVisibilityStateChanged(mojoState); |
| } |
| } |
| |
| simulateInputSourceConnection(fakeInputSourceInit) { |
| const index = this.next_input_source_index_; |
| this.next_input_source_index_++; |
| |
| const source = new MockXRInputSource(fakeInputSourceInit, index, this); |
| this.input_sources_.set(index, source); |
| return source; |
| } |
| |
| // WebXR Test API Hit Test extensions |
| setWorld(world) { |
| this.world_ = world; |
| } |
| |
| clearWorld() { |
| this.world_ = null; |
| } |
| |
| // WebXR Test API Anchor extensions |
| setAnchorCreationCallback(callback) { |
| this.anchor_creation_callback_ = callback; |
| } |
| |
| setHitTestSourceCreationCallback(callback) { |
| this.hit_test_source_creation_callback_ = callback; |
| } |
| |
| // WebXR Test API Lighting estimation extensions |
| setLightEstimate(fakeXrLightEstimateInit) { |
| if (!fakeXrLightEstimateInit.sphericalHarmonicsCoefficients) { |
| throw new TypeError("sphericalHarmonicsCoefficients must be set"); |
| } |
| |
| if (fakeXrLightEstimateInit.sphericalHarmonicsCoefficients.length != 27) { |
| throw new TypeError("Must supply all 27 sphericalHarmonicsCoefficients"); |
| } |
| |
| if (fakeXrLightEstimateInit.primaryLightDirection && fakeXrLightEstimateInit.primaryLightDirection.w != 0) { |
| throw new TypeError("W component of primaryLightDirection must be 0"); |
| } |
| |
| if (fakeXrLightEstimateInit.primaryLightIntensity && fakeXrLightEstimateInit.primaryLightIntensity.w != 1) { |
| throw new TypeError("W component of primaryLightIntensity must be 1"); |
| } |
| |
| // If the primaryLightDirection or primaryLightIntensity aren't set, we need to set them |
| // to the defaults that the spec expects. ArCore will either give us everything or nothing, |
| // so these aren't nullable on the mojom. |
| if (!fakeXrLightEstimateInit.primaryLightDirection) { |
| fakeXrLightEstimateInit.primaryLightDirection = { x: 0.0, y: 1.0, z: 0.0, w: 0.0 }; |
| } |
| |
| if (!fakeXrLightEstimateInit.primaryLightIntensity) { |
| fakeXrLightEstimateInit.primaryLightIntensity = { x: 0.0, y: 0.0, z: 0.0, w: 1.0 }; |
| } |
| |
| let c = fakeXrLightEstimateInit.sphericalHarmonicsCoefficients; |
| |
| this.light_estimate_ = { |
| lightProbe: { |
| // XRSphereicalHarmonics |
| sphericalHarmonics: { |
| coefficients: [ |
| { red: c[0], green: c[1], blue: c[2] }, |
| { red: c[3], green: c[4], blue: c[5] }, |
| { red: c[6], green: c[7], blue: c[8] }, |
| { red: c[9], green: c[10], blue: c[11] }, |
| { red: c[12], green: c[13], blue: c[14] }, |
| { red: c[15], green: c[16], blue: c[17] }, |
| { red: c[18], green: c[19], blue: c[20] }, |
| { red: c[21], green: c[22], blue: c[23] }, |
| { red: c[24], green: c[25], blue: c[26] } |
| ] |
| }, |
| // Vector3dF |
| mainLightDirection: { |
| x: fakeXrLightEstimateInit.primaryLightDirection.x, |
| y: fakeXrLightEstimateInit.primaryLightDirection.y, |
| z: fakeXrLightEstimateInit.primaryLightDirection.z |
| }, |
| // RgbTupleF32 |
| mainLightIntensity: { |
| red: fakeXrLightEstimateInit.primaryLightIntensity.x, |
| green: fakeXrLightEstimateInit.primaryLightIntensity.y, |
| blue: fakeXrLightEstimateInit.primaryLightIntensity.z |
| } |
| } |
| } |
| } |
| |
| // WebXR Test API depth Sensing Extensions |
| _setDepthSupport(depthSupport) { |
| this.depthSupport_ = {}; |
| |
| this.depthSupport_.depthTypes = []; |
| for (const type of (depthSupport.depthTypes || [])) { |
| this.depthSupport_.depthTypes.push(MockRuntime._depthTypeToMojoMap[type]); |
| } |
| |
| this.depthSupport_.depthFormats = []; |
| for (const format of (depthSupport.depthFormats || [])) { |
| this.depthSupport_.depthFormats.push(MockRuntime._depthDataFormatToMojoMap[format]); |
| } |
| |
| this.depthSupport_.depthUsages = []; |
| for (const usage of (depthSupport.depthUsages || [])) { |
| // Because chrome doesn't support gpu-optimized for any devices at present |
| // avoid "false positive" WPTs by indicating that we don't support |
| // gpu-optimized. |
| if (usage === "gpu-optimized") { |
| continue; |
| } |
| |
| this.depthSupport_.depthUsages.push(MockRuntime._depthUsageToMojoMap[usage]); |
| } |
| } |
| |
| setDepthSensingData(depthSensingData) { |
| for(const key of ["depthData", "normDepthBufferFromNormView", "rawValueToMeters", "width", "height"]) { |
| if(!(key in depthSensingData)) { |
| throw new TypeError("Required key not present. Key: " + key); |
| } |
| } |
| |
| if(depthSensingData.depthData != null) { |
| // Create new object w/ properties based on the depthSensingData, but |
| // convert the FakeXRRigidTransformInit into a transformation matrix object. |
| this.depthSensingData_ = Object.assign({}, |
| depthSensingData, { |
| normDepthBufferFromNormView: composeGFXTransform(depthSensingData.normDepthBufferFromNormView), |
| }); |
| } else { |
| throw new TypeError("`depthData` is not set"); |
| } |
| |
| this.depthSensingDataDirty_ = true; |
| } |
| |
| clearDepthSensingData() { |
| this.depthSensingData_ = null; |
| this.depthSensingDataDirty_ = true; |
| } |
| |
| // Internal Implementation/Helper Methods |
| _convertModeToEnum(sessionMode) { |
| if (sessionMode in MockRuntime._sessionModeToMojoMap) { |
| return MockRuntime._sessionModeToMojoMap[sessionMode]; |
| } |
| |
| throw new TypeError("Unrecognized value for XRSessionMode enum: " + sessionMode); |
| } |
| |
| _convertModesToEnum(sessionModes) { |
| return sessionModes.map(mode => this._convertModeToEnum(mode)); |
| } |
| |
| _convertBlendModeToEnum(blendMode) { |
| if (blendMode in MockRuntime._environmentBlendModeToMojoMap) { |
| return MockRuntime._environmentBlendModeToMojoMap[blendMode]; |
| } else { |
| if (this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) { |
| return vrMojom.XREnvironmentBlendMode.kAdditive; |
| } else if (this.supportedModes_.includes( |
| xrSessionMojom.XRSessionMode.kImmersiveVr)) { |
| return vrMojom.XREnvironmentBlendMode.kOpaque; |
| } |
| } |
| } |
| |
| _convertInteractionModeToEnum(interactionMode) { |
| if (interactionMode in MockRuntime._interactionModeToMojoMap) { |
| return MockRuntime._interactionModeToMojoMap[interactionMode]; |
| } else { |
| return vrMojom.XRInteractionMode.kWorldSpace; |
| } |
| } |
| |
| _setViews(deviceViews, xOffset, views) { |
| for (let i = 0; i < deviceViews.length; i++) { |
| views[i] = this._getView(deviceViews[i], xOffset); |
| xOffset += deviceViews[i].resolution.width; |
| } |
| |
| return xOffset; |
| } |
| |
| _findCameraImage(views) { |
| const viewWithCamera = views.find(view => view.cameraImageInit); |
| if (viewWithCamera) { |
| //If we have one view with a camera resolution, all views should have the same camera resolution. |
| const allViewsHaveSameCamera = views.every( |
| view => isSameCameraImageInit(view.cameraImageInit, viewWithCamera.cameraImageInit)); |
| |
| if (!allViewsHaveSameCamera) { |
| throw new Error("If present, camera resolutions on each view must match each other!"); |
| } |
| |
| return viewWithCamera.cameraImageInit; |
| } |
| |
| return null; |
| } |
| |
| _onStageParametersUpdated() { |
| // Indicate for the frame loop that the stage parameters have been updated. |
| this.stageParametersId_++; |
| } |
| |
| _getDefaultViews() { |
| if (this.primaryViews_) { |
| return this.primaryViews_; |
| } |
| |
| const viewport_size = 20; |
| return [{ |
| eye: vrMojom.XREye.kLeft, |
| geometry: { |
| fieldOfView: { |
| upDegrees: 48.316, |
| downDegrees: 50.099, |
| leftDegrees: 50.899, |
| rightDegrees: 35.197 |
| }, |
| mojoFromView: this._getMojoFromViewerWithOffset(composeGFXTransform({ |
| position: [-0.032, 0, 0], |
| orientation: [0, 0, 0, 1] |
| })) |
| }, |
| viewport: { x: 0, y: 0, width: viewport_size, height: viewport_size } |
| }, |
| { |
| eye: vrMojom.XREye.kRight, |
| geometry: { |
| fieldOfView: { |
| upDegrees: 48.316, |
| downDegrees: 50.099, |
| leftDegrees: 50.899, |
| rightDegrees: 35.197 |
| }, |
| mojoFromView: this._getMojoFromViewerWithOffset(composeGFXTransform({ |
| position: [0.032, 0, 0], |
| orientation: [0, 0, 0, 1] |
| })) |
| }, |
| viewport: { x: viewport_size, y: 0, width: viewport_size, height: viewport_size } |
| }]; |
| } |
| |
| _getFovFromProjectionMatrix(projectionMatrix) { |
| const m = projectionMatrix; |
| |
| function toDegrees(tan) { |
| return Math.atan(tan) * 180 / Math.PI; |
| } |
| |
| const leftTan = (1 - m[8]) / m[0]; |
| const rightTan = (1 + m[8]) / m[0]; |
| const upTan = (1 + m[9]) / m[5]; |
| const downTan = (1 - m[9]) / m[5]; |
| |
| return { |
| upDegrees: toDegrees(upTan), |
| downDegrees: toDegrees(downTan), |
| leftDegrees: toDegrees(leftTan), |
| rightDegrees: toDegrees(rightTan) |
| }; |
| } |
| |
| // This function converts between the matrix provided by the WebXR test API |
| // and the internal data representation. |
| _getView(fakeXRViewInit, xOffset) { |
| let fov = null; |
| |
| if (fakeXRViewInit.fieldOfView) { |
| fov = { |
| upDegrees: fakeXRViewInit.fieldOfView.upDegrees, |
| downDegrees: fakeXRViewInit.fieldOfView.downDegrees, |
| leftDegrees: fakeXRViewInit.fieldOfView.leftDegrees, |
| rightDegrees: fakeXRViewInit.fieldOfView.rightDegrees |
| }; |
| } else { |
| fov = this._getFovFromProjectionMatrix(fakeXRViewInit.projectionMatrix); |
| } |
| |
| let viewEye = vrMojom.XREye.kNone; |
| // The eye passed in corresponds to the values in the WebXR spec, which are |
| // the strings "none", "left", and "right". They should be converted to the |
| // corresponding values of XREye in vr_service.mojom. |
| switch(fakeXRViewInit.eye) { |
| case "none": |
| viewEye = vrMojom.XREye.kNone; |
| break; |
| case "left": |
| viewEye = vrMojom.XREye.kLeft; |
| break; |
| case "right": |
| viewEye = vrMojom.XREye.kRight; |
| break; |
| } |
| |
| return { |
| eye: viewEye, |
| geometry: { |
| fieldOfView: fov, |
| mojoFromView: this._getMojoFromViewerWithOffset(composeGFXTransform(fakeXRViewInit.viewOffset)), |
| // Mojo will ignore extra members, we stash the raw projection matrix |
| // here for ease of use with the depth extensions. |
| projectionMatrix: fakeXRViewInit.projectionMatrix, |
| }, |
| viewport: { |
| x: xOffset, |
| y: 0, |
| width: fakeXRViewInit.resolution.width, |
| height: fakeXRViewInit.resolution.height |
| }, |
| isFirstPersonObserver: fakeXRViewInit.isFirstPersonObserver ? true : false, |
| viewOffset: composeGFXTransform(fakeXRViewInit.viewOffset) |
| }; |
| } |
| |
| _setFeatures(supportedFeatures) { |
| function convertFeatureToMojom(feature) { |
| if (feature in MockRuntime._featureToMojoMap) { |
| return MockRuntime._featureToMojoMap[feature]; |
| } else { |
| return xrSessionMojom.XRSessionFeature.INVALID; |
| } |
| } |
| |
| this.supportedFeatures_ = []; |
| |
| for (let i = 0; i < supportedFeatures.length; i++) { |
| const feature = convertFeatureToMojom(supportedFeatures[i]); |
| if (feature !== xrSessionMojom.XRSessionFeature.INVALID) { |
| this.supportedFeatures_.push(feature); |
| } |
| } |
| } |
| |
| // These methods are intended to be used by MockXRInputSource only. |
| _addInputSource(source) { |
| if (!this.input_sources_.has(source.source_id_)) { |
| this.input_sources_.set(source.source_id_, source); |
| } |
| } |
| |
| _removeInputSource(source) { |
| this.input_sources_.delete(source.source_id_); |
| } |
| |
| // These methods are intended to be used by FakeXRAnchorController only. |
| _deleteAnchorController(controllerId) { |
| this.anchor_controllers_.delete(controllerId); |
| } |
| |
| // Extension point for non-standard modules. |
| _injectAdditionalFrameData(options, frameData) { |
| } |
| |
| // Mojo function implementations. |
| |
| // XRFrameDataProvider implementation. |
| getFrameData(options) { |
| return new Promise((resolve) => { |
| |
| const populatePose = () => { |
| const mojo_space_reset = this.send_mojo_space_reset_; |
| this.send_mojo_space_reset_ = false; |
| |
| if (this.pose_) { |
| this.pose_.poseIndex++; |
| } |
| |
| // Setting the input_state to null tests a slightly different path than |
| // the browser tests where if the last input source is removed, the device |
| // code always sends up an empty array, but it's also valid mojom to send |
| // up a null array. |
| let input_state = null; |
| if (this.input_sources_.size > 0) { |
| input_state = []; |
| for (const input_source of this.input_sources_.values()) { |
| input_state.push(input_source._getInputSourceState()); |
| } |
| } |
| |
| let frame_views = this.primaryViews_; |
| for (let i = 0; i < this.primaryViews_.length; i++) { |
| this.primaryViews_[i].geometry.mojoFromView = |
| this._getMojoFromViewerWithOffset(this.primaryViews_[i].viewOffset); |
| } |
| if (this.enabledFeatures_.includes(xrSessionMojom.XRSessionFeature.SECONDARY_VIEWS)) { |
| for (let i = 0; i < this.secondaryViews_.length; i++) { |
| this.secondaryViews_[i].geometry.mojoFromView = |
| this._getMojoFromViewerWithOffset(this.secondaryViews_[i].viewOffset); |
| } |
| |
| frame_views = frame_views.concat(this.secondaryViews_); |
| } |
| |
| const frameData = { |
| renderInfo: { |
| frameId: this.next_frame_id_, |
| mojoFromViewer: this.pose_, |
| views: frame_views |
| }, |
| mojoSpaceReset: mojo_space_reset, |
| inputState: input_state, |
| timeDelta: { |
| // window.performance.now() is in milliseconds, so convert to microseconds. |
| microseconds: BigInt(Math.floor(window.performance.now() * 1000)), |
| }, |
| bufferHolder: null, |
| cameraImageSize: this.cameraImage_ ? { |
| width: this.cameraImage_.width, |
| height: this.cameraImage_.height |
| } : null, |
| renderingTimeRatio: 0, |
| stageParameters: this.stageParameters_, |
| stageParametersId: this.stageParametersId_, |
| lightEstimationData: this.light_estimate_ |
| }; |
| |
| this.next_frame_id_++; |
| |
| this._calculateHitTestResults(frameData); |
| |
| this._calculateAnchorInformation(frameData); |
| |
| if (options.depthActive) { |
| this._calculateDepthInformation(frameData); |
| } |
| |
| this._injectAdditionalFrameData(options, frameData); |
| |
| resolve({frameData}); |
| }; |
| |
| if(this.sessionOptions_.mode == xrSessionMojom.XRSessionMode.kInline) { |
| // Inline sessions should not have a delay introduced since it causes them |
| // to miss a vsync blink-side and delays propagation of changes that happened |
| // within a rAFcb by one frame (e.g. setViewerOrigin() calls would take 2 frames |
| // to propagate). |
| populatePose(); |
| } else { |
| // For immerive sessions, add additional delay to allow for anchor creation |
| // promises to run. |
| setTimeout(populatePose, 3); // note: according to MDN, the timeout is not exact |
| } |
| }); |
| } |
| |
| getEnvironmentIntegrationProvider(environmentProviderRequest) { |
| if (this.environmentProviderReceiver_) { |
| this.environmentProviderReceiver_.$.close(); |
| } |
| this.environmentProviderReceiver_ = |
| new vrMojom.XREnvironmentIntegrationProviderReceiver(this); |
| this.environmentProviderReceiver_.$.bindHandle( |
| environmentProviderRequest.handle); |
| } |
| |
| // XREnvironmentIntegrationProvider implementation: |
| subscribeToHitTest(nativeOriginInformation, entityTypes, ray) { |
| if (!this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) { |
| // Reject outside of AR. |
| return Promise.resolve({ |
| result : vrMojom.SubscribeToHitTestResult.FAILURE_GENERIC, |
| subscriptionId : 0n |
| }); |
| } |
| |
| if (!this._nativeOriginKnown(nativeOriginInformation)) { |
| return Promise.resolve({ |
| result : vrMojom.SubscribeToHitTestResult.FAILURE_GENERIC, |
| subscriptionId : 0n |
| }); |
| } |
| |
| // Reserve the id for hit test source: |
| const id = this.next_hit_test_id_++; |
| const hitTestParameters = { isTransient: false, profileName: null }; |
| const controller = new FakeXRHitTestSourceController(id); |
| |
| |
| return this._shouldHitTestSourceCreationSucceed(hitTestParameters, controller) |
| .then((succeeded) => { |
| if(succeeded) { |
| // Store the subscription information as-is (including controller): |
| this.hitTestSubscriptions_.set(id, { nativeOriginInformation, entityTypes, ray, controller }); |
| |
| return Promise.resolve({ |
| result : vrMojom.SubscribeToHitTestResult.SUCCESS, |
| subscriptionId : id |
| }); |
| } else { |
| return Promise.resolve({ |
| result : vrMojom.SubscribeToHitTestResult.FAILURE_GENERIC, |
| subscriptionId : 0n |
| }); |
| } |
| }); |
| } |
| |
| subscribeToHitTestForTransientInput(profileName, entityTypes, ray){ |
| if (!this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) { |
| // Reject outside of AR. |
| return Promise.resolve({ |
| result : vrMojom.SubscribeToHitTestResult.FAILURE_GENERIC, |
| subscriptionId : 0n |
| }); |
| } |
| |
| const id = this.next_hit_test_id_++; |
| const hitTestParameters = { isTransient: true, profileName: profileName }; |
| const controller = new FakeXRHitTestSourceController(id); |
| |
| // Check if we have hit test source creation callback. |
| // If yes, ask it if the hit test source creation should succeed. |
| // If no, for back-compat, assume the hit test source creation succeeded. |
| return this._shouldHitTestSourceCreationSucceed(hitTestParameters, controller) |
| .then((succeeded) => { |
| if(succeeded) { |
| // Store the subscription information as-is (including controller): |
| this.transientHitTestSubscriptions_.set(id, { profileName, entityTypes, ray, controller }); |
| |
| return Promise.resolve({ |
| result : vrMojom.SubscribeToHitTestResult.SUCCESS, |
| subscriptionId : id |
| }); |
| } else { |
| return Promise.resolve({ |
| result : vrMojom.SubscribeToHitTestResult.FAILURE_GENERIC, |
| subscriptionId : 0n |
| }); |
| } |
| }); |
| } |
| |
| unsubscribeFromHitTest(subscriptionId) { |
| let controller = null; |
| if(this.transientHitTestSubscriptions_.has(subscriptionId)){ |
| controller = this.transientHitTestSubscriptions_.get(subscriptionId).controller; |
| this.transientHitTestSubscriptions_.delete(subscriptionId); |
| } else if(this.hitTestSubscriptions_.has(subscriptionId)){ |
| controller = this.hitTestSubscriptions_.get(subscriptionId).controller; |
| this.hitTestSubscriptions_.delete(subscriptionId); |
| } |
| |
| if(controller) { |
| controller.deleted = true; |
| } |
| } |
| |
| createAnchor(nativeOriginInformation, nativeOriginFromAnchor) { |
| return new Promise((resolve) => { |
| if(this.anchor_creation_callback_ == null) { |
| resolve({ |
| result : vrMojom.CreateAnchorResult.FAILURE, |
| anchorId : 0n |
| }); |
| |
| return; |
| } |
| |
| const mojoFromNativeOrigin = this._getMojoFromNativeOrigin(nativeOriginInformation); |
| if(mojoFromNativeOrigin == null) { |
| resolve({ |
| result : vrMojom.CreateAnchorResult.FAILURE, |
| anchorId : 0n |
| }); |
| |
| return; |
| } |
| |
| const mojoFromAnchor = XRMathHelper.mul4x4(mojoFromNativeOrigin, nativeOriginFromAnchor); |
| |
| const anchorCreationParameters = { |
| requestedAnchorOrigin: mojoFromAnchor, |
| isAttachedToEntity: false, |
| }; |
| |
| const anchorController = new FakeXRAnchorController(); |
| |
| this.anchor_creation_callback_(anchorCreationParameters, anchorController) |
| .then((result) => { |
| if(result) { |
| // If the test allowed the anchor creation, |
| // store the anchor controller & return success. |
| |
| const anchor_id = this.next_anchor_id_; |
| this.next_anchor_id_++; |
| |
| this.anchor_controllers_.set(anchor_id, anchorController); |
| anchorController.device = this; |
| anchorController.id = anchor_id; |
| |
| resolve({ |
| result : vrMojom.CreateAnchorResult.SUCCESS, |
| anchorId : anchor_id |
| }); |
| } else { |
| // The test has rejected anchor creation. |
| resolve({ |
| result : vrMojom.CreateAnchorResult.FAILURE, |
| anchorId : 0n |
| }); |
| } |
| }) |
| .catch(() => { |
| // The test threw an error, treat anchor creation as failed. |
| resolve({ |
| result : vrMojom.CreateAnchorResult.FAILURE, |
| anchorId : 0n |
| }); |
| }); |
| }); |
| } |
| |
| createPlaneAnchor(planeFromAnchor, planeId) { |
| return new Promise((resolve) => { |
| |
| // Not supported yet. |
| |
| resolve({ |
| result : vrMojom.CreateAnchorResult.FAILURE, |
| anchorId : 0n, |
| }); |
| }); |
| } |
| |
| detachAnchor(anchorId) {} |
| |
| // Utility function |
| _requestRuntimeSession(sessionOptions) { |
| return this._runtimeSupportsSession(sessionOptions).then((result) => { |
| // The JavaScript bindings convert c_style_names to camelCase names. |
| const options = { |
| transportMethod: |
| vrMojom.XRPresentationTransportMethod.SUBMIT_AS_MAILBOX_HOLDER, |
| waitForTransferNotification: true, |
| waitForRenderNotification: true, |
| waitForGpuFence: false, |
| }; |
| |
| let submit_frame_sink; |
| if (result.supportsSession) { |
| submit_frame_sink = { |
| clientReceiver: this.presentation_provider_._getClientReceiver(), |
| provider: this.presentation_provider_._bindProvider(sessionOptions), |
| transportOptions: options |
| }; |
| |
| const dataProviderPtr = new vrMojom.XRFrameDataProviderRemote(); |
| this.dataProviderReceiver_ = |
| new vrMojom.XRFrameDataProviderReceiver(this); |
| this.dataProviderReceiver_.$.bindHandle( |
| dataProviderPtr.$.bindNewPipeAndPassReceiver().handle); |
| this.sessionOptions_ = sessionOptions; |
| |
| this.sessionClient_ = new vrMojom.XRSessionClientRemote(); |
| const clientReceiver = this.sessionClient_.$.bindNewPipeAndPassReceiver(); |
| |
| const enabled_features = []; |
| for (let i = 0; i < sessionOptions.requiredFeatures.length; i++) { |
| const feature = sessionOptions.requiredFeatures[i]; |
| if (this._maybeEnableFeature(feature, sessionOptions)) { |
| enabled_features.push(feature); |
| } else { |
| return Promise.resolve({session: null}); |
| } |
| } |
| |
| for (let i =0; i < sessionOptions.optionalFeatures.length; i++) { |
| const feature = sessionOptions.optionalFeatures[i]; |
| if (this._maybeEnableFeature(feature, sessionOptions)) { |
| enabled_features.push(feature); |
| } |
| } |
| |
| this.enabledFeatures_ = enabled_features; |
| |
| return Promise.resolve({ |
| session: { |
| submitFrameSink: submit_frame_sink, |
| dataProvider: dataProviderPtr, |
| clientReceiver: clientReceiver, |
| enabledFeatures: enabled_features, |
| deviceConfig: { |
| defaultFramebufferScale: this.defaultFramebufferScale_, |
| supportsViewportScaling: true, |
| // If depth was not enabled above, this should be null. |
| depthConfiguration: this.depthConfiguration_, |
| views: this._getDefaultViews(), |
| }, |
| enviromentBlendMode: this.enviromentBlendMode_, |
| interactionMode: this.interactionMode_ |
| } |
| }); |
| } else { |
| return Promise.resolve({session: null}); |
| } |
| }); |
| } |
| |
| _runtimeSupportsSession(options) { |
| let result = this.supportedModes_.includes(options.mode); |
| return Promise.resolve({ |
| supportsSession: result, |
| }); |
| } |
| |
| _tryGetDepthConfig(options) { |
| if (!options.depthOptions) { |
| return null; |
| } |
| |
| // At present, there are only two depth usages, and we only support CPU. |
| if (options.depthOptions.usagePreferences.length !== 0 && |
| !options.depthOptions.usagePreferences.includes( |
| xrSessionMojom.XRDepthUsage.kCPUOptimized)) { |
| return null; |
| } |
| const selectedUsage = xrSessionMojom.XRDepthUsage.kCPUOptimized; |
| |
| let selectedFormat = null; |
| if (options.depthOptions.dataFormatPreferences.length === 0) { |
| selectedFormat = this.depthSupport_.depthFormats.length === 0 ? |
| xrSessionMojom.XRDepthDataFormat.kLuminanceAlpha : this.depthSupport_.depthFormats[0]; |
| } else { |
| for (const dataFormatRequest of options.depthOptions.dataFormatPreferences) { |
| if (this.depthSupport_.depthFormats.length === 0 || |
| this.depthSupport_.depthFormats.includes(dataFormatRequest)) { |
| selectedFormat = dataFormatRequest; |
| break; |
| } |
| } |
| } |
| |
| if (selectedFormat === null) { |
| return null; |
| } |
| |
| // Default to our first supported depth type. If it's empty (meaning all), |
| // then just default to raw. |
| let selectedDepthType = this.depthSupport_.depthTypes.length === 0 ? |
| xrSessionMojom.XRDepthType.kRawDepth : this.depthSupport_.depthTypes[0]; |
| // Try to set the depthType to the earliest requested one if it's supported. |
| for (const depthTypeRequest of options.depthOptions.depthTypeRequest) { |
| if (this.depthSupport_.depthTypes.length === 0 || |
| this.depthSupport_.depthTypes.includes(depthTypeRequest)) { |
| selectedDepthType = depthTypeRequest; |
| break; |
| } |
| } |
| |
| return { |
| depthUsage: selectedUsage, |
| depthDataFormat: selectedFormat, |
| depthType: selectedDepthType, |
| }; |
| } |
| |
| _maybeEnableFeature(feature, options) { |
| if (this.supportedFeatures_.indexOf(feature) === -1) { |
| return false; |
| } |
| |
| switch (feature) { |
| case xrSessionMojom.XRSessionFeature.DEPTH: |
| this.depthConfiguration_ = this._tryGetDepthConfig(options); |
| this.matchDepthView_ = options.depthOptions && options.depthOptions.matchDepthView; |
| return this.depthConfiguration_ != null; |
| default: |
| return true; |
| } |
| } |
| |
| // Private functions - utilities: |
| _nativeOriginKnown(nativeOriginInformation){ |
| |
| if (nativeOriginInformation.inputSourceSpaceInfo !== undefined) { |
| if (!this.input_sources_.has(nativeOriginInformation.inputSourceSpaceInfo.inputSourceId)) { |
| // Unknown input source. |
| return false; |
| } |
| |
| return true; |
| } else if (nativeOriginInformation.referenceSpaceType !== undefined) { |
| // Bounded_floor & unbounded ref spaces are not yet supported for AR: |
| if (nativeOriginInformation.referenceSpaceType == vrMojom.XRReferenceSpaceType.kUnbounded |
| || nativeOriginInformation.referenceSpaceType == vrMojom.XRReferenceSpaceType.kBoundedFloor) { |
| return false; |
| } |
| |
| return true; |
| } else { |
| // Planes and anchors are not yet supported by the mock interface. |
| return false; |
| } |
| } |
| |
| // Private functions - anchors implementation: |
| |
| // Modifies passed in frameData to add anchor information. |
| _calculateAnchorInformation(frameData) { |
| if (!this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) { |
| return; |
| } |
| |
| frameData.anchorsData = {allAnchorsIds: [], updatedAnchorsData: []}; |
| for(const [id, controller] of this.anchor_controllers_) { |
| frameData.anchorsData.allAnchorsIds.push(id); |
| |
| // Send the entire anchor data over if there was a change since last GetFrameData(). |
| if(controller.dirty) { |
| const anchorData = {id}; |
| if(!controller.paused) { |
| anchorData.mojoFromAnchor = getPoseFromTransform( |
| XRMathHelper.decomposeRigidTransform( |
| controller._getAnchorOrigin())); |
| } |
| |
| controller._markProcessed(); |
| |
| frameData.anchorsData.updatedAnchorsData.push(anchorData); |
| } |
| } |
| } |
| |
| // Private functions - depth sensing implementation: |
| |
| /** |
| * Helper to get a TypedArray view for a given depth format. |
| * @param {ArrayBuffer} buffer The ArrayBuffer. |
| * @param {xrSessionMojom.XRDepthDataFormat} format The depth format. |
| * @return {Uint16Array|Float32Array} A typed array view. |
| */ |
| static _getTypedArrayForFormat(buffer, format) { |
| if (format === xrSessionMojom.XRDepthDataFormat.kFloat32) { |
| return new Float32Array(buffer); |
| } else { // "luminance-alpha" or "unsigned-short" |
| return new Uint16Array(buffer); |
| } |
| } |
| |
| /** |
| * Helper to get a TypedArray for the given depth format. |
| * @param {xrSessionMojom.XRDepthDataFormat} format - The Depth format |
| * @param {number} size - The size of the array to be created. |
| * @return {Uint16Array|Float32Array} A typed array view. |
| */ |
| static _getEmptyTypedArrayForFormat(format, size) { |
| if (format === xrSessionMojom.XRDepthDataFormat.kFloat32) { |
| return new Float32Array(size).fill(0.0); |
| } else { // "luminance-alpha" or "unsigned-short" (Uint16) |
| return new Uint16Array(size).fill(0); |
| } |
| } |
| |
| /** |
| * Reprojects depth data from a source view to a target view. |
| * The returned array will be the same width/height, but will be returned as a |
| * Uint8Array of the targetFormat (essentially a byte array that can be sent |
| * across mojo), so the overall returned size may be different. |
| * |
| * @param {ArrayBuffer} sourceDepthArrayBuffer - Raw depth data for the source. |
| * @param {number} width - Width of the depth data. |
| * @param {number} height - Height of the depth data. |
| * @param {xrSessionMojom.XRDepthDataFormat} sourceFormatEnum - Format of the source depth data. |
| * @param {Float32Array} sourceClipFromSourceView - Projection matrix for the source view. |
| * @param {Float32Array} mojoFromSourceView- Matrix of the transform for the source view. |
| * @param {xrSessionMojom.XRDepthDataFormat} targetFormatEnum - Format of the target depth data. |
| * @param {Float32Array} targetClipFromTargetView - Projection matrix for the target view. |
| * @param {Float32Array} mojoFromTargetView - Matrix of the transform for the target view. |
| * @return {Uint8Array | null} The reprojected depth data as an Uint8Array, or null on matrix error. |
| */ |
| static copyDepthData( |
| sourceDepthArrayBuffer, width, height, sourceFormatEnum, |
| sourceClipFromSourceView, mojoFromSourceView, |
| targetFormatEnum, |
| targetClipFromTargetView, mojoFromTargetView) { |
| |
| const targetViewFromTargetClip = XRMathHelper.inverse(targetClipFromTargetView); |
| const sourceViewFromMojo = XRMathHelper.inverse(mojoFromSourceView); |
| |
| // Check if any matrices were not supplied or matrix inversions failed. |
| if (!targetViewFromTargetClip || !sourceViewFromMojo || !mojoFromTargetView) { |
| return null; |
| } |
| |
| // Build the full transformation from Target Clip space to Source Clip space. |
| const mojoFromTargetClip = XRMathHelper.mul4x4(mojoFromTargetView, targetViewFromTargetClip); |
| if (!mojoFromTargetClip) return null; |
| |
| const sourceViewFromTargetClip = XRMathHelper.mul4x4(sourceViewFromMojo, mojoFromTargetClip); |
| if (!sourceViewFromTargetClip) return null; |
| |
| const sourceClipFromTargetClip = XRMathHelper.mul4x4(sourceClipFromSourceView, sourceViewFromTargetClip); |
| if (!sourceClipFromTargetClip) return null; |
| |
| const sourceTypedArray = MockRuntime._getTypedArrayForFormat(sourceDepthArrayBuffer, sourceFormatEnum); |
| let internalTargetDepthTypedArray = MockRuntime._getEmptyTypedArrayForFormat(targetFormatEnum, width * height); |
| |
| // Iterate over target pixels (Backward Mapping) |
| for (let ty = 0; ty < height; ++ty) { |
| for (let tx = 0; tx < width; ++tx) { |
| // Convert target pixel (tx, ty) to target NDC coordinates |
| const u_tgt_pixel = (tx + 0.5) / width; // u in [0, 1], Y-down from top-left |
| const v_tgt_pixel = (ty + 0.5) / height; // v in [0, 1], Y-down from top-left |
| |
| const ndc_x_tgt = u_tgt_pixel * 2.0 - 1.0; // NDC X in [-1, 1] |
| const ndc_y_tgt = 1.0 - v_tgt_pixel * 2.0; // NDC Y in [-1, 1], Y-up |
| |
| // Define a point on the near plane in target clip space |
| const P_clip_tgt = { x: ndc_x_tgt, y: ndc_y_tgt, z: -1.0, w: 1.0 }; |
| |
| // Transform this point to source clip space |
| const P_clip_src = XRMathHelper.transform_by_matrix(sourceClipFromTargetClip, P_clip_tgt); |
| |
| // Homogenize to get source NDC coordinates |
| if (Math.abs(P_clip_src.w) < XRMathHelper.EPSILON) { |
| internalTargetDepthTypedArray[ty * width + tx] = 0; // Cannot project |
| continue; |
| } |
| const ndc_x_src = P_clip_src.x / P_clip_src.w; |
| const ndc_y_src = P_clip_src.y / P_clip_src.w; |
| |
| // Convert source NDC to source pixel coordinates |
| const u_src_pixel = (ndc_x_src + 1.0) / 2.0; |
| const v_src_pixel = (1.0 - ndc_y_src) / 2.0; // Convert source NDC Y-up to pixel Y-down |
| |
| const sx = Math.floor(u_src_pixel * width); |
| const sy = Math.floor(v_src_pixel * height); |
| |
| let target_raw_depth = 0; // Default to 0 (no data) |
| |
| // Check if the calculated source pixel is within bounds |
| if (sx >= 0 && sx < width && sy >= 0 && sy < height) { |
| const source_raw_value = sourceTypedArray[sy * width + sx]; |
| |
| let isValidSourceDepth = false; |
| if (sourceFormatEnum === xrSessionMojom.XRDepthDataFormat.kFloat32) { |
| if (source_raw_value > 0 && isFinite(source_raw_value)) { |
| isValidSourceDepth = true; |
| } |
| } else { // Uint16 source |
| if (source_raw_value > 0) { |
| isValidSourceDepth = true; |
| } |
| } |
| |
| if (isValidSourceDepth) { |
| if (targetFormatEnum === xrSessionMojom.XRDepthDataFormat.kFloat32) { |
| target_raw_depth = source_raw_value; |
| } else { |
| // Clamp to the valid range for Uint16 |
| target_raw_depth = Math.max(0, Math.min(0xFFFF, Math.round(source_raw_value))); |
| } |
| } |
| } |
| |
| // If not in bounds or source depth invalid, target_raw_depth remains 0. |
| internalTargetDepthTypedArray[ty * width + tx] = target_raw_depth; |
| } |
| } |
| |
| return new Uint8Array(internalTargetDepthTypedArray.buffer); |
| } |
| |
| _getDepthPixelData(depthGeometry) { |
| if (!this.matchDepthView_ || !depthGeometry) { |
| return { bytes: this.depthSensingData_.depthData }; |
| } |
| |
| const sourceProjectionMatrix = depthGeometry.projectionMatrix; |
| const sourceViewOffset = depthGeometry.mojoFromView; |
| if (!sourceProjectionMatrix || !sourceViewOffset) { |
| return { bytes: this.depthSensingData_.depthData }; |
| } |
| |
| if (this.primaryViews_.length === 0) { |
| return { bytes: this.depthSensingData_.depthData }; |
| } |
| |
| const targetView = this.primaryViews_[0]; |
| const targetProjectionMatrix = targetView.geometry.projectionMatrix; |
| const targetViewOffset = targetView.geometry.mojoFromView; |
| if (!targetProjectionMatrix || !targetViewOffset) { |
| return { bytes: this.depthSensingData_.depthData }; |
| } |
| |
| return { bytes: MockRuntime.copyDepthData( |
| this.depthSensingData_.depthData, |
| this.depthSensingData_.width, |
| this.depthSensingData_.height, |
| MockRuntime._depthDataFormatToMojoMap[this.depthSensingData_.depthFormat], |
| sourceProjectionMatrix, |
| sourceViewOffset.matrix, |
| this.depthConfiguration_.depthDataFormat, |
| targetProjectionMatrix, |
| targetViewOffset.matrix |
| )}; |
| } |
| |
| // Modifies passed in frameData to add anchor information. |
| _calculateDepthInformation(frameData) { |
| if (!this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) { |
| return; |
| } |
| |
| if (!this.enabledFeatures_.includes(xrSessionMojom.XRSessionFeature.DEPTH)) { |
| return; |
| } |
| |
| let newDepthData; |
| |
| // If we don't have a current depth data, we'll return null |
| // (i.e. no data is not a valid data, so it cannot be "StillValid"). |
| if (this.depthSensingData_ == null) { |
| newDepthData = null; |
| } else if(!this.depthSensingDataDirty_) { |
| newDepthData = { dataStillValid: {}}; |
| } else { |
| let viewGeometry = null; |
| const projectionMatrix = this.depthSensingData_.projectionMatrix; |
| const viewOffset = this.depthSensingData_.viewOffset; |
| |
| if (projectionMatrix && viewOffset) { |
| const fov = this._getFovFromProjectionMatrix(projectionMatrix); |
| |
| viewGeometry = { |
| fieldOfView: fov, |
| mojoFromView: this._getMojoFromViewerWithOffset(composeGFXTransform(viewOffset)), |
| // Convenience member for `_getDepthPixelData` |
| projectionMatrix: projectionMatrix |
| }; |
| } |
| |
| newDepthData = { |
| updatedDepthData: { |
| timeDelta: frameData.timeDelta, |
| normTextureFromNormView: this.depthSensingData_.normDepthBufferFromNormView, |
| rawValueToMeters: this.depthSensingData_.rawValueToMeters, |
| size: { width: this.depthSensingData_.width, height: this.depthSensingData_.height }, |
| pixelData: this._getDepthPixelData(viewGeometry), |
| viewGeometry: this.matchDepthView_ ? null : viewGeometry |
| } |
| }; |
| } |
| |
| for (let i = 0; i < this.primaryViews_.length; i++) { |
| this.primaryViews_[i].depthData = newDepthData; |
| } |
| if (this.enabledFeatures_.includes(xrSessionMojom.XRSessionFeature.SECONDARY_VIEWS)) { |
| for (let i = 0; i < this.secondaryViews_.length; i++) { |
| this.secondaryViews_[i].depthData = newDepthData; |
| } |
| } |
| |
| this.depthSensingDataDirty_ = false; |
| } |
| |
| // Private functions - hit test implementation: |
| |
| // Returns a Promise<bool> that signifies whether hit test source creation should succeed. |
| // If we have a hit test source creation callback installed, invoke it and return its result. |
| // If it's not installed, for back-compat just return a promise that resolves to true. |
| _shouldHitTestSourceCreationSucceed(hitTestParameters, controller) { |
| if(this.hit_test_source_creation_callback_) { |
| return this.hit_test_source_creation_callback_(hitTestParameters, controller); |
| } else { |
| return Promise.resolve(true); |
| } |
| } |
| |
| // Modifies passed in frameData to add hit test results. |
| _calculateHitTestResults(frameData) { |
| if (!this.supportedModes_.includes(xrSessionMojom.XRSessionMode.kImmersiveAr)) { |
| return; |
| } |
| |
| frameData.hitTestSubscriptionResults = {results: [], |
| transientInputResults: []}; |
| if (!this.world_) { |
| return; |
| } |
| |
| // Non-transient hit test: |
| for (const [id, subscription] of this.hitTestSubscriptions_) { |
| const mojo_from_native_origin = this._getMojoFromNativeOrigin(subscription.nativeOriginInformation); |
| if (!mojo_from_native_origin) continue; |
| |
| const [mojo_ray_origin, mojo_ray_direction] = this._transformRayToMojoSpace( |
| subscription.ray, |
| mojo_from_native_origin |
| ); |
| |
| const results = this._hitTestWorld(mojo_ray_origin, mojo_ray_direction, subscription.entityTypes); |
| frameData.hitTestSubscriptionResults.results.push( |
| {subscriptionId: id, hitTestResults: results}); |
| } |
| |
| // Transient hit test: |
| const mojo_from_viewer = this._getMojoFromViewer(); |
| |
| for (const [id, subscription] of this.transientHitTestSubscriptions_) { |
| const result = {subscriptionId: id, |
| inputSourceIdToHitTestResults: new Map()}; |
| |
| // Find all input sources that match the profile name: |
| const matching_input_sources = Array.from(this.input_sources_.values()) |
| .filter(input_source => input_source.profiles_.includes(subscription.profileName)); |
| |
| for (const input_source of matching_input_sources) { |
| const mojo_from_native_origin = input_source._getMojoFromInputSource(mojo_from_viewer); |
| |
| const [mojo_ray_origin, mojo_ray_direction] = this._transformRayToMojoSpace( |
| subscription.ray, |
| mojo_from_native_origin |
| ); |
| |
| const results = this._hitTestWorld(mojo_ray_origin, mojo_ray_direction, subscription.entityTypes); |
| |
| result.inputSourceIdToHitTestResults.set(input_source.source_id_, results); |
| } |
| |
| frameData.hitTestSubscriptionResults.transientInputResults.push(result); |
| } |
| } |
| |
| // Returns 2-element array [origin, direction] of a ray in mojo space. |
| // |ray| is expressed relative to native origin. |
| _transformRayToMojoSpace(ray, mojo_from_native_origin) { |
| const ray_origin = { |
| x: ray.origin.x, |
| y: ray.origin.y, |
| z: ray.origin.z, |
| w: 1 |
| }; |
| const ray_direction = { |
| x: ray.direction.x, |
| y: ray.direction.y, |
| z: ray.direction.z, |
| w: 0 |
| }; |
| |
| const mojo_ray_origin = XRMathHelper.transform_by_matrix( |
| mojo_from_native_origin, |
| ray_origin); |
| const mojo_ray_direction = XRMathHelper.transform_by_matrix( |
| mojo_from_native_origin, |
| ray_direction); |
| |
| return [mojo_ray_origin, mojo_ray_direction]; |
| } |
| |
| // Hit tests the passed in ray (expressed as origin and direction) against the mocked world data. |
| _hitTestWorld(origin, direction, entityTypes) { |
| let result = []; |
| |
| for (const region of this.world_.hitTestRegions) { |
| const partial_result = this._hitTestRegion( |
| region, |
| origin, direction, |
| entityTypes); |
| |
| result = result.concat(partial_result); |
| } |
| |
| return result.sort((lhs, rhs) => lhs.distance - rhs.distance).map((hitTest) => { |
| delete hitTest.distance; |
| return hitTest; |
| }); |
| } |
| |
| // Hit tests the passed in ray (expressed as origin and direction) against world region. |
| // |entityTypes| is a set of FakeXRRegionTypes. |
| // |region| is FakeXRRegion. |
| // Returns array of XRHitResults, each entry will be decorated with the distance from the ray origin (along the ray). |
| _hitTestRegion(region, origin, direction, entityTypes) { |
| const regionNameToMojoEnum = { |
| "point": vrMojom.EntityTypeForHitTest.POINT, |
| "plane": vrMojom.EntityTypeForHitTest.PLANE, |
| "mesh":null |
| }; |
| |
| if (!entityTypes.includes(regionNameToMojoEnum[region.type])) { |
| return []; |
| } |
| |
| const result = []; |
| for (const face of region.faces) { |
| const maybe_hit = this._hitTestFace(face, origin, direction); |
| if (maybe_hit) { |
| result.push(maybe_hit); |
| } |
| } |
| |
| // The results should be sorted by distance and there should be no 2 entries with |
| // the same distance from ray origin - that would mean they are the same point. |
| // This situation is possible when a ray intersects the region through an edge shared |
| // by 2 faces. |
| return result.sort((lhs, rhs) => lhs.distance - rhs.distance) |
| .filter((val, index, array) => index === 0 || val.distance !== array[index - 1].distance); |
| } |
| |
| // Hit tests the passed in ray (expressed as origin and direction) against a single face. |
| // |face|, |origin|, and |direction| are specified in world (aka mojo) coordinates. |
| // |face| is an array of DOMPointInits. |
| // Returns null if the face does not intersect with the ray, otherwise the result is |
| // an XRHitResult with matrix describing the pose of the intersection point. |
| _hitTestFace(face, origin, direction) { |
| const add = XRMathHelper.add; |
| const sub = XRMathHelper.sub; |
| const mul = XRMathHelper.mul; |
| const normalize = XRMathHelper.normalize; |
| const dot = XRMathHelper.dot; |
| const cross = XRMathHelper.cross; |
| const neg = XRMathHelper.neg; |
| |
| //1. Calculate plane normal in world coordinates. |
| const point_A = face.vertices[0]; |
| const point_B = face.vertices[1]; |
| const point_C = face.vertices[2]; |
| |
| const edge_AB = sub(point_B, point_A); |
| const edge_AC = sub(point_C, point_A); |
| |
| const normal = normalize(cross(edge_AB, edge_AC)); |
| |
| const numerator = dot(sub(point_A, origin), normal); |
| const denominator = dot(direction, normal); |
| |
| if (Math.abs(denominator) < XRMathHelper.EPSILON) { |
| // Planes are nearly parallel - there's either infinitely many intersection points or 0. |
| // Both cases signify a "no hit" for us. |
| return null; |
| } else { |
| // Single intersection point between the infinite plane and the line (*not* ray). |
| // Need to calculate the hit test matrix taking into account the face vertices. |
| const distance = numerator / denominator; |
| if (distance < 0) { |
| // Line - plane intersection exists, but not the half-line - plane does not. |
| return null; |
| } else { |
| const intersection_point = add(origin, mul(distance, direction)); |
| // Since we are treating the face as a solid, flip the normal so that its |
| // half-space will contain the ray origin. |
| const y_axis = denominator > 0 ? neg(normal) : normal; |
| |
| let z_axis = null; |
| const cos_direction_and_y_axis = dot(direction, y_axis); |
| if (Math.abs(cos_direction_and_y_axis) > (1 - XRMathHelper.EPSILON)) { |
| // Ray and the hit test normal are co-linear - try using the 'up' or 'right' vector's projection on the face plane as the Z axis. |
| // Note: this edge case is currently not covered by the spec. |
| const up = {x: 0.0, y: 1.0, z: 0.0, w: 0.0}; |
| const right = {x: 1.0, y: 0.0, z: 0.0, w: 0.0}; |
| |
| z_axis = Math.abs(dot(up, y_axis)) > (1 - XRMathHelper.EPSILON) |
| ? sub(up, mul(dot(right, y_axis), y_axis)) // `up is also co-linear with hit test normal, use `right` |
| : sub(up, mul(dot(up, y_axis), y_axis)); // `up` is not co-linear with hit test normal, use it |
| } else { |
| // Project the ray direction onto the plane, negate it and use as a Z axis. |
| z_axis = neg(sub(direction, mul(cos_direction_and_y_axis, y_axis))); // Z should point towards the ray origin, not away. |
| } |
| |
| z_axis = normalize(z_axis); |
| const x_axis = normalize(cross(y_axis, z_axis)); |
| |
| // Filter out the points not in polygon. |
| if (!XRMathHelper.pointInFace(intersection_point, face)) { |
| return null; |
| } |
| |
| const hitResult = {planeId: 0n}; |
| hitResult.distance = distance; // Extend the object with additional information used by higher layers. |
| // It will not be serialized over mojom. |
| |
| const matrix = new Array(16); |
| |
| matrix[0] = x_axis.x; |
| matrix[1] = x_axis.y; |
| matrix[2] = x_axis.z; |
| matrix[3] = 0; |
| |
| matrix[4] = y_axis.x; |
| matrix[5] = y_axis.y; |
| matrix[6] = y_axis.z; |
| matrix[7] = 0; |
| |
| matrix[8] = z_axis.x; |
| matrix[9] = z_axis.y; |
| matrix[10] = z_axis.z; |
| matrix[11] = 0; |
| |
| matrix[12] = intersection_point.x; |
| matrix[13] = intersection_point.y; |
| matrix[14] = intersection_point.z; |
| matrix[15] = 1; |
| |
| hitResult.mojoFromResult = getPoseFromTransform( |
| XRMathHelper.decomposeRigidTransform(matrix)); |
| return hitResult; |
| } |
| } |
| } |
| |
| _getMojoFromViewer() { |
| if (!this.pose_) { |
| return XRMathHelper.identity(); |
| } |
| const transform = { |
| position: [ |
| this.pose_.position.x, |
| this.pose_.position.y, |
| this.pose_.position.z], |
| orientation: [ |
| this.pose_.orientation.x, |
| this.pose_.orientation.y, |
| this.pose_.orientation.z, |
| this.pose_.orientation.w], |
| }; |
| |
| return getMatrixFromTransform(transform); |
| } |
| |
| _getMojoFromViewerWithOffset(viewOffset) { |
| return { matrix: XRMathHelper.mul4x4(this._getMojoFromViewer(), viewOffset.matrix) }; |
| } |
| |
| _getMojoFromNativeOrigin(nativeOriginInformation) { |
| const mojo_from_viewer = this._getMojoFromViewer(); |
| |
| if (nativeOriginInformation.inputSourceSpaceInfo !== undefined) { |
| if (!this.input_sources_.has(nativeOriginInformation.inputSourceSpaceInfo.inputSourceId)) { |
| return null; |
| } else { |
| const inputSource = this.input_sources_.get(nativeOriginInformation.inputSourceSpaceInfo.inputSourceId); |
| return inputSource._getMojoFromInputSource(mojo_from_viewer); |
| } |
| } else if (nativeOriginInformation.referenceSpaceType !== undefined) { |
| switch (nativeOriginInformation.referenceSpaceType) { |
| case vrMojom.XRReferenceSpaceType.kLocal: |
| return XRMathHelper.identity(); |
| case vrMojom.XRReferenceSpaceType.kLocalFloor: |
| if (this.stageParameters_ == null || this.stageParameters_.mojoFromStage == null) { |
| console.warn("Standing transform not available."); |
| return null; |
| } |
| return this.stageParameters_.mojoFromStage.matrix; |
| case vrMojom.XRReferenceSpaceType.kViewer: |
| return mojo_from_viewer; |
| case vrMojom.XRReferenceSpaceType.kBoundedFloor: |
| return null; |
| case vrMojom.XRReferenceSpaceType.kUnbounded: |
| return null; |
| default: |
| throw new TypeError("Unrecognized XRReferenceSpaceType!"); |
| } |
| } else { |
| // Anchors & planes are not yet supported for hit test. |
| return null; |
| } |
| } |
| } |
| |
| class MockXRInputSource { |
| constructor(fakeInputSourceInit, id, pairedDevice) { |
| this.source_id_ = id; |
| this.pairedDevice_ = pairedDevice; |
| this.handedness_ = fakeInputSourceInit.handedness; |
| this.target_ray_mode_ = fakeInputSourceInit.targetRayMode; |
| |
| if (fakeInputSourceInit.pointerOrigin == null) { |
| throw new TypeError("FakeXRInputSourceInit.pointerOrigin is required."); |
| } |
| |
| this.setPointerOrigin(fakeInputSourceInit.pointerOrigin); |
| this.setProfiles(fakeInputSourceInit.profiles); |
| |
| this.primary_input_pressed_ = false; |
| if (fakeInputSourceInit.selectionStarted != null) { |
| this.primary_input_pressed_ = fakeInputSourceInit.selectionStarted; |
| } |
| |
| this.primary_input_clicked_ = false; |
| if (fakeInputSourceInit.selectionClicked != null) { |
| this.primary_input_clicked_ = fakeInputSourceInit.selectionClicked; |
| } |
| |
| this.primary_squeeze_pressed_ = false; |
| this.primary_squeeze_clicked_ = false; |
| |
| this.mojo_from_input_ = null; |
| if (fakeInputSourceInit.gripOrigin != null) { |
| this.setGripOrigin(fakeInputSourceInit.gripOrigin); |
| } |
| |
| // This properly handles if supportedButtons were not specified. |
| this.setSupportedButtons(fakeInputSourceInit.supportedButtons); |
| |
| this.emulated_position_ = false; |
| this.desc_dirty_ = true; |
| } |
| |
| // WebXR Test API |
| setHandedness(handedness) { |
| if (this.handedness_ != handedness) { |
| this.desc_dirty_ = true; |
| this.handedness_ = handedness; |
| } |
| } |
| |
| setTargetRayMode(targetRayMode) { |
| if (this.target_ray_mode_ != targetRayMode) { |
| this.desc_dirty_ = true; |
| this.target_ray_mode_ = targetRayMode; |
| } |
| } |
| |
| setProfiles(profiles) { |
| this.desc_dirty_ = true; |
| this.profiles_ = profiles; |
| } |
| |
| setGripOrigin(transform, emulatedPosition = false) { |
| // grip_origin was renamed to mojo_from_input in mojo |
| this.mojo_from_input_ = composeGFXTransform(transform); |
| this.emulated_position_ = emulatedPosition; |
| |
| // Technically, setting the grip shouldn't make the description dirty, but |
| // the webxr-test-api sets our pointer as mojoFromPointer; however, we only |
| // support it across mojom as inputFromPointer, so we need to recalculate it |
| // whenever the grip moves. |
| this.desc_dirty_ = true; |
| } |
| |
| clearGripOrigin() { |
| // grip_origin was renamed to mojo_from_input in mojo |
| if (this.mojo_from_input_ != null) { |
| this.mojo_from_input_ = null; |
| this.emulated_position_ = false; |
| this.desc_dirty_ = true; |
| } |
| } |
| |
| setPointerOrigin(transform, emulatedPosition = false) { |
| // pointer_origin is mojo_from_pointer. |
| this.desc_dirty_ = true; |
| this.mojo_from_pointer_ = composeGFXTransform(transform); |
| this.emulated_position_ = emulatedPosition; |
| } |
| |
| disconnect() { |
| this.pairedDevice_._removeInputSource(this); |
| } |
| |
| reconnect() { |
| this.pairedDevice_._addInputSource(this); |
| } |
| |
| startSelection() { |
| this.primary_input_pressed_ = true; |
| if (this.gamepad_) { |
| this.gamepad_.buttons[0].pressed = true; |
| this.gamepad_.buttons[0].touched = true; |
| } |
| } |
| |
| endSelection() { |
| if (!this.primary_input_pressed_) { |
| throw new Error("Attempted to end selection which was not started"); |
| } |
| |
| this.primary_input_pressed_ = false; |
| this.primary_input_clicked_ = true; |
| |
| if (this.gamepad_) { |
| this.gamepad_.buttons[0].pressed = false; |
| this.gamepad_.buttons[0].touched = false; |
| } |
| } |
| |
| simulateSelect() { |
| this.primary_input_clicked_ = true; |
| } |
| |
| setSupportedButtons(supportedButtons) { |
| this.gamepad_ = null; |
| this.supported_buttons_ = []; |
| |
| // If there are no supported buttons, we can stop now. |
| if (supportedButtons == null || supportedButtons.length < 1) { |
| return; |
| } |
| |
| const supported_button_map = {}; |
| this.gamepad_ = this._getEmptyGamepad(); |
| for (let i = 0; i < supportedButtons.length; i++) { |
| const buttonType = supportedButtons[i].buttonType; |
| this.supported_buttons_.push(buttonType); |
| supported_button_map[buttonType] = supportedButtons[i]; |
| } |
| |
| // Let's start by building the button state in order of priority: |
| // Primary button is index 0. |
| this.gamepad_.buttons.push({ |
| pressed: this.primary_input_pressed_, |
| touched: this.primary_input_pressed_, |
| value: this.primary_input_pressed_ ? 1.0 : 0.0 |
| }); |
| |
| // Now add the rest of our buttons |
| this._addGamepadButton(supported_button_map['grip']); |
| this._addGamepadButton(supported_button_map['touchpad']); |
| this._addGamepadButton(supported_button_map['thumbstick']); |
| this._addGamepadButton(supported_button_map['optional-button']); |
| this._addGamepadButton(supported_button_map['optional-thumbstick']); |
| |
| // Finally, back-fill placeholder buttons/axes |
| for (let i = 0; i < this.gamepad_.buttons.length; i++) { |
| if (this.gamepad_.buttons[i] == null) { |
| this.gamepad_.buttons[i] = { |
| pressed: false, |
| touched: false, |
| value: 0 |
| }; |
| } |
| } |
| |
| for (let i=0; i < this.gamepad_.axes.length; i++) { |
| if (this.gamepad_.axes[i] == null) { |
| this.gamepad_.axes[i] = 0; |
| } |
| } |
| } |
| |
| updateButtonState(buttonState) { |
| if (this.supported_buttons_.indexOf(buttonState.buttonType) == -1) { |
| throw new Error("Tried to update state on an unsupported button"); |
| } |
| |
| const buttonIndex = this._getButtonIndex(buttonState.buttonType); |
| const axesStartIndex = this._getAxesStartIndex(buttonState.buttonType); |
| |
| if (buttonIndex == -1) { |
| throw new Error("Unknown Button Type!"); |
| } |
| |
| // is this a 'squeeze' button? |
| if (buttonIndex === this._getButtonIndex('grip')) { |
| // squeeze |
| if (buttonState.pressed) { |
| this.primary_squeeze_pressed_ = true; |
| } else if (this.gamepad_.buttons[buttonIndex].pressed) { |
| this.primary_squeeze_clicked_ = true; |
| this.primary_squeeze_pressed_ = false; |
| } else { |
| this.primary_squeeze_clicked_ = false; |
| this.primary_squeeze_pressed_ = false; |
| } |
| } |
| |
| this.gamepad_.buttons[buttonIndex].pressed = buttonState.pressed; |
| this.gamepad_.buttons[buttonIndex].touched = buttonState.touched; |
| this.gamepad_.buttons[buttonIndex].value = buttonState.pressedValue; |
| |
| if (axesStartIndex != -1) { |
| this.gamepad_.axes[axesStartIndex] = buttonState.xValue == null ? 0.0 : buttonState.xValue; |
| this.gamepad_.axes[axesStartIndex + 1] = buttonState.yValue == null ? 0.0 : buttonState.yValue; |
| } |
| } |
| |
| // DOM Overlay Extensions |
| setOverlayPointerPosition(x, y) { |
| this.overlay_pointer_position_ = {x: x, y: y}; |
| } |
| |
| // Helpers for Mojom |
| _getInputSourceState() { |
| const input_state = {}; |
| |
| input_state.sourceId = this.source_id_; |
| input_state.isAuxiliary = false; |
| |
| input_state.primaryInputPressed = this.primary_input_pressed_; |
| input_state.primaryInputClicked = this.primary_input_clicked_; |
| |
| input_state.primarySqueezePressed = this.primary_squeeze_pressed_; |
| input_state.primarySqueezeClicked = this.primary_squeeze_clicked_; |
| // Setting the input source's "clicked" state should generate one "select" |
| // event. Reset the input value to prevent it from continuously generating |
| // events. |
| this.primary_input_clicked_ = false; |
| // Setting the input source's "clicked" state should generate one "squeeze" |
| // event. Reset the input value to prevent it from continuously generating |
| // events. |
| this.primary_squeeze_clicked_ = false; |
| |
| input_state.mojoFromInput = this.mojo_from_input_; |
| |
| input_state.gamepad = this.gamepad_; |
| |
| input_state.emulatedPosition = this.emulated_position_; |
| |
| if (this.desc_dirty_) { |
| const input_desc = {}; |
| |
| switch (this.target_ray_mode_) { |
| case 'gaze': |
| input_desc.targetRayMode = vrMojom.XRTargetRayMode.GAZING; |
| break; |
| case 'tracked-pointer': |
| input_desc.targetRayMode = vrMojom.XRTargetRayMode.POINTING; |
| break; |
| case 'screen': |
| input_desc.targetRayMode = vrMojom.XRTargetRayMode.TAPPING; |
| break; |
| default: |
| throw new Error('Unhandled target ray mode ' + this.target_ray_mode_); |
| } |
| |
| switch (this.handedness_) { |
| case 'left': |
| input_desc.handedness = vrMojom.XRHandedness.LEFT; |
| break; |
| case 'right': |
| input_desc.handedness = vrMojom.XRHandedness.RIGHT; |
| break; |
| default: |
| input_desc.handedness = vrMojom.XRHandedness.NONE; |
| break; |
| } |
| |
| // Mojo requires us to send the pointerOrigin as relative to the grip |
| // space. If we don't have a grip space, we'll just assume that there |
| // is a grip at identity. This allows tests to simulate controllers that |
| // are really just a pointer with no tracked grip, though we will end up |
| // exposing that grip space. |
| let mojo_from_input = XRMathHelper.identity(); |
| switch (this.target_ray_mode_) { |
| case 'gaze': |
| case 'screen': |
| // For gaze and screen space, we won't have a mojo_from_input; however |
| // the "input" position is just the viewer, so use mojo_from_viewer. |
| mojo_from_input = this.pairedDevice_._getMojoFromViewer(); |
| break; |
| case 'tracked-pointer': |
| // If we have a tracked grip position (e.g. mojo_from_input), then use |
| // that. If we don't, then we'll just set the pointer offset directly, |
| // using identity as set above. |
| if (this.mojo_from_input_) { |
| mojo_from_input = this.mojo_from_input_.matrix; |
| } |
| break; |
| default: |
| throw new Error('Unhandled target ray mode ' + this.target_ray_mode_); |
| } |
| |
| // To convert mojo_from_pointer to input_from_pointer, we need: |
| // input_from_pointer = input_from_mojo * mojo_from_pointer |
| // Since we store mojo_from_input, we need to invert it here before |
| // multiplying. |
| let input_from_mojo = XRMathHelper.inverse(mojo_from_input); |
| input_desc.inputFromPointer = {}; |
| input_desc.inputFromPointer.matrix = |
| XRMathHelper.mul4x4(input_from_mojo, this.mojo_from_pointer_.matrix); |
| |
| input_desc.profiles = this.profiles_; |
| |
| input_state.description = input_desc; |
| |
| this.desc_dirty_ = false; |
| } |
| |
| // Pointer data for DOM Overlay, set by setOverlayPointerPosition() |
| if (this.overlay_pointer_position_) { |
| input_state.overlayPointerPosition = this.overlay_pointer_position_; |
| this.overlay_pointer_position_ = null; |
| } |
| |
| return input_state; |
| } |
| |
| _getEmptyGamepad() { |
| // Mojo complains if some of the properties on Gamepad are null, so set |
| // everything to reasonable defaults that tests can override. |
| const gamepad = { |
| connected: true, |
| id: [], |
| timestamp: 0n, |
| axes: [], |
| buttons: [], |
| touchEvents: [], |
| mapping: GamepadMapping.GamepadMappingStandard, |
| displayId: 0, |
| }; |
| |
| switch (this.handedness_) { |
| case 'left': |
| gamepad.hand = GamepadHand.GamepadHandLeft; |
| break; |
| case 'right': |
| gamepad.hand = GamepadHand.GamepadHandRight; |
| break; |
| default: |
| gamepad.hand = GamepadHand.GamepadHandNone; |
| break; |
| } |
| |
| return gamepad; |
| } |
| |
| _addGamepadButton(buttonState) { |
| if (buttonState == null) { |
| return; |
| } |
| |
| const buttonIndex = this._getButtonIndex(buttonState.buttonType); |
| const axesStartIndex = this._getAxesStartIndex(buttonState.buttonType); |
| |
| if (buttonIndex == -1) { |
| throw new Error("Unknown Button Type!"); |
| } |
| |
| this.gamepad_.buttons[buttonIndex] = { |
| pressed: buttonState.pressed, |
| touched: buttonState.touched, |
| value: buttonState.pressedValue |
| }; |
| |
| // Add x/y value if supported. |
| if (axesStartIndex != -1) { |
| this.gamepad_.axes[axesStartIndex] = (buttonState.xValue == null ? 0.0 : buttonSate.xValue); |
| this.gamepad_.axes[axesStartIndex + 1] = (buttonState.yValue == null ? 0.0 : buttonSate.yValue); |
| } |
| } |
| |
| // General Helper methods |
| _getButtonIndex(buttonType) { |
| switch (buttonType) { |
| case 'grip': |
| return 1; |
| case 'touchpad': |
| return 2; |
| case 'thumbstick': |
| return 3; |
| case 'optional-button': |
| return 4; |
| case 'optional-thumbstick': |
| return 5; |
| default: |
| return -1; |
| } |
| } |
| |
| _getAxesStartIndex(buttonType) { |
| switch (buttonType) { |
| case 'touchpad': |
| return 0; |
| case 'thumbstick': |
| return 2; |
| case 'optional-thumbstick': |
| return 4; |
| default: |
| return -1; |
| } |
| } |
| |
| _getMojoFromInputSource(mojo_from_viewer) { |
| return this.mojo_from_pointer_.matrix; |
| } |
| } |
| |
| // Mojo helper classes |
| class FakeXRHitTestSourceController { |
| constructor(id) { |
| this.id_ = id; |
| this.deleted_ = false; |
| } |
| |
| get deleted() { |
| return this.deleted_; |
| } |
| |
| // Internal setter: |
| set deleted(value) { |
| this.deleted_ = value; |
| } |
| } |
| |
| class MockXRPresentationProvider { |
| constructor() { |
| this.receiver_ = null; |
| this.submit_frame_count_ = 0; |
| this.missing_frame_count_ = 0; |
| } |
| |
| _bindProvider() { |
| const provider = new vrMojom.XRPresentationProviderRemote(); |
| |
| if (this.receiver_) { |
| this.receiver_.$.close(); |
| } |
| this.receiver_ = new vrMojom.XRPresentationProviderReceiver(this); |
| this.receiver_.$.bindHandle(provider.$.bindNewPipeAndPassReceiver().handle); |
| return provider; |
| } |
| |
| _getClientReceiver() { |
| this.submitFrameClient_ = new vrMojom.XRPresentationClientRemote(); |
| return this.submitFrameClient_.$.bindNewPipeAndPassReceiver(); |
| } |
| |
| // XRPresentationProvider mojo implementation |
| updateLayerBounds(frameId, leftBounds, rightBounds, sourceSize) {} |
| |
| 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(); |
| } |
| |
| submitFrameWithTextureHandle(frameId, texture, syncToken) {} |
| |
| submitFrameDrawnIntoTexture(frameId, syncToken, timeWaited) {} |
| |
| // Utility methods |
| _close() { |
| if (this.receiver_) { |
| this.receiver_.$.close(); |
| } |
| } |
| } |
| |
| // Export these into the global object as a side effect of importing this |
| // module. |
| self.ChromeXRTest = ChromeXRTest; |
| self.MockRuntime = MockRuntime; |
| self.MockVRService = MockVRService; |
| self.SubscribeToHitTestResult = vrMojom.SubscribeToHitTestResult; |
| |
| navigator.xr.test = new ChromeXRTest(); |