Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

session.ts 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621
  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. const ios = /(iPhone|iPad|iPod).* (CPU[\s\w]* OS|CPU iPhone|iOS) ([\d\._]+)/.exec(navigator.userAgent); // Chrome, Firefox, Edge, Safari on iOS
  156. const safari = /(AppleWebKit)\/.* (Version)\/([\d\.]+)/.exec(navigator.userAgent); // Desktop and Mobile Safari, Epiphany on Linux
  157. const matches = safari || ios; // match safari first (min version)
  158. if(matches !== null) {
  159. const version = matches[3] || '0.0';
  160. const [x, y] = version.split(/[\._]/).map(v => parseInt(v));
  161. if((x < 15) || (x == 15 && y < 2)) {
  162. Utils.error(`${matches === safari ? 'Safari' : 'iOS'} version ${version} is not supported! User agent: ${navigator.userAgent}`);
  163. return false;
  164. }
  165. }
  166. else
  167. Utils.warning(`Unrecognized user agent: ${navigator.userAgent}`);
  168. }
  169. // Check if WebGL2 and WebAssembly are supported
  170. return Speedy.isSupported();
  171. }
  172. /**
  173. * Instantiate a session
  174. * @param options options
  175. * @returns a promise that resolves to a new session
  176. */
  177. static instantiate(options: SessionOptions = DEFAULT_OPTIONS): SpeedyPromise<Session>
  178. {
  179. const {
  180. mode = DEFAULT_OPTIONS.mode,
  181. sources = DEFAULT_OPTIONS.sources,
  182. trackers = DEFAULT_OPTIONS.trackers,
  183. viewport = DEFAULT_OPTIONS.viewport,
  184. stats = DEFAULT_OPTIONS.stats,
  185. gizmos = DEFAULT_OPTIONS.gizmos,
  186. } = options;
  187. Utils.log(`Starting a new ${mode} session...`);
  188. return Speedy.Promise.resolve().then(() => {
  189. // is the engine supported?
  190. if(!Session.isSupported())
  191. throw new NotSupportedError('You need a browser/device compatible with WebGL2 and WebAssembly in order to experience Augmented Reality with the MARTINS.js engine');
  192. // block multiple immersive sessions
  193. if(mode !== 'inline' && Session.count > 0)
  194. throw new IllegalOperationError(`Can't start more than one immersive session`);
  195. // initialize matrix routines
  196. return Speedy.Matrix.ready();
  197. }).then(() => {
  198. // validate sources of data
  199. const videoSources = sources.filter(source => source._type == 'video');
  200. if(videoSources.length != 1)
  201. throw new IllegalArgumentError(`One video source of data must be provided`);
  202. for(let i = sources.length - 1; i >= 0; i--) {
  203. if(sources.indexOf(sources[i]) < i)
  204. throw new IllegalArgumentError(`Found repeated sources of data`);
  205. }
  206. // initialize sources of data
  207. return Speedy.Promise.all(
  208. sources.map(source => source._init())
  209. );
  210. }).then(() => {
  211. // get the viewport
  212. if(!viewport)
  213. throw new IllegalArgumentError(`Can't create a session without a viewport`);
  214. // instantiate session
  215. return new Session(sources, mode, viewport, stats, gizmos);
  216. }).then(session => {
  217. // validate trackers
  218. if(trackers.length == 0)
  219. Utils.warning(`No trackers have been attached to the session!`);
  220. for(let i = trackers.length - 1; i >= 0; i--) {
  221. if(trackers.indexOf(trackers[i]) < i)
  222. throw new IllegalArgumentError(`Found repeated trackers`);
  223. }
  224. // attach trackers and return the session
  225. return Speedy.Promise.all(
  226. trackers.map(tracker => session._attachTracker(tracker))
  227. ).then(() => session);
  228. }).catch(err => {
  229. // log errors, if any
  230. Utils.error(`Can't start session: ${err.message}`);
  231. throw err;
  232. });
  233. }
  234. /**
  235. * Number of active sessions
  236. */
  237. static get count(): number
  238. {
  239. return this._count;
  240. }
  241. /**
  242. * End the session
  243. * @returns promise that resolves after the session is shut down
  244. */
  245. end(): SpeedyPromise<void>
  246. {
  247. // is the session inactive?
  248. if(!this._active)
  249. return Speedy.Promise.resolve();
  250. // deactivate the session
  251. Utils.log('Shutting down the session...');
  252. this._active = false; // set before wait()
  253. // wait a few ms, so that the GPU is no longer sending any data
  254. const wait = (ms: number) => new Speedy.Promise<void>(resolve => {
  255. setTimeout(resolve, ms);
  256. });
  257. // release resources
  258. return wait(100).then(() => Speedy.Promise.all(
  259. // release trackers
  260. this._trackers.map(tracker => tracker._release())
  261. )).then(() => Speedy.Promise.all(
  262. // release input sources
  263. this._sources.map(source => source._release())
  264. )).then(() => {
  265. this._sources.length = 0;
  266. this._trackers.length = 0;
  267. // release internal components
  268. this._updateStats.reset();
  269. this._renderStats.reset();
  270. this._statsPanel.release();
  271. this._viewport._release();
  272. // end the session
  273. Session._count--;
  274. // dispatch event
  275. const event = new SessionEvent('end');
  276. this.dispatchEvent(event);
  277. // done!
  278. Utils.log('Session ended.');
  279. });
  280. }
  281. /**
  282. * Analogous to window.requestAnimationFrame()
  283. * @param callback
  284. * @returns a handle
  285. */
  286. requestAnimationFrame(callback: SessionRequestAnimationFrameCallback): SessionRequestAnimationFrameHandle
  287. {
  288. const handle: SessionRequestAnimationFrameHandle = Symbol('raf-handle');
  289. if(this._active)
  290. this._rafQueue.push([ handle, callback ]);
  291. else
  292. throw new IllegalOperationError(`Can't requestAnimationFrame(): session ended.`);
  293. return handle;
  294. }
  295. /**
  296. * Analogous to window.cancelAnimationFrame()
  297. * @param handle a handle returned by this.requestAnimationFrame()
  298. */
  299. cancelAnimationFrame(handle: SessionRequestAnimationFrameHandle): void
  300. {
  301. for(let i = this._rafQueue.length - 1; i >= 0; i--) {
  302. if(this._rafQueue[i][0] === handle) {
  303. this._rafQueue.splice(i, 1);
  304. break;
  305. }
  306. }
  307. }
  308. /**
  309. * The underlying media (generally a camera stream)
  310. * @internal
  311. */
  312. get media(): SpeedyMedia
  313. {
  314. for(let i = this._sources.length - 1; i >= 0; i--) {
  315. if(this._sources[i]._type == 'video')
  316. return this._sources[i]._data as SpeedyMedia;
  317. }
  318. // this shouldn't happen
  319. throw new IllegalOperationError(`Invalid input source`);
  320. }
  321. /**
  322. * Session mode
  323. */
  324. get mode(): SessionMode
  325. {
  326. return this._mode;
  327. }
  328. /**
  329. * Rendering viewport
  330. */
  331. get viewport(): Viewport
  332. {
  333. return this._viewport;
  334. }
  335. /**
  336. * Time utilities
  337. */
  338. get time(): Time
  339. {
  340. return this._time;
  341. }
  342. /**
  343. * Visual cues for testing & debugging
  344. */
  345. get gizmos(): Gizmos
  346. {
  347. return this._gizmos;
  348. }
  349. /**
  350. * Attach a tracker to the session
  351. * @param tracker
  352. */
  353. private _attachTracker(tracker: Tracker): SpeedyPromise<void>
  354. {
  355. if(this._trackers.indexOf(tracker) >= 0)
  356. throw new IllegalArgumentError(`Duplicate tracker attached to the session`);
  357. else if(!this._active)
  358. throw new IllegalOperationError(`Inactive session`);
  359. this._trackers.push(tracker);
  360. return tracker._init(this);
  361. }
  362. /**
  363. * Render the user media to the background canvas
  364. */
  365. private _renderUserMedia(): void
  366. {
  367. const canvas = this._viewport._background;
  368. const ctx = canvas.getContext('2d', { alpha: false });
  369. if(ctx) {
  370. ctx.imageSmoothingEnabled = false;
  371. // draw user media
  372. const image = this.media.source;
  373. ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
  374. // render output image(s)
  375. for(let i = 0; i < this._trackers.length; i++) {
  376. const image = this._trackers[i]._output.image;
  377. if(image !== undefined)
  378. ctx.drawImage(image.source, 0, 0, canvas.width, canvas.height);
  379. //ctx.drawImage(image.source, canvas.width - image.width, canvas.height - image.height, image.width, image.height);
  380. }
  381. // render gizmos
  382. this._gizmos._render(this._viewport, this._trackers);
  383. }
  384. }
  385. /**
  386. * Setup the update loop
  387. */
  388. private _setupUpdateLoop(): void
  389. {
  390. const scheduleNextFrame = () => {
  391. if(this._active) {
  392. if(Settings.powerPreference == 'high-performance')
  393. asap(repeat);
  394. else
  395. window.requestAnimationFrame(repeat);
  396. }
  397. };
  398. const update = () => {
  399. this._update().then(scheduleNextFrame).turbocharge();
  400. };
  401. function repeat() {
  402. if(Settings.powerPreference == 'low-power') // 30 fps
  403. window.requestAnimationFrame(update);
  404. else
  405. update();
  406. }
  407. window.requestAnimationFrame(update);
  408. }
  409. /**
  410. * The core of the update loop
  411. */
  412. private _update(): SpeedyPromise<void>
  413. {
  414. // active session?
  415. if(this._active) {
  416. return Speedy.Promise.all(
  417. // update trackers
  418. this._trackers.map(tracker => tracker._update().turbocharge())
  419. ).then(() => {
  420. // update internals
  421. this._updateStats.update();
  422. this._frameReady = true;
  423. }).catch(err => {
  424. // handle error
  425. Utils.warning('Tracking error: ' + err.toString());
  426. });
  427. }
  428. else {
  429. // inactive session
  430. this._updateStats.reset();
  431. return Speedy.Promise.resolve();
  432. }
  433. }
  434. /**
  435. * Setup the render loop
  436. */
  437. private _setupRenderLoop(): void
  438. {
  439. let skip = false, toggle = false;
  440. const render = (timestamp: DOMHighResTimeStamp) => {
  441. const enableFrameSkipping = (Settings.powerPreference == 'low-power');
  442. const highPerformance = (Settings.powerPreference == 'high-performance');
  443. // advance time
  444. this._time._update(timestamp);
  445. // skip frames
  446. if(!enableFrameSkipping || !(skip = !skip))
  447. this._render(timestamp, false);
  448. //this._render(timestamp, !enableFrameSkipping && !highPerformance && (toggle = !toggle));
  449. // repeat
  450. if(this._active)
  451. window.requestAnimationFrame(render);
  452. };
  453. window.requestAnimationFrame(render);
  454. }
  455. /**
  456. * Render a frame (RAF callback)
  457. * @param time current time, in ms
  458. * @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?)
  459. */
  460. private _render(time: DOMHighResTimeStamp, skipUserMedia: boolean): void
  461. {
  462. // is the session active?
  463. if(this._active) {
  464. // are we ready to render a frame?
  465. if(this._frameReady) {
  466. // create a frame
  467. const results = this._trackers.map(tracker =>
  468. tracker._output.exports || ({
  469. tracker: tracker,
  470. trackables: [],
  471. })
  472. );
  473. const frame = new Frame(this, results);
  474. // clone & clear the RAF queue
  475. const rafQueue = this._rafQueue.slice(0);
  476. this._rafQueue.length = 0;
  477. // render user media
  478. if(!skipUserMedia)
  479. this._renderUserMedia();
  480. // render frame
  481. for(let i = 0; i < rafQueue.length; i++)
  482. rafQueue[i][1].call(undefined, time, frame);
  483. // update internals
  484. this._renderStats.update();
  485. this._statsPanel.update(time, this._trackers, this._sources, this._updateStats.cyclesPerSecond, this._renderStats.cyclesPerSecond);
  486. this._frameReady = false;
  487. }
  488. else {
  489. // skip frame
  490. ;
  491. // we'll update the renderStats even if we skip the frame,
  492. // otherwise this becomes updateStats! (approximately)
  493. // This is a window.requestAnimationFrame() call, so the
  494. // browser is rendering content even if we're not.
  495. this._renderStats.update();
  496. }
  497. }
  498. else {
  499. // inactive session
  500. this._renderStats.reset();
  501. }
  502. }
  503. }