Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

session.ts 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659
  1. /*
  2. * MARTINS.js
  3. * GPU-accelerated Augmented Reality for the web
  4. * Copyright (C) 2022-2024 Alexandre Martins <alemartf(at)gmail.com>
  5. *
  6. * This program is free software: you can redistribute it and/or modify
  7. * it under the terms of the GNU Lesser General Public License as published
  8. * by the Free Software Foundation, either version 3 of the License, or
  9. * (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. * GNU Lesser General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Lesser General Public License
  17. * along with this program. If not, see <https://www.gnu.org/licenses/>.
  18. *
  19. * session.ts
  20. * WebAR Session
  21. */
  22. import Speedy from 'speedy-vision';
  23. import { SpeedyMedia } from 'speedy-vision/types/core/speedy-media';
  24. import { SpeedyPromise } from 'speedy-vision/types/core/speedy-promise';
  25. import { Nullable, Utils } from '../utils/utils';
  26. import { AREvent, AREventTarget } from '../utils/ar-events';
  27. import { IllegalArgumentError, IllegalOperationError, NotSupportedError } from '../utils/errors';
  28. import { Viewport, ImmersiveViewport, InlineViewport } from './viewport';
  29. import { Settings } from './settings';
  30. import { Stats } from './stats';
  31. import { StatsPanel } from './stats-panel';
  32. import { Frame } from './frame';
  33. import { Tracker } from '../trackers/tracker';
  34. import { Time } from './time';
  35. import { Gizmos } from './gizmos';
  36. import { Source } from '../sources/source';
  37. import { asap } from '../utils/asap';
  38. /** Session mode */
  39. export type SessionMode = 'immersive' | 'inline';
  40. /** Session options */
  41. export interface SessionOptions
  42. {
  43. /** session mode */
  44. mode?: SessionMode;
  45. /** trackers */
  46. trackers: Tracker[];
  47. /** sources of data */
  48. sources: Source[];
  49. /** viewport */
  50. viewport: Nullable<Viewport>;
  51. /** show stats? */
  52. stats?: boolean;
  53. /** Render gizmos? */
  54. gizmos?: boolean;
  55. }
  56. /** requestAnimationFrame callback */
  57. type SessionRequestAnimationFrameCallback = (time: DOMHighResTimeStamp, frame: Frame) => void;
  58. /** requestAnimationFrame callback handle */
  59. type SessionRequestAnimationFrameHandle = symbol;
  60. /** All possible event types emitted by a Session */
  61. type SessionEventType = 'end';
  62. /** An event emitted by a Session */
  63. class SessionEvent extends AREvent<SessionEventType> { }
  64. /** Default options when starting a session */
  65. const DEFAULT_OPTIONS: Readonly<Required<SessionOptions>> = {
  66. mode: 'immersive',
  67. trackers: [],
  68. sources: [],
  69. viewport: null,
  70. stats: false,
  71. gizmos: false,
  72. };
  73. /**
  74. * A Session represents an intent to display AR content
  75. * and encapsulates the main loop (update-render cycle)
  76. */
  77. export class Session extends AREventTarget<SessionEventType>
  78. {
  79. /** Number of active sessions */
  80. private static _count = 0;
  81. /** Session mode */
  82. private readonly _mode: SessionMode;
  83. /** Attached trackers */
  84. private _trackers: Tracker[];
  85. /** Sources of data */
  86. private readonly _sources: Source[];
  87. /** Rendering viewport */
  88. private readonly _viewport: Viewport;
  89. /** Time Manager */
  90. private _time: Time;
  91. /** Is the session currently active? */
  92. private _active: boolean;
  93. /** Whether or not the frame is ready to be rendered */
  94. private _frameReady: boolean;
  95. /** Request animation frame callback queue */
  96. private _rafQueue: Array<[SessionRequestAnimationFrameHandle, SessionRequestAnimationFrameCallback]>;
  97. /** Update stats (GPU cycles/s) */
  98. private _updateStats: Stats;
  99. /** Render stats (FPS) */
  100. private _renderStats: Stats;
  101. /** Stats panel */
  102. private _statsPanel: StatsPanel;
  103. /** Gizmos */
  104. private _gizmos: Gizmos;
  105. /**
  106. * Constructor
  107. * @param sources previously initialized sources of data
  108. * @param mode session mode
  109. * @param viewport viewport
  110. * @param stats render stats panel?
  111. * @param gizmos render gizmos?
  112. */
  113. private constructor(sources: Source[], mode: SessionMode, viewport: Viewport, stats: boolean, gizmos: boolean)
  114. {
  115. super();
  116. this._mode = mode;
  117. this._trackers = [];
  118. this._sources = sources;
  119. this._updateStats = new Stats();
  120. this._renderStats = new Stats();
  121. this._active = true;
  122. this._frameReady = true; // no trackers at the moment
  123. this._rafQueue = [];
  124. this._time = new Time();
  125. this._gizmos = new Gizmos();
  126. this._gizmos.visible = gizmos;
  127. // get media
  128. const media = this.media;
  129. // setup the viewport
  130. if(mode == 'immersive')
  131. this._viewport = new ImmersiveViewport(viewport, () => media.size);
  132. else if(mode == 'inline')
  133. this._viewport = new InlineViewport(viewport, () => media.size);
  134. else
  135. throw new IllegalArgumentError(`Invalid session mode "${mode}"`);
  136. this._viewport._init();
  137. // setup the main loop
  138. this._setupUpdateLoop();
  139. this._setupRenderLoop();
  140. // setup the stats panel
  141. this._statsPanel = new StatsPanel(this._viewport.hud.container);
  142. this._statsPanel.visible = stats;
  143. // done!
  144. Session._count++;
  145. Utils.log(`The ${mode} session is now active!`);
  146. }
  147. /**
  148. * Checks if the engine can be run in the browser the client is using
  149. * @returns true if the engine is compatible with the browser
  150. */
  151. static isSupported(): boolean
  152. {
  153. // If Safari or iOS, require version 15.2 or later
  154. if(/(Mac|iOS|iPhone|iPad|iPod)/i.test(Utils.platformString())) {
  155. /*
  156. iOS compatibility
  157. -----------------
  158. The engine is known to work on iPhone 8 or later, with iOS 15.2 or
  159. later. Tested on many devices, including iPads, on the cloud.
  160. The engine crashes on an iPhone 13 Pro Max with iOS 15.1 and on an
  161. iPhone 12 Pro with iOS 15.0.2. A (valid) shader from speedy-vision
  162. version 0.9.1 (bf-knn) fails to compile: "WebGL error. Program has
  163. not been successfully linked".
  164. The engine freezes on an older iPhone 6S (2015) with iOS 15.8.2.
  165. The exact cause is unknown, but it happens when training an image
  166. tracker, at ImageTrackerTrainingState._gpuUpdate() (a WebGL error?
  167. a hardware limitation?)
  168. Successfully tested down to iPhone 8 so far.
  169. Successfully tested down to iOS 15.2.
  170. >> WebGL2 support was introduced in Safari 15 <<
  171. Note: the webp image format used in the demos is supported on
  172. Safari for iOS 14+. Desktop Safari 14-15.6 supports webp, but
  173. requires macOS 11 Big Sur or later. https://caniuse.com/webp
  174. */
  175. const ios = /(iPhone|iPad|iPod).* (CPU[\s\w]* OS|CPU iPhone|iOS) ([\d\._]+)/.exec(navigator.userAgent); // Chrome, Firefox, Edge, Safari on iOS
  176. const safari = /(AppleWebKit)\/.* (Version)\/([\d\.]+)/.exec(navigator.userAgent); // Desktop and Mobile Safari, Epiphany on Linux
  177. const matches = safari || ios; // match safari first (min version)
  178. if(matches !== null) {
  179. const version = matches[3] || '0.0';
  180. const [x, y] = version.split(/[\._]/).map(v => parseInt(v));
  181. if((x < 15) || (x == 15 && y < 2)) {
  182. Utils.error(`${matches === safari ? 'Safari' : 'iOS'} version ${version} is not supported! User agent: ${navigator.userAgent}`);
  183. return false;
  184. }
  185. }
  186. else
  187. Utils.warning(`Unrecognized user agent: ${navigator.userAgent}`);
  188. }
  189. // Check if WebGL2 and WebAssembly are supported
  190. return Speedy.isSupported();
  191. }
  192. /**
  193. * Instantiate a session
  194. * @param options options
  195. * @returns a promise that resolves to a new session
  196. */
  197. static instantiate(options: SessionOptions = DEFAULT_OPTIONS): SpeedyPromise<Session>
  198. {
  199. const {
  200. mode = DEFAULT_OPTIONS.mode,
  201. sources = DEFAULT_OPTIONS.sources,
  202. trackers = DEFAULT_OPTIONS.trackers,
  203. viewport = DEFAULT_OPTIONS.viewport,
  204. stats = DEFAULT_OPTIONS.stats,
  205. gizmos = DEFAULT_OPTIONS.gizmos,
  206. } = options;
  207. Utils.log(`Starting a new ${mode} session...`);
  208. return Speedy.Promise.resolve().then(() => {
  209. // is the engine supported?
  210. if(!Session.isSupported())
  211. throw new NotSupportedError('You need a browser/device compatible with WebGL2 and WebAssembly in order to experience Augmented Reality with the MARTINS.js engine');
  212. // block multiple immersive sessions
  213. if(mode !== 'inline' && Session.count > 0)
  214. throw new IllegalOperationError(`Can't start more than one immersive session`);
  215. // initialize matrix routines
  216. return Speedy.Matrix.ready();
  217. }).then(() => {
  218. // validate sources of data
  219. const videoSources = sources.filter(source => source._type == 'video');
  220. if(videoSources.length != 1)
  221. throw new IllegalArgumentError(`One video source of data must be provided`);
  222. for(let i = sources.length - 1; i >= 0; i--) {
  223. if(sources.indexOf(sources[i]) < i)
  224. throw new IllegalArgumentError(`Found repeated sources of data`);
  225. }
  226. // initialize sources of data
  227. return Speedy.Promise.all(
  228. sources.map(source => source._init())
  229. );
  230. }).then(() => {
  231. // get the viewport
  232. if(!viewport)
  233. throw new IllegalArgumentError(`Can't create a session without a viewport`);
  234. // instantiate session
  235. return new Session(sources, mode, viewport, stats, gizmos);
  236. }).then(session => {
  237. // validate trackers
  238. if(trackers.length == 0)
  239. Utils.warning(`No trackers have been attached to the session!`);
  240. for(let i = trackers.length - 1; i >= 0; i--) {
  241. if(trackers.indexOf(trackers[i]) < i)
  242. throw new IllegalArgumentError(`Found repeated trackers`);
  243. }
  244. // attach trackers and return the session
  245. return Speedy.Promise.all(
  246. trackers.map(tracker => session._attachTracker(tracker))
  247. ).then(() => session);
  248. }).catch(err => {
  249. // log errors, if any
  250. Utils.error(`Can't start session: ${err.message}`);
  251. throw err;
  252. });
  253. }
  254. /**
  255. * Number of active sessions
  256. */
  257. static get count(): number
  258. {
  259. return this._count;
  260. }
  261. /**
  262. * End the session
  263. * @returns promise that resolves after the session is shut down
  264. */
  265. end(): SpeedyPromise<void>
  266. {
  267. // is the session inactive?
  268. if(!this._active)
  269. return Speedy.Promise.resolve();
  270. // deactivate the session
  271. Utils.log('Shutting down the session...');
  272. this._active = false; // set before wait()
  273. // wait a few ms, so that the GPU is no longer sending any data
  274. const wait = (ms: number) => new Speedy.Promise<void>(resolve => {
  275. setTimeout(resolve, ms);
  276. });
  277. // release resources
  278. return wait(100).then(() => Speedy.Promise.all(
  279. // release trackers
  280. this._trackers.map(tracker => tracker._release())
  281. )).then(() => Speedy.Promise.all(
  282. // release input sources
  283. this._sources.map(source => source._release())
  284. )).then(() => {
  285. this._sources.length = 0;
  286. this._trackers.length = 0;
  287. // release internal components
  288. this._updateStats.reset();
  289. this._renderStats.reset();
  290. this._statsPanel.release();
  291. this._viewport._release();
  292. // end the session
  293. Session._count--;
  294. // dispatch event
  295. const event = new SessionEvent('end');
  296. this.dispatchEvent(event);
  297. // done!
  298. Utils.log('Session ended.');
  299. });
  300. }
  301. /**
  302. * Analogous to window.requestAnimationFrame()
  303. * @param callback
  304. * @returns a handle
  305. */
  306. requestAnimationFrame(callback: SessionRequestAnimationFrameCallback): SessionRequestAnimationFrameHandle
  307. {
  308. const handle: SessionRequestAnimationFrameHandle = Symbol('raf-handle');
  309. if(this._active)
  310. this._rafQueue.push([ handle, callback ]);
  311. else
  312. throw new IllegalOperationError(`Can't requestAnimationFrame(): session ended.`);
  313. return handle;
  314. }
  315. /**
  316. * Analogous to window.cancelAnimationFrame()
  317. * @param handle a handle returned by this.requestAnimationFrame()
  318. */
  319. cancelAnimationFrame(handle: SessionRequestAnimationFrameHandle): void
  320. {
  321. for(let i = this._rafQueue.length - 1; i >= 0; i--) {
  322. if(this._rafQueue[i][0] === handle) {
  323. this._rafQueue.splice(i, 1);
  324. break;
  325. }
  326. }
  327. }
  328. /**
  329. * The underlying media (generally a camera stream)
  330. * @internal
  331. */
  332. get media(): SpeedyMedia
  333. {
  334. for(let i = this._sources.length - 1; i >= 0; i--) {
  335. if(this._sources[i]._type == 'video')
  336. return this._sources[i]._data as SpeedyMedia;
  337. }
  338. // this shouldn't happen
  339. throw new IllegalOperationError(`Invalid input source`);
  340. }
  341. /**
  342. * Session mode
  343. */
  344. get mode(): SessionMode
  345. {
  346. return this._mode;
  347. }
  348. /**
  349. * Rendering viewport
  350. */
  351. get viewport(): Viewport
  352. {
  353. return this._viewport;
  354. }
  355. /**
  356. * Time utilities
  357. */
  358. get time(): Time
  359. {
  360. return this._time;
  361. }
  362. /**
  363. * Visual cues for testing & debugging
  364. */
  365. get gizmos(): Gizmos
  366. {
  367. return this._gizmos;
  368. }
  369. /**
  370. * Attach a tracker to the session
  371. * @param tracker
  372. */
  373. private _attachTracker(tracker: Tracker): SpeedyPromise<void>
  374. {
  375. if(this._trackers.indexOf(tracker) >= 0)
  376. throw new IllegalArgumentError(`Duplicate tracker attached to the session`);
  377. else if(!this._active)
  378. throw new IllegalOperationError(`Inactive session`);
  379. this._trackers.push(tracker);
  380. return tracker._init(this);
  381. }
  382. /**
  383. * Render the user media to the background canvas
  384. */
  385. private _renderUserMedia(): void
  386. {
  387. const canvas = this._viewport._background;
  388. const ctx = canvas.getContext('2d', { alpha: false });
  389. if(ctx && this.media.type != 'data') {
  390. ctx.imageSmoothingEnabled = false;
  391. // draw user media
  392. const image = this.media.source as CanvasImageSource;
  393. ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
  394. // render output image(s)
  395. for(let i = 0; i < this._trackers.length; i++) {
  396. const media = this._trackers[i]._output.image;
  397. if(media !== undefined) {
  398. const image = media.source as CanvasImageSource;
  399. ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
  400. //ctx.drawImage(image, canvas.width - media.width, canvas.height - media.height, media.width, media.height);
  401. }
  402. }
  403. // render gizmos
  404. this._gizmos._render(this._viewport, this._trackers);
  405. }
  406. }
  407. /**
  408. * Setup the update loop
  409. */
  410. private _setupUpdateLoop(): void
  411. {
  412. const scheduleNextFrame = () => {
  413. if(this._active) {
  414. if(Settings.powerPreference == 'high-performance')
  415. asap(repeat);
  416. else
  417. window.requestAnimationFrame(repeat);
  418. }
  419. };
  420. const update = () => {
  421. this._update().then(scheduleNextFrame).turbocharge();
  422. };
  423. function repeat() {
  424. if(Settings.powerPreference == 'low-power') // 30 fps
  425. window.requestAnimationFrame(update);
  426. else
  427. update();
  428. }
  429. window.requestAnimationFrame(update);
  430. }
  431. /**
  432. * The core of the update loop
  433. */
  434. private _update(): SpeedyPromise<void>
  435. {
  436. // active session?
  437. if(this._active) {
  438. return Speedy.Promise.all(
  439. // update trackers
  440. this._trackers.map(tracker => tracker._update().turbocharge())
  441. ).then(() => {
  442. // update internals
  443. this._updateStats.update();
  444. this._frameReady = true;
  445. }).catch((err: any) => {
  446. // log error
  447. Utils.error('Tracking error: ' + err.toString(), err);
  448. // throw WebGL errors
  449. if(err.name == 'GLError' || (typeof err.cause == 'object' && err.cause.name == 'GLError')) {
  450. alert(err.message); // fatal error?
  451. throw err;
  452. }
  453. });
  454. }
  455. else {
  456. // inactive session
  457. this._updateStats.reset();
  458. return Speedy.Promise.resolve();
  459. }
  460. }
  461. /**
  462. * Setup the render loop
  463. */
  464. private _setupRenderLoop(): void
  465. {
  466. let skip = false, toggle = false;
  467. const render = (timestamp: DOMHighResTimeStamp) => {
  468. const enableFrameSkipping = (Settings.powerPreference == 'low-power');
  469. const highPerformance = (Settings.powerPreference == 'high-performance');
  470. // advance time
  471. this._time._update(timestamp);
  472. // skip frames
  473. if(!enableFrameSkipping || !(skip = !skip))
  474. this._render(timestamp, false);
  475. //this._render(timestamp, !enableFrameSkipping && !highPerformance && (toggle = !toggle));
  476. // repeat
  477. if(this._active)
  478. window.requestAnimationFrame(render);
  479. };
  480. window.requestAnimationFrame(render);
  481. }
  482. /**
  483. * Render a frame (RAF callback)
  484. * @param time current time, in ms
  485. * @param skipUserMedia skip copying the pixels of the user media to the background canvas in order to reduce the processing load (video stream is probably at 30fps?)
  486. */
  487. private _render(time: DOMHighResTimeStamp, skipUserMedia: boolean): void
  488. {
  489. // is the session active?
  490. if(this._active) {
  491. // are we ready to render a frame?
  492. if(this._frameReady) {
  493. // create a frame
  494. const results = this._trackers.map(tracker =>
  495. tracker._output.exports || ({
  496. tracker: tracker,
  497. trackables: [],
  498. })
  499. );
  500. const frame = new Frame(this, results);
  501. // clone & clear the RAF queue
  502. const rafQueue = this._rafQueue.slice(0);
  503. this._rafQueue.length = 0;
  504. // render user media
  505. if(!skipUserMedia)
  506. this._renderUserMedia();
  507. // render frame
  508. for(let i = 0; i < rafQueue.length; i++)
  509. rafQueue[i][1].call(undefined, time, frame);
  510. // update internals
  511. this._renderStats.update();
  512. this._statsPanel.update(time, this._trackers, this._sources, this._updateStats.cyclesPerSecond, this._renderStats.cyclesPerSecond);
  513. this._frameReady = false;
  514. }
  515. else {
  516. // skip frame
  517. ;
  518. // we'll update the renderStats even if we skip the frame,
  519. // otherwise this becomes updateStats! (approximately)
  520. // This is a window.requestAnimationFrame() call, so the
  521. // browser is rendering content even if we're not.
  522. this._renderStats.update();
  523. }
  524. }
  525. else {
  526. // inactive session
  527. this._renderStats.reset();
  528. }
  529. }
  530. }