diff --git a/build/dependencies.txt b/build/dependencies.txt index 4201f2cbe54..89e0193954b 100644 --- a/build/dependencies.txt +++ b/build/dependencies.txt @@ -111,6 +111,7 @@ ../src/input/element-input.js ../src/vr/vr-manager.js ../src/vr/vr-display.js +../src/xr/xr-manager.js ../src/net/http.js ../src/script/script-registry.js ../src/script/script.js diff --git a/examples/examples.js b/examples/examples.js index 7eb85ae6a3a..fdfefdb409c 100644 --- a/examples/examples.js +++ b/examples/examples.js @@ -71,5 +71,11 @@ var categories = [ "text-wrap", "various" ] + }, { + name: "xr", + examples: [ + 'augmented-reality-basic', + 'virtual-reality-basic' + ] } -]; \ No newline at end of file +]; diff --git a/examples/style.css b/examples/style.css index c35a8f7813f..cd40ee09016 100644 --- a/examples/style.css +++ b/examples/style.css @@ -4,7 +4,8 @@ body { font-family: "Proxima Nova", Helvetica, arial, sans-serif; font-size: 16px; margin: 0; - overscroll-behavior: none; } + overscroll-behavior: none; +} /* Sidebar menu */ .sidenav { @@ -22,13 +23,17 @@ body { display: flex; flex-direction: column; background: #364346; - transition: all 240ms ease-in-out; } - @media screen and (max-width: 1024px) { - .sidenav { - background: rgba(54, 67, 70, 0.98); - width: 100%; } - .sidenav.closed { - left: -100%; } } + transition: all 240ms ease-in-out; +} +@media screen and (max-width: 1024px) { + .sidenav { + background: rgba(54, 67, 70, 0.98); + width: 100%; + } + .sidenav.closed { + left: -100%; + } +} .sidenav-toggle { position: absolute; @@ -42,39 +47,50 @@ body { display: none; background: #20292b; border-radius: 300px; - cursor: pointer; } - .sidenav-toggle:hover { - background: black; } - @media screen and (max-width: 1024px) { - .sidenav-toggle { - display: block; } } + cursor: pointer; +} +.sidenav-toggle:hover { + background: black; +} +@media screen and (max-width: 1024px) { + .sidenav-toggle { + display: block; + } +} /* Navigation menu links */ .sidenav a, p { text-decoration: none; - display: block; } + display: block; +} .sidenav a { padding: 6px 8px 6px 32px; font-size: 1rem; color: #b1b8ba; - cursor: pointer; } - .sidenav a:hover { - color: #ffffff; } - .sidenav a.active { - color: #f60; } - @media screen and (max-width: 1024px) { - .sidenav a { - font-size: 1.2rem; - padding: 6px 8px 12px 32px; } } + cursor: pointer; +} +.sidenav a:hover { + color: #ffffff; +} +.sidenav a.active { + color: #f60; +} +@media screen and (max-width: 1024px) { + .sidenav a { + font-size: 1.2rem; + padding: 6px 8px 12px 32px; + } +} .sidenav p { padding: 24px 8px 8px 24px; margin: 0; font-size: 1.4rem; font-weight: bold; - color: #ffffff; } + color: #ffffff; +} #example, iframe { @@ -82,14 +98,17 @@ iframe { border: 0px; left: 0; right: 0; - padding-left: 160px; - width: calc(100% - 160px); + padding-left: 240px; + width: calc(100% - 240px); height: 100%; - overflow: auto; } - @media screen and (max-width: 1024px) { - #example, - iframe { - padding-left: 0; - width: 100%; } } + overflow: auto; +} +@media screen and (max-width: 1024px) { + #example, +iframe { + padding-left: 0; + width: 100%; + } +} /*# sourceMappingURL=style.css.map */ diff --git a/examples/style.css.map b/examples/style.css.map index ad59ed9f2e7..5bb4af39e5e 100644 --- a/examples/style.css.map +++ b/examples/style.css.map @@ -1,7 +1 @@ -{ -"version": 3, -"mappings": "AAmBA,IAAK;EACH,gBAAgB,EAAE,IAAI;EACtB,KAAK,EAAE,OAAO;EACd,WAAW,EAAE,4CAA4C;EACzD,SAAS,EAAE,IAAI;EACf,MAAM,EAAE,CAAC;EACT,mBAAmB,EAAE,IAAI;;AAG3B,kBAAkB;AAClB,QAAS;EACP,MAAM,EAAE,IAAI;EACZ,KAAK,EAAE,KAAK;EACZ,QAAQ,EAAE,KAAK;EAAE,6CAA6C;EAC9D,OAAO,EAAE,CAAC;EAAE,iBAAiB;EAC7B,GAAG,EAAE,CAAC;EACN,IAAI,EAAE,CAAC;EACP,UAAU,EAAE,MAAM;EAAE,+BAA+B;EACnD,WAAW,EAAE,GAAG;EAChB,OAAO,EAAE,IAAI;EACb,cAAc,EAAE,MAAM;EACtB,UAAU,EApCE,OAAO;EAqCnB,UAAU,EAAE,qBAAqB;EACjC,qCAA6C;IAb/C,QAAS;MAcL,UAAU,EAAE,sBAAsB;MAClC,KAAK,EAAE,IAAI;MACX,eAAS;QACP,IAAI,EAAE,KAAK;;AAKjB,eAAgB;EACd,QAAQ,EAAE,QAAQ;EAClB,KAAK,EAAE,IAAI;EACX,GAAG,EAAE,IAAI;EACT,MAAM,EAAE,IAAI;EACZ,KAAK,EAAE,IAAI;EACX,OAAO,EAAE,IAAI;EACb,KAAK,EAAE,KAAK;EACZ,OAAO,EAAE,GAAG;EACZ,OAAO,EAAE,IAAI;EACb,UAAU,EA5DE,OAAO;EA6DnB,aAAa,EAAE,KAAK;EACpB,MAAM,EAAE,OAAO;EACf,qBAAQ;IACN,UAAU,EAAE,KAAK;EAEnB,qCAA6C;IAhB/C,eAAgB;MAiBZ,OAAO,EAAE,KAAK;;AAIlB,2BAA2B;AAC3B;CACE;EACA,eAAe,EAAE,IAAI;EACrB,OAAO,EAAE,KAAK;;AAGhB,UAAW;EACT,OAAO,EAAE,gBAAgB;EACzB,SAAS,EAAE,IAAI;EACf,KAAK,EA3EU,OAAO;EA4EtB,MAAM,EAAE,OAAO;EACf,gBAAQ;IACN,KAAK,EA7EM,OAAO;EA+EpB,iBAAS;IACP,KAAK,EA/EK,IAAI;EAiFhB,qCAA6C;IAX/C,UAAW;MAYP,SAAS,EAAE,MAAM;MACjB,OAAO,EAAE,iBAAiB;;AAI9B,UAAW;EACT,OAAO,EAAE,iBAAiB;EAC1B,MAAM,EAAE,CAAC;EACT,SAAS,EAAE,MAAM;EACjB,WAAW,EAAE,IAAI;EACjB,KAAK,EA7FQ,OAAO;;AAgGtB;MACO;EACL,QAAQ,EAAE,KAAK;EACf,MAAM,EAAE,GAAG;EACX,IAAI,EAAE,CAAC;EACP,KAAK,EAAE,CAAC;EACR,YAAY,EAAE,KAAK;EACnB,KAAK,EAAE,kBAAkB;EACzB,MAAM,EAAE,IAAI;EACZ,QAAQ,EAAE,IAAI;EACd,qCAA6C;IAV/C;UACO;MAUH,YAAY,EAAE,CAAC;MACf,KAAK,EAAE,IAAI", -"sources": ["style.scss"], -"names": [], -"file": "style.css" -} \ No newline at end of file +{"version":3,"sourceRoot":"","sources":["style.scss"],"names":[],"mappings":"AAmBA;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;AACA;EACE;EACA;EACA;AAAiB;EACjB;AAAY;EACZ;EACA;EACA;AAAoB;EACpB;EACA;EACA;EACA,YApCY;EAqCZ;;AACA;EAbF;IAcI;IACA;;EACA;IACE;;;;AAKN;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,YA5DY;EA6DZ;EACA;;AACA;EACE;;AAEF;EAhBF;IAiBI;;;;AAIJ;AACA;AAAA;EAEE;EACA;;;AAGF;EACE;EACA;EACA,OA3Ee;EA4Ef;;AACA;EACE,OA7EW;;AA+Eb;EACE,OA/EU;;AAiFZ;EAXF;IAYI;IACA;;;;AAIJ;EACE;EACA;EACA;EACA;EACA,OA7Fa;;;AAgGf;AAAA;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AACA;EAVF;AAAA;IAWI;IACA","file":"style.css"} \ No newline at end of file diff --git a/examples/style.scss b/examples/style.scss index 373d90b8883..199ba6a2575 100644 --- a/examples/style.scss +++ b/examples/style.scss @@ -48,7 +48,7 @@ body { } } } - + .sidenav-toggle { position: absolute; right: 12px; @@ -108,8 +108,8 @@ iframe { border: 0px; left: 0; right: 0; - padding-left: 160px; - width: calc(100% - 160px); + padding-left: 240px; + width: calc(100% - 240px); height: 100%; overflow: auto; @media screen and (max-width: $break-medium) { diff --git a/examples/xr/augmented-reality-basic.html b/examples/xr/augmented-reality-basic.html new file mode 100644 index 00000000000..28e38cc487d --- /dev/null +++ b/examples/xr/augmented-reality-basic.html @@ -0,0 +1,170 @@ + + + + PlayCanvas Virtual Reality + + + + + + + + + +
+

+
+ + + + + + diff --git a/examples/xr/virtual-reality-basic.html b/examples/xr/virtual-reality-basic.html new file mode 100644 index 00000000000..73c16def9bb --- /dev/null +++ b/examples/xr/virtual-reality-basic.html @@ -0,0 +1,170 @@ + + + + PlayCanvas Virtual Reality + + + + + + + + + +
+

+
+ + + + + + diff --git a/src/callbacks.js b/src/callbacks.js index 50e9648c895..3acaa1aff73 100644 --- a/src/callbacks.js +++ b/src/callbacks.js @@ -171,3 +171,9 @@ * @callback pc.callbacks.VrFrame * @description Callback used by {@link pc.VrDisplay#requestAnimationFrame}. */ + +/** + * @callback pc.callbacks.XrError + * @description Callback used by {@link pc.XrManager#endXr} and {@link pc.XrManager#startXr}. + * @param {Error|null} err - The Error object or null if operation was successfull. + */ diff --git a/src/framework/application.js b/src/framework/application.js index 3541b2b2ded..965e224e15a 100644 --- a/src/framework/application.js +++ b/src/framework/application.js @@ -124,6 +124,17 @@ Object.assign(pc, function () { * this.app.systems.sound.volume = 0.5; */ + /** + * @name pc.Application#xr + * @type {pc.XrManager} + * @description The XR Manager that provides ability to start VR/AR sessions. + * @example + * // check if VR is available + * if (app.xr.isAvailable(pc.XRTYPE_VR)) { + * // VR is available + * } + */ + /** * @name pc.Application#loader * @type {pc.ResourceLoader} @@ -260,6 +271,11 @@ Object.assign(pc, function () { // for compatibility this.context = this; + if (! options.graphicsDeviceOptions) + options.graphicsDeviceOptions = { }; + + options.graphicsDeviceOptions.xrCompatible = true; + this.graphicsDevice = new pc.GraphicsDevice(canvas, options.graphicsDeviceOptions); this.stats = new pc.ApplicationStats(this.graphicsDevice); this._audioManager = new pc.SoundManager(options); @@ -536,6 +552,7 @@ Object.assign(pc, function () { this.elementInput.app = this; this.vr = null; + this.xr = new pc.XrManager(this); this._inTools = false; @@ -1381,6 +1398,10 @@ Object.assign(pc, function () { resizeCanvas: function (width, height) { if (!this._allowResize) return; // prevent resizing (e.g. if presenting in VR HMD) + // prevent resizing when in XR session + if (this.xr && this.xr.session) + return; + var windowWidth = window.innerWidth; var windowHeight = window.innerHeight; @@ -1744,6 +1765,7 @@ Object.assign(pc, function () { this.vr.destroy(); this.vr = null; } + this.xr.end(); this.graphicsDevice.destroy(); this.graphicsDevice = null; @@ -1789,13 +1811,19 @@ Object.assign(pc, function () { // create tick function to be wrapped in closure var makeTick = function (_app) { var app = _app; - return function (timestamp) { - if (!app.graphicsDevice) { + var frameRequest; + + return function (timestamp, frame) { + if (!app.graphicsDevice) return; - } Application._currentApplication = app; + if (frameRequest) { + cancelAnimationFrame(frameRequest); + frameRequest = null; + } + // have current application pointer in pc pc.app = app; @@ -1809,19 +1837,27 @@ Object.assign(pc, function () { // Submit a request to queue up a new animation frame immediately if (app.vr && app.vr.display) { - app.vr.display.requestAnimationFrame(app.tick); + frameRequest = app.vr.display.requestAnimationFrame(app.tick); + } else if (app.xr.session) { + frameRequest = app.xr.session.requestAnimationFrame(app.tick); } else { - requestAnimationFrame(app.tick); + frameRequest = requestAnimationFrame(app.tick); } - if (app.graphicsDevice.contextLost) { + if (app.graphicsDevice.contextLost) return; - } // #ifdef PROFILER app._fillFrameStats(now, dt, ms); // #endif + if (frame) { + app.xr.calculateViews(frame); + app.graphicsDevice.defaultFramebuffer = frame.session.renderState.baseLayer.framebuffer; + } else { + app.graphicsDevice.defaultFramebuffer = null; + } + app.update(dt); if (app.autoRender || app.renderNextFrame) { diff --git a/src/framework/components/camera/component.js b/src/framework/components/camera/component.js index f1564b2e1e0..4d57f31bf43 100644 --- a/src/framework/components/camera/component.js +++ b/src/framework/components/camera/component.js @@ -556,6 +556,51 @@ Object.assign(pc, function () { } else { callback("Not presenting VR"); } + }, + + /** + * @function + * @name pc.CameraComponent#startXr + * @description Attempt to start XR session with this camera + * @param {string} type - The type of session. Can be one of the following: + * + * * {@link pc.XRTYPE_INLINE}: Inline - always available type of session. It has limited features availability and is rendered into HTML element. + * * {@link pc.XRTYPE_VR}: Immersive VR - session that provides exclusive access to VR device with best available tracking features. + * * {@link pc.XRTYPE_AR}: Immersive AR - session that provides exclusive access to VR/AR device that is intended to be blended with real-world environment. + * + * @param {pc.callbacks.XrError} [callback] - Optional callback function called once session is started. The callback has one argument Error - it is null if successfully started XR session. + * @example + * // On an entity with a camera component + * this.entity.camera.startXr(pc.XRTYPE_VR, function (err) { + * if (err) { + * // failed to start XR session + * } else { + * // in XR + * } + * }); + */ + startXr: function (type, callback) { + this.system.app.xr.start(this, type, callback); + }, + + /** + * @function + * @name pc.CameraComponent#endXr + * @description Attempt to end XR session of this camera + * @param {pc.callbacks.XrError} [callback] - Optional callback function called once session is ended. The callback has one argument Error - it is null if successfully ended XR session. + * @example + * // On an entity with a camera component + * this.entity.camera.endXr(function (err) { + * // not anymore in XR + * }); + */ + endXr: function (callback) { + if (! this.camera.xr) { + if (callback) callback(new Error("Camera is not in XR")); + return; + } + + this.camera.xr.end(callback); } }); diff --git a/src/graphics/device.js b/src/graphics/device.js index 5b8d498e6e3..c690817b207 100644 --- a/src/graphics/device.js +++ b/src/graphics/device.js @@ -186,6 +186,7 @@ Object.assign(pc, function () { this._enableAutoInstancing = false; this.autoInstancingMaxObjects = 16384; this.attributesInvalidated = true; + this.defaultFramebuffer = null; this.boundBuffer = null; this.boundElementBuffer = null; this.instancedAttribs = { }; @@ -1269,7 +1270,7 @@ Object.assign(pc, function () { this.setFramebuffer(target._glFrameBuffer); } } else { - this.setFramebuffer(null); + this.setFramebuffer(this.defaultFramebuffer); } }, diff --git a/src/scene/forward-renderer.js b/src/scene/forward-renderer.js index b058a55e253..3bb58f6e56e 100644 --- a/src/scene/forward-renderer.js +++ b/src/scene/forward-renderer.js @@ -555,6 +555,10 @@ Object.assign(pc, function () { viewInvMat.copy(viewMat).invert(); this.viewInvId.setValue(viewInvMat.data); camera.frustum.update(projMat, viewMat); + } else if (camera.xr && camera.xr.views.length) { + // calculate frustum based on XR view + var view = camera.xr.views[0]; + camera.frustum.update(view.projMat, view.viewOffMat); return; } @@ -577,43 +581,9 @@ Object.assign(pc, function () { // make sure colorWrite is set to true to all channels, if you want to fully clear the target setCamera: function (camera, target, clear, cullBorder) { var vrDisplay = camera.vrDisplay; - if (!vrDisplay || !vrDisplay.presenting) { - // Projection Matrix - projMat = camera.getProjectionMatrix(); - if (camera.overrideCalculateProjection) camera.calculateProjection(projMat, pc.VIEW_CENTER); - this.projId.setValue(projMat.data); - - // ViewInverse Matrix - if (camera.overrideCalculateTransform) { - camera.calculateTransform(viewInvMat, pc.VIEW_CENTER); - } else { - var pos = camera._node.getPosition(); - var rot = camera._node.getRotation(); - viewInvMat.setTRS(pos, rot, pc.Vec3.ONE); - } - this.viewInvId.setValue(viewInvMat.data); - - // View Matrix - viewMat.copy(viewInvMat).invert(); - this.viewId.setValue(viewMat.data); - - // View 3x3 - mat3FromMat4(viewMat3, viewMat); - this.viewId3.setValue(viewMat3.data); - - // ViewProjection Matrix - viewProjMat.mul2(projMat, viewMat); - this.viewProjId.setValue(viewProjMat.data); - - // View Position (world space) - var cameraPos = camera._node.getPosition(); - this.viewPos[0] = cameraPos.x; - this.viewPos[1] = cameraPos.y; - this.viewPos[2] = cameraPos.z; - this.viewPosId.setValue(this.viewPos); + var parent, transform; - camera.frustum.update(projMat, viewMat); - } else { + if (vrDisplay && vrDisplay.presenting) { // Projection LR projL = vrDisplay.leftProj; projR = vrDisplay.rightProj; @@ -632,9 +602,9 @@ Object.assign(pc, function () { viewR.copy(viewInvR).invert(); viewMat.copy(viewInvMat).invert(); } else { - var parent = camera._node.parent; + parent = camera._node.parent; if (parent) { - var transform = parent.getWorldTransform(); + transform = parent.getWorldTransform(); // ViewInverse LR (parent) viewInvL.mul2(transform, vrDisplay.leftViewInv); @@ -677,6 +647,69 @@ Object.assign(pc, function () { viewPosR.y = viewInvR.data[13]; viewPosR.z = viewInvR.data[14]; + camera.frustum.update(projMat, viewMat); + } else if (camera.xr && camera.xr.session) { + parent = camera._node.parent; + if (parent) transform = parent.getWorldTransform(); + + var views = camera.xr.views; + + for (var v = 0; v < views.length; v++) { + var view = views[v]; + + if (parent) { + view.viewInvOffMat.mul2(transform, view.viewInvMat); + view.viewOffMat.copy(view.viewInvOffMat).invert(); + } else { + view.viewInvOffMat.copy(view.viewInvMat); + view.viewOffMat.copy(view.viewMat); + } + + mat3FromMat4(view.viewMat3, view.viewOffMat); + view.projViewOffMat.mul2(view.projMat, view.viewOffMat); + + view.positionOff.x = view.viewInvOffMat.data[12]; + view.positionOff.y = view.viewInvOffMat.data[13]; + view.positionOff.z = view.viewInvOffMat.data[14]; + + camera.frustum.update(view.projMat, view.viewOffMat); + } + } else { + // Projection Matrix + projMat = camera.getProjectionMatrix(); + if (camera.overrideCalculateProjection) camera.calculateProjection(projMat, pc.VIEW_CENTER); + this.projId.setValue(projMat.data); + + // ViewInverse Matrix + if (camera.overrideCalculateTransform) { + camera.calculateTransform(viewInvMat, pc.VIEW_CENTER); + } else { + var pos = camera._node.getPosition(); + var rot = camera._node.getRotation(); + viewInvMat.setTRS(pos, rot, pc.Vec3.ONE); + } + this.viewInvId.setValue(viewInvMat.data); + + // View Matrix + viewMat.copy(viewInvMat).invert(); + this.viewId.setValue(viewMat.data); + + // View 3x3 + mat3FromMat4(viewMat3, viewMat); + this.viewId3.setValue(viewMat3.data); + + // ViewProjection Matrix + viewProjMat.mul2(projMat, viewMat); + this.viewProjId.setValue(viewProjMat.data); + + // View Position (world space) + var cameraPos = camera._node.getPosition(); + this.viewPos[0] = cameraPos.x; + this.viewPos[1] = cameraPos.y; + this.viewPos[2] = cameraPos.z; + + this.viewPosId.setValue(this.viewPos); + camera.frustum.update(projMat, viewMat); } @@ -1728,6 +1761,29 @@ Object.assign(pc, function () { this.viewPosId.setValue(this.viewPos); i += this.drawInstance2(device, drawCall, mesh, style); this._forwardDrawCalls++; + } else if (camera.xr && camera.xr.session && camera.xr.views.length) { + var views = camera.xr.views; + + for (var v = 0; v < views.length; v++) { + var view = views[v]; + + device.setViewport(view.viewport.x, view.viewport.y, view.viewport.z, view.viewport.w); + + this.projId.setValue(view.projMat.data); + this.viewId.setValue(view.viewOffMat.data); + this.viewInvId.setValue(view.viewInvOffMat.data); + this.viewId3.setValue(view.viewMat3.data); + this.viewProjId.setValue(view.projViewOffMat.data); + this.viewPosId.setValue(view.positionOff.data); + + if (v === 0) { + i += this.drawInstance(device, drawCall, mesh, style, true); + } else { + i += this.drawInstance2(device, drawCall, mesh, style, true); + } + + this._forwardDrawCalls++; + } } else { i += this.drawInstance(device, drawCall, mesh, style, true); this._forwardDrawCalls++; @@ -2280,7 +2336,6 @@ Object.assign(pc, function () { } for (pass = 0; pass < passes; pass++) { - if (type === pc.LIGHTTYPE_POINT) { shadowCamNode.setRotation(pointLightRotations[pass]); shadowCam.renderTarget = light._shadowCubeMap[pass]; diff --git a/src/xr/xr-manager.js b/src/xr/xr-manager.js new file mode 100644 index 00000000000..26763f9f340 --- /dev/null +++ b/src/xr/xr-manager.js @@ -0,0 +1,476 @@ +Object.assign(pc, function () { + var sessionTypes = { + /** + * @constant + * @type string + * @name pc.XRTYPE_INLINE + * @description Inline - always available type of session. It has limited features availability and is rendered into HTML element. + */ + XRTYPE_INLINE: 'inline', + + /** + * @constant + * @type string + * @name pc.XRTYPE_VR + * @description Immersive VR - session that provides exclusive access to VR device with best available tracking features. + */ + XRTYPE_VR: 'immersive-vr', + + /** + * @constant + * @type string + * @name pc.XRTYPE_AR + * @description Immersive AR - session that provides exclusive access to VR/AR device that is intended to be blended with real-world environment. + */ + XRTYPE_AR: 'immersive-ar' + }; + + + /** + * @class + * @name pc.XrManager + * @augments pc.EventHandler + * @classdesc Manage and update XR session and its states. + * @description Manage and update XR session and its states. + * @param {pc.Application} app - The main application. + * @property {boolean} supported Returns true if XR is supported. + * @property {boolean} active Returns true if XR session is running. + * @property {string|null} type Returns type of curently running XR session or null if no session is running. + */ + var XrManager = function (app) { + pc.EventHandler.call(this); + + var self = this; + + this.app = app; + + this._supported = !! navigator.xr; + + this._available = { }; + for (var key in sessionTypes) { + this._available[sessionTypes[key]] = false; + } + + this._type = null; + this._session = null; + this._baseLayer = null; + this._referenceSpace = null; + this._inputSources = []; + + this._camera = null; + this._pose = null; + this.views = []; + this.viewsPool = []; + this.position = new pc.Vec3(); + this.rotation = new pc.Quat(); + + this._depthNear = 0.1; + this._depthFar = 1000; + + this._width = 0; + this._height = 0; + + // TODO + // 1. HMD class with its params + // 2. Space class + // 3. Controllers class + + if (this._supported) { + navigator.xr.addEventListener('devicechange', function () { + self._deviceAvailabilityCheck(); + }); + this._deviceAvailabilityCheck(); + } + }; + XrManager.prototype = Object.create(pc.EventHandler.prototype); + XrManager.prototype.constructor = XrManager; + + /** + * @event + * @name pc.XrManager#available + * @description Fired when availability of specific XR type is changed. + * @param {string} type - The session type that has changed availability. + * @param {boolean} available - True if specified session type is now available. + * @example + * app.xr.on('available', function (type, available) { + * console.log('"' + type + '" XR session is now ' + (available ? 'available' : 'unavailable')); + * }); + */ + + /** + * @event + * @name pc.XrManager#available:[type] + * @description Fired when availability of specific XR type is changed. + * @param {boolean} available - True if specified session type is now available. + * @example + * app.xr.on('available:' + pc.XRTYPE_VR, function (available) { + * console.log('Immersive VR session is now ' + (available ? 'available' : 'unavailable')); + * }); + */ + + /** + * @event + * @name pc.XrManager#start + * @description Fired when XR session is started + * @example + * app.xr.on('start', function () { + * // XR session has started + * }); + */ + + /** + * @event + * @name pc.XrManager#end + * @description Fired when XR session is ended + * @example + * app.xr.on('end', function () { + * // XR session has ended + * }); + */ + + /** + * @function + * @name pc.XrManager#start + * @description Attempts to start XR session for provided {@link pc.CameraComponent} and optionally fires callback when session is created or failed to create. + * @param {pc.CameraComponent} camera - it will be used to render XR session and manipulated based on pose tracking + * @param {string} type - session type. Can be one of the following: + * + * * {@link pc.XRTYPE_INLINE}: Inline - always available type of session. It has limited features availability and is rendered into HTML element. + * * {@link pc.XRTYPE_VR}: Immersive VR - session that provides exclusive access to VR device with best available tracking features. + * * {@link pc.XRTYPE_AR}: Immersive AR - session that provides exclusive access to VR/AR device that is intended to be blended with real-world environment. + * + * @example + * button.on('click', function () { + * app.xr.start(camera, PC.XRTYPE_VR); + * }); + * @param {pc.callbacks.XrError} [callback] - Optional callback function called once session is started. The callback has one argument Error - it is null if successfully started XR session. + */ + XrManager.prototype.start = function (camera, type, callback) { + if (! this._available[type]) { + if (callback) callback(new Error('XR is not available')); + return; + } + + if (this._session) { + if (callback) callback(new Error('XR session is already started')); + return; + } + + var self = this; + + this._camera = camera; + this._camera.camera.xr = this; + this._type = type; + + this._setClipPlanes(camera.nearClip, camera.farClip); + + // TODO + // makeXRCompatible + // scenario to test: + // 1. app is running on integrated GPU + // 2. XR device is connected, to another GPU + // 3. probably immersive-vr will fail to be created + // 4. call makeXRCompatible, very likely will lead to context loss + + navigator.xr.requestSession(type).then(function (session) { + self._onSessionStart(session, callback); + }); + }; + + /** + * @function + * @name pc.XrManager#end + * @description Attempts to end XR session and optionally fires callback when session is ended or failed to end. + * @example + * app.keyboard.on('keydown', function (evt) { + * if (evt.key === pc.KEY_ESCAPE && app.xr.active) { + * app.xr.end(); + * } + * }); + * @param {pc.callbacks.XrError} [callback] - Optional callback function called once session is started. The callback has one argument Error - it is null if successfully started XR session. + */ + XrManager.prototype.end = function (callback) { + if (! this._session) { + if (callback) callback(new Error('XR Session is not initialized')); + return; + } + + if (callback) this.once('end', callback); + + this._session.end(); + }; + + /** + * @function + * @name pc.XrManager#isAvailable + * @description Check if specific type of session is available + * @param {string} type - session type. Can be one of the following: + * + * * {@link pc.XRTYPE_INLINE}: Inline - always available type of session. It has limited features availability and is rendered into HTML element. + * * {@link pc.XRTYPE_VR}: Immersive VR - session that provides exclusive access to VR device with best available tracking features. + * * {@link pc.XRTYPE_AR}: Immersive AR - session that provides exclusive access to VR/AR device that is intended to be blended with real-world environment. + * + * @example + * if (app.xr.isAvailable(pc.XRTYPE_VR)) { + * // VR is available + * } + * @returns {boolean} True if specified session type is available. + */ + XrManager.prototype.isAvailable = function (type) { + return this._available[type]; + }; + + XrManager.prototype._deviceAvailabilityCheck = function () { + for (var key in this._available) { + this._sessionSupportCheck(key); + } + }; + + XrManager.prototype._sessionSupportCheck = function (type) { + var self = this; + + navigator.xr.isSessionSupported(type).then(function (available) { + if (self._available[type] === available) + return; + + self._available[type] = available; + self.fire('available', type, available); + self.fire('available:' + type, available); + }); + }; + + XrManager.prototype._onSessionStart = function (session, callback) { + var self = this; + + this._session = session; + + var onVisibilityChange = function () { + self.fire('visibility:change', session.visibilityState); + }; + + var onInputSourcesChange = function (evt) { + var i; + + for (i = 0; i < evt.removed.length; i++) { + self._inputSourceRemove(evt.removed[i]); + } + for (i = 0; i < evt.added.length; i++) { + self._inputSourceAdd(evt.added[i]); + } + }; + + var onClipPlanesChange = function () { + self._setClipPlanes(self._camera.nearClip, self._camera.farClip); + }; + + // clean up once session is ended + var onEnd = function () { + self._session = null; + self._referenceSpace = null; + self._inputSources = []; + self._pose = null; + self.views = []; + self._width = 0; + self._height = 0; + self._type = null; + + if (self._camera) { + self._camera.off('set_nearClip', onClipPlanesChange); + self._camera.off('set_farClip', onClipPlanesChange); + + self._camera.camera.xr = null; + self._camera = null; + } + + session.removeEventListener('end', onEnd); + session.removeEventListener('visibilitychange', onVisibilityChange); + session.removeEventListener('inputsourceschange', onInputSourcesChange); + + // old requestAnimationFrame will never be triggered, + // so queue up new tick + self.app.tick(); + + self.fire('end'); + }; + + session.addEventListener('end', onEnd); + session.addEventListener('visibilitychange', onVisibilityChange); + session.addEventListener('inputsourceschange', onInputSourcesChange); + + this._camera.on('set_nearClip', onClipPlanesChange); + this._camera.on('set_farClip', onClipPlanesChange); + + this._baseLayer = new XRWebGLLayer(session, this.app.graphicsDevice.gl); + + session.updateRenderState({ + baseLayer: this._baseLayer, + depthNear: this._depthNear, + depthFar: this._depthFar + }); + + // request reference space + session.requestReferenceSpace('local').then(function (referenceSpace) { + self._referenceSpace = referenceSpace; + + // old requestAnimationFrame will never be triggered, + // so queue up new tick + self.app.tick(); + + if (callback) callback(null); + self.fire('start'); + }); + }; + + XrManager.prototype._inputSourceAdd = function (inputSource) { + this._inputSources.push(inputSource); + this.fire('inputSource:add', inputSource); + }; + + XrManager.prototype._inputSourceRemove = function (inputSource) { + var ind = this._inputSources.indexOf(inputSource); + if (ind === -1) return; + this._inputSources.splice(ind, 1); + this.fire('inputSource:remove', inputSource); + }; + + XrManager.prototype._setClipPlanes = function (near, far) { + near = Math.min(0.0001, Math.max(0.1, near)); + far = Math.max(1000, far); + + if (this._depthNear === near && this._depthFar === far) + return; + + this._depthNear = near; + this._depthFar = far; + + if (! this._session) + return; + + // if session is available, + // queue up render state update + this._session.updateRenderState({ + depthNear: this._depthNear, + depthFar: this._depthFar + }); + }; + + XrManager.prototype.calculateViews = function (frame) { + if (! this._session) return; + + var i, view, viewRaw, layer, viewport; + var lengthNew; + + // canvas resolution should be set on first frame availability or resolution changes + var width = frame.session.renderState.baseLayer.framebufferWidth; + var height = frame.session.renderState.baseLayer.framebufferHeight; + if (this._width !== width || this._height !== height) { + this._width = width; + this._height = height; + this.app.graphicsDevice.setResolution(width, height); + } + + this._pose = frame.getViewerPose(this._referenceSpace); + lengthNew = this._pose ? this._pose.views.length : 0; + + if (lengthNew > this.views.length) { + // add new views into list + for (i = 0; i <= (lengthNew - this.views.length); i++) { + view = this.viewsPool.pop(); + if (! view) { + view = { + viewport: new pc.Vec4(), + projMat: new pc.Mat4(), + viewMat: new pc.Mat4(), + viewOffMat: new pc.Mat4(), + viewInvMat: new pc.Mat4(), + viewInvOffMat: new pc.Mat4(), + projViewOffMat: new pc.Mat4(), + viewMat3: new pc.Mat3(), + position: new pc.Vec3(), + positionOff: new pc.Vec3(), + rotation: new pc.Quat() + }; + } + + this.views.push(view); + } + } else if (lengthNew <= this.views.length) { + // remove views from list into pool + for (i = 0; i < (this.views.length - lengthNew); i++) { + this.viewsPool.push(this.views.pop()); + } + } + + // reset position + var posePosition = this._pose.transform.position; + var poseOrientation = this._pose.transform.orientation; + this.position.set(posePosition.x, posePosition.y, posePosition.z); + this.rotation.set(poseOrientation.x, poseOrientation.y, poseOrientation.z, poseOrientation.w); + + if (this._pose) { + layer = frame.session.renderState.baseLayer; + + for (i = 0; i < this._pose.views.length; i++) { + // for each view, calculate matrices + viewRaw = this._pose.views[i]; + view = this.views[i]; + viewport = layer.getViewport(viewRaw); + + view.viewport.x = viewport.x; + view.viewport.y = viewport.y; + view.viewport.z = viewport.width; + view.viewport.w = viewport.height; + + view.projMat.set(viewRaw.projectionMatrix); + view.viewMat.set(viewRaw.transform.inverse.matrix); + view.viewInvMat.set(viewRaw.transform.matrix); + } + } + + // position and rotate camera based on calculated vectors + this._camera.camera._node.setLocalPosition(this.position); + this._camera.camera._node.setLocalRotation(this.rotation); + }; + + Object.defineProperty(XrManager.prototype, 'supported', { + get: function () { + return this._supported; + } + }); + + Object.defineProperty(XrManager.prototype, 'active', { + get: function () { + return !! this._session; + } + }); + + Object.defineProperty(XrManager.prototype, 'type', { + get: function () { + return this._type; + } + }); + + Object.defineProperty(XrManager.prototype, 'session', { + get: function () { + return this._session; + } + }); + + Object.defineProperty(XrManager.prototype, 'visibilityState', { + get: function () { + if (! this._session) + return null; + + return this._session.visibilityState; + } + }); + + + var obj = { + XrManager: XrManager + }; + Object.assign(obj, sessionTypes); + + + return obj; +}());