Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

pointer-tracker.ts 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609
  1. /*
  2. * encantar.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. * pointer-tracker.ts
  20. * Tracker of pointer-based input
  21. */
  22. import Speedy from 'speedy-vision';
  23. import { SpeedyPromise } from 'speedy-vision/types/core/speedy-promise';
  24. import { TrackerResult, TrackerOutput, Tracker } from '../tracker';
  25. import { TrackablePointer, TrackablePointerPhase } from './trackable-pointer';
  26. import { PointerSource } from '../../sources/pointer-source';
  27. import { Vector2 } from '../../geometry/vector2';
  28. import { Utils, Nullable } from '../../utils/utils';
  29. import { IllegalOperationError, IllegalArgumentError } from '../../utils/errors';
  30. import { Session } from '../../core/session';
  31. import { Viewport } from '../../core/viewport';
  32. /**
  33. * A result of a PointerTracker. It's meant to be consumed by the user/application
  34. */
  35. export interface PointerTrackerResult extends TrackerResult
  36. {
  37. /** the tracker that generated this result */
  38. readonly tracker: PointerTracker;
  39. /** the trackables */
  40. readonly trackables: TrackablePointer[];
  41. }
  42. /**
  43. * The output of a PointerTracker in a particular Frame of a Session
  44. */
  45. export interface PointerTrackerOutput extends TrackerOutput
  46. {
  47. /** tracker result to be consumed by the user */
  48. readonly exports: PointerTrackerResult;
  49. }
  50. /**
  51. * The space in which pointers are located.
  52. *
  53. * - In "normalized" space, pointers are located in [-1,1]x[-1,1]. The origin
  54. * of the space is at the center of the viewport. The x-axis points to the
  55. * right and the y-axis points up. This is the default space.
  56. *
  57. * - The "adjusted" space is similar to the normalized space, except that it is
  58. * scaled so that it matches the aspect ratio of the viewport.
  59. *
  60. * Pointers in adjusted space are contained in normalized space, but unless
  61. * the viewport is a square, one of their coordinates, x or y, will no longer
  62. * range from -1 to +1. It will range from -s to +s, where s = min(a, 1/a).
  63. * In this expression, a is the aspect ratio of the viewport and s is less
  64. * than or equal to 1.
  65. *
  66. * Selecting the adjusted space is useful for making sure that pointer speeds
  67. * are equivalent in both axes and for preserving movement curves. Speeds are
  68. * not equivalent and movement curves are not preserved by default because
  69. * the normalized space is a square, whereas the viewport is a rectangle.
  70. */
  71. export type PointerSpace = 'normalized' | 'adjusted'; // | 'viewport';
  72. /**
  73. * Options for instantiating a PointerTracker
  74. */
  75. export interface PointerTrackerOptions
  76. {
  77. /** the space in which pointers will be located */
  78. space?: PointerSpace;
  79. }
  80. /** Convert event type to trackable pointer phase */
  81. const EVENTTYPE2PHASE: Record<string, TrackablePointerPhase> = {
  82. 'pointerdown': 'began',
  83. 'pointerup': 'ended',
  84. 'pointermove': 'moved',
  85. 'pointercancel': 'canceled',
  86. 'pointerleave': 'ended',
  87. 'pointerenter': 'began',
  88. };
  89. /** Default options for instantiating a PointerTracker */
  90. const DEFAULT_OPTIONS: Readonly<Required<PointerTrackerOptions>> = {
  91. space: 'normalized'
  92. };
  93. /**
  94. * A tracker of pointer-based input such as mouse, touch or pen
  95. */
  96. export class PointerTracker implements Tracker
  97. {
  98. /** the source of data */
  99. private _source: Nullable<PointerSource>;
  100. /** the viewport */
  101. private _viewport: Nullable<Viewport>;
  102. /** pointer space */
  103. private _space: PointerSpace;
  104. /** active pointers */
  105. private _activePointers: Map<number, TrackablePointer>;
  106. /** new pointers */
  107. private _newPointers: Map<number, TrackablePointer>;
  108. /** helper map for normalizing IDs */
  109. private _idMap: Map<number, number>;
  110. /** previous output */
  111. private _previousOutput: PointerTrackerOutput;
  112. /** time of the previous update */
  113. private _previousUpdateTime: DOMHighResTimeStamp;
  114. /** helper flag */
  115. private _wantToReset: boolean;
  116. /** auto-increment ID */
  117. private _nextId: number;
  118. /**
  119. * Constructor
  120. * @param options
  121. */
  122. constructor(options: PointerTrackerOptions)
  123. {
  124. const settings = this._buildSettings(options);
  125. this._source = null;
  126. this._viewport = null;
  127. this._space = settings.space;
  128. this._activePointers = new Map();
  129. this._newPointers = new Map();
  130. this._idMap = new Map();
  131. this._nextId = 1;
  132. this._previousOutput = this._generateOutput();
  133. this._previousUpdateTime = Number.POSITIVE_INFINITY;
  134. this._wantToReset = false;
  135. this._resetInTheNextUpdate = this._resetInTheNextUpdate.bind(this);
  136. }
  137. /**
  138. * Build a full and validated options object
  139. * @param options
  140. * @returns validated options with defaults
  141. */
  142. private _buildSettings(options: PointerTrackerOptions): Required<PointerTrackerOptions>
  143. {
  144. const settings: Required<PointerTrackerOptions> = Object.assign({}, DEFAULT_OPTIONS, options);
  145. if(settings.space != 'normalized' && settings.space != 'adjusted')
  146. throw new IllegalArgumentError(`Invalid pointer space: "${settings.space}"`);
  147. return settings;
  148. }
  149. /**
  150. * The type of the tracker
  151. */
  152. get type(): string
  153. {
  154. return 'pointer-tracker';
  155. }
  156. /**
  157. * Initialize the tracker
  158. * @param session
  159. * @returns a promise that is resolved as soon as the tracker is initialized
  160. * @internal
  161. */
  162. _init(session: Session): SpeedyPromise<void>
  163. {
  164. Utils.log('Initializing PointerTracker...');
  165. // set the viewport
  166. this._viewport = session.viewport;
  167. // find the pointer source
  168. for(const source of session.sources) {
  169. if(source._type == 'pointer-source') {
  170. this._source = source as PointerSource;
  171. break;
  172. }
  173. }
  174. if(this._source === null)
  175. return Speedy.Promise.reject(new IllegalOperationError('A PointerTracker expects a PointerSource'));
  176. // link the pointer source to the viewport
  177. this._source._setViewport(this._viewport);
  178. // reset trackables
  179. document.addEventListener('visibilitychange', this._resetInTheNextUpdate);
  180. // done!
  181. return Speedy.Promise.resolve();
  182. }
  183. /**
  184. * Release the tracker
  185. * @returns a promise that is resolved as soon as the tracker is released
  186. * @internal
  187. */
  188. _release(): SpeedyPromise<void>
  189. {
  190. this._source = null;
  191. this._viewport = null;
  192. this._activePointers.clear();
  193. this._newPointers.clear();
  194. this._idMap.clear();
  195. document.removeEventListener('visibilitychange', this._resetInTheNextUpdate);
  196. return Speedy.Promise.resolve();
  197. }
  198. /**
  199. * Update the tracker (update cycle)
  200. * @returns a promise that is resolved as soon as the tracker is updated
  201. * @internal
  202. */
  203. _update(): SpeedyPromise<void>
  204. {
  205. const canvas = this._viewport!.canvas;
  206. const rect = canvas.getBoundingClientRect(); // may be different in different frames!
  207. // find the time between this and the previous update of this tracker
  208. const deltaTime = this._updateTime();
  209. const inverseDeltaTime = (deltaTime > 1e-5) ? 1 / deltaTime : 60; // 1/dt = 1 / (1/60) with 60 fps
  210. // remove inactive trackables from the previous frame (update cycle)
  211. const inactiveTrackables = this._findInactiveTrackables();
  212. for(let i = inactiveTrackables.length - 1; i >= 0; i--)
  213. this._activePointers.delete(inactiveTrackables[i].id);
  214. // make all active trackables stationary
  215. this._updateAllTrackables({
  216. phase: 'stationary',
  217. velocity: Vector2.ZERO,
  218. deltaPosition: Vector2.ZERO
  219. });
  220. // want to reset?
  221. if(this._wantToReset) {
  222. this._reset();
  223. this._wantToReset = false;
  224. }
  225. // consume events
  226. let event: Nullable<PointerEvent>;
  227. while((event = this._source!._consume()) !== null) {
  228. // sanity check
  229. if(event.target !== canvas)
  230. return Speedy.Promise.reject(new IllegalOperationError('Invalid PointerEvent target ' + event.target));
  231. else if(!EVENTTYPE2PHASE.hasOwnProperty(event.type))
  232. return Speedy.Promise.reject(new IllegalOperationError('Invalid PointerEvent type ' + event.type));
  233. // determine the ID
  234. const id = this._normalizeId(event.pointerId, event.pointerType);
  235. // determine the previous states, if any, of the trackable
  236. const previous = this._activePointers.get(id); // state in the previous frame
  237. const current = this._newPointers.get(id); // previous state in the current frame
  238. // determine the phase
  239. const phase = EVENTTYPE2PHASE[event.type];
  240. // new trackables always begin with a pointerdown event,
  241. // or with a pointerenter event having buttons pressed
  242. // (example: a mousemove without a previous mousedown isn't tracked)
  243. if(!(event.type == 'pointerdown' || (event.type == 'pointerenter' && event.buttons > 0))) {
  244. if(!previous && !current)
  245. continue; // discard event
  246. }
  247. else if(previous) {
  248. // discard a 'began' after another 'began'
  249. continue;
  250. }
  251. else if(event.button != 0 && event.pointerType == 'mouse') {
  252. // require left mouse click
  253. continue;
  254. }
  255. // discard event if 'began' and 'ended' happened in the same frame
  256. // (difficult to reproduce, but it can be done ;)
  257. if(!previous) {
  258. if(phase == 'ended' || phase == 'canceled') {
  259. this._newPointers.delete(id);
  260. continue;
  261. }
  262. }
  263. // what if we receive 'began' after 'ended' in the same frame?
  264. else if(phase == 'began' && current) {
  265. if(current.phase == 'ended' || current.phase == 'canceled') {
  266. this._newPointers.delete(id);
  267. continue;
  268. }
  269. }
  270. // discard previously canceled pointers (e.g., with a visibilitychange event)
  271. if(previous?.phase == 'canceled')
  272. continue;
  273. // more special rules
  274. switch(event.type) {
  275. case 'pointermove':
  276. if(event.buttons == 0 || current?.phase == 'began')
  277. continue;
  278. break;
  279. case 'pointerenter':
  280. if(event.buttons == 0 || previous?.phase == 'began' || current?.phase == 'began')
  281. continue;
  282. break;
  283. case 'pointercancel': // purge everything
  284. this._reset();
  285. this._newPointers.clear();
  286. continue;
  287. }
  288. // determine the current position in normalized space
  289. const absX = event.pageX - (rect.left + window.scrollX);
  290. const absY = event.pageY - (rect.top + window.scrollY);
  291. const relX = 2 * absX / rect.width - 1; // convert to [-1,1]
  292. const relY = -(2 * absY / rect.height - 1); // flip Y axis
  293. const position = new Vector2(relX, relY);
  294. // scale the normalized space so that it matches the aspect ratio of the viewport
  295. if(this._space == 'adjusted') {
  296. const a = this._viewport!.aspectRatio;
  297. if(a >= 1) {
  298. // landscape
  299. position._set(relX, relY / a);
  300. }
  301. else {
  302. // portrait
  303. position._set(relX * a, relY);
  304. }
  305. }
  306. // determine the position delta
  307. const deltaPosition = !previous ? Vector2.ZERO :
  308. position._clone()._subtract(previous.position);
  309. // determine the initial position
  310. const initialPosition = previous ? previous.initialPosition :
  311. Object.freeze(position._clone()) as Vector2;
  312. // determine the velocity
  313. const velocity = deltaPosition._clone()._scale(inverseDeltaTime);
  314. // determine the elapsed time since the tracking began
  315. const duration = previous ? previous.duration + deltaTime : 0;
  316. // determine how much this pointer has moved since its tracking began
  317. const movementLength = previous ? previous.movementLength + deltaPosition.length() : 0;
  318. // determine whether or not this is the primary pointer for this type
  319. const isPrimary = event.isPrimary;
  320. // determine the type of the originating device
  321. const kind = event.pointerType;
  322. // we create new trackable instances on each frame;
  323. // these will be exported and consumed by the user
  324. this._newPointers.set(id, { id, phase, position, deltaPosition, initialPosition, velocity, duration, movementLength, isPrimary, kind });
  325. }
  326. // update trackables
  327. this._newPointers.forEach((trackable, id) => this._activePointers.set(id, trackable));
  328. this._newPointers.clear();
  329. this._advanceAllStationaryTrackables(deltaTime);
  330. // discard unused IDs
  331. if(this._activePointers.size == 0 && this._idMap.size > 0)
  332. this._idMap.clear();
  333. // generate output
  334. this._previousOutput = this._generateOutput();
  335. // test
  336. //console.log(JSON.stringify(this._prevOutput.exports.trackables, null, 4));
  337. // done!
  338. return Speedy.Promise.resolve();
  339. }
  340. /**
  341. * Output of the previous frame
  342. * @internal
  343. */
  344. get _output(): PointerTrackerOutput
  345. {
  346. return this._previousOutput;
  347. }
  348. /**
  349. * Stats info
  350. * @internal
  351. */
  352. get _stats(): string
  353. {
  354. const n = this._activePointers.size;
  355. const s = n != 1 ? 's' : '';
  356. return n + ' pointer' + s;
  357. }
  358. /**
  359. * The space in which pointers are located.
  360. * You may set it when instantiating the tracker.
  361. */
  362. get space(): PointerSpace
  363. {
  364. return this._space;
  365. }
  366. /**
  367. * Generate tracker output
  368. * @returns a new PointerTrackerOutput object
  369. */
  370. private _generateOutput(): PointerTrackerOutput
  371. {
  372. const trackables: TrackablePointer[] = [];
  373. this._activePointers.forEach(trackable => trackables.push(trackable));
  374. return {
  375. exports: {
  376. tracker: this,
  377. trackables: this._sortTrackables(trackables)
  378. }
  379. };
  380. }
  381. /**
  382. * Update all active pointers
  383. * @param fields
  384. */
  385. private _updateAllTrackables(fields: Partial<TrackablePointer>): void
  386. {
  387. this._activePointers.forEach((trackable, id) => {
  388. this._activePointers.set(id, Object.assign({}, trackable, fields));
  389. });
  390. }
  391. /**
  392. * Advance the elapsed time of all stationary pointers
  393. * @param deltaTime
  394. */
  395. private _advanceAllStationaryTrackables(deltaTime: number): void
  396. {
  397. this._activePointers.forEach((trackable, id) => {
  398. if(trackable.phase == 'stationary') {
  399. (trackable as any).duration += deltaTime;
  400. /*
  401. this._activePointers.set(id, Object.assign({}, trackable, {
  402. duration: trackable.duration + deltaTime
  403. }));
  404. */
  405. }
  406. });
  407. }
  408. /**
  409. * Normalize pointer IDs across browsers
  410. * @param pointerId browser-provided pointer ID
  411. * @param pointerType pointer type
  412. * @returns a normalized pointer ID
  413. */
  414. private _normalizeId(pointerId: number, pointerType: string): number
  415. {
  416. // XXX different hardware devices acting simultaneously may produce
  417. // events with the same pointerId - handling this seems overkill?
  418. if(pointerType == 'mouse')
  419. return 0;
  420. if(!this._idMap.has(pointerId))
  421. this._idMap.set(pointerId, this._nextId++);
  422. return this._idMap.get(pointerId)!;
  423. }
  424. /**
  425. * Cancel all active pointers and consume all events
  426. * @param deltaTime
  427. */
  428. private _reset(): void
  429. {
  430. // cancel all active pointers
  431. this._updateAllTrackables({
  432. phase: 'canceled',
  433. velocity: Vector2.ZERO,
  434. deltaPosition: Vector2.ZERO
  435. });
  436. // consume all events
  437. while(this._source!._consume() !== null);
  438. }
  439. /**
  440. * Reset in the next update of the tracker
  441. */
  442. private _resetInTheNextUpdate(): void
  443. {
  444. this._wantToReset = true;
  445. }
  446. /**
  447. * As a convenience, let's make sure that a primary pointer, if any exists,
  448. * is at the beginning of the trackables array
  449. * @param trackables
  450. * @returns sorted trackables
  451. */
  452. private _sortTrackables(trackables: TrackablePointer[]): TrackablePointer[]
  453. {
  454. /*
  455. Note: the browser may not report a new unique pointer (phase: "began")
  456. as primary. This logic makes trackables[0] primary, or sort of primary.
  457. Behavior on Chrome 130 on Android: when moving multiple touch points,
  458. remove focus from the browser. Touch points will be canceled as
  459. expected. When touching the screen again with a single finger, the
  460. (only one) registered pointer will not be primary. That's undesirable.
  461. Touching the screen again with multiple fingers (none will be primary),
  462. and then releasing them, will restore the desired behavior.
  463. */
  464. // nothing to do
  465. if(trackables.length <= 1 || trackables[0].isPrimary)
  466. return trackables;
  467. // find a primary pointer and swap
  468. for(let j = 1; j < trackables.length; j++) {
  469. if(trackables[j].isPrimary) {
  470. const primary = trackables[j];
  471. trackables[j] = trackables[0];
  472. trackables[0] = primary;
  473. break;
  474. }
  475. }
  476. // done!
  477. return trackables;
  478. }
  479. /**
  480. * Find trackables to remove
  481. * @returns a list of trackables to remove
  482. */
  483. private _findInactiveTrackables(): TrackablePointer[]
  484. {
  485. const trackables: TrackablePointer[] = [];
  486. this._activePointers.forEach(trackable => {
  487. if(trackable.phase == 'ended' || trackable.phase == 'canceled')
  488. trackables.push(trackable);
  489. });
  490. return trackables;
  491. }
  492. /**
  493. * Update the time
  494. * @returns delta time in seconds
  495. */
  496. private _updateTime(): DOMHighResTimeStamp
  497. {
  498. const now = performance.now() * 0.001;
  499. if(this._previousUpdateTime > now)
  500. this._previousUpdateTime = now;
  501. const prev = this._previousUpdateTime;
  502. this._previousUpdateTime = now;
  503. return now - prev;
  504. }
  505. }