Needle Engine

Changes between version 3.31.0 and 3.32.0-alpha
Files changed (83) hide show
  1. src/engine-schemes/vrUserStateBuffer.fbs +0 -0
  2. src/engine-components/api.ts +1 -1
  3. src/engine/api.ts +1 -0
  4. src/engine-components/AudioSource.ts +1 -1
  5. src/engine-components/avatar/AvatarBlink_Simple.ts +1 -1
  6. src/engine-components/ui/BaseUIComponent.ts +2 -2
  7. src/engine-components/ui/Button.ts +2 -2
  8. src/engine-components/Camera.ts +2 -3
  9. src/engine-components/Component.ts +90 -6
  10. src/engine-components/codegen/components.ts +9 -14
  11. src/engine/debug/debug_console.ts +3 -2
  12. src/engine-components/DragControls.ts +931 -176
  13. src/engine-components/Duplicatable.ts +68 -88
  14. src/engine/engine_context.ts +64 -36
  15. src/engine/engine_create_objects.ts +11 -0
  16. src/engine/engine_element_loading.ts +22 -9
  17. src/engine/engine_element_overlay.ts +17 -0
  18. src/engine/engine_element.ts +28 -3
  19. src/engine/engine_gizmos.ts +56 -16
  20. src/engine/engine_input.ts +340 -169
  21. src/engine/engine_lifecycle_api.ts +27 -3
  22. src/engine/engine_mainloop_utils.ts +27 -1
  23. src/engine/engine_networking_instantiate.ts +7 -2
  24. src/engine/engine_networking_streams.ts +3 -3
  25. src/engine/engine_physics.ts +1 -1
  26. src/engine/engine_serialization_core.ts +2 -2
  27. src/engine/engine_three_utils.ts +15 -2
  28. src/engine/engine_types.ts +19 -1
  29. src/engine/engine_utils.ts +41 -4
  30. src/engine-components/ui/EventSystem.ts +98 -126
  31. src/engine-components/ui/Graphic.ts +2 -2
  32. src/engine-components/GroundProjection.ts +7 -2
  33. src/engine-components/webxr/index.ts +1 -2
  34. src/engine-components/Interactable.ts +6 -14
  35. src/engine-components/Light.ts +3 -7
  36. src/engine/extensions/NEEDLE_techniques_webgl.ts +2 -0
  37. src/engine-components/utils/OpenURL.ts +5 -37
  38. src/engine-components/OrbitControls.ts +3 -3
  39. src/engine-components/PlayerColor.ts +17 -13
  40. src/engine-components-experimental/networking/PlayerSync.ts +108 -21
  41. src/engine-components/ui/PointerEvents.ts +48 -23
  42. src/engine-components/ui/Raycaster.ts +25 -7
  43. src/engine/codegen/register_types.ts +15 -25
  44. src/engine-components/Renderer.ts +19 -25
  45. src/engine-components/RendererLightmap.ts +2 -2
  46. src/engine-components/SceneSwitcher.ts +9 -9
  47. src/engine-components/SpectatorCamera.ts +12 -23
  48. src/engine-components/SyncedCamera.ts +1 -2
  49. src/engine-components/SyncedTransform.ts +1 -0
  50. src/engine-components/ui/Text.ts +4 -4
  51. src/engine-components/export/usdz/USDZExporter.ts +6 -78
  52. src/engine-components/export/usdz/extensions/USDZUI.ts +1 -1
  53. src/engine-components/ui/Utils.ts +2 -1
  54. src/engine-schemes/vr-user-state-buffer.ts +37 -30
  55. src/engine-components/webxr/WebARCameraBackground.ts +37 -45
  56. src/engine-components/webxr/WebARSessionRoot.ts +387 -27
  57. src/engine-components/webxr/WebXR.ts +203 -674
  58. src/engine-components/webxr/WebXRAvatar.ts +8 -299
  59. src/engine-components/webxr/WebXRController.ts +0 -1168
  60. src/engine-components/webxr/WebXRGrabRendering.ts +0 -151
  61. src/engine-components/webxr/WebXRImageTracking.ts +63 -71
  62. src/engine-components/webxr/WebXRPlaneTracking.ts +52 -45
  63. src/engine-components/webxr/WebXRRig.ts +43 -8
  64. src/engine-components/webxr/WebXRSync.ts +0 -463
  65. src/engine-components/XRFlag.ts +0 -139
  66. src/engine-schemes/README.md +2 -0
  67. src/engine-components/webxr/Avatar.ts +220 -0
  68. src/engine/engine_xr.ts +2 -0
  69. src/engine/xr/index.ts +5 -0
  70. src/engine/xr/internal.ts +34 -0
  71. src/engine/xr/NeedleXRController.ts +558 -0
  72. src/engine/xr/NeedleXRSession.ts +1168 -0
  73. src/engine/xr/NeedleXRSync.ts +221 -0
  74. src/engine-components/webxr/TeleportTarget.ts +9 -0
  75. src/engine/xr/TempXRContext.ts +182 -0
  76. src/engine-components/webxr/types.ts +4 -0
  77. src/engine/xr/utils.ts +39 -0
  78. src/engine-components/webxr/WebXRButtons.ts +265 -0
  79. src/engine-components/webxr/controllers/XRControllerFollow.ts +58 -0
  80. src/engine-components/webxr/controllers/XRControllerModel.ts +252 -0
  81. src/engine-components/webxr/controllers/XRControllerMovement.ts +282 -0
  82. src/engine-components/webxr/XRFlag.ts +143 -0
  83. src/engine/xr/XRRig.ts +9 -0
src/engine-schemes/vrUserStateBuffer.fbs CHANGED
File without changes
src/engine-components/api.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // We dont want to export everything in the extensions
5
5
  export * from "./js-extensions/RGBAColor.js";
6
6
  export * from "./js-extensions/Object3D.js";
7
- export * from "./XRFlag.js"
7
+ export * from "./webxr/XRFlag.js"
8
8
 
9
9
  export * from "./export/index.js"
10
10
  export * from "./postprocessing/index.js"
src/engine/api.ts CHANGED
@@ -47,6 +47,7 @@
47
47
  export * from "./engine_utils_screenshot.js";
48
48
  export * from "./engine_web_api.js";
49
49
  export * from "./engine_utils.js";
50
+ export * from "./engine_xr.js";
50
51
 
51
52
  export { TypeStore, registerType } from "./engine_typestore.js";
52
53
 
src/engine-components/AudioSource.ts CHANGED
@@ -411,7 +411,7 @@
411
411
  this._hasEnded = true;
412
412
  if (debug)
413
413
  console.log("Audio clip ended", this.clip);
414
- this.sound.dispatchEvent({ type: 'ended', target: this });
414
+ this.dispatchEvent(new CustomEvent("ended", { detail: this }));
415
415
  }
416
416
 
417
417
  // this.gameObject.position.x = Math.sin(time.time) * 2;
src/engine-components/avatar/AvatarBlink_Simple.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Object3D } from "three";
2
2
  import { Behaviour, GameObject } from "../Component.js";
3
- import { XRFlag, XRState } from "../XRFlag.js";
3
+ import { XRFlag, XRState } from "../webxr/XRFlag.js";
4
4
  import { serializable } from "../../engine/engine_serialization_decorator.js";
5
5
 
6
6
 
src/engine-components/ui/BaseUIComponent.ts CHANGED
@@ -38,7 +38,7 @@
38
38
  EventSystem.markUIDirty(this.context);
39
39
  }
40
40
 
41
- shadowComponent: ThreeMeshUI.Block | null = null;
41
+ shadowComponent: Object3D | null = null;
42
42
 
43
43
  private _controlsChildLayout = true;
44
44
  get controlsChildLayout(): boolean { return this._controlsChildLayout; }
@@ -148,7 +148,7 @@
148
148
  // })
149
149
  }
150
150
 
151
- protected setShadowComponentOwner(current: Object3D | null | undefined) {
151
+ protected setShadowComponentOwner(current: ThreeMeshUI.MeshUIBaseElement | Object3D | null | undefined) {
152
152
  if (!current) return;
153
153
  // TODO: only traverse our own hierarchy, we can stop if we find another owner
154
154
  if (current[$shadowDomOwner] === undefined || current[$shadowDomOwner] === this) {
src/engine-components/ui/Button.ts CHANGED
@@ -120,10 +120,10 @@
120
120
  }
121
121
 
122
122
  onPointerClick(args: PointerEventData) {
123
- if (!this.interactable || args.pointerId !== 0) return;
123
+ if (!this.interactable) return;
124
124
 
125
+ if (args.button !== 0 && args.event.pointerType === PointerType.Mouse) return;
125
126
  // Button clicks should only run with left mouse button while using mouse
126
- if(args.pointerId !== 0 && this.context.input.getIsMouse(args.pointerId)) return;
127
127
  if (debug) {
128
128
  console.warn("Button Click", this.onClick);
129
129
  showBalloonMessage("CLICKED button " + this.name + " at " + this.context.time.frameCount);
src/engine-components/Camera.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import { getParam } from "../engine/engine_utils.js";
3
3
  import { serializable } from "../engine/engine_serialization_decorator.js";
4
4
  import { RGBAColor } from "./js-extensions/RGBAColor.js";
5
- import { Context, XRSessionMode } from "../engine/engine_setup.js";
5
+ import { Context } from "../engine/engine_setup.js";
6
6
  import type { ICamera } from "../engine/engine_types.js"
7
7
  import { isDevEnvironment, showBalloonMessage, showBalloonWarning } from "../engine/debug/index.js";
8
8
  import { getWorldPosition } from "../engine/engine_three_utils.js";
@@ -350,7 +350,6 @@
350
350
  if (this._backgroundBlurriness !== undefined)
351
351
  this.context.scene.backgroundBlurriness = this._backgroundBlurriness;
352
352
  if (this._backgroundIntensity !== undefined)
353
- //@ts-ignore
354
353
  this.context.scene.backgroundIntensity = this._backgroundIntensity;
355
354
 
356
355
  break;
@@ -392,7 +391,7 @@
392
391
  if (debug)
393
392
  showBalloonMessage("Environment blend mode: " + environmentBlendMode + " on " + navigator.userAgent);
394
393
  let transparent = environmentBlendMode === 'additive' || environmentBlendMode === 'alpha-blend';
395
- if (context.xrSessionMode === XRSessionMode.ImmersiveAR) {
394
+ if (context.isInAR) {
396
395
  if (environmentBlendMode === "opaque") {
397
396
  // workaround for Quest 2 returning opaque when it should be alpha-blend
398
397
  // check user agent if this is the Quest browser and return true if so
src/engine-components/Component.ts CHANGED
@@ -10,6 +10,8 @@
10
10
 
11
11
  import { Euler, Object3D, Quaternion, Scene, Vector3 } from "three";
12
12
  import { showBalloonWarning, isDevEnvironment } from "../engine/debug/index.js";
13
+ import { ControllerChangedEvt, INeedleXRSessionEventReceiver, NeedleXRControllerEventArgs, NeedleXREventArgs, NeedleXRSession } from "../engine/engine_xr.js";
14
+ import { IPointerEventHandler, PointerEventData } from "./ui/PointerEvents.js";
13
15
 
14
16
  // export interface ISerializationCallbackReceiver {
15
17
  // onBeforeSerialize?(): object | void;
@@ -294,7 +296,7 @@
294
296
  abstract set worldQuaternion(val: Quaternion);
295
297
  abstract get worldQuaternion(): Quaternion;
296
298
  abstract set worldRotation(val: Vector3);
297
- abstract get worldRotation(): Vector3;
299
+ abstract get worldRotation(): Vector3;
298
300
  abstract set worldScale(val: Vector3);
299
301
  abstract get worldScale(): Vector3;
300
302
 
@@ -305,17 +307,22 @@
305
307
 
306
308
 
307
309
 
308
- export class Component implements IComponent, EventTarget {
310
+ export abstract class Component implements IComponent, EventTarget,
311
+ Partial<INeedleXRSessionEventReceiver>,
312
+ Partial<IPointerEventHandler>
313
+ {
309
314
 
310
315
  get isComponent(): boolean { return true; }
311
316
 
312
317
  private __context: Context | undefined;
318
+ /** Use the context to get access to many Needle Engine features and use physics, timing, access the camera or scene */
313
319
  get context(): Context {
314
320
  return this.__context ?? Context.Current;
315
321
  }
316
322
  set context(context: Context) {
317
323
  this.__context = context;
318
324
  }
325
+ /** shorthand for `this.context.scene` */
319
326
  get scene(): Scene { return this.context.scene; }
320
327
 
321
328
  get layer(): number {
@@ -355,7 +362,7 @@
355
362
  return this.gameObject?.userData.hideFlags;
356
363
  }
357
364
 
358
-
365
+ /** @returns true if the object is enabled and active in the hierarchy */
359
366
  get activeAndEnabled(): boolean {
360
367
  if (this.destroyed) return false;
361
368
  if (this.__isEnabled === false) return false;
@@ -385,19 +392,27 @@
385
392
  this.gameObject[activeInHierarchyFieldName] = val;
386
393
  }
387
394
 
395
+ /** the object this component is attached to. Note that this is a threejs Object3D with some additional features */
388
396
  gameObject!: GameObject;
397
+ /** the unique identifier for this component */
389
398
  guid: string = "invalid";
399
+ /** holds the source identifier this object was created with/from (e.g. if it was part of a glTF file the sourceId holds the url to the glTF) */
390
400
  sourceId?: SourceIdentifier;
391
401
  // transform: Object3D = nullObject;
392
402
 
393
403
  /** called on a component with a map of old to new guids (e.g. when instantiate generated new guids and e.g. timeline track bindings needs to remape them) */
394
404
  resolveGuids?(guidsMap: GuidsMap): void;
395
405
 
396
- /** called once when the component becomes active for the first time */
406
+ /** called once when the component becomes active for the first time (once per component)
407
+ * This is the first callback to be called */
397
408
  awake() { }
398
- /** called every time when the component gets enabled (this is invoked after awake and before start) */
409
+ /** called every time when the component gets enabled (this is invoked after awake and before start)
410
+ * or when it becomes active in the hierarchy (e.g. if a parent object or this.gameObject gets set to visible)
411
+ */
399
412
  onEnable() { }
413
+ /** called every time the component gets disabled or if a parent object (or this.gameObject) gets set to invisible */
400
414
  onDisable() { }
415
+ /** Called when the component gets destroyed */
401
416
  onDestroy() {
402
417
  this.__destroyed = true;
403
418
  }
@@ -409,11 +424,17 @@
409
424
  /** Called for all scripts when the context gets paused or unpaused */
410
425
  onPausedChanged?(isPaused: boolean, wasPaused: boolean): void;
411
426
 
427
+ /** called at the beginning of a frame (once per component) */
412
428
  start?(): void;
429
+ /** first callback in a frame (called every frame when implemented) */
413
430
  earlyUpdate?(): void;
431
+ /** regular callback in a frame (called every frame when implemented) */
414
432
  update?(): void;
433
+ /** late callback in a frame (called every frame when implemented) */
415
434
  lateUpdate?(): void;
435
+ /** called before the scene gets rendered in the main update loop */
416
436
  onBeforeRender?(frame: XRFrame | null): void;
437
+ /** called after the scene was rendered */
417
438
  onAfterRender?(): void;
418
439
 
419
440
  onCollisionEnter?(col: Collision);
@@ -424,18 +445,79 @@
424
445
  onTriggerStay?(col: ICollider);
425
446
  onTriggerExit?(col: ICollider);
426
447
 
448
+
449
+ /** Optional callback, you can implement this to only get callbacks for VR or AR sessions if necessary.
450
+ * @returns true if the mode is supported (if false the mode is not supported by this ciomponent and it will not receive XR callbacks for this mode)
451
+ */
452
+ supportsXR?(mode: XRSessionMode): boolean;
453
+ /** Called before the XR session is requested. Use this callback if you want to modify the session init features */
454
+ onBeforeXR?(mode: XRSessionMode, args: XRSessionInit): void;
455
+ /** Callback when this component joins a xr session (or becomes active in a running XR session) */
456
+ onEnterXR?(args: NeedleXREventArgs): void;
457
+ /** Callback when a xr session updates (while it is still active in XR session) */
458
+ onUpdateXR?(args: NeedleXREventArgs): void;
459
+ /** Callback when this component exists a xr session (or when it becomes inactive in a running XR session) */
460
+ onLeaveXR?(args: NeedleXREventArgs): void;
461
+ /** Callback when a controller is connected/added while in a XR session
462
+ * OR when the component joins a running XR session that has already connected controllers
463
+ * OR when the component becomes active during a running XR session that has already connected controllers */
464
+ onXRControllerAdded?(args: NeedleXRControllerEventArgs): void;
465
+ /** callback when a controller is removed while in a XR session
466
+ * OR when the component becomes inactive during a running XR session
467
+ */
468
+ onXRControllerRemoved?(args: NeedleXRControllerEventArgs): void;
469
+
470
+
471
+ /* IPointerEventReceiver */
472
+ /* @inheritdoc */
473
+ onPointerEnter?(args: PointerEventData);
474
+ onPointerMove?(args: PointerEventData);
475
+ onPointerExit?(args: PointerEventData);
476
+ onPointerDown?(args: PointerEventData);
477
+ onPointerUp?(args: PointerEventData);
478
+ onPointerClick?(args: PointerEventData);
479
+
480
+
481
+ /** starts a coroutine (javascript generator function)
482
+ * `yield` will wait for the next frame:
483
+ * - Use `yield WaitForSeconds(1)` to wait for 1 second.
484
+ * - Use `yield WaitForFrames(10)` to wait for 10 frames.
485
+ * - Use `yield new Promise(...)` to wait for a promise to resolve.
486
+ * @param routine generator function to start
487
+ * @param evt event to register the coroutine for (default: FrameEvent.Update). Note that all coroutine FrameEvent callbacks are invoked after the matching regular component callbacks. For example `FrameEvent.Update` will be called after regular component `update()` methods)
488
+ * @returns the generator function (use it to stop the coroutine with `stopCoroutine`)
489
+ * @example
490
+ * ```ts
491
+ * onEnable() { this.startCoroutine(this.myCoroutine()); }
492
+ * private *myCoroutine() {
493
+ * while(this.activeAndEnabled) {
494
+ * console.log("Hello World", this.context.time.frame);
495
+ * // wait for 5 frames
496
+ * for(let i = 0; i < 5; i++) yield;
497
+ * }
498
+ * }
499
+ * ```
500
+ */
427
501
  startCoroutine(routine: Generator, evt: FrameEvent = FrameEvent.Update): Generator {
428
502
  return this.context.registerCoroutineUpdate(this, routine, evt);
429
503
  }
430
-
504
+ /**
505
+ * Stop a coroutine that was previously started with `startCoroutine`
506
+ * @param routine the routine to be stopped
507
+ * @param evt the frame event to unregister the routine from (default: FrameEvent.Update)
508
+ */
431
509
  stopCoroutine(routine: Generator, evt: FrameEvent = FrameEvent.Update): void {
432
510
  this.context.unregisterCoroutineUpdate(routine, evt);
433
511
  }
434
512
 
513
+ /** @returns true if this component was destroyed (`this.destroy()`) or the whole object this component was part of */
435
514
  public get destroyed(): boolean {
436
515
  return this.__destroyed;
437
516
  }
438
517
 
518
+ /**
519
+ * Destroys this component (and removes it from the object)
520
+ */
439
521
  public destroy() {
440
522
  if (this.__destroyed) return;
441
523
  this.__internalDestroy();
@@ -666,5 +748,7 @@
666
748
  }
667
749
  }
668
750
 
751
+
752
+
669
753
  export class Behaviour extends Component {
670
754
  }
src/engine-components/codegen/components.ts CHANGED
@@ -11,11 +11,11 @@
11
11
  export { Animator } from "../Animator.js";
12
12
  export { AnimatorController } from "../AnimatorController.js";
13
13
  export { Antialiasing } from "../postprocessing/Effects/Antialiasing.js";
14
- export { AttachedObject } from "../webxr/WebXRController.js";
15
14
  export { AudioExtension } from "../export/usdz/extensions/behavior/AudioExtension.js";
16
15
  export { AudioListener } from "../AudioListener.js";
17
16
  export { AudioSource } from "../AudioSource.js";
18
17
  export { AudioTrackHandler } from "../timeline/TimelineTracks.js";
18
+ export { Avatar } from "../webxr/Avatar.js";
19
19
  export { Avatar_Brain_LookAt } from "../avatar/Avatar_Brain_LookAt.js";
20
20
  export { Avatar_MouthShapes } from "../avatar/Avatar_MouthShapes.js";
21
21
  export { Avatar_MustacheShake } from "../avatar/Avatar_MustacheShake.js";
@@ -51,7 +51,6 @@
51
51
  export { ColorAdjustments } from "../postprocessing/Effects/ColorAdjustments.js";
52
52
  export { ColorBySpeedModule } from "../ParticleSystemModules.js";
53
53
  export { ColorOverLifetimeModule } from "../ParticleSystemModules.js";
54
- export { Component } from "../Component.js";
55
54
  export { ContactShadows } from "../ContactShadows.js";
56
55
  export { ControlTrackHandler } from "../timeline/TimelineTracks.js";
57
56
  export { CustomBranding } from "../export/usdz/USDZExporter.js";
@@ -88,7 +87,6 @@
88
87
  export { Image } from "../ui/Image.js";
89
88
  export { InheritVelocityModule } from "../ParticleSystemModules.js";
90
89
  export { InputField } from "../ui/InputField.js";
91
- export { Interactable } from "../Interactable.js";
92
90
  export { Light } from "../Light.js";
93
91
  export { LimitVelocityOverLifetimeModule } from "../ParticleSystemModules.js";
94
92
  export { LODGroup } from "../LODGroup.js";
@@ -102,6 +100,7 @@
102
100
  export { MeshRenderer } from "../Renderer.js";
103
101
  export { MinMaxCurve } from "../ParticleSystemModules.js";
104
102
  export { MinMaxGradient } from "../ParticleSystemModules.js";
103
+ export { NeedleWebXRHtmlElement } from "../webxr/WebXRButtons.js";
105
104
  export { NestedGltf } from "../NestedGltf.js";
106
105
  export { Networking } from "../Networking.js";
107
106
  export { NoiseModule } from "../ParticleSystemModules.js";
@@ -125,7 +124,6 @@
125
124
  export { PreliminaryAction } from "../export/usdz/extensions/behavior/BehaviourComponents.js";
126
125
  export { PreliminaryTrigger } from "../export/usdz/extensions/behavior/BehaviourComponents.js";
127
126
  export { RawImage } from "../ui/Image.js";
128
- export { Raycaster } from "../ui/Raycaster.js";
129
127
  export { Rect } from "../ui/RectTransform.js";
130
128
  export { RectTransform } from "../ui/RectTransform.js";
131
129
  export { ReflectionProbe } from "../ReflectionProbe.js";
@@ -153,6 +151,7 @@
153
151
  export { SizeOverLifetimeModule } from "../ParticleSystemModules.js";
154
152
  export { SkinnedMeshRenderer } from "../Renderer.js";
155
153
  export { SmoothFollow } from "../SmoothFollow.js";
154
+ export { SpatialGrabRaycaster } from "../ui/Raycaster.js";
156
155
  export { SpatialHtml } from "../ui/SpatialHtml.js";
157
156
  export { SpatialTrigger } from "../SpatialTrigger.js";
158
157
  export { SpatialTriggerReceiver } from "../SpatialTrigger.js";
@@ -167,7 +166,7 @@
167
166
  export { SyncedRoom } from "../SyncedRoom.js";
168
167
  export { SyncedTransform } from "../SyncedTransform.js";
169
168
  export { TapGestureTrigger } from "../export/usdz/extensions/behavior/BehaviourComponents.js";
170
- export { TeleportTarget } from "../webxr/WebXRController.js";
169
+ export { TeleportTarget } from "../webxr/TeleportTarget.js";
171
170
  export { TestRunner } from "../TestRunner.js";
172
171
  export { TestSimulateUserData } from "../TestRunner.js";
173
172
  export { Text } from "../ui/Text.js";
@@ -197,20 +196,16 @@
197
196
  export { Volume } from "../postprocessing/Volume.js";
198
197
  export { VolumeParameter } from "../postprocessing/VolumeParameter.js";
199
198
  export { VolumeProfile } from "../postprocessing/VolumeProfile.js";
200
- export { VRUserState } from "../webxr/WebXRSync.js";
201
- export { WebAR } from "../webxr/WebXR.js";
202
199
  export { WebARCameraBackground } from "../webxr/WebARCameraBackground.js";
203
200
  export { WebARSessionRoot } from "../webxr/WebARSessionRoot.js";
204
201
  export { WebXR } from "../webxr/WebXR.js";
205
- export { WebXRAvatar } from "../webxr/WebXRAvatar.js";
206
- export { WebXRController } from "../webxr/WebXRController.js";
207
202
  export { WebXRImageTracking } from "../webxr/WebXRImageTracking.js";
208
203
  export { WebXRImageTrackingModel } from "../webxr/WebXRImageTracking.js";
209
204
  export { WebXRPlaneTracking } from "../webxr/WebXRPlaneTracking.js";
210
- export { WebXRSync } from "../webxr/WebXRSync.js";
211
205
  export { WebXRTrackedImage } from "../webxr/WebXRImageTracking.js";
212
- export { XRFlag } from "../XRFlag.js";
213
- export { XRGrabModel } from "../webxr/WebXRGrabRendering.js";
214
- export { XRGrabRendering } from "../webxr/WebXRGrabRendering.js";
206
+ export { XRControllerFollow } from "../webxr/controllers/XRControllerFollow.js";
207
+ export { XRControllerModel } from "../webxr/controllers/XRControllerModel.js";
208
+ export { XRControllerMovement } from "../webxr/controllers/XRControllerMovement.js";
209
+ export { XRFlag } from "../webxr/XRFlag.js";
215
210
  export { XRRig } from "../webxr/WebXRRig.js";
216
- export { XRState } from "../XRFlag.js";
211
+ export { XRState } from "../webxr/XRFlag.js";
src/engine/debug/debug_console.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { getErrorCount } from "./debug_overlay.js";
2
- import { getParam, isMobileDevice } from "../engine_utils.js";
2
+ import { getParam, isMobileDevice, isQuest } from "../engine_utils.js";
3
3
  import { isLocalNetwork } from "../engine_networking_utils.js";
4
+ import { isDevEnvironment } from "./debug.js";
4
5
 
5
6
  let consoleInstance: any = null;
6
7
  let consoleHtmlElement: HTMLElement | null = null;
@@ -22,7 +23,7 @@
22
23
  currentUrl.searchParams.set("console", "1");
23
24
  console.log("🌵 Tip: You can add the \"?console\" query parameter to the url to show the debug console (on mobile it will automatically open in the bottom right corner when your get errors during development)", "\nOpen this page console: " + currentUrl.toString());
24
25
  }
25
- const isMobile = isMobileDevice();
26
+ const isMobile = isMobileDevice() || (isQuest() && isDevEnvironment());
26
27
  if (isMobile) {
27
28
  beginWatchingLogs();
28
29
  createConsole(true);
src/engine-components/DragControls.ts CHANGED
@@ -1,104 +1,125 @@
1
- import { GameObject } from "./Component.js";
1
+ import { Behaviour, GameObject } from "./Component.js";
2
2
  import { SyncedTransform } from "./SyncedTransform.js";
3
- import type { IPointerDownHandler, IPointerEnterHandler, IPointerEventHandler, IPointerExitHandler, IPointerUpHandler, PointerEventData } from "./ui/PointerEvents.js";
3
+ import type { IPointerEventHandler, PointerEventData } from "./ui/PointerEvents.js";
4
4
  import { Context } from "../engine/engine_setup.js";
5
- import { Interactable, UsageMarker } from "./Interactable.js";
5
+ import { UsageMarker } from "./Interactable.js";
6
6
  import { Rigidbody } from "./RigidBody.js";
7
- import { WebXR } from "./webxr/WebXR.js";
8
7
  import { Avatar_POI } from "./avatar/Avatar_Brain_LookAt.js";
9
8
  import { RaycastOptions } from "../engine/engine_physics.js";
10
9
  import { getWorldPosition, setWorldPosition } from "../engine/engine_three_utils.js";
11
- import type { KeyCode } from "../engine/engine_input.js";
12
- import { nameofFactory } from "../engine/engine_utils.js";
13
10
  import { InstancingUtil } from "../engine/engine_instancing.js";
14
11
  import { OrbitControls } from "./OrbitControls.js";
15
- import { BufferGeometry, Camera, Color, Line, LineBasicMaterial, Matrix4, Mesh, MeshBasicMaterial, Object3D, Plane, Ray, Raycaster, SphereGeometry, Vector2, Vector3 } from "three";
12
+ import { AxesHelper, Box3, BufferGeometry, Camera, Color, Event, Line, LineBasicMaterial, Matrix3, Matrix4, Mesh, MeshBasicMaterial, Object3D, Plane, PlaneHelper, Quaternion, Ray, Raycaster, SphereGeometry, Vector3 } from "three";
16
13
  import { ObjectRaycaster } from "./ui/Raycaster.js";
17
14
  import { serializable } from "../engine/engine_serialization_decorator.js";
15
+ import { IGameObject } from "../engine/engine_types.js";
16
+ import { Mathf } from "../engine/engine_math.js";
17
+ import { getParam } from "../engine/engine_utils.js";
18
+ import { Gizmos } from "../engine/engine_gizmos.js";
19
+ import { NeedleXRSession } from "../engine/engine_xr.js";
18
20
 
19
- const debug = false;
21
+ const debug = getParam("debugdrag");
20
22
 
21
- export enum DragEvents {
22
- SelectStart = "selectstart",
23
- SelectEnd = "selectend",
23
+ export enum DragMode {
24
+ /** Object stays at the same horizontal plane as it started. Commonly used for objects on the floor */
25
+ XZPlane = 0,
26
+ /** Object is dragged as if it was attached to the pointer. In 2D, that means it's dragged along the camera screen plane. In XR, it's dragged by the controller/hand. */
27
+ Attached = 1,
28
+ /** Object is dragged along the initial raycast hit normal. */
29
+ HitNormal = 2,
30
+ /** Combination of XZ and Screen based on the viewing angle. Low angles result in Screen dragging and higher angles in XZ dragging. */
31
+ DynamicViewAngle = 3,
32
+ /** The drag plane is adjusted dynamically while dragging. */
33
+ SnapToSurfaces = 4,
24
34
  }
25
35
 
26
- interface SelectArgs {
27
- selected: Object3D;
28
- attached: Object3D | GameObject | null;
29
- }
36
+ export class DragControls extends Behaviour implements IPointerEventHandler {
30
37
 
38
+ // dragPlane (floor, object, view)
39
+ // snap to surface (snap orientation?)
40
+ // two-handed drag (scale, rotate, move)
41
+ // keep upright (no tilt)
31
42
 
32
- export interface IDragEventListener {
33
- onDragStart?();
34
- onDragEnd?();
35
- }
43
+ /** How and where the object is dragged along. */
44
+ @serializable()
45
+ public dragMode: DragMode = DragMode.DynamicViewAngle;
36
46
 
37
- export class DragControls extends Interactable implements IPointerEventHandler {
47
+ /** Snap dragged objects to a XYZ grid – 0 means: no snapping. */
48
+ @serializable()
49
+ public snapGridResolution: number = 0.0;
50
+
51
+ /** Keep the original rotation of the dragged object. */
52
+ @serializable()
53
+ public keepRotation: boolean = true;
54
+
55
+ /** How and where the object is dragged along while dragging in XR. */
56
+ @serializable()
57
+ public xrDragMode: DragMode = DragMode.Attached;
38
58
 
39
- private static _active: number = 0;
40
- public static get HasAnySelected(): boolean { return this._active > 0; }
59
+ /** Keep the original rotation of the dragged object while dragging in XR. */
60
+ @serializable()
61
+ public xrKeepRotation: boolean = false;
41
62
 
42
- /** Show's drag gizmos when enabled */
63
+ /** Accelerate dragging objects closer / further away when in XR */
43
64
  @serializable()
44
- public showGizmo: boolean = true;
65
+ public xrDistanceDragFactor: number = 1;
45
66
 
46
- /** When enabled DragControls will drag vertically when the object is viewed from a low angle */
67
+ /** When enabled, draws a line from the dragged object downwards to the next raycast hit. */
47
68
  @serializable()
48
- public useViewAngle: boolean = true;
69
+ public showGizmo: boolean = false;
49
70
 
50
- public transformSelf: boolean = true;
51
- // public transformGroup: boolean = true;
52
- // public targets: Object3D[] | null = null;
71
+ // future:
72
+ // constraints?
53
73
 
54
- // private controls: Control | null = null;
74
+ public static get HasAnySelected(): boolean { return this._active > 0; }
75
+ private static _active: number = 0;
76
+
77
+ /** The object to be dragged – we pass this to handlers when they are created */
78
+ private targetObject: GameObject | null = null;
55
79
  private orbit: OrbitControls | null = null;
80
+ private _dragHelper: LegacyDragVisualsHelper | null = null;
81
+ private static lastHovered: Object3D;
82
+ private _draggingRigidbodies: Rigidbody[] = [];
83
+ private _potentialDragStartEvt: PointerEventData | null = null;
84
+ private _dragHandlers: Map<Object3D, IDragHandler> = new Map();
85
+ private _totalMovement: Vector3 = new Vector3();
86
+ /** A marker is attached to components that are currently interacted with, to e.g. prevent them from being deleted. */
87
+ private _marker: UsageMarker | null = null;
88
+ private _isDragging: boolean = false;
89
+ private _didDrag: boolean = false;
56
90
 
57
- private selectStartEventListener: ((controls: DragControls, args: SelectArgs) => void)[] = [];
58
- private selectEndEventListener: Array<Function> = [];
59
- private _dragHelper: DragHelper | null = null;
60
-
61
- constructor() {
62
- super();
63
- this.selectStartEventListener = [];
64
- this.selectEndEventListener = [];
65
- this._dragDelta = new Vector2();
91
+ setTargetObject(obj: Object3D | null) {
92
+ this.targetObject = obj as GameObject;
93
+ for (const handler of this._dragHandlers.values()) {
94
+ handler.setTargetObject(obj);
95
+ }
66
96
  }
67
97
 
68
-
69
- // TODO: Update DragEventListener code
70
- addDragEventListener(type: DragEvents, cb: (ctrls: DragControls, args: SelectArgs) => void | Function) {
71
- switch (type) {
72
- case DragEvents.SelectStart:
73
- this.selectStartEventListener.push(cb);
74
- break;
75
- case DragEvents.SelectEnd:
76
- this.selectEndEventListener.push(cb);
77
- break;
78
- }
98
+ awake() {
99
+ // initialize all data that may be cloned incorrectly otherwise
100
+ this._potentialDragStartEvt = null;
101
+ this._dragHandlers = new Map();
102
+ this._totalMovement = new Vector3();
103
+ this._marker = null;
104
+ this._isDragging = false;
105
+ this._didDrag = false;
106
+ this._dragHelper = null;
107
+ this._draggingRigidbodies = [];
79
108
  }
80
109
 
81
-
82
-
83
110
  start() {
84
111
  this.orbit = GameObject.findObjectOfType(OrbitControls, this.context);
85
- if (!this.gameObject.getComponentInParent(ObjectRaycaster)) {
112
+ if (!this.gameObject.getComponentInParent(ObjectRaycaster))
86
113
  this.gameObject.addNewComponent(ObjectRaycaster);
87
- }
88
114
  }
89
115
 
90
- private static lastHovered: Object3D;
91
- private _draggingRigidbodies: Rigidbody[] = [];
92
-
93
116
  private allowEdit(_obj: Object3D | null = null) {
94
117
  return this.context.connection.allowEditing;
95
118
  }
96
119
 
97
120
  onPointerEnter(evt: PointerEventData) {
98
121
  if (!this.allowEdit(this.gameObject)) return;
99
- if (WebXR.IsInWebXR) return;
100
- // const interactable = GameObject.getComponentInParent(evt.object, Interactable);
101
- // if (!interactable) return;
122
+ if (evt.mode !== "screen") return;
102
123
  const dc = GameObject.getComponentInParent(evt.object, DragControls);
103
124
  if (!dc || dc !== this) return;
104
125
  DragControls.lastHovered = evt.object;
@@ -107,83 +128,120 @@
107
128
 
108
129
  onPointerExit(evt: PointerEventData) {
109
130
  if (!this.allowEdit(this.gameObject)) return;
110
- if (WebXR.IsInWebXR) return;
131
+ if (evt.mode !== "screen") return;
111
132
  if (DragControls.lastHovered !== evt.object) return;
112
- // const interactable = GameObject.getComponentInParent(evt.object, Interactable);
113
- // if (!interactable) return;
114
133
  this.context.domElement.style.cursor = 'auto';
115
134
  }
116
135
 
117
- private _waitingForDragStart: PointerEventData | null = null;
118
-
119
136
  onPointerDown(args: PointerEventData) {
120
137
  if (!this.allowEdit(this.gameObject)) return;
121
- if (WebXR.IsInWebXR) return;
122
- DragControls._active += 1;
123
- this._dragDelta.set(0, 0);
124
- this._didDrag = false;
125
- // Clone to not modify the original event (and this event is used in the actual onDragStart method)
126
- this._waitingForDragStart = args.clone();
127
- args.stopPropagation();
128
- // disabling pointer controls here already, otherwise we get a few frames of movement event in orbit controls and this will rotate the camera sligthly AFTER drag controls dragging ends.
129
- if (this.orbit) this.orbit.enabled = false;
138
+ DragControls.lastHovered = args.object;
139
+
140
+ if (args.button === 0) {
141
+ if (this._dragHandlers.size === 0) {
142
+ this._didDrag = false;
143
+ this._totalMovement.set(0, 0, 0);
144
+ this._potentialDragStartEvt = args;
145
+ }
146
+
147
+ DragControls._active += 1;
148
+
149
+ const newDragHandler = new DragPointerHandler(this, this.targetObject || this.gameObject);
150
+ this._dragHandlers.set(args.event.space, newDragHandler);
151
+
152
+ // We need to turn off OrbitControls immediately, otherwise they still get data for a short moment
153
+ // and they don't properly handle being disabled while already processing data (smoothing happens when enabling again)
154
+ if (this.orbit) this.orbit.enabled = false;
155
+
156
+ newDragHandler.onDragStart(args);
157
+
158
+ if (this._dragHandlers.size === 2) {
159
+ const iterator = this._dragHandlers.values();
160
+ const a = iterator.next().value;
161
+ const b = iterator.next().value;
162
+ const mtHandler = new MultiTouchDragHandler(this, this.targetObject || this.gameObject, a, b);
163
+ this._dragHandlers.set(this.gameObject, mtHandler);
164
+
165
+ mtHandler.onDragStart(args);
166
+ }
167
+
168
+ args.use();
169
+ }
130
170
  }
131
171
 
132
172
  onPointerMove(args: PointerEventData) {
133
- if(this._isDragging || this._waitingForDragStart !== null) args.use();
173
+ if (this._isDragging || this._potentialDragStartEvt !== null) args.use();
134
174
  }
135
175
 
136
176
  onPointerUp(args: PointerEventData) {
137
- this._waitingForDragStart = null;
177
+
178
+ if(debug) Gizmos.DrawLabel(args.point ?? this.gameObject.worldPosition, "POINTERUP:" + args.pointerId + ", " + args.button, .03, 3);
179
+
138
180
  if (!this.allowEdit(this.gameObject)) return;
139
- if (DragControls._active > 0)
140
- DragControls._active -= 1;
141
- if (WebXR.IsInWebXR) return;
142
- this.onDragEnd(args);
143
- args.stopPropagation();
144
- if (this.orbit) this.orbit.enabled = true;
181
+ if (args.button !== 0) return;
182
+ this._potentialDragStartEvt = null;
183
+
184
+ const handler = this._dragHandlers.get(args.event.space);
185
+ const mtHandler = this._dragHandlers.get(this.gameObject) as MultiTouchDragHandler;
186
+ if (mtHandler && (mtHandler.handlerA === handler || mtHandler.handlerB === handler)) {
187
+ // any of the two handlers has been released, so we can remove the multi-touch handler
188
+ this._dragHandlers.delete(this.gameObject);
189
+ mtHandler.onDragEnd(args);
190
+ }
191
+
192
+ if (handler) {
193
+ if (DragControls._active > 0)
194
+ DragControls._active -= 1;
195
+
196
+ if (handler.onDragEnd) handler.onDragEnd(args);
197
+ this._dragHandlers.delete(args.event.space);
198
+
199
+ if (this._dragHandlers.size === 0) {
200
+ this.onLastDragEnd(args);
201
+ }
202
+ args.use();
203
+ }
204
+
205
+ if (DragControls._active === 0) {
206
+ if (this.orbit) this.orbit.enabled = true;
207
+ }
145
208
  }
146
209
 
147
-
148
210
  update(): void {
149
- if (WebXR.IsInWebXR) return;
150
211
 
212
+ for (const handler of this._dragHandlers.values()) {
213
+ if (handler.collectMovementInfo) handler.collectMovementInfo();
214
+ // TODO this doesn't make sense, we should instead just use the max here
215
+ // or even better, each handler can decide on their own how to handle this
216
+ if (handler.getTotalMovement) this._totalMovement.add(handler.getTotalMovement());
217
+ }
218
+
151
219
  // drag start only after having dragged for some pixels
152
- if (this._waitingForDragStart) {
220
+ if (this._potentialDragStartEvt) {
153
221
  if (!this._didDrag) {
154
- // this is so we can e.g. process clicks without having a drag change the position
155
- // e.g. a click to rotate the object
156
- const delta = this.context.input.getPointerPositionDelta(0);
157
- if (delta)
158
- this._dragDelta.add(delta);
159
- if (this._dragDelta.length() > 2)
222
+ // this is so we can e.g. process clicks without having a drag change the position, e.g. a click to call a method.
223
+ // TODO probably needs to be treated differently for spatial (3D motion) and screen (2D pixel motion) drags
224
+ if (this._totalMovement.length() > 0.0003)
160
225
  this._didDrag = true;
161
226
  else return;
162
227
  }
163
- const args = this._waitingForDragStart;
164
- this._waitingForDragStart = null;
165
- this.onDragStart(args);
228
+ const args = this._potentialDragStartEvt;
229
+ this._potentialDragStartEvt = null;
230
+ this.onFirstDragStart(args);
166
231
  }
167
232
 
168
- if (this._dragHelper && this._dragHelper.hasSelected) {
169
- this.onUpdateDrag();
170
- }
171
-
172
- if (this._dragHelper?.hasSelected === false || (this._activePointerId !== undefined && this.context.input.getPointerPressed(this._activePointerId) === false)) {
173
- this.onDragEnd(null);
174
- }
233
+ for (const handler of this._dragHandlers.values())
234
+ if (handler.onDragUpdate) handler.onDragUpdate(this._dragHandlers.size);
235
+
236
+ if (this._dragHelper && this._dragHelper.hasSelected)
237
+ this.onAnyDragUpdate();
175
238
  }
176
239
 
177
- private _isDragging: boolean = false;
178
- private _marker: UsageMarker | null = null;
179
- private _dragDelta!: Vector2;
180
- private _didDrag: boolean = false;
181
- private _activePointerId?: number;
182
-
183
- private onDragStart(evt: PointerEventData) {
240
+ /** Called when the first pointer starts dragging on this object. Not called for subsequent pointers on the same object. */
241
+ private onFirstDragStart(evt: PointerEventData) {
184
242
  if (!this._dragHelper) {
185
243
  if (this.context.mainCamera)
186
- this._dragHelper = new DragHelper(this.context.mainCamera);
244
+ this._dragHelper = new LegacyDragVisualsHelper(this.context.mainCamera);
187
245
  else
188
246
  return;
189
247
  }
@@ -192,46 +250,17 @@
192
250
  const dc = GameObject.getComponentInParent(evt.object, DragControls);
193
251
  if (!dc || dc !== this) return;
194
252
 
253
+ const object = this.targetObject || this.gameObject;
195
254
 
196
- let object: Object3D = evt.object;
255
+ if (!object) return;
197
256
 
198
- if (this.transformSelf) {
199
- object = this.gameObject;
200
- }
201
-
202
- // raise event
203
- const args: { selected: Object3D, attached: Object3D | null } = { selected: object, attached: object };
204
- for (const listener of this.selectStartEventListener) {
205
- listener(this, args);
206
- }
207
-
208
- this._activePointerId = evt.pointerId;
209
-
210
- if (!args.attached) return;
211
- if (args.attached !== object) {
212
- // // if duplicatable changes the object being dragged
213
- // // should it also change the active drag controls (e.g. if it has a own one)
214
- // const drag = GameObject.getComponentInParent(args.attached, DragControls);
215
- // if (drag && drag !== this) {
216
- // // incredibly ugly code to pass the drag controls event to another drag controls instance
217
- // // This is necessary since we dont call the onPointerUp events anymore for all objects
218
- // // that have previously received the onPointerDown event.
219
- // // NOTE: added the EventSystem.raisedPointerDownEvents array again because of this uglyness here. The code was originally removed in 757fc5e5bafd02aa13d6cd35dd5e8729c841465a and now we're adding it in 8ce886d8344d1abd5ebb89ae3e1fb8d6d47293da
220
- // this.onDragEnd(null);
221
- // drag.onPointerDown(evt);
222
- // evt.object = args.attached;
223
- // drag.onDragStart(evt);
224
- // return;
225
- // }
226
- }
227
- object = args.attached;
228
257
  this._isDragging = true;
229
258
  this._dragHelper.setSelected(object, this.context);
230
259
  if (this.orbit) this.orbit.enabled = false;
231
260
 
232
261
  const sync = GameObject.getComponentInChildren(object, SyncedTransform);
233
- if (debug)
234
- console.log("DRAG START", sync, object);
262
+ if (debug) console.log("DRAG START", sync, object);
263
+
235
264
  if (sync) {
236
265
  sync.fastMode = true;
237
266
  sync?.requestOwnership();
@@ -239,30 +268,31 @@
239
268
 
240
269
  this._marker = GameObject.addNewComponent(object, UsageMarker);
241
270
 
242
- // console.log(object, this._marker);
243
-
244
271
  this._draggingRigidbodies.length = 0;
245
272
  const rbs = GameObject.getComponentsInChildren(object, Rigidbody);
246
273
  if (rbs)
247
274
  this._draggingRigidbodies.push(...rbs);
248
-
249
- const l = nameofFactory<IDragEventListener>();
250
- GameObject.invokeOnChildren(this._dragHelper.selected, l("onDragStart"));
251
275
  }
252
276
 
253
- private onUpdateDrag() {
277
+ /** Called each frame as long as any pointer is dragging this object. */
278
+ private onAnyDragUpdate() {
254
279
  if (!this._dragHelper) return;
255
280
  this._dragHelper.showGizmo = this.showGizmo;
256
- this._dragHelper.useViewAngle = this.useViewAngle;
257
281
 
258
282
  this._dragHelper.onUpdate(this.context);
259
283
  for (const rb of this._draggingRigidbodies) {
260
284
  rb.wakeUp();
261
285
  rb.resetVelocities();
286
+ rb.resetForcesAndTorques();
262
287
  }
288
+
289
+ const object = this.targetObject || this.gameObject;
290
+
291
+ InstancingUtil.markDirty(object);
263
292
  }
264
293
 
265
- private onDragEnd(evt: PointerEventData | null) {
294
+ /** Called when the last pointer has been removed from this object. */
295
+ private onLastDragEnd(evt: PointerEventData | null) {
266
296
  if (!this || !this._isDragging) return;
267
297
  this._isDragging = false;
268
298
  if (!this._dragHelper) return;
@@ -271,8 +301,7 @@
271
301
  }
272
302
  this._draggingRigidbodies.length = 0;
273
303
  const selected = this._dragHelper.selected;
274
- if (debug)
275
- console.log("DRAG END", selected, selected?.visible)
304
+ if (debug) console.log("DRAG END", selected, selected?.visible)
276
305
  this._dragHelper.setSelected(null, this.context);
277
306
  if (this.orbit) this.orbit.enabled = true;
278
307
  if (evt?.object) {
@@ -282,23 +311,751 @@
282
311
  // sync?.requestOwnership();
283
312
  }
284
313
  }
285
- if (this._marker) {
314
+ if (this._marker)
286
315
  this._marker.destroy();
316
+ }
317
+ }
318
+
319
+ /** Handles two touch points affecting one object. Allows movement, scale and rotation of objects. */
320
+ class MultiTouchDragHandler implements IDragHandler {
321
+
322
+ handlerA: DragPointerHandler;
323
+ handlerB: DragPointerHandler;
324
+
325
+ private context: Context;
326
+ private settings: DragControls;
327
+ private gameObject: GameObject;
328
+ private _handlerAAttachmentPoint: Vector3 = new Vector3();
329
+ private _handlerBAttachmentPoint: Vector3 = new Vector3();
330
+
331
+ private _followObject: GameObject;
332
+ private _manipulatorObject: GameObject;
333
+ private _deviceMode!: XRTargetRayMode;
334
+ private _followObjectStartWorldQuaternion: Quaternion = new Quaternion();
335
+
336
+ constructor(dragControls: DragControls, gameObject: GameObject, pointerA: DragPointerHandler, pointerB: DragPointerHandler) {
337
+ this.context = dragControls.context;
338
+ this.settings = dragControls;
339
+ this.gameObject = gameObject;
340
+ this.handlerA = pointerA;
341
+ this.handlerB = pointerB;
342
+
343
+ this._followObject = new Object3D() as GameObject;
344
+ this._manipulatorObject = new Object3D() as GameObject;
345
+
346
+ this.context.scene.add(this._manipulatorObject);
347
+
348
+ const rig = NeedleXRSession.active?.rig?.gameObject;
349
+
350
+ if (!this.handlerA || !this.handlerB || !this.handlerA.hitPointInLocalSpace || !this.handlerB.hitPointInLocalSpace) {
351
+ console.error("Invalid: MultiTouchDragHandler needs two valid DragPointerHandlers with hitPointInLocalSpace set.");
352
+ return;
287
353
  }
288
- // raise event
289
- for (const listener of this.selectEndEventListener) {
290
- listener(this);
354
+
355
+ this._tempVec1.copy(this.handlerA.hitPointInLocalSpace);
356
+ this._tempVec2.copy(this.handlerB.hitPointInLocalSpace);
357
+ this.gameObject.localToWorld(this._tempVec1);
358
+ this.gameObject.localToWorld(this._tempVec2);
359
+ if (rig) {
360
+ rig.worldToLocal(this._tempVec1);
361
+ rig.worldToLocal(this._tempVec2);
291
362
  }
363
+ this._initialDistance = this._tempVec1.distanceTo(this._tempVec2);
364
+
365
+ if (this._initialDistance < 0.02) {
366
+ if (debug) {
367
+ console.log("Finding alternative drag attachment points since initial distance is too low: " + this._initialDistance.toFixed(2));
368
+ }
369
+ // We want two reasonable pointer attachment points here.
370
+ // But if the hitPointInLocalSpace are very close to each other, we instead fall back to controller positions.
371
+ this.handlerA.followObject.parent!.getWorldPosition(this._tempVec1);
372
+ this.handlerB.followObject.parent!.getWorldPosition(this._tempVec2);
373
+ this._handlerAAttachmentPoint.copy(this._tempVec1);
374
+ this._handlerBAttachmentPoint.copy(this._tempVec2);
375
+ this.gameObject.worldToLocal(this._handlerAAttachmentPoint);
376
+ this.gameObject.worldToLocal(this._handlerBAttachmentPoint);
377
+ this._initialDistance = this._tempVec1.distanceTo(this._tempVec2);
292
378
 
293
- const l = nameofFactory<IDragEventListener>();
294
- GameObject.invokeOnChildren(selected, l("onDragEnd"));
379
+ if (this._initialDistance < 0.001) {
380
+ console.warn("Not supported right now – controller drag points for multitouch are too close!");
381
+ this._initialDistance = 1;
382
+ }
383
+ }
384
+ else {
385
+ this._handlerAAttachmentPoint.copy(this.handlerA.hitPointInLocalSpace);
386
+ this._handlerBAttachmentPoint.copy(this.handlerB.hitPointInLocalSpace);
387
+ }
388
+
389
+ this._tempVec3.lerpVectors(this._tempVec1, this._tempVec2, 0.5);
390
+ this._initialScale.copy(gameObject.scale);
391
+
392
+ if (debug) {
393
+ this._followObject.add(new AxesHelper(2));
394
+ this._manipulatorObject.add(new AxesHelper(5));
395
+
396
+ const formatVec = (v: Vector3) => `${v.x.toFixed(2)}, ${v.y.toFixed(2)}, ${v.z.toFixed(2)}`;
397
+ Gizmos.DrawLine(this._tempVec1, this._tempVec2, 0x00ffff, 0, false);
398
+ Gizmos.DrawLabel(this._tempVec3, "A:B " + this._initialDistance.toFixed(2) + "\n" + formatVec(this._tempVec1) + "\n" + formatVec(this._tempVec2), 0.03, 5);
399
+ }
295
400
  }
401
+
402
+ onDragStart(_args: PointerEventData): void {
403
+ // align _followObject with the object we want to drag
404
+ this.gameObject.add(this._followObject);
405
+ this._followObject.matrixAutoUpdate = false;
406
+ this._followObject.matrix.identity();
407
+ this._deviceMode = _args.mode;
408
+ this._followObjectStartWorldQuaternion.copy(this._followObject.worldQuaternion);
409
+
410
+ // align _manipulatorObject in the same way it would if this was a drag update
411
+ this.alignManipulator();
412
+
413
+ // and then parent it to the space object so it follows along.
414
+ this._manipulatorObject.attach(this._followObject);
415
+
416
+ // store offsets in local space
417
+ this._manipulatorPosOffset.copy(this._followObject.position);
418
+ this._manipulatorRotOffset.copy(this._followObject.quaternion);
419
+ this._manipulatorScaleOffset.copy(this._followObject.scale);
420
+ }
421
+
422
+ onDragEnd(_args: PointerEventData): void {
423
+ if (!this.handlerA || !this.handlerB) {
424
+ console.error("onDragEnd called on MultiTouchDragHandler without valid handlers. This is likely a bug.");
425
+ return;
426
+ }
427
+
428
+ // we want to initialize the drag points for these handlers again.
429
+ // one of them will be removed, but we don't know here which one
430
+ this.handlerA.recenter();
431
+ this.handlerB.recenter();
432
+
433
+ // destroy helper objects
434
+ this._manipulatorObject.removeFromParent();
435
+ this._followObject.removeFromParent();
436
+ this._manipulatorObject.destroy();
437
+ this._followObject.destroy();
438
+ }
439
+
440
+ private _manipulatorPosOffset: Vector3 = new Vector3();
441
+ private _manipulatorRotOffset: Quaternion = new Quaternion();
442
+ private _manipulatorScaleOffset: Vector3 = new Vector3();
443
+
444
+ private _tempVec1: Vector3 = new Vector3();
445
+ private _tempVec2: Vector3 = new Vector3();
446
+ private _tempVec3: Vector3 = new Vector3();
447
+ private tempLookMatrix: Matrix4 = new Matrix4();
448
+ private _initialScale: Vector3 = new Vector3();
449
+ private _initialDistance: number = 0;
450
+
451
+ private alignManipulator() {
452
+ this._tempVec1.copy(this._handlerAAttachmentPoint);
453
+ this._tempVec2.copy(this._handlerBAttachmentPoint);
454
+ this.handlerA.followObject.localToWorld(this._tempVec1);
455
+ this.handlerB.followObject.localToWorld(this._tempVec2);
456
+ this._tempVec3.lerpVectors(this._tempVec1, this._tempVec2, 0.5);
457
+
458
+ this._manipulatorObject.position.copy(this._tempVec3);
459
+
460
+ // - lookAt the second point on handlerB
461
+ const camera = this.context.mainCamera;
462
+ this.tempLookMatrix.lookAt(this._tempVec3, this._tempVec2, (camera as any as IGameObject).worldUp);
463
+ this._manipulatorObject.quaternion.setFromRotationMatrix(this.tempLookMatrix);
464
+
465
+ // - scale based on the distance between the two points
466
+ const dist = this._tempVec1.distanceTo(this._tempVec2);
467
+ this._manipulatorObject.scale.copy(this._initialScale).multiplyScalar(dist / this._initialDistance);
468
+
469
+ this._manipulatorObject.updateMatrix();
470
+ this._manipulatorObject.updateMatrixWorld(true);
471
+
472
+ if (debug) {
473
+ Gizmos.DrawLabel(this._tempVec3.clone().add(new Vector3(0,0.2,0)), "A:B " + dist.toFixed(2), 0.03);
474
+ Gizmos.DrawLine(this._tempVec1, this._tempVec2, 0x00ff00, 0, false);
475
+
476
+ // const wp = this._manipulatorObject.worldPosition;
477
+ // Gizmos.DrawWireSphere(wp, this._initialScale.length() * dist / this._initialDistance, 0x00ff00, 0, false);
478
+ }
479
+ }
480
+
481
+ onDragUpdate() {
482
+ // At this point we've run both the other handlers, but their effects have been suppressed because they can't handle
483
+ // two events at the same time. They're basically providing us with two Object3D's and we can combine these here
484
+ // into a reasonable two-handed translation/rotation/scale.
485
+ // One approach:
486
+ // - position our control object on the center between the two pointer control objects
487
+
488
+ // TODO close grab needs to be handled differently because there we don't have a hit point -
489
+ // Hit point is just the center of the object
490
+ // So probably we should fix that close grab has a better hit point approximation (point on bounds?)
491
+
492
+ this.alignManipulator();
493
+
494
+ // apply (smoothed) to the gameObject
495
+ const lerpStrength = 30;
496
+ const lerpFactor = 1.0;
497
+
498
+ this._followObject.position.copy(this._manipulatorPosOffset);
499
+ this._followObject.quaternion.copy(this._manipulatorRotOffset);
500
+ this._followObject.scale.copy(this._manipulatorScaleOffset);
501
+
502
+ const draggedObject = this.gameObject;
503
+ const targetObject = this._followObject;
504
+
505
+ targetObject.updateMatrix();
506
+ targetObject.updateMatrixWorld(true);
507
+
508
+ const isSpatialInput = this._deviceMode === "tracked-pointer";
509
+ const keepRotation = isSpatialInput ? this.settings.xrKeepRotation : this.settings.keepRotation;
510
+
511
+ // TODO refactor to a common place
512
+ // apply constraints (position grid snap, rotation, ...)
513
+ if (this.settings.snapGridResolution > 0) {
514
+ const wp = this._followObject.worldPosition;
515
+ const snap = this.settings.snapGridResolution;
516
+ wp.x = Math.round(wp.x / snap) * snap;
517
+ wp.y = Math.round(wp.y / snap) * snap;
518
+ wp.z = Math.round(wp.z / snap) * snap;
519
+ this._followObject.worldPosition = wp;
520
+ this._followObject.updateMatrix();
521
+ }
522
+ if (keepRotation) {
523
+ this._followObject.worldQuaternion = this._followObjectStartWorldQuaternion;
524
+ this._followObject.updateMatrix();
525
+ }
526
+
527
+ // TODO refactor to a common place
528
+ // TODO should use unscaled time here // some test for lerp speed depending on distance
529
+ const t = Mathf.clamp01(this.context.time.deltaTime * lerpStrength * lerpFactor);// / (currentDist - 1 + 0.01));
530
+
531
+ const wp = draggedObject.worldPosition;
532
+ wp.lerp(targetObject.worldPosition, t);
533
+ draggedObject.worldPosition = wp;
534
+
535
+ const rot = draggedObject.worldQuaternion;
536
+ rot.slerp(targetObject.worldQuaternion, t);
537
+ draggedObject.worldQuaternion = rot;
538
+
539
+ const scl = draggedObject.worldScale;
540
+ scl.lerp(targetObject.worldScale, t);
541
+ draggedObject.worldScale = scl;
542
+ }
543
+
544
+ setTargetObject(obj: Object3D | null): void {
545
+ this.gameObject = obj as GameObject;
546
+ }
296
547
  }
297
548
 
549
+ /** Common interface for pointer handlers (single touch and multi touch) */
550
+ interface IDragHandler {
551
+ /** Used to determine if a drag has happened for this handler */
552
+ getTotalMovement?(): Vector3;
553
+ /** Target object can change mid-flight (e.g. in Duplicatable), handlers should react properly to that */
554
+ setTargetObject(obj: Object3D | null): void;
555
+
556
+ /** Prewarms the drag – can already move internal points around here but should not move the object itself */
557
+ collectMovementInfo?(): void;
558
+ onDragStart?(args: PointerEventData): void;
559
+ onDragEnd?(args: PointerEventData): void;
560
+ /** The target object is moved around */
561
+ onDragUpdate?(numberOfPointers: number): void;
562
+ }
298
563
 
564
+ /** Handles a single pointer on an object. DragPointerHandlers are created on pointer enter,
565
+ * help with determining if there's actually a drag, and then perform operations based on spatial pointer data.
566
+ */
567
+ class DragPointerHandler implements IDragHandler {
299
568
 
300
- class DragHelper {
569
+ /** Absolute movement of the pointer. Used for determining if a motion/drag is happening.
570
+ * This is in world units, so very small for screens (near-plane space change) */
571
+ getTotalMovement(): Vector3 { return this._totalMovement; }
572
+ get followObject(): GameObject { return this._followObject; }
573
+ get hitPointInLocalSpace(): Vector3 { return this._hitPointInLocalSpace; }
301
574
 
575
+ private context: Context;
576
+ private gameObject: GameObject;
577
+ private settings: DragControls;
578
+ private _lastRig: IGameObject | undefined = undefined;
579
+
580
+ /** This object is placed at the pivot of the dragged object, and parented to the control space. */
581
+ private _followObject: GameObject;
582
+ private _totalMovement: Vector3 = new Vector3();
583
+ /** Motion along the pointer ray. On screens this doesn't change. In XR it can be used to determine how much
584
+ * effort someone is putting into moving an object closer or further away. */
585
+ private _totalMovementAlongRayDirection: number = 0;
586
+ /** Distance between _followObject and its parent at grab start, in local space */
587
+ private _grabStartDistance: number = 0;
588
+ private _deviceMode!: XRTargetRayMode;
589
+ private _followObjectStartPosition: Vector3 = new Vector3();
590
+ private _followObjectStartQuaternion: Quaternion = new Quaternion();
591
+ private _followObjectStartWorldQuaternion: Quaternion = new Quaternion();
592
+ private _lastDragPosRigSpace: Vector3 | undefined;
593
+ private _tempVec: Vector3 = new Vector3();
594
+ private _tempMat: Matrix4 = new Matrix4();
595
+
596
+ private _hitPointInLocalSpace: Vector3 = new Vector3();
597
+ private _hitNormalInLocalSpace: Vector3 = new Vector3();
598
+ private _bottomCenter = new Vector3();
599
+ private _backCenter = new Vector3();
600
+ private _backBottomCenter = new Vector3();
601
+ private _bounds = new Box3();
602
+ private _dragPlane = new Plane(new Vector3(0, 1, 0));
603
+ private _draggedOverObject: Object3D | null = null;
604
+ private _draggedOverObjectLastSetUp: Object3D | null = null;
605
+ private _draggedOverObjectLastNormal: Vector3 = new Vector3();
606
+ private _draggedOverObjectDuration: number = 0;
607
+
608
+ /** Allows overriding which object is dragged while a drag is already ongoing. Used for example by Duplicatable */
609
+ setTargetObject(obj: Object3D | null) {
610
+ this.gameObject = obj as GameObject;
611
+ }
612
+
613
+ constructor(dragControls: DragControls, gameObject: GameObject) {
614
+ this.settings = dragControls;
615
+ this.context = dragControls.context;
616
+ this.gameObject = gameObject;
617
+ this._followObject = new Object3D() as GameObject;
618
+ }
619
+
620
+ recenter() {
621
+ if (!this._followObject.parent) {
622
+ console.warn("Error: space follow object doesn't have parent but recenter() is called. This is likely a bug");
623
+ return;
624
+ }
625
+
626
+ const p = this._followObject.parent as GameObject;
627
+
628
+ this.gameObject.add(this._followObject);
629
+ this._followObject.matrixAutoUpdate = false;
630
+
631
+ this._followObject.position.set(0, 0, 0);
632
+ this._followObject.quaternion.set(0, 0, 0, 1);
633
+ this._followObject.scale.set(1, 1, 1);
634
+
635
+ this._followObject.updateMatrix();
636
+ this._followObject.updateMatrixWorld(true);
637
+
638
+ p.attach(this._followObject);
639
+
640
+ this._followObjectStartPosition.copy(this._followObject.position);
641
+ this._followObjectStartQuaternion.copy(this._followObject.quaternion);
642
+ this._followObjectStartWorldQuaternion.copy(this._followObject.worldQuaternion);
643
+
644
+ this._followObject.updateMatrix();
645
+ this._followObject.updateMatrixWorld(true);
646
+
647
+ const hitPointWP = this._hitPointInLocalSpace.clone();
648
+ this.gameObject.localToWorld(hitPointWP);
649
+ this._grabStartDistance = hitPointWP.distanceTo(p.worldPosition);
650
+ const rig = NeedleXRSession.active?.rig?.gameObject;
651
+ const rigScale = rig?.worldScale.x || 1;
652
+ this._grabStartDistance /= rigScale;
653
+
654
+ this._totalMovementAlongRayDirection = 0;
655
+ this._lastDragPosRigSpace = undefined;
656
+
657
+ if (debug)
658
+ {
659
+ Gizmos.DrawLine(hitPointWP, p.worldPosition, 0x00ff00, 0.5, false);
660
+ Gizmos.DrawLabel(p.worldPosition.add(new Vector3(0,0.1,0)), this._grabStartDistance.toFixed(2), 0.03, 0.5);
661
+ }
662
+ }
663
+
664
+ onDragStart(args: PointerEventData) {
665
+
666
+ args.event.space.add(this._followObject);
667
+
668
+ // prepare for drag, we will start dragging after an object has been dragged for a few centimeters
669
+ this._lastDragPosRigSpace = undefined;
670
+
671
+ if (args.point && args.normal) {
672
+ this._hitPointInLocalSpace.copy(args.point);
673
+ this.gameObject.worldToLocal(this._hitPointInLocalSpace);
674
+ this._hitNormalInLocalSpace.copy(args.normal);
675
+ }
676
+ else if (args) {
677
+ // can happen for e.g. close grabs; we can assume/guess a good hit point and normal based on the object's bounds or so
678
+ // convert controller world position to local space instead and use that as hit point
679
+ const controller = args.event.space as GameObject;
680
+ const controllerWp = controller.worldPosition;
681
+ this.gameObject.worldToLocal(controllerWp);
682
+ this._hitPointInLocalSpace.copy(controllerWp);
683
+
684
+ const controllerUp = controller.worldUp;
685
+ this._tempMat.copy(this.gameObject.matrixWorld).invert();
686
+ controllerUp.transformDirection(this._tempMat);
687
+ this._hitNormalInLocalSpace.copy(controllerUp);
688
+ }
689
+
690
+ this.recenter();
691
+
692
+ this._totalMovement.set(0, 0, 0);
693
+ this._deviceMode = args.mode;
694
+
695
+
696
+ const dragSource = this._followObject.parent as IGameObject;
697
+ const rayDirection = dragSource.worldForward;
698
+
699
+ const isSpatialInput = this._deviceMode === "tracked-pointer";
700
+ const dragMode = isSpatialInput ? this.settings.xrDragMode : this.settings.dragMode;
701
+
702
+ // set up drag plane; we don't really know the normal yet but we can already set the point
703
+ const hitWP = this._hitPointInLocalSpace.clone();
704
+ this.gameObject.localToWorld(hitWP);
705
+
706
+ switch (dragMode) {
707
+ case DragMode.XZPlane:
708
+ const up = new Vector3(0,1,0);
709
+ if (this.gameObject.parent) {
710
+ // TODO in this case _dragPlane should be in parent space, not world space,
711
+ // otherwise dragging the parent and this object at the same time doesn't keep the plane constrained
712
+ up.transformDirection(this.gameObject.parent.matrixWorld.clone().invert());
713
+ }
714
+ this._dragPlane.setFromNormalAndCoplanarPoint(up, hitWP);
715
+ break;
716
+ case DragMode.HitNormal:
717
+ const hitNormal = this._hitNormalInLocalSpace.clone();
718
+ hitNormal.transformDirection(this.gameObject.matrixWorld);
719
+ this._dragPlane.setFromNormalAndCoplanarPoint(hitNormal, hitWP);
720
+ break;
721
+ case DragMode.Attached:
722
+ this._dragPlane.setFromNormalAndCoplanarPoint(rayDirection, hitWP);
723
+ break;
724
+ case DragMode.DynamicViewAngle:
725
+ const v0 = new Vector3(0, 1, 0);
726
+ const v1 = rayDirection;
727
+ const angle = v0.angleTo(v1);
728
+ const angleThreshold = 0.5;
729
+ if (angle > Math.PI / 2 + angleThreshold || angle < Math.PI / 2 - angleThreshold)
730
+ this._dragPlane.setFromNormalAndCoplanarPoint(new Vector3(0, 1, 0), hitWP);
731
+ else
732
+ this._dragPlane.setFromNormalAndCoplanarPoint(rayDirection, hitWP);
733
+ break;
734
+ }
735
+
736
+ // calculate bounding box and snapping points. We want to either snap the "back" point or the "bottom" point.
737
+ const bbox = new Box3();
738
+ const p = this.gameObject.parent;
739
+ const localP = this.gameObject.position.clone();
740
+ const localQ = this.gameObject.quaternion.clone();
741
+ const localS = this.gameObject.scale.clone();
742
+ if (p) p.remove(this.gameObject);
743
+ this.gameObject.position.set(0, 0, 0);
744
+ this.gameObject.quaternion.set(0, 0, 0, 1);
745
+ this.gameObject.scale.set(1, 1, 1);
746
+ bbox.setFromObject(this.gameObject);
747
+
748
+ // get front center point of the bbox. basically (0, 0, 1) in local space
749
+ const bboxCenter = new Vector3();
750
+ bbox.getCenter(bboxCenter);
751
+ const bboxSize = new Vector3();
752
+ bbox.getSize(bboxSize);
753
+
754
+ // attachment points for dragging
755
+ this._bottomCenter.copy(bboxCenter.clone().add(new Vector3(0, -bboxSize.y / 2, 0)));
756
+ this._backCenter.copy(bboxCenter.clone().add(new Vector3(0, 0, bboxSize.z / 2)));
757
+ this._backBottomCenter.copy(bboxCenter.clone().add(new Vector3(0, -bboxSize.y / 2, bboxSize.z / 2)));
758
+
759
+ this._bounds.copy(bbox);
760
+
761
+ // restore original transform
762
+ if (p) p.add(this.gameObject);
763
+ this.gameObject.position.copy(localP);
764
+ this.gameObject.quaternion.copy(localQ);
765
+ this.gameObject.scale.copy(localS);
766
+
767
+ // surface snapping
768
+ this._draggedOverObject = null;
769
+ this._draggedOverObjectLastSetUp = null;
770
+ this._draggedOverObjectLastNormal.set(0, 1, 0);
771
+ this._draggedOverObjectDuration = 0;
772
+ }
773
+
774
+ collectMovementInfo() {
775
+ // we're dragging - there is a controlling object
776
+ if (!this._followObject.parent) return;
777
+
778
+ // TODO This should all be handled properly per-pointer
779
+ // and we want to have a chance to react to multiple pointers being on the same object.
780
+ // some common stuff (calculating of movement offsets, etc) could be done by default
781
+ // and then the main thing to override is the actual movement of the object based on N _followObjects
782
+
783
+ const dragSource = this._followObject.parent as IGameObject;
784
+
785
+ // modify _followObject with constraints, e.g.
786
+ // - dragging on a plane, e.g. the floor (keeping the distance to the floor plane constant)
787
+ /* TODO fix jump on drag start
788
+ const p0 = this._followObject.parent as GameObject;
789
+ const ray = new Ray(p0.worldPosition, p0.worldForward.multiplyScalar(-1));
790
+ const p = new Vector3();
791
+ const t0 = ray.intersectPlane(new Plane(new Vector3(0, 1, 0)), p);
792
+ if (t0 !== null)
793
+ this._followObject.worldPosition = t0;
794
+ */
795
+
796
+ this._followObject.updateMatrix();
797
+ const dragPosRigSpace = dragSource.worldPosition;
798
+ const rig = NeedleXRSession.active?.rig?.gameObject;
799
+ if (rig)
800
+ rig.worldToLocal(dragPosRigSpace);
801
+
802
+ // sum up delta
803
+ // TODO We need to do all/most of these calculations in Rig Space instead of world space
804
+ // moving the rig while holding an object should not affect _rayDelta / _dragDelta
805
+ if (this._lastDragPosRigSpace === undefined || rig != this._lastRig) {
806
+ this._lastDragPosRigSpace = dragPosRigSpace.clone();
807
+ this._lastRig = rig;
808
+ }
809
+ this._tempVec.copy(dragPosRigSpace).sub(this._lastDragPosRigSpace);
810
+
811
+ const rayDirectionRigSpace = dragSource.worldForward;
812
+ if (rig) {
813
+ this._tempMat.copy(rig.matrixWorld).invert();
814
+ rayDirectionRigSpace.transformDirection(this._tempMat);
815
+ }
816
+ // sum up delta movement along ray
817
+ this._totalMovementAlongRayDirection += rayDirectionRigSpace.dot(this._tempVec);
818
+ this._tempVec.x = Math.abs(this._tempVec.x);
819
+ this._tempVec.y = Math.abs(this._tempVec.y);
820
+ this._tempVec.z = Math.abs(this._tempVec.z);
821
+
822
+ // sum up absolute total movement
823
+ this._totalMovement.add(this._tempVec);
824
+ this._lastDragPosRigSpace.copy(dragPosRigSpace);
825
+
826
+ if (debug) {
827
+ let wp = dragPosRigSpace;
828
+ // ray direction of the input source object
829
+ if (rig) {
830
+ wp = wp.clone();
831
+ wp.transformDirection(rig.matrixWorld);
832
+ }
833
+ Gizmos.DrawRay(wp, rayDirectionRigSpace, 0x0000ff);
834
+ }
835
+ }
836
+
837
+ onDragUpdate(numberOfPointers: number) {
838
+
839
+ // can only handle a single pointer
840
+ // if there's more, we defer to multi-touch drag handlers
841
+ if (numberOfPointers > 1) return;
842
+
843
+ const draggedObject = this.gameObject as IGameObject;
844
+ const dragSource = this._followObject.parent as IGameObject;
845
+ this._followObject.updateMatrix();
846
+ const dragSourceWP = dragSource.worldPosition;
847
+ const rayDirection = dragSource.worldForward;
848
+
849
+
850
+ // Actually move and rotate draggedObject
851
+ const isSpatialInput = this._deviceMode === "tracked-pointer";
852
+ const keepRotation = isSpatialInput ? this.settings.xrKeepRotation : this.settings.keepRotation;
853
+ const dragMode = isSpatialInput ? this.settings.xrDragMode : this.settings.dragMode;
854
+
855
+ const lerpStrength = 10;
856
+ // - keeping rotation constant during dragging
857
+ if (keepRotation) this._followObject.worldQuaternion = this._followObjectStartWorldQuaternion;
858
+ this._followObject.updateMatrix();
859
+ this._followObject.updateMatrixWorld(true);
860
+
861
+ // Acceleration for moving the object - move followObject along the ray distance by _totalMovementAlongRayDirection
862
+ let currentDist = 1.0;
863
+ let lerpFactor = 1.0;
864
+ if (this._deviceMode === "tracked-pointer" && this._grabStartDistance > 0.5) // hands and controllers, but not touches
865
+ {
866
+ const factor = 1 + this._totalMovementAlongRayDirection * (2 * this.settings.xrDistanceDragFactor);
867
+ currentDist = Math.max(0.0, factor);
868
+ currentDist = currentDist * currentDist * currentDist;
869
+ }
870
+ else if (this._grabStartDistance <= 0.5)
871
+ {
872
+ // TODO there's still a frame delay between dragged objects and the hand models
873
+ lerpFactor = 3.0;
874
+ }
875
+
876
+ // reset _followObject to its original position and rotation
877
+ this._followObject.position.copy(this._followObjectStartPosition);
878
+ if (!keepRotation)
879
+ this._followObject.quaternion.copy(this._followObjectStartQuaternion);
880
+
881
+ // TODO restore previous functionality:
882
+ // When distance dragging, the HIT POINT should move along the ray until it reaches the controller;
883
+ // NOT the pivot point of the dragged object. E.g. grabbing a large cube and pulling towards you should at most
884
+ // move the grabbed point to your head and not slap the cube in your head.
885
+ this._followObject.position.multiplyScalar(currentDist);
886
+ this._followObject.updateMatrix();
887
+
888
+ const ray = new Ray(dragSourceWP, rayDirection);
889
+
890
+ // Surface snapping.
891
+ // Feels quite weird in VR right now!
892
+ if (dragMode == DragMode.SnapToSurfaces) {
893
+ // Idea: Do a sphere cast if we're still in the proximity of the current draggedObject.
894
+ // This would allow dragging slightly out of the object's bounds and still continue snapping to it.
895
+ // Do a regular raycast (without the dragged object) to determine if we should change what is dragged onto.
896
+ const opts = new RaycastOptions();
897
+ opts.ignore = [draggedObject];
898
+ const hits = this.context.physics.raycastFromRay(ray, opts);
899
+
900
+ if (hits.length > 0) {
901
+ const hit = hits[0];
902
+ // if we're above the same surface for a specified time, adjust drag options:
903
+ // - set that surface as the drag "plane". We will follow that object's surface instead now (raycast onto only that)
904
+ // - if the drag plane is an object, we also want to
905
+ // - calculate an initial rotation offset matching what surface/face the user originally started the drag on
906
+ // - rotate the dragged object to match the surface normal
907
+ if (this._draggedOverObject === hit.object)
908
+ this._draggedOverObjectDuration += this.context.time.deltaTime;
909
+ else {
910
+ this._draggedOverObject = hit.object;
911
+ this._draggedOverObjectDuration = 0;
912
+ }
913
+
914
+ if (hit.face) {
915
+ // Adjust drag plane if we're dragging over a different object (for a certain amount of time)
916
+ // or if the surface normal changed
917
+ if (this._draggedOverObjectDuration > 0.15 &&
918
+ (this._draggedOverObjectLastSetUp !== this._draggedOverObject ||
919
+ this._draggedOverObjectLastNormal.dot(hit.face.normal) < 0.999999)
920
+ ) {
921
+ this._draggedOverObjectLastSetUp = this._draggedOverObject;
922
+ this._draggedOverObjectLastNormal.copy(hit.face.normal);
923
+
924
+ const center = new Vector3();
925
+ const size = new Vector3();
926
+
927
+ this._bounds.getCenter(center);
928
+ this._bounds.getSize(size);
929
+ center.sub(size.multiplyScalar(0.5).multiply(hit.face.normal));
930
+ this._hitPointInLocalSpace.copy(center);
931
+ this._hitNormalInLocalSpace.copy(hit.face.normal);
932
+
933
+ // ensure plane is far enough up that we don't drag into the surface
934
+ // Which offset we use here depends on the face normal direction we hit
935
+ // If we hit the bottom, we want to use the top, and vice versa
936
+ // To do this dynamically, we can find the intersection between our local bounds and the hit face normal (which is already in local space)
937
+ this._bounds.getCenter(center);
938
+ this._bounds.getSize(size);
939
+ center.add(size.multiplyScalar(0.5).multiply(hit.face.normal));
940
+
941
+ const offset = this._hitPointInLocalSpace.clone().add(center);
942
+ this._followObject.localToWorld(offset);
943
+ const offsetWP = this._followObject.worldPosition.sub(offset);
944
+
945
+ this._dragPlane.setFromNormalAndCoplanarPoint(hit.face.normal, hit.point.sub(offsetWP));
946
+ }
947
+ }
948
+ }
949
+ }
950
+
951
+ // Objects could also serve as "slots" for dragging other objects into. In that case, we don't want to snap to the surface,
952
+ // we want to snap to the pivot of that object. These dragged-over objects could also need to be invisible (a "slot")
953
+ // Raycast along the ray to the drag plane and move _followObject so that the grabbed point stays at the hit point
954
+ if (dragMode !== DragMode.Attached && ray.intersectPlane(this._dragPlane, this._tempVec)) {
955
+
956
+ this._followObject.worldPosition = this._tempVec;
957
+ this._followObject.updateMatrix();
958
+ this._followObject.updateMatrixWorld(true);
959
+
960
+ const newWP = this._hitPointInLocalSpace.clone();
961
+ this._followObject.localToWorld(newWP);
962
+
963
+ if (debug) {
964
+ Gizmos.DrawLine(newWP, this._tempVec, 0x00ffff, 0, false);
965
+ }
966
+
967
+ this._followObject.worldPosition = this._tempVec.multiplyScalar(2).sub(newWP);
968
+ this._followObject.updateMatrix();
969
+
970
+ /*
971
+ // TODO figure out nicer look rotation here
972
+ const normal = this._dragPlane.normal;
973
+ const lookPoint = normal.clone().multiplyScalar(1000).add(this._tempVec);
974
+ if (lookPoint) {
975
+ this._followObject.lookAt(lookPoint);
976
+ this._followObject.rotateX(Math.PI / 2);
977
+ }
978
+ */
979
+ this._followObject.updateMatrix();
980
+ }
981
+
982
+ // TODO refactor to a common place
983
+ // apply constraints (position grid snap, rotation, ...)
984
+ if (this.settings.snapGridResolution > 0) {
985
+ const wp = this._followObject.worldPosition;
986
+ const snap = this.settings.snapGridResolution;
987
+ wp.x = Math.round(wp.x / snap) * snap;
988
+ wp.y = Math.round(wp.y / snap) * snap;
989
+ wp.z = Math.round(wp.z / snap) * snap;
990
+ this._followObject.worldPosition = wp;
991
+ this._followObject.updateMatrix();
992
+ }
993
+ if (keepRotation) {
994
+ this._followObject.worldQuaternion = this._followObjectStartWorldQuaternion;
995
+ this._followObject.updateMatrix();
996
+ }
997
+
998
+ // TODO refactor to a common place
999
+ // TODO should use unscaled time here // some test for lerp speed depending on distance
1000
+ const t = Mathf.clamp01(this.context.time.deltaTime * lerpStrength * lerpFactor);// / (currentDist - 1 + 0.01));
1001
+
1002
+ const wp = draggedObject.worldPosition;
1003
+ wp.lerp(this._followObject.worldPosition, t);
1004
+ draggedObject.worldPosition = wp;
1005
+
1006
+ const rot = draggedObject.worldQuaternion;
1007
+ rot.slerp(this._followObject.worldQuaternion, t);
1008
+ draggedObject.worldQuaternion = rot;
1009
+
1010
+
1011
+ if (debug)
1012
+ {
1013
+ const hitPointWP = this._hitPointInLocalSpace.clone();
1014
+ draggedObject.localToWorld(hitPointWP);
1015
+ // draw grab attachment point and normal. They are in grabbed object space
1016
+ Gizmos.DrawSphere(hitPointWP, 0.02, 0xff0000);
1017
+ const hitNormalWP = this._hitNormalInLocalSpace.clone();
1018
+ hitNormalWP.applyQuaternion(rot);
1019
+ Gizmos.DrawRay(hitPointWP, hitNormalWP, 0xff0000);
1020
+
1021
+ // debug info
1022
+ Gizmos.DrawLabel(wp.add(new Vector3(0, 0.25, 0)),
1023
+ `Distance: ${this._totalMovement.length().toFixed(2)}\n
1024
+ Along Ray: ${this._totalMovementAlongRayDirection.toFixed(2)}\n
1025
+ Session: ${!!NeedleXRSession.active}\n
1026
+ Device: ${this._deviceMode}\n
1027
+ `,
1028
+ 0.03
1029
+ );
1030
+
1031
+ // draw bottom/back snap points
1032
+ const bottomCenter = this._bottomCenter.clone();
1033
+ const backCenter = this._backCenter.clone();
1034
+ const backBottomCenter = this._backBottomCenter.clone();
1035
+ draggedObject.localToWorld(bottomCenter);
1036
+ draggedObject.localToWorld(backCenter);
1037
+ draggedObject.localToWorld(backBottomCenter);
1038
+ Gizmos.DrawSphere(bottomCenter, 0.01, 0x00ff00, 0, false);
1039
+ Gizmos.DrawSphere(backCenter, 0.01, 0x0000ff, 0, false);
1040
+ Gizmos.DrawSphere(backBottomCenter, 0.01, 0xff00ff, 0, false);
1041
+ Gizmos.DrawLine(bottomCenter, backBottomCenter, 0x00ffff, 0, false);
1042
+ Gizmos.DrawLine(backBottomCenter, backCenter, 0x00ffff, 0, false);
1043
+ }
1044
+ }
1045
+
1046
+ onDragEnd(args: PointerEventData) {
1047
+ console.assert(this._followObject.parent === args.event.space, "Drag end: _followObject is not parented to the space object");
1048
+ this._followObject.removeFromParent();
1049
+ this._followObject.destroy();
1050
+ this._lastDragPosRigSpace = undefined;
1051
+ }
1052
+ }
1053
+
1054
+ /** Currently does _only_ provide visuals support for DragControls operations.
1055
+ * Previously it also provided the actual drag functionality, but that has been moved to DragControls for now.
1056
+ */
1057
+ class LegacyDragVisualsHelper {
1058
+
302
1059
  showGizmo: boolean = true;
303
1060
  useViewAngle: boolean = true;
304
1061
 
@@ -336,13 +1093,12 @@
336
1093
  constructor(camera: Camera) {
337
1094
  this._camera = camera;
338
1095
 
339
- const line = new Line(DragHelper.geometry);
1096
+ const line = new Line(LegacyDragVisualsHelper.geometry);
340
1097
  const mat = line.material as LineBasicMaterial;
341
1098
  mat.color = new Color(.4, .4, .4);
342
1099
  line.layers.set(2);
343
1100
  line.name = 'line';
344
1101
  line.scale.y = 1;
345
- // line.matrixAutoUpdate = false;
346
1102
  this._groundLine = line;
347
1103
 
348
1104
  const geometry = new SphereGeometry(.5, 22, 22);
@@ -357,13 +1113,12 @@
357
1113
  if (this._selected && context) {
358
1114
  for (const rb of this._rbs) {
359
1115
  rb.wakeUp();
360
- // if (!rb.smoothedVelocity) continue;
361
1116
  rb.setVelocity(0, 0, 0);
362
1117
  }
363
1118
  }
364
1119
 
365
1120
  if (this._selected) {
366
-
1121
+ // TODO move somewhere else
367
1122
  Avatar_POI.Remove(context, this._selected);
368
1123
  }
369
1124
 
@@ -385,6 +1140,8 @@
385
1140
  console.error("DragHelper: no context");
386
1141
  return;
387
1142
  }
1143
+
1144
+ // TODO move somewhere else
388
1145
  Avatar_POI.Add(context, this._selected, null);
389
1146
 
390
1147
  this._groundOffsetFactor = 0;
@@ -392,7 +1149,6 @@
392
1149
  this._groundOffset.set(0, 0, 0);
393
1150
  this._requireUpdateGroundPlane = true;
394
1151
 
395
- // this._rbs = GameObject.getComponentsInChildren(this._selected, Rigidbody);
396
1152
  this.onUpdateScreenSpacePlane();
397
1153
  }
398
1154
  }
@@ -402,6 +1158,16 @@
402
1158
  private _didDragOnGroundPlaneLastFrame: boolean = false;
403
1159
 
404
1160
  onUpdate(_context: Context) {
1161
+
1162
+ if (!this._selected) return;
1163
+
1164
+ const wp = getWorldPosition(this._selected);
1165
+ this.onUpdateWorldPosition(wp, this._groundPlanePoint, false);
1166
+ this.onUpdateGroundPlane();
1167
+ this._didDragOnGroundPlaneLastFrame = true;
1168
+ this._hasGroundPlane = true;
1169
+
1170
+ /*
405
1171
  if (!this._context) return;
406
1172
 
407
1173
  const mainKey: KeyCode = "Space";
@@ -488,6 +1254,7 @@
488
1254
  this.onDidUpdate();
489
1255
  }
490
1256
  }
1257
+ */
491
1258
  }
492
1259
 
493
1260
  private onUpdateWorldPosition(wp: Vector3, pointOnPlane: Vector3 | null, heightOnly: boolean) {
@@ -549,18 +1316,6 @@
549
1316
  this._groundOffset.copy(this._intersection).sub(wp);
550
1317
  }
551
1318
 
552
- private onDidUpdate() {
553
- // todo: when using instancing we need to mark the matrix to update
554
- InstancingUtil.markDirty(this._selected);
555
-
556
- for (const rb of this._rbs) {
557
- rb.wakeUp();
558
- rb.resetForcesAndTorques();
559
- // rb.setBodyFromGameObject({ x: 0, y: 0, z: 0 });
560
- rb.setAngularVelocity(0, 0, 0);
561
- }
562
- }
563
-
564
1319
  private contains(obj: Object3D, toSearch: Object3D): boolean {
565
1320
  if (obj === toSearch) return true;
566
1321
  if (obj.children) {
src/engine-components/Duplicatable.ts CHANGED
@@ -1,22 +1,24 @@
1
1
  import { Behaviour, GameObject } from "./Component.js";
2
- import { WebXRController, ControllerEvents } from "./webxr/WebXRController.js";
3
- import { DragControls, DragEvents } from "./DragControls.js";
4
- import { Interactable } from "./Interactable.js";
5
- import { Animation } from "./Animation.js";
2
+ import { DragControls } from "./DragControls.js";
6
3
  import { Vector3, Quaternion, Object3D } from "three";
7
4
  import { serializable } from "../engine/engine_serialization_decorator.js";
8
5
  import { InstantiateOptions } from "../engine/engine_gameobject.js";
6
+ import { IPointerEventHandler, ObjectRaycaster, PointerEventData } from "./api.js";
9
7
 
10
- export class Duplicatable extends Interactable {
8
+ export class Duplicatable extends Behaviour implements IPointerEventHandler {
11
9
 
10
+ /** Duplicates will be parented into the set object. If not defined, this GameObject will be used as parent. */
12
11
  @serializable(Object3D)
13
12
  parent: GameObject | null = null;
13
+
14
+ /** The object to be duplicated */
14
15
  @serializable(Object3D)
15
16
  object: GameObject | null = null;
16
17
 
17
18
  // limit max object spawn count per interval
18
19
  @serializable()
19
20
  limitCount = 10;
21
+
20
22
  @serializable()
21
23
  limitInterval = 60;
22
24
 
@@ -24,17 +26,7 @@
24
26
  private _startPosition: THREE.Vector3 | null = null;
25
27
  private _startQuaternion: THREE.Quaternion | null = null;
26
28
 
27
- awake(): void {
28
- // TODO: add support to not having to assign a object to clone
29
- // if(!this.object){
30
- // const opts = new InstantiateOptions();
31
- // opts.parent = this.gameObject;
32
- // opts.idProvider = InstantiateIdProvider.createFromString(this.guid);
33
- // const clone = GameObject.instantiate(this.gameObject, opts);
34
- // const duplicatable =
35
- // this.object = clone;
36
- // }
37
- // console.log(this, this.object);
29
+ start(): void {
38
30
  if (this.object) {
39
31
  if (this.object as any === this.gameObject) {
40
32
  console.error("Can not duplicate self");
@@ -48,32 +40,43 @@
48
40
  this._startQuaternion = this.object.quaternion?.clone() ?? new Quaternion(0, 0, 0, 1);
49
41
  }
50
42
 
51
- const drag = GameObject.getComponentInParent(this.gameObject, DragControls);
52
- if (drag) {
53
- drag.addDragEventListener(DragEvents.SelectStart, (_ctrls, args) => {
54
- if (this._currentCount >= this.limitCount) {
55
- args.attached = null;
56
- return;
57
- }
58
- const res = this.handleDuplication(args.selected);
59
- if (res) {
60
- console.assert(res !== args.selected, "Duplicated object is original");
61
- args.attached = res;
62
- }
63
- });
43
+ // legacy – DragControls was required for duplication and so often the component is still there; we work around that by disabling it here
44
+ const dragControls = this.gameObject.getComponent(DragControls);
45
+ if (dragControls) {
46
+ console.warn("Please remove DragControls from object with Duplicatable component, it's not needed anymore.");
47
+ dragControls.enabled = false;
64
48
  }
65
- else console.warn("Could no find drag controls in parent", this.name);
49
+
50
+ if (!this.gameObject.getComponentInParent(ObjectRaycaster))
51
+ this.gameObject.addNewComponent(ObjectRaycaster);
66
52
 
67
- WebXRController.addEventListener(ControllerEvents.SelectStart, (_controller: WebXRController, args: { selected: THREE.Object3D, grab: THREE.Object3D | GameObject | null }) => {
68
- if (this._currentCount >= this.limitCount) {
69
- args.grab = null;
70
- return;
53
+ this.cloneLimitIntervalFn();
54
+ }
55
+
56
+ private _forwardPointerEvents: Map<Object3D, DragControls> = new Map();
57
+
58
+ onPointerDown(args: PointerEventData) {
59
+ if (!this.object) return;
60
+ if (!this.context.connection.allowEditing) return;
61
+ if (args.button !== 0) return;
62
+
63
+ const res = this.handleDuplication();
64
+ if (res) {
65
+ const dragControls = GameObject.getComponent(res, DragControls);
66
+ if (!dragControls) console.warn("Duplicated object does not have DragControls");
67
+ else {
68
+ dragControls.onPointerDown(args);
69
+ this._forwardPointerEvents.set(args.event.space, dragControls);
71
70
  }
72
- const res = this.handleDuplication(args.selected);
73
- if (res) args.grab = res;
74
- });
71
+ }
72
+ }
75
73
 
76
- this.cloneLimitIntervalFn();
74
+ onPointerUp(args: PointerEventData) {
75
+ const dragControls = this._forwardPointerEvents.get(args.event.space);
76
+ if (dragControls) {
77
+ dragControls.onPointerUp(args);
78
+ this._forwardPointerEvents.delete(args.event.space);
79
+ }
77
80
  }
78
81
 
79
82
  private cloneLimitIntervalFn() {
@@ -86,62 +89,39 @@
86
89
  }, (this.limitInterval / this.limitCount) * 1000);
87
90
  }
88
91
 
89
- private handleDuplication(selected: THREE.Object3D): THREE.Object3D | null {
92
+ private handleDuplication(): THREE.Object3D | null {
93
+ if (!this.object) return null;
90
94
  if (this._currentCount >= this.limitCount) return null;
91
- if (!this.object) return null;
92
- if (selected === this.gameObject || this.handleMultiObject(selected)) {
95
+ if (this.object as any === this.gameObject) return null;
93
96
 
94
- if (this.object as any === this.gameObject) return null;
95
- this.object.visible = true;
97
+ this.object.visible = true;
96
98
 
97
- if (this._startPosition)
98
- this.object.position.copy(this._startPosition);
99
- if (this._startQuaternion)
100
- this.object.quaternion.copy(this._startQuaternion);
99
+ if (this._startPosition)
100
+ this.object.position.copy(this._startPosition);
101
+ if (this._startQuaternion)
102
+ this.object.quaternion.copy(this._startQuaternion);
101
103
 
102
- const opts = new InstantiateOptions();
103
- if (!this.parent) this.parent = this.gameObject.parent as GameObject;
104
- if (this.parent) {
105
- opts.parent = this.parent.guid ?? this.parent.userData?.guid;
106
- opts.keepWorldPosition = true;
107
- }
108
- opts.position = this.worldPosition;
109
- opts.rotation = this.worldQuaternion;
110
- opts.context = this.context;
111
- this._currentCount += 1;
104
+ const opts = new InstantiateOptions();
105
+ if (!this.parent) this.parent = this.gameObject.parent as GameObject;
106
+ if (this.parent) {
107
+ opts.parent = this.parent.guid ?? this.parent.userData?.guid;
108
+ opts.keepWorldPosition = true;
109
+ }
110
+ opts.position = this.worldPosition;
111
+ opts.rotation = this.worldQuaternion;
112
+ opts.context = this.context;
113
+ this._currentCount += 1;
112
114
 
113
- const newInstance = GameObject.instantiateSynced(this.object as GameObject, opts) as GameObject;
114
- console.assert(newInstance !== this.object, "Duplicated object is original");
115
- this.object.visible = false;
115
+ const newInstance = GameObject.instantiateSynced(this.object as GameObject, opts) as GameObject;
116
+ console.assert(newInstance !== this.object, "Duplicated object is original");
117
+ this.object.visible = false;
116
118
 
117
- // see if this fixes object being offset when duplicated and dragged - it looks like three clone has shared position/quaternion objects?
118
- if (this._startPosition)
119
- this.object.position.clone().copy(this._startPosition);
120
- if (this._startQuaternion)
121
- this.object.quaternion.clone().copy(this._startQuaternion);
119
+ // see if this fixes object being offset when duplicated and dragged - it looks like three clone has shared position/quaternion objects?
120
+ if (this._startPosition)
121
+ this.object.position.clone().copy(this._startPosition);
122
+ if (this._startQuaternion)
123
+ this.object.quaternion.clone().copy(this._startQuaternion);
122
124
 
123
- return newInstance;
124
- }
125
- return null;
125
+ return newInstance;
126
126
  }
127
-
128
- private handleMultiObject(selected: THREE.Object3D): boolean {
129
- const shouldSearchInChildren = this.gameObject.type === "Group" || this.gameObject.type === "Object3D";
130
- if (!shouldSearchInChildren) return false;
131
- return this.isInChildren(this.gameObject, selected);
132
- }
133
-
134
- private isInChildren(current: THREE.Object3D, search: THREE.Object3D): boolean {
135
- if (!current) return false;
136
- if (current === search) return true;
137
- if (current.children) {
138
- for (const child of current.children) {
139
- if (this.isInChildren(child, search)) {
140
- return true;
141
- }
142
- }
143
- }
144
- return false;
145
- }
146
-
147
127
  }
src/engine/engine_context.ts CHANGED
@@ -36,6 +36,7 @@
36
36
  import { isLocalNetwork } from './engine_networking_utils.js';
37
37
  import { WaitForPromise } from './engine_coroutine.js';
38
38
  import { invokeLifecycleFunctions } from './engine_lifecycle_functions_internal.js';
39
+ import type { INeedleXRSessionEventReceiver } from './engine_xr.js';
39
40
 
40
41
 
41
42
  const debug = utils.getParam("debugcontext");
@@ -101,11 +102,6 @@
101
102
  Undefined = -1,
102
103
  }
103
104
 
104
- export enum XRSessionMode {
105
- ImmersiveVR = "immersive-vr",
106
- ImmersiveAR = "immersive-ar",
107
- }
108
-
109
105
  /** threejs callback event signature */
110
106
  export declare type OnRenderCallback = (renderer: WebGLRenderer, scene: Scene, camera: Camera, geometry: BufferGeometry, material: Material, group: Group) => void
111
107
 
@@ -213,6 +209,7 @@
213
209
  private _boundingClientRectFrame: number = -1;
214
210
  private _boundingClientRect: DOMRect | null = null;
215
211
  private _domX; private _domY;
212
+ /** update bounding rects + domX, domY */
216
213
  private calculateBoundingClientRect() {
217
214
  // workaround for mozilla webXR viewer
218
215
  if (this.isInAR) {
@@ -227,30 +224,40 @@
227
224
  this._domY = this._boundingClientRect.y;
228
225
  }
229
226
 
227
+ /** The width of the `<needle-engine>` element on the website */
230
228
  get domWidth(): number {
231
229
  // for mozilla XR
232
230
  if (this.isInAR) return window.innerWidth;
233
231
  return this.domElement.clientWidth;
234
232
  }
233
+ /** The height of the `<needle-engine>` element on the website */
235
234
  get domHeight(): number {
236
235
  // for mozilla XR
237
236
  if (this.isInAR) return window.innerHeight;
238
237
  return this.domElement.clientHeight;
239
238
  }
239
+ /** the X position of the Needle Engine element on the website */
240
240
  get domX(): number {
241
241
  this.calculateBoundingClientRect();
242
242
  return this._domX;
243
243
  }
244
+ /** the Y position of the Needlee Engine element on the website */
244
245
  get domY(): number {
245
246
  this.calculateBoundingClientRect();
246
247
  return this._domY;
247
248
  }
248
249
  get isInXR() { return this.renderer?.xr?.isPresenting || false; }
250
+ // TODO: can we get the session mode from the xr session without relying on the initiator to set it?
249
251
  xrSessionMode: XRSessionMode | undefined = undefined;
250
- get isInVR() { return this.xrSessionMode === XRSessionMode.ImmersiveVR; }
251
- get isInAR() { return this.xrSessionMode === XRSessionMode.ImmersiveAR; }
252
+ get isInVR() { return this.xrSessionMode === "immersive-vr"; }
253
+ get isInAR() { return this.xrSessionMode === "immersive-ar"; }
254
+ /** access the raw `XRSession` object (shorthand for `context.renderer.xr.getSession()`). For more control use `NeedleXRSession.active` */
252
255
  get xrSession() { return this.renderer?.xr?.getSession(); }
256
+ /** @returns the latest XRFrame (if a XRSession is currently active)
257
+ * @link https://developer.mozilla.org/en-US/docs/Web/API/XRFrame
258
+ */
253
259
  get xrFrame() { return this._xrFrame }
260
+ /** @returns the current WebXR camera (shorthand for `context.renderer.xr.getCamera()`) */
254
261
  get xrCamera(): WebXRArrayCamera | undefined { return this.renderer?.xr?.getCamera(); }
255
262
  private _xrFrame: XRFrame | null = null;
256
263
  get arOverlayElement(): HTMLElement {
@@ -270,17 +277,30 @@
270
277
  composer: EffectComposer | null = null;
271
278
 
272
279
  // all scripts
273
- scripts: IComponent[] = [];
274
- scripts_pausedChanged: IComponent[] = [];
280
+ readonly scripts: IComponent[] = [];
281
+ readonly scripts_pausedChanged: IComponent[] = [];
275
282
  // scripts with update event
276
- scripts_earlyUpdate: IComponent[] = [];
277
- scripts_update: IComponent[] = [];
278
- scripts_lateUpdate: IComponent[] = [];
279
- scripts_onBeforeRender: IComponent[] = [];
280
- scripts_onAfterRender: IComponent[] = [];
281
- scripts_WithCorroutines: IComponent[] = [];
282
- coroutines: { [FrameEvent: number]: Array<CoroutineData> } = {}
283
+ readonly scripts_earlyUpdate: IComponent[] = [];
284
+ readonly scripts_update: IComponent[] = [];
285
+ readonly scripts_lateUpdate: IComponent[] = [];
286
+ readonly scripts_onBeforeRender: IComponent[] = [];
287
+ readonly scripts_onAfterRender: IComponent[] = [];
288
+ readonly scripts_WithCorroutines: IComponent[] = [];
289
+ readonly scripts_immersive_vr: INeedleXRSessionEventReceiver[] = [];
290
+ readonly scripts_immersive_ar: INeedleXRSessionEventReceiver[] = [];
291
+ readonly coroutines: { [FrameEvent: number]: Array<CoroutineData> } = {}
283
292
 
293
+ readonly post_setup_callbacks: Function[] = [];
294
+ readonly pre_update_callbacks: Function[] = [];
295
+ readonly pre_render_callbacks: Array<(frame: XRFrame | null) => void> = [];
296
+ readonly post_render_callbacks: Function[] = [];
297
+
298
+ readonly new_scripts: IComponent[] = [];
299
+ readonly new_script_start: IComponent[] = [];
300
+ readonly new_scripts_pre_setup_callbacks: Function[] = [];
301
+ readonly new_scripts_post_setup_callbacks: Function[] = [];
302
+ readonly new_scripts_xr: INeedleXRSessionEventReceiver[] = [];
303
+
284
304
  mainCameraComponent: ICamera | undefined;
285
305
 
286
306
  private _camera: Camera | null = null;
@@ -300,20 +320,13 @@
300
320
  this._camera = cam;
301
321
  }
302
322
 
303
- post_setup_callbacks: Function[] = [];
304
- pre_update_callbacks: Function[] = [];
305
- pre_render_callbacks: Function[] = [];
306
- post_render_callbacks: Function[] = [];
307
-
308
- new_scripts: IComponent[] = [];
309
- new_script_start: IComponent[] = [];
310
- new_scripts_pre_setup_callbacks: Function[] = [];
311
- new_scripts_post_setup_callbacks: Function[] = [];
312
-
313
323
  application: Application;
324
+ /** access timings (current frame number, deltaTime, timeScale, ...) */
314
325
  time: Time;
315
326
  input: Input;
327
+ /** access physics related methods (e.g. raycasting). To access the phyiscs engine use `context.physics.engine` */
316
328
  physics: Physics;
329
+ /** access networking methods (use it to send or listen to messages or join a networking backend) */
317
330
  connection: NetworkConnection;
318
331
  /**
319
332
  * @deprecated AssetDataBase is deprecated
@@ -412,6 +425,8 @@
412
425
  this.renderer.outputColorSpace = SRGBColorSpace;
413
426
  // https://github.com/mrdoob/three.js/pull/25556
414
427
  this.renderer.useLegacyLights = false;
428
+
429
+ this.input.bindEvents();
415
430
  }
416
431
 
417
432
 
@@ -423,10 +438,13 @@
423
438
 
424
439
  private _disposeCallbacks: Function[] = [];
425
440
 
426
- // private _requestSizeUpdate : boolean = false;
441
+
442
+ /** will request a renderer size update the next render call (will call updateSize the next update) */
443
+ requestSizeUpdate() { this._sizeChanged = true; }
427
444
 
428
- updateSize() {
429
- if (!this.isManagedExternally && this.renderer.xr?.isPresenting === false) {
445
+ /** update the renderer and canvas size */
446
+ updateSize(force: boolean = false) {
447
+ if (force || (!this.isManagedExternally && this.renderer.xr?.isPresenting === false)) {
430
448
  this._sizeChanged = false;
431
449
  const scaleFactor = this.resolutionScaleFactor;
432
450
  const width = this.domWidth * scaleFactor;
@@ -530,11 +548,11 @@
530
548
  if (this.renderer) {
531
549
  this.renderer.setClearAlpha(0);
532
550
  this.renderer.clear();
551
+ if (!this.isManagedExternally) {
552
+ if (debug) console.log("Disposing renderer");
553
+ this.renderer.dispose();
554
+ }
533
555
  }
534
- if (!this.isManagedExternally) {
535
- if(debug) console.log("Disposing renderer");
536
- this.renderer.dispose();
537
- }
538
556
  this.scene = null!;
539
557
  this.renderer = null!;
540
558
  this.input.dispose();
@@ -552,6 +570,10 @@
552
570
  this._isCreated = false;
553
571
  ContextRegistry.dispatchCallback(ContextEvent.ContextDestroyed, this);
554
572
  ContextRegistry.unregister(this);
573
+ if (Context.Current === this) {
574
+ //@ts-ignore
575
+ Context.Current = null;
576
+ }
555
577
  }
556
578
 
557
579
  registerCoroutineUpdate(script: IComponent, coroutine: Generator, evt: FrameEvent): Generator {
@@ -810,6 +832,8 @@
810
832
  }
811
833
  }
812
834
 
835
+ this.input.bindEvents();
836
+
813
837
  Context.Current = this;
814
838
  looputils.processNewScripts(this);
815
839
 
@@ -852,7 +876,7 @@
852
876
  this._dispatchReadyAfterFrame = true;
853
877
  const res = ContextRegistry.dispatchCallback(ContextEvent.ContextCreated, this, { files: loadedFiles });
854
878
  if (res) {
855
- if("internalSetLoadingMessage" in this.domElement && typeof this.domElement.internalSetLoadingMessage === "function")
879
+ if ("internalSetLoadingMessage" in this.domElement && typeof this.domElement.internalSetLoadingMessage === "function")
856
880
  this.domElement?.internalSetLoadingMessage("finish loading");
857
881
  await res;
858
882
  }
@@ -1007,7 +1031,11 @@
1007
1031
 
1008
1032
  private internalOnBeforeRender(timestamp: DOMHighResTimeStamp, frame: XRFrame | null) {
1009
1033
 
1034
+ const sessionStarted = frame !== null && this._xrFrame === null;
1010
1035
  this._xrFrame = frame;
1036
+ if (sessionStarted) {
1037
+ this.domElement.dispatchEvent(new CustomEvent("xr-session-started", { detail: { context: this, session: this.xrSession, frame: frame } }));
1038
+ }
1011
1039
 
1012
1040
  this._currentFrameEvent = FrameEvent.Undefined;
1013
1041
 
@@ -1128,7 +1156,7 @@
1128
1156
 
1129
1157
  if (this.pre_render_callbacks) {
1130
1158
  for (const i in this.pre_render_callbacks) {
1131
- this.pre_render_callbacks[i]();
1159
+ this.pre_render_callbacks[i](frame);
1132
1160
  }
1133
1161
  }
1134
1162
 
@@ -1206,8 +1234,8 @@
1206
1234
  }
1207
1235
  this._isRendering = true;
1208
1236
  this.renderRequiredTextures();
1209
-
1210
1237
 
1238
+
1211
1239
  if (this.composer && !this.isInXR) {
1212
1240
  this.composer.render(this.time.deltaTime);
1213
1241
  }
src/engine/engine_create_objects.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { PlaneGeometry, MeshBasicMaterial, DoubleSide, Mesh, Material, MeshStandardMaterial, BoxGeometry, SphereGeometry, ColorRepresentation } from "three"
2
+ import { Vec3 } from "./engine_types.js";
2
3
 
3
4
  export enum PrimitiveType {
4
5
  Quad = 0,
@@ -9,6 +10,10 @@
9
10
  export type ObjectOptions = {
10
11
  name?: string,
11
12
  material?: Material,
13
+ position?: Vec3,
14
+ /** euler */
15
+ rotation?: Vec3,
16
+ scale?: Vec3,
12
17
  }
13
18
 
14
19
  export class ObjectUtils {
@@ -35,6 +40,12 @@
35
40
  }
36
41
  if (opts?.name)
37
42
  obj.name = opts.name;
43
+ if (opts?.position)
44
+ obj.position.set(opts.position.x, opts.position.y, opts.position.z);
45
+ if (opts?.rotation)
46
+ obj.rotation.set(opts.rotation.x, opts.rotation.y, opts.rotation.z);
47
+ if (opts?.scale)
48
+ obj.scale.set(opts.scale.x, opts.scale.y, opts.scale.z);
38
49
  return obj;
39
50
  }
40
51
  }
src/engine/engine_element_loading.ts CHANGED
@@ -233,7 +233,7 @@
233
233
  const maxWidth = 30;
234
234
  loadingBarContainer.style.display = "flex";
235
235
  loadingBarContainer.style.width = maxWidth + "%";
236
- loadingBarContainer.style.height = "2px";
236
+ loadingBarContainer.style.height = "3px";
237
237
  if (loadingStyle === "light")
238
238
  loadingBarContainer.style.backgroundColor = "rgba(0,0,0,.2)"
239
239
  else
@@ -247,6 +247,15 @@
247
247
  logo.style.marginBottom = "20px";
248
248
  logo.style.userSelect = "none";
249
249
  logo.style.objectFit = "contain";
250
+ if (!hasCommercialLicense()) {
251
+ logo.style.transition = "transform 1s ease-in-out, opacity 1s ease-in-out";
252
+ logo.style.transform = "translateY(10px)";
253
+ logo.style.opacity = "1";
254
+ setTimeout(() => {
255
+ logo.style.transform = "translateY(0px)";
256
+ logo.style.opacity = "1";
257
+ }, 1);
258
+ }
250
259
  logo.src = logoSVG;
251
260
  let isUsingCustomLogo = false;
252
261
  if (hasLicense && this._element) {
@@ -323,6 +332,16 @@
323
332
  // if it's the case then we don't need to perform a runtime check
324
333
  if (commercialLicense) return;
325
334
 
335
+ // If we don't have a commercial license, then we need to display our message
336
+ if (debugLicense) console.log("Loading UI has commercial license?", commercialLicense);
337
+ const nonCommercialContainer = document.createElement("div");
338
+ nonCommercialContainer.style.paddingTop = ".6em";
339
+ nonCommercialContainer.style.fontSize = ".8em";
340
+ nonCommercialContainer.style.textTransform = "uppercase";
341
+ nonCommercialContainer.innerText = "non commercial";
342
+ nonCommercialContainer.style.opacity = "0";
343
+ loadingElement.appendChild(nonCommercialContainer);
344
+
326
345
  // Use the runtime license check
327
346
  if (runtimeLicenseCheckPromise) {
328
347
  if (debugLicense) console.log("Waiting for runtime license check");
@@ -330,13 +349,7 @@
330
349
  commercialLicense = hasCommercialLicense();
331
350
  }
332
351
  if (commercialLicense) return;
333
-
334
- // If we don't have a commercial license, then we need to display our message
335
- if (debugLicense) console.log("Loading UI has commercial license?", commercialLicense);
336
- const nonCommercialContainer = document.createElement("div");
337
- nonCommercialContainer.style.paddingTop = ".6em";
338
- nonCommercialContainer.style.fontSize = ".8em";
339
- nonCommercialContainer.innerText = "NON COMMERCIAL";
340
- loadingElement.appendChild(nonCommercialContainer);
352
+ nonCommercialContainer.style.transition = "opacity .5s ease-in-out";
353
+ nonCommercialContainer.style.opacity = "1";
341
354
  }
342
355
  }
src/engine/engine_element_overlay.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  private _createdAROnlyElements: Array<any> = [];
17
17
  private _reparentedObjects: Array<{ el: Element, previousParent: HTMLElement | null }> = [];
18
18
  private contentElement: HTMLElement | null = null;
19
+ private originalDomOverlayParent: ParentNode | null = null;
19
20
 
20
21
  requestEndAR = () => {
21
22
  this.onRequestedEndAR();
@@ -34,6 +35,22 @@
34
35
  this._reparentedObjects.push({ el: el, previousParent: el.parentElement });
35
36
  this.arContainer?.appendChild(el);
36
37
  }
38
+
39
+ if(overlayContainer) {
40
+ this.originalDomOverlayParent = overlayContainer.parentNode;
41
+ if (this.originalDomOverlayParent)
42
+ {
43
+ console.log("Reparent DOM Overlay to body", overlayContainer, overlayContainer.style.display);
44
+ // mozilla webxr does hide elements on session start
45
+ // this is only necessary if we generated the overlay element
46
+ overlayContainer.style.display = "";
47
+ overlayContainer.style.visibility = "";
48
+ document.body.appendChild(overlayContainer);
49
+ }
50
+ }
51
+ else {
52
+ console.warn("WebXRViewer: No DOM Overlay found");
53
+ }
37
54
  }
38
55
  this.ensureQuitARButton(this.arContainer);
39
56
  }
src/engine/engine_element.ts CHANGED
@@ -143,12 +143,15 @@
143
143
  }
144
144
  :host .quit-ar-button {
145
145
  position: absolute;
146
- top: 40px;
146
+ // top: env(titlebar-area-y); /** this doesnt work **/
147
+ top: 60px; /** camera access needs a bit more space **/
147
148
  right: 20px;
148
149
  z-index: 9999;
149
150
  }
150
151
  </style>
151
- <canvas></canvas>
152
+ <div> <!-- this wrapper is necessary for WebXR https://github.com/meta-quest/immersive-web-emulator/issues/55 -->
153
+ <canvas></canvas>
154
+ </div>
152
155
  <div class="content">
153
156
  <slot class="overlay-content"></slot>
154
157
  </div>
@@ -167,6 +170,7 @@
167
170
  console.log("<needle-engine> connected");
168
171
  }
169
172
 
173
+ this.addEventListener("xr-session-started", this.onXRSessionStarted);
170
174
  this.onSetupDesktop();
171
175
 
172
176
  if (!this.getAttribute("src")) {
@@ -196,6 +200,8 @@
196
200
  }
197
201
 
198
202
  disconnectedCallback() {
203
+ this.removeEventListener("xr-session-started", this.onXRSessionStarted);
204
+
199
205
  this._didFullyLoad = false;
200
206
  const keepAlive = this.getAttribute("keep-alive");
201
207
  const dispose = keepAlive == undefined || (keepAlive?.length > 0 && keepAlive !== "true" && keepAlive !== "1");
@@ -384,6 +390,23 @@
384
390
  }));
385
391
  }
386
392
 
393
+ private onXRSessionStarted = () => {
394
+ const xrSessionMode = this.context.xrSessionMode;
395
+ if (xrSessionMode === "immersive-ar")
396
+ this.onEnterAR(this.context.xrSession!);
397
+ else if (xrSessionMode === "immersive-vr")
398
+ this.onEnterVR(this.context.xrSession!);
399
+
400
+ // handle session end:
401
+ this.context.xrSession?.addEventListener("end", () => {
402
+ this.dispatchEvent(new CustomEvent("xr-session-ended", { detail: { session: this.context.xrSession, context: this._context, sessionMode: xrSessionMode } }));
403
+ if (xrSessionMode === "immersive-ar")
404
+ this.onExitAR(this.context.xrSession!);
405
+ else if (xrSessionMode === "immersive-vr")
406
+ this.onExitVR(this.context.xrSession!);
407
+ });
408
+ };
409
+
387
410
  /** called by the context when the first frame has been rendered */
388
411
  private onReady = () => this._loadingView?.onLoadingFinished();
389
412
  private onError = () => this._loadingView?.setMessage("Loading failed!");
@@ -474,8 +497,10 @@
474
497
  return null;
475
498
  }
476
499
 
477
- onEnterAR(session: XRSession, overlayContainer: HTMLElement) {
500
+ onEnterAR(session: XRSession) {
478
501
  this.onSetupAR();
502
+ const overlayContainer = this.getAROverlayContainer();
503
+ console.log("onEnterAR", session, overlayContainer);
479
504
  this._overlay_ar.onBegin(this._context!, overlayContainer, session);
480
505
  this.dispatchEvent(new CustomEvent("enter-ar", { detail: { session: session, context: this._context, htmlContainer: this._overlay_ar?.ARContainer } }));
481
506
  }
src/engine/engine_gizmos.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  import { getParam } from './engine_utils.js';
7
7
  import { type Options } from 'three-mesh-ui/build/types/core/elements/MeshUIBaseElement.js';
8
8
  import { isDestroyed } from './engine_gameobject.js';
9
+ import { NeedleXRSession } from './engine_xr.js';
9
10
 
10
11
  const _tmp = new Vector3();
11
12
  const _tmp2 = new Vector3();
@@ -21,6 +22,15 @@
21
22
 
22
23
  export class Gizmos {
23
24
 
25
+ /**
26
+ * Allow creating gizmos
27
+ * If disabled then no gizmos will be added to the scene anymore
28
+ */
29
+ static enabled = true;
30
+
31
+ /**
32
+ * Returns true if a given object is a gizmo
33
+ */
24
34
  static isGizmo(obj: Object3D) {
25
35
  return obj[$cacheSymbol] !== undefined;
26
36
  }
@@ -29,10 +39,12 @@
29
39
  * Draw a label in the scene or attached to an object (if a parent is provided)
30
40
  * @returns a handle to the label that can be used to change the text
31
41
  */
32
- static DrawLabel(position: Vec3, text: string, size: number = .1, duration: number = 9999, color?: ColorRepresentation, backgroundColor?: ColorRepresentation | ColorWithAlpha, parent?: Object3D,) {
42
+ static DrawLabel(position: Vec3, text: string, size: number = .1, duration: number = 0, color?: ColorRepresentation, backgroundColor?: ColorRepresentation | ColorWithAlpha, parent?: Object3D,) {
43
+ if (!Gizmos.enabled) return null;
33
44
  if (!color) color = defaultColor;
34
- const element = Internal.getTextLabel(duration, text, size, color, backgroundColor);
35
- if (parent instanceof Object3D) parent.add(element);
45
+ const rigScale = NeedleXRSession.active?.rigScale ?? 1;
46
+ const element = Internal.getTextLabel(duration, text, size * rigScale, color, backgroundColor);
47
+ if (parent instanceof Object3D) parent.add(element as any);
36
48
  element.position.x = position.x;
37
49
  element.position.y = position.y;
38
50
  element.position.z = position.z;
@@ -40,6 +52,7 @@
40
52
  }
41
53
 
42
54
  static DrawRay(origin: Vec3, dir: Vec3, color: ColorRepresentation = defaultColor, duration: number = 0, depthTest: boolean = true) {
55
+ if (!Gizmos.enabled) return;
43
56
  const obj = Internal.getLine(duration);
44
57
  const positions = obj.geometry.getAttribute("position");
45
58
  positions.setXYZ(0, origin.x, origin.y, origin.z);
@@ -52,6 +65,7 @@
52
65
  }
53
66
 
54
67
  static DrawDirection(pt: Vec3, direction: Vec3 | Vec4, color: ColorRepresentation = defaultColor, duration: number = 0, depthTest: boolean = true, lengthFactor: number = 1) {
68
+ if (!Gizmos.enabled) return;
55
69
  const obj = Internal.getLine(duration);
56
70
  const positions = obj.geometry.getAttribute("position");
57
71
  positions.setXYZ(0, pt.x, pt.y, pt.z);
@@ -73,8 +87,8 @@
73
87
  }
74
88
 
75
89
  static DrawLine(pt0: Vec3, pt1: Vec3, color: ColorRepresentation = defaultColor, duration: number = 0, depthTest: boolean = true) {
90
+ if (!Gizmos.enabled) return;
76
91
  const obj = Internal.getLine(duration);
77
-
78
92
  const positions = obj.geometry.getAttribute("position");
79
93
  positions.setXYZ(0, pt0.x, pt0.y, pt0.z);
80
94
  positions.setXYZ(1, pt1.x, pt1.y, pt1.z);
@@ -85,6 +99,7 @@
85
99
  }
86
100
 
87
101
  static DrawWireSphere(center: Vec3, radius: number, color: ColorRepresentation = defaultColor, duration: number = 0, depthTest: boolean = true) {
102
+ if (!Gizmos.enabled) return;
88
103
  const obj = Internal.getSphere(radius, duration, true);
89
104
  setWorldPositionXYZ(obj, center.x, center.y, center.z);
90
105
  obj.material["color"].set(color);
@@ -93,6 +108,7 @@
93
108
  }
94
109
 
95
110
  static DrawSphere(center: Vec3, radius: number, color: ColorRepresentation = defaultColor, duration: number = 0, depthTest: boolean = true) {
111
+ if (!Gizmos.enabled) return;
96
112
  const obj = Internal.getSphere(radius, duration, false);
97
113
  setWorldPositionXYZ(obj, center.x, center.y, center.z);
98
114
  obj.material["color"].set(color);
@@ -101,6 +117,7 @@
101
117
  }
102
118
 
103
119
  static DrawWireBox(center: Vec3, size: Vec3, color: ColorRepresentation = defaultColor, duration: number = 0, depthTest: boolean = true) {
120
+ if (!Gizmos.enabled) return;
104
121
  const obj = Internal.getBox(duration);
105
122
  obj.position.set(center.x, center.y, center.z);
106
123
  obj.scale.set(size.x, size.y, size.z);
@@ -111,6 +128,7 @@
111
128
  }
112
129
 
113
130
  static DrawWireBox3(box: Box3, color: ColorRepresentation = defaultColor, duration: number = 0, depthTest: boolean = true) {
131
+ if (!Gizmos.enabled) return;
114
132
  const obj = Internal.getBox(duration);
115
133
  obj.position.copy(box.getCenter(_tmp));
116
134
  obj.scale.copy(box.getSize(_tmp));
@@ -122,6 +140,7 @@
122
140
 
123
141
  private static _up = new Vector3(0, 1, 0);
124
142
  static DrawArrow(pt0: Vec3, pt1: Vec3, color: ColorRepresentation = defaultColor, duration: number = 0, depthTest: boolean = true, wireframe: boolean = false) {
143
+ if (!Gizmos.enabled) return;
125
144
  const obj = Internal.getArrowHead(duration);
126
145
  obj.position.set(pt1.x, pt1.y, pt1.z);
127
146
  obj.quaternion.setFromUnitVectors(this._up.set(0, 1, 0), _tmp.set(pt1.x, pt1.y, pt1.z).sub(_tmp2.set(pt0.x, pt0.y, pt0.z)).normalize());
@@ -194,6 +213,7 @@
194
213
  textContent: text,
195
214
  borderRadius: 1 * size,
196
215
  padding: 1 * size,
216
+ whiteSpace: 'pre',
197
217
  };
198
218
 
199
219
  if (!element) {
@@ -201,7 +221,7 @@
201
221
  const global = this;
202
222
  const labelHandle = element as LabelHandle & Text;
203
223
  labelHandle.setText = function (str: string) {
204
- this.set({ textContent: str, whiteSpace: 'pre' });
224
+ this.set({ textContent: str });
205
225
  global.tmuiNeedsUpdate = true;
206
226
  };
207
227
  }
@@ -211,9 +231,8 @@
211
231
  // handle.setText(text);
212
232
  }
213
233
  this.tmuiNeedsUpdate = true;
214
- element.layers.disableAll();
215
- element.layers.enable(2);
216
- this.registerTimedObject(Context.Current, element, duration, this.textLabelCache);
234
+ element.layers.enableAll();
235
+ this.registerTimedObject(Context.Current, element as any, duration, this.textLabelCache as any);
217
236
  return element as Text & LabelHandle;
218
237
  }
219
238
 
@@ -269,20 +288,41 @@
269
288
  private static textLabelCache: Array<Text> = [];
270
289
 
271
290
  private static registerTimedObject(context: Context, object: Object3D, duration: number, cache: Array<Object3D>) {
272
- if (!this.contextPostRenderCallbacks.get(context)) {
291
+ const beforeRender = this.contextBeforeRenderCallbacks.get(context);
292
+ const postRender = this.contextPostRenderCallbacks.get(context);
293
+
294
+ if (!beforeRender) {
295
+ const cb = () => { this.onBeforeRender(context, this.timedObjectsBuffer) };
296
+ this.contextBeforeRenderCallbacks.set(context, cb);
297
+ context.pre_render_callbacks.push(cb);
298
+ }
299
+ // make sure gizmo pre render is the last one being called
300
+ else if (context.pre_render_callbacks[context.pre_render_callbacks.length - 1] !== beforeRender) {
301
+ const index = context.pre_render_callbacks.indexOf(beforeRender);
302
+ if (index >= 0) {
303
+ context.pre_render_callbacks.splice(index, 1);
304
+ }
305
+ context.pre_render_callbacks.push(beforeRender);
306
+ }
307
+
308
+ if (!postRender) {
273
309
  const cb = () => { this.onPostRender(context, this.timedObjectsBuffer, this.timesBuffer) };
274
310
  this.contextPostRenderCallbacks.set(context, cb);
275
311
  context.post_render_callbacks.push(cb);
276
312
  }
277
- if (!this.contextBeforeRenderCallbacks.get(context)) {
278
- const cb = () => { this.onBeforeRender(context, this.timedObjectsBuffer) };
279
- this.contextBeforeRenderCallbacks.set(context, cb);
280
- context.pre_render_callbacks.push(cb);
313
+ // make sure gizmo post render is the last one being called
314
+ else if (context.post_render_callbacks[context.post_render_callbacks.length - 1] !== postRender) {
315
+ const index = context.post_render_callbacks.indexOf(postRender);
316
+ if (index >= 0) {
317
+ context.post_render_callbacks.splice(index, 1);
318
+ }
319
+ context.post_render_callbacks.push(postRender);
281
320
  }
282
321
 
283
- object.renderOrder = 999999;
284
322
  object.layers.disableAll();
285
323
  object.layers.enable(2);
324
+
325
+ object.renderOrder = 999999;
286
326
  object[$cacheSymbol] = cache;
287
327
  this.timedObjectsBuffer.push(object);
288
328
  this.timesBuffer.push(Context.Current.time.realtimeSinceStartup + duration);
@@ -304,13 +344,13 @@
304
344
  for (let i = 0; i < objects.length; i++) {
305
345
  const obj = objects[i];
306
346
  if (ctx.mainCamera && obj instanceof ThreeMeshUI.MeshUIBaseElement) {
307
- if (isDestroyed(obj)) {
347
+ if (isDestroyed(obj as any)) {
308
348
  continue;
309
349
  }
310
350
  const isInXR = ctx.isInVR;
311
351
  const keepUp = isInXR;
312
352
  const copyRotation = !isInXR;
313
- lookAtObject(obj, ctx.mainCamera, keepUp, copyRotation);
353
+ lookAtObject(obj as any, ctx.mainCamera, keepUp, copyRotation);
314
354
  }
315
355
  }
316
356
  }
src/engine/engine_input.ts CHANGED
@@ -1,22 +1,63 @@
1
- import { Vector2 } from 'three';
1
+ import { Matrix4, Object3D, Ray, Vector2, Vector3 } from 'three';
2
2
  import { showBalloonMessage, showBalloonWarning } from './debug/debug.js';
3
3
  import { Context } from './engine_setup.js';
4
- import type { IInput, Vec2 } from './engine_types.js';
5
- import { getParam } from './engine_utils.js';
4
+ import type { ButtonName, IGameObject, IInput, MouseButtonName, Vec2 } from './engine_types.js';
5
+ import { EnumToPrimitiveUnion, getParam, isMozillaXR } from './engine_utils.js';
6
6
 
7
7
  const debug = getParam("debuginput");
8
8
 
9
+ export declare type NEPointerEventInit = PointerEventInit &
10
+ {
11
+ pointerId: number;
12
+ mode: XRTargetRayMode,
13
+ ray?: Ray;
14
+ /** The control object for this input. In the case of spatial devices the controller,
15
+ * otherwise a generated object in screen space. The object may not be in the scene. */
16
+ device: Object3D;
17
+ buttonName: ButtonName | "none";
18
+ }
19
+
20
+
9
21
  export class NEPointerEvent extends PointerEvent {
22
+ /** the browser event that triggered this event (if any) */
10
23
  readonly source: Event | null;
11
24
 
12
- constructor(type: InputEvents, source: Event | null, init: PointerEventInit) {
25
+ readonly mode: XRTargetRayMode;
26
+ /** A ray in worldspace for the event */
27
+ readonly ray?: Ray;
28
+ /** The device space (this object is not necessarily rendered in the scene but you can access or copy the matrix) */
29
+ readonly space: Object3D;
30
+
31
+ isClick: boolean = false;
32
+ isDoubleClick: boolean = false;
33
+
34
+ constructor(type: InputEvents, source: Event | null, init: NEPointerEventInit) {
13
35
  super(type, init)
14
36
  this.source = source;
37
+ this.mode = init.mode;
38
+ this.ray = init.ray;
39
+ this.space = init.device;
15
40
  }
41
+
42
+ private _immediatePropagationStopped = false;
43
+ get immediatePropagationStopped() {
44
+ return this._immediatePropagationStopped;
45
+ }
46
+ private _propagationStopped = false;
47
+ get propagationStopped() {
48
+ return this._immediatePropagationStopped || this._propagationStopped;
49
+ }
50
+
16
51
  stopImmediatePropagation(): void {
52
+ this._immediatePropagationStopped = true;
17
53
  super.stopImmediatePropagation();
18
54
  this.source?.stopImmediatePropagation();
19
55
  }
56
+ stopPropagation(): void {
57
+ this._propagationStopped = true;
58
+ super.stopPropagation();
59
+ this.source?.stopPropagation();
60
+ }
20
61
  }
21
62
  export class NEKeyboardEvent extends KeyboardEvent {
22
63
  source?: Event
@@ -41,22 +82,72 @@
41
82
  }
42
83
  }
43
84
 
44
- export enum InputEvents {
85
+
86
+ export const enum PointerType {
87
+ Mouse = "mouse",
88
+ Touch = "touch",
89
+ Controller = "controller",
90
+ Hand = "hand"
91
+ }
92
+
93
+ const enum PointerEnumType {
45
94
  PointerDown = "pointerdown",
46
95
  PointerUp = "pointerup",
47
96
  PointerMove = "pointermove",
97
+ }
98
+ const enum KeyboardEnumType {
48
99
  KeyDown = "keydown",
49
100
  KeyUp = "keyup",
50
101
  KeyPressed = "keypress"
51
102
  }
52
103
 
53
- export enum PointerType {
54
- Mouse = "mouse",
55
- Touch = "touch",
104
+ export const enum InputEvents {
105
+ PointerDown = "pointerdown",
106
+ PointerUp = "pointerup",
107
+ PointerMove = "pointermove",
108
+ KeyDown = "keydown",
109
+ KeyUp = "keyup",
110
+ KeyPressed = "keypress"
56
111
  }
112
+ type InputEventNames = EnumToPrimitiveUnion<InputEvents>;
57
113
 
58
- export class Input extends EventTarget implements IInput {
59
114
 
115
+ declare type PointerEventListener = (evt: NEPointerEvent) => void;
116
+ declare type KeyboardEventListener = (evt: NEKeyboardEvent) => void;
117
+ declare type InputEventListener = PointerEventListener | KeyboardEventListener;
118
+
119
+ export class Input implements IInput {
120
+
121
+ private readonly _pointerEventListener: { [key: string]: PointerEventListener[] } = {};
122
+
123
+ addEventListener(type: InputEvents | InputEventNames, callback: PointerEventListener): void {
124
+ if (!this._pointerEventListener[type]) this._pointerEventListener[type] = [];
125
+ this._pointerEventListener[type].push(callback);
126
+ }
127
+ removeEventListener(type: InputEvents | InputEventNames, callback: PointerEventListener): void {
128
+ if (!this._pointerEventListener[type]) return;
129
+ const index = this._pointerEventListener[type].indexOf(callback);
130
+ if (index >= 0) this._pointerEventListener[type].splice(index, 1);
131
+ }
132
+ private dispatchEvent(evt: NEPointerEvent | NEKeyboardEvent) {
133
+ if (evt instanceof NEKeyboardEvent) {
134
+ // TODO: implement. We want typescript to be smart enough to detect the event listener by the type union (e.g. keydown | keyup === keyboard events)
135
+ }
136
+ else {
137
+ const listeners = this._pointerEventListener[evt.type];
138
+ if (listeners) {
139
+ for (const l of listeners) {
140
+ if (evt.immediatePropagationStopped) {
141
+ if(debug) console.log("immediatePropagationStopped", evt.type);
142
+ break;
143
+ }
144
+ l(evt);
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+
60
151
  _doubleClickTimeThreshold = .2;
61
152
  _longPressTimeThreshold = 1;
62
153
 
@@ -243,6 +334,8 @@
243
334
  private _mouseWheelDeltaY: number[] = [0];
244
335
  private _pointerEvent: Event[] = [];
245
336
  private _pointerUsed: boolean[] = [];
337
+ /** This is added/updated for pointers. screenspace pointers set this to the camera near plane */
338
+ private _pointerSpace: Object3D[] = [];
246
339
 
247
340
  getKeyDown(): string | null {
248
341
  for (const key in this.keysPressed) {
@@ -313,39 +406,54 @@
313
406
  return null;
314
407
  }
315
408
 
316
- createPointerDown(args: NEPointerEvent) {
317
- if (debug) showBalloonMessage("Create Pointer down");
318
- this.onDown(args);
409
+ createInputEvent(args: NEPointerEvent) {
410
+ // TODO: technically we would need to check for circular invocations here!
411
+ switch (args.type) {
412
+ case InputEvents.PointerDown:
413
+ if (debug) showBalloonMessage("Create Pointer down");
414
+ this.onDown(args);
415
+ break;
416
+ case InputEvents.PointerMove:
417
+ if (debug) showBalloonMessage("Create Pointer move");
418
+ this.onMove(args);
419
+ break;
420
+ case InputEvents.PointerUp:
421
+ if (debug) showBalloonMessage("Create Pointer up");
422
+ this.onUp(args);
423
+ break;
424
+ }
319
425
  }
320
426
 
321
- createPointerMove(args: NEPointerEvent) {
322
- if (debug) showBalloonMessage("Create Pointer move");
323
- this.onMove(args);
324
- }
325
-
326
- createPointerUp(args: NEPointerEvent) {
327
- if (debug) showBalloonMessage("Create Pointer up");
328
- this.onUp(args);
329
- }
330
-
331
427
  convertScreenspaceToRaycastSpace(vec2: Vec2) {
332
428
  vec2.x = (vec2.x - this.context.domX) / this.context.domWidth * 2 - 1;
333
429
  vec2.y = -((vec2.y - this.context.domY) / this.context.domHeight) * 2 + 1;
334
430
  }
335
431
 
336
432
  constructor(context: Context) {
337
- super();
338
433
  this.context = context;
339
434
  this.context.post_render_callbacks.push(this.onEndOfFrame);
435
+ }
340
436
 
341
- window.addEventListener('touchstart', this.onTouchStart, false);
437
+ /** this is the html element we subscribed to for events */
438
+ private _htmlEventSource!: HTMLElement;
439
+
440
+ bindEvents() {
441
+ this.unbindEvents();
442
+
443
+ // we subscribe to the canvas element because we don't want to receive events when the user is interacting with the UI
444
+ // e.g. if we have slotted HTML elements in the needle engine DOM elements we don't want to receive input events for those
445
+ this._htmlEventSource = this.context.renderer.domElement;
446
+
447
+
448
+ this._htmlEventSource.addEventListener('touchstart', this.onTouchStart, false);
449
+ window.addEventListener('touchstart', this.onTouchStartWindow);
342
450
  window.addEventListener('touchmove', this.onTouchMove, { passive: true });
343
451
  window.addEventListener('touchend', this.onTouchUp, false);
344
452
 
345
- window.addEventListener('mousedown', this.onMouseDown, false);
453
+ this._htmlEventSource.addEventListener('mousedown', this.onMouseDown);
346
454
  window.addEventListener('mousemove', this.onMouseMove, false);
347
455
  window.addEventListener('mouseup', this.onMouseUp, false);
348
- window.addEventListener('wheel', this.onMouseWheel, { passive: true });
456
+ this._htmlEventSource.addEventListener('wheel', this.onMouseWheel, { passive: true });
349
457
 
350
458
  window.addEventListener("keydown", this.onKeyDown, false);
351
459
  window.addEventListener("keypress", this.onKeyPressed, false);
@@ -355,18 +463,16 @@
355
463
  window.addEventListener('blur', this.onLostFocus);
356
464
  }
357
465
 
358
- dispose() {
359
- const index = this.context.post_render_callbacks.indexOf(this.onEndOfFrame);
360
- if (index >= 0) this.context.post_render_callbacks.splice(index, 1);
361
-
362
- window.removeEventListener('touchstart', this.onTouchStart, false);
466
+ unbindEvents() {
467
+ this._htmlEventSource?.removeEventListener('touchstart', this.onTouchStart, false);
468
+ window.removeEventListener('touchstart', this.onTouchStartWindow);
363
469
  window.removeEventListener('touchmove', this.onTouchMove, false);
364
470
  window.removeEventListener('touchend', this.onTouchUp, false);
365
471
 
366
- window.removeEventListener('mousedown', this.onMouseDown, false);
472
+ this._htmlEventSource?.removeEventListener('mousedown', this.onMouseDown, false);
367
473
  window.removeEventListener('mousemove', this.onMouseMove, false);
368
474
  window.removeEventListener('mouseup', this.onMouseUp, false);
369
- window.removeEventListener('wheel', this.onMouseWheel, false);
475
+ this._htmlEventSource?.removeEventListener('wheel', this.onMouseWheel, false);
370
476
 
371
477
  window.removeEventListener("keydown", this.onKeyDown, false);
372
478
  window.removeEventListener("keypress", this.onKeyPressed, false);
@@ -375,6 +481,12 @@
375
481
  window.removeEventListener('blur', this.onLostFocus);
376
482
  }
377
483
 
484
+ dispose() {
485
+ const index = this.context.post_render_callbacks.indexOf(this.onEndOfFrame);
486
+ if (index >= 0) this.context.post_render_callbacks.splice(index, 1);
487
+ this.unbindEvents();
488
+ }
489
+
378
490
  private onLostFocus = () => {
379
491
  for (const kp in this.keysPressed) {
380
492
  this.keysPressed[kp].pressed = false;
@@ -403,11 +515,14 @@
403
515
  // if(evt.target === this.context.renderer.domElement) return true;
404
516
  // const css = window.getComputedStyle(evt.target as HTMLElement);
405
517
  // if(css.pointerEvents === "all") return false;
406
-
407
518
  // We only check the target elements here since the canvas may be overlapped by other elements
408
519
  // in which case we do not want to use the input (e.g. if a HTML element is being triggered)
409
- if(evt.target === this.context.renderer?.domElement) return true;
410
- if(evt.target === this.context.domElement) return true;
520
+ if (evt.target === this.context.renderer?.domElement) return true;
521
+ if (evt.target === this.context.domElement) return true;
522
+
523
+ // looks like in Mozilla WebXR viewer the target element is the body
524
+ if (this.context.isInAR && evt.target === document.body && isMozillaXR()) return true;
525
+
411
526
  return false;
412
527
  }
413
528
 
@@ -453,6 +568,12 @@
453
568
  this._mouseWheelDeltaY[0] = current + evt.deltaY;
454
569
  }
455
570
 
571
+ private onTouchStartWindow = (evt: TouchEvent) => {
572
+ // onTouchStart registers on the renderer canvas so that we don't receive events when clicking on UI elements slotted in Needle Engine
573
+ // however in AR we need to handle these events on the window since they're not emitted otherwise (see NE-4098)
574
+ if(!this.context.isInAR) return;
575
+ this.onTouchStart(evt);
576
+ };
456
577
  private onTouchStart = (evt: TouchEvent) => {
457
578
  if (evt.changedTouches.length <= 0) return;
458
579
  if (this.canReceiveInput(evt) === false) return;
@@ -460,7 +581,8 @@
460
581
  const touch = evt.changedTouches[i];
461
582
  const id = this.getPointerIndex(touch.identifier)
462
583
  if (debug) showBalloonMessage(`touch start #${id}, identifier:${touch.identifier}`);
463
- const ne = new NEPointerEvent(InputEvents.PointerDown, evt, { button: id, clientX: touch.clientX, clientY: touch.clientY, pointerType: PointerType.Touch });
584
+ const space = this.getAndUpdateSpatialObjectForScreenPosition(id, touch.clientX, touch.clientY);
585
+ const ne = new NEPointerEvent(InputEvents.PointerDown, evt, { mode: "screen", pointerId: id, button: 0, clientX: touch.clientX, clientY: touch.clientY, pointerType: PointerType.Touch, buttonName: "unknown", device: space });
464
586
  this.onDown(ne);
465
587
  }
466
588
  }
@@ -470,7 +592,8 @@
470
592
  for (let i = 0; i < evt.changedTouches.length; i++) {
471
593
  const touch = evt.changedTouches[i];
472
594
  const id = this.getPointerIndex(touch.identifier)
473
- const ne = new NEPointerEvent(InputEvents.PointerMove, evt, { button: id, clientX: touch.clientX, clientY: touch.clientY, pointerType: PointerType.Touch });
595
+ const space = this.getAndUpdateSpatialObjectForScreenPosition(id, touch.clientX, touch.clientY);
596
+ const ne = new NEPointerEvent(InputEvents.PointerMove, evt, { mode: "screen", pointerId: id, button: 0, clientX: touch.clientX, clientY: touch.clientY, pointerType: PointerType.Touch, buttonName: "unknown", device: space });
474
597
  this.onMove(ne);
475
598
  }
476
599
  }
@@ -484,34 +607,78 @@
484
607
  if (!this.isNewEvent(evt.timeStamp, id, this._pointerUpTimestamp)) continue;
485
608
 
486
609
  if (debug) showBalloonMessage(`touch up #${id}, identifier:${touch.identifier}`);
487
- const ne = new NEPointerEvent(InputEvents.PointerUp, evt, { button: id, clientX: touch.clientX, clientY: touch.clientY, pointerType: PointerType.Touch });
610
+ const space = this.getAndUpdateSpatialObjectForScreenPosition(id, touch.clientX, touch.clientY);
611
+ const ne = new NEPointerEvent(InputEvents.PointerUp, evt, { mode: "screen", pointerId: id, button: 0, clientX: touch.clientX, clientY: touch.clientY, pointerType: PointerType.Touch, buttonName: "unknown", device:space });
488
612
  this.onUp(ne);
489
613
  }
490
614
  }
491
615
 
492
616
  private onMouseDown = (evt: MouseEvent) => {
617
+ if (this.context.isInVR) return;
493
618
  if (evt.defaultPrevented) return;
494
619
  if (this.canReceiveInput(evt) === false) return;
620
+ // TODO: if we have multiple mouse devices we need to get the deviceId
495
621
  const id = evt.button;
496
- const ne = new NEPointerEvent(InputEvents.PointerDown, evt, { button: id, clientX: evt.clientX, clientY: evt.clientY, pointerType: PointerType.Mouse });
622
+ let buttonName: MouseButtonName | "none" = "none";
623
+ switch (id) {
624
+ case 0: buttonName = "left"; break;
625
+ case 1: buttonName = "middle"; break;
626
+ case 2: buttonName = "right"; break;
627
+ }
628
+ const space = this.getAndUpdateSpatialObjectForScreenPosition(id, evt.clientX, evt.clientY);
629
+ const ne = new NEPointerEvent(InputEvents.PointerDown, evt, { mode: "screen", pointerId: 0, button: id, clientX: evt.clientX, clientY: evt.clientY, pointerType: PointerType.Mouse, buttonName: buttonName, device: space });
497
630
  this.onDown(ne);
498
631
  }
499
632
 
500
633
  private onMouseMove = (evt: MouseEvent) => {
634
+ if (this.context.isInVR) return;
501
635
  if (evt.defaultPrevented) return;
502
636
  const id = evt.button;
503
- const ne = new NEPointerEvent(InputEvents.PointerMove, evt, { button: id, clientX: evt.clientX, clientY: evt.clientY, pointerType: PointerType.Mouse });
637
+ const space = this.getAndUpdateSpatialObjectForScreenPosition(id, evt.clientX, evt.clientY);
638
+ const ne = new NEPointerEvent(InputEvents.PointerMove, evt, { mode: "screen", pointerId: 0, button: id, clientX: evt.clientX, clientY: evt.clientY, pointerType: PointerType.Mouse, buttonName: "none", device: space });
504
639
  this.onMove(ne);
505
640
  }
506
641
 
507
642
  private onMouseUp = (evt: MouseEvent) => {
643
+ if (this.context.isInVR) return;
508
644
  if (evt.defaultPrevented) return;
509
645
  const id = evt.button;
510
646
  if (!this.isNewEvent(evt.timeStamp, id, this._pointerUpTimestamp)) return;
511
- const ne = new NEPointerEvent(InputEvents.PointerUp, evt, { button: id, clientX: evt.clientX, clientY: evt.clientY, pointerType: PointerType.Mouse });
647
+ let buttonName: MouseButtonName | "none" = "none";
648
+ switch (id) {
649
+ case 0: buttonName = "left"; break;
650
+ case 1: buttonName = "middle"; break;
651
+ case 2: buttonName = "right"; break;
652
+ }
653
+ const space = this.getAndUpdateSpatialObjectForScreenPosition(id, evt.clientX, evt.clientY);
654
+ const ne = new NEPointerEvent(InputEvents.PointerUp, evt, { mode: "screen", pointerId: 0, button: id, clientX: evt.clientX, clientY: evt.clientY, pointerType: PointerType.Mouse, buttonName: buttonName, device: space, });
512
655
  this.onUp(ne);
513
656
  }
514
657
 
658
+ private readonly tempNearPlaneVector = new Vector3();
659
+ private readonly tempFarPlaneVector = new Vector3();
660
+ private readonly tempLookMatrix = new Matrix4();
661
+ private getAndUpdateSpatialObjectForScreenPosition(id: number, screenX: number, screenY: number): Object3D {
662
+ let space = this._pointerSpace[id]
663
+ if (!space) {
664
+ space = new Object3D();
665
+ this._pointerSpace[id] = space;
666
+ }
667
+ this._pointerSpace[id] = space;
668
+ const camera = this.context.mainCamera;
669
+ if (camera) {
670
+ const pointOnNearPlane = this.tempNearPlaneVector.set(screenX, screenY, -1);
671
+ this.convertScreenspaceToRaycastSpace(pointOnNearPlane);
672
+ const pointOnFarPlane = this.tempFarPlaneVector.set(pointOnNearPlane.x, pointOnNearPlane.y, 1);
673
+ pointOnNearPlane.unproject(camera);
674
+ pointOnFarPlane.unproject(camera);
675
+ this.tempLookMatrix.lookAt(pointOnFarPlane, pointOnNearPlane, (camera as any as IGameObject).worldUp);
676
+ space.position.set(pointOnNearPlane.x, pointOnNearPlane.y, pointOnNearPlane.z);
677
+ space.quaternion.setFromRotationMatrix(this.tempLookMatrix);
678
+ }
679
+ return space;
680
+ }
681
+
515
682
  // Prevent the same event being handled twice (e.g. on touch we get a mouseUp and touchUp evt with the same timestamp)
516
683
  private isNewEvent(timestamp: number, index: number, arr: number[]): boolean {
517
684
  while (arr.length <= index) arr.push(-1);
@@ -532,12 +699,19 @@
532
699
  }
533
700
 
534
701
  private onDown(evt: NEPointerEvent) {
535
- if (debug) console.log(evt.pointerType, "DOWN", evt.button);
702
+ const index = evt.pointerId;
703
+ if (this.getPointerPressed(index)) {
704
+ console.error("ERROR: pointerId is already pressed", index);
705
+ return;
706
+ }
707
+ if (debug) console.log(evt.pointerType, "DOWN", index);
536
708
  if (!this.isInRect(evt)) return;
537
709
 
710
+ // TODO: this whole pointer handling doesnt consider that in VR we have multiple pointers. It's not enough to store the button as the pointer ID anymore
711
+
538
712
  // check if we received an mouse UP event for a touch (for some reason we get a mouse.down for touch.up)
539
713
  if (evt.pointerType === PointerType.Mouse) {
540
- const upTime = this._pointerUpTimestamp[evt.button];
714
+ const upTime = this._pointerUpTimestamp[index];
541
715
  if (upTime > 0 && evt.source?.timeStamp !== undefined) {
542
716
  const diff = (evt.source.timeStamp - upTime);
543
717
  // on android touch up and mouse up have the exact same value
@@ -550,20 +724,20 @@
550
724
  }
551
725
  }
552
726
 
553
- this.setPointerState(evt.button, this._pointerPressed, true);
554
- this.setPointerState(evt.button, this._pointerDown, true);
555
- this.setPointerStateT(evt.button, this._pointerEvent, evt.source);
727
+ this.setPointerState(index, this._pointerPressed, true);
728
+ this.setPointerState(index, this._pointerDown, true);
729
+ this.setPointerStateT(index, this._pointerEvent, evt.source);
556
730
 
557
- while (evt.button >= this._pointerTypes.length) this._pointerTypes.push(evt.pointerType);
558
- this._pointerTypes[evt.button] = evt.pointerType;
731
+ while (index >= this._pointerTypes.length) this._pointerTypes.push(evt.pointerType);
732
+ this._pointerTypes[index] = evt.pointerType;
559
733
 
560
- while (evt.button >= this._pointerPositionDown.length) this._pointerPositionDown.push(new Vector2());
561
- this._pointerPositionDown[evt.button].set(evt.clientX, evt.clientY);
562
- while (evt.button >= this._pointerPositions.length) this._pointerPositions.push(new Vector2());
563
- this._pointerPositions[evt.button].set(evt.clientX, evt.clientY);
734
+ while (index >= this._pointerPositionDown.length) this._pointerPositionDown.push(new Vector2());
735
+ this._pointerPositionDown[index].set(evt.clientX, evt.clientY);
736
+ while (index >= this._pointerPositions.length) this._pointerPositions.push(new Vector2());
737
+ this._pointerPositions[index].set(evt.clientX, evt.clientY);
564
738
 
565
- if (evt.button >= this._pointerDownTime.length) this._pointerDownTime.push(0);
566
- this._pointerDownTime[evt.button] = this.context.time.time;
739
+ if (index >= this._pointerDownTime.length) this._pointerDownTime.push(0);
740
+ this._pointerDownTime[index] = this.context.time.time;
567
741
 
568
742
  this.updatePointerPosition(evt);
569
743
 
@@ -571,63 +745,60 @@
571
745
  }
572
746
  // moveEvent?: Event;
573
747
  private onMove(evt: NEPointerEvent) {
574
- const index = evt.button;
575
-
748
+ const index = evt.pointerId;
749
+
576
750
  const isDown = this.getPointerPressed(index);
577
751
  if (isDown === false && !this.isInRect(evt)) return;
578
752
  if (evt.pointerType === PointerType.Touch && !isDown) return;
579
- if (debug) console.log(evt.pointerType, "MOVE", index);
580
-
753
+ if (debug) console.log(evt.pointerType, "MOVE", index, "hasSpace=" + evt.space != null);
754
+
581
755
  this.updatePointerPosition(evt);
582
756
  this.setPointerStateT(index, this._pointerEvent, evt.source);
583
757
  this.onDispatchEvent(evt);
584
758
  }
585
759
  private onUp(evt: NEPointerEvent) {
586
- if (this._pointerIds?.length >= evt.button)
587
- this._pointerIds[evt.button] = -1;
588
- const wasDown = this._pointerPressed[evt.button];
760
+ const index = evt.pointerId;
761
+ const wasDown = this.getPointerPressed(index);
589
762
  if (!wasDown) {
590
- if (debug) console.log(evt.pointerType, "UP", evt.button, "was not down");
763
+ if (debug) console.log(evt.pointerType, "UP", index, "was not down");
591
764
  return;
592
765
  }
593
- if (debug) console.log(evt.pointerType, "UP", evt.button);
594
- this.setPointerState(evt.button, this._pointerPressed, false);
595
- this.setPointerStateT(evt.button, this._pointerEvent, evt.source);
766
+ if (debug) console.log(evt.pointerType, "UP", index);
767
+ this.setPointerState(index, this._pointerPressed, false);
768
+ this.setPointerStateT(index, this._pointerEvent, evt.source);
769
+ this.setPointerState(index, this._pointerUp, true);
596
770
 
597
- // if (!this.isInRect(evt)) {
598
- // if (debug) showBalloonWarning("Pointer out of bounds: " + evt.clientX + ", " + evt.clientY);
599
- // return;
600
- // }
601
- this.setPointerState(evt.button, this._pointerUp, true);
771
+ while (index >= this._pointerUsed.length) this._pointerUsed.push(false);
772
+ this.setPointerState(index, this._pointerUsed, false);
602
773
 
603
- while (evt.button >= this._pointerUsed.length) this._pointerUsed.push(false);
604
- this.setPointerState(evt.button, this._pointerUsed, false);
605
-
606
774
  this.updatePointerPosition(evt);
607
775
 
608
- if (!this._pointerPositionDown[evt.button]) {
609
- if (debug) showBalloonWarning("Received pointer up event without matching down event for button: " + evt.button);
610
- console.warn("Received pointer up event without matching down event for button: " + evt.button)
776
+ if (!this._pointerPositionDown[index]) {
777
+ if (debug) showBalloonWarning("Received pointer up event without matching down event for button: " + index);
778
+ console.warn("Received pointer up event without matching down event for button: " + index)
611
779
  return;
612
780
  }
613
- const dx = evt.clientX - this._pointerPositionDown[evt.button].x;
614
- const dy = evt.clientY - this._pointerPositionDown[evt.button].y;
781
+ const dx = evt.clientX - this._pointerPositionDown[index].x;
782
+ const dy = evt.clientY - this._pointerPositionDown[index].y;
615
783
 
616
- if (evt.button >= this._pointerUpTime.length) this._pointerUpTime.push(-99);
784
+ if (index >= this._pointerUpTime.length) this._pointerUpTime.push(-99);
617
785
 
618
- // console.log(dx, dy);
786
+
619
787
  if (Math.abs(dx) < 5 && Math.abs(dy) < 5) {
620
- this.setPointerState(evt.button, this._pointerClick, true);
788
+ if(debug) console.log("CLICK", index)
789
+ this.setPointerState(index, this._pointerClick, true);
790
+ evt.isClick = true;
621
791
 
622
792
  // handle double click
623
- const lastUp = this._pointerUpTime[evt.button];
793
+ const lastUp = this._pointerUpTime[index];
624
794
  const dt = this.context.time.time - lastUp;
625
795
  // console.log(dt);
626
796
  if (dt < this._doubleClickTimeThreshold && dt > 0) {
627
- this.setPointerState(evt.button, this._pointerDoubleClick, true);
797
+ this.setPointerState(index, this._pointerDoubleClick, true);
798
+ evt.isDoubleClick = true;
628
799
  }
629
800
  }
630
- this._pointerUpTime[evt.button] = this.context.time.time;
801
+ this._pointerUpTime[index] = this.context.time.time;
631
802
 
632
803
  this.onDispatchEvent(evt);
633
804
  }
@@ -645,11 +816,11 @@
645
816
  let dx = evt.clientX - lf.x;
646
817
  let dy = evt.clientY - lf.y;
647
818
  // if pointer is locked, clientX and Y are not changed, but Movement is.
648
- if(evt.source instanceof MouseEvent || evt.source instanceof TouchEvent) {
819
+ if (evt.source instanceof MouseEvent || evt.source instanceof TouchEvent) {
649
820
  const source = evt.source as PointerEvent;
650
- if(dx === 0 && source.movementX !== 0)
821
+ if (dx === 0 && source.movementX !== 0)
651
822
  dx = source.movementX || 0;
652
- if(dy === 0 && source.movementY !== 0)
823
+ if (dy === 0 && source.movementY !== 0)
653
824
  dy = source.movementY || 0;
654
825
  }
655
826
  delta.x += dx;
@@ -691,16 +862,16 @@
691
862
  }
692
863
 
693
864
  private setPointerState(index: number, arr: boolean[], value: boolean) {
694
- while (arr.length <= index) arr.push(false);
695
865
  arr[index] = value;
696
866
  }
697
867
 
698
868
  private setPointerStateT<T>(index: number, arr: T[], value: T) {
699
- while (arr.length <= index) arr.push(null as any);
869
+ // while (arr.length <= index) arr.push(null as any);
700
870
  arr[index] = value;
871
+ return value;
701
872
  }
702
873
 
703
- private onDispatchEvent(evt: NEPointerEvent | KeyboardEvent) {
874
+ private onDispatchEvent(evt: NEPointerEvent | NEKeyboardEvent) {
704
875
  const prevContext = Context.Current;
705
876
  try {
706
877
  Context.Current = this.context;
@@ -800,81 +971,81 @@
800
971
  | "F11"
801
972
  | "F12";
802
973
 
803
- // KEY_1 = 49,
804
- // KEY_2 = 50,
805
- // KEY_3 = 51,
806
- // KEY_4 = 52,
807
- // KEY_5 = 53,
808
- // KEY_6 = 54,
809
- // KEY_7 = 55,
810
- // KEY_8 = 56,
811
- // KEY_9 = 57,
812
- // KEY_A = 65,
813
- // KEY_B = 66,
814
- // KEY_C = 67,
815
- // KEY_D = "d",
816
- // KEY_E = 69,
817
- // KEY_F = 70,
818
- // KEY_G = 71,
819
- // KEY_H = 72,
820
- // KEY_I = 73,
821
- // KEY_J = 74,
822
- // KEY_K = 75,
823
- // KEY_L = 76,
824
- // KEY_M = 77,
825
- // KEY_N = 78,
826
- // KEY_O = 79,
827
- // KEY_P = 80,
828
- // KEY_Q = 81,
829
- // KEY_R = 82,
830
- // KEY_S = 83,
831
- // KEY_T = 84,
832
- // KEY_U = 85,
833
- // KEY_V = 86,
834
- // KEY_W = 87,
835
- // KEY_X = 88,
836
- // KEY_Y = 89,
837
- // KEY_Z = 90,
838
- // LEFT_META = 91,
839
- // RIGHT_META = 92,
840
- // SELECT = 93,
841
- // NUMPAD_0 = 96,
842
- // NUMPAD_1 = 97,
843
- // NUMPAD_2 = 98,
844
- // NUMPAD_3 = 99,
845
- // NUMPAD_4 = 100,
846
- // NUMPAD_5 = 101,
847
- // NUMPAD_6 = 102,
848
- // NUMPAD_7 = 103,
849
- // NUMPAD_8 = 104,
850
- // NUMPAD_9 = 105,
851
- // MULTIPLY = 106,
852
- // ADD = 107,
853
- // SUBTRACT = 109,
854
- // DECIMAL = 110,
855
- // DIVIDE = 111,
856
- // F1 = 112,
857
- // F2 = 113,
858
- // F3 = 114,
859
- // F4 = 115,
860
- // F5 = 116,
861
- // F6 = 117,
862
- // F7 = 118,
863
- // F8 = 119,
864
- // F9 = 120,
865
- // F10 = 121,
866
- // F11 = 122,
867
- // F12 = 123,
868
- // NUM_LOCK = 144,
869
- // SCROLL_LOCK = 145,
870
- // SEMICOLON = 186,
871
- // EQUALS = 187,
872
- // COMMA = 188,
873
- // DASH = 189,
874
- // PERIOD = 190,
875
- // FORWARD_SLASH = 191,
876
- // GRAVE_ACCENT = 192,
877
- // OPEN_BRACKET = 219,
878
- // BACK_SLASH = 220,
879
- // CLOSE_BRACKET = 221,
880
- // SINGLE_QUOTE = 222
974
+ // KEY_1 = 49,
975
+ // KEY_2 = 50,
976
+ // KEY_3 = 51,
977
+ // KEY_4 = 52,
978
+ // KEY_5 = 53,
979
+ // KEY_6 = 54,
980
+ // KEY_7 = 55,
981
+ // KEY_8 = 56,
982
+ // KEY_9 = 57,
983
+ // KEY_A = 65,
984
+ // KEY_B = 66,
985
+ // KEY_C = 67,
986
+ // KEY_D = "d",
987
+ // KEY_E = 69,
988
+ // KEY_F = 70,
989
+ // KEY_G = 71,
990
+ // KEY_H = 72,
991
+ // KEY_I = 73,
992
+ // KEY_J = 74,
993
+ // KEY_K = 75,
994
+ // KEY_L = 76,
995
+ // KEY_M = 77,
996
+ // KEY_N = 78,
997
+ // KEY_O = 79,
998
+ // KEY_P = 80,
999
+ // KEY_Q = 81,
1000
+ // KEY_R = 82,
1001
+ // KEY_S = 83,
1002
+ // KEY_T = 84,
1003
+ // KEY_U = 85,
1004
+ // KEY_V = 86,
1005
+ // KEY_W = 87,
1006
+ // KEY_X = 88,
1007
+ // KEY_Y = 89,
1008
+ // KEY_Z = 90,
1009
+ // LEFT_META = 91,
1010
+ // RIGHT_META = 92,
1011
+ // SELECT = 93,
1012
+ // NUMPAD_0 = 96,
1013
+ // NUMPAD_1 = 97,
1014
+ // NUMPAD_2 = 98,
1015
+ // NUMPAD_3 = 99,
1016
+ // NUMPAD_4 = 100,
1017
+ // NUMPAD_5 = 101,
1018
+ // NUMPAD_6 = 102,
1019
+ // NUMPAD_7 = 103,
1020
+ // NUMPAD_8 = 104,
1021
+ // NUMPAD_9 = 105,
1022
+ // MULTIPLY = 106,
1023
+ // ADD = 107,
1024
+ // SUBTRACT = 109,
1025
+ // DECIMAL = 110,
1026
+ // DIVIDE = 111,
1027
+ // F1 = 112,
1028
+ // F2 = 113,
1029
+ // F3 = 114,
1030
+ // F4 = 115,
1031
+ // F5 = 116,
1032
+ // F6 = 117,
1033
+ // F7 = 118,
1034
+ // F8 = 119,
1035
+ // F9 = 120,
1036
+ // F10 = 121,
1037
+ // F11 = 122,
1038
+ // F12 = 123,
1039
+ // NUM_LOCK = 144,
1040
+ // SCROLL_LOCK = 145,
1041
+ // SEMICOLON = 186,
1042
+ // EQUALS = 187,
1043
+ // COMMA = 188,
1044
+ // DASH = 189,
1045
+ // PERIOD = 190,
1046
+ // FORWARD_SLASH = 191,
1047
+ // GRAVE_ACCENT = 192,
1048
+ // OPEN_BRACKET = 219,
1049
+ // BACK_SLASH = 220,
1050
+ // CLOSE_BRACKET = 221,
1051
+ // SINGLE_QUOTE = 222
src/engine/engine_lifecycle_api.ts CHANGED
@@ -6,25 +6,49 @@
6
6
  /**
7
7
  * Register a callback in the engine context created event.
8
8
  * This happens once per context (after the context has been created and the first content has been loaded)
9
- */
9
+ * ```ts
10
+ * onInitialized((ctx : Context) => {
11
+ * // do something
12
+ * }
13
+ * ```
14
+ * */
10
15
  export function onInitialized(cb: LifecycleMethod) {
11
16
  registerFrameEventCallback(cb, ContextEvent.ContextCreated);
12
17
  }
13
18
 
14
19
  /** Register a callback in the engine start event.
15
- * This happens at the beginning of each frame */
20
+ * This happens at the beginning of each frame
21
+ * ```ts
22
+ * onStart((ctx : Context) => {
23
+ * // do something
24
+ * }
25
+ * ```
26
+ * */
16
27
  export function onStart(cb: LifecycleMethod) {
17
28
  registerFrameEventCallback(cb, FrameEvent.Start);
18
29
  }
19
30
 
20
31
 
21
32
  /** Register a callback in the engine update event
22
- * This is called every frame
33
+ * This is called every frame
34
+ * ```ts
35
+ * onUpdate((ctx : Context) => {
36
+ * // do something
37
+ * }
38
+ * ```
23
39
  * */
24
40
  export function onUpdate(cb: LifecycleMethod) {
25
41
  registerFrameEventCallback(cb, FrameEvent.Update);
26
42
  }
27
43
 
44
+ /** Register a callback in the engine onBeforeRender event
45
+ * This is called every frame
46
+ * ```ts
47
+ * onBeforeRender((ctx : Context) => {
48
+ * // do something
49
+ * }
50
+ * ```
51
+ * */
28
52
  export function onBeforeRender(cb: LifecycleMethod) {
29
53
  registerFrameEventCallback(cb, FrameEvent.OnBeforeRender);
30
54
  }
src/engine/engine_mainloop_utils.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  import { isActiveSelf } from './engine_gameobject.js';
7
7
  import { ContextRegistry } from "./engine_context_registry.js";
8
8
  import { isDevEnvironment } from "./debug/index.js";
9
+ import type { INeedleXRSessionEventReceiver } from "./engine_xr.js";
9
10
 
10
11
  const debug = getParam("debugnewscripts");
11
12
  const debugHierarchy = getParam("debughierarchy");
@@ -208,9 +209,12 @@
208
209
  if (script.onBeforeRender) context.scripts_onBeforeRender.push(script);
209
210
  if (script.onAfterRender) context.scripts_onAfterRender.push(script);
210
211
  if (script.onPausedChanged) context.scripts_pausedChanged.push(script);
212
+ if (isNeedleXRSessionEventReceiver(script, null)) context.new_scripts_xr.push(script);
213
+ // do we want to check if a XR session is active before adding scripts here?
214
+ if (isNeedleXRSessionEventReceiver(script, "immersive-vr")) context.scripts_immersive_vr.push(script);
215
+ if (isNeedleXRSessionEventReceiver(script, "immersive-ar")) context.scripts_immersive_ar.push(script);
211
216
  }
212
217
 
213
-
214
218
  export function removeScriptFromContext(script: any, context: IContext) {
215
219
  removeFromArray(script, context.new_scripts);
216
220
  removeFromArray(script, context.new_script_start);
@@ -221,6 +225,9 @@
221
225
  removeFromArray(script, context.scripts_onBeforeRender);
222
226
  removeFromArray(script, context.scripts_onAfterRender);
223
227
  removeFromArray(script, context.scripts_pausedChanged);
228
+ removeFromArray(script, context.new_scripts_xr);
229
+ removeFromArray(script, context.scripts_immersive_vr);
230
+ removeFromArray(script, context.scripts_immersive_ar);
224
231
  context.stopAllCoroutinesFrom(script);
225
232
  }
226
233
 
@@ -229,7 +236,26 @@
229
236
  if (index >= 0) array.splice(index, 1);
230
237
  }
231
238
 
239
+ export function isNeedleXRSessionEventReceiver(script: any, mode: XRSessionMode | null): script is INeedleXRSessionEventReceiver {
240
+ if (script) {
241
+ const i = script as Partial<INeedleXRSessionEventReceiver>;
242
+ if (i.onBeforeXR ||
243
+ i.onEnterXR ||
244
+ i.onUpdateXR ||
245
+ i.onLeaveXR ||
246
+ i.onXRControllerAdded ||
247
+ i.onXRControllerRemoved
248
+ ) {
249
+ if (mode != null) {
250
+ if (i.supportsXR?.(mode) === false) return false;
251
+ }
252
+ return true;
253
+ }
254
+ }
255
+ return false;
256
+ }
232
257
 
258
+
233
259
  export function updateIsActive(obj?: Object3D) {
234
260
  if (!obj) obj = ContextRegistry.Current.scene;
235
261
  if (!obj) {
src/engine/engine_networking_instantiate.ts CHANGED
@@ -163,7 +163,7 @@
163
163
  }
164
164
  }
165
165
 
166
- class NewInstanceModel implements IModel {
166
+ export class NewInstanceModel implements IModel {
167
167
  guid: string;
168
168
  originalGuid: string;
169
169
  seed: number | undefined;
@@ -176,6 +176,9 @@
176
176
  rotation: { x: number, y: number, z: number, w: number } | undefined;
177
177
  scale: { x: number, y: number, z: number } | undefined;
178
178
 
179
+ /** Set to true to prevent this model from being instantiated */
180
+ preventCreation?: boolean = undefined;
181
+
179
182
  constructor(originalGuid: string, newGuid: string) {
180
183
  this.originalGuid = originalGuid;
181
184
  this.guid = newGuid;
@@ -249,11 +252,13 @@
249
252
  export function beginListenInstantiate(context: Context) {
250
253
  context.connection.beginListen(InstantiateEvent.NewInstanceCreated, async (model: NewInstanceModel) => {
251
254
  const obj: GameObject | null = await tryResolvePrefab(model.originalGuid, context.scene) as GameObject;
255
+ if (model.preventCreation === true) {
256
+ return;
257
+ }
252
258
  if (!obj) {
253
259
  console.warn("could not find object that was instantiated: " + model.guid);
254
260
  return;
255
261
  }
256
- // console.log(model);
257
262
  const options = new InstantiateOptions();
258
263
  if (model.position)
259
264
  options.position = new THREE.Vector3(model.position.x, model.position.y, model.position.z);
src/engine/engine_networking_streams.ts CHANGED
@@ -56,7 +56,7 @@
56
56
  Outgoing = "outgoing",
57
57
  }
58
58
 
59
- class CallHandle extends EventDispatcher {
59
+ class CallHandle extends EventDispatcher<any> {
60
60
  readonly userId: string;
61
61
  readonly direction: CallDirection;
62
62
  readonly call: MediaConnection;
@@ -105,7 +105,7 @@
105
105
  }
106
106
  }
107
107
 
108
- export class PeerHandle extends EventDispatcher {
108
+ export class PeerHandle extends EventDispatcher<any> {
109
109
 
110
110
  private static readonly instances: Map<string, PeerHandle> = new Map();
111
111
 
@@ -305,7 +305,7 @@
305
305
  // userId: string;
306
306
  // }
307
307
 
308
- export class NetworkedStreams extends EventDispatcher {
308
+ export class NetworkedStreams extends EventDispatcher<any> {
309
309
 
310
310
  static create(comp: IComponent) {
311
311
  const peer = PeerHandle.getOrCreate(comp.context, comp.context.connection.connectionId!);
src/engine/engine_physics.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  export declare type RaycastTestObjectReturnType = void | boolean | "continue in children";
13
13
  export declare type RaycastTestObjectCallback = (obj: Object3D) => RaycastTestObjectReturnType;
14
14
 
15
- declare interface IRaycastOptions {
15
+ export declare interface IRaycastOptions {
16
16
  /** Optionally a custom raycaster can be provided. Other properties will then be set on this raycaster */
17
17
  raycaster?: Raycaster;
18
18
  /** Optional ray that can be used for raycasting
src/engine/engine_serialization_core.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import { AnimationClip, Material, Mesh, Object3D, Texture } from "three";
4
4
  import { Context } from "./engine_setup.js";
5
5
  import { isPersistentAsset } from "./extensions/NEEDLE_persistent_assets.js";
6
- import { type ConstructorConcrete, type SourceIdentifier } from "./engine_types.js";
6
+ import { Constructor, type ConstructorConcrete, type SourceIdentifier } from "./engine_types.js";
7
7
  import { debugExtension } from "../engine/engine_default_parameters.js";
8
8
  import { LogType, addLog } from "./debug/debug_overlay.js";
9
9
  import { isLocalNetwork } from "./engine_networking_utils.js";
@@ -124,7 +124,7 @@
124
124
  // }
125
125
  // }
126
126
 
127
- constructor(type: ConstructorConcrete<any> | ConstructorConcrete<any>[]) {
127
+ constructor(type: Constructor<any> | Constructor<any>[]) {
128
128
  if (Array.isArray(type)) {
129
129
  for (const key of type)
130
130
  helper.register(key.name, this);
src/engine/engine_three_utils.ts CHANGED
@@ -47,11 +47,24 @@
47
47
 
48
48
 
49
49
  const _tempVecs = new CircularBuffer(() => new Vector3(), 100);
50
- export function getTempVector(value?: Vector3) {
50
+ export function getTempVector(vecOrX?: Vector3 | number | DOMPointReadOnly, y?: number, z?: number) {
51
51
  const vec = _tempVecs.get();
52
- if(value instanceof Vector3) vec.copy(value);
52
+ if (vecOrX instanceof Vector3) vec.copy(vecOrX);
53
+ else if (vecOrX instanceof DOMPointReadOnly) vec.set(vecOrX.x, vecOrX.y, vecOrX.z);
54
+ else {
55
+ if (typeof vecOrX === "number") vec.x = vecOrX;
56
+ if (typeof y === "number") vec.y = y;
57
+ if (typeof z === "number") vec.z = z;
58
+ }
53
59
  return vec;
54
60
  }
61
+ const _tempQuats = new CircularBuffer(() => new Quaternion(), 100);
62
+ export function getTempQuaternion(value?: Quaternion | DOMPointReadOnly) {
63
+ const val = _tempQuats.get();
64
+ if (value instanceof Quaternion) val.copy(value);
65
+ else if (value instanceof DOMPointReadOnly) val.set(value.x, value.y, value.z, value.w);
66
+ return val;
67
+ }
55
68
 
56
69
 
57
70
  const _worldPositions = new CircularBuffer(() => new Vector3(), 100);
src/engine/engine_types.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import { CollisionDetectionMode, type PhysicsMaterial, RigidbodyConstraints } from "./engine_physics.types.js";
6
6
  import { CircularBuffer } from "./engine_utils.js";
7
7
  import { type GLTF as GLTF3 } from "three/examples/jsm/loaders/GLTFLoader.js";
8
+ import { type INeedleXRSessionEventReceiver } from "./engine_xr.js";
8
9
 
9
10
  export type GLTF = GLTF3 & {
10
11
  // asset: { generator: string, version: string }
@@ -72,13 +73,14 @@
72
73
 
73
74
  scripts: IComponent[];
74
75
  scripts_pausedChanged: IComponent[];
75
- // scripts with update event
76
76
  scripts_earlyUpdate: IComponent[];
77
77
  scripts_update: IComponent[];
78
78
  scripts_lateUpdate: IComponent[];
79
79
  scripts_onBeforeRender: IComponent[];
80
80
  scripts_onAfterRender: IComponent[];
81
81
  scripts_WithCorroutines: IComponent[];
82
+ scripts_immersive_vr: INeedleXRSessionEventReceiver[];
83
+ scripts_immersive_ar: INeedleXRSessionEventReceiver[];
82
84
  coroutines: { [FrameEvent: number]: Array<CoroutineData> };
83
85
 
84
86
  post_setup_callbacks: Function[];
@@ -90,6 +92,7 @@
90
92
  new_script_start: IComponent[];
91
93
  new_scripts_pre_setup_callbacks: Function[];
92
94
  new_scripts_post_setup_callbacks: Function[];
95
+ new_scripts_xr: INeedleXRSessionEventReceiver[];
93
96
 
94
97
  stopAllCoroutinesFrom(script: IComponent);
95
98
  }
@@ -507,3 +510,18 @@
507
510
  /** Enable to visualize raycasts in the scene with gizmos */
508
511
  debugRenderRaycasts: boolean;
509
512
  }
513
+
514
+
515
+ /** Typical mouse button names for most devices */
516
+ export type MouseButtonName = "left" | "right" | "middle";
517
+
518
+ /** Button names on typical controllers (since there seems to be no agreed naming)
519
+ * https://w3c.github.io/gamepad/#remapping
520
+ */
521
+ export type GamepadButtonName = "a-button" | "b-button" | "x-button" | "y-button";
522
+ /** Button names as used in the xr profile */
523
+
524
+ export type XRControllerButtonName = "thumbrest" | "xr-standard-trigger" | "xr-standard-squeeze" | "xr-standard-thumbstick" | "xr-standard-touchpad" | "menu" | GamepadButtonName;
525
+
526
+ /** All known (types) button names for various devices and cases combined. You should use the device specific names if you e.g. know you only deal with a mouse use MouseButtonName */
527
+ export type ButtonName = "unknown" | MouseButtonName | GamepadButtonName | XRControllerButtonName;
src/engine/engine_utils.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  return nameofFactory<T>()(name);
9
9
  }
10
10
 
11
+ type ParseNumber<T> = T extends `${infer U extends number}` ? U : never;
12
+ export type EnumToPrimitiveUnion<T> = `${T & string}` | ParseNumber<`${T & number}`>;
11
13
 
12
14
  export function isDebugMode(): boolean {
13
15
  return getParam("debug") ? true : false;
@@ -516,10 +518,6 @@
516
518
  return json;
517
519
  }
518
520
 
519
-
520
-
521
-
522
-
523
521
  declare type AttributeChangeCallback = (value: string | null) => void;
524
522
  declare type HtmlElementExtra = {
525
523
  observer: MutationObserver,
@@ -611,4 +609,43 @@
611
609
  anyFailed: anyFailed,
612
610
  results: res,
613
611
  };
612
+ }
613
+
614
+
615
+
616
+
617
+
618
+
619
+ /** using https://github.com/davidshimjs/qrcodejs */
620
+ export async function generateQRCode(args: { domElement?: HTMLElement, text: string, width?: number, height?: number, colorDark?: string, colorLight?: string, correctLevel?: any }): Promise<HTMLElement> {
621
+
622
+ // ensure that the QRCode library is loaded
623
+ if (!globalThis["QRCode"]) {
624
+ const url = "https://cdn.rawgit.com/davidshimjs/qrcodejs/gh-pages/qrcode.min.js";
625
+ let script = document.head.querySelector(`script[src="${url}"]`) as HTMLScriptElement;
626
+ if (!script) {
627
+ script = document.createElement("script");
628
+ script.src = url;
629
+ document.head.appendChild(script);
630
+ }
631
+
632
+ await new Promise((res, _) => {
633
+ script.addEventListener("load", () => {
634
+ res(true);
635
+ });
636
+ });
637
+ }
638
+
639
+ const QRCODE = globalThis["QRCode"];
640
+ const target = args.domElement ?? document.createElement("div");
641
+ new QRCODE(target, {
642
+ width: args.width ?? 256,
643
+ height: args.height ?? 256,
644
+ colorDark: "#000000",
645
+ colorLight: "#ffffff",
646
+ correctLevel: QRCODE.CorrectLevel.M,
647
+ ...args,
648
+ });
649
+ console.log("QRCode generated for " + args.text);
650
+ return target;
614
651
  }
src/engine-components/ui/EventSystem.ts CHANGED
@@ -1,13 +1,11 @@
1
1
  import { RaycastOptions, RaycastTestObjectReturnType } from "../../engine/engine_physics.js";
2
- import { Behaviour, Component, GameObject } from "../Component.js";
3
- import { WebXR } from "../webxr/WebXR.js";
4
- import { ControllerEvents, WebXRController } from "../webxr/WebXRController.js";
2
+ import { Behaviour, GameObject } from "../Component.js";
5
3
  import * as ThreeMeshUI from 'three-mesh-ui'
6
4
  import { Context } from "../../engine/engine_setup.js";
7
5
  import { type IPointerEventHandler, PointerEventData, hasPointerEventComponent } from "./PointerEvents.js";
8
6
  import { ObjectRaycaster, Raycaster } from "./Raycaster.js";
9
7
  import { InputEvents, NEPointerEvent, PointerType } from "../../engine/engine_input.js";
10
- import { Mesh, Object3D } from "three";
8
+ import { Object3D } from "three";
11
9
  import type { ICanvasGroup } from "./Interfaces.js";
12
10
  import { getParam } from "../../engine/engine_utils.js";
13
11
  import { UIRaycastUtils } from "./RaycastUtils.js";
@@ -15,6 +13,7 @@
15
13
  import { isDevEnvironment, showBalloonMessage } from "../../engine/debug/index.js";
16
14
  import { Mathf } from "../../engine/engine_math.js";
17
15
  import { isUIObject } from "./Utils.js";
16
+ import { Gizmos } from "../../engine/engine_gizmos.js";
18
17
 
19
18
  const debug = getParam("debugeventsystem");
20
19
 
@@ -112,89 +111,16 @@
112
111
  }
113
112
  }
114
113
 
115
- private _selectStartFn?: any;
116
- private _selectEndFn?: any;
117
- private _selectUpdateFn?: any;
118
- private _handleEventCycleFn?: any;
119
114
  private _handleInputFn?: any;
120
115
 
121
116
  onEnable(): void {
122
- const grabbed: Map<any, Object3D | null> = new Map();
123
- this._selectStartFn ??= (ctrl, args: { grab: THREE.Object3D | null }) => {
124
- if (!args.grab) return;
125
- MeshUIHelper.resetLastSelected();
126
- const opts = new PointerEventData(this.context.input);
127
- opts.inputSource = ctrl;
128
- opts.pointerId = 0;
129
- opts.isDown = ctrl.selectionDown;
130
- opts.isUp = ctrl.selectionUp;
131
- opts.isPressed = ctrl.selectionPressed;
132
- opts.isClicked = false;
133
- grabbed.set(ctrl, args.grab);
134
- if (args.grab && !this.handleEventOnObject(args.grab, opts)) {
135
- args.grab = null;
136
- };
137
- }
138
- this._selectEndFn ??= (ctrl: WebXRController, args: { grab: THREE.Object3D }) => {
139
- if (!args.grab) return;
140
- const opts = new PointerEventData(this.context.input);
141
- opts.inputSource = ctrl;
142
- opts.pointerId = 0;
143
- opts.isDown = ctrl.selectionDown;
144
- opts.isUp = ctrl.selectionUp;
145
- opts.isPressed = ctrl.selectionPressed;
146
- opts.isClicked = ctrl.selectionClick;
147
- this.handleEventOnObject(args.grab, opts);
148
-
149
- const prevGrabbed = grabbed.get(ctrl);
150
- grabbed.set(ctrl, null);
151
- if (prevGrabbed) {
152
-
153
- for (const key of this.pressedByID.keys()) {
154
- const e = this.pressedByID[key] as {
155
- obj: Object3D<Event>;
156
- data: PointerEventData;
157
- handler: IPointerEventHandler;
158
- };
159
-
160
- if (e && e.obj === prevGrabbed && e.handler) {
161
- e.handler.onPointerUp?.call(e.handler, opts);
162
- this.pressedByID.delete(key);
163
- }
164
- }
165
- }
166
- };
167
-
168
- const controllerRcOpts = new RaycastOptions();
169
- this._selectUpdateFn ??= (_ctrl: WebXRController) => {
170
- controllerRcOpts.ray = _ctrl.getRay();
171
- const rc = this.performRaycast(controllerRcOpts) ?? [];
172
- const opts = new PointerEventData(this.context.input);
173
- opts.inputSource = _ctrl;
174
- opts.pointerId = _ctrl.input?.handedness === "right" ? 0 : 1;
175
- opts.isDown = _ctrl.selectionDown;
176
- opts.isUp = _ctrl.selectionUp;
177
- opts.isPressed = _ctrl.selectionPressed;
178
- opts.isClicked = false;
179
- this.handleIntersections(opts.pointerId, rc, opts);
180
- };
181
-
182
- WebXRController.addEventListener(ControllerEvents.SelectStart, this._selectStartFn);
183
- WebXRController.addEventListener(ControllerEvents.SelectEnd, this._selectEndFn);
184
- WebXRController.addEventListener(ControllerEvents.Update, this._selectUpdateFn);
185
-
186
- this._handleInputFn = this.onPointerEvent.bind(this);
187
-
117
+ this._handleInputFn ??= this.onPointerEvent.bind(this);
188
118
  this.context.input.addEventListener(InputEvents.PointerDown, this._handleInputFn);
189
119
  this.context.input.addEventListener(InputEvents.PointerUp, this._handleInputFn);
190
120
  this.context.input.addEventListener(InputEvents.PointerMove, this._handleInputFn);
191
121
  }
192
122
 
193
123
  onDisable(): void {
194
- WebXRController.removeEventListener(ControllerEvents.SelectStart, this._selectStartFn);
195
- WebXRController.removeEventListener(ControllerEvents.SelectEnd, this._selectEndFn);
196
- WebXRController.removeEventListener(ControllerEvents.Update, this._selectUpdateFn);
197
-
198
124
  this.context.input.removeEventListener(InputEvents.PointerDown, this._handleInputFn);
199
125
  this.context.input.removeEventListener(InputEvents.PointerUp, this._handleInputFn);
200
126
  this.context.input.removeEventListener(InputEvents.PointerMove, this._handleInputFn);
@@ -224,28 +150,32 @@
224
150
  */
225
151
  private onPointerEvent(pointerEvent: NEPointerEvent) {
226
152
  if (pointerEvent === undefined) return;
153
+ if (pointerEvent.propagationStopped) return;
227
154
 
228
- // On mouse input has to be always 0 regardless of the button user pressed
229
- // because otherwise it would be taken as 3 unique pointers and create OnEnter and OnExit events which is not expected
230
- const id = pointerEvent.pointerType == PointerType.Touch ? pointerEvent.button : 0;
231
- const data = new PointerEventData(this.context.input, pointerEvent);
155
+ // Use the pointerID, this is the touch id or the mouse (always 0, could be index of the mouse DEVICE) or the index of the XRInputSource
156
+ const id = pointerEvent.pointerId * 100 + pointerEvent.button;
157
+ const data = new PointerEventData(id, this.context.input, pointerEvent);
232
158
 
233
159
  data.inputSource = this.context.input;
234
- data.pointerId = pointerEvent.button;
235
- data.isClicked = pointerEvent.type == InputEvents.PointerUp && this.context.input.getPointerClicked(pointerEvent.button)
160
+ data.isClicked = pointerEvent.isClick;
236
161
  // using the input type directly instead of input API -> otherwise onMove events can sometimes be getPointerUp() == true
237
162
  data.isDown = pointerEvent.type == InputEvents.PointerDown;
238
163
  data.isUp = pointerEvent.type == InputEvents.PointerUp;
239
- data.isPressed = this.context.input.getPointerPressed(pointerEvent.button);
164
+ data.isPressed = this.context.input.getPointerPressed(pointerEvent.pointerId);
240
165
 
241
166
  if (debug && data.isClicked) console.log("CLICK", data.pointerId);
242
167
 
243
168
  // raycast
244
169
  const options = new RaycastOptions();
245
- options.screenPoint = this.context.input.getPointerPositionRC(id)!;
170
+ if (pointerEvent.ray) {
171
+ options.ray = pointerEvent.ray;
172
+ }
173
+ else {
174
+ options.screenPoint = this.context.input.getPointerPositionRC(id)!;
175
+ }
246
176
 
177
+
247
178
  const hits = this.performRaycast(options);
248
- if (!hits) return;
249
179
 
250
180
  if (debug && data.isClicked) {
251
181
  showBalloonMessage("EventSystem: " + data.pointerId + " - " + this.context.time.frame + " - Up:" + data.isUp + ", Down:" + data.isDown)
@@ -271,6 +201,9 @@
271
201
  * cache for objects that we want to raycast against. It's cleared before each call to performRaycast invoking raycasters
272
202
  */
273
203
  private readonly _testObjectsCache = new Map<Object3D, boolean>();
204
+ /** that's the raycaster that is CURRENTLY being used for raycasting (the shouldRaycastObject method uses this) */
205
+ private _currentlyActiveRaycaster: Raycaster | null = null;
206
+
274
207
  /**
275
208
  * Checks if an object that we encounter has an event component and if it does, we add it to our objects cache
276
209
  * If it doesnt we tell our raycasting system to ignore it and continue in the child hierarchy
@@ -283,57 +216,72 @@
283
216
  * */
284
217
  private shouldRaycastObject = (obj: Object3D): RaycastTestObjectReturnType => {
285
218
  // check if this object is actually a UI shadow hierarchy object
286
- let shadowComponent: Object3D | null = null;
219
+ let uiOwner: Object3D | null = null;
287
220
  const isUI = isUIObject(obj);
288
221
  // if yes we want to grab the actual object that is the owner of the shadow dom
289
222
  // and check that object for the event component
290
223
  if (isUI) {
291
- shadowComponent = obj[$shadowDomOwner]?.gameObject;
224
+ uiOwner = obj[$shadowDomOwner]?.gameObject;
292
225
  }
293
226
 
294
227
  // check if the object was seen previously
295
- if (this._testObjectsCache.has(obj) || (shadowComponent && this._testObjectsCache.has(shadowComponent))) {
228
+ if (this._testObjectsCache.has(obj) || (uiOwner && this._testObjectsCache.has(uiOwner))) {
296
229
  // if yes we check if it was previously stored as "YES WE NEED TO RAYCAST THIS"
297
230
  const prev = this._testObjectsCache.get(obj)!;
298
231
  if (prev === false) return "continue in children"
299
232
  return true;
300
233
  }
301
234
  else {
235
+
236
+ // if the object has another raycaster component than the one that is currently raycasting, we ignore this here
237
+ // because then this other raycaster is responsible for raycasting this object
238
+ // const rc = GameObject.getComponent(obj, Raycaster);
239
+ // if (rc?.activeAndEnabled && rc !== this._currentlyActiveRaycaster) return false;
240
+
302
241
  // the object was not yet seen so we test if it has an event component
303
242
  let hasEventComponent = hasPointerEventComponent(obj);
304
- if (!hasEventComponent && shadowComponent) hasEventComponent = hasPointerEventComponent(shadowComponent);
243
+ if (!hasEventComponent && uiOwner) hasEventComponent = hasPointerEventComponent(uiOwner);
305
244
 
306
245
  if (hasEventComponent) {
307
246
  // it has an event component: we add it and all its children to the cache
308
247
  // we don't need to do the same for the shadow component hierarchy
309
248
  // because the next object that will be detecting that the shadow owner was already seen
310
249
  this._testObjectsCache.set(obj, true);
311
- obj.traverse((o) => {
312
- this._testObjectsCache.set(o, true);
313
- })
250
+ for (const ch of obj.children) this.shouldRaycastObject_AddToYesCache(ch);
314
251
  return true;
315
252
  }
316
253
  this._testObjectsCache.set(obj, false);
317
254
  return "continue in children"
318
255
  }
319
256
  }
257
+ private shouldRaycastObject_AddToYesCache(obj: Object3D) {
258
+ // if the object has another raycaster component than the one that is currently raycasting, we ignore this here
259
+ // because then this other raycaster is responsible for raycasting this object
260
+ // const rc = GameObject.getComponent(obj, Raycaster);
261
+ // if (rc?.activeAndEnabled && rc !== this._currentlyActiveRaycaster) return;
320
262
 
263
+ this._testObjectsCache.set(obj, true);
264
+ for (const ch of obj.children) {
265
+ this.shouldRaycastObject_AddToYesCache(ch);
266
+ }
267
+ }
268
+
321
269
  /** the raycast filter is always overriden */
322
270
  private performRaycast(opts: RaycastOptions | null): THREE.Intersection[] | null {
323
271
  if (!this.raycaster) return null;
324
-
272
+ // we clear the cache of previously seen objects
273
+ this._testObjectsCache.clear();
325
274
  this._sortedHits.length = 0;
326
275
 
327
276
  if (!opts) opts = new RaycastOptions();
328
-
329
- // we clear the cache of previously seen objects
330
- this._testObjectsCache.clear();
331
277
  opts.testObject = this.shouldRaycastObject;
332
278
 
333
279
  for (const rc of this.raycaster) {
334
280
  if (!rc.activeAndEnabled) continue;
335
281
 
282
+ this._currentlyActiveRaycaster = rc;
336
283
  const res = rc.performRaycast(opts);
284
+ this._currentlyActiveRaycaster = null;
337
285
 
338
286
  if (res && res.length > 0) {
339
287
  // console.log(res.length, res.map(r => r.object.name));
@@ -346,10 +294,13 @@
346
294
  return this._sortedHits;
347
295
  }
348
296
 
349
- private handleIntersections(id:number, hits: THREE.Intersection[], args: PointerEventData): boolean {
297
+ private handleIntersections(id: number, hits: THREE.Intersection[] | null | undefined, args: PointerEventData): boolean {
350
298
  if (hits?.length) {
351
299
  hits = this.sortCandidates(hits);
352
300
  for (const hit of hits) {
301
+ if (args.event.immediatePropagationStopped) {
302
+ return false;
303
+ }
353
304
  const { object } = hit;
354
305
  args.point = hit.point;
355
306
  args.normal = hit.normal;
@@ -367,7 +318,7 @@
367
318
  // thus is not hovering over anything
368
319
  const hoveredData = this.hoveredByID.get(id);
369
320
  if (hoveredData) {
370
- this.triggerOnExit(hoveredData.obj, hoveredData.data);
321
+ this.triggerOnExit(hoveredData.obj, hoveredData.data, null);
371
322
  }
372
323
  this.hoveredByID.delete(id);
373
324
 
@@ -423,7 +374,7 @@
423
374
 
424
375
  // Event without pointer can't be handled
425
376
  if (args.pointerId === undefined) {
426
- if(debug) console.warn("Event without pointer can't be handled", args);
377
+ if (debug) console.warn("Event without pointer can't be handled", args);
427
378
  return false;
428
379
  }
429
380
 
@@ -472,11 +423,12 @@
472
423
  // Handle OnPointerExit -> in case when we are about to hover something new
473
424
  // TODO: we need to keep track of the components that already received a PointerEnterEvent -> we can have a hierarchy where the hovered object changes but the component is on the parent and should not receive a PointerExit event because it's still hovered (just another child object)
474
425
  const hovering = this.hoveredByID.get(args.pointerId);
475
- const isNewlyHovering = hovering?.obj !== object;
426
+ const prevHovering = hovering?.obj;
427
+ const isNewlyHovering = prevHovering !== object;
476
428
 
477
429
  // trigger onPointerExit
478
- if (isNewlyHovering && hovering?.obj) {
479
- this.triggerOnExit(hovering.obj, hovering.data);
430
+ if (isNewlyHovering && prevHovering) {
431
+ this.triggerOnExit(prevHovering, hovering.data, object);
480
432
  }
481
433
 
482
434
  // save hovered object
@@ -499,7 +451,7 @@
499
451
  }
500
452
  }
501
453
  if (canvasGroup === null || canvasGroup.interactable) {
502
- this.handleMainInteraction(object, args, isNewlyHovering);
454
+ this.handleMainInteraction(object, args, prevHovering ?? null);
503
455
  }
504
456
 
505
457
  return true;
@@ -508,22 +460,20 @@
508
460
  /**
509
461
  * Propagate up in hiearchy and call the callback for each component that is possibly a handler
510
462
  */
511
- private propagate(object: THREE.Object3D, _args: PointerEventData, onComponent: (behaviour: Behaviour) => void) {
463
+ private propagate(object: Object3D | null, args: PointerEventData, onComponent: (behaviour: Behaviour) => void) {
512
464
 
513
465
  while (true) {
466
+
514
467
  // Propagate up the hierarchy
468
+ if (args.used) break;
515
469
 
516
- if(_args.used) return;
470
+ if (!object) break;
517
471
 
518
472
  GameObject.foreachComponent(object, comp => {
519
473
  // TODO: implement Stop Immediate Propagation
520
-
521
474
  onComponent(comp);
522
- // return undefined to continue iterating
523
- return undefined;
524
475
  }, false);
525
476
 
526
- if (!object.parent) break;
527
477
  // walk up
528
478
  object = object.parent;
529
479
  }
@@ -533,18 +483,27 @@
533
483
  /**
534
484
  * Propagate up in hiearchy and call handlers based on the pointer event data
535
485
  */
536
- private handleMainInteraction(object: THREE.Object3D, args: PointerEventData, isNewlyHovering: boolean) {
486
+ private handleMainInteraction(object: Object3D, args: PointerEventData, prevHovering: Object3D | null) {
537
487
  if (args.pointerId === undefined) return;
538
488
  const pressedEvent = this.pressedByID.get(args.pointerId);
489
+ const hoveredObjectChanged = prevHovering !== object;
539
490
 
491
+ const posLastFrame = this.context.input.getPointerPositionLastFrame(args.pointerId!)!;
492
+ const posThisFrame = this.context.input.getPointerPosition(args.pointerId!)!;
493
+ const isMoving = posLastFrame && !Mathf.approximately(posLastFrame, posThisFrame);
494
+
540
495
  this.propagate(object, args, (behaviour) => {
541
496
  const comp = behaviour as any;
542
497
 
543
498
  if (comp.interactable === false) return;
544
499
 
545
500
  if (comp.onPointerEnter) {
546
- if (isNewlyHovering) {
547
- comp.onPointerEnter(args);
501
+ if (hoveredObjectChanged) {
502
+ if (!comp[this.pointerEnterSymbol]) {
503
+ comp[this.pointerEnterSymbol] = true;
504
+ delete comp[this.pointerExitSymbol];
505
+ comp.onPointerEnter(args);
506
+ }
548
507
  }
549
508
  }
550
509
 
@@ -559,12 +518,9 @@
559
518
  }
560
519
  }
561
520
 
562
- const posLastFrame = this.context.input.getPointerPositionLastFrame(args.pointerId!)!;
563
- const posThisFrame = this.context.input.getPointerPosition(args.pointerId!)!;
564
- const isMoving = posLastFrame && !Mathf.approximately(posLastFrame, posThisFrame);
565
-
566
- if (isMoving && comp.onPointerMove) {
567
- comp.onPointerMove(args);
521
+ if (comp.onPointerMove) {
522
+ if (isMoving)
523
+ comp.onPointerMove(args);
568
524
  }
569
525
 
570
526
  if (args.isUp) {
@@ -609,19 +565,35 @@
609
565
  /**
610
566
  * Propagate up in hiearchy and call OnExit regardless of the pointer event data
611
567
  */
612
- private triggerOnExit(object: THREE.Object3D, args: PointerEventData) {
613
- args.used = false;
568
+ private triggerOnExit(object: Object3D, args: PointerEventData, newObject: Object3D | null) {
614
569
 
615
570
  this.propagate(object, args, (behaviour) => {
616
571
  if (!behaviour.gameObject || behaviour.destroyed) return;
617
572
 
618
573
  const inst: any = behaviour;
619
574
  if (inst.onPointerExit) {
575
+ // if the newly hovered object is a child of the current object, we don't want to call onPointerExit
576
+ if (newObject && this.isChild(newObject, behaviour.gameObject)) {
577
+ return;
578
+ }
579
+ if (inst[this.pointerExitSymbol]) return;
580
+ inst[this.pointerExitSymbol] = true;
581
+ delete inst[this.pointerEnterSymbol];
620
582
  inst.onPointerExit(args);
621
583
  }
622
584
  });
623
585
  }
624
586
 
587
+ private readonly pointerEnterSymbol = Symbol("pointerEnter");
588
+ private readonly pointerExitSymbol = Symbol("pointerExit");
589
+
590
+ private isChild(obj: Object3D, possibleChild: Object3D): boolean {
591
+ if (!obj || !possibleChild) return false;
592
+ if (obj === possibleChild) return true;
593
+ if (!obj.parent) return false;
594
+ return this.isChild(obj.parent, possibleChild);
595
+ }
596
+
625
597
  private handleMeshUiObjectWithoutShadowDom(obj: any, pressed: boolean) {
626
598
  if (!obj || !obj.isUI) return true;
627
599
  const hit = this.handleMeshUIIntersection(obj, pressed);
@@ -629,7 +601,7 @@
629
601
  return hit;
630
602
  }
631
603
 
632
- private currentActiveMeshUIComponents: ThreeMeshUI.Block[] = [];
604
+ private currentActiveMeshUIComponents: Object3D[] = [];
633
605
 
634
606
  private handleMeshUIIntersection(meshUiObject: THREE.Object3D, pressed: boolean): boolean {
635
607
  const res = MeshUIHelper.updateState(meshUiObject, pressed);
@@ -697,8 +669,8 @@
697
669
  threeMeshUI.update();
698
670
  }
699
671
 
700
- static updateState(intersect: THREE.Object3D, _selectState: boolean): ThreeMeshUI.Block | null {
701
- let foundBlock: ThreeMeshUI.Block | null = null;
672
+ static updateState(intersect: THREE.Object3D, _selectState: boolean): Object3D | null {
673
+ let foundBlock: Object3D | null = null;
702
674
 
703
675
  if (intersect) {
704
676
  foundBlock = this.findBlockInParent(intersect);
@@ -725,7 +697,7 @@
725
697
  this.needsUpdate = true;
726
698
  }
727
699
 
728
- static findBlockInParent(elem: any): ThreeMeshUI.Block | null {
700
+ static findBlockInParent(elem: any): Object3D | null {
729
701
  if (!elem) return null;
730
702
  if (elem.isBlock) {
731
703
  // @TODO : Replace states managements
src/engine-components/ui/Graphic.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import { RGBAColor } from "../js-extensions/RGBAColor.js"
4
4
  import { BaseUIComponent } from "./BaseUIComponent.js";
5
5
  import { serializable } from '../../engine/engine_serialization_decorator.js';
6
- import { Color, LinearSRGBColorSpace, SRGBColorSpace, Texture } from 'three';
6
+ import { Color, LinearSRGBColorSpace, Object3D, SRGBColorSpace, Texture } from 'three';
7
7
  import { RectTransform } from './RectTransform.js';
8
8
  import { onChange, scheduleAction } from "./Utils.js"
9
9
  import { GameObject } from '../Component.js';
@@ -137,7 +137,7 @@
137
137
  onEnable(): void {
138
138
  super.onEnable();
139
139
  if (this.uiObject) {
140
- this.rectTransform.shadowComponent?.add(this.uiObject);
140
+ this.rectTransform.shadowComponent?.add(this.uiObject as unknown as Object3D);
141
141
  this.addShadowComponent(this.uiObject, this.rectTransform);
142
142
  }
143
143
 
src/engine-components/GroundProjection.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Behaviour, GameObject } from "./Component.js";
2
- import { GroundProjectedSkybox as GroundProjection } from 'three/examples/jsm/objects/GroundProjectedSkybox.js';
2
+ import { GroundedSkybox as GroundProjection } from 'three/examples/jsm/objects/GroundedSkybox.js';
3
3
  import { serializable } from "../engine/engine_serialization_decorator.js";
4
4
  import { Watch as Watch, getParam } from "../engine/engine_utils.js";
5
5
  import { Texture } from "three";
@@ -81,14 +81,19 @@
81
81
  if (!this.env || this.context.scene.environment !== this._lastEnvironment) {
82
82
  if (debug)
83
83
  console.log("Create/Update Ground Projection", this.context.scene.environment.name);
84
- this.env = new GroundProjection(this.context.scene.environment);
84
+ this.env = new GroundProjection(this.context.scene.environment, this._height, this.radius, 32);
85
+ this.env.position.y = this._height;
85
86
  }
86
87
  this._lastEnvironment = this.context.scene.environment;
87
88
  if (!this.env.parent)
88
89
  this.gameObject.add(this.env);
90
+
91
+ /* TODO realtime adjustments aren't possible anymore with GroundedSkybox (mesh generation)
89
92
  this.env.scale.setScalar(this._scale);
90
93
  this.env.radius = this._radius;
91
94
  this.env.height = this._height;
95
+ */
96
+
92
97
  // dont make the ground projection raycastable by default
93
98
  if (this.env.isObject3D === true) {
94
99
  this.env.layers.set(2);
src/engine-components/webxr/index.ts CHANGED
@@ -1,4 +1,3 @@
1
- export * from "./WebXR.js";
2
1
  export * from "./WebXRPlaneTracking.js";
3
2
  export * from "./WebXRImageTracking.js";
4
- export * from "./WebXRController.js";
3
+ export { WebXR as WebXR } from "./WebXR.js";
src/engine-components/Interactable.ts CHANGED
@@ -1,19 +1,11 @@
1
1
  import { Behaviour } from "./Component.js";
2
- import type { IPointerClickHandler, PointerEventData } from "./ui/PointerEvents.js";
3
2
 
4
-
5
- export class Interactable extends Behaviour implements IPointerClickHandler {
6
-
7
- canGrab : boolean = true;
8
-
9
- onPointerClick(_args: PointerEventData) {
10
- }
11
- }
12
-
13
-
14
- // TODO: how do we sync things like that...
3
+ /**
4
+ * Marks an object as currently being interacted with.
5
+ * For example, DragControls set this on the dragged object to prevent DeleteBox from deleting it.
6
+ */
15
7
  export class UsageMarker extends Behaviour
16
8
  {
17
- public isUsed : boolean = true;
18
- public usedBy : any = null;
9
+ public isUsed: boolean = true;
10
+ public usedBy: any = null;
19
11
  }
src/engine-components/Light.ts CHANGED
@@ -5,9 +5,9 @@
5
5
  import { FrameEvent } from "../engine/engine_setup.js";
6
6
  import { serializable } from "../engine/engine_serialization_decorator.js";
7
7
  import { Color, DirectionalLight, OrthographicCamera } from "three";
8
- import { WebXR, WebXREvent } from "./webxr/WebXR.js";
9
8
  import { WebARSessionRoot } from "./webxr/WebARSessionRoot.js";
10
9
  import type { ILight } from "../engine/engine_types.js";
10
+ import { NeedleXREventArgs } from "../needle-engine.js";
11
11
 
12
12
  // https://threejs.org/examples/webgl_shadowmap_csm.html
13
13
 
@@ -270,8 +270,6 @@
270
270
  }
271
271
  if (this.type === LightType.Directional)
272
272
  this.startCoroutine(this.updateMainLightRoutine(), FrameEvent.LateUpdate);
273
- this._webXRStartedListener = WebXR.addEventListener(WebXREvent.XRStarted, this.onWebXRStarted.bind(this));
274
- this._webXREndedListener = WebXR.addEventListener(WebXREvent.XRStopped, this.onWebXREnded.bind(this));
275
273
  }
276
274
 
277
275
  onDisable() {
@@ -282,15 +280,13 @@
282
280
  else
283
281
  this.light.visible = false;
284
282
  }
285
- WebXR.removeEventListener(WebXREvent.XRStarted, this._webXRStartedListener);
286
- WebXR.removeEventListener(WebXREvent.XRStopped, this._webXREndedListener);
287
283
  }
288
284
 
289
285
  private _webXRStartedListener?: Function;
290
286
  private _webXREndedListener?: Function;
291
287
  private _webARRoot?: WebARSessionRoot;
292
288
 
293
- private onWebXRStarted() {
289
+ onEnterXR(_args: NeedleXREventArgs): void {
294
290
  this._webARRoot = GameObject.getComponentInParent(this.gameObject, WebARSessionRoot) ?? undefined;
295
291
  // this.startCoroutine(this._updateLightIntensityInARRoutine());
296
292
  }
@@ -303,7 +299,7 @@
303
299
  // }
304
300
  // }
305
301
 
306
- private onWebXREnded() {
302
+ onLeaveXR(_args: NeedleXREventArgs): void {
307
303
  // this.updateIntensity();
308
304
  }
309
305
 
src/engine/extensions/NEEDLE_techniques_webgl.ts CHANGED
@@ -88,7 +88,9 @@
88
88
  if (debug)
89
89
  console.log(this);
90
90
 
91
+ //@ts-ignore - TODO: how to override and do we even need this?
91
92
  this.type = "NEEDLE_CUSTOM_SHADER";
93
+
92
94
  if (!this.uniforms[this._objToWorldName])
93
95
  this.uniforms[this._objToWorldName] = { value: [] };
94
96
  if (!this.uniforms[this._worldToObjectName])
src/engine-components/utils/OpenURL.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import { Behaviour } from "../Component.js";
4
4
  import { serializable } from "../../engine/engine_serialization.js";
5
5
  import { isDevEnvironment, showBalloonMessage } from "../../engine/debug/index.js";
6
- import { isSafari } from "../../engine/engine_utils.js";
6
+ import { isSafari, isiOS } from "../../engine/engine_utils.js";
7
7
  import { ObjectRaycaster, Raycaster } from "../ui/Raycaster.js";
8
8
  import { tryGetUIComponent } from "../ui/Utils.js";
9
9
 
@@ -34,7 +34,6 @@
34
34
 
35
35
  if (isDevEnvironment()) showBalloonMessage("Open URL: " + this.url)
36
36
 
37
-
38
37
  switch (this.mode) {
39
38
  case OpenURLMode.NewTab:
40
39
  if (isSafari()) {
@@ -44,10 +43,12 @@
44
43
  globalThis.open(this.url, "_blank");
45
44
  break;
46
45
  case OpenURLMode.SameTab:
47
- if (isSafari()) {
46
+ // TODO: test if "same tab" now also works on iOS
47
+ if (isSafari() && isiOS()) {
48
48
  globalThis.open(this.url, "_top");
49
49
  }
50
- else globalThis.open(this.url, "_self");
50
+ else
51
+ globalThis.open(this.url, "_self");
51
52
  break;
52
53
  case OpenURLMode.NewWindow:
53
54
  if (isSafari()) {
@@ -58,19 +59,10 @@
58
59
 
59
60
  }
60
61
  }
61
-
62
62
  start(): void {
63
63
  const raycaster = this.gameObject.getComponentInParent(ObjectRaycaster);
64
64
  if (!raycaster) this.gameObject.addNewComponent(ObjectRaycaster);
65
65
  }
66
-
67
- onEnable(): void {
68
- if (isSafari()) window.addEventListener("touchend", this._safariNewTabWorkaround);
69
- }
70
- onDisable(): void {
71
- if (isSafari()) window.removeEventListener("touchend", this._safariNewTabWorkaround);
72
- }
73
-
74
66
  onPointerEnter(args) {
75
67
  if (!args.used && this.clickable)
76
68
  this.context.input.setCursorPointer();
@@ -83,30 +75,6 @@
83
75
  if (this.clickable && !args.used && this.url?.length)
84
76
  this.open();
85
77
  }
86
-
87
- private _safariNewTabWorkaround = () => {
88
- if (!this.clickable || !this.url?.length) return;
89
- // we only need this workaround for opening a new tab
90
- if (this.mode === OpenURLMode.SameTab) return;
91
- // When we process the click directly in the browser event we can open a new tab
92
- // by emitting a link attribute and calling onClick
93
- const raycaster = this.gameObject.getComponentInParent(Raycaster);
94
- if (raycaster) {
95
- const hits = raycaster.performRaycast();
96
- if (!hits) return;
97
- for (const hit of hits) {
98
- if (hit.object === this.gameObject || tryGetUIComponent(hit.object)?.gameObject === this.gameObject) {
99
- this._validateUrl();
100
- var a = document.createElement('a') as HTMLAnchorElement;
101
- a.setAttribute("target", "_blank");
102
- a.setAttribute("href", this.url);
103
- a.click();
104
- break;
105
- }
106
- }
107
- }
108
- }
109
-
110
78
  private _validateUrl() {
111
79
  if (!this.url) return;
112
80
  if (this.url.startsWith("www.")) {
src/engine-components/OrbitControls.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  import { setCameraController, useForAutoFit } from "../engine/engine_camera.js";
14
14
  import { SyncedTransform } from "./SyncedTransform.js";
15
15
  import { tryGetUIComponent } from "./ui/Utils.js";
16
- import { GroundProjectedSkybox } from "three/examples/jsm/objects/GroundProjectedSkybox.js";
16
+ import { GroundedSkybox } from "three/examples/jsm/objects/GroundedSkybox.js";
17
17
  import { Mathf } from "../engine/engine_math.js";
18
18
  import { Gizmos } from "../engine/engine_gizmos.js";
19
19
 
@@ -373,7 +373,7 @@
373
373
  this._controls.enableZoom = false;
374
374
  }
375
375
  }
376
- //@ts-ignore
376
+
377
377
  // this._controls.zoomToCursor = this.zoomToCursor;
378
378
  if (!this.context.isInXR) {
379
379
  if (!freeCam && this.lookAtConstraint?.locked) this.setLookTargetFromConstraint(0, this.lookAtConstraint01);
@@ -542,7 +542,7 @@
542
542
  if (obj instanceof Box3Helper) allowExpanding = false;
543
543
  if (obj instanceof GridHelper) allowExpanding = false;
544
544
  // ignore GroundProjectedEnv
545
- if (obj instanceof GroundProjectedSkybox) allowExpanding = false;
545
+ if (obj instanceof GroundedSkybox) allowExpanding = false;
546
546
  // // Ignore shadow catcher geometry
547
547
  if ((obj as Mesh).material instanceof ShadowMaterial) allowExpanding = false;
548
548
  // ONLY fit meshes
src/engine-components/PlayerColor.ts CHANGED
@@ -1,40 +1,44 @@
1
1
  import { RoomEvents } from "../engine/engine_networking.js";
2
2
  import { Behaviour, GameObject } from "./Component.js";
3
3
  import * as THREE from "three";
4
- import { AvatarMarker } from "./webxr/WebXRAvatar.js";
5
4
  import { WaitForSeconds } from "../engine/engine_coroutine.js";
5
+ import { PlayerState } from "../engine-components-experimental/networking/PlayerSync.js";
6
+ import { AvatarMarker } from "./api.js";
6
7
 
7
8
 
8
9
  export class PlayerColor extends Behaviour {
9
10
 
10
- awake(): void {
11
- // console.log("AWAKE", this.name);
12
- this.context.connection.beginListen(RoomEvents.JoinedRoom, this.tryAssignColor.bind(this));
13
- }
14
-
15
11
  private _didAssignPlayerColor: boolean = false;
16
12
 
17
13
  onEnable(): void {
18
- // console.log("ENABLE", this.name);
14
+ this.context.connection.beginListen(RoomEvents.JoinedRoom, this.tryAssignColor);
19
15
  if (!this._didAssignPlayerColor)
20
16
  this.startCoroutine(this.waitForConnection());
21
17
  }
18
+ onDisable(): void {
19
+ this.context.connection.stopListen(RoomEvents.JoinedRoom, this.tryAssignColor);
20
+ }
22
21
 
23
22
  private *waitForConnection() {
24
- while (!this.destroyed && this.enabled) {
23
+ while (!this.destroyed && this.activeAndEnabled) {
25
24
  yield WaitForSeconds(.2);
26
25
  if (this.tryAssignColor()) break;
27
26
  }
28
- // console.log("STOP WAITING", this.name, this.destroyed);
29
27
  }
30
28
 
31
- private tryAssignColor(): boolean {
32
- const marker = GameObject.getComponentInParent(this.gameObject, AvatarMarker);
33
- if (marker && marker.connectionId) {
29
+ private tryAssignColor = () => {
30
+ const marker = GameObject.getComponentInParent(this.gameObject, PlayerState);
31
+ if (marker && marker.owner) {
34
32
  this._didAssignPlayerColor = true;
35
- this.assignUserColor(marker.connectionId);
33
+ this.assignUserColor(marker.owner);
36
34
  return true;
37
35
  }
36
+ const avatar = GameObject.getComponentInParent(this.gameObject, AvatarMarker);
37
+ if (avatar?.connectionId) {
38
+ this._didAssignPlayerColor = true;
39
+ this.assignUserColor(avatar.connectionId);
40
+ return true;
41
+ }
38
42
  return false;
39
43
  }
40
44
 
src/engine-components-experimental/networking/PlayerSync.ts CHANGED
@@ -4,36 +4,66 @@
4
4
  import { syncField } from "../../engine/engine_networking_auto.js"
5
5
  import { RoomEvents } from "../../engine/engine_networking.js";
6
6
  import { syncDestroy } from "../../engine/engine_networking_instantiate.js";
7
- import { getParam } from "../../engine/engine_utils.js";
7
+ import { delay, getParam } from "../../engine/engine_utils.js";
8
8
 
9
9
  import { Object3D } from "three";
10
10
  import { EventList } from "../../engine-components/EventList.js";
11
+ import { IGameObject } from "../../needle-engine.js";
11
12
 
12
13
 
13
14
  const debug = getParam("debugplayersync");
14
15
 
15
16
  export class PlayerSync extends Behaviour {
17
+
18
+ /** when enabled PlayerSync will automatically load and instantiate the assigned asset when joining a networked room */
19
+ @serializable()
20
+ autoSync: boolean = true;
21
+
22
+ /** This asset will be loaded and instantiated when PlayerSync becomes active and joins a networked room */
16
23
  @serializable(AssetReference)
17
24
  asset?: AssetReference;
18
25
 
26
+ /** Event called when */
19
27
  @serializable(EventList)
20
28
  onPlayerSpawned?: EventList;
21
29
 
30
+
31
+ private _localInstance?: Promise<IGameObject>;
32
+
22
33
  awake(): void {
23
34
  this.watchTabVisible();
35
+ if (!this.onPlayerSpawned) this.onPlayerSpawned = new EventList();
24
36
  }
25
37
 
26
38
  onEnable(): void {
27
39
  this.context.connection.beginListen(RoomEvents.RoomStateSent, this.onJoinedRoom);
40
+ this.context.connection.beginListen(RoomEvents.JoinedRoom, this.onJoinedRoom);
41
+ if (this.context.connection.isInRoom) {
42
+ this.onJoinedRoom();
43
+ }
28
44
  }
29
45
  onDisable(): void {
30
- this.context.connection.stopListen(RoomEvents.RoomStateSent, this.onJoinedRoom)
46
+ this.context.connection.stopListen(RoomEvents.RoomStateSent, this.onJoinedRoom);
47
+ this.context.connection.stopListen(RoomEvents.JoinedRoom, this.onJoinedRoom);
31
48
  }
32
49
 
33
- private onJoinedRoom = async (_model) => {
34
- if (debug) console.log("PlayerSync.onUserJoined", _model);
50
+ private onJoinedRoom = () => {
51
+ if (debug) console.log("PlayerSync.joinedRoom. autoSync is set to " + this.autoSync);
52
+ if (this.autoSync) this.getInstance();
53
+ }
35
54
 
36
- const instance = await this.asset?.instantiateSynced({ parent: this.gameObject }, true);
55
+ async getInstance() {
56
+ if (this._localInstance) return this._localInstance;
57
+
58
+ if (debug) console.log("PlayerSync.createInstance", this.asset?.uri);
59
+
60
+ if (!this.asset?.asset && !this.asset?.uri) {
61
+ console.error("PlayerSync: can not create an instance because \"asset\" is not set!");
62
+ return null;
63
+ }
64
+
65
+ this._localInstance = this.asset?.instantiateSynced({ parent: this.gameObject }, true) as Promise<IGameObject>;
66
+ const instance = await this._localInstance;
37
67
  if (instance) {
38
68
  const pl = GameObject.getComponent(instance, PlayerState);
39
69
  if (pl) {
@@ -41,15 +71,29 @@
41
71
  this.onPlayerSpawned?.invoke(instance);
42
72
  }
43
73
  else {
74
+ this._localInstance = undefined;
44
75
  console.error("<strong>Failed finding PlayerState on " + this.asset?.uri + "</strong>: please make sure the asset has a PlayerState component!");
45
76
  GameObject.destroySynced(instance);
46
77
  }
47
78
  }
48
- else{
79
+ else {
80
+ this._localInstance = undefined;
49
81
  console.warn("PlayerSync: failed instantiating asset!")
50
82
  }
83
+
84
+ return this._localInstance;
51
85
  }
52
86
 
87
+ destroyInstance() {
88
+ this._localInstance?.then(go => {
89
+ if (debug) console.log("PlayerSync.destroyInstance", go);
90
+ GameObject.destroySynced(go);
91
+ });
92
+ this._localInstance = undefined;
93
+ }
94
+
95
+
96
+
53
97
  private watchTabVisible() {
54
98
  window.addEventListener("visibilitychange", _ => {
55
99
  if (document.visibilityState === "visible") {
@@ -90,19 +134,22 @@
90
134
  return PlayerState._local;
91
135
  }
92
136
 
93
- //** use to check if a component or gameobject is part of a instance owned by the local player */
94
- static isLocalPlayer(obj: Object3D | Component): boolean {
137
+ static getFor(obj: Object3D | Component) {
95
138
  if (obj instanceof Object3D) {
96
- const state = GameObject.getComponentInParent(obj, PlayerState);
97
- return state?.isLocalPlayer ?? false;
139
+ return GameObject.getComponentInParent(obj, PlayerState);
98
140
  }
99
141
  else if (obj instanceof Component) {
100
- const state = GameObject.getComponentInParent(obj.gameObject, PlayerState);
101
- return state?.isLocalPlayer ?? false;
142
+ return GameObject.getComponentInParent(obj.gameObject, PlayerState);
102
143
  }
103
- return false;
144
+ return undefined;
104
145
  }
105
146
 
147
+ //** use to check if a component or gameobject is part of a instance owned by the local player */
148
+ static isLocalPlayer(obj: Object3D | Component): boolean {
149
+ const state = PlayerState.getFor(obj);
150
+ return state?.isLocalPlayer ?? false;
151
+ }
152
+
106
153
  // static Callback
107
154
  private static _callbacks: { [key: string]: PlayerStateEventCallback[] } = {};
108
155
  /**
@@ -152,13 +199,13 @@
152
199
  }
153
200
 
154
201
  // call local events
155
- if(!this.hasOwner) {
202
+ if (!this.hasOwner) {
156
203
  this.hasOwner = true;
157
204
  this.onFirstOwnerChangeEvent?.invoke(detail);
158
205
  }
159
206
 
160
207
  this.onOwnerChangeEvent?.invoke(detail);
161
-
208
+
162
209
  // call remote events
163
210
  if (this.owner === this.context.connection.connectionId) {
164
211
  PlayerState._local.push(this);
@@ -188,20 +235,60 @@
188
235
  }
189
236
 
190
237
 
191
- start() {
238
+ async start() {
239
+ if (debug) console.log("PLAYERSTATE.START, owner: " + this.owner, this.context.connection.usersInRoom([]))
240
+
241
+ // generate number from owner
242
+ // if (this.owner) {
243
+ // // string to number
244
+ // let num = 0;
245
+ // for (let i = 0; i < this.owner.length; i++) {
246
+ // num += this.owner.charCodeAt(i);
247
+ // }
248
+ // console.log(num)
249
+ // num = num / 1000
250
+ // this.gameObject.position.y = num;
251
+ // }
252
+
192
253
  // If a player is spawned but not in the room anymore we want to destroy it
193
254
  // this might happen in a case where all users get disconnected at once and the server
194
255
  // still has the syncInstantiate messages that are sent to all clients
195
- if (this.owner && !this.context.connection.userIsInRoom(this.owner)) {
196
- if (debug) console.log("PlayerSync.start → doDestroy because user is not in room anymore...", this)
197
- this.doDestroy();
198
- return;
256
+ if (this.owner) {
257
+ // a slight delay is necessary right now because the syncInstantiate call might has created this object already with the owner assigned but the user has not yet joined the room
258
+ if (!this.context.connection.isInRoom) await delay(300);
259
+ if (this.context.connection.userIsInRoom(this.owner) == false) {
260
+ if (debug) console.log(`PlayerSync.start → doDestroy \"${this.name}\" because user \"${this.owner}\" is not in room anymore...`, "Currently in room:", ...this.context.connection.usersInRoom())
261
+ this.doDestroy();
262
+ }
199
263
  }
264
+ else if (!this.owner) {
265
+ if (debug) console.warn("PlayerState.start → owner is undefined!", this.name);
266
+ // we can delete it here immediately because it is not synced anymore or the owner has left the room
267
+ // we could also do this in a timeout and check if the owner is still not assigned after a second (but that would be a hack)
268
+ setTimeout(() => {
269
+ if (!this.destroyed && !this.owner) {
270
+ if (debug) console.warn(`PlayerState.start → owner is still undefined: destroying \"${this.name}\" instance now`);
271
+ this.doDestroy();
272
+ }
273
+ else console.log("PlayerState.start → owner is assigned", this.owner);
274
+ }, 2000);
275
+ }
200
276
  }
201
277
 
278
+ // onEnable() {
279
+ // if (debug) this.startCoroutine(this.debugRoutine());
280
+ // }
281
+
282
+ // *debugRoutine() {
283
+ // while (!this.destroyed && this.activeAndEnabled) {
284
+ // Gizmos.DrawLabel(this.gameObject.worldPosition, this.owner ?? "no owner");
285
+ // yield;
286
+ // }
287
+ // }
288
+
202
289
  /** this tells the server that this client has been destroyed and the networking message for the instantiate will be removed */
203
290
  doDestroy() {
204
- if (debug) console.log("PlayerSync.doDestroy → syncDestroy", this);
291
+ if (debug) console.log("PlayerSync.doDestroy → syncDestroy", this.name);
205
292
  syncDestroy(this.gameObject, this.context.connection);
206
293
  }
207
294
 
src/engine-components/ui/PointerEvents.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { GameObject } from "../Component.js";
2
2
  import { Input, NEPointerEvent } from "../../engine/engine_input.js";
3
3
  import { Face, Object3D, Vector3 } from "three";
4
+ import { GamepadButtonName, MouseButtonName } from "../../engine/engine_types.js";
4
5
 
5
6
  export interface IInputEventArgs {
6
7
  get used(): boolean;
@@ -10,93 +11,117 @@
10
11
 
11
12
  export class PointerEventData implements IInputEventArgs {
12
13
 
13
- // TODO: should we make this a getter and return the input used state instead? -> this.context.getPointerUsed(this.pointerId);
14
- used: boolean = false;
14
+ readonly event: NEPointerEvent;
15
+ readonly pointerId: number;
16
+ /**
17
+ * mouse button 0 === LEFT, 1 === MIDDLE, 2 === RIGHT
18
+ * */
19
+ readonly button: number;
20
+ readonly buttonName: MouseButtonName | GamepadButtonName | undefined;
15
21
 
22
+ private _used: boolean = false;
23
+ get used(): boolean {
24
+ return this._used;
25
+ }
26
+
16
27
  use() {
17
- this.used = true;
28
+ this._used = true;
18
29
  if (this.pointerId !== undefined)
19
30
  this.input.setPointerUsed(this.pointerId);
20
31
  }
21
32
 
33
+ /** @deprecated use `stopImmediatePropagation` */
22
34
  stopPropagation() {
23
- this._event?.stopImmediatePropagation();
35
+ // we currently don't have a distinction between stopPropagation and stopImmediatePropagation
36
+ this.event.stopImmediatePropagation();
24
37
  }
25
-
26
- /**@deprecated use use() */
27
- Use() {
28
- this.use();
38
+ stopImmediatePropagation() {
39
+ this.event.stopImmediatePropagation();
29
40
  }
30
41
 
31
- /**@deprecated use stopPropagation() */
32
- StopPropagation() {
33
- this._event?.stopImmediatePropagation();
34
- }
35
42
 
36
43
  /** Who initiated this event */
37
44
  inputSource: Input | any;
38
45
 
46
+ /** Returns the input target ray mode e.g. "screen" for 2D mouse and touch events */
47
+ get mode(): XRTargetRayMode { return this.event.mode; }
48
+
39
49
  /** The object this event hit or interacted with */
40
50
  object!: THREE.Object3D;
41
51
  /** The world position of this event */
42
52
  point?: Vector3;
43
- /** The world normal of this event */
53
+ /** The object-space normal of this event */
44
54
  normal?: Vector3;
55
+ /** */
45
56
  face?: Face | null;
57
+ /** The distance of the hit point from the origin */
46
58
  distance?: number;
59
+ /** The instance ID of an object hit by a raycast (if a instanced object was hit) */
47
60
  instanceId?: number;
48
61
 
49
- pointerId: number | undefined;
50
62
  isDown: boolean | undefined;
51
63
  isUp: boolean | undefined;
52
64
  isPressed: boolean | undefined;
53
65
  isClicked: boolean | undefined;
54
66
 
55
- /** mouse button 0 === LEFT, 1 === MIDDLE, 2 === RIGHT */
56
- readonly button: number | string;
57
67
 
58
68
  private input: Input;
59
69
 
60
- private _event?: NEPointerEvent;
61
- get event() { return this._event; }
62
-
63
- constructor(input: Input, event?: NEPointerEvent) {
64
- this._event = event;
70
+ constructor(pointerId: number, input: Input, event: NEPointerEvent) {
71
+ this.pointerId = pointerId;
72
+ this.event = event;
65
73
  this.input = input;
66
- this.button = event?.button ?? 0;
74
+ this.button = event.button;
67
75
  }
68
76
 
69
77
  clone() {
70
- const clone = new PointerEventData(this.input, this._event);
78
+ const clone = new PointerEventData(this.pointerId, this.input, this.event);
71
79
  Object.assign(clone, this);
72
80
  return clone;
73
81
  }
82
+
83
+ /**@deprecated use use() */
84
+ Use() {
85
+ this.use();
86
+ }
87
+
88
+ /**@deprecated use stopPropagation() */
89
+ StopPropagation() {
90
+ this.event.stopImmediatePropagation();
91
+ }
74
92
  }
75
93
 
76
94
  export interface IPointerDownHandler {
95
+ /** Called when a button is started to being pressed on an object (or a child object) */
77
96
  onPointerDown?(args: PointerEventData);
78
97
  }
79
98
 
80
99
  export interface IPointerUpHandler {
100
+ /** Called when a button is released (which was previously pressed in `onPointerDown`) */
81
101
  onPointerUp?(args: PointerEventData);
82
102
  }
83
103
 
84
104
  export interface IPointerEnterHandler {
105
+ /** Called when a pointer (mouse, touch, xr controller) starts pointing on/hovering an object (or a child object) */
85
106
  onPointerEnter?(args: PointerEventData);
86
107
  }
87
108
 
88
109
  export interface IPointerMoveHandler {
110
+ /** Called when a pointer (mouse, touch, xr controller) is moving over an object (or a child object) */
89
111
  onPointerMove?(args: PointerEventData);
90
112
  }
91
113
 
92
114
  export interface IPointerExitHandler {
115
+ /** Called when a pointer (mouse, touch, xr controller) exists an object (it was hovering the object before but now it's not anymore) */
93
116
  onPointerExit?(args: PointerEventData);
94
117
  }
95
118
 
96
119
  export interface IPointerClickHandler {
120
+ /** Called when an object (or any child object) is clicked (needs a EventSystem in the scene) */
97
121
  onPointerClick?(args: PointerEventData);
98
122
  }
99
123
 
124
+ /** Implement on your component to receive input events via the `EventSystem` component */
100
125
  export interface IPointerEventHandler extends IPointerDownHandler,
101
126
  IPointerUpHandler, IPointerEnterHandler, IPointerMoveHandler, IPointerExitHandler, IPointerClickHandler { }
102
127
 
src/engine-components/ui/Raycaster.ts CHANGED
@@ -1,11 +1,16 @@
1
1
  import { serializable } from "../../engine/engine_serialization.js";
2
- import { RaycastOptions } from "../../engine/engine_physics.js";
3
- import { Behaviour, Component } from "../Component.js";
2
+ import { IRaycastOptions, RaycastOptions } from "../../engine/engine_physics.js";
3
+ import { Behaviour } from "../Component.js";
4
4
  import { EventSystem } from "./EventSystem.js";
5
5
  import { SkinnedMesh } from "three";
6
+ import { NeedleXRSession } from "../../engine/engine_xr.js";
6
7
 
7
8
 
8
- export class Raycaster extends Behaviour {
9
+ /** Derive from this class to create your own custom Raycaster
10
+ * If you override awake, onEnable or onDisable, be sure to call the base class methods
11
+ * Implement `performRaycast` to perform your custom raycasting logic
12
+ */
13
+ export abstract class Raycaster extends Behaviour {
9
14
  awake(): void {
10
15
  EventSystem.createIfNoneExists(this.context);
11
16
  }
@@ -18,9 +23,7 @@
18
23
  EventSystem.get(this.context)?.unregister(this);
19
24
  }
20
25
 
21
- performRaycast(_opts: RaycastOptions | null = null): THREE.Intersection[] | null {
22
- return null;
23
- }
26
+ abstract performRaycast(_opts?: IRaycastOptions | RaycastOptions | null): THREE.Intersection[] | null;
24
27
  }
25
28
 
26
29
 
@@ -35,7 +38,7 @@
35
38
  this.targets = [this.gameObject];
36
39
  }
37
40
 
38
- performRaycast(opts: RaycastOptions | null = null): THREE.Intersection[] | null {
41
+ performRaycast(opts: IRaycastOptions | RaycastOptions | null = null): THREE.Intersection[] | null {
39
42
  if (!this.targets) return null;
40
43
  opts ??= new RaycastOptions();
41
44
  opts.targets = this.targets;
@@ -70,4 +73,19 @@
70
73
  }
71
74
  }
72
75
 
76
+ export class SpatialGrabRaycaster extends Raycaster {
77
+ performRaycast(_opts?: IRaycastOptions | RaycastOptions | null): THREE.Intersection[] | null {
78
+ // ensure we're in XR, otherwise return
79
+ if (!NeedleXRSession.active) return null;
80
+ if (!_opts?.ray) return null;
73
81
 
82
+ const rayOrigin = _opts.ray.origin;
83
+ const radius = 0.01;
84
+
85
+ // TODO if needed, check if the input source is a XR controller or hand
86
+ // draw gizmo around ray origin
87
+ // Gizmos.DrawSphere(rayOrigin, radius, 0x00ff0022);
88
+
89
+ return this.context.physics.sphereOverlap(rayOrigin, radius);
90
+ }
91
+ }
src/engine/codegen/register_types.ts CHANGED
@@ -13,11 +13,11 @@
13
13
  import { Animator } from "../../engine-components/Animator.js";
14
14
  import { AnimatorController } from "../../engine-components/AnimatorController.js";
15
15
  import { Antialiasing } from "../../engine-components/postprocessing/Effects/Antialiasing.js";
16
- import { AttachedObject } from "../../engine-components/webxr/WebXRController.js";
17
16
  import { AudioExtension } from "../../engine-components/export/usdz/extensions/behavior/AudioExtension.js";
18
17
  import { AudioListener } from "../../engine-components/AudioListener.js";
19
18
  import { AudioSource } from "../../engine-components/AudioSource.js";
20
19
  import { AudioTrackHandler } from "../../engine-components/timeline/TimelineTracks.js";
20
+ import { Avatar } from "../../engine-components/webxr/Avatar.js";
21
21
  import { Avatar_Brain_LookAt } from "../../engine-components/avatar/Avatar_Brain_LookAt.js";
22
22
  import { Avatar_MouthShapes } from "../../engine-components/avatar/Avatar_MouthShapes.js";
23
23
  import { Avatar_MustacheShake } from "../../engine-components/avatar/Avatar_MustacheShake.js";
@@ -53,7 +53,6 @@
53
53
  import { ColorAdjustments } from "../../engine-components/postprocessing/Effects/ColorAdjustments.js";
54
54
  import { ColorBySpeedModule } from "../../engine-components/ParticleSystemModules.js";
55
55
  import { ColorOverLifetimeModule } from "../../engine-components/ParticleSystemModules.js";
56
- import { Component } from "../../engine-components/Component.js";
57
56
  import { ContactShadows } from "../../engine-components/ContactShadows.js";
58
57
  import { ControlTrackHandler } from "../../engine-components/timeline/TimelineTracks.js";
59
58
  import { CustomBranding } from "../../engine-components/export/usdz/USDZExporter.js";
@@ -90,7 +89,6 @@
90
89
  import { Image } from "../../engine-components/ui/Image.js";
91
90
  import { InheritVelocityModule } from "../../engine-components/ParticleSystemModules.js";
92
91
  import { InputField } from "../../engine-components/ui/InputField.js";
93
- import { Interactable } from "../../engine-components/Interactable.js";
94
92
  import { Light } from "../../engine-components/Light.js";
95
93
  import { LimitVelocityOverLifetimeModule } from "../../engine-components/ParticleSystemModules.js";
96
94
  import { LODGroup } from "../../engine-components/LODGroup.js";
@@ -104,6 +102,7 @@
104
102
  import { MeshRenderer } from "../../engine-components/Renderer.js";
105
103
  import { MinMaxCurve } from "../../engine-components/ParticleSystemModules.js";
106
104
  import { MinMaxGradient } from "../../engine-components/ParticleSystemModules.js";
105
+ import { NeedleWebXRHtmlElement } from "../../engine-components/webxr/WebXRButtons.js";
107
106
  import { NestedGltf } from "../../engine-components/NestedGltf.js";
108
107
  import { Networking } from "../../engine-components/Networking.js";
109
108
  import { NoiseModule } from "../../engine-components/ParticleSystemModules.js";
@@ -130,7 +129,6 @@
130
129
  import { PreliminaryTrigger } from "../../engine-components/export/usdz/extensions/behavior/BehaviourComponents.js";
131
130
  import { PresentationMode } from "../../engine-components-experimental/Presentation.js";
132
131
  import { RawImage } from "../../engine-components/ui/Image.js";
133
- import { Raycaster } from "../../engine-components/ui/Raycaster.js";
134
132
  import { Rect } from "../../engine-components/ui/RectTransform.js";
135
133
  import { RectTransform } from "../../engine-components/ui/RectTransform.js";
136
134
  import { ReflectionProbe } from "../../engine-components/ReflectionProbe.js";
@@ -158,6 +156,7 @@
158
156
  import { SizeOverLifetimeModule } from "../../engine-components/ParticleSystemModules.js";
159
157
  import { SkinnedMeshRenderer } from "../../engine-components/Renderer.js";
160
158
  import { SmoothFollow } from "../../engine-components/SmoothFollow.js";
159
+ import { SpatialGrabRaycaster } from "../../engine-components/ui/Raycaster.js";
161
160
  import { SpatialHtml } from "../../engine-components/ui/SpatialHtml.js";
162
161
  import { SpatialTrigger } from "../../engine-components/SpatialTrigger.js";
163
162
  import { SpatialTriggerReceiver } from "../../engine-components/SpatialTrigger.js";
@@ -172,7 +171,7 @@
172
171
  import { SyncedRoom } from "../../engine-components/SyncedRoom.js";
173
172
  import { SyncedTransform } from "../../engine-components/SyncedTransform.js";
174
173
  import { TapGestureTrigger } from "../../engine-components/export/usdz/extensions/behavior/BehaviourComponents.js";
175
- import { TeleportTarget } from "../../engine-components/webxr/WebXRController.js";
174
+ import { TeleportTarget } from "../../engine-components/webxr/TeleportTarget.js";
176
175
  import { TestRunner } from "../../engine-components/TestRunner.js";
177
176
  import { TestSimulateUserData } from "../../engine-components/TestRunner.js";
178
177
  import { Text } from "../../engine-components/ui/Text.js";
@@ -202,23 +201,19 @@
202
201
  import { Volume } from "../../engine-components/postprocessing/Volume.js";
203
202
  import { VolumeParameter } from "../../engine-components/postprocessing/VolumeParameter.js";
204
203
  import { VolumeProfile } from "../../engine-components/postprocessing/VolumeProfile.js";
205
- import { VRUserState } from "../../engine-components/webxr/WebXRSync.js";
206
- import { WebAR } from "../../engine-components/webxr/WebXR.js";
207
204
  import { WebARCameraBackground } from "../../engine-components/webxr/WebARCameraBackground.js";
208
205
  import { WebARSessionRoot } from "../../engine-components/webxr/WebARSessionRoot.js";
209
206
  import { WebXR } from "../../engine-components/webxr/WebXR.js";
210
- import { WebXRAvatar } from "../../engine-components/webxr/WebXRAvatar.js";
211
- import { WebXRController } from "../../engine-components/webxr/WebXRController.js";
212
207
  import { WebXRImageTracking } from "../../engine-components/webxr/WebXRImageTracking.js";
213
208
  import { WebXRImageTrackingModel } from "../../engine-components/webxr/WebXRImageTracking.js";
214
209
  import { WebXRPlaneTracking } from "../../engine-components/webxr/WebXRPlaneTracking.js";
215
- import { WebXRSync } from "../../engine-components/webxr/WebXRSync.js";
216
210
  import { WebXRTrackedImage } from "../../engine-components/webxr/WebXRImageTracking.js";
217
- import { XRFlag } from "../../engine-components/XRFlag.js";
218
- import { XRGrabModel } from "../../engine-components/webxr/WebXRGrabRendering.js";
219
- import { XRGrabRendering } from "../../engine-components/webxr/WebXRGrabRendering.js";
211
+ import { XRControllerFollow } from "../../engine-components/webxr/controllers/XRControllerFollow.js";
212
+ import { XRControllerModel } from "../../engine-components/webxr/controllers/XRControllerModel.js";
213
+ import { XRControllerMovement } from "../../engine-components/webxr/controllers/XRControllerMovement.js";
214
+ import { XRFlag } from "../../engine-components/webxr/XRFlag.js";
220
215
  import { XRRig } from "../../engine-components/webxr/WebXRRig.js";
221
- import { XRState } from "../../engine-components/XRFlag.js";
216
+ import { XRState } from "../../engine-components/webxr/XRFlag.js";
222
217
 
223
218
  // Register types
224
219
  TypeStore.add("__Ignore", __Ignore);
@@ -233,11 +228,11 @@
233
228
  TypeStore.add("Animator", Animator);
234
229
  TypeStore.add("AnimatorController", AnimatorController);
235
230
  TypeStore.add("Antialiasing", Antialiasing);
236
- TypeStore.add("AttachedObject", AttachedObject);
237
231
  TypeStore.add("AudioExtension", AudioExtension);
238
232
  TypeStore.add("AudioListener", AudioListener);
239
233
  TypeStore.add("AudioSource", AudioSource);
240
234
  TypeStore.add("AudioTrackHandler", AudioTrackHandler);
235
+ TypeStore.add("Avatar", Avatar);
241
236
  TypeStore.add("Avatar_Brain_LookAt", Avatar_Brain_LookAt);
242
237
  TypeStore.add("Avatar_MouthShapes", Avatar_MouthShapes);
243
238
  TypeStore.add("Avatar_MustacheShake", Avatar_MustacheShake);
@@ -273,7 +268,6 @@
273
268
  TypeStore.add("ColorAdjustments", ColorAdjustments);
274
269
  TypeStore.add("ColorBySpeedModule", ColorBySpeedModule);
275
270
  TypeStore.add("ColorOverLifetimeModule", ColorOverLifetimeModule);
276
- TypeStore.add("Component", Component);
277
271
  TypeStore.add("ContactShadows", ContactShadows);
278
272
  TypeStore.add("ControlTrackHandler", ControlTrackHandler);
279
273
  TypeStore.add("CustomBranding", CustomBranding);
@@ -310,7 +304,6 @@
310
304
  TypeStore.add("Image", Image);
311
305
  TypeStore.add("InheritVelocityModule", InheritVelocityModule);
312
306
  TypeStore.add("InputField", InputField);
313
- TypeStore.add("Interactable", Interactable);
314
307
  TypeStore.add("Light", Light);
315
308
  TypeStore.add("LimitVelocityOverLifetimeModule", LimitVelocityOverLifetimeModule);
316
309
  TypeStore.add("LODGroup", LODGroup);
@@ -324,6 +317,7 @@
324
317
  TypeStore.add("MeshRenderer", MeshRenderer);
325
318
  TypeStore.add("MinMaxCurve", MinMaxCurve);
326
319
  TypeStore.add("MinMaxGradient", MinMaxGradient);
320
+ TypeStore.add("NeedleWebXRHtmlElement", NeedleWebXRHtmlElement);
327
321
  TypeStore.add("NestedGltf", NestedGltf);
328
322
  TypeStore.add("Networking", Networking);
329
323
  TypeStore.add("NoiseModule", NoiseModule);
@@ -350,7 +344,6 @@
350
344
  TypeStore.add("PreliminaryTrigger", PreliminaryTrigger);
351
345
  TypeStore.add("PresentationMode", PresentationMode);
352
346
  TypeStore.add("RawImage", RawImage);
353
- TypeStore.add("Raycaster", Raycaster);
354
347
  TypeStore.add("Rect", Rect);
355
348
  TypeStore.add("RectTransform", RectTransform);
356
349
  TypeStore.add("ReflectionProbe", ReflectionProbe);
@@ -378,6 +371,7 @@
378
371
  TypeStore.add("SizeOverLifetimeModule", SizeOverLifetimeModule);
379
372
  TypeStore.add("SkinnedMeshRenderer", SkinnedMeshRenderer);
380
373
  TypeStore.add("SmoothFollow", SmoothFollow);
374
+ TypeStore.add("SpatialGrabRaycaster", SpatialGrabRaycaster);
381
375
  TypeStore.add("SpatialHtml", SpatialHtml);
382
376
  TypeStore.add("SpatialTrigger", SpatialTrigger);
383
377
  TypeStore.add("SpatialTriggerReceiver", SpatialTriggerReceiver);
@@ -422,20 +416,16 @@
422
416
  TypeStore.add("Volume", Volume);
423
417
  TypeStore.add("VolumeParameter", VolumeParameter);
424
418
  TypeStore.add("VolumeProfile", VolumeProfile);
425
- TypeStore.add("VRUserState", VRUserState);
426
- TypeStore.add("WebAR", WebAR);
427
419
  TypeStore.add("WebARCameraBackground", WebARCameraBackground);
428
420
  TypeStore.add("WebARSessionRoot", WebARSessionRoot);
429
421
  TypeStore.add("WebXR", WebXR);
430
- TypeStore.add("WebXRAvatar", WebXRAvatar);
431
- TypeStore.add("WebXRController", WebXRController);
432
422
  TypeStore.add("WebXRImageTracking", WebXRImageTracking);
433
423
  TypeStore.add("WebXRImageTrackingModel", WebXRImageTrackingModel);
434
424
  TypeStore.add("WebXRPlaneTracking", WebXRPlaneTracking);
435
- TypeStore.add("WebXRSync", WebXRSync);
436
425
  TypeStore.add("WebXRTrackedImage", WebXRTrackedImage);
426
+ TypeStore.add("XRControllerFollow", XRControllerFollow);
427
+ TypeStore.add("XRControllerModel", XRControllerModel);
428
+ TypeStore.add("XRControllerMovement", XRControllerMovement);
437
429
  TypeStore.add("XRFlag", XRFlag);
438
- TypeStore.add("XRGrabModel", XRGrabModel);
439
- TypeStore.add("XRGrabRendering", XRGrabRendering);
440
430
  TypeStore.add("XRRig", XRRig);
441
431
  TypeStore.add("XRState", XRState);
src/engine-components/Renderer.ts CHANGED
@@ -253,11 +253,11 @@
253
253
  return undefined;
254
254
  }
255
255
 
256
- get sharedMaterial(): THREE.Material {
256
+ get sharedMaterial(): Material {
257
257
  return this.sharedMaterials[0];
258
258
  }
259
259
 
260
- set sharedMaterial(mat: THREE.Material) {
260
+ set sharedMaterial(mat: Material) {
261
261
  const cur = this.sharedMaterials[0];
262
262
  if (cur === mat) return;
263
263
  this.sharedMaterials[0] = mat;
@@ -265,12 +265,12 @@
265
265
  }
266
266
 
267
267
  /**@deprecated please use sharedMaterial */
268
- get material(): THREE.Material {
268
+ get material(): Material {
269
269
  return this.sharedMaterials[0];
270
270
  }
271
271
 
272
272
  /**@deprecated please use sharedMaterial */
273
- set material(mat: THREE.Material) {
273
+ set material(mat: Material) {
274
274
  this.sharedMaterial = mat;
275
275
  }
276
276
 
@@ -455,12 +455,10 @@
455
455
 
456
456
  private _isInstancingEnabled: boolean = false;
457
457
  private handles: InstanceHandle[] | null | undefined = undefined;
458
- private prevLayers: number[] | null | undefined = undefined;
459
458
 
460
459
  private clearInstancingState() {
461
460
  this._isInstancingEnabled = false;
462
461
  this.handles = undefined;
463
- this.prevLayers = undefined;
464
462
  }
465
463
 
466
464
  setInstancingEnabled(enabled: boolean): boolean {
@@ -606,11 +604,7 @@
606
604
  if (this._isInstancingEnabled && this.handles) {
607
605
  for (let i = 0; i < this.handles.length; i++) {
608
606
  const handle = this.handles[i];
609
- if (!this.prevLayers) this.prevLayers = [];
610
- const layer = handle.object.layers.mask;
611
- if (i >= this.prevLayers.length) this.prevLayers.push(layer);
612
- else this.prevLayers[i] = layer;
613
- handle.object.layers.disableAll();
607
+ setCustomVisibility(handle.object, false);
614
608
  }
615
609
  }
616
610
 
@@ -677,10 +671,10 @@
677
671
  }
678
672
 
679
673
  onAfterRender() {
680
- if (this._isInstancingEnabled && this.handles && this.prevLayers && this.prevLayers.length >= this.handles.length) {
674
+ if (this._isInstancingEnabled && this.handles) {
681
675
  for (let i = 0; i < this.handles.length; i++) {
682
676
  const handle = this.handles[i];
683
- handle.object.layers.mask = this.prevLayers[i];
677
+ setCustomVisibility(handle.object, true);
684
678
  }
685
679
  }
686
680
 
@@ -999,8 +993,8 @@
999
993
  this.inst = new THREE.InstancedMesh(geo, material, count);
1000
994
  this.inst[$instancingAutoUpdateBounds] = true;
1001
995
  this.inst.count = 0;
1002
- this.inst.layers.set(2);
1003
996
  this.inst.visible = true;
997
+ this.context.scene.add(this.inst);
1004
998
 
1005
999
  // Not handled by RawShaderMaterial, so we need to set the define explicitly.
1006
1000
  // Edge case: theoretically some users of the material could use it in an
@@ -1014,26 +1008,25 @@
1014
1008
  material.defines["USE_INSTANCING"] = true;
1015
1009
  material.needsUpdate = true;
1016
1010
  }
1017
-
1018
- // this.inst.castShadow = true;
1019
- // this.inst.receiveShadow = true;
1020
- this.context.scene.add(this.inst);
1011
+
1021
1012
  context.pre_render_callbacks.push(this.onBeforeRender);
1022
- // console.log(this.inst);
1023
- // this.context.pre_render_callbacks.push(this.onPreRender.bind(this));
1024
-
1025
- // setInterval(() => {
1026
- // this.inst.visible = !this.inst.visible;
1027
- // }, 500);
1013
+ context.post_render_callbacks.push(this.onAfterRender);
1028
1014
  }
1029
1015
 
1030
1016
  private onBeforeRender = () => {
1017
+ // ensure the instanced mesh is rendered / has correct layers
1018
+ this.inst.layers.enableAll();
1019
+
1031
1020
  if (this._needUpdateBounds && this.inst[$instancingAutoUpdateBounds] === true) {
1032
1021
  if (debugInstancing)
1033
1022
  console.log("Update instancing bounds", this.name, this.inst.matrixWorldNeedsUpdate);
1034
1023
  this.updateBounds();
1035
1024
  }
1036
1025
  }
1026
+ private onAfterRender = () => {
1027
+ // hide the instanced mesh again when its not being rendered (for raycasting we still use the original object)
1028
+ this.inst.layers.disableAll();
1029
+ }
1037
1030
 
1038
1031
  private randomColor() {
1039
1032
  return new THREE.Color(Math.random(), Math.random(), Math.random());
@@ -1076,7 +1069,7 @@
1076
1069
  if (this.inst.count > 0)
1077
1070
  this.inst.visible = true;
1078
1071
 
1079
- // console.log("Added", this.name, this.inst.count, this.handles);
1072
+ if (debugInstancing) console.log("Added", this.name, this.inst.count);
1080
1073
  }
1081
1074
 
1082
1075
  remove(handle: InstanceHandle) {
@@ -1116,6 +1109,7 @@
1116
1109
  this.inst.visible = false;
1117
1110
 
1118
1111
  this.inst.instanceMatrix.needsUpdate = true;
1112
+ if (debugInstancing) console.log("Removed", this.name, this.inst.count);
1119
1113
  }
1120
1114
 
1121
1115
  updateInstance(mat: THREE.Matrix4, index: number) {
src/engine-components/RendererLightmap.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Material, Mesh, type Shader, ShaderMaterial, Texture, Vector4 } from "three";
1
+ import { Material, Mesh, type WebGLProgramParametersWithUniforms, ShaderMaterial, Texture, Vector4 } from "three";
2
2
  import type { Context, OnRenderCallback } from "../engine/engine_setup.js";
3
3
  import { getParam } from "../engine/engine_utils.js";
4
4
 
@@ -99,7 +99,7 @@
99
99
  }
100
100
  }
101
101
 
102
- private onBeforeCompile = (shader: Shader, _) => {
102
+ private onBeforeCompile = (shader: WebGLProgramParametersWithUniforms, _) => {
103
103
  if (debug) console.log("Lightmaps, before compile", shader)
104
104
  //@ts-ignore
105
105
  shader.lightMapUv = "uv1";
src/engine-components/SceneSwitcher.ts CHANGED
@@ -125,9 +125,9 @@
125
125
 
126
126
  async onEnable() {
127
127
  globalThis.addEventListener("popstate", this.onPopState);
128
- this.context.input.addEventListener(InputEvents.KeyDown, this.onKeyDown);
129
- this.context.input.addEventListener(InputEvents.PointerMove, this.onPointerMove);
130
- this.context.input.addEventListener(InputEvents.PointerUp, this.onPointerUp);
128
+ this.context.input.addEventListener(InputEvents.KeyDown, this.onInputKeyDown);
129
+ this.context.input.addEventListener(InputEvents.PointerMove, this.onInputPointerMove);
130
+ this.context.input.addEventListener(InputEvents.PointerUp, this.onInputPointerUp);
131
131
 
132
132
  if (!this._engineElementOverserver) {
133
133
  this._engineElementOverserver = new MutationObserver((mutations) => {
@@ -172,9 +172,9 @@
172
172
 
173
173
  onDisable(): void {
174
174
  globalThis.removeEventListener("popstate", this.onPopState);
175
- this.context.input.removeEventListener(InputEvents.KeyDown, this.onKeyDown);
176
- this.context.input.removeEventListener(InputEvents.PointerMove, this.onPointerMove);
177
- this.context.input.removeEventListener(InputEvents.PointerUp, this.onPointerUp);
175
+ this.context.input.removeEventListener(InputEvents.KeyDown, this.onInputKeyDown);
176
+ this.context.input.removeEventListener(InputEvents.PointerMove, this.onInputPointerMove);
177
+ this.context.input.removeEventListener(InputEvents.PointerUp, this.onInputPointerUp);
178
178
  this._preloadScheduler?.stop();
179
179
  }
180
180
 
@@ -202,7 +202,7 @@
202
202
 
203
203
  private normalizedSwipeThresholdX = 0.1;
204
204
  private _didSwipe: boolean = false;
205
- private onPointerMove = (e: any) => {
205
+ private onInputPointerMove = (e: any) => {
206
206
  if (!this.useSwipe) return;
207
207
  if (!this._didSwipe && e.button === 0 && e.pointerType === "touch" && this.context.input.getPointerPressedCount() === 1) {
208
208
  const delta = this.context.input.getPointerPositionDelta(e.button);
@@ -220,13 +220,13 @@
220
220
  }
221
221
  }
222
222
 
223
- private onPointerUp = (e: any) => {
223
+ private onInputPointerUp = (e: any) => {
224
224
  if (e.button === 0) {
225
225
  this._didSwipe = false;
226
226
  }
227
227
  };
228
228
 
229
- private onKeyDown = (e: any) => {
229
+ private onInputKeyDown = (e: any) => {
230
230
  if (!this.useKeyboard) return;
231
231
  if (!this.scenes) return;
232
232
  const key = e.key.toLowerCase();
src/engine-components/SpectatorCamera.ts CHANGED
@@ -2,9 +2,8 @@
2
2
  import { Camera } from "./Camera.js";
3
3
  import * as THREE from "three";
4
4
  import { OrbitControls } from "./OrbitControls.js";
5
- import { WebXR, WebXREvent } from "./webxr/WebXR.js";
6
5
  import { AvatarMarker } from "./webxr/WebXRAvatar.js";
7
- import { XRStateFlag } from "./XRFlag.js";
6
+ import { XRStateFlag } from "./webxr/XRFlag.js";
8
7
  import { SmoothFollow } from "./SmoothFollow.js";
9
8
  import { Object3D } from "three";
10
9
  import { InputEvents } from "../engine/engine_input.js";
@@ -145,23 +144,11 @@
145
144
  if (!this._handler && this.cam)
146
145
  this._handler = new SpectatorHandler(this.context, this.cam, this);
147
146
 
148
-
149
- this.eventSub_WebXRRequestStartEvent = this.onXRSessionRequestStart.bind(this);
150
- this.eventSub_WebXRStartEvent = this.onXRSessionStart.bind(this);
151
- this.eventSub_WebXREndEvent = this.onXRSessionEnded.bind(this);
152
-
153
- WebXR.addEventListener(WebXREvent.RequestVRSession, this.eventSub_WebXRRequestStartEvent);
154
- WebXR.addEventListener(WebXREvent.XRStarted, this.eventSub_WebXRStartEvent);
155
- WebXR.addEventListener(WebXREvent.XRStopped, this.eventSub_WebXREndEvent);
156
-
157
147
  this.orbit = GameObject.getComponent(this.context.mainCamera, OrbitControls);
158
148
  }
159
149
 
160
150
  onDestroy(): void {
161
151
  this.stopSpectating();
162
- WebXR.removeEventListener(WebXREvent.RequestVRSession, this.eventSub_WebXRStartEvent);
163
- WebXR.removeEventListener(WebXREvent.XRStarted, this.eventSub_WebXRStartEvent);
164
- WebXR.removeEventListener(WebXREvent.XRStopped, this.eventSub_WebXREndEvent);
165
152
  this._handler?.destroy();
166
153
  this._networking?.destroy();
167
154
  }
@@ -173,13 +160,13 @@
173
160
  return standalone && !isHololens;
174
161
  }
175
162
 
176
- private onXRSessionRequestStart(_evt) {
163
+ onBeforeXR(_evt) {
177
164
  if (!this.isSupportedPlatform()) return;
178
165
  GameObject.setActive(this.gameObject, true);
179
166
  }
180
167
 
181
168
 
182
- private onXRSessionStart(_evt) {
169
+ onEnterXR(_evt) {
183
170
  if (!this.isSupportedPlatform()) return;
184
171
  if (debug) console.log(this.context.mainCamera);
185
172
  if (this.context.mainCamera) {
@@ -187,7 +174,7 @@
187
174
  }
188
175
  }
189
176
 
190
- private onXRSessionEnded(_evt) {
177
+ onLeaveXR(_evt) {
191
178
  this.context.removeCamera(this.cam as ICamera);
192
179
  GameObject.setActive(this.gameObject, false);
193
180
  if (this.orbit) this.orbit.enabled = true;
@@ -224,14 +211,16 @@
224
211
  const previousRenderTarget = renderer.getRenderTarget();
225
212
  let oldFramebuffer: WebGLFramebuffer | null = null;
226
213
 
214
+ const webglState = renderer.state as THREE.WebGLState & { bindXRFramebuffer?: Function };
215
+
227
216
  // seems that in some cases, renderer.getRenderTarget returns null
228
217
  // even when we're rendering to a headset.
229
218
  if (!previousRenderTarget) {
230
- if (!renderer.state.bindFramebuffer || !renderer.state.bindXRFramebuffer)
219
+ if (!renderer.state.bindFramebuffer || !webglState.bindXRFramebuffer)
231
220
  return;
232
221
 
233
222
  oldFramebuffer = renderer["_framebuffer"];
234
- renderer.state.bindXRFramebuffer(null);
223
+ webglState.bindXRFramebuffer(null);
235
224
  }
236
225
 
237
226
  this.setAvatarFlagsBeforeRender();
@@ -279,8 +268,8 @@
279
268
 
280
269
  if (previousRenderTarget)
281
270
  renderer.setRenderTarget(previousRenderTarget);
282
- else
283
- renderer.state.bindXRFramebuffer(oldFramebuffer);
271
+ else if (webglState.bindXRFramebuffer)
272
+ webglState.bindXRFramebuffer(oldFramebuffer);
284
273
 
285
274
  this.resetAvatarFlags();
286
275
  }
@@ -289,7 +278,7 @@
289
278
  const isFirstPersonMode = this._mode === SpectatorMode.FirstPerson;
290
279
 
291
280
  for (const av of AvatarMarker.instances) {
292
- if (av.avatar && "isLocalAvatar" in av.avatar) {
281
+ if (av.avatar && "isLocalAvatar" in av.avatar && "flags" in av.avatar) {
293
282
  let mask = XRStateFlag.All;
294
283
  if (this.isSpectatingSelf)
295
284
  mask = isFirstPersonMode && av.avatar.isLocalAvatar ? XRStateFlag.FirstPerson : XRStateFlag.ThirdPerson;
@@ -308,7 +297,7 @@
308
297
  const flags = av.avatar.flags;
309
298
  if (!flags) continue;
310
299
  for (const flag of flags) {
311
- if (av.avatar?.isLocalAvatar) {
300
+ if ("isLocalAvatar" in av.avatar && av.avatar?.isLocalAvatar) {
312
301
  flag.UpdateVisible(XRStateFlag.FirstPerson);
313
302
  }
314
303
  else {
src/engine-components/SyncedCamera.ts CHANGED
@@ -2,7 +2,6 @@
2
2
  import { Behaviour, GameObject } from "./Component.js";
3
3
  import { Camera } from "./Camera.js";
4
4
  import * as utils from "../engine/engine_three_utils.js"
5
- import { WebXR } from "./webxr/WebXR.js";
6
5
  import { Builder } from "flatbuffers";
7
6
  import { SyncedCameraModel } from "../engine-schemes/synced-camera-model.js";
8
7
  import { Vec3 } from "../engine-schemes/vec3.js";
@@ -130,7 +129,7 @@
130
129
  }
131
130
  }
132
131
 
133
- if (WebXR.IsInWebXR) return;
132
+ if (this.context.isInXR) return;
134
133
 
135
134
  const cam = this.context.mainCamera
136
135
  if (cam === null) {
src/engine-components/SyncedTransform.ts CHANGED
@@ -57,6 +57,7 @@
57
57
  private _receivedFastUpdate: boolean = false;
58
58
  private _shouldRequestOwnership: boolean = false;
59
59
 
60
+ /** Request ownership of an object - you need to be connected to a room */
60
61
  public requestOwnership() {
61
62
  if (debug)
62
63
  console.log("Request ownership");
src/engine-components/ui/Text.ts CHANGED
@@ -313,12 +313,12 @@
313
313
  const child = this.uiObject.children[i];
314
314
  // @ts-ignore
315
315
  if (child.isUI) {
316
- this.uiObject.remove(child);
316
+ this.uiObject.remove(child as any);
317
317
  child.clear();
318
318
  }
319
319
  }
320
320
  const el = new ThreeMeshUI.Inline({ textContent: text.substring(0, currentTag.startIndex), color: 'inherit' });
321
- this.uiObject.add(el);
321
+ this.uiObject.add(el as any);
322
322
  }
323
323
 
324
324
  const stackArray: Array<TagStackEntry> = [];
@@ -335,13 +335,13 @@
335
335
  opts.textContent = this.getText(text, currentTag, next);
336
336
  this.handleTag(currentTag, opts, stackArray);
337
337
  const el = new ThreeMeshUI.Inline(opts);
338
- this.uiObject?.add(el)
338
+ this.uiObject?.add(el as any)
339
339
 
340
340
  } else {
341
341
  opts.textContent = text.substring(currentTag.endIndex);
342
342
  this.handleTag(currentTag, opts, stackArray);
343
343
  const el = new ThreeMeshUI.Inline(opts);
344
- this.uiObject?.add(el);
344
+ this.uiObject?.add(el as any);
345
345
  }
346
346
  currentTag = next;
347
347
  }
src/engine-components/export/usdz/USDZExporter.ts CHANGED
@@ -7,7 +7,6 @@
7
7
  import { registerAnimatorsImplictly } from "./utils/animationutils.js";
8
8
  import type { IUSDExporterExtension } from "./Extension.js";
9
9
  import { Behaviour, GameObject } from "../../Component.js";
10
- import { WebXR } from "../../webxr/WebXR.js"
11
10
  import { serializable } from "../../../engine/engine_serialization.js";
12
11
  import { showBalloonMessage, showBalloonWarning } from "../../../engine/debug/index.js";
13
12
  import { Context } from "../../../engine/engine_setup.js";
@@ -18,7 +17,7 @@
18
17
  import { TextExtension } from "./extensions/USDZText.js";
19
18
  import { USDZUIExtension } from "./extensions/USDZUI.js";
20
19
  import { Renderer } from "../../Renderer.js"
21
- import { XRFlag, XRState, XRStateFlag } from "../../XRFlag.js";
20
+ import { XRFlag, XRState, XRStateFlag } from "../../webxr/XRFlag.js";
22
21
 
23
22
  const debug = getParam("debugusdz");
24
23
 
@@ -76,7 +75,6 @@
76
75
  extensions: IUSDExporterExtension[] = [];
77
76
 
78
77
  private link!: HTMLAnchorElement;
79
- private webxr?: WebXR;
80
78
 
81
79
  start() {
82
80
  if (debug) {
@@ -114,8 +112,6 @@
114
112
  const ios = isiOS()
115
113
  const safari = isSafari();
116
114
  if (debug || (ios && safari)) {
117
- if (debug || this.allowCreateQuicklookButton)
118
- this.addQuicklookButton();
119
115
  this.lastCallback = this.quicklookCallback.bind(this);
120
116
  this.link = ensureQuicklookLinkIsCreated(this.context);
121
117
  this.link.addEventListener('message', this.lastCallback);
@@ -128,11 +124,11 @@
128
124
 
129
125
  onDisable() {
130
126
  this.link?.removeEventListener('message', this.lastCallback);
131
- const ios = isiOS()
132
- const safari = isSafari();
133
- if (debug || (ios && safari)) {
134
- this.removeQuicklookButton();
135
- }
127
+ // const ios = isiOS()
128
+ // const safari = isSafari();
129
+ // if (debug || (ios && safari)) {
130
+ // this.removeQuicklookButton();
131
+ // }
136
132
  if (debug)
137
133
  showBalloonMessage("USDZ Exporter disabled: " + this.name);
138
134
 
@@ -383,74 +379,6 @@
383
379
 
384
380
 
385
381
 
386
-
387
- private _quicklookButton?: HTMLElement;
388
-
389
- private async createQuicklookButton() {
390
- if (!this.webxr) {
391
- await delay(1);
392
- this.webxr = GameObject.findObjectOfType(WebXR) ?? undefined;
393
- if (this.webxr) {
394
- if (this.webxr.VRButton) this.webxr.VRButton.parentElement?.removeChild(this.webxr.VRButton);
395
- // check if we have an AR button already and re-use that
396
- if (this.webxr.ARButton && this._quicklookButton !== this.webxr.ARButton) {
397
- this._quicklookButton = this.webxr.ARButton;
398
- // Hack to remove the immersiveweb link
399
- const linkInButton = this.webxr.ARButton.parentElement?.querySelector("a");
400
- if (linkInButton) {
401
- linkInButton.href = "";
402
- }
403
- this.webxr.ARButton.innerText = "Open in Quicklook";
404
- this.webxr.ARButton.disabled = false;
405
- this.webxr.ARButton.addEventListener("click", evt => {
406
- evt.preventDefault();
407
- this.exportAsync();
408
- });
409
- this.webxr.ARButton.classList.add("quicklook-ar-button");
410
- this._quicklookButtonContainer = this.webxr.ARButton.parentElement;
411
- this.dispatchEvent(new CustomEvent("created-button", { detail: this.webxr.ARButton }))
412
- }
413
- // create a button if WebXR didnt create one yet
414
- else {
415
- this.webxr.createARButton = false;
416
- this.webxr.createVRButton = false;
417
- let container = this.context.domElement.shadowRoot!.querySelector(".webxr-buttons");
418
- if (!container) {
419
- container = document.createElement("div");
420
- container.classList.add("webxr-buttons");
421
- }
422
- const button = document.createElement("button");
423
- button.innerText = "Open in Quicklook";
424
- button.addEventListener("click", () => {
425
- this.exportAsync();
426
- });
427
- button.classList.add('webxr-ar-button');
428
- button.classList.add('webxr-button');
429
- button.classList.add("quicklook-ar-button");
430
- this._quicklookButton = button;
431
- container.appendChild(button);
432
- this._quicklookButtonContainer = container;
433
- this.dispatchEvent(new CustomEvent("created-button", { detail: button }))
434
- }
435
- }
436
- else {
437
- console.warn("Could not find WebXR component: will not create Quicklook button", Context.Current);
438
- }
439
- }
440
- }
441
-
442
-
443
- private _quicklookButtonContainer: Element | null = null;
444
- private async addQuicklookButton() {
445
- await this.createQuicklookButton();
446
- if (this._quicklookButton && this._quicklookButtonContainer) {
447
- this._quicklookButtonContainer.appendChild(this._quicklookButton);
448
- }
449
- }
450
- private removeQuicklookButton() {
451
- this._quicklookButton?.remove();
452
- }
453
-
454
382
  private applyWebARSessionRoot() {
455
383
  if (!this.objectToExport) return;
456
384
 
src/engine-components/export/usdz/extensions/USDZUI.ts CHANGED
@@ -31,7 +31,7 @@
31
31
  height = rt.height;
32
32
 
33
33
  const shadowRootModel = USDObject.createEmpty();
34
- const shadowComponent = rt.shadowComponent;
34
+ const shadowComponent = rt.shadowComponent as unknown as Object3D;
35
35
  model.add(shadowRootModel);
36
36
 
37
37
  if (shadowComponent) {
src/engine-components/ui/Utils.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import { FrameEvent } from "../../engine/engine_setup.js";
4
4
  import { Behaviour } from "../Component.js";
5
5
  import { $shadowDomOwner, BaseUIComponent } from "./BaseUIComponent.js";
6
+ import ThreeMeshUI from "three-mesh-ui";
6
7
 
7
8
  export function tryGetUIComponent(obj: Object3D): BaseUIComponent | null {
8
9
  const owner = obj[$shadowDomOwner];
@@ -27,7 +28,7 @@
27
28
  receiveShadows?: boolean;
28
29
  }
29
30
 
30
- export function updateRenderSettings(shadowComponent: Object3D, settings: RenderSettings) {
31
+ export function updateRenderSettings(shadowComponent: Object3D | ThreeMeshUI.MeshUIBaseElement, settings: RenderSettings) {
31
32
  if (!shadowComponent) return;
32
33
  // const owner = shadowComponent[$shadowDomOwner];
33
34
  // if (!owner)
src/engine-schemes/vr-user-state-buffer.ts CHANGED
@@ -24,102 +24,109 @@
24
24
  return (obj || new VrUserStateBuffer()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
25
25
  }
26
26
 
27
- guid():string|null
28
- guid(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
29
- guid(optionalEncoding?:any):string|Uint8Array|null {
27
+ time():flatbuffers.Long {
30
28
  const offset = this.bb!.__offset(this.bb_pos, 4);
31
- return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
32
- }
33
-
34
- time():flatbuffers.Long {
35
- const offset = this.bb!.__offset(this.bb_pos, 6);
36
29
  return offset ? this.bb!.readInt64(this.bb_pos + offset) : this.bb!.createLong(0, 0);
37
30
  }
38
31
 
39
32
  avatarId():string|null
40
33
  avatarId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
41
34
  avatarId(optionalEncoding?:any):string|Uint8Array|null {
42
- const offset = this.bb!.__offset(this.bb_pos, 8);
35
+ const offset = this.bb!.__offset(this.bb_pos, 6);
43
36
  return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
44
37
  }
45
38
 
46
39
  position(obj?:Vec3):Vec3|null {
47
- const offset = this.bb!.__offset(this.bb_pos, 10);
40
+ const offset = this.bb!.__offset(this.bb_pos, 8);
48
41
  return offset ? (obj || new Vec3()).__init(this.bb_pos + offset, this.bb!) : null;
49
42
  }
50
43
 
51
44
  rotation(obj?:Vec4):Vec4|null {
52
- const offset = this.bb!.__offset(this.bb_pos, 12);
45
+ const offset = this.bb!.__offset(this.bb_pos, 10);
53
46
  return offset ? (obj || new Vec4()).__init(this.bb_pos + offset, this.bb!) : null;
54
47
  }
55
48
 
56
49
  scale():number {
57
- const offset = this.bb!.__offset(this.bb_pos, 14);
50
+ const offset = this.bb!.__offset(this.bb_pos, 12);
58
51
  return offset ? this.bb!.readFloat32(this.bb_pos + offset) : 0.0;
59
52
  }
60
53
 
54
+ headPosition(obj?:Vec3):Vec3|null {
55
+ const offset = this.bb!.__offset(this.bb_pos, 14);
56
+ return offset ? (obj || new Vec3()).__init(this.bb_pos + offset, this.bb!) : null;
57
+ }
58
+
59
+ headRotation(obj?:Vec4):Vec4|null {
60
+ const offset = this.bb!.__offset(this.bb_pos, 16);
61
+ return offset ? (obj || new Vec4()).__init(this.bb_pos + offset, this.bb!) : null;
62
+ }
63
+
61
64
  posLeftHand(obj?:Vec3):Vec3|null {
62
- const offset = this.bb!.__offset(this.bb_pos, 16);
65
+ const offset = this.bb!.__offset(this.bb_pos, 18);
63
66
  return offset ? (obj || new Vec3()).__init(this.bb_pos + offset, this.bb!) : null;
64
67
  }
65
68
 
66
69
  posRightHand(obj?:Vec3):Vec3|null {
67
- const offset = this.bb!.__offset(this.bb_pos, 18);
70
+ const offset = this.bb!.__offset(this.bb_pos, 20);
68
71
  return offset ? (obj || new Vec3()).__init(this.bb_pos + offset, this.bb!) : null;
69
72
  }
70
73
 
71
74
  rotLeftHand(obj?:Vec4):Vec4|null {
72
- const offset = this.bb!.__offset(this.bb_pos, 20);
75
+ const offset = this.bb!.__offset(this.bb_pos, 22);
73
76
  return offset ? (obj || new Vec4()).__init(this.bb_pos + offset, this.bb!) : null;
74
77
  }
75
78
 
76
79
  rotRightHand(obj?:Vec4):Vec4|null {
77
- const offset = this.bb!.__offset(this.bb_pos, 22);
80
+ const offset = this.bb!.__offset(this.bb_pos, 24);
78
81
  return offset ? (obj || new Vec4()).__init(this.bb_pos + offset, this.bb!) : null;
79
82
  }
80
83
 
81
84
  static startVrUserStateBuffer(builder:flatbuffers.Builder) {
82
- builder.startObject(10);
85
+ builder.startObject(11);
83
86
  }
84
87
 
85
- static addGuid(builder:flatbuffers.Builder, guidOffset:flatbuffers.Offset) {
86
- builder.addFieldOffset(0, guidOffset, 0);
87
- }
88
-
89
88
  static addTime(builder:flatbuffers.Builder, time:flatbuffers.Long) {
90
- builder.addFieldInt64(1, time, builder.createLong(0, 0));
89
+ builder.addFieldInt64(0, time, builder.createLong(0, 0));
91
90
  }
92
91
 
93
92
  static addAvatarId(builder:flatbuffers.Builder, avatarIdOffset:flatbuffers.Offset) {
94
- builder.addFieldOffset(2, avatarIdOffset, 0);
93
+ builder.addFieldOffset(1, avatarIdOffset, 0);
95
94
  }
96
95
 
97
96
  static addPosition(builder:flatbuffers.Builder, positionOffset:flatbuffers.Offset) {
98
- builder.addFieldStruct(3, positionOffset, 0);
97
+ builder.addFieldStruct(2, positionOffset, 0);
99
98
  }
100
99
 
101
100
  static addRotation(builder:flatbuffers.Builder, rotationOffset:flatbuffers.Offset) {
102
- builder.addFieldStruct(4, rotationOffset, 0);
101
+ builder.addFieldStruct(3, rotationOffset, 0);
103
102
  }
104
103
 
105
104
  static addScale(builder:flatbuffers.Builder, scale:number) {
106
- builder.addFieldFloat32(5, scale, 0.0);
105
+ builder.addFieldFloat32(4, scale, 0.0);
107
106
  }
108
107
 
108
+ static addHeadPosition(builder:flatbuffers.Builder, headPositionOffset:flatbuffers.Offset) {
109
+ builder.addFieldStruct(5, headPositionOffset, 0);
110
+ }
111
+
112
+ static addHeadRotation(builder:flatbuffers.Builder, headRotationOffset:flatbuffers.Offset) {
113
+ builder.addFieldStruct(6, headRotationOffset, 0);
114
+ }
115
+
109
116
  static addPosLeftHand(builder:flatbuffers.Builder, posLeftHandOffset:flatbuffers.Offset) {
110
- builder.addFieldStruct(6, posLeftHandOffset, 0);
117
+ builder.addFieldStruct(7, posLeftHandOffset, 0);
111
118
  }
112
119
 
113
120
  static addPosRightHand(builder:flatbuffers.Builder, posRightHandOffset:flatbuffers.Offset) {
114
- builder.addFieldStruct(7, posRightHandOffset, 0);
121
+ builder.addFieldStruct(8, posRightHandOffset, 0);
115
122
  }
116
123
 
117
124
  static addRotLeftHand(builder:flatbuffers.Builder, rotLeftHandOffset:flatbuffers.Offset) {
118
- builder.addFieldStruct(8, rotLeftHandOffset, 0);
125
+ builder.addFieldStruct(9, rotLeftHandOffset, 0);
119
126
  }
120
127
 
121
128
  static addRotRightHand(builder:flatbuffers.Builder, rotRightHandOffset:flatbuffers.Offset) {
122
- builder.addFieldStruct(9, rotRightHandOffset, 0);
129
+ builder.addFieldStruct(10, rotRightHandOffset, 0);
123
130
  }
124
131
 
125
132
  static endVrUserStateBuffer(builder:flatbuffers.Builder):flatbuffers.Offset {
src/engine-components/webxr/WebARCameraBackground.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { Behaviour } from "../Component.js";
2
2
  import { serializable } from "../../engine/engine_serialization_decorator.js";
3
3
  import { RGBAColor } from "../js-extensions/RGBAColor.js"
4
- import { WebXR } from "./WebXR.js";
4
+ import { getParam } from "../../engine/engine_utils.js";
5
+ import { NeedleXREventArgs } from "../../engine/engine_xr.js";
5
6
  import {
6
7
  Scene,
7
8
  Texture,
@@ -14,36 +15,39 @@
14
15
  PerspectiveCamera,
15
16
  } from "three";
16
17
 
18
+ const debug = getParam("debugarcamera");
19
+
17
20
  export class WebARCameraBackground extends Behaviour {
18
21
 
19
- awake(): void {
20
- WebXR.OptionalFeatures_AR.push('camera-access');
21
- }
22
+ onBeforeXR(_mode: XRSessionMode, args: XRSessionInit): void {
23
+ args.optionalFeatures = args.optionalFeatures || [];
24
+ args.optionalFeatures.push('camera-access');
22
25
 
23
- @serializable()
24
- public backgroundTint: RGBAColor = new RGBAColor(1,1,1,1);
25
-
26
- public get background() {
27
- return this.backgroundPlane;
26
+ if (debug) console.warn("Requesting camera-access");
28
27
  }
29
28
 
30
- private _preRender;
31
-
32
- onEnable(): void {
33
- this._preRender = this.preRender.bind(this);
34
- this.context.pre_render_callbacks.push(this._preRender);
35
-
29
+ onEnterXR(_args: NeedleXREventArgs): void {
36
30
  if (this.backgroundPlane) {
37
- this.gameObject.add(this.backgroundPlane);
31
+ this.context.scene.add(this.backgroundPlane);
38
32
  this.backgroundPlane.visible = false;
39
33
  }
34
+
35
+ if (this.backgroundPlane) this.context.scene.add(this.backgroundPlane);
36
+ this.context.pre_render_callbacks.push(this.preRender);
40
37
  }
41
38
 
42
- onDisable(): void {
43
- this.context.pre_render_callbacks = this.context.pre_render_callbacks.filter(cb => cb !== this._preRender);
39
+ onLeaveXR(_args: NeedleXREventArgs): void {
40
+ if (this.backgroundPlane) this.backgroundPlane.removeFromParent();
41
+ const i = this.context.pre_render_callbacks.indexOf(this.preRender);
42
+ if (i >= 0)
43
+ this.context.pre_render_callbacks.splice(i, 1);
44
+ }
44
45
 
45
- if (this.backgroundPlane)
46
- this.gameObject.remove(this.backgroundPlane);
46
+ @serializable()
47
+ public backgroundTint: RGBAColor = new RGBAColor(1,1,1,1);
48
+
49
+ public get background() {
50
+ return this.backgroundPlane;
47
51
  }
48
52
 
49
53
  private backgroundPlane?: Mesh;
@@ -58,11 +62,13 @@
58
62
  return function forceTextureInitialization(renderer, texture) {
59
63
  material.map = texture;
60
64
  renderer.render(scene, camera);
65
+ if (debug) console.warn("Force texture initialization");
61
66
  };
62
67
  }();
63
68
 
64
- // TODO should only attach on session start, and detach on session end
65
- private preRender() {
69
+
70
+
71
+ private preRender = () => {
66
72
  if (!this || !this.gameObject) return;
67
73
 
68
74
  const xr = this.context.renderer.xr;
@@ -81,19 +87,14 @@
81
87
  // from three: WebGLBackground
82
88
  if (this.backgroundPlane === undefined) {
83
89
  this.backgroundPlane = makeFullscreenPlane(this.backgroundTint);
84
- this.gameObject.add(this.backgroundPlane);
85
90
  }
91
+ if(this.backgroundPlane.parent !== this.scene)
92
+ this.scene.add(this.backgroundPlane);
86
93
 
87
94
  // WebXR Raw Camera Access -
88
95
  // we composite the camera texture into the scene background by rendering it first.
89
96
  this.updateFromFrame(frame);
90
97
  }
91
-
92
- /*
93
- if (this.planeMesh) {
94
- this.planeMesh.visible = frame != null;
95
- }
96
- */
97
98
  }
98
99
 
99
100
  onBeforeRender(frame: XRFrame | null) {
@@ -131,17 +132,9 @@
131
132
  this.backgroundPlane.setTexture(this.threeTexture);
132
133
  this.backgroundPlane.visible = true;
133
134
  }
134
-
135
- // TODO this would be a lot better but currently
136
- // setting color space doesn't work.
137
- // Plus we need to understand how we can supply a custom shader in
138
- // this case.
139
- /*
140
- if (this.threeTexture) {
141
- this.context.scene.background = this.threeTexture;
142
- this.threeTexture.colorSpace = NoColorSpace;
135
+ else {
136
+ if (debug) console.warn("No background plane to set texture on");
143
137
  }
144
- */
145
138
  }
146
139
  }
147
140
  else {
@@ -175,15 +168,14 @@
175
168
  gl_FragColor = texColor * <backgroundTint>;
176
169
 
177
170
  #include <tonemapping_fragment>
178
- #include <encodings_fragment>
179
-
171
+ #include <colorspace_fragment>
180
172
  }
181
173
  `;
182
174
 
183
175
  // not sure where we want to move this and in which form is best (extends Object3D?)
184
176
  export function makeFullscreenPlane(tint: RGBAColor ) {
185
177
  const replacementTint = "vec4(" + tint.r.toFixed(3) + "," + tint.g.toFixed(3) + "," + tint.b.toFixed(3) + "," + tint.a.toFixed(3) + ")";
186
- console.log(replacementTint);
178
+ if (debug) console.log(replacementTint);
187
179
  const planeMesh = new Mesh(
188
180
  new PlaneGeometry(2, 2),
189
181
  // @ts-ignore
@@ -191,7 +183,7 @@
191
183
  name: 'BackgroundMaterial',
192
184
  uniforms: UniformsUtils.clone( ShaderLib.background.uniforms ),
193
185
  vertexShader: ShaderLib.background.vertexShader,
194
- fragmentShader: backgroundFragment.replace("<backgroundTint>", replacementTint), // ShaderLib.background.fragmentShader,
186
+ fragmentShader: backgroundFragment.replaceAll("<backgroundTint>", replacementTint), // ShaderLib.background.fragmentShader,
195
187
  side: DoubleSide,
196
188
  depthTest: false,
197
189
  depthWrite: false,
@@ -211,8 +203,8 @@
211
203
  // Option 1: add the planeMesh to our scene for rendering.
212
204
  // This is useful for applying custom shader effects on the background (instead of using the system composite)
213
205
  planeMesh.renderOrder = -10000; // render first
214
- planeMesh.layers.disableAll();
215
- planeMesh.layers.enable(2); // ignore raycasts
206
+ // planeMesh.layers.disableAll();
207
+ planeMesh.layers.set(2); // ignore raycasts
216
208
  planeMesh.frustumCulled = false;
217
209
 
218
210
  // should be a class, for now lets just define a method for the weird way the texture needs to be set
src/engine-components/webxr/WebARSessionRoot.ts CHANGED
@@ -1,44 +1,377 @@
1
1
  import { Behaviour, GameObject } from "../Component.js";
2
- import { Matrix4, Object3D, Plane, Quaternion, Ray, Raycaster, Vector2, Vector3 } from "three";
3
- import { WebAR, WebXR } from "./WebXR.js";
4
- import { InstancingUtil } from "../../engine/engine_instancing.js";
2
+ import { AxesHelper, DoubleSide, Matrix4, Mesh, MeshBasicMaterial, Object3D, Plane, Quaternion, Raycaster, RingGeometry, Scene, Vector3 } from "three";
5
3
  import { serializable } from "../../engine/engine_serialization_decorator.js";
6
4
  import { Context } from "../../engine/engine_context.js";
7
- import { isQuest } from "../../engine/engine_utils.js";
5
+ import { IComponent, IGameObject } from "../../engine/engine_types.js";
6
+ import { NeedleXREventArgs, NeedleXRHitTestResult, NeedleXRSession } from "../../engine/engine_xr.js";
7
+ import { NEPointerEvent } from "../../engine/engine_input.js";
8
+ import { getParam } from "../../engine/engine_utils.js";
9
+ import { destroy } from "../../engine/engine_gameobject.js";
10
+ import { isDevEnvironment, showBalloonWarning } from "../../engine/debug/index.js";
11
+ import { ObjectUtils, PrimitiveType } from "../../engine/engine_create_objects.js";
8
12
 
9
13
  // https://github.com/takahirox/takahirox.github.io/blob/master/js.mmdeditor/examples/js/controls/DeviceOrientationControls.js
10
14
 
11
- const tempMatrix = new Matrix4();
15
+ const debug = getParam("debugwebxr");
12
16
 
17
+ const invertForwardMatrix = new Matrix4().makeRotationY(Math.PI);
18
+
19
+ // TODO: webarsessionroot needs to place the rig (and not itself)
20
+
13
21
  export class WebARSessionRoot extends Behaviour {
14
22
 
15
- webAR: WebAR | null = null;
16
-
17
- get rig(): Object3D | undefined {
18
- return this.webAR?.webxr.Rig;
23
+ /** The scale of a user in AR:
24
+ * a large value makes the scene appear smaller
25
+ * default is 1
26
+ */
27
+ @serializable()
28
+ get arScale(): number {
29
+ return this._arScale;
19
30
  }
31
+ set arScale(val: number) {
32
+ if (val === this._arScale) return;
33
+ this._arScale = val;
34
+ this.onScaleChanged();
35
+ }
36
+ private _arScale: number = 1;
20
37
 
38
+ /** When enabled the scene will be rotated by 180° in the Y axes */
21
39
  @serializable()
22
40
  invertForward: boolean = false;
23
41
 
42
+ /** When enabled we will create a XR anchor for the scene placement
43
+ * and make sure the scene is at that anchored point during a XR session */
44
+ @serializable()
45
+ useXRAnchor: boolean = false;
46
+
24
47
  /** Preview feature: enable touch transform */
25
48
  @serializable()
26
49
  arTouchTransform: boolean = false;
27
50
 
28
- @serializable()
29
- get arScale(): number {
30
- return this._arScale;
51
+ /** true if we're currently placing the scene */
52
+ private _isPlacing = true;
53
+
54
+ /** This is the world matrix of the ar session root when entering webxr
55
+ * it is applied when the scene has been placed (e.g. if the session root is x:10, z:10 we want this position to be the center of the scene)
56
+ */
57
+ private readonly _startOffset: Matrix4 = new Matrix4();
58
+
59
+ private _createdPlacementObject: Object3D | null = null;
60
+ private readonly _reparentedComponents: Array<{ comp: IComponent, originalObject: IGameObject }> = [];
61
+
62
+ // move objects into a temporary scene while placing (which is not rendered) so that the components won't be disabled during this process
63
+ // e.g. we want the avatar to still be updated while placing
64
+ // another possibly solution would be to ensure from this component that the Rig is *also* not disabled while placing
65
+ private readonly _placementScene: Scene = new Scene();
66
+
67
+ /** the reticles used for placement */
68
+ private readonly _reticle: IGameObject[] = [];
69
+ /** needs to be in sync with the reticles */
70
+ private readonly _hits: XRHitTestResult[] = [];
71
+
72
+ private _placementStartTime: number = -1;
73
+ private _rigPlacementMatrix?: Matrix4;
74
+ /** if useAnchor is enabled this is the anchor we have created on placing the scene using the placement hit */
75
+ private _anchor: XRAnchor | null = null;
76
+ /** user input is used for ar touch transform */
77
+ private userInput?: WebXRSessionRootUserInput;
78
+
79
+ supportsXR(mode: XRSessionMode): boolean {
80
+ return mode === "immersive-ar";
31
81
  }
32
- set arScale(val: number) {
33
- if (val === this._arScale) return;
34
- this._arScale = val;
35
- this.setScale(val);
82
+
83
+ onEnterXR(_args: NeedleXREventArgs): void {
84
+ if (debug) console.log("ENTER WEBXR: SessionRoot start...");
85
+
86
+ this._anchor = null;
87
+
88
+ // if (_args.xr.session.enabledFeatures?.includes("image-tracking")) {
89
+ // console.warn("Image tracking is enabled - will not place scene");
90
+ // return;
91
+ // }
92
+
93
+ // save the transform of the session root in the scene to apply it when placing the scene
94
+ this.gameObject.updateMatrixWorld();
95
+ this._startOffset.copy(this.gameObject.matrixWorld);
96
+
97
+ // create a new root object for the session placement scripts
98
+ // and move all the children in the scene in a temporary scene that is not rendered
99
+ const rootObject = new Object3D();
100
+ this._createdPlacementObject = rootObject;
101
+ rootObject.name = "AR Session Root";
102
+ this._placementScene.children.length = 0;
103
+ for (let i = this.context.scene.children.length - 1; i >= 0; i--) {
104
+ const ch = this.context.scene.children[i];
105
+ this._placementScene.add(ch);
106
+ }
107
+ this.context.scene.add(rootObject);
108
+
109
+ // reparent components
110
+ // save which gameobject the sessionroot component was previously attached to
111
+ this._reparentedComponents.length = 0;
112
+ this._reparentedComponents.push({ comp: this, originalObject: this.gameObject });
113
+ GameObject.addComponent(rootObject, this);
114
+ // const webXR = GameObject.findObjectOfType(WebXR2);
115
+ // if (webXR) {
116
+ // this._reparentedComponents.push({ comp: webXR, originalObject: webXR.gameObject });
117
+ // GameObject.addComponent(rootObject, webXR);
118
+ // const playerSync = GameObject.findObjectOfType(XRFlag);
119
+ // }
120
+
121
+ // recreate the reticle every time we enter AR
122
+ for (const ret of this._reticle) {
123
+ destroy(ret);
124
+ }
125
+ this._isPlacing = true;
126
+ this.context.input.addEventListener("pointerup", this.onPlaceScene);
36
127
  }
128
+ onLeaveXR() {
129
+ // TODO: WebARSessionRoot doesnt work when we enter passthrough and leave XR without having placed the session!!!
130
+ this.context.input.removeEventListener("pointerup", this.onPlaceScene)
131
+ this.onRevertSceneChanges();
132
+ // this._anchor?.delete();
133
+ this._anchor = null;
134
+ this._rigPlacementMatrix = undefined;
135
+ }
136
+ onUpdateXR(args: NeedleXREventArgs): void {
37
137
 
138
+ if (args.xr.isTrackingImages) {
139
+ for (const ret of this._reticle)
140
+ ret.visible = false;
141
+ return;
142
+ }
143
+
144
+ if (this._isPlacing) {
145
+ const rigObject = args.xr.rig?.gameObject;
146
+ // the rig should be parented to the scene while placing
147
+ // since the camera is always parented to the rig this ensures that the camera is always rendering
148
+ if (rigObject && rigObject.parent !== this.context.scene) {
149
+ this.context.scene.add(rigObject);
150
+ }
151
+ // in pass through mode we want to place the scene using an XR controller
152
+ if (args.xr.isPassThrough && args.xr.controllers.length > 0) {
153
+ for (const ctrl of args.xr.controllers) {
154
+ // with this we can only place with the left / first controller right now
155
+ // we also only have one reticle... this should probably be refactored a bit so we can have multiple reticles
156
+ // and then place at the reticle for which the user clicked the place button
157
+ const hit = ctrl.getHitTest();
158
+ if (hit) {
159
+ this.updateReticleAndHits(args.xr, ctrl.index, hit, args.xr.rigScale);
160
+ }
161
+ }
162
+ }
163
+ // in screen AR mode we use "camera" hit testing
164
+ else {
165
+ const hit = args.xr.getHitTest();
166
+ if (hit) {
167
+ this.updateReticleAndHits(args.xr, 0, hit, args.xr.rigScale);
168
+ }
169
+ }
170
+
171
+ }
172
+ else {
173
+ if (this._anchor && args.xr.referenceSpace) {
174
+ const pose = args.xr.frame.getPose(this._anchor.anchorSpace, args.xr.referenceSpace);
175
+ if (pose && this.context.time.frame % 20 === 0) {
176
+ // apply the anchor pose to one of the reticles
177
+ const converted = args.xr.convertSpace(pose.transform);
178
+ const reticle = this._reticle[0];
179
+ if (reticle) {
180
+ reticle.position.copy(converted.position);
181
+ reticle.quaternion.copy(converted.quaternion);
182
+ this.onApplyPose(reticle);
183
+ }
184
+ }
185
+ }
186
+
187
+ // scene has been placed
188
+ if (this.arTouchTransform) {
189
+ if (!this.userInput) this.userInput = new WebXRSessionRootUserInput(this.context);
190
+ this.userInput?.enable();
191
+ }
192
+ else this.userInput?.disable();
193
+ if (this.arTouchTransform && this.userInput?.hasChanged) {
194
+ if (args.xr.rig) {
195
+ const rig = args.xr.rig.gameObject;
196
+ this.userInput.applyMatrixTo(rig.matrix, true);
197
+ rig.matrix.decompose(rig.position, rig.quaternion, rig.scale);
198
+ // if the rig is scaled large we want the drag touch to be faster
199
+ this.userInput.factor = rig.scale.x;
200
+ }
201
+ this.userInput.reset();
202
+ }
203
+ }
204
+ }
205
+
206
+ private updateReticleAndHits(_xr: NeedleXRSession, i: number, hit: NeedleXRHitTestResult, scale: number) {
207
+ // save the hit test
208
+ this._hits[i] = hit.hit;
209
+
210
+ let reticle = this._reticle[i];
211
+ if (!reticle) {
212
+ reticle = new Mesh(
213
+ new RingGeometry(0.07, 0.09, 32).rotateX(- Math.PI / 2),
214
+ new MeshBasicMaterial({ side: DoubleSide })
215
+ ) as any as IGameObject;
216
+ if (debug) {
217
+ const axes = new AxesHelper(1);
218
+ axes.position.y += .01;
219
+ reticle.add(axes);
220
+ }
221
+ this._reticle[i] = reticle;
222
+ reticle.name = "AR Placement Reticle";
223
+ reticle.matrixAutoUpdate = false;
224
+ reticle.visible = false;
225
+ }
226
+
227
+ reticle.position.lerp(hit.position, this.context.time.deltaTime / .1);
228
+ reticle.quaternion.slerp(hit.quaternion, this.context.time.deltaTime / .05);
229
+ reticle.scale.set(scale, scale, scale);
230
+ // if (this.invertForward) {
231
+ // reticle.rotateY(Math.PI);
232
+ // }
233
+ reticle.updateMatrix();
234
+ reticle.visible = true;
235
+ if (reticle.parent !== this.context.scene)
236
+ this.context.scene.add(reticle);
237
+
238
+ if (this._placementStartTime < 0) {
239
+ this._placementStartTime = this.context.time.realtimeSinceStartup;
240
+ }
241
+ }
242
+
243
+ private onPlaceScene = (evt: NEPointerEvent) => {
244
+ if (this._isPlacing == false) return;
245
+
246
+ let reticle = this._reticle[0];
247
+ let hit = this._hits[0];
248
+
249
+ if (evt.mode === "tracked-pointer") {
250
+ // until we can use hit testing for both controllers and have multple reticles we only allow placement with the first controller
251
+ reticle = this._reticle[evt.pointerId];
252
+ hit = this._hits[evt.pointerId];
253
+ }
254
+
255
+ if (!reticle) {
256
+ console.warn("No reticle to place...");
257
+ return;
258
+ }
259
+
260
+ if (!reticle.visible) {
261
+ console.warn("Reticle is not visible (can not place)");
262
+ return;
263
+ }
264
+
265
+ if (NeedleXRSession.active?.isTrackingImages) {
266
+ console.warn("Scene Placement is disabled while images are being tracked");
267
+ return;
268
+ }
269
+
270
+ // if we place the scene we don't want this event to be propagated to any sub-objects (via the EventSystem) anymore and trigger e.g. a click on objects for the "place tap" event
271
+ evt.stopImmediatePropagation();
272
+
273
+ this._isPlacing = false;
274
+ this.context.input.removeEventListener("pointerup", this.onPlaceScene);
275
+
276
+ this.onRevertSceneChanges();
277
+
278
+ this.onApplyPose(reticle);
279
+
280
+ if (this.useXRAnchor) {
281
+ this.onCreateAnchor(NeedleXRSession.active!, hit);
282
+ }
283
+ }
284
+
285
+ private onScaleChanged() {
286
+ // TODO: implement
287
+ }
288
+
289
+ private onRevertSceneChanges() {
290
+ for (const ret of this._reticle) {
291
+ ret?.removeFromParent();
292
+ }
293
+
294
+ for (let i = this._placementScene.children.length - 1; i >= 0; i--) {
295
+ const ch = this._placementScene.children[i];
296
+ this.context.scene.add(ch);
297
+ }
298
+ this._createdPlacementObject?.removeFromParent();
299
+
300
+ for (const reparented of this._reparentedComponents) {
301
+ GameObject.addComponent(reparented.originalObject, reparented.comp);
302
+ }
303
+ }
304
+
305
+ private async onCreateAnchor(session: NeedleXRSession, hit: XRHitTestResult) {
306
+ if (hit.createAnchor === undefined) {
307
+ console.warn("Hit does not support creating an anchor", hit);
308
+ if (isDevEnvironment()) showBalloonWarning("Hit does not support creating an anchor");
309
+ return;
310
+ }
311
+ else {
312
+ const anchor = await hit.createAnchor(session.viewerPose!.transform);
313
+ // make sure the session is still active
314
+ if (session.running && anchor) {
315
+ this._anchor = anchor;
316
+ }
317
+ }
318
+ }
319
+
320
+ private onApplyPose(reticle: Object3D) {
321
+ const rigObject = NeedleXRSession.active?.rig?.gameObject;
322
+ if (rigObject) {
323
+ // save the previous rig parent
324
+ const previousParent = rigObject.parent || this.context.scene;
325
+
326
+ // if we have placed this rig before and this is just "replacing" with the anchor
327
+ // we need to make sure the XRRig attached to the reticle is at the same position as last time
328
+ // since in the following code we move it inside the reticle (relative to the reticle)
329
+ if (this._rigPlacementMatrix) {
330
+ this._rigPlacementMatrix?.decompose(rigObject.position, rigObject.quaternion, rigObject.scale);
331
+ }
332
+ else {
333
+ this._rigPlacementMatrix = rigObject.matrix.clone();
334
+ }
335
+
336
+ reticle.updateMatrix();
337
+ // attach rig to reticle (since the reticle is in rig space it's a easy way to place the rig where we want it relative to the reticle)
338
+ this.context.scene.add(reticle);
339
+ reticle.attach(rigObject);
340
+ reticle.removeFromParent();
341
+
342
+
343
+ // move rig now relative tot he reticle
344
+ // apply scale
345
+ rigObject.scale.set(this.arScale, this.arScale, this.arScale);
346
+ rigObject.position.multiplyScalar(this.arScale);
347
+
348
+ rigObject.updateMatrix();
349
+ if (this.invertForward)
350
+ rigObject.matrix.premultiply(invertForwardMatrix);
351
+ rigObject.matrix.premultiply(this._startOffset);
352
+
353
+ // apply the rig modifications and add it back to the previous parent
354
+ rigObject.matrix.decompose(rigObject.position, rigObject.quaternion, rigObject.scale);
355
+ previousParent.add(rigObject);
356
+ }
357
+ }
358
+
359
+
360
+
361
+
362
+ /*
363
+
364
+ webAR: WebAR | null = null;
365
+
366
+ get rig(): Object3D | undefined {
367
+ return this.webAR?.webxr.Rig;
368
+ }
369
+
370
+
371
+
38
372
  private readonly _initalMatrix = new Matrix4();
39
373
  private readonly _selectStartFn = this.onSelectStart.bind(this);
40
374
  private readonly _selectEndFn = this.onSelectEnd.bind(this);
41
- private userInput?: WebXRSessionRootUserInput;
42
375
 
43
376
  start() {
44
377
  const xr = GameObject.findObjectOfType(WebXR);
@@ -48,7 +381,6 @@
48
381
  }
49
382
  }
50
383
 
51
- private _arScale: number = 1;
52
384
  private _rig: Object3D | null = null;
53
385
  private _startPose: Matrix4 | null = null;
54
386
  private _placementPose: Matrix4 | null = null;
@@ -101,7 +433,7 @@
101
433
  if (this.webAR) this.webAR.setReticleActive(false);
102
434
  this.placeAt(rig, poseMatrix);
103
435
  if (hit && pose && !isQuest()) // TODO anchors seem to behave differently with an XRRig
104
- this.onCreatePlacementAnchor(hit, pose);
436
+ this.onCreatePlacementAnchor(hit, pose);
105
437
 
106
438
  return true;
107
439
  }
@@ -220,6 +552,8 @@
220
552
  rig.matrix.decompose(rig.position, rig.quaternion, rig.scale);
221
553
  rig.updateMatrixWorld();
222
554
  }
555
+
556
+ */
223
557
  }
224
558
 
225
559
 
@@ -234,11 +568,14 @@
234
568
  twoFingerRotate: boolean = true;
235
569
  twoFingerScale: boolean = true;
236
570
 
571
+ factor: number = 1;
572
+
237
573
  readonly context: Context;
238
574
  readonly offset: Matrix4;
239
575
  readonly plane: Plane;
240
576
 
241
577
  private _scale: number = 1;
578
+ private _hasChanged: boolean = false;
242
579
 
243
580
  // readonly translate: Vector3 = new Vector3();
244
581
  // readonly rotation: Quaternion = new Quaternion();
@@ -270,8 +607,21 @@
270
607
  this._scale = 1;
271
608
  this.offset.identity();
272
609
  }
273
- applyMatrixTo(matrix: Matrix4) {
274
- matrix.premultiply(this.offset);
610
+ get hasChanged() { return this._hasChanged; }
611
+
612
+ /**
613
+ * Applies the matrix to the offset matrix
614
+ * @param matrix the matrix to apply the drag offset to
615
+ * @param invert if true the offset matrix will be inverted before applying it to the matrix and premultiplied
616
+ */
617
+ applyMatrixTo(matrix: Matrix4, invert: boolean) {
618
+ this._hasChanged = false;
619
+ if (invert) {
620
+ this.offset.invert();
621
+ matrix.premultiply(this.offset);
622
+ }
623
+ else
624
+ matrix.multiply(this.offset);
275
625
  // if (this._needsUpdate)
276
626
  // this.updateMatrix();
277
627
  // matrix.premultiply(this._rotationMatrix);
@@ -324,7 +674,7 @@
324
674
  }
325
675
  private touchMove = (evt: TouchEvent) => {
326
676
  if (evt.defaultPrevented) return;
327
-
677
+
328
678
  if (evt.touches.length === 1) {
329
679
  // if we had multiple touches before due to e.g. pinching / rotating
330
680
  // and stopping one of the touches, we don't want to move the scene suddenly
@@ -405,21 +755,26 @@
405
755
  // this.translate.z -= dz;
406
756
  // this._needsUpdate = true;
407
757
  // return
408
- // some arbitrary factor
409
- dx *= .75;
410
- dz *= .75;
758
+
411
759
  // increase diff if the scene is scaled small
412
760
  dx /= this._scale;
413
761
  dz /= this._scale;
762
+
763
+ dx *= this.factor;
764
+ dz *= this.factor;
765
+
414
766
  // apply it
415
- this.offset.elements[12] -= dx;
416
- this.offset.elements[14] -= dz;
767
+ this.offset.elements[12] += dx;
768
+ this.offset.elements[14] += dz;
769
+ if (dx !== 0 || dz !== 0)
770
+ this._hasChanged = true;
417
771
  };
418
772
 
419
773
  private readonly _tempMatrix: Matrix4 = new Matrix4();
420
774
 
421
775
  private addScale(diff: number) {
422
776
  diff /= window.innerWidth
777
+ diff *= -1;
423
778
 
424
779
  // this.scale.x *= 1 + diff;
425
780
  // this.scale.y *= 1 + diff;
@@ -433,14 +788,19 @@
433
788
  // apply the scale
434
789
  this._tempMatrix.makeScale(1 - diff, 1 - diff, 1 - diff);
435
790
  this.offset.premultiply(this._tempMatrix);
791
+ if (diff !== 0)
792
+ this._hasChanged = true;
436
793
  }
437
794
 
438
795
 
439
796
  private addRotation(rot: number) {
797
+ rot *= -1;
440
798
  // this.rotation.multiply(new Quaternion().setFromAxisAngle(WebXRSessionRootUserInput.up, rot));
441
799
  // this._needsUpdate = true;
442
800
  // return;
443
801
  this._tempMatrix.makeRotationY(rot);
444
802
  this.offset.premultiply(this._tempMatrix);
803
+ if (rot !== 0)
804
+ this._hasChanged = true;
445
805
  }
446
806
  }
src/engine-components/webxr/WebXR.ts CHANGED
@@ -1,762 +1,291 @@
1
- import { Color, Euler, EventDispatcher, Group, Matrix4, Mesh, MeshBasicMaterial, Object3D, Quaternion, RingGeometry, Texture, Vector3, type WebXRArrayCamera } from 'three';
2
- import { ARButton } from '../../include/three/ARButton.js';
3
- import { VRButton } from '../../include/three/VRButton.js';
4
-
1
+ import { Behaviour, GameObject } from "../Component.js";
2
+ import { NeedleXREventArgs, NeedleXRSession } from "../../engine/engine_xr.js";
5
3
  import { AssetReference } from "../../engine/engine_addressables.js";
6
- import { serializable } from "../../engine/engine_serialization_decorator.js";
7
- import { XRSessionMode } from "../../engine/engine_setup.js";
8
- import { getWorldPosition, getWorldQuaternion, setWorldPosition, setWorldQuaternion } from "../../engine/engine_three_utils.js";
9
- import type { INeedleEngineComponent } from "../../engine/engine_types.js";
10
- import { getParam, isMozillaXR, isQuest, setOrAddParamsToUrl } from "../../engine/engine_utils.js";
11
-
12
- import { Behaviour, GameObject } from "../Component.js";
13
- import { noVoip } from "../Voip.js";
4
+ import { serializable } from "../../engine/engine_serialization.js";
5
+ import { Object3D } from "three";
6
+ import { Avatar } from "./Avatar.js";
7
+ import { XRState, XRStateFlag } from "./XRFlag.js";
14
8
  import { WebARSessionRoot } from "./WebARSessionRoot.js";
15
- import { ControllerType, WebXRController } from "./WebXRController.js";
16
- import { XRRig } from "./WebXRRig.js";
17
- import { WebXRSync } from "./WebXRSync.js";
18
- import { XRState, XRStateFlag } from "../XRFlag.js";
19
- import { showBalloonWarning } from '../../engine/debug/index.js';
20
- import { isDestroyed } from '../../engine/engine_gameobject.js';
9
+ import { USDZExporter } from "../export/usdz/USDZExporter.js";
10
+ import { getParam, isDesktop, isMobileDevice, isQuest, isSafari, isiOS } from "../../engine/engine_utils.js";
11
+ import { XRControllerMovement } from "./controllers/XRControllerMovement.js";
12
+ import { XRControllerModel } from "./controllers/XRControllerModel.js";
13
+ import { PlayerSync } from "../../engine-components-experimental/networking/PlayerSync.js";
14
+ import { SpatialGrabRaycaster } from "../ui/Raycaster.js";
15
+ import { NeedleWebXRHtmlElement } from "./WebXRButtons.js";
21
16
 
22
- const debugWebXR = getParam("debugwebxr");
17
+ const debug = getParam("debugwebxr");
18
+ const debugQuicklook = getParam("debugusdz");
23
19
 
24
- export async function detectARSupport() {
25
- if (isMozillaXR()) return true;
26
- if ("xr" in navigator) {
27
- //@ts-ignore
28
- return (await navigator["xr"].isSessionSupported('immersive-ar')) === true;
29
- }
30
- return false;
31
- }
32
- export async function detectVRSupport() {
33
- if ("xr" in navigator) {
34
- //@ts-ignore
35
- return (await navigator["xr"].isSessionSupported('immersive-vr')) === true;
36
- }
37
- return false;
38
- }
20
+ export class WebXR extends Behaviour {
39
21
 
40
- let arSupported = false;
41
- let vrSupported = false;
42
- detectARSupport().then(res => arSupported = res);
43
- detectVRSupport().then(res => vrSupported = res);
22
+ // UI
23
+ /** When enabled a button will be added to the UI to enter VR */
24
+ createVRButton: boolean = true;
25
+ /** When enabled a button will be added to the UI to enter AR */
26
+ createARButton: boolean = true;
27
+ /** When enabled a send to quest button will be shown if the device does not support VR */
28
+ createSendToQuestButton: boolean = true;
29
+ /** When enabled a QRCode will be created to open the website on a mobile device */
30
+ createQRCode: boolean = true;
44
31
 
45
- // import TeleportVR from "teleportvr.js";
32
+ // VR Settings
33
+ /** When enabled default movement behaviour will be added */
34
+ useDefaultControls: boolean = true;
35
+ /** When enabled controller models will automatically be created and updated when you are using controllers in WebXR */
36
+ showControllerModels: boolean = true;
37
+ /** When enabled hand models will automatically be created and updated when you are using hands in WebXR */
38
+ showHandModels: boolean = true;
46
39
 
47
- export enum WebXREvent {
48
- XRStarted = "xrStarted",
49
- XRStopped = "xrStopped",
50
- XRUpdate = "xrUpdate",
51
- RequestVRSession = "requestVRSession",
52
- ModifyAROptions = "modify-ar-options",
53
- }
40
+ // AR Settings
41
+ /** When enabled the scene must be placed in AR */
42
+ usePlacementReticle: boolean = true;
43
+ /** When enabled you can position, rotate or scale your AR scene with one or two fingers */
44
+ usePlacementAdjustment: boolean = true;
45
+ /** Used when `usePlacementReticle` is enabled */
46
+ arSceneScale: number = 1;
47
+ /** Experimental: When enabled an XRAnchor will be created for the AR scene and the position will be updated to the anchor position every few frames */
48
+ useXRAnchor: boolean = false;
54
49
 
55
- export declare type CreateButtonOptions = {
56
- registerClick: boolean
57
- };
50
+ /** When enabled a USDZExporter component will be added to the scene (if none is found) */
51
+ useQuicklookExport: boolean = false;
58
52
 
59
- export class WebXR extends Behaviour {
60
53
 
61
- @serializable()
62
- enableVR = true;
63
- @serializable()
64
- enableAR = true;
54
+ /** Preview feature enabling occlusion (when available: https://github.com/cabanier/three.js/commit/b6ee92bcd8f20718c186120b7f19a3b68a1d4e47)
55
+ * Enables the 'depth-sensing' WebXR feature to provide realtime depth occlusion. Only supported on Oculus Quest right now.
56
+ */
57
+ useDepthSensing: boolean = false;
65
58
 
59
+
60
+ /** This avatar representation will be spawned when you enter a webxr session */
66
61
  @serializable(AssetReference)
67
62
  defaultAvatar?: AssetReference;
68
- @serializable()
69
- handModelPath: string = "";
70
63
 
71
- @serializable()
72
- createVRButton: boolean = true;
73
- @serializable()
74
- createARButton: boolean = true;
64
+ private _playerSync?: PlayerSync;
65
+ /** these components were created by the WebXR component on session start and will be cleaned up again in session end */
66
+ private readonly _createdComponentsInSession: Behaviour[] = [];
75
67
 
76
- private static _isInXr: boolean = false;
77
- private static events: EventDispatcher = new EventDispatcher();
68
+ private _usdzExporter?: USDZExporter;
78
69
 
79
- public static get IsInWebXR(): boolean { return this._isInXr; }
80
- public static get XRSupported(): boolean { return 'xr' in navigator && (arSupported || vrSupported); }
81
- public static get IsARSupported(): boolean { return arSupported; }
82
- public static get IsVRSupported(): boolean { return vrSupported; }
83
-
84
- private static _optionalFeatures_VR: string[] = ['local-floor', 'bounded-floor', 'hand-tracking', 'high-fixed-foveation-level', 'layers'];
85
- private static _optionalFeatures_AR: string[] = ['anchors', 'local-floor', 'hand-tracking', 'layers'];
86
- public static get OptionalFeatures_VR(): string[] { return this._optionalFeatures_VR; }
87
- public static get OptionalFeatures_AR(): string[] { return this._optionalFeatures_AR; }
88
-
89
- public static addEventListener(type: string, listener: any): any {
90
- this.events.addEventListener(type, listener);
91
- return listener;
70
+ awake() {
71
+ NeedleXRSession.getXRSync(this.context);
72
+ if (debug) window.addEventListener("keydown", evt => { if (evt.key === "x") this.exitXR(); });
92
73
  }
93
- public static removeEventListener(type: string, listener: any): any {
94
- this.events.removeEventListener(type, listener);
95
- return listener;
96
- }
97
- private static dispatchEvent(type: string, event: any): void {
98
- this.events.dispatchEvent({ type, detail: event });
99
- }
100
74
 
101
- public static createVRButton(webXR: WebXR, opts?: CreateButtonOptions): HTMLButtonElement | HTMLAnchorElement {
102
- if (!WebXR.XRSupported) {
103
- console.warn("WebXR is not supported on this device");
75
+ onEnable(): void {
76
+ if (this.useQuicklookExport) {
77
+ this._usdzExporter = GameObject.findObjectOfType(USDZExporter) || undefined;
78
+ if (!this._usdzExporter) {
79
+ // if no USDZ Exporter is found we add one and assign the scene to be exported
80
+ if (debug) console.log("WebXR: Adding USDZExporter");
81
+ this._usdzExporter = GameObject.addNewComponent(this.gameObject, USDZExporter);
82
+ this._usdzExporter.objectToExport = this.context.scene;
83
+ }
104
84
  }
105
- else
106
- webXR.__internalAwake();
107
- const options = { optionalFeatures: WebXR.OptionalFeatures_VR };
108
- const vrButton = VRButton.createButton(webXR.context.renderer, options);
109
- vrButton.classList.add('webxr-ar-button');
110
- vrButton.classList.add('webxr-button');
111
- this.resetButtonStyles(vrButton);
112
- // if (this.enableAR) vrButton.style.marginLeft = "60px";
113
- if (opts?.registerClick ?? true)
114
- vrButton.addEventListener('click', webXR.onClickedVRButton.bind(webXR));
115
- return vrButton;
116
- }
117
85
 
118
- public static createARButton(webXR: WebXR, opts?: CreateButtonOptions): HTMLButtonElement | HTMLAnchorElement {
119
- webXR.__internalAwake();
120
- const domOverlayRoot = webXR.webAR?.getAROverlayContainer();
121
- const options: any = { optionalFeatures: [...this.OptionalFeatures_AR] };
122
- if (domOverlayRoot) {
123
- options.domOverlay = { root: domOverlayRoot };
124
- options.optionalFeatures.push('dom-overlay')
125
- options.optionalFeatures.push('hit-test');
126
- options.optionalFeatures.push('anchors');
86
+ this.handleCreatingHTML();
87
+ this.handleOfferSession();
88
+
89
+ if (this.defaultAvatar) {
90
+ this._playerSync = this.gameObject.getOrAddComponent(PlayerSync);
91
+ this._playerSync.autoSync = false;
127
92
  }
128
- else {
129
- console.warn("No dom overlay root found, HTML overlays on top of screen-based AR will not work.");
93
+ if (this._playerSync) {
94
+ this._playerSync.asset = this.defaultAvatar;
95
+ this._playerSync.onPlayerSpawned?.removeEventListener(this.onAvatarSpawned);
96
+ this._playerSync.onPlayerSpawned?.addEventListener(this.onAvatarSpawned);
130
97
  }
131
98
 
132
- const arButton = ARButton.createButton(webXR.context.renderer, options, this.onModifyAROptions.bind(this));
133
- arButton.classList.add('webxr-ar-button');
134
- arButton.classList.add('webxr-button');
135
- WebXR.resetButtonStyles(arButton);
136
- if (opts?.registerClick ?? true)
137
- arButton.addEventListener('click', webXR.onClickedARButton.bind(webXR));
138
- return arButton;
99
+ // if a button container exists and is not attached to any parent element we attach it to the shadow root (assuming it was removed during onDisable)
100
+ if (this._container && !this._container.parentNode) {
101
+ this.context.domElement.shadowRoot?.appendChild(this._container);
102
+ }
139
103
  }
140
104
 
141
- private static onModifyAROptions(options) {
142
- WebXR.dispatchEvent(WebXREvent.ModifyAROptions, options);
105
+ onDisable(): void {
106
+ // remove the container automatically if it was added to the shadow root
107
+ this._container?.remove();
143
108
  }
144
109
 
145
- public static resetButtonStyles(button) {
146
- if (!button) return;
147
- button.style.position = "";
148
- button.style.bottom = "";
149
- button.style.left = "";
110
+ private async handleOfferSession() {
111
+ const hasVRSupport = await NeedleXRSession.isVRSupported();
112
+ if (hasVRSupport) {
113
+ return NeedleXRSession.offerSession("immersive-vr", "default", this.context);
114
+ }
115
+ const hasARSupport = await NeedleXRSession.isARSupported();
116
+ if (hasARSupport) {
117
+ return NeedleXRSession.offerSession("immersive-ar", "default", this.context);
118
+ }
119
+ return false;
150
120
  }
151
121
 
152
- public endSession() {
153
- const session = this.context.renderer.xr.getSession();
154
- if (session) session.end();
122
+ /** the currently active webxr input session */
123
+ get session(): NeedleXRSession | null {
124
+ return NeedleXRSession.active ?? null;
155
125
  }
156
-
157
- public get Rig(): Object3D {
158
- this.ensureRig();
159
- return this.rig;
126
+ /** immersive-vr or immersive-ar */
127
+ get sessionMode(): XRSessionMode | null {
128
+ return NeedleXRSession.activeMode ?? null;;
160
129
  }
161
130
 
162
-
163
- private controllers: WebXRController[] = [];
164
- public get Controllers(): WebXRController[] {
165
- return this.controllers;
131
+ /** Call to start an WebVR session */
132
+ async enterVR(init?: XRSessionInit): Promise<NeedleXRSession | null> {
133
+ return NeedleXRSession.start("immersive-vr", init, this.context);
166
134
  }
167
-
168
- public get LeftController(): WebXRController | null {
169
- if (this.controllers.length > 0 && this.controllers[0].input?.handedness === "left") return this.controllers[0];
170
- if (this.controllers.length > 1 && this.controllers[1].input?.handedness === "left") return this.controllers[1];
171
- return null;
135
+ /** Call to start an WebAR session */
136
+ async enterAR(init?: XRSessionInit): Promise<NeedleXRSession | null> {
137
+ return NeedleXRSession.start("immersive-ar", init, this.context);
172
138
  }
173
-
174
- public get RightController(): WebXRController | null {
175
- if (this.controllers.length > 0 && this.controllers[0].input?.handedness === "right") return this.controllers[0];
176
- if (this.controllers.length > 1 && this.controllers[1].input?.handedness === "right") return this.controllers[1];
177
- return null;
139
+ /** Call to end a WebXR (AR or VR) session */
140
+ exitXR() {
141
+ NeedleXRSession.stop();
178
142
  }
179
143
 
180
- public get ARButton(): HTMLButtonElement | undefined {
181
- return this._arButton;
182
- }
144
+ private _previousXRState: number = 0;
183
145
 
184
- public get VRButton(): HTMLButtonElement | undefined {
185
- return this._vrButton;
146
+ onBeforeXR(_mode: XRSessionMode, args: XRSessionInit): void {
147
+ if (_mode == "immersive-ar" && this.useDepthSensing) {
148
+ args.optionalFeatures = args.optionalFeatures || [];
149
+ args.optionalFeatures.push("depth-sensing");
150
+ }
186
151
  }
187
152
 
188
- public get IsInVR() { return this._isInVR; }
189
- public get IsInAR() { return this._isInAR; }
153
+ async onEnterXR(args: NeedleXREventArgs) {
154
+ if (debug) console.log("WebXR onEnterXR")
155
+ // set XR flags
156
+ this._previousXRState = XRState.Global.Mask;
157
+ const isVR = args.xr.isVR;
158
+ XRState.Global.Set(isVR ? XRStateFlag.VR : XRStateFlag.AR);
190
159
 
191
- /** When enabled */
192
- allowARPlacementReticle: boolean = true;
193
-
194
- private rig!: Object3D;
195
- private isInit: boolean = false;
196
-
197
- private _requestedAR: boolean = false;
198
- private _requestedVR: boolean = false;
199
- private _isInAR: boolean = false;
200
- private _isInVR: boolean = false;
201
-
202
- private _arButton?: HTMLButtonElement;
203
- private _vrButton?: HTMLButtonElement;
204
-
205
- private webAR: WebAR | null = null;
206
-
207
- awake(): void {
208
- // as the webxr component is most of the times currently loaded as part of the scene
209
- // and not part of the glTF directly and thus does not go through the whole serialization process currently
210
- // we need to to manuall make sure it is of the correct type here
211
- if (this.defaultAvatar) {
212
- if (typeof (this.defaultAvatar) === "string") {
213
- this.defaultAvatar = AssetReference.getOrCreate(this.sourceId ?? "/", this.defaultAvatar, this.context);
160
+ // Handle AR session root
161
+ if (this.usePlacementReticle && args.xr.isAR) {
162
+ let sessionroot = GameObject.findObjectOfType(WebARSessionRoot);
163
+ if (!sessionroot) {
164
+ const implicitSessionRoot = new Object3D();
165
+ for (const ch of this.context.scene.children)
166
+ implicitSessionRoot.add(ch);
167
+ this.context.scene.add(implicitSessionRoot);
168
+ sessionroot = GameObject.addNewComponent(implicitSessionRoot, WebARSessionRoot)!;
169
+ this._createdComponentsInSession.push(sessionroot);
170
+ sessionroot.arScale = this.arSceneScale;
171
+ sessionroot.arTouchTransform = this.usePlacementAdjustment;
172
+ sessionroot.useXRAnchor = this.useXRAnchor;
214
173
  }
215
174
  }
216
- if (!GameObject.findObjectOfType(WebXRSync, this.context)) {
217
- const sync = GameObject.addNewComponent(this.gameObject, WebXRSync, false) as WebXRSync;
218
- sync.webXR = this;
219
- }
220
- this.webAR = new WebAR(this);
221
175
 
222
- if (location.protocol == 'http:' && location.host.indexOf('localhost') < 0) {
223
- showBalloonWarning("WebXR only works on https");
224
- console.warn("WebXR only works on https. https://engine.needle.tools/docs/xr.html");
176
+ // handle VR controls
177
+ if (this.useDefaultControls) {
178
+ this.setDefaultMovementEnabled(true);
225
179
  }
226
- }
227
-
228
- onEnable() {
229
- if (this.isInit) return;
230
- if (!this.enableAR && !this.enableVR) return;
231
- this.isInit = true;
232
-
233
- this.context.renderer.xr.enabled = true;
234
-
235
- // TODO: move the whole buttons positioning out of here and make it configureable from css
236
- // better set proper classes so user code can react to it instead
237
- // of this hardcoded stuff
238
- let arButton, vrButton;
239
- const buttonsContainer = document.createElement('div');
240
- buttonsContainer.classList.add("webxr-buttons");
241
- buttonsContainer.style.cssText = `
242
- position: absolute;
243
- bottom: 21px;
244
- left: 50%;
245
- transform: translate(-50%, 0%);
246
- z-index: 1000;
247
-
248
- display: flex;
249
- flex-direction: row;
250
- justify-content: center;
251
- align-items: flex-start;
252
- gap: 10px;
253
- `;
254
- this.context.appendHTMLElement(buttonsContainer);
255
-
256
- const forceButtons = debugWebXR;
257
- if (debugWebXR) console.log("ARSupported?", arSupported, "VRSupported?", vrSupported);
258
-
259
- // AR support
260
- if (forceButtons || (this.createARButton && this.enableAR && arSupported)) {
261
- arButton = WebXR.createARButton(this);
262
- this._arButton = arButton;
263
- buttonsContainer.appendChild(arButton);
180
+ if (this.showControllerModels || this.showHandModels) {
181
+ this.setDefaultControllerRenderingEnabled(true);
264
182
  }
265
183
 
266
- // VR support
267
- if (forceButtons || (this.createVRButton && this.enableVR && vrSupported)) {
268
- vrButton = WebXR.createVRButton(this);
269
- this._vrButton = vrButton;
270
- buttonsContainer.appendChild(vrButton);
184
+ // ensure we have a spatial grab raycaster for close grabs
185
+ let raycaster = GameObject.findObjectOfType(SpatialGrabRaycaster);
186
+ if (!raycaster) {
187
+ raycaster = this.gameObject.addNewComponent(SpatialGrabRaycaster);
271
188
  }
272
189
 
273
- setTimeout(() => {
274
- WebXR.resetButtonStyles(vrButton);
275
- WebXR.resetButtonStyles(arButton);
276
- }, 1000);
190
+ this.createLocalAvatar(args.xr);
277
191
  }
278
192
 
279
- private _transformOrientation: Quaternion = new Quaternion();
280
- public get TransformOrientation(): Quaternion { return this._transformOrientation; }
193
+ onLeaveXR(_: NeedleXREventArgs): void {
194
+ // revert XR flags
195
+ XRState.Global.Set(this._previousXRState);
281
196
 
282
- private _currentHeadPose: XRViewerPose | null = null;
283
- public get HeadPose(): XRViewerPose | null { return this._currentHeadPose; }
197
+ this._playerSync?.destroyInstance();
284
198
 
285
- onBeforeRender(frame:XRFrame | null | undefined) {
286
- if (!frame) return;
287
- // TODO: figure out why screen is black if we enable the code written here
288
- // const referenceSpace = renderer.xr.getReferenceSpace();
289
- const session = this.context.renderer.xr.getSession();
290
-
291
-
292
- if (session) {
293
- const referenceSpace = this.context.renderer.xr.getReferenceSpace();
294
- if(!referenceSpace) return;
295
- const pose = frame.getViewerPose(referenceSpace);
296
- if (!pose) return;
297
- this._currentHeadPose = pose;
298
- const transform: XRRigidTransform = pose?.transform;
299
- if (transform) {
300
- this._transformOrientation.set(transform.orientation.x, transform.orientation.y, transform.orientation.z, transform.orientation.w);
301
- }
302
-
303
- if (WebXR._isInXr === false && session) {
304
- this.onEnterXR(session, frame);
305
- }
306
- else if (this.IsInVR) {
307
- if (this.context.mainCamera) {
308
- this.ensureRig();
309
- }
310
- }
311
-
312
- for (const ctrl of this.controllers) {
313
- ctrl.onUpdate(session);
314
- }
315
-
316
- if (this._isInAR) {
317
- this.webAR?.onUpdate(session, frame);
318
- }
199
+ for (const comp of this._createdComponentsInSession) {
200
+ comp.destroy();
319
201
  }
202
+ this._createdComponentsInSession.length = 0;
320
203
 
321
- WebXR.events.dispatchEvent({ type: WebXREvent.XRUpdate, frame: frame, xr: this.context.renderer.xr, rig: this.rig });
204
+ this.handleOfferSession();
322
205
  }
323
206
 
324
- private onClickedARButton() {
325
- if (!this._isInAR) {
326
- this._requestedAR = true;
327
- this._requestedVR = false;
328
207
 
329
- // if we do this on enter xr the state has already been changed in AR mode
330
- // so we need to to this before session has started
331
- this.captureStateBeforeXR();
208
+ /** Call to enable or disable default controller behaviour */
209
+ setDefaultMovementEnabled(enabled: boolean): XRControllerMovement | null {
210
+ let movement = this.gameObject.getComponent(XRControllerMovement)
211
+ if (!movement && enabled) {
212
+ movement = this.gameObject.addNewComponent(XRControllerMovement)!;
213
+ this._createdComponentsInSession.push(movement);
332
214
  }
215
+ if (movement) movement.enabled = enabled;
216
+ return movement;
333
217
  }
334
-
335
- private onClickedVRButton() {
336
- if (!this._isInVR) {
337
-
338
- // happens e.g. when headset is off and xr session never actually started
339
- if (this._requestedVR) {
340
- this.onExitXR(null);
341
- return;
342
- }
343
-
344
- this._requestedAR = false;
345
- this._requestedVR = true;
346
- this.captureStateBeforeXR();
347
-
348
- // build controllers before session begins - this seems to fix issue with controller models not appearing/not getting connection event
349
- this.ensureRig();
350
- for (let i = 0; i < 2; i++) {
351
- WebXRController.Create(this, i, this.gameObject as GameObject, ControllerType.PhysicalDevice);
352
- }
353
-
354
- WebXR.events.dispatchEvent({ type: WebXREvent.RequestVRSession });
218
+ /** Call to enable or disable default controller rendering */
219
+ setDefaultControllerRenderingEnabled(enabled: boolean): XRControllerModel | null {
220
+ let models = this.gameObject.getComponent(XRControllerModel);
221
+ if (!models && enabled) {
222
+ models = this.gameObject.addNewComponent(XRControllerModel)!;
223
+ this._createdComponentsInSession.push(models);
224
+ models.createControllerModel = this.showControllerModels;
225
+ models.createHandModel == this.showHandModels;
355
226
  }
227
+ if (models) models.enabled = enabled;
228
+ return models;
356
229
  }
357
230
 
358
- private captureStateBeforeXR() {
359
- if (this.context.mainCamera) {
360
- this._originalCameraPosition.copy(getWorldPosition(this.context.mainCamera));
361
- this._originalCameraRotation.copy(getWorldQuaternion(this.context.mainCamera));
362
- this._originalCameraParent = this.context.mainCamera.parent;
363
- }
364
- if (this.Rig) {
365
- this._originalXRRigParent = this.Rig.parent;
366
- this._originalXRRigPosition.copy(this.Rig.position);
367
- this._originalXRRigRotation.copy(this.Rig.quaternion);
368
- }
369
- }
370
231
 
371
- private ensureRig() {
372
- if (!this.rig || isDestroyed(this.rig)) {
373
- // currently just used for pose
374
- const xrRig = GameObject.findObjectOfType(XRRig, this.context);
375
- if (xrRig) {
376
- // make it match unity forward
377
- this.rig = xrRig.gameObject;
378
- this.rig.rotateY(Math.PI);
379
- // this.rig.position.copy(existing.worldPosition);
380
- // this.rig.quaternion.premultiply(existing.worldQuaternion);
381
- }
382
- else {
383
- this.rig = new Group();
384
- this.rig.rotateY(Math.PI);
385
- this.rig.name = "XRRig";
386
- this.context.scene.add(this.rig);
387
- }
388
- }
389
232
 
390
- // Make sure the webxr camera is parented to the xr rig
391
- if (this.context.isInXR && this.context.mainCamera && this.context.mainCamera.parent !== this.rig) {
392
- this.rig.add(this.context.mainCamera);
393
-
394
- // Hack: make sure we have the correct position and rotation (e.g. where we are dealing with an implicitly created rig)
395
- // This handles the case where we switch between multiple scenes
396
- if (this.IsInVR) {
397
- const other = GameObject.findObjectOfType(XRRig);
398
- if (other && other?.gameObject !== this.rig) {
399
- this.rig.position.copy(other.gameObject.position);
400
- this.rig.quaternion.copy(other.gameObject.quaternion);
401
- this.rig.rotateY(Math.PI);
402
- this.rig.scale.copy(other.gameObject.scale);
403
- }
404
- }
233
+ protected async createLocalAvatar(xr: NeedleXRSession) {
234
+ if (this._playerSync && xr.running) {
235
+ this._playerSync.asset = this.defaultAvatar;
236
+ await this._playerSync.getInstance();
405
237
  }
406
238
  }
407
239
 
240
+ private onAvatarSpawned = (instance: GameObject) => {
241
+ // spawned webxr avatars must have a avatar component
242
+ if (debug) console.log("WebXR.onAvatarSpawned", instance);
243
+ GameObject.getOrAddComponent(instance, Avatar);
244
+ };
408
245
 
409
- private _originalCameraParent: Object3D | null = null;
410
- private _originalCameraPosition: Vector3 = new Vector3();
411
- private _originalCameraRotation: Quaternion = new Quaternion();
412
246
 
413
- private _originalXRRigParent: Object3D | null = null;
414
- private _originalXRRigPosition: Vector3 = new Vector3();
415
- private _originalXRRigRotation: Quaternion = new Quaternion();
416
247
 
417
- private onEnterXR(session: XRSession, frame: XRFrame) {
418
- console.log("[XR] session begin", session, frame);
419
- WebXR._isInXr = true;
420
248
 
421
- this.ensureRig();
422
-
423
- const space = this.context.renderer.xr.getReferenceSpace();
424
- if (space && this.rig) {
425
- const pose = frame.getViewerPose(space);
426
- const rot = pose?.transform.orientation;
427
- if (rot) {
428
- const quat = new Quaternion(rot.x, rot.y, rot.z, rot.w);
429
- const eu = new Euler().setFromQuaternion(quat);
430
- this.rig.rotateY(eu.y);
431
- // this.rig.quaternion.multiply(quat);
432
- }
249
+ // HTML UI
250
+ /** Calling this function will get the Needle WebXR button container (it will be created if it doesnt exist yet)
251
+ * @returns the Needle WebXR button container */
252
+ getButtonsContainer(): NeedleWebXRHtmlElement {
253
+ if (!this._container) {
254
+ this._container = NeedleWebXRHtmlElement.create();
255
+ this.context.domElement.shadowRoot?.appendChild(this._container);
433
256
  }
434
-
435
- // when we set unity layers objects will only be rendered on one eye
436
- // we set layers to sync raycasting and have a similar behaviour to unity
437
- const xr = this.context.renderer.xr;
438
- if (this.context.mainCamera) {
439
- const cam = xr.getCamera() as WebXRArrayCamera;
440
- if (debugWebXR) console.log("WebXRCamera", cam);
441
- const cull = this.context.mainCameraComponent?.cullingMask;
442
- if (cam && cull !== undefined) {
443
- for (const c of cam.cameras) {
444
- c.layers.mask = cull;
445
- }
446
- cam.layers.mask = cull;
447
- }
448
- else if (cam) {
449
- for (const c of cam.cameras) {
450
- c.layers.enableAll();
451
- }
452
- cam.layers.enableAll();
453
- }
454
- if (this._requestedAR) {
455
- this.context.scene.add(this.rig);
456
- }
457
- }
458
-
459
- const flag = this._requestedAR ? XRStateFlag.AR : XRStateFlag.VR;
460
-
461
- XRState.Global.Set(flag);
462
-
463
- switch (flag) {
464
- case XRStateFlag.AR:
465
- this.context.xrSessionMode = XRSessionMode.ImmersiveAR;
466
- this._isInAR = true;
467
- this.webAR?.onBegin(session);
468
- break;
469
- case XRStateFlag.VR:
470
- this.context.xrSessionMode = XRSessionMode.ImmersiveVR;
471
- this._isInVR = true;
472
- this.onEnterVR(session);
473
- break;
474
- }
475
-
476
- session.addEventListener('end', () => {
477
- console.log("[XR] session end");
478
- WebXR._isInXr = false;
479
- this.onExitXR(session);
480
- });
481
-
482
- this.onEnterXR_HandleMirrorWindow(session);
483
-
484
- WebXR.events.dispatchEvent({ type: WebXREvent.XRStarted, session: session });
257
+ return this._container;
485
258
  }
486
259
 
487
- private onExitXR(session: XRSession | null) {
260
+ private _container?: NeedleWebXRHtmlElement;
261
+ private handleCreatingHTML() {
488
262
 
489
- const wasInAR = this._isInAR;
490
-
491
- if (session) {
492
- if (this._isInAR) {
493
- this.webAR?.onEnd(session);
263
+ if (this.createARButton || this.createVRButton || this.useQuicklookExport) {
264
+ // Quicklook / iOS
265
+ if ((isiOS() && isSafari()) || debugQuicklook) {
266
+ if (this.createARButton || this.useQuicklookExport) {
267
+ this.getButtonsContainer().createQuicklookButton();
268
+ }
494
269
  }
270
+ // WebXR
495
271
  else {
496
- // if in VR we want to restore the FOV
497
- this.context.mainCameraComponent?.applyClearFlagsIfIsActiveCamera();
272
+ if (this.createARButton) this.getButtonsContainer().createARButton();
273
+ if (this.createVRButton) this.getButtonsContainer().createVRButton();
498
274
  }
499
275
  }
500
276
 
501
- this._isInAR = false;
502
- this._isInVR = false;
503
- this._requestedAR = false;
504
- this._requestedVR = false;
505
- this.context.xrSessionMode = undefined;
506
-
507
- if (this.xrMirrorWindow) {
508
- this.xrMirrorWindow.close();
509
- this.xrMirrorWindow = null;
277
+ if (this.createSendToQuestButton && !isQuest()) {
278
+ NeedleXRSession.isVRSupported().then(supported => {
279
+ if (!supported) this.getButtonsContainer().createSendToQuestButton();
280
+ });
510
281
  }
511
282
 
512
- this.destroyControllers();
513
-
514
- if (this.context.mainCamera) {
515
- this._originalCameraParent?.add(this.context.mainCamera);
516
- setWorldPosition(this.context.mainCamera, this._originalCameraPosition);
517
- setWorldQuaternion(this.context.mainCamera, this._originalCameraRotation);
518
- this.context.mainCamera.scale.set(1, 1, 1);
519
- }
520
-
521
- if (wasInAR) {
522
- this._originalXRRigParent?.add(this.rig);
523
- this.rig.position.copy(this._originalXRRigPosition);
524
- this.rig.quaternion.copy(this._originalXRRigRotation);
525
- }
526
-
527
- XRState.Global.Set(XRStateFlag.Browser | XRStateFlag.ThirdPerson);
528
- WebXR.events.dispatchEvent({ type: WebXREvent.XRStopped, session: session });
529
- }
530
-
531
- private onEnterVR(_session: XRSession) {
532
- }
533
-
534
- private destroyControllers() {
535
- for (let i = this.controllers.length - 1; i >= 0; i -= 1) {
536
- this.controllers[i]?.destroy();
537
- }
538
- this.controllers.length = 0;
539
- }
540
-
541
- private xrMirrorWindow: Window | null = null;
542
-
543
- private onEnterXR_HandleMirrorWindow(session: XRSession) {
544
- if (!getParam("mirror")) return;
545
- setTimeout(() => {
546
- if (!WebXR.IsInWebXR) return;
547
- const url = new URL(window.location.href);
548
- setOrAddParamsToUrl(url.searchParams, noVoip, 1);
549
- setOrAddParamsToUrl(url.searchParams, "isMirror", 1);
550
- const str = url.toString();
551
- this.xrMirrorWindow = window.open(str, "webxr sync", "popup=yes");
552
- if (this.xrMirrorWindow) {
553
- this.xrMirrorWindow.onload = () => {
554
- if (this.xrMirrorWindow)
555
- this.xrMirrorWindow.onbeforeunload = () => {
556
- if (WebXR.IsInWebXR)
557
- session.end();
558
- };
559
- }
560
- }
561
- }, 1000);
562
- }
563
- }
564
-
565
-
566
- // not sure if this should be a behaviour.
567
- // for now we dont really need it to go through the usual update loop
568
- export class WebAR {
569
-
570
- get webxr(): WebXR { return this._webxr; }
571
-
572
- private _webxr: WebXR;
573
-
574
- private reticle: Object3D | null = null;
575
- private reticleParent: Object3D | null = null;
576
- private hitTestSource: XRHitTestSource | null = null;
577
- private reticleActive: boolean = true;
578
-
579
- // scene.background before entering AR
580
- private previousBackground: Color | null | Texture = null;
581
- private previousEnvironment: Texture | null = null;
582
-
583
- private sessionRoot: WebARSessionRoot | null = null;
584
- private _previousParent: Object3D | null = null;
585
- // we need this in case the session root is on the same object as the webxr component
586
- // so if we disable the session root we attach the webxr component to this temporary object
587
- // to still receive updates
588
- private static tempWebXRObject: Object3D;
589
-
590
- private get context() { return this.webxr.context; }
591
-
592
- constructor(webxr: WebXR) {
593
- this._webxr = webxr;
594
- }
595
-
596
- private arDomOverlay: HTMLElement | null = null;
597
- private arOverlayElement: INeedleEngineComponent | HTMLElement | null = null;
598
- private noHitTestAvailable: boolean = false;
599
- private didPlaceARSessionRoot: boolean = false;
600
-
601
- getAROverlayContainer(): HTMLElement | null {
602
- this.arDomOverlay = this.webxr.context.domElement as HTMLElement;
603
- // for react cases we dont have an Engine Element
604
- const element: any = this.arDomOverlay;
605
- if (element.getAROverlayContainer)
606
- this.arOverlayElement = element.getAROverlayContainer();
607
- else this.arOverlayElement = this.arDomOverlay;
608
- return this.arOverlayElement;
609
- }
610
-
611
- setReticleActive(active: boolean) {
612
- this.reticleActive = active;
613
- }
614
-
615
- async onBegin(session: XRSession) {
616
- const context = this.webxr.context;
617
- this.reticleActive = true;
618
- this.didPlaceARSessionRoot = false;
619
- this.getAROverlayContainer();
620
-
621
- const deviceType = isQuest() ? ControllerType.PhysicalDevice : ControllerType.Touch;
622
- const controllerCount = deviceType === ControllerType.Touch ? 4 : 2;
623
- for (let i = 0; i < controllerCount; i++) {
624
- WebXRController.Create(this.webxr, i, this.webxr.gameObject as GameObject, deviceType)
625
- }
626
-
627
- if (!this.sessionRoot || this.sessionRoot.destroyed || !this.sessionRoot.activeAndEnabled)
628
- this.sessionRoot = GameObject.findObjectOfType(WebARSessionRoot, context);
629
- if (!this.sessionRoot) {
630
- // TODO: adding it on the scene directly doesnt work (probably because then everything in the scene is disabled including this component). See code a bit furhter below where we add this component to a temporary object inside the scene
631
- const obj = this.webxr.gameObject;
632
- this.sessionRoot = GameObject.addNewComponent(obj, WebARSessionRoot);
633
- console.warn("WebAR: No ARSessionRoot found, creating one automatically on the WebXR object");
634
- }
635
-
636
- this.previousBackground = context.scene.background;
637
- this.previousEnvironment = context.scene.environment;
638
- context.scene.background = null;
639
-
640
- session.requestReferenceSpace('viewer').then((referenceSpace) => {
641
- session.requestHitTestSource?.call(session, { space: referenceSpace })?.then((source) => {
642
- this.hitTestSource = source;
643
- }).catch((err) => {
644
- this.noHitTestAvailable = true;
645
- console.warn("WebXR: Hit test not supported", err);
283
+ if (this.createQRCode && !isMobileDevice()) {
284
+ NeedleXRSession.isXRSupported().then(supported => {
285
+ if (isDesktop() || !supported) this.getButtonsContainer().createQRCode();
646
286
  });
647
- });
648
-
649
- if (!this.reticle && this.sessionRoot) {
650
- this.reticle = new Mesh(
651
- new RingGeometry(0.07, 0.09, 32).rotateX(- Math.PI / 2),
652
- new MeshBasicMaterial()
653
- );
654
- this.reticle.name = "AR Placement reticle";
655
- this.reticle.matrixAutoUpdate = false;
656
- this.reticle.visible = false;
657
-
658
- // create AR reticle parent to allow WebXRSessionRoot to be translated, rotated or scaled
659
- this.reticleParent = new Object3D();
660
- this.reticleParent.name = "AR Reticle Parent";
661
- this.reticleParent.matrixAutoUpdate = false;
662
- this.reticleParent.add(this.reticle);
663
- // this.reticleParent.matrix.copy(this.sessionRoot.gameObject.matrixWorld);
664
-
665
- if (this.webxr.scene) {
666
- this.context.scene.add(this.reticleParent);
667
- // this.context.scene.add(this.reticle);
668
- this.context.scene.visible = true;
669
- }
670
- else console.warn("Could not found WebXR Rig");
671
287
  }
672
-
673
- this._previousParent = this.webxr.gameObject;
674
- if (!WebAR.tempWebXRObject) WebAR.tempWebXRObject = new Object3D();
675
- this.context.scene.add(WebAR.tempWebXRObject);
676
- GameObject.addComponent(WebAR.tempWebXRObject as GameObject, this.webxr);
677
-
678
- if (this.sessionRoot) {
679
- this.sessionRoot.webAR = this;
680
- this.sessionRoot?.onBegin(session);
681
- }
682
- else console.warn("No WebARSessionRoot found in scene")
683
-
684
- const eng = this.context.domElement as INeedleEngineComponent;
685
- eng?.onEnterAR?.call(eng, session, this.arOverlayElement!);
686
-
687
- this.context.mainCameraComponent?.applyClearFlagsIfIsActiveCamera();
688
288
  }
689
289
 
690
- onEnd(session: XRSession) {
691
- if (this._previousParent) {
692
- GameObject.addComponent(this._previousParent as GameObject, this.webxr);
693
- this._previousParent = null;
694
- }
695
- this.hitTestSource = null;
696
- const context = this.webxr.context;
697
- context.scene.background = this.previousBackground;
698
- context.scene.environment = this.previousEnvironment;
699
- if (this.sessionRoot) {
700
- this.sessionRoot.onEnd(this.webxr.Rig, session);
701
- }
702
290
 
703
- const el = this.context.domElement as INeedleEngineComponent;
704
- el.onExitAR?.call(el, session);
705
-
706
- this.context.mainCameraComponent?.applyClearFlagsIfIsActiveCamera();
707
- }
708
-
709
- onUpdate(session: XRSession, frame: XRFrame) {
710
-
711
- if (this.noHitTestAvailable === true) {
712
- if (this.reticle)
713
- this.reticle.visible = false;
714
- if (!this.didPlaceARSessionRoot) {
715
- this.didPlaceARSessionRoot = true;
716
- const rig = this.webxr.Rig;
717
- const placementMatrix = arPlacementWithoutHitTestMatrix.clone();
718
- // if (rig) {
719
- // const positionFromRig = new Vector3(0, 0, 0).add(rig.position).divideScalar(this.sessionRoot?.arScale ?? 1);
720
- // placementMatrix.multiply(new Matrix4().makeTranslation(positionFromRig.x, positionFromRig.y, positionFromRig.z));
721
- // // placementMatrix.setPosition(positionFromRig);
722
- // }
723
- this.sessionRoot?.placeAt(rig, placementMatrix);
724
- }
725
- return;
726
- }
727
-
728
- if (!this.hitTestSource) return;
729
- const hitTestResults = frame.getHitTestResults(this.hitTestSource);
730
- if (hitTestResults.length) {
731
- const hit = hitTestResults[0];
732
- const referenceSpace = this.webxr.context.renderer.xr.getReferenceSpace();
733
- if (referenceSpace) {
734
- const pose = hit.getPose(referenceSpace);
735
-
736
- if (this.sessionRoot) {
737
- const didPlace = this.sessionRoot.onUpdate(this.webxr.Rig, session, hit, pose);
738
- this.didPlaceARSessionRoot = didPlace;
739
- }
740
-
741
- if (this.reticle) {
742
- this.reticle.visible = this.reticleActive && this._webxr.allowARPlacementReticle;
743
- if (this.reticleActive) {
744
- if (pose) {
745
- const matrix = pose.transform.matrix;
746
- this.reticle.matrix.fromArray(matrix);
747
- if (this.webxr.Rig)
748
- this.reticle.matrix.premultiply(this.webxr.Rig.matrix);
749
- }
750
- }
751
- }
752
- }
753
-
754
- } else {
755
- this.sessionRoot?.onUpdate(this.webxr.Rig, session, null, null);
756
- if (this.reticle)
757
- this.reticle.visible = false;
758
- }
759
- }
760
291
  }
761
-
762
- const arPlacementWithoutHitTestMatrix = new Matrix4().identity().makeTranslation(0, 0, 0);
src/engine-components/webxr/WebXRAvatar.ts CHANGED
@@ -1,16 +1,7 @@
1
1
  import { Behaviour, GameObject } from "../Component.js";
2
- import { WebXR } from "./WebXR.js";
3
- import { Quaternion, Vector3 } from "three";
4
- import { AvatarLoader } from "../AvatarLoader.js";
5
- import { XRFlag, XRStateFlag } from "../XRFlag.js";
6
- import { Avatar_POI } from "../avatar/Avatar_Brain_LookAt.js";
7
- import { Context } from "../../engine/engine_setup.js";
8
- import { AssetReference } from "../../engine/engine_addressables.js";
9
2
  import { Object3D } from "three";
10
- import { VRUserState } from "./WebXRSync.js";
11
3
  import { getParam } from "../../engine/engine_utils.js";
12
- import { ViewDevice } from "../../engine/engine_playerview.js";
13
- import { InstancingUtil } from "../../engine/engine_instancing.js";
4
+ import { XRFlag } from "./XRFlag.js";
14
5
 
15
6
  export const debug = getParam("debugavatar");
16
7
 
@@ -19,6 +10,12 @@
19
10
  gameObject: Object3D;
20
11
  }
21
12
 
13
+ /**
14
+ * This is used to mark an object being controlled / owned by a player
15
+ * This system might be refactored and moved to a more centralized place in a future version
16
+ */
17
+ // We might be updating this system in the future to a centralized API (PlayerView)
18
+ // but since currently quite a few core components rely on it, we're keeping it for now
22
19
  export class AvatarMarker extends Behaviour {
23
20
 
24
21
  public static getAvatar(index: number): AvatarMarker | null {
@@ -44,7 +41,7 @@
44
41
 
45
42
 
46
43
  public connectionId!: string;
47
- public avatar?: WebXRAvatar | Object3D;
44
+ public avatar?: Object3D & { flags?: XRFlag[] }
48
45
 
49
46
  awake() {
50
47
  AvatarMarker.instances.push(this);
@@ -65,292 +62,4 @@
65
62
  isLocalAvatar() {
66
63
  return this.connectionId === this.context.connection.connectionId;
67
64
  }
68
-
69
- setVisible(visible: boolean) {
70
- if (this.avatar) {
71
- if ("setVisible" in this.avatar)
72
- this.avatar.setVisible(visible);
73
- else {
74
- GameObject.setActive(this.avatar, visible);
75
- }
76
- }
77
- }
78
65
  }
79
-
80
-
81
- export class WebXRAvatar {
82
- private static loader: AvatarLoader = new AvatarLoader();
83
-
84
- private _isVisible: boolean = true;
85
- setVisible(visible: boolean) {
86
- this._isVisible = visible;
87
- this.updateVisibility();
88
- }
89
-
90
- get isWebXRAvatar() { return true; }
91
-
92
- // TODO: set layers on all avatars
93
- /** the user id */
94
- public guid: string;
95
-
96
- private root: Object3D | null = null;
97
- public head: Object3D | null = null;
98
- public handLeft: Object3D | null = null;
99
- public handRight: Object3D | null = null;
100
- public lastUpdate: number = -1;
101
- public isLocalAvatar: boolean = false;
102
- public flags: XRFlag[] | null = null;
103
- private headScale: Vector3 = new Vector3(1, 1, 1);
104
- private handLeftScale: Vector3 = new Vector3(1, 1, 1);
105
- private handRightScale: Vector3 = new Vector3(1, 1, 1);
106
-
107
- private readonly webxr: WebXR;
108
-
109
- private lastAvatarId: string | null = null;
110
- private hasAvatarOverride: boolean = false;
111
-
112
-
113
- private context: Context;
114
- private avatarMarker: AvatarMarker | null = null;
115
-
116
- constructor(context: Context, guid: string, webXR: WebXR) {
117
- this.context = context;
118
- this.guid = guid;
119
- this.webxr = webXR;
120
- this.setupCustomAvatar(this.webxr.defaultAvatar);
121
- }
122
-
123
- public updateFlags() {
124
- if (!this.flags)
125
- return;
126
- let mask = this.isLocalAvatar ? XRStateFlag.FirstPerson : XRStateFlag.ThirdPerson;
127
- if (this.context.isInVR)
128
- mask |= XRStateFlag.VR;
129
- else if (this.context.isInAR)
130
- mask |= XRStateFlag.AR;
131
- else
132
- mask |= XRStateFlag.Browser;
133
- for (const f of this.flags) {
134
- f.gameObject.visible = true;
135
- f.UpdateVisible(mask);
136
- }
137
- }
138
-
139
- public async setAvatarOverride(avatarId: string | null): Promise<boolean | null> {
140
- this.hasAvatarOverride = avatarId !== null;
141
- if (this.hasAvatarOverride && this.lastAvatarId !== avatarId) {
142
- this.lastAvatarId = avatarId;
143
- if (avatarId != null && avatarId.length > 0)
144
- return await this.setupCustomAvatar(avatarId);
145
- }
146
- return null;
147
- }
148
-
149
- private _headTarget: Object3D = new Object3D();
150
- private _handLeftTarget: Object3D = new Object3D();
151
- private _handRightTarget: Object3D = new Object3D();
152
- private _canInterpolate: boolean = false;
153
-
154
- private static invertRotation: Quaternion = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI);
155
-
156
- public tryUpdate(state: VRUserState, _timeDiff: number) {
157
- if (state.guid === this.guid) {
158
-
159
- if (this.lastAvatarId !== state.avatarId && state.avatarId && state.avatarId.length > 0) {
160
- this.lastAvatarId = state.avatarId;
161
- this.setupCustomAvatar(state.avatarId);
162
- }
163
-
164
- this.lastUpdate = state.time;
165
- if (this.head) {
166
-
167
- const device = this.webxr.IsInAR ? ViewDevice.Handheld : ViewDevice.Headset;
168
- const viewObj = this.head;
169
- // if (this.isLocalAvatar) {
170
- // if (this.context.mainCamera && this.context.isInXR) {
171
- // viewObj = this.context.renderer.xr.getCamera(this.context.mainCamera);
172
- // }
173
- // }
174
- this.context.players.setPlayerView(state.guid, viewObj, device);
175
-
176
- InstancingUtil.markDirty(this.head);
177
-
178
- this._canInterpolate = true;
179
- const ht = this.isLocalAvatar ? this.head : this._headTarget;
180
- ht.position.set(state.position.x, state.position.y, state.position.z);
181
- // not sure how position in local space can be correct but rotation is wrong / offset when parent rotates
182
- ht.quaternion.set(state.rotation.x, state.rotation.y, state.rotation.z, state.rotation.w);
183
- ht.scale.set(state.scale, state.scale, state.scale);
184
- ht.scale.multiply(this.headScale);
185
-
186
- if (this.handLeft) {
187
- const ht = this.isLocalAvatar ? this.handLeft : this._handLeftTarget;
188
- ht.position.set(state.posLeftHand.x, state.posLeftHand.y, state.posLeftHand.z);
189
- ht.quaternion.set(state.rotLeftHand["_x"], state.rotLeftHand["_y"], state.rotLeftHand["_z"], state.rotLeftHand["_w"]);
190
- ht.quaternion.multiply(WebXRAvatar.invertRotation);
191
- ht.scale.set(state.scale, state.scale, state.scale);
192
- ht.scale.multiply(this.handLeftScale);
193
- InstancingUtil.markDirty(this.handLeft);
194
- }
195
-
196
- if (this.handRight) {
197
- const ht = this.isLocalAvatar ? this.handRight : this._handRightTarget;
198
- ht.position.set(state.posRightHand.x, state.posRightHand.y, state.posRightHand.z);
199
- ht.quaternion.set(state.rotRightHand["_x"], state.rotRightHand["_y"], state.rotRightHand["_z"], state.rotRightHand["_w"]);
200
- ht.quaternion.multiply(WebXRAvatar.invertRotation);
201
- ht.scale.set(state.scale, state.scale, state.scale);
202
- ht.scale.multiply(this.handRightScale);
203
- InstancingUtil.markDirty(this.handRight);
204
- }
205
- }
206
- }
207
- }
208
-
209
- public update() {
210
- if (this.isLocalAvatar)
211
- return;
212
- if (!this._canInterpolate)
213
- return;
214
- const t = this.context.time.deltaTime / .1;
215
- if (this.head) {
216
- this.head.position.lerp(this._headTarget.position, t);
217
- this.head.quaternion.slerp(this._headTarget.quaternion, t);
218
- this.head.scale.lerp(this._headTarget.scale, t);
219
- }
220
- if (this.handLeft && this._handLeftTarget) {
221
- this.handLeft.position.lerp(this._handLeftTarget.position, t);
222
- this.handLeft.quaternion.slerp(this._handLeftTarget.quaternion, t);
223
- this.handLeft.scale.lerp(this._handLeftTarget.scale, t);
224
- }
225
- if (this.handRight && this._handRightTarget) {
226
- this.handRight.position.lerp(this._handRightTarget.position, t);
227
- this.handRight.quaternion.slerp(this._handRightTarget.quaternion, t);
228
- this.handRight.scale.lerp(this._handRightTarget.scale, t);
229
- }
230
- }
231
-
232
- public destroy() {
233
- if (debug)
234
- console.log("Destroy avatar", this.guid);
235
- this.root?.removeFromParent();
236
- this.avatarMarker?.destroy();
237
- this.lastAvatarId = null;
238
-
239
- if (this.head) {
240
- Avatar_POI.Remove(this.context, this.head);
241
- }
242
- // this.head?.removeFromParent();
243
- // this.handLeft?.removeFromParent();
244
- // this.handRight?.removeFromParent();
245
- }
246
-
247
- private updateVisibility() {
248
- const root = this.root;
249
- if (root) {
250
- GameObject.setActive(root, this._isVisible);
251
- }
252
- }
253
-
254
- private async setupCustomAvatar(avatarId: string | Object3D | AssetReference | undefined): Promise<boolean> {
255
- if (debug)
256
- console.log("LOAD", avatarId, this);
257
-
258
- if (!avatarId || (typeof avatarId === "string" && avatarId.length <= 0))
259
- return false;
260
-
261
- if (this.head) {
262
- Avatar_POI.Remove(this.context, this.head);
263
- }
264
-
265
- const reference = avatarId as AssetReference;
266
- if (reference?.loadAssetAsync !== undefined) {
267
- await reference.loadAssetAsync();
268
- const prefab = reference.asset as Object3D;
269
- GameObject.setActive(prefab, false);
270
- avatarId = GameObject.instantiate(prefab as Object3D) as Object3D;
271
- GameObject.setActive(avatarId, true);
272
- // console.log("Avatar", avatarId);
273
- }
274
- if (debug)
275
- console.log(avatarId);
276
-
277
- const model = await WebXRAvatar.loader.getOrCreateNewAvatarInstance(this.context, avatarId as (Object3D | string));
278
- if (debug)
279
- console.log(model, model?.isValid, this.lastAvatarId, avatarId);
280
- // if (this.lastAvatarId !== avatarId) {
281
- // // avatar id changed in the meantime
282
- // return true;
283
- // }
284
- if (model?.isValid) {
285
- this.root = model.root;
286
-
287
- this.root.position.set(0, 0, 0);
288
- this.root.quaternion.set(0, 0, 0, 1);
289
- this.root.scale.set(1, 1, 1); // should we allow a scaled avatar root?!
290
-
291
- this.avatarMarker = GameObject.addNewComponent(this.root as GameObject, AvatarMarker) as AvatarMarker;
292
- this.avatarMarker.connectionId = this.guid;
293
- this.avatarMarker.avatar = this;
294
-
295
- if (this.head && this.head !== model.head)
296
- this.head?.removeFromParent();
297
- this.head = model.head;
298
- this.headScale.copy(this.head.scale);
299
-
300
- if (this.head && !this.isLocalAvatar) {
301
- Avatar_POI.Add(this.context, this.head, this.avatarMarker);
302
- }
303
-
304
- if (model.leftHand)
305
- this.handLeft?.removeFromParent();
306
- this.handLeft = model.leftHand ?? this.handLeft;
307
- if (this.handLeft)
308
- this.handLeftScale.copy(this.handLeft.scale);
309
- else
310
- this.handLeftScale.set(1, 1, 1);
311
-
312
- if (model.rigthHand)
313
- this.handRight?.removeFromParent();
314
- this.handRight = model.rigthHand ?? this.handRight;
315
- if (this.handRight)
316
- this.handRightScale.copy(this.handRight.scale);
317
- else
318
- this.handRightScale.set(1, 1, 1);
319
-
320
-
321
- this.context.scene.add(this.root);
322
- // scene.add(this.handLeft);
323
- // scene.add(this.handRight);
324
- // this.mouthShapes = null;
325
- // this.needSearchEyes = true;
326
- if (this.flags == null)
327
- this.flags = [];
328
- this.flags.length = 0;
329
- this.flags.push(...GameObject.getComponentsInChildren(this.root as GameObject, XRFlag));
330
- // if no flags are found add at least a head flag to hide head in first person VR
331
- if (this.flags.length <= 0) {
332
- if (this.head) {
333
- const flag = GameObject.addNewComponent(this.head, XRFlag) as XRFlag;
334
- // TODO: the defaults are wrong? should be Desktop | ThirdPerson ?
335
- flag.visibleIn = XRStateFlag.ThirdPerson | XRStateFlag.VR;
336
- this.flags.push(flag);
337
- if (debug)
338
- console.log("Added flag to head: " + flag.visibleIn, this.head.name);
339
- }
340
- }
341
-
342
- if (debug)
343
- console.log("[Avatar], is Local? ", this.isLocalAvatar, this.root);
344
- this.updateFlags();
345
-
346
- this.updateVisibility();
347
-
348
- return true;
349
- }
350
- else {
351
- if (debug)
352
- console.warn("build avatar failed");
353
- return false;
354
- }
355
- }
356
- }
src/engine-components/webxr/WebXRController.ts DELETED
@@ -1,1168 +0,0 @@
1
- import { BoxHelper, BufferGeometry, Color, Euler, Group, type Intersection, Layers, Line, LineBasicMaterial, Material, Mesh, MeshBasicMaterial, Object3D, PerspectiveCamera, Quaternion, Ray, SphereGeometry, Vector2, Vector3 } from "three";
2
- import { OculusHandModel } from 'three/examples/jsm/webxr/OculusHandModel.js';
3
- import { OculusHandPointerModel } from 'three/examples/jsm/webxr/OculusHandPointerModel.js';
4
- import { XRControllerModel, XRControllerModelFactory } from 'three/examples/jsm/webxr/XRControllerModelFactory.js';
5
- import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
6
-
7
- import { InstancingUtil } from "../../engine/engine_instancing.js";
8
- import { Mathf } from "../../engine/engine_math.js";
9
- import { RaycastOptions } from "../../engine/engine_physics.js";
10
- import { getWorldPosition, getWorldQuaternion, setWorldPosition, setWorldQuaternion } from "../../engine/engine_three_utils.js";
11
- import { getParam, resolveUrl } from "../../engine/engine_utils.js";
12
- import { addDracoAndKTX2Loaders } from "../../engine/engine_loaders.js";
13
-
14
- import { Avatar_POI } from "../avatar/Avatar_Brain_LookAt.js";
15
- import { Behaviour, GameObject } from "../Component.js";
16
- import { Interactable, UsageMarker } from "../Interactable.js";
17
- import { Rigidbody } from "../RigidBody.js";
18
- import { SyncedTransform } from "../SyncedTransform.js";
19
- import { UIRaycastUtils } from "../ui/RaycastUtils.js";
20
- import { WebXR } from "./WebXR.js";
21
- import { XRRig } from "./WebXRRig.js";
22
- import { InputEvents, NEPointerEvent, PointerType } from "../../engine/engine_input.js";
23
-
24
- const debug = getParam("debugwebxrcontroller");
25
-
26
- export enum ControllerType {
27
- PhysicalDevice = 0,
28
- Touch = 1,
29
- }
30
-
31
- export enum ControllerEvents {
32
- SelectStart = "select-start",
33
- SelectEnd = "select-end",
34
- Update = "update",
35
- }
36
-
37
- export class TeleportTarget extends Behaviour {
38
-
39
- }
40
-
41
- export class WebXRController extends Behaviour {
42
-
43
- public static Factory: XRControllerModelFactory = new XRControllerModelFactory();
44
-
45
- private static raycastColor: Color = new Color(.9, .3, .3);
46
- private static raycastNoHitColor: Color = new Color(.6, .6, .6);
47
- private static geometry = new BufferGeometry().setFromPoints([new Vector3(0, 0, 0), new Vector3(0, 0, -1)]);
48
- private static handModels: { [index: number]: OculusHandPointerModel } = {};
49
-
50
- private static CreateRaycastLine(): Line {
51
- const line = new Line(this.geometry);
52
- const mat = line.material as LineBasicMaterial;
53
- mat.color = this.raycastColor;
54
- // mat.linewidth = 10;
55
- line.layers.set(2);
56
- line.name = 'line';
57
- line.scale.z = 1;
58
- return line;
59
- }
60
-
61
- private static CreateRaycastHitPoint(): Mesh {
62
- const geometry = new SphereGeometry(.5, 22, 22);
63
- const material = new MeshBasicMaterial({ color: this.raycastColor });
64
- const sphere = new Mesh(geometry, material);
65
- sphere.visible = false;
66
- sphere.layers.set(2);
67
- return sphere;
68
- }
69
-
70
- public static Create(owner: WebXR, index: number, addTo: GameObject, type: ControllerType): WebXRController {
71
- const ctrl = addTo ? GameObject.addNewComponent(addTo, WebXRController, false) : new WebXRController();
72
-
73
- ctrl.webXR = owner;
74
- ctrl.index = index;
75
- ctrl.type = type;
76
-
77
- const context = owner.context;
78
- // from https://github.com/mrdoob/js/blob/master/examples/webxr_vr_dragging.html
79
- // controllers
80
- ctrl.controller = context.renderer.xr.getController(index);
81
- ctrl.controllerGrip = context.renderer.xr.getControllerGrip(index);
82
- ctrl.controllerModel = this.Factory.createControllerModel(ctrl.controller);
83
- ctrl.controllerGrip.add(ctrl.controllerModel);
84
-
85
- ctrl.hand = context.renderer.xr.getHand(index);
86
-
87
- const loader = new GLTFLoader();
88
- addDracoAndKTX2Loaders(loader, context);
89
- if (ctrl.webXR.handModelPath && ctrl.webXR.handModelPath !== "")
90
- loader.setPath(resolveUrl(owner.sourceId, ctrl.webXR.handModelPath));
91
- else
92
- // from XRHandMeshModel.js
93
- loader.setPath('https://cdn.jsdelivr.net/npm/@webxr-input-profiles/[email protected]/dist/profiles/generic-hand/');
94
- //@ts-ignore
95
- const hand = new OculusHandModel(ctrl.hand, loader);
96
-
97
- ctrl.hand.add(hand);
98
- ctrl.hand.traverse(x => x.layers.set(2));
99
-
100
- ctrl.handPointerModel = new OculusHandPointerModel(ctrl.hand, ctrl.controller);
101
-
102
-
103
- // TODO remove all these once https://github.com/mrdoob/js/pull/23279 lands
104
- ctrl.controller.addEventListener('connected', (_) => {
105
- ctrl.setControllerLayers(ctrl.controllerModel, 2);
106
- ctrl.setControllerLayers(ctrl.controllerGrip, 2);
107
- ctrl.setControllerLayers(ctrl.hand, 2);
108
- setTimeout(() => {
109
- ctrl.setControllerLayers(ctrl.controllerModel, 2);
110
- ctrl.setControllerLayers(ctrl.controllerGrip, 2);
111
- ctrl.setControllerLayers(ctrl.hand, 2);
112
- }, 1000);
113
- });
114
-
115
- // TODO: unsubscribe! this should be moved into onenable and ondisable!
116
- // TODO remove all these once https://github.com/mrdoob/js/pull/23279 lands
117
- ctrl.hand.addEventListener('connected', (event) => {
118
- const xrInputSource = event.data;
119
- if (xrInputSource.hand) {
120
- if (owner.Rig) owner.Rig.add(ctrl.hand);
121
- ctrl.type = ControllerType.PhysicalDevice;
122
- ctrl.handPointerModel.traverse(x => x.layers.set(2)); // ignore raycast
123
- ctrl.handPointerModel.pointerObject?.traverse(x => x.layers.set(2));
124
-
125
- // when exiting and re-entering xr the joints are not parented to the hand anymore
126
- // this is a workaround to fix that temporarely
127
- // see https://github.com/needle-tools/needle-tiny-playground/issues/123
128
- const jnts = ctrl.hand["joints"];
129
- if (jnts) {
130
- for (const key of Object.keys(jnts)) {
131
- const joint = jnts[key];
132
- if (joint.parent) continue;
133
- ctrl.hand.add(joint);
134
- }
135
- }
136
- }
137
- });
138
-
139
- return ctrl;
140
- }
141
-
142
- // TODO: replace with component events
143
- public static addEventListener(evt: ControllerEvents, callback: (controller: WebXRController, args: any) => void) {
144
- const list = this.eventSubs[evt] ?? [];
145
- list.push(callback);
146
- this.eventSubs[evt] = list;
147
- }
148
-
149
- // TODO: replace with component events
150
- public static removeEventListener(evt: ControllerEvents, callback: (controller: WebXRController, args: any) => void) {
151
- if (!callback) return;
152
- const list = this.eventSubs[evt] ?? [];
153
- const idx = list.indexOf(callback);
154
- if (idx >= 0) list.splice(idx, 1);
155
- this.eventSubs[evt] = list;
156
- }
157
-
158
- private static eventSubs: { [key: string]: Function[] } = {};
159
-
160
- public webXR?: WebXR;
161
- public index: number = -1;
162
- public controllerModel!: XRControllerModel;
163
- public controller!: Group;
164
- public controllerGrip!: Group;
165
- public hand!: Group;
166
- public handPointerModel!: OculusHandPointerModel;
167
- public grabbed: AttachedObject | null = null;
168
- public input: XRInputSource | null = null;
169
- public type: ControllerType = ControllerType.PhysicalDevice;
170
- public showRaycastLine: boolean = true;
171
- public enableRaycasts: boolean = true;
172
- public enableDefaultControls: boolean = true;
173
-
174
- get isUsingHands(): boolean {
175
- const r = this.input?.hand;
176
- return r !== null && r !== undefined;
177
- }
178
-
179
- get wrist(): Object3D | null {
180
- if (!this.hand) return null;
181
- const jnts = this.hand["joints"];
182
- if (!jnts) return null;
183
- return jnts["wrist"];
184
- }
185
-
186
- private _wristQuaternion: Quaternion | null = null;
187
- getWristQuaternion(): Quaternion | null {
188
- const wrist = this.wrist;
189
- if (!wrist) return null;
190
- if (!this._wristQuaternion) this._wristQuaternion = new Quaternion();
191
- const wr = getWorldQuaternion(wrist).multiply(this._wristQuaternion.setFromEuler(new Euler(-Math.PI / 4, 0, 0)));
192
- return wr;
193
- }
194
-
195
- private movementVector: Vector3 = new Vector3();
196
- private worldRot: Quaternion = new Quaternion();
197
- private joystick: Vector2 = new Vector2();
198
- private didRotate: boolean = false;
199
- private didTeleport: boolean = false;
200
- private didChangeScale: boolean = false;
201
- private static PreviousCameraFarDistance: number | undefined = undefined;
202
- private static MovementSpeedFactor: number = 1;
203
-
204
- private lastHit: Intersection | null = null;
205
-
206
- private raycastLine: Line | null = null;
207
- private _raycastHitPoint: Object3D | null = null;
208
- private _connnectedCallback: any | null = null;
209
- private _disconnectedCallback: any | null = null;
210
- private _selectStartEvt: any | null = null;
211
- private _selectEndEvt: any | null = null;
212
-
213
- public get selectionDown(): boolean { return this._selectionPressed && !this._selectionPressedLastFrame; }
214
- public get selectionUp(): boolean { return !this._selectionPressed && this._selectionPressedLastFrame; }
215
- public get selectionPressed(): boolean { return this._selectionPressed; }
216
- public get selectionClick(): boolean { return this._selectionEndTime - this._selectionStartTime < 0.3; }
217
- public get raycastHitPoint(): Object3D | null { return this._raycastHitPoint; }
218
-
219
- private _selectionPressed: boolean = false;
220
- private _selectionPressedLastFrame: boolean = false;
221
- private _selectionStartTime: number = 0;
222
- private _selectionEndTime: number = 0;
223
-
224
- public get useSmoothing(): boolean { return this._useSmoothing };
225
- private _useSmoothing: boolean = true;
226
-
227
- awake(): void {
228
- if (!this.controller) {
229
- console.warn("WebXRController: Missing controller object.", this);
230
- return;
231
- }
232
- this._connnectedCallback = this.onSourceConnected.bind(this);
233
- this._disconnectedCallback = this.onSourceDisconnected.bind(this);
234
- this._selectStartEvt = this.onSelectStart.bind(this);
235
- this._selectEndEvt = this.onSelectEnd.bind(this);
236
- if (this.type === ControllerType.Touch) {
237
- this.controllerGrip.addEventListener("connected", this._connnectedCallback);
238
- this.controllerGrip.addEventListener("disconnected", this._disconnectedCallback);
239
- this.controller.addEventListener('selectstart', this._selectStartEvt);
240
- this.controller.addEventListener('selectend', this._selectEndEvt);
241
- }
242
- if (this.type === ControllerType.PhysicalDevice) {
243
- this.controller.addEventListener('selectstart', this._selectStartEvt);
244
- this.controller.addEventListener('selectend', this._selectEndEvt);
245
- }
246
- }
247
-
248
- onDestroy(): void {
249
- if (this.type === ControllerType.Touch) {
250
- this.controllerGrip.removeEventListener("connected", this._connnectedCallback);
251
- this.controllerGrip.removeEventListener("disconnected", this._disconnectedCallback);
252
- this.controller.removeEventListener('selectstart', this._selectStartEvt);
253
- this.controller.removeEventListener('selectend', this._selectEndEvt);
254
- }
255
- if (this.type === ControllerType.PhysicalDevice) {
256
- this.controller.removeEventListener('selectstart', this._selectStartEvt);
257
- this.controller.removeEventListener('selectend', this._selectEndEvt);
258
- }
259
-
260
- this.hand?.clear();
261
- this.controllerGrip?.clear();
262
- this.controller?.clear();
263
- }
264
-
265
- public onEnable(): void {
266
- if (!this.webXR) {
267
- console.warn("No WebXR component assigned to WebXRController.");
268
- return;
269
- }
270
-
271
- if (this.hand)
272
- this.hand.name = "Hand";
273
- if (this.controllerGrip)
274
- this.controllerGrip.name = "ControllerGrip";
275
- if (this.controller)
276
- this.controller.name = "Controller";
277
- if (this.raycastLine)
278
- this.raycastLine.name = "RaycastLine;"
279
-
280
- if (this.webXR.Controllers.indexOf(this) < 0)
281
- this.webXR.Controllers.push(this);
282
-
283
- if (!this.raycastLine)
284
- this.raycastLine = WebXRController.CreateRaycastLine();
285
- if (!this._raycastHitPoint)
286
- this._raycastHitPoint = WebXRController.CreateRaycastHitPoint();
287
-
288
- this.webXR.Rig?.add(this.hand);
289
- this.webXR.Rig?.add(this.controllerGrip);
290
- this.webXR.Rig?.add(this.controller);
291
- this.webXR.Rig?.add(this.raycastLine);
292
- this.raycastLine?.add(this._raycastHitPoint);
293
- this._raycastHitPoint.visible = false;
294
- this.hand.add(this.handPointerModel);
295
- if (debug)
296
- console.log("ADDED TO RIG", this.webXR.Rig);
297
-
298
- // // console.log("enable", this.index, this.controllerGrip.uuid)
299
- }
300
-
301
- onDisable(): void {
302
- // console.log("XR controller disabled", this);
303
- this.hand?.removeFromParent();
304
- this.controllerGrip?.removeFromParent();
305
- this.controller?.removeFromParent();
306
- this.raycastLine?.removeFromParent();
307
- this._raycastHitPoint?.removeFromParent();
308
- // console.log("Disable", this._connnectedCallback, this._disconnectedCallback);
309
- // this.controllerGrip.removeEventListener("connected", this._connnectedCallback);
310
- // this.controllerGrip.removeEventListener("disconnected", this._disconnectedCallback);
311
-
312
- if (this.webXR) {
313
- const i = this.webXR.Controllers.indexOf(this);
314
- if (i >= 0)
315
- this.webXR.Controllers.splice(i, 1);
316
- }
317
- }
318
-
319
- // onDestroy(): void {
320
- // console.log("destroyed", this.index);
321
- // }
322
-
323
- private _isConnected: boolean = false;
324
-
325
- private onSourceConnected(e: { data: XRInputSource, target: any }) {
326
- if (this._isConnected) {
327
- console.warn("Received connected event for controller that is already connected", this.index, e);
328
- return;
329
- }
330
- this._isConnected = true;
331
- this.input = e.data;
332
-
333
- if (this.type === ControllerType.Touch) {
334
- this.onSelectStart();
335
- }
336
- }
337
-
338
- private onSourceDisconnected(_e: any) {
339
- if (!this._isConnected) {
340
- console.warn("Received discnnected event for controller that is not connected", _e);
341
- return;
342
- }
343
- this._isConnected = false;
344
- if (this.type === ControllerType.Touch) {
345
- this.onSelectEnd();
346
- }
347
- this.input = null;
348
- }
349
-
350
- private createPointerEvent(type: string) {
351
- switch (type) {
352
- case "down":
353
- this.context.input.createPointerDown(new NEPointerEvent(InputEvents.PointerDown, null, { clientX: 0, clientY: 0, button: this.index, pointerType: PointerType.Touch }));
354
- break;
355
- case "move":
356
- break;
357
- case "up":
358
- this.context.input.createPointerUp(new NEPointerEvent(InputEvents.PointerUp, null, { clientX: 0, clientY: 0, button: this.index, pointerType: PointerType.Touch }));
359
- break;
360
- }
361
- }
362
-
363
- rayRotation: Quaternion = new Quaternion();
364
-
365
- private raycastUpdate(raycastLine: Line, wp: Vector3) {
366
- const allowRaycastLineVisible = this.showRaycastLine && this.type !== ControllerType.Touch;
367
- if (this.type === ControllerType.Touch) {
368
- raycastLine.visible = false;
369
- }
370
- else if (this.isUsingHands) {
371
- raycastLine.visible = !this.grabbed && allowRaycastLineVisible;
372
- setWorldPosition(raycastLine, wp);
373
- const jnts = this.hand!['joints'];
374
- if (jnts) {
375
- const wrist = jnts['wrist'];
376
- if (wrist && this.grabbed && this.grabbed.isCloseGrab) {
377
- const wr = this.getWristQuaternion();
378
- if (wr)
379
- this.rayRotation.copy(wr);
380
- // this.rayRotation.slerp(wr, this.useSmoothing ? t * 2 : 1);
381
- }
382
- }
383
- setWorldQuaternion(raycastLine, this.rayRotation);
384
- }
385
- else {
386
- raycastLine.visible = allowRaycastLineVisible;
387
- setWorldQuaternion(raycastLine, this.rayRotation);
388
- setWorldPosition(raycastLine, wp);
389
- }
390
- }
391
-
392
- update(): void {
393
- if (!this.webXR) return;
394
-
395
- // TODO: we should wait until we actually have models, this is just a workaround
396
- if (this.context.time.frameCount % 60 === 0) {
397
- this.setControllerLayers(this.controller, 2);
398
- this.setControllerLayers(this.controllerGrip, 2);
399
- this.setControllerLayers(this.hand, 2);
400
- }
401
-
402
- const subs = WebXRController.eventSubs[ControllerEvents.Update];
403
- if (subs && subs.length > 0) {
404
- for (const sub of subs) {
405
- sub(this);
406
- }
407
- }
408
-
409
- let t = 1;
410
- if (this.type === ControllerType.PhysicalDevice) t = this.context.time.deltaTime / .1;
411
- else if (this.isUsingHands && this.handPointerModel.pinched) t = this.context.time.deltaTime / .3;
412
- this.rayRotation.slerp(getWorldQuaternion(this.controller), this.useSmoothing ? t : 1.0);
413
- const wp = getWorldPosition(this.controller);
414
-
415
- // hide hand pointer model, it's giant and doesn't really help
416
- if (this.isUsingHands && this.handPointerModel.cursorObject) {
417
- this.handPointerModel.cursorObject.visible = false;
418
- }
419
-
420
- // perform raycasts
421
- if(this.enableRaycasts)
422
- {
423
- if (this.raycastLine) {
424
- this.raycastUpdate(this.raycastLine, wp);
425
- }
426
-
427
- this.lastHit = this.updateLastHit();
428
-
429
- if (this.grabbed) {
430
- this.grabbed.update();
431
- }
432
- }
433
- else { // hide line when raycasting is disabled
434
- if (this.raycastLine) {
435
- this.raycastLine.visible = false;
436
- }
437
- }
438
-
439
- this._selectionPressedLastFrame = this._selectionPressed;
440
-
441
- if (this.selectStartCallback) {
442
- this.selectStartCallback();
443
- }
444
- }
445
-
446
- onUpdate(session: XRSession) {
447
- this.lastHit = null;
448
-
449
- if (!session || session.inputSources.length <= this.index) {
450
- this.input = null;
451
- return;
452
- }
453
- if (this.type === ControllerType.PhysicalDevice)
454
- this.input = session.inputSources[this.index];
455
- if (!this.input) return;
456
- const rig = this.webXR!.Rig;
457
- if (!rig) return;
458
-
459
- if (this._didNotEndSelection && !this.handPointerModel.pinched) {
460
- this._didNotEndSelection = false;
461
- this.onSelectEnd();
462
- }
463
-
464
- this.updateStick(this.input);
465
-
466
- const buttons = this.input?.gamepad?.buttons;
467
-
468
- if(this.enableDefaultControls) {
469
- switch (this.input.handedness) {
470
- case "left":
471
- this.movementUpdate(rig, buttons);
472
- break;
473
-
474
- case "right":
475
- this.rotationUpdate(rig, buttons);
476
- break;
477
- }
478
- }
479
- }
480
-
481
-
482
- private movementUpdate(rig: Object3D, buttons?: readonly GamepadButton[]) {
483
- const speedFactor = 3 * WebXRController.MovementSpeedFactor;
484
- const powFactor = 2;
485
- const speed = Mathf.clamp01(this.joystick.length() * 2);
486
-
487
- const sideDir = this.joystick.x > 0 ? 1 : -1;
488
- let side = Math.pow(this.joystick.x, powFactor);
489
- side *= sideDir;
490
- side *= speed;
491
-
492
-
493
- const forwardDir = this.joystick.y > 0 ? 1 : -1;
494
- let forward = Math.pow(this.joystick.y, powFactor);
495
- forward *= forwardDir;
496
- side *= speed;
497
-
498
- rig.getWorldQuaternion(this.worldRot);
499
- this.movementVector.set(side, 0, forward);
500
- this.movementVector.applyQuaternion(this.webXR!.TransformOrientation);
501
- this.movementVector.y = 0;
502
- this.movementVector.applyQuaternion(this.worldRot);
503
- this.movementVector.multiplyScalar(speedFactor * this.context.time.deltaTime);
504
- rig.position.add(this.movementVector);
505
-
506
- if (this.isUsingHands)
507
- this.runTeleport(rig, buttons);
508
- }
509
-
510
- private rotationUpdate(rig: Object3D, buttons?: readonly GamepadButton[]) {
511
- const rotate = this.joystick.x;
512
- const rotAbs = Math.abs(rotate);
513
- if (rotAbs < 0.4) {
514
- this.didRotate = false;
515
- }
516
- else if (rotAbs > .5 && !this.didRotate) {
517
- const dir = rotate > 0 ? -1 : 1;
518
- rig.rotateY(Mathf.toRadians(30 * dir));
519
- this.didRotate = true;
520
- }
521
-
522
- this.runTeleport(rig, buttons);
523
- }
524
- private _pinchStartTime: number | undefined = undefined;
525
-
526
- private runTeleport(rig: Object3D, buttons?: readonly GamepadButton[]) {
527
- let teleport = -this.joystick.y;
528
- if (this.hand?.visible && !this.grabbed) {
529
- const pinched = this.handPointerModel.isPinched();
530
- if (pinched && this._pinchStartTime === undefined) {
531
- this._pinchStartTime = this.context.time.time;
532
- }
533
- if (pinched && this._pinchStartTime && this.context.time.time - this._pinchStartTime > .8) {
534
- // hacky approach for basic hand teleportation -
535
- // we teleport if we pinch and the back of the hand points down (open hand gesture)
536
- // const v1 = new Vector3();
537
- // const worldQuaternion = new Quaternion();
538
- // this.controller.getWorldQuaternion(worldQuaternion);
539
- // v1.copy(this.controller.up).applyQuaternion(worldQuaternion);
540
- // const dotPr = -v1.dot(this.controller.up);
541
- teleport = this.handPointerModel.isPinched() ? 1 : 0;
542
- }
543
- if (!pinched) this._pinchStartTime = undefined;
544
- }
545
- else this._pinchStartTime = undefined;
546
-
547
- const inVR = this.webXR!.IsInVR;
548
- const xrRig = this.webXR!.Rig;
549
- let doTeleport = teleport > .5 && inVR;
550
- let isInMiniatureMode = xrRig ? xrRig?.scale?.x < .999 : false;
551
- let newRigScale: number | null = null;
552
-
553
- if (buttons && this.input && !this.input.hand) {
554
- for (let i = 0; i < buttons.length; i++) {
555
- const btn = buttons[i];
556
- // button[4] seems to be the A button if it exists. On hololens it's randomly pressed though for hands
557
- // see https://www.w3.org/TR/webxr-gamepads-module-1/#xr-standard-gamepad-mapping
558
- if (i === 4) {
559
- if (btn.pressed && !this.didChangeScale && inVR) {
560
- this.didChangeScale = true;
561
- const rig = xrRig;
562
- if (rig) {
563
- const args = this.switchScale(rig, doTeleport, isInMiniatureMode, newRigScale);
564
- doTeleport = args.doTeleport;
565
- isInMiniatureMode = args.isInMiniatureMode;
566
- newRigScale = args.newRigScale;
567
- }
568
- }
569
- else if (!btn.pressed)
570
- this.didChangeScale = false;
571
- }
572
- }
573
- }
574
-
575
- if (doTeleport) {
576
- if (!this.didTeleport) {
577
- const rc = this.raycast();
578
- this.didTeleport = true;
579
- if (rc && rc.length > 0) {
580
- const hit = rc[0];
581
- if (isInMiniatureMode || this.isValidTeleportTarget(hit.object)) {
582
- const point = hit.point;
583
- setWorldPosition(rig, point);
584
- }
585
- }
586
- }
587
- }
588
- else if (teleport < .1) {
589
- this.didTeleport = false;
590
- }
591
-
592
- if (newRigScale !== null) {
593
- rig.scale.set(newRigScale, newRigScale, newRigScale);
594
- rig.updateMatrixWorld();
595
- }
596
- }
597
-
598
-
599
- private isValidTeleportTarget(obj: Object3D): boolean {
600
- return GameObject.getComponentInParent(obj, TeleportTarget) != null;
601
- }
602
-
603
- private switchScale(rig: Object3D, doTeleport: boolean, isInMiniatureMode: boolean, newRigScale: number | null) {
604
- if (!isInMiniatureMode) {
605
- isInMiniatureMode = true;
606
- doTeleport = true;
607
- newRigScale = .1;
608
- WebXRController.MovementSpeedFactor = newRigScale * 2;
609
- const cam = this.context.mainCamera as PerspectiveCamera;
610
- WebXRController.PreviousCameraFarDistance = cam.far;
611
- cam.far /= newRigScale;
612
- }
613
- else {
614
- isInMiniatureMode = false;
615
- rig.scale.set(1, 1, 1);
616
- newRigScale = 1;
617
- WebXRController.MovementSpeedFactor = 1;
618
- const cam = this.context.mainCamera as PerspectiveCamera;
619
- if (WebXRController.PreviousCameraFarDistance)
620
- cam.far = WebXRController.PreviousCameraFarDistance;
621
- }
622
- return { doTeleport, isInMiniatureMode, newRigScale }
623
- }
624
-
625
- private updateStick(inputSource: XRInputSource) {
626
- if (!inputSource || !inputSource.gamepad || inputSource.gamepad.axes?.length < 4) return;
627
- this.joystick.x = inputSource.gamepad.axes[2];
628
- this.joystick.y = inputSource.gamepad.axes[3];
629
- }
630
-
631
- private updateLastHit(): Intersection | null {
632
- const rc = this.raycast();
633
- const hit = rc ? rc[0] : null;
634
- this.lastHit = hit;
635
- let factor = 1;
636
- if (this.webXR!.Rig) {
637
- factor /= this.webXR!.Rig.scale.x;
638
- }
639
- // if (!hit) factor = 0;
640
-
641
- if (this.raycastLine) {
642
- this.raycastLine.scale.z = factor * (this.lastHit?.distance ?? 9999);
643
- const mat = this.raycastLine.material as LineBasicMaterial;
644
- if (hit != null) mat.color = WebXRController.raycastColor;
645
- else mat.color = WebXRController.raycastNoHitColor;
646
- }
647
- if (this._raycastHitPoint) {
648
- if (this.lastHit != null) {
649
- this._raycastHitPoint.position.z = -1;// -this.lastHit.distance;
650
- const scale = Mathf.clamp(this.lastHit.distance * .01 * factor, .015, .1);
651
- this._raycastHitPoint.scale.set(scale, scale, scale);
652
- }
653
- this._raycastHitPoint.visible = this.lastHit !== null && this.lastHit !== undefined;
654
- }
655
- return hit;
656
- }
657
-
658
- private onSelectStart() {
659
- if (!this.context.connection.allowEditing) return;
660
- // console.log("SELECT START", _event);
661
- // if we process the event immediately the controller
662
- // world positions are not yet correctly updated and we have info from the last frame
663
- // so we delay the event processing one frame
664
- // only necessary for AR - ideally we can get it to work right here
665
- // but should be fine as a workaround for now
666
- this.selectStartCallback = () => this.onHandleSelectStart();
667
- }
668
-
669
- private selectStartCallback: Function | null = null;
670
- private lastSelectStartObject: Object3D | null = null;;
671
-
672
- private onHandleSelectStart() {
673
- this.selectStartCallback = null;
674
- this._selectionPressed = true;
675
- this._selectionStartTime = this.context.time.time;
676
- this._selectionEndTime = 1000;
677
- // console.log("DOWN", this.index, WebXRController.eventSubs);
678
-
679
- // let maxDistance = this.isUsingHands ? .1 : undefined;
680
- let intersections: Intersection[] | null = null;
681
- let closeGrab: boolean = false;
682
- if (this.isUsingHands) {
683
- intersections = this.overlap();
684
- if (intersections.length <= 0) {
685
- intersections = this.raycast();
686
- closeGrab = false;
687
- }
688
- else {
689
- closeGrab = true;
690
- }
691
- }
692
- else intersections = this.raycast();
693
-
694
- if (debug)
695
- console.log("onHandleSelectStart", "close grab? " + closeGrab, "intersections", intersections);
696
-
697
- if (intersections && intersections.length > 0) {
698
- for (const intersection of intersections) {
699
- const object = intersection.object;
700
- this.lastSelectStartObject = object;
701
- const args = { selected: object, grab: object };
702
- const subs = WebXRController.eventSubs[ControllerEvents.SelectStart];
703
- if (subs && subs.length > 0) {
704
- for (const sub of subs) {
705
- sub(this, args);
706
- }
707
- }
708
- if (args.grab !== object && debug)
709
- console.log("Grabbed object changed", "original", object, "new", args.grab);
710
- if (args.grab) {
711
- this.grabbed = AttachedObject.TryTake(this, args.grab, intersection, closeGrab);
712
- }
713
- break;
714
- }
715
- }
716
- else {
717
- const subs = WebXRController.eventSubs[ControllerEvents.SelectStart];
718
- const args = { selected: null, grab: null };
719
- if (subs && subs.length > 0) {
720
- for (const sub of subs) {
721
- sub(this, args);
722
- }
723
- }
724
- }
725
- }
726
-
727
- private _didNotEndSelection: boolean = false;
728
-
729
- private onSelectEnd() {
730
- if (this.isUsingHands) {
731
- if (this.handPointerModel.pinched) {
732
- this._didNotEndSelection = true;
733
- return;
734
- }
735
- }
736
-
737
- if (!this._selectionPressed) return;
738
- this.selectStartCallback = null;
739
- this._selectionPressed = false;
740
- this._selectionEndTime = this.context.time.time;
741
-
742
- const args = { grab: this.grabbed?.selected ?? this.lastSelectStartObject };
743
- const subs = WebXRController.eventSubs[ControllerEvents.SelectEnd];
744
- if (subs && subs.length > 0) {
745
- for (const sub of subs) {
746
- sub(this, args);
747
- }
748
- }
749
-
750
- if (this.grabbed) {
751
- this.grabbed.free();
752
- this.grabbed = null;
753
- }
754
- }
755
-
756
- private testIsVisible(obj: Object3D | null): boolean {
757
- if (!obj) return false;
758
- if (GameObject.isActiveInHierarchy(obj) === false) return false;
759
- if (UIRaycastUtils.isInteractable(obj) === false) {
760
- return false;
761
- }
762
- return true;
763
- // if (!obj.visible) return false;
764
- // return this.testIsVisible(obj.parent);
765
- }
766
-
767
- private setControllerLayers(obj: Object3D, layer: number) {
768
- if (!obj) return;
769
- obj.layers.set(layer);
770
- if (obj.children) {
771
- for (const ch of obj.children) {
772
- if (this.grabbed?.selected === ch || this.grabbed?.selectedMesh === ch) {
773
- continue;
774
- }
775
- this.setControllerLayers(ch, layer);
776
- }
777
- }
778
- }
779
-
780
- public getRay(): Ray {
781
- const ray = new Ray();
782
- // this.tempMatrix.identity().extractRotation(this.controller.matrixWorld);
783
- // ray.origin.setFromMatrixPosition(this.controller.matrixWorld);
784
- ray.origin.copy(getWorldPosition(this.controller));
785
- ray.direction.set(0, 0, -1).applyQuaternion(this.rayRotation);
786
- return ray;
787
- }
788
-
789
- private closeGrabBoundingBoxHelper?: BoxHelper;
790
-
791
- public overlap(): Intersection[] {
792
- const overlapCenter = (this.isUsingHands && this.handPointerModel) ? this.handPointerModel.pointerObject : this.controllerGrip;
793
-
794
- if (debug) {
795
- if (!this.closeGrabBoundingBoxHelper && overlapCenter) {
796
- this.closeGrabBoundingBoxHelper = new BoxHelper(overlapCenter, 0xffff00);
797
- this.scene.add(this.closeGrabBoundingBoxHelper);
798
- }
799
-
800
- if (this.closeGrabBoundingBoxHelper && overlapCenter) {
801
- this.closeGrabBoundingBoxHelper.setFromObject(overlapCenter);
802
- }
803
- }
804
-
805
- if (!overlapCenter)
806
- return new Array<Intersection>();
807
-
808
- const wp = getWorldPosition(overlapCenter).clone();
809
- return this.context.physics.sphereOverlap(wp, .02);
810
- }
811
-
812
- public raycast(): Intersection[] {
813
- const opts = new RaycastOptions();
814
- opts.layerMask = new Layers();
815
- opts.layerMask.enableAll();
816
- opts.layerMask.disable(2);
817
- opts.ray = this.getRay();
818
- const hits = this.context.physics.raycast(opts);
819
- for (let i = 0; i < hits.length; i++) {
820
- const hit = hits[i];
821
- const obj = hit.object;
822
- if (!this.testIsVisible(obj)) {
823
- hits.splice(i, 1);
824
- i--;
825
- continue;
826
- }
827
- hit.object = UIRaycastUtils.getObject(obj);
828
- break;
829
- }
830
- // console.log(...hits);
831
- return hits;
832
- }
833
- }
834
-
835
-
836
- export enum AttachedObjectEvents {
837
- WillTake = "WillTake",
838
- DidTake = "DidTake",
839
- WillFree = "WillFree",
840
- DidFree = "DidFree",
841
- }
842
-
843
- export class AttachedObject {
844
-
845
- public static Events: { [key: string]: Function[] } = {};
846
- public static AddEventListener(event: AttachedObjectEvents, callback: Function): Function {
847
- if (!AttachedObject.Events[event]) AttachedObject.Events[event] = [];
848
- AttachedObject.Events[event].push(callback);
849
- return callback;
850
- }
851
- public static RemoveEventListener(event: AttachedObjectEvents, callback: Function | null) {
852
- if (!callback) return;
853
- if (!AttachedObject.Events[event]) return;
854
- const idx = AttachedObject.Events[event].indexOf(callback);
855
- if (idx >= 0) AttachedObject.Events[event].splice(idx, 1);
856
- }
857
-
858
-
859
- public static Current: AttachedObject[] = [];
860
-
861
- private static Register(obj: AttachedObject) {
862
-
863
- if (!this.Current.find(x => x === obj)) {
864
- this.Current.push(obj);
865
- }
866
- }
867
-
868
- private static Remove(obj: AttachedObject) {
869
- const i = this.Current.indexOf(obj);
870
- if (i >= 0) {
871
- this.Current.splice(i, 1);
872
- }
873
- }
874
-
875
- public static TryTake(controller: WebXRController, candidate: Object3D, intersection: Intersection, closeGrab: boolean): AttachedObject | null {
876
- const interactable = GameObject.getComponentInParent(candidate, Interactable);
877
- if (!interactable) {
878
- if (debug)
879
- console.warn("Prevented taking object that is not interactable", candidate);
880
- return null;
881
- }
882
- else candidate = interactable.gameObject;
883
-
884
-
885
- let objectToAttach = candidate;
886
- const sync = GameObject.getComponentInParent(candidate, SyncedTransform);
887
- if (sync) {
888
- sync.requestOwnership();
889
- objectToAttach = sync.gameObject;
890
- }
891
-
892
- for (const o of this.Current) {
893
- if (o.selected === objectToAttach) {
894
- if (o.controller === controller) return o;
895
- o.free();
896
- o.Take(controller, objectToAttach, candidate, sync, interactable, intersection, closeGrab);
897
- return o;
898
- }
899
- }
900
-
901
- const att = new AttachedObject();
902
- att.Take(controller, objectToAttach, candidate, sync, interactable, intersection, closeGrab);
903
- return att;
904
- }
905
-
906
-
907
- public sync: SyncedTransform | null = null;
908
- public selected: Object3D | null = null;
909
- public selectedParent: Object3D | null = null;
910
- public selectedMesh: Mesh | null = null;
911
- public controller: WebXRController | null = null;
912
- public grabTime: number = 0;
913
- public grabUUID: string = "";
914
- public isCloseGrab: boolean = false; // when taken via sphere cast with hands
915
-
916
- private originalMaterial: Material | Material[] | null = null;
917
- private usageMarker: UsageMarker | null = null;
918
- private rigidbodies: Rigidbody[] | null = null;
919
- private didReparent: boolean = false;
920
- private grabDistance: number = 0;
921
- private interactable: Interactable | null = null;
922
- private positionSource: Object3D | null = null;
923
-
924
- private Take(controller: WebXRController, take: Object3D, hit: Object3D, sync: SyncedTransform | null, _interactable: Interactable,
925
- intersection: Intersection, closeGrab: boolean)
926
- : AttachedObject {
927
- console.assert(take !== null, "Expected object to be taken but was", take);
928
-
929
- if (controller.isUsingHands) {
930
- this.positionSource = closeGrab ? controller.wrist : controller.controller;
931
- }
932
- else {
933
- this.positionSource = controller.controller;
934
- }
935
- if (!this.positionSource) {
936
- console.warn("No position source");
937
- return this;
938
- }
939
-
940
- const args = { controller, take, hit, sync, interactable: _interactable };
941
- AttachedObject.Events.WillTake?.forEach(x => x(this, args));
942
-
943
-
944
- const mesh = hit as Mesh;
945
- if (mesh?.material) {
946
- this.originalMaterial = mesh.material;
947
- if (!Array.isArray(mesh.material)) {
948
- mesh.material = (mesh.material as Material).clone();
949
- if (mesh.material && mesh.material["emissive"])
950
- mesh.material["emissive"].b = .2;
951
- }
952
- }
953
-
954
- this.selected = take;
955
- if (!this.selectedParent) {
956
- this.selectedParent = take.parent;
957
- }
958
- this.selectedMesh = mesh;
959
- this.controller = controller;
960
- this.interactable = _interactable;
961
- this.isCloseGrab = closeGrab;
962
- // if (interactable.canGrab) {
963
- // this.didReparent = true;
964
- // this.device.controller.attach(take);
965
- // }
966
- // else
967
- this.didReparent = false;
968
-
969
-
970
- this.sync = sync;
971
- this.grabTime = controller.context.time.time;
972
- this.grabUUID = Date.now().toString();
973
- this.usageMarker = GameObject.addNewComponent(this.selected, UsageMarker);
974
- this.rigidbodies = GameObject.getComponentsInChildren(this.selected, Rigidbody);
975
- getWorldPosition(this.positionSource, this.lastControllerWorldPos);
976
- const getGrabPoint = () => closeGrab ? this.lastControllerWorldPos.clone() : intersection.point.clone();
977
- this.grabDistance = getGrabPoint().distanceTo(this.lastControllerWorldPos);
978
- this.totalChangeAlongDirection = 0.0;
979
-
980
- // we're storing position relative to the grab point
981
- // we're storing rotation relative to the ray
982
- this.localPositionOffsetToGrab = this.selected.worldToLocal(getGrabPoint());
983
- const rot = controller.isUsingHands && closeGrab ? this.controller.getWristQuaternion()!.clone() : controller.rayRotation.clone();
984
- getWorldQuaternion(this.selected, this.localQuaternionToGrab).premultiply(rot.invert());
985
-
986
- const rig = this.controller.webXR!.Rig;
987
- if (rig)
988
- this.rigPositionLastFrame.copy(getWorldPosition(rig))
989
-
990
- Avatar_POI.Add(controller.context, this.selected);
991
- AttachedObject.Register(this);
992
-
993
- if (this.sync) {
994
- this.sync.fastMode = true;
995
- }
996
-
997
- AttachedObject.Events.DidTake?.forEach(x => x(this, args));
998
-
999
- return this;
1000
- }
1001
-
1002
- public free(): void {
1003
- if (!this.selected) return;
1004
-
1005
- const args = { controller: this.controller, take: this.selected, hit: this.selected, sync: this.sync, interactable: null };
1006
- AttachedObject.Events.WillFree?.forEach(x => x(this, args));
1007
-
1008
- Avatar_POI.Remove(this.controller!.context, this.selected);
1009
- AttachedObject.Remove(this);
1010
-
1011
- if (this.sync) {
1012
- this.sync.fastMode = false;
1013
- }
1014
-
1015
- const mesh = this.selectedMesh;
1016
- if (mesh && this.originalMaterial && mesh.material) {
1017
- mesh.material = this.originalMaterial;
1018
- }
1019
-
1020
- const object = this.selected;
1021
- // only attach the object back if it has a parent
1022
- // no parent means it was destroyed while holding it!
1023
- if (this.didReparent && object.parent) {
1024
- const prevParent = this.selectedParent;
1025
- if (prevParent) prevParent.attach(object);
1026
- else this.controller?.context.scene.attach(object);
1027
- }
1028
-
1029
- this.usageMarker?.destroy();
1030
-
1031
- if (this.controller)
1032
- this.controller.grabbed = null;
1033
- this.selected = null;
1034
- this.selectedParent = null;
1035
- this.selectedMesh = null;
1036
- this.sync = null;
1037
-
1038
-
1039
- // TODO: make throwing work again
1040
- if (this.rigidbodies) {
1041
- for (const rb of this.rigidbodies) {
1042
- rb.wakeUp();
1043
- rb.setVelocity(rb.smoothedVelocity);
1044
- }
1045
- }
1046
- this.rigidbodies = null;
1047
-
1048
- this.localPositionOffsetToGrab = null;
1049
- this.quaternionLerp = null;
1050
-
1051
- AttachedObject.Events.DidFree?.forEach(x => x(this, args));
1052
- }
1053
-
1054
- public grabPoint: Vector3 = new Vector3();
1055
-
1056
- private localPositionOffsetToGrab: Vector3 | null = null;
1057
- private localPositionOffsetToGrab_worldSpace: Vector3 = new Vector3();
1058
- private localQuaternionToGrab: Quaternion = new Quaternion(0, 0, 0, 1);
1059
- private targetDir: Vector3 | null = null;
1060
- private quaternionLerp: Quaternion | null = null;
1061
-
1062
- private controllerDir = new Vector3();
1063
- private controllerWorldPos = new Vector3();
1064
- private lastControllerWorldPos = new Vector3();
1065
- private controllerPosDelta = new Vector3();
1066
- private totalChangeAlongDirection = 0.0;
1067
- private rigPositionLastFrame = new Vector3();
1068
-
1069
- private controllerMovementSinceLastFrame() {
1070
- if (!this.positionSource || !this.controller) return 0.0;
1071
-
1072
- // controller direction
1073
- this.controllerDir.set(0, 0, -1);
1074
- this.controllerDir.applyQuaternion(this.controller.rayRotation);
1075
-
1076
- // controller delta
1077
- getWorldPosition(this.positionSource, this.controllerWorldPos);
1078
- this.controllerPosDelta.copy(this.controllerWorldPos);
1079
- this.controllerPosDelta.sub(this.lastControllerWorldPos);
1080
- this.lastControllerWorldPos.copy(this.controllerWorldPos);
1081
- const rig = this.controller.webXR!.Rig;
1082
- if (rig) {
1083
- const rigPos = getWorldPosition(rig);
1084
- const rigDelta = this.rigPositionLastFrame.sub(rigPos);
1085
- this.controllerPosDelta.add(rigDelta);
1086
- this.rigPositionLastFrame.copy(rigPos);
1087
- }
1088
-
1089
- // calculate delta along direction
1090
- const changeAlongControllerDirection = this.controllerDir.dot(this.controllerPosDelta);
1091
-
1092
- return changeAlongControllerDirection;
1093
- }
1094
-
1095
- public update() {
1096
- if (this.rigidbodies)
1097
- for (const rb of this.rigidbodies)
1098
- rb.resetVelocities();
1099
- // TODO: add/use sync lost ownership event
1100
- if (this.sync && this.controller && this.controller.context.connection.isInRoom) {
1101
- const td = this.controller.context.time.time - this.grabTime;
1102
- // if (time.frameCount % 60 === 0) {
1103
- // console.log("ownership?", this.selected.name, this.sync.hasOwnership(), td)
1104
- // }
1105
- if (td > 3) {
1106
- // if (time.frameCount % 60 === 0) {
1107
- // console.log(this.sync.hasOwnership())
1108
- // }
1109
- if (this.sync.hasOwnership() === false) {
1110
- console.log("no ownership, will leave", this.sync.guid);
1111
- this.free();
1112
- }
1113
- }
1114
- }
1115
- if (this.interactable && !this.interactable.canGrab) return;
1116
-
1117
- if (!this.didReparent && this.selected && this.controller) {
1118
-
1119
- const rigScale = this.controller.webXR!.Rig?.scale.x ?? 1.0;
1120
-
1121
- this.totalChangeAlongDirection += this.controllerMovementSinceLastFrame();
1122
- // console.log(this.totalChangeAlongDirection);
1123
-
1124
- // alert("yo: " + this.controller.webXR.Rig?.scale.x); // this is 0.1 on Hololens
1125
- let currentDist = 1.0;
1126
- if (this.controller.type === ControllerType.PhysicalDevice) // only for controllers and not on touch (AR touches are controllers)
1127
- {
1128
- currentDist = Math.max(0.0, 1 + this.totalChangeAlongDirection * 2.0 / rigScale);
1129
- currentDist = currentDist * currentDist * currentDist;
1130
- }
1131
- if (this.grabDistance / rigScale < 0.8) currentDist = 1.0; // don't accelerate if this is a close grab, want full control
1132
-
1133
- if (!this.targetDir) {
1134
- this.targetDir = new Vector3();
1135
- }
1136
- this.targetDir.set(0, 0, -this.grabDistance * currentDist);
1137
- const target = this.targetDir.applyQuaternion(this.controller.rayRotation).add(this.controllerWorldPos);
1138
-
1139
- // apply rotation
1140
- const targetQuat = this.controller.rayRotation.clone().multiplyQuaternions(this.controller.rayRotation, this.localQuaternionToGrab);
1141
- if (!this.quaternionLerp) {
1142
- this.quaternionLerp = targetQuat.clone();
1143
- }
1144
- this.quaternionLerp.slerp(targetQuat, this.controller.useSmoothing ? this.controller.context.time.deltaTime / .03 : 1.0);
1145
- setWorldQuaternion(this.selected, this.quaternionLerp);
1146
- this.selected.updateWorldMatrix(false, false); // necessary so that rotation is correct for the following position update
1147
-
1148
- // apply position
1149
- this.grabPoint.copy(target);
1150
- // apply local grab offset
1151
- if (this.localPositionOffsetToGrab) {
1152
- this.localPositionOffsetToGrab_worldSpace.copy(this.localPositionOffsetToGrab);
1153
- this.selected.localToWorld(this.localPositionOffsetToGrab_worldSpace).sub(getWorldPosition(this.selected));
1154
- target.sub(this.localPositionOffsetToGrab_worldSpace);
1155
- }
1156
- setWorldPosition(this.selected, target);
1157
- }
1158
-
1159
-
1160
- if (this.rigidbodies != null) {
1161
- for (const rb of this.rigidbodies) {
1162
- rb.wakeUp();
1163
- }
1164
- }
1165
-
1166
- InstancingUtil.markDirty(this.selected, true);
1167
- }
1168
- }
src/engine-components/webxr/WebXRGrabRendering.ts DELETED
@@ -1,151 +0,0 @@
1
- import { getWorldPosition, setWorldPosition, setWorldPositionXYZ } from "../../engine/engine_three_utils.js";
2
- import { Behaviour, GameObject } from "../Component.js";
3
- import { AttachedObject, AttachedObjectEvents } from "./WebXRController.js";
4
- import { Object3D, Vector3 } from "three";
5
- import { PlayerColor } from "../PlayerColor.js";
6
- import { Context } from "../../engine/engine_setup.js";
7
- import { type IModel, SendQueue } from "../../engine/engine_networking_types.js";
8
-
9
- enum XRGrabEvent {
10
- StartOrUpdate = "xr-grab-visual-start-or-update",
11
- End = "xr-grab-visual-end",
12
- }
13
-
14
- export class XRGrabModel implements IModel {
15
- guid!: any;
16
- dontSave: boolean = true;
17
-
18
- userId : string | null | undefined;
19
- point: { x: number, y: number, z: number } = { x: 0, y: 0, z: 0 };
20
- source: { x: number, y: number, z: number } = { x: 0, y: 0, z: 0 };
21
- target: string | undefined;
22
-
23
- update(context : Context, point: Vector3, source: Vector3, target: string | undefined = undefined) {
24
- this.userId = context.connection.connectionId;
25
- this.point.x = point.x;
26
- this.point.y = point.y;
27
- this.point.z = point.z;
28
- this.source.x = source.x;
29
- this.source.y = source.y;
30
- this.source.z = source.z;
31
- this.target = target;
32
- }
33
- }
34
-
35
- // sends grab info to other users and creates rendering instances
36
- export class XRGrabRendering extends Behaviour {
37
- prefab: Object3D | null = null;
38
-
39
- private _grabModels: Array<XRGrabModel> = [];
40
- private _grabModelsUpdateTime: Array<number> = [];
41
- private _addOrUpdateSub: Function | null = null;
42
- private _endSub: Function | null = null;
43
- private _freeSub: Function | null = null;
44
- private _instances: { [key: string]: {instance:Object3D, model:XRGrabModel} } = {};
45
-
46
- awake(): void {
47
- if(this.prefab) this.prefab.visible = false;
48
- }
49
-
50
- onEnable(): void {
51
- this._addOrUpdateSub = this.context.connection.beginListen(XRGrabEvent.StartOrUpdate, this.onRemoteGrabStartOrUpdate.bind(this));
52
- this._endSub = this.context.connection.beginListen(XRGrabEvent.End, this.onRemoteGrabEnd.bind(this));
53
- this._freeSub = AttachedObject.AddEventListener(AttachedObjectEvents.WillFree, this.onAttachedObjectFree.bind(this));
54
- }
55
-
56
- onDisable(): void {
57
- this.context.connection.stopListen(XRGrabEvent.StartOrUpdate, this._addOrUpdateSub);
58
- this.context.connection.stopListen(XRGrabEvent.End, this._endSub);
59
- AttachedObject.RemoveEventListener(AttachedObjectEvents.WillFree, this._freeSub);
60
- }
61
-
62
- addOrUpdateGrab(model: XRGrabModel) {
63
- this.context.connection.send(XRGrabEvent.StartOrUpdate, model, SendQueue.Queued);
64
- }
65
-
66
- endGrab(model: XRGrabModel) {
67
- this.context.connection.send(XRGrabEvent.End, model, SendQueue.Queued);
68
- }
69
-
70
- private onRemoteGrabStartOrUpdate(data: XRGrabModel) {
71
- if(!this.prefab) return;
72
- const inst = this._instances[data.guid];
73
- if(!inst)
74
- {
75
- const instance = GameObject.instantiate(this.prefab) as Object3D;
76
- instance.visible = true;
77
- this._instances[data.guid] = {instance, model:data};
78
- if(data.userId){
79
- const playerColor = GameObject.getComponentsInChildren(instance, PlayerColor);
80
- if(playerColor?.length > 0)
81
- {
82
- for(const pl of playerColor){
83
- pl.assignUserColor(data.userId)
84
- }
85
- }
86
- }
87
- return;
88
- }
89
- inst.model = data;
90
- }
91
-
92
- private onRemoteGrabEnd(data: XRGrabModel) {
93
- if (!data) return;
94
- const id = data.guid;
95
- if(this._instances[id])
96
- {
97
- GameObject.destroy(this._instances[id].instance);
98
- delete this._instances[id];
99
- }
100
- }
101
-
102
- private onAttachedObjectFree(att: AttachedObject) {
103
- if (this._grabModels.length <= 0) return;
104
- const mod = this._grabModels[0];
105
- this.updateModel(mod, att);
106
- this.endGrab(mod);
107
- }
108
-
109
- onBeforeRender() {
110
- this.updateRendering();
111
-
112
- if (!this.prefab) return;
113
- this.prefab.visible = false;
114
- if (this.context.time.frameCount % 10 !== 0) return;
115
- for (let i = 0; i < AttachedObject.Current.length; i++) {
116
- const att = AttachedObject.Current[i];
117
-
118
- if (!att.controller || !att.selected) continue;
119
-
120
- if (this._grabModels.length <= i) {
121
- this._grabModels.push(new XRGrabModel());
122
- this._grabModelsUpdateTime.push(0);
123
- }
124
- this._grabModelsUpdateTime[i] = this.context.time.time;
125
- const model = this._grabModels[i];
126
- this.updateModel(model, att);
127
- this.addOrUpdateGrab(model);
128
- }
129
- }
130
-
131
- private updateModel(model: XRGrabModel, att: AttachedObject) {
132
- if (!att.controller || !att.selected) return;
133
- model.guid = att.grabUUID;
134
- const targetObject = att.selected["guid"];
135
- model.update(this.context, att.grabPoint, att.controller.worldPosition, targetObject);
136
- }
137
-
138
- private temp : Vector3 = new Vector3();
139
- private updateRendering() {
140
- const step = this.context.time.deltaTime / .5;
141
- for(const key in this._instances){
142
- const { instance, model } = this._instances[key];
143
- if(!instance || !model) continue;
144
- const { point } = model;
145
- const wp = getWorldPosition(instance);
146
- this.temp.set(point.x, point.y, point.z);
147
- wp.lerp(this.temp, step);
148
- setWorldPosition(instance, wp);
149
- }
150
- }
151
- }
src/engine-components/webxr/WebXRImageTracking.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { WebXR, WebXREvent } from "./WebXR.js";
2
1
  import { serializable } from "../../engine/engine_serialization.js";
3
2
  import { Behaviour, GameObject } from "../Component.js";
4
3
  import { Object3D, Quaternion, Vector3 } from "three";
@@ -8,6 +7,8 @@
8
7
 
9
8
  import { USDZExporter } from "../../engine-components/export/usdz/USDZExporter.js";
10
9
  import { USDZExporterContext, USDWriter, imageToCanvas } from "../../engine-components/export/usdz/ThreeUSDZExporter.js";
10
+ import { NeedleXREventArgs, NeedleXRSession } from "../../engine/xr/index.js";
11
+ import { InstancingUtil, Renderer } from "../Renderer.js";
11
12
 
12
13
  // https://github.com/immersive-web/marker-tracking/blob/main/explainer.md
13
14
 
@@ -44,11 +45,13 @@
44
45
  if (t01 === undefined || t01 >= 1 || haveChanged) {
45
46
  object.position.copy(this._position);
46
47
  object.quaternion.copy(this._rotation);
48
+ // InstancingUtil.markDirty(object);
47
49
  }
48
50
  else {
49
51
  t01 = Math.max(0, Math.min(1, t01));
50
52
  object.position.lerp(this._position, t01);
51
53
  object.quaternion.slerp(this._rotation, t01);
54
+ // InstancingUtil.markDirty(object);
52
55
  }
53
56
  object.quaternion.multiply(WebXRTrackedImage.y180);
54
57
  }
@@ -61,15 +64,10 @@
61
64
  if (!this._position) {
62
65
  this._position = WebXRTrackedImage._positionBuffer.get();
63
66
  this._rotation = WebXRTrackedImage._rotationBuffer.get();
64
- const t = this._pose.transform;
65
-
66
- // when parented to the world, we need to flip data here
67
- //this._position.set(-t.position.x, t.position.y, -t.position.z);
68
- // this._rotation.set(-t.orientation.x, t.orientation.y, -t.orientation.z, t.orientation.w);
69
-
70
- // for some reason when parented to the XRRig, we need the original data
71
- this._position.set(t.position.x, t.position.y, t.position.z);
72
- this._rotation.set(t.orientation.x, t.orientation.y, t.orientation.z, t.orientation.w);
67
+ const t = this._pose.transform as XRRigidTransform;
68
+ const converted = NeedleXRSession.active!.convertSpace(t);
69
+ this._position.copy(converted?.position);
70
+ this._rotation.copy(converted?.quaternion);
73
71
  }
74
72
  }
75
73
 
@@ -141,9 +139,7 @@
141
139
  trackedImages?: WebXRImageTrackingModel[];
142
140
 
143
141
  private readonly trackedImageIndexMap: Map<number, WebXRImageTrackingModel> = new Map();
144
-
145
142
  private static _imageElements: Map<string, ImageBitmap | null> = new Map();
146
- private webxr: WebXR | null = null;
147
143
 
148
144
  awake(): void {
149
145
  if (debug) console.log(this)
@@ -182,51 +178,35 @@
182
178
  }
183
179
  }
184
180
 
181
+ onBeforeXR(_mode: XRSessionMode, args: XRSessionInit & { trackedImages: Array<any> }): void {
182
+ // console.log("onXRRequested", args, this.trackedImages)
183
+ if (this.trackedImages) {
184
+ args.optionalFeatures = args.optionalFeatures || [];
185
+ if (!args.optionalFeatures.includes("image-tracking"))
186
+ args.optionalFeatures.push("image-tracking");
185
187
 
186
- onEnable(): void {
187
- this.webxr = GameObject.findObjectOfType(WebXR);
188
- WebXR.addEventListener(WebXREvent.ModifyAROptions, this.onModifyAROptions);
189
- WebXR.addEventListener(WebXREvent.XRStarted, this.onXRStarted);
190
- WebXR.addEventListener(WebXREvent.XRUpdate, this.onXRUpdate);
191
- this.addEventListener("image-tracking", this.onImageTrackingUpdate);
192
- }
193
-
194
- onDisable(): void {
195
- WebXR.removeEventListener(WebXREvent.ModifyAROptions, this.onModifyAROptions);
196
- WebXR.removeEventListener(WebXREvent.XRStarted, this.onXRStarted);
197
- WebXR.removeEventListener(WebXREvent.XRUpdate, this.onXRUpdate);
198
- this.removeEventListener("image-tracking", this.onImageTrackingUpdate);
199
- }
200
-
201
- private onModifyAROptions = (event: any) => {
202
- if (!this.trackedImages) return;
203
- const options = event.detail;
204
- const features = options.optionalFeatures || [];
205
- if (!features.includes("image-tracking"))
206
- features.push("image-tracking");
207
- options.optionalFeatures = features;
208
-
209
- options.trackedImages = [];
210
- for (const trackedImage of this.trackedImages) {
211
- if (trackedImage.image?.length && trackedImage.widthInMeters > 0) {
212
- const bitmap = WebXRImageTracking._imageElements.get(trackedImage.image);
213
- if (bitmap) {
214
- this.trackedImageIndexMap.set(options.trackedImages.length, trackedImage);
215
- options.trackedImages.push({
216
- image: bitmap,
217
- widthInMeters: trackedImage.widthInMeters
218
- });
188
+ args.trackedImages = [];
189
+ for (const trackedImage of this.trackedImages) {
190
+ if (trackedImage.image?.length && trackedImage.widthInMeters > 0) {
191
+ const bitmap = WebXRImageTracking._imageElements.get(trackedImage.image);
192
+ if (bitmap) {
193
+ this.trackedImageIndexMap.set(args.trackedImages.length, trackedImage);
194
+ args.trackedImages.push({
195
+ image: bitmap,
196
+ widthInMeters: trackedImage.widthInMeters
197
+ });
198
+ }
219
199
  }
220
200
  }
221
201
  }
222
202
  }
223
203
 
224
- private onXRStarted = (_: any) => {
204
+ onEnterXR(_args: NeedleXREventArgs): void {
225
205
  if (this.trackedImages) {
226
206
  for (const trackedImage of this.trackedImages) {
227
207
  if (trackedImage.object?.asset) {
228
208
  const obj = trackedImage.object.asset;
229
- obj.visible = false;
209
+ // obj.visible = false;
230
210
  }
231
211
  }
232
212
  }
@@ -236,17 +216,16 @@
236
216
  }
237
217
  };
238
218
 
239
- private readonly imageToObjectMap = new Map<WebXRImageTrackingModel, { object: GameObject | null, frames: number, lastTrackingTime:number }>();
219
+ private readonly imageToObjectMap = new Map<WebXRImageTrackingModel, { object: GameObject | null, frames: number, lastTrackingTime: number }>();
240
220
  private readonly currentImages: WebXRTrackedImage[] = [];
241
221
 
242
-
243
- private onXRUpdate = (evt): void => {
222
+ onUpdateXR(args: NeedleXREventArgs): void {
244
223
  this.currentImages.length = 0;
245
224
 
246
- const frame = evt.frame;
225
+ const frame = args.xr.frame;
247
226
  if (!frame) return;
248
227
 
249
- if (frame.session && !("getImageTrackingResults" in frame)) {
228
+ if (!("getImageTrackingResults" in frame)) {
250
229
  const warning = "Image tracking is currently not supported on this device. On Chrome for Android, you can enable the <a target=\"_blank\" href=\"#\" onclick=\"() => console.log('I')\">chrome://flags/#webxr-incubations</a> flag.";
251
230
  if (!this["didPrintWarning"]) {
252
231
  this["didPrintWarning"] = true;
@@ -255,8 +234,7 @@
255
234
  showBalloonWarning(warning);
256
235
  return;
257
236
  }
258
-
259
- if (frame.session && typeof frame.getImageTrackingResults === "function") {
237
+ else if (frame.session && typeof frame.getImageTrackingResults === "function") {
260
238
  const results = frame.getImageTrackingResults();
261
239
  if (results.length > 0) {
262
240
  const space = this.context.renderer.xr.getReferenceSpace();
@@ -279,9 +257,7 @@
279
257
  if (this.currentImages.length > 0) {
280
258
  try {
281
259
  this.dispatchEvent(new CustomEvent("image-tracking", { detail: this.currentImages }));
282
- if (this.webxr && this.webxr.allowARPlacementReticle) {
283
- this.webxr.allowARPlacementReticle = false;
284
- }
260
+ this.onImageTrackingUpdate(this.currentImages);
285
261
  }
286
262
  catch (e) {
287
263
  console.error(e);
@@ -314,9 +290,11 @@
314
290
  }
315
291
 
316
292
 
317
- private onImageTrackingUpdate = (event: any) => {
318
- const images = event.detail as WebXRTrackedImage[];
293
+ private onImageTrackingUpdate = (images: WebXRTrackedImage[]) => {
294
+ const xr = NeedleXRSession.active;
295
+ if (!xr) return;
319
296
 
297
+
320
298
  for (const image of images) {
321
299
  const model = image.model;
322
300
  const isTracked = image.state === "tracked";
@@ -336,20 +314,31 @@
336
314
  if (asset) {
337
315
  trackedData!.object = asset;
338
316
 
317
+ // workaround for instancing currently not properly updating
318
+ // instanced objects become visible when the image is recognized for the second time
319
+ // we need to look into this further https://linear.app/needle/issue/NE-3936
320
+ for (const rend of asset.getComponentsInChildren(Renderer)) {
321
+ rend.setInstancingEnabled(false);
322
+ }
323
+
339
324
  // make sure to parent to the WebXR.rig
340
- if (this.webxr) {
341
- this.webxr.Rig.add(asset);
325
+ if (xr.rig) {
326
+ xr.rig.gameObject.add(asset);
327
+ image.applyToObject(asset);
328
+ if (!asset.activeSelf)
329
+ GameObject.setActive(asset, true);
330
+ // InstancingUtil.markDirty(asset);
342
331
  }
332
+ else {
333
+ console.warn("XRImageTracking: missing XRRig");
334
+ }
343
335
 
344
- image.applyToObject(asset);
345
- if (!asset.activeSelf)
346
- GameObject.setActive(asset, true);
347
336
  }
348
337
  });
349
338
  }
350
339
  else {
351
340
  trackedData.frames++;
352
- if(isTracked)
341
+ if (isTracked)
353
342
  trackedData.lastTrackingTime = Date.now();
354
343
 
355
344
  // TODO we could do a bit more here: e.g. sample for the first 1s or so of getting pose data
@@ -359,13 +348,16 @@
359
348
 
360
349
  if (!trackedData.object) continue;
361
350
 
362
- if (this.webxr) {
363
- this.webxr.Rig.add(trackedData.object);
351
+ if (xr.rig) {
352
+
353
+ xr.rig.gameObject.add(trackedData.object);
354
+
355
+ image.applyToObject(trackedData.object);
356
+ if (!trackedData.object.activeSelf) {
357
+ GameObject.setActive(trackedData.object, true);
358
+ }
359
+ // InstancingUtil.markDirty(trackedData.object);
364
360
  }
365
-
366
- image.applyToObject(trackedData.object);
367
- if (!trackedData.object.activeSelf)
368
- GameObject.setActive(trackedData.object, true);
369
361
  }
370
362
  }
371
363
  }
src/engine-components/webxr/WebXRPlaneTracking.ts CHANGED
@@ -1,13 +1,14 @@
1
- import { Box3, BufferAttribute, BufferGeometry, Group, Material, Mesh, Object3D, Vector3 } from "three";
1
+ import { Box3, BufferAttribute, BufferGeometry, Group, Material, Matrix4, Mesh, MeshBasicMaterial, MeshNormalMaterial, Object3D, Vector3 } from "three";
2
2
 
3
3
  import { MeshCollider } from "../Collider.js";
4
4
  import { Behaviour, GameObject } from "../Component.js";
5
- import { WebXR, WebXREvent } from "./WebXR.js";
6
5
  import { serializable } from "../../engine/engine_serialization.js";
7
6
  import type { Vec3 } from "../../engine/engine_types.js";
8
7
  import { disposeObjectResources } from "../../engine/engine_assetdatabase.js";
9
8
  import { getParam } from "../../engine/engine_utils.js";
10
9
  import { destroy } from "../../engine/engine_gameobject.js";
10
+ import { NeedleXREventArgs, NeedleXRSession } from "../../engine/engine_xr.js";
11
+ import { AssetReference } from "../../engine/engine_addressables.js";
11
12
  // import { SimplifyModifier } from 'three/examples/jsm/modifiers/SimplifyModifier.js';
12
13
 
13
14
  const debug = getParam("debugplanetracking");
@@ -41,8 +42,8 @@
41
42
  export class WebXRPlaneTracking extends Behaviour {
42
43
 
43
44
  /** Optional: if assigned it will be instantiated per tracked plane/tracked mesh */
44
- @serializable(Object3D)
45
- dataTemplate?: Object3D;
45
+ @serializable(AssetReference)
46
+ dataTemplate?: AssetReference;
46
47
 
47
48
  @serializable()
48
49
  initiateRoomCaptureIfNoData = true;
@@ -53,34 +54,25 @@
53
54
  @serializable()
54
55
  useMeshData: boolean = true;
55
56
 
57
+ /** when enabled mesh or plane tracking will also be used in VR */
58
+ @serializable()
59
+ runInVR = true;
60
+
56
61
  get trackedPlanes() { return this._allPlanes.values(); }
57
62
  get trackedMeshes() { return this._allMeshes.values(); }
58
63
 
59
- onEnable(): void {
60
- WebXR.addEventListener(WebXREvent.XRStarted, this.onXRStarted);
61
- WebXR.addEventListener(WebXREvent.XRUpdate, this.onXRUpdate);
62
- WebXR.addEventListener(WebXREvent.ModifyAROptions, this.onModifyAROptions);
63
- }
64
64
 
65
- onDisable(): void {
66
- WebXR.removeEventListener(WebXREvent.XRStarted, this.onXRStarted);
67
- WebXR.removeEventListener(WebXREvent.XRUpdate, this.onXRUpdate);
68
- WebXR.removeEventListener(WebXREvent.ModifyAROptions, this.onModifyAROptions);
69
- }
70
65
 
71
- private onModifyAROptions = (event: any) => {
72
- const options = event.detail;
73
- const features = options.optionalFeatures || [];
74
-
75
- if (this.usePlaneData && !features.includes("plane-detection"))
76
- features.push("plane-detection");
77
- if (this.useMeshData && !features.includes("mesh-detection"))
78
- features.push("mesh-detection");
79
-
80
- options.optionalFeatures = features;
66
+ onBeforeXR(_mode: XRSessionMode, args: XRSessionInit): void {
67
+ if (_mode === "immersive-vr" && !this.runInVR) return;
68
+ args.optionalFeatures = args.optionalFeatures || [];
69
+ if (this.usePlaneData && !args.optionalFeatures.includes("plane-detection"))
70
+ args.optionalFeatures.push("plane-detection");
71
+ if (this.useMeshData && !args.optionalFeatures.includes("mesh-detection"))
72
+ args.optionalFeatures.push("mesh-detection");
81
73
  }
82
74
 
83
- private onXRStarted = (_evt) => {
75
+ onEnterXR(_evt) {
84
76
  // remove all previously added data from the scene again
85
77
  for (const data of this._allPlanes.keys()) {
86
78
  this.removeData(data, this._allPlanes);
@@ -90,18 +82,24 @@
90
82
  }
91
83
  }
92
84
 
93
- private onXRUpdate = (evt) => {
94
-
85
+ onUpdateXR(args: NeedleXREventArgs): void {
86
+
87
+ if (!this.runInVR && args.xr.isVR) return;
88
+
95
89
  // parenting tracked planes to the XR rig ensures that they synced with the real-world user data;
96
90
  // otherwise they would "swim away" when the user rotates / moves / teleports and so on.
97
91
  // There may be cases where we want that! E.g. a user walks around on their own table in castle builder
98
- if (!evt.rig) return;
92
+ const rig = args.xr.rig;
93
+ if (!rig) {
94
+ console.warn("No XR rig found, cannot parent tracked planes to it");
95
+ return;
96
+ }
99
97
 
100
- const frame = evt.frame as XRFramePlanes;
98
+ const frame = args.xr.frame as XRFramePlanes;
101
99
  const renderer = this.context.renderer;
102
100
  const referenceSpace = renderer.xr.getReferenceSpace();
103
101
  if (!referenceSpace) return;
104
-
102
+
105
103
  const planes = frame.detectedPlanes;
106
104
  const meshes = frame.detectedMeshes;
107
105
  const hasAnyPlanes = planes !== undefined && planes.size > 0;
@@ -126,10 +124,10 @@
126
124
  }
127
125
 
128
126
  if (planes !== undefined)
129
- this.processFrameData(evt.rig, evt.frame, planes, this._allPlanes);
127
+ this.processFrameData(args.xr, rig.gameObject, frame, planes, this._allPlanes);
130
128
 
131
129
  if (meshes !== undefined)
132
- this.processFrameData(evt.rig, evt.frame, meshes, this._allMeshes);
130
+ this.processFrameData(args.xr, rig.gameObject, frame, meshes, this._allMeshes);
133
131
  }
134
132
 
135
133
  private removeData(data: XRPlane | XRMesh, _all: Map<XRPlane | XRMesh, XRPlaneContext>) {
@@ -156,11 +154,11 @@
156
154
  private readonly _allMeshes = new Map<XRMesh, XRPlaneContext>();
157
155
  private firstTimeNoPlanesDetected = -100;
158
156
 
159
- private processFrameData(rig: Object3D, frame: XRFramePlanes, detected: Set<XRPlane | XRMesh>, _all: Map<XRPlane | XRMesh, XRPlaneContext>) {
157
+ private processFrameData(_xr: NeedleXRSession, rig: Object3D, frame: XRFramePlanes, detected: Set<XRPlane | XRMesh>, _all: Map<XRPlane | XRMesh, XRPlaneContext>) {
160
158
  const renderer = this.context.renderer;
161
159
  const referenceSpace = renderer.xr.getReferenceSpace();
162
160
  if (!referenceSpace) return;
163
-
161
+
164
162
  for (const data of _all.keys()) {
165
163
  if (!detected.has(data)) {
166
164
  this.removeData(data, _all);
@@ -170,7 +168,7 @@
170
168
  for (const data of detected) {
171
169
  const space = "planeSpace" in data ? data.planeSpace
172
170
  : ("meshSpace" in data ? data.meshSpace
173
- : undefined);
171
+ : undefined);
174
172
  if (!space) continue;
175
173
  const planePose = frame.getPose(space, referenceSpace);
176
174
 
@@ -243,12 +241,18 @@
243
241
 
244
242
  // if we don't have any template assigned we just use a simple mesh object
245
243
  if (!this.dataTemplate) {
246
- this.dataTemplate = new Mesh();
244
+ const mesh = new Mesh();
245
+ if (debug) mesh.material = new MeshNormalMaterial();
246
+ else mesh.material = new MeshBasicMaterial({ wireframe: true, opacity: .33, transparent: true, color: 0x000000 });
247
+ this.dataTemplate = new AssetReference("", "", mesh);
247
248
  }
248
249
 
249
- if (this.dataTemplate) {
250
+ if (!this.dataTemplate.asset) {
251
+ this.dataTemplate.loadAssetAsync();
252
+ }
253
+ else {
250
254
  // Create instance
251
- const newPlane = GameObject.instantiate(this.dataTemplate) as GameObject;
255
+ const newPlane = GameObject.instantiate(this.dataTemplate.asset) as GameObject;
252
256
  planeMesh = newPlane;
253
257
 
254
258
  if (newPlane instanceof Mesh) {
@@ -265,7 +269,7 @@
265
269
  }
266
270
  }
267
271
  }
268
-
272
+
269
273
  const mc = newPlane.getComponent(MeshCollider) as MeshCollider;
270
274
  if (mc) {
271
275
  const mesh = newPlane as unknown as Mesh;
@@ -312,6 +316,7 @@
312
316
  if (planePose) {
313
317
  planeMesh.visible = true;
314
318
  planeMesh.matrix.fromArray(planePose.transform.matrix);
319
+ planeMesh.matrix.premultiply(this._flipForwardMatrix);
315
320
  } else {
316
321
  planeMesh.visible = false;
317
322
  }
@@ -319,9 +324,11 @@
319
324
  };
320
325
  }
321
326
 
327
+ private _flipForwardMatrix = new Matrix4().makeRotationY(Math.PI);
328
+
322
329
  // heuristic to determine if a collider should be convex or not -
323
330
  // the "global mesh" should be non-convex, other meshes should be
324
- checkIfContextShouldBeConvex(mesh: Mesh | Group | undefined, xrData: XRPlane | XRMesh) {
331
+ private checkIfContextShouldBeConvex(mesh: Mesh | Group | undefined, xrData: XRPlane | XRMesh) {
325
332
  if (!mesh) return true;
326
333
  if (mesh) {
327
334
  // get bounding box of the mesh
@@ -346,7 +353,7 @@
346
353
  return true;
347
354
  }
348
355
 
349
- createGeometry(data: XRPlane | XRMesh) {
356
+ private createGeometry(data: XRPlane | XRMesh) {
350
357
  if ("polygon" in data) {
351
358
  return this.createPlaneGeometry(data.polygon);
352
359
  }
@@ -359,7 +366,7 @@
359
366
  // we cache vertices-to-geometry, because it looks like when we get an update sometimes the geometry stays the same.
360
367
  // so we don't want to re-create the geometry every time.
361
368
  private _verticesCache = new Map<string, BufferGeometry>();
362
- createMeshGeometry(vertices: Float32Array, indices: Uint32Array) {
369
+ private createMeshGeometry(vertices: Float32Array, indices: Uint32Array) {
363
370
  const key = vertices.toString() + "_" + indices.toString();
364
371
  if (this._verticesCache.has(key)) {
365
372
  return this._verticesCache.get(key)!;
@@ -369,7 +376,7 @@
369
376
  geometry.setAttribute('position', new BufferAttribute(vertices, 3));
370
377
  // set UVs in worldspace
371
378
  const uvs = Array<number>();
372
- for (let i = 0; i < vertices.length; i+=3) {
379
+ for (let i = 0; i < vertices.length; i += 3) {
373
380
  uvs.push(vertices[i], vertices[i + 2]);
374
381
  }
375
382
  geometry.setAttribute('uv', new BufferAttribute(new Float32Array(uvs), 2));
@@ -387,9 +394,9 @@
387
394
 
388
395
  this._verticesCache.set(key, geometry);
389
396
  return geometry;
390
- }
397
+ }
391
398
 
392
- createPlaneGeometry(polygon: Vec3[]) {
399
+ private createPlaneGeometry(polygon: Vec3[]) {
393
400
  const geometry = new BufferGeometry();
394
401
 
395
402
  const vertices: number[] = [];
src/engine-components/webxr/WebXRRig.ts CHANGED
@@ -1,22 +1,57 @@
1
- import { Object3D } from "three";
1
+ import { AxesHelper, Euler, Object3D, Quaternion, Vector3 } from "three";
2
2
  import type { IGameObject } from "../../engine/engine_types.js";
3
3
  import { getParam } from "../../engine/engine_utils.js";
4
4
  import { Behaviour } from "../Component.js";
5
5
  import { BoxGizmo } from "../Gizmos.js";
6
+ import { NeedleXREventArgs, NeedleXRSession } from "../../engine/engine_xr.js";
7
+ import { IXRRig } from "../../engine/engine_xr.js";
8
+ import { serializable } from "../../engine/engine_serialization_decorator.js";
6
9
 
7
- const debug = getParam("debugrig");
10
+ const debug = getParam("debugwebxr");
8
11
 
9
- export class XRRig extends Behaviour {
12
+ export class XRRig extends Behaviour implements IXRRig {
13
+
14
+ @serializable()
15
+ priority: number = 0;
16
+
17
+ get isActive() { return this.activeAndEnabled && this.gameObject.visible; }
18
+
19
+ /** Sets this rig to be the active XR rig (needs to be called during an active XR session) */
20
+ setAsActiveXRRig() {
21
+ NeedleXRSession.active?.setRigActive(this);
22
+ }
23
+
10
24
  awake(): void {
11
- // const helper = new AxesHelper(.1);
12
- // this.gameObject.add(helper);
13
25
  if (debug) {
14
26
  const gizmoObj = new Object3D() as IGameObject;
15
27
  gizmoObj.position.y += .5;
16
28
  this.gameObject.add(gizmoObj);
17
- const gizmo = gizmoObj.addNewComponent(BoxGizmo);
18
- if (gizmo)
19
- gizmo.isGizmo = false;
29
+ const box = gizmoObj.addNewComponent(BoxGizmo);
30
+ if (box)
31
+ box.isGizmo = false;
32
+ const axes = new AxesHelper(.5);
33
+ this.gameObject.add(axes)
20
34
  }
21
35
  }
36
+
37
+ isXRRig(): boolean {
38
+ return true;
39
+ }
40
+
41
+ supportsXR(_mode: XRSessionMode): boolean {
42
+ return true;
43
+ }
44
+
45
+ private _startScale?: Vector3;
46
+
47
+ onEnterXR(args: NeedleXREventArgs): void {
48
+ this._startScale = this.gameObject.scale.clone();
49
+ args.xr.addRig(this);
50
+ }
51
+ onLeaveXR(args: NeedleXREventArgs): void {
52
+ args.xr.removeRig(this);
53
+ if (this._startScale && this.gameObject)
54
+ this.gameObject.scale.copy(this._startScale);
55
+ }
56
+
22
57
  }
src/engine-components/webxr/WebXRSync.ts DELETED
@@ -1,463 +0,0 @@
1
- import { Behaviour, GameObject } from "../Component.js";
2
- import { RoomEvents, OwnershipModel, NetworkConnection } from "../../engine/engine_networking.js";
3
- import { WebXR, WebXREvent } from "./WebXR.js";
4
- import { Group, Quaternion, Vector3, Vector4, WebXRManager } from "three";
5
- import { getParam } from "../../engine/engine_utils.js";
6
- import { Voip } from "../Voip.js";
7
- import { Builder, Long } from "flatbuffers";
8
- import { VrUserStateBuffer } from "../../engine-schemes/vr-user-state-buffer.js";
9
- import { Vec3 } from "../../engine-schemes/vec3.js";
10
- import { registerBinaryType } from "../../engine-schemes/schemes.js";
11
- import { Vec4 } from "../../engine-schemes/vec4.js";
12
- import { WebXRAvatar } from "./WebXRAvatar.js";
13
-
14
- // for debug GUI
15
- // import { GUI } from "three/examples/jsm/libs/lil-gui.module.min.js";
16
- // import { HTMLMesh } from 'three/examples/jsm/interactive/HTMLMesh.js';
17
- // import { InteractiveGroup } from 'three/examples/jsm/interactive/InteractiveGroup.js';
18
- // import { renderer, sceneData } from "../engine/engine_setup.js";
19
-
20
- const debugLogs = getParam("debugxr");
21
- const debugAvatar = getParam("debugavatar");
22
- // const debugAvatarVoip = getParam("debugavatarvoip");
23
-
24
- enum WebXRSyncEvent {
25
- WebXR_UserJoined = "webxr-user-joined",
26
- WebXR_UserLeft = "webxr-user-left",
27
- VRSessionStart = "vr-session-started",
28
- VRSessionEnd = "vr-session-ended",
29
- VRSessionUpdate = "vr-session-update",
30
- }
31
-
32
- enum XRMode {
33
- VR = "vr",
34
- AR = "ar",
35
- }
36
-
37
- const VRUserStateBufferIdentifier = "VRUS";
38
- registerBinaryType(VRUserStateBufferIdentifier, VrUserStateBuffer.getRootAsVrUserStateBuffer);
39
-
40
- function getTimeStampNow() {
41
- return new Date().getTime(); // avoid sending millis in flatbuffer
42
- }
43
-
44
- function flatbuffers_long_from_number(num: number): Long {
45
- const low = num & 0xffffffff
46
- const high = (num / Math.pow(2, 32)) & 0xfffff
47
- return Long.create(low, high);
48
- }
49
-
50
- export class VRUserState {
51
- public guid: string;
52
- public time!: number;
53
- public avatarId!: string;
54
- public position: Vector3 = new Vector3();
55
- public rotation: Vector4 = new Vector4();
56
- public scale: number = 1;
57
-
58
- public posLeftHand = new Vector3();
59
- public posRightHand = new Vector3();
60
-
61
- public rotLeftHand = new Quaternion();
62
- public rotRightHand = new Quaternion();
63
-
64
- public constructor(guid: string) {
65
- this.guid = guid;
66
- }
67
-
68
- private static invertRotation: Quaternion = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI);
69
-
70
- public update(rig: Group, pos: DOMPointReadOnly, rot: DOMPointReadOnly, webXR: WebXR, avatarId: string) {
71
- this.time = getTimeStampNow();
72
- this.avatarId = avatarId;
73
- this.position.set(pos.x, pos.y, pos.z);
74
- if (rig)
75
- this.position.applyMatrix4(rig.matrixWorld);
76
-
77
- let q0 = VRUserState.quat0;
78
- const q1 = VRUserState.quat1;
79
- q0.set(rot.x, rot.y, rot.z, rot.w);
80
- q0 = q0.multiplyQuaternions(q0, VRUserState.invertRotation);
81
-
82
- if (rig) {
83
- rig.getWorldQuaternion(q1);
84
- q0.multiplyQuaternions(q1, q0);
85
- }
86
-
87
- this.rotation.set(q0.x, q0.y, q0.z, q0.w);
88
- this.scale = rig.scale.x;
89
-
90
- // for controllers, it seems we need grip pose
91
- const ctrl0 = webXR.LeftController?.controllerGrip;
92
- if (ctrl0) {
93
- ctrl0.getWorldPosition(this.posLeftHand);
94
- ctrl0.getWorldQuaternion(this.rotLeftHand);
95
- }
96
- const ctrl1 = webXR.RightController?.controllerGrip;
97
- if (ctrl1) {
98
- ctrl1.getWorldPosition(this.posRightHand);
99
- ctrl1.getWorldQuaternion(this.rotRightHand);
100
- }
101
-
102
- // if this is a hand, we need to get the root bone of that / use that for position/rotation
103
- if (webXR.LeftController?.hand?.visible) {
104
- const wrist = webXR.LeftController.wrist;
105
- if (wrist) {
106
- wrist.getWorldPosition(this.posLeftHand);
107
- wrist.getWorldQuaternion(this.rotLeftHand);
108
- }
109
- }
110
-
111
- if (webXR.RightController?.hand?.visible) {
112
- const wrist = webXR.RightController.wrist;
113
- if (wrist) {
114
- wrist.getWorldPosition(this.posRightHand);
115
- wrist.getWorldQuaternion(this.rotRightHand);
116
- }
117
- }
118
- }
119
-
120
- private static quat0: Quaternion = new Quaternion();
121
- private static quat1: Quaternion = new Quaternion();
122
-
123
- public sendAsBuffer(builder: Builder, net: NetworkConnection) {
124
- builder.clear();
125
- const guid = builder.createString(this.guid);
126
- const id = builder.createString(this.avatarId);
127
- VrUserStateBuffer.startVrUserStateBuffer(builder);
128
- VrUserStateBuffer.addGuid(builder, guid);
129
- VrUserStateBuffer.addTime(builder, flatbuffers_long_from_number(this.time));
130
- VrUserStateBuffer.addAvatarId(builder, id);
131
- VrUserStateBuffer.addPosition(builder, Vec3.createVec3(builder, this.position.x, this.position.y, this.position.z));
132
- VrUserStateBuffer.addRotation(builder, Vec4.createVec4(builder, this.rotation.x, this.rotation.y, this.rotation.z, this.rotation.w));
133
- VrUserStateBuffer.addScale(builder, this.scale);
134
- VrUserStateBuffer.addPosLeftHand(builder, Vec3.createVec3(builder, this.posLeftHand.x, this.posLeftHand.y, this.posLeftHand.z));
135
- VrUserStateBuffer.addPosRightHand(builder, Vec3.createVec3(builder, this.posRightHand.x, this.posRightHand.y, this.posRightHand.z));
136
- VrUserStateBuffer.addRotLeftHand(builder, Vec4.createVec4(builder, this.rotLeftHand.x, this.rotLeftHand.y, this.rotLeftHand.z, this.rotLeftHand.w));
137
- VrUserStateBuffer.addRotRightHand(builder, Vec4.createVec4(builder, this.rotRightHand.x, this.rotRightHand.y, this.rotRightHand.z, this.rotRightHand.w));
138
- const res = VrUserStateBuffer.endVrUserStateBuffer(builder);
139
- builder.finish(res, VRUserStateBufferIdentifier);
140
- const arr = builder.asUint8Array();
141
- net.sendBinary(arr);
142
- }
143
-
144
- public setFromBuffer(guid: string, state: VrUserStateBuffer) {
145
- if (!guid) return;
146
- this.guid = guid;
147
- this.time = state.time().toFloat64();
148
- const id = state.avatarId();
149
- if (id)
150
- this.avatarId = id;
151
- const pos = state.position();
152
- if (pos)
153
- this.position.set(pos.x(), pos.y(), pos.z());
154
- // TODO: maybe just send one float more instead of converting back and forth
155
- const rot = state.rotation();
156
- if (rot)
157
- this.rotation.set(rot.x(), rot.y(), rot.z(), rot.w());
158
- const posLeftHand = state.posLeftHand();
159
- if (posLeftHand)
160
- this.posLeftHand.set(posLeftHand.x(), posLeftHand.y(), posLeftHand.z());
161
- const posRightHand = state.posRightHand();
162
- if (posRightHand)
163
- this.posRightHand.set(posRightHand.x(), posRightHand.y(), posRightHand.z());
164
- const rotLeftHand = state.rotLeftHand();
165
- if (rotLeftHand)
166
- this.rotLeftHand.set(rotLeftHand.x(), rotLeftHand.y(), rotLeftHand.z(), rotLeftHand.w());
167
- const rotRightHand = state.rotRightHand();
168
- if (rotRightHand)
169
- this.rotRightHand.set(rotRightHand.x(), rotRightHand.y(), rotRightHand.z(), rotRightHand.w());
170
- this.scale = state.scale();
171
- }
172
- }
173
-
174
- export class WebXRSync extends Behaviour {
175
-
176
- webXR: WebXR | null = null;
177
-
178
- // private allowCustomAvatars: boolean | null = true;
179
-
180
- private debugAvatarUser: WebXRAvatar | null = null;
181
- private voip: Voip | null = null;
182
-
183
- async awake() {
184
-
185
- if(!this.webXR) this.webXR = GameObject.getComponent(this.gameObject, WebXR);
186
- if(!this.webXR) this.webXR = GameObject.findObjectOfType(WebXR, this.context);
187
-
188
- if(!this.webXR)
189
- {
190
- this.webXR = GameObject.findObjectOfType(WebXR, this.context);
191
- if(!this.webXR) {
192
- console.warn("WebXRSync: Could not find WebXR component, won't sync.");
193
- return;
194
- }
195
- }
196
-
197
- if (!this.voip) this.voip = GameObject.findObjectOfType(Voip, this.context);
198
-
199
- if (debugAvatar) {
200
- const debugGuid = "debug-avatar-" + debugAvatar;
201
- const newUser = new WebXRAvatar(this.context, debugGuid, this.webXR);
202
- // newUser.isLocalAvatar = true;
203
- this.debugAvatarUser = newUser;
204
- if (typeof debugAvatar === "string" && debugAvatar.length > 0) {
205
- if (await newUser.setAvatarOverride(debugAvatar)) {
206
- const debugState = new VRUserState(debugGuid);
207
- debugState.position.y += 1;
208
- const off = .5;
209
- debugState.posLeftHand.y += off;
210
- debugState.posLeftHand.x += off;
211
- debugState.posRightHand.y += off;
212
- debugState.posRightHand.x -= off;
213
- newUser.tryUpdate(debugState, 0);
214
- }
215
- else {
216
- newUser.destroy();
217
- }
218
- }
219
- }
220
- }
221
-
222
- onEnable() {
223
- // const debugUser = new WebXRAvatar(this.context, "sorry-no-guid", this.webXR!);
224
-
225
- if (!this.webXR) {
226
- this.webXR = GameObject.getComponent(this.gameObject, WebXR);
227
- if (!this.webXR) {
228
- console.warn("Missing webxr component on " + this.gameObject.name);
229
- return;
230
- }
231
- }
232
-
233
- this.eventSub_WebXRStartEvent = this.onXRSessionStart.bind(this);
234
- WebXR.addEventListener(WebXREvent.XRStarted, this.eventSub_WebXRStartEvent);
235
- this.eventSub_WebXRUpdateEvent = this.onXRSessionUpdate.bind(this);
236
- WebXR.addEventListener(WebXREvent.XRUpdate, this.eventSub_WebXRUpdateEvent);
237
- this.eventSub_WebXREndEvent = this.onXRSessionEnded.bind(this);
238
- WebXR.addEventListener(WebXREvent.XRStopped, this.eventSub_WebXREndEvent);
239
-
240
- this.eventSub_ConnectionEvent = this.onConnected.bind(this);
241
- this.context.connection.beginListen(RoomEvents.JoinedRoom, this.eventSub_ConnectionEvent);
242
- this.context.connection.beginListen(WebXRSyncEvent.WebXR_UserJoined, _evt => {
243
- console.log("webxr user joined evt");
244
- });
245
- this.context.connection.beginListen(WebXRSyncEvent.WebXR_UserLeft, evt => {
246
- const hasId = evt.id !== null && evt.id !== undefined;
247
- if (!hasId) return;
248
- console.log("webxr user left evt");
249
- if (hasId) {
250
- const avatar = this.avatars[evt.id];
251
- avatar?.destroy();
252
- this.avatars[evt.id] = undefined;
253
- }
254
- });
255
- this.context.connection.beginListenBinary(VRUserStateBufferIdentifier, (state: VrUserStateBuffer) => {
256
- // console.log("BUFFER", state);
257
- const guid = state.guid();
258
- if (!guid) return;
259
- const time = state.time().toFloat64();
260
- const temp = this.tempState;
261
- temp.setFromBuffer(guid, state);
262
- // console.log(temp);
263
- const user = this.onTryGetAvatar(guid, time);
264
- user?.tryUpdate(temp, time);
265
- });
266
- this.context.connection.beginListen(WebXRSyncEvent.VRSessionUpdate, (state: VRUserState) => {
267
- const guid = state.guid;
268
- const time = state.time;
269
- const user = this.onTryGetAvatar(guid, time);
270
- user?.tryUpdate(state, time);
271
- });
272
- }
273
-
274
- private tempState: VRUserState = new VRUserState("");
275
-
276
- private onTryGetAvatar(guid: string, time: number) {
277
- if (guid === this.context.connection.connectionId) return null; // ignore self in case we receive that also!
278
- const timeDiff = new Date().getTime() - time;
279
- if (timeDiff > 5000) {
280
- if (debugLogs)
281
- console.log("old data", timeDiff, guid)
282
- return null;
283
- }
284
- if (!this.webXR) return null;
285
- let user = this.avatars[guid];
286
- if (user === undefined) {
287
- try {
288
- console.log("create new avatar");
289
- const newUser = new WebXRAvatar(this.context, guid, this.webXR);
290
- user = newUser;
291
- this.avatars[guid] = newUser;
292
- } catch (err) {
293
- this.avatars[guid] = null;
294
- console.error(err);
295
- }
296
- }
297
- return user;
298
- }
299
-
300
- onDisable() {
301
- if (this.eventSub_ConnectionEvent)
302
- this.context.connection.stopListen(RoomEvents.JoinedRoom, this.eventSub_ConnectionEvent);
303
- WebXR.removeEventListener(WebXREvent.XRStarted, this.eventSub_WebXRStartEvent);
304
- WebXR.removeEventListener(WebXREvent.XRUpdate, this.eventSub_WebXRUpdateEvent);
305
- WebXR.removeEventListener(WebXREvent.XRStopped, this.eventSub_WebXREndEvent);
306
- }
307
-
308
- update(): void {
309
-
310
- const now = getTimeStampNow();
311
-
312
- if (this.debugAvatarUser) {
313
- this.debugAvatarUser.lastUpdate = now;
314
- }
315
-
316
- this.detectPotentiallyDisconnectedAvatarsAndRemove();
317
-
318
- for (const key in this.avatars) {
319
- const avatar = this.avatars[key];
320
- if (!avatar) continue;
321
- avatar.update();
322
- }
323
- }
324
-
325
-
326
- private _removeAvatarsList: string[] = [];
327
- private detectPotentiallyDisconnectedAvatarsAndRemove() {
328
- const utcnow = getTimeStampNow();
329
- for (const key in this.avatars) {
330
- const avatar = this.avatars[key];
331
- if (!avatar) {
332
- this._removeAvatarsList.push(key);
333
- continue;
334
- }
335
- if (utcnow - avatar.lastUpdate > 10_000) {
336
- console.log("avatar timed out (didnt receive any updates in a while) - destroying it now");
337
- avatar.destroy();
338
- this.avatars[key] = undefined;
339
- }
340
- }
341
- for (const rem of this._removeAvatarsList) {
342
- delete this.avatars[rem];
343
- }
344
- this._removeAvatarsList.length = 0;
345
- }
346
-
347
- private buildLocalAvatar() {
348
- if (this.localAvatar || !this.webXR) return;
349
- const connectionId = this.context.connection?.connectionId ?? this.k_LocalAvatarNoNetworkingGuid;
350
- this.localAvatar = new WebXRAvatar(this.context, connectionId, this.webXR);
351
- this.localAvatar.isLocalAvatar = true;
352
- this.localAvatar.setAvatarOverride(this.getAvatarId());
353
- this.avatars[this.localAvatar.guid] = this.localAvatar;
354
- }
355
-
356
-
357
- private eventSub_ConnectionEvent: Function | null = null;
358
- private eventSub_WebXRStartEvent: Function | null = null;
359
- private eventSub_WebXREndEvent: Function | null = null;
360
- private eventSub_WebXRUpdateEvent: Function | null = null;
361
- private avatars: { [key: string]: WebXRAvatar | undefined | null } = {}
362
- private localAvatar: WebXRAvatar | null = null;
363
- private k_LocalAvatarNoNetworkingGuid = "local";
364
-
365
- private onConnected() {
366
- // this event gets fired when we have joined a room and are ready to update
367
- if (debugLogs)
368
- console.log("Hey you are connected as " + this.context.connection.connectionId);
369
-
370
- if (this.localAvatar?.guid === this.k_LocalAvatarNoNetworkingGuid) {
371
- if (this.localAvatar) {
372
- this.localAvatar?.destroy();
373
- this.avatars[this.localAvatar.guid] = undefined;
374
- }
375
- this.localAvatar = null;
376
- this.xrState = null;
377
- this.ownership?.freeOwnership();
378
- this.ownership = null;
379
- }
380
- }
381
-
382
- private onXRSessionStart(_evt: { session: XRSession }) {
383
- console.log("XR session started");
384
- this.context.connection.send(WebXRSyncEvent.WebXR_UserJoined, { id: this.context.connection.connectionId, mode: XRMode.VR });
385
-
386
- if (this.localAvatar) {
387
- this.localAvatar?.destroy();
388
- this.avatars[this.localAvatar.guid] = undefined;
389
- this.localAvatar = null;
390
- }
391
- this.xrState = null;
392
- this.ownership?.freeOwnership();
393
- this.ownership = null;
394
-
395
- if (this.avatars) {
396
- for (const key in this.avatars) {
397
- this.avatars[key]?.updateFlags();
398
- }
399
- }
400
- }
401
-
402
- private onXRSessionEnded(_evt: { session: XRSession }) {
403
- console.log("XR session ended");
404
- this.context.connection.send(WebXRSyncEvent.WebXR_UserLeft, { id: this.context.connection.connectionId, mode: XRMode.VR });
405
- if(this.localAvatar){
406
- this.localAvatar?.destroy();
407
- this.avatars[this.localAvatar.guid] = undefined;
408
- this.localAvatar = null;
409
- }
410
- }
411
-
412
- private ownership: OwnershipModel | null = null;
413
- private xrState: VRUserState | null = null;
414
- private builder: Builder = new Builder(1024);
415
-
416
- private onXRSessionUpdate(evt: { rig: Group, frame: XRFrame, xr: WebXRManager, input: XRInputSource[] }) {
417
-
418
- this.xrState ??= new VRUserState(this.context.connection.connectionId ?? this.k_LocalAvatarNoNetworkingGuid);
419
- this.ownership ??= new OwnershipModel(this.context.connection, this.context.connection.connectionId ?? this.k_LocalAvatarNoNetworkingGuid);
420
- this.ownership.guid = this.context.connection.connectionId ?? this.k_LocalAvatarNoNetworkingGuid;
421
- this.buildLocalAvatar();
422
-
423
-
424
- const { frame, xr, rig } = evt;
425
- const pose = frame.getViewerPose(xr.getReferenceSpace()!);
426
- if (!pose) return; // e.g. if user is not wearing headset
427
- const transform: XRRigidTransform = pose?.transform;
428
- const pos = transform.position;
429
- const rot = transform.orientation;
430
- this.xrState.update(rig, pos, rot, this.webXR!, this.getAvatarId());
431
-
432
- if (this.localAvatar) {
433
- if (this.context.connection.connectionId) {
434
- this.localAvatar.guid = this.context.connection.connectionId;
435
- }
436
- this.localAvatar.tryUpdate(this.xrState, 0);
437
- }
438
-
439
- if (this.ownership && !this.ownership.hasOwnership && this.context.connection.isConnected) {
440
- if (this.context.time.frameCount % 120 === 0)
441
- this.ownership.requestOwnership();
442
- if (!this.ownership.hasOwnership) {
443
- // console.log("NO OWNERSHIP", this.ownership.guid);
444
- return;
445
- }
446
- }
447
-
448
- if (!this.context.connection.isConnected || !this.context.connection.connectionId) {
449
- return;
450
- }
451
-
452
- this.xrState.sendAsBuffer(this.builder, this.context.connection);
453
-
454
- // this.context.connection.send(WebXRSyncEvent.VRSessionUpdate, this.xrState);
455
-
456
- }
457
-
458
- private getAvatarId() {
459
- const urlAvatar = getParam("avatar") as string;
460
- const avatarId = urlAvatar ?? null;
461
- return avatarId;
462
- }
463
- }
src/engine-components/XRFlag.ts DELETED
@@ -1,139 +0,0 @@
1
- import { Behaviour, GameObject } from "./Component.js";
2
- import { getParam } from "../engine/engine_utils.js";
3
- import { serializable } from "../engine/engine_serialization_decorator.js";
4
-
5
-
6
- const debug = getParam("debugflags");
7
-
8
- export enum XRStateFlag {
9
- Never = 0,
10
- Browser = 1 << 0,
11
- AR = 1 << 1,
12
- VR = 1 << 2,
13
- FirstPerson = 1 << 3,
14
- ThirdPerson = 1 << 4,
15
- All = 0xffffffff
16
- }
17
-
18
- export class XRState {
19
-
20
- public static Global: XRState = new XRState();
21
-
22
- public Mask: XRStateFlag = XRStateFlag.Browser | XRStateFlag.ThirdPerson;
23
-
24
- public Has(state: XRStateFlag) {
25
- const res = (this.Mask & state);
26
- return res !== 0;
27
- }
28
-
29
- public Set(state: number) {
30
- if(debug) console.warn("Set XR flag state to", state)
31
- this.Mask = state as number;
32
- XRFlag.Apply();
33
- }
34
-
35
- public Enable(state: number) {
36
- this.Mask |= state;
37
- XRFlag.Apply();
38
- }
39
-
40
- public Disable(state: number) {
41
- this.Mask &= ~state;
42
- XRFlag.Apply();
43
- }
44
-
45
- public Toggle(state: number) {
46
- this.Mask ^= state;
47
- XRFlag.Apply();
48
- }
49
-
50
- public EnableAll() {
51
- this.Mask = 0xffffffff | 0;
52
- XRFlag.Apply();
53
- }
54
-
55
- public DisableAll() {
56
- this.Mask = 0;
57
- XRFlag.Apply();
58
- }
59
- }
60
-
61
- export class XRFlag extends Behaviour {
62
-
63
- private static registry: XRFlag[] = [];
64
-
65
- public static Apply() {
66
- for (const r of this.registry) r.UpdateVisible(XRState.Global);
67
- }
68
-
69
- private static firstApply: boolean;
70
- private static buffer: XRState = new XRState();
71
-
72
- @serializable()
73
- public visibleIn!: number;
74
-
75
- awake() {
76
- XRFlag.registry.push(this);
77
- }
78
-
79
- onEnable(): void {
80
- if (!XRFlag.firstApply) {
81
- XRFlag.firstApply = true;
82
- XRFlag.Apply();
83
- }
84
- else {
85
- this.UpdateVisible(XRState.Global);
86
- }
87
- }
88
-
89
- onDestroy(): void {
90
- const i = XRFlag.registry.indexOf(this);
91
- if (i >= 0)
92
- XRFlag.registry.splice(i, 1);
93
- }
94
-
95
- public get isOn(): boolean { return this.gameObject.visible; }
96
-
97
- public UpdateVisible(state: XRState | XRStateFlag | null = null) {
98
- // XR flags set visibility of whole hierarchy which is like setting the whole object inactive
99
- // so we need to ignore the enabled state of the XRFlag component
100
- // if(!this.enabled) return;
101
- let res: boolean | undefined = undefined;
102
-
103
- const flag = state as number;
104
- if (flag && typeof flag === "number") {
105
- console.assert(typeof flag === "number", "XRFlag.UpdateVisible: state must be a number", flag);
106
- if (debug)
107
- console.log(flag);
108
- XRFlag.buffer.Mask = flag;
109
- state = XRFlag.buffer;
110
- }
111
-
112
- const st = state as XRState;
113
- if (st) {
114
- if (debug)
115
- console.warn(this.name, "use passed in mask", st.Mask, this.visibleIn)
116
- res = st.Has(this.visibleIn);
117
- }
118
- else {
119
- if (debug)
120
- console.log(this.name, "use global mask")
121
- XRState.Global.Has(this.visibleIn);
122
- }
123
- if (res === undefined) return;
124
- if (res) {
125
- if (debug)
126
- console.log(this.name, "is visible", this.gameObject.uuid)
127
- // this.gameObject.visible = true;
128
- GameObject.setActive(this.gameObject, true);
129
- } else {
130
- if (debug)
131
- console.log(this.name, "is not visible", this.gameObject.uuid);
132
- const isVisible = this.gameObject.visible;
133
- if(!isVisible) return;
134
- this.gameObject.visible = false;
135
- // console.trace("DISABLE", this.name);
136
- // GameObject.setActive(this.gameObject, false);
137
- }
138
- }
139
- }
src/engine-schemes/README.md ADDED
@@ -0,0 +1,2 @@
1
+ Using flatbuffer compiler 2.0
2
+ https://github.com/google/flatbuffers/releases/tag/v2.0.0
src/engine-components/webxr/Avatar.ts ADDED
@@ -0,0 +1,220 @@
1
+ import { AssetReference } from "../../engine/engine_addressables.js";
2
+ import { serializable } from "../../engine/engine_serialization_decorator.js";
3
+ import { PromiseAllWithErrors, getParam } from "../../engine/engine_utils.js";
4
+ import { NeedleXREventArgs, NeedleXRSession, NeedleXRUtils } from "../../engine/xr/index.js";
5
+ import { Behaviour, GameObject } from "../Component.js";
6
+ import { Object3D, Quaternion, Vector3 } from "three";
7
+ import { ObjectUtils, PrimitiveType } from "../../engine/engine_create_objects.js";
8
+ import { SyncedTransform } from "../SyncedTransform.js";
9
+ import { PlayerState } from "../../engine-components-experimental/networking/PlayerSync.js";
10
+ import { IGameObject } from "../../engine/engine_types.js";
11
+ import { XRFlag } from "./XRFlag.js";
12
+ import { AvatarMarker } from "./WebXRAvatar.js";
13
+
14
+ const debug = getParam("debugwebxr");
15
+
16
+ const flipForwardQuaternion = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI);
17
+
18
+ export class Avatar extends Behaviour {
19
+
20
+ @serializable(AssetReference)
21
+ head?: AssetReference;
22
+
23
+ @serializable(AssetReference)
24
+ leftHand?: AssetReference;
25
+
26
+ @serializable(AssetReference)
27
+ rightHand?: AssetReference;
28
+
29
+ private _syncTransforms?: SyncedTransform[];
30
+
31
+ async onEnterXR(_args: NeedleXREventArgs) {
32
+ if (!this.activeAndEnabled) return;
33
+ if (debug) console.warn("AVATAR ENTER XR", this.guid, this.sourceId, this, this.activeAndEnabled)
34
+ if (this._syncTransforms)
35
+ this._syncTransforms.length = 0;
36
+ await this.prepareAvatar();
37
+
38
+ const playerstate = PlayerState.getFor(this);
39
+ if (playerstate?.owner) {
40
+ const marker = this.gameObject.addNewComponent(AvatarMarker)!;
41
+ marker.avatar = this.gameObject;
42
+ marker.connectionId = playerstate.owner;
43
+ }
44
+ else console.error("No player state found for avatar", this);
45
+ }
46
+
47
+ onLeaveXR(_args: NeedleXREventArgs): void {
48
+ const marker = this.gameObject.getComponent(AvatarMarker);
49
+ if (marker) {
50
+ marker.destroy();
51
+ }
52
+ }
53
+
54
+ onUpdateXR(args: NeedleXREventArgs): void {
55
+ if (!this.activeAndEnabled) return;
56
+
57
+ const isLocalPlayer = PlayerState.isLocalPlayer(this);
58
+ if (!isLocalPlayer) return;
59
+
60
+ const xr = args.xr;
61
+ // make sure the avatar is inside the active rig
62
+ if (xr.rig && xr.rig.gameObject !== this.gameObject.parent) {
63
+ this.gameObject.position.set(0, 0, 0);
64
+ this.gameObject.rotation.set(0, 0, 0);
65
+ this.gameObject.scale.set(1, 1, 1);
66
+ xr.rig.gameObject.add(this.gameObject);
67
+ }
68
+ // this.gameObject.position.copy(xr.rig!.gameObject.position);
69
+ // this.gameObject.quaternion.copy(xr.rig!.gameObject.quaternion);
70
+ // this.gameObject.scale.set(1, 1, 1);
71
+
72
+
73
+ if (this._syncTransforms && isLocalPlayer) {
74
+ for (const sync of this._syncTransforms) {
75
+ sync.fastMode = true;
76
+ if (!sync.isOwned())
77
+ sync.requestOwnership();
78
+ }
79
+ }
80
+
81
+
82
+ // synchronize head
83
+ if (this.head && this.context.mainCamera) {
84
+ const headObj = this.head.asset as IGameObject;
85
+ headObj.position.copy(this.context.mainCamera.position);
86
+ headObj.quaternion.copy(this.context.mainCamera.quaternion);
87
+ headObj.quaternion.x *= -1;
88
+
89
+ // HACK: XRFlag limitation workaround to make sure first person user head is never rendered
90
+ if (this.context.time.frameCount % 10 === 0) {
91
+ const xrflags = GameObject.getComponentsInChildren(this.head.asset, XRFlag);
92
+ for (const flag of xrflags) {
93
+ flag.enabled = false;
94
+ flag.gameObject.visible = false;
95
+ }
96
+ }
97
+ }
98
+
99
+ // synchronize hands
100
+ const leftCtrl = args.xr.leftController;
101
+ const leftObj = this.leftHand?.asset as Object3D;
102
+ if (leftCtrl && leftObj) {
103
+ leftObj.position.copy(leftCtrl.gripPosition);
104
+ leftObj.quaternion.copy(leftCtrl.gripQuaternion);
105
+ leftObj.quaternion.multiply(flipForwardQuaternion);
106
+ leftObj.visible = leftCtrl.isTracking;
107
+ }
108
+
109
+ const right = args.xr.rightController;
110
+ if (right && this.rightHand?.asset) {
111
+ const rightObj = this.rightHand.asset as Object3D;
112
+ rightObj.position.copy(right.gripPosition);
113
+ rightObj.quaternion.copy(right.gripQuaternion);
114
+ rightObj.quaternion.multiply(flipForwardQuaternion);
115
+ rightObj.visible = right.isTracking;
116
+ }
117
+ }
118
+
119
+ onBeforeRender(): void {
120
+ if (this.context.time.frame % 10 === 0)
121
+ this.updateRemoteAvatarVisibility();
122
+ }
123
+
124
+
125
+ private updateRemoteAvatarVisibility() {
126
+ if (this.context.connection.isConnected) {
127
+ const state = PlayerState.getFor(this);
128
+ if (state && state.isLocalPlayer == false) {
129
+
130
+ const sync = NeedleXRSession.getXRSync(this.context);
131
+ if (sync) {
132
+ if (sync.hasState(state.owner)) {
133
+ this.tryFindAvatarObjectsIfMissing();
134
+
135
+ const leftObj = this.leftHand?.asset as Object3D;
136
+ if (leftObj) {
137
+ leftObj.visible = sync?.isTracking(state.owner, "left") ?? false;
138
+ }
139
+ const rightObj = this.rightHand?.asset as Object3D;
140
+ if (rightObj) {
141
+ rightObj.visible = sync?.isTracking(state.owner, "right") ?? false;
142
+ }
143
+ }
144
+ }
145
+
146
+ // HACK: XRFlag limitation workaround to make sure first person user head of OTHER users is ALWAYS rendered
147
+ if (this.head?.asset) {
148
+ const xrflags = GameObject.getComponentsInChildren(this.head.asset, XRFlag);
149
+ for (const flag of xrflags) {
150
+ flag.enabled = false;
151
+ flag.gameObject.visible = true;
152
+ }
153
+ }
154
+ }
155
+ }
156
+ }
157
+
158
+
159
+
160
+ private tryFindAvatarObjectsIfMissing() {
161
+ // if no avatar objects are set, try to find them
162
+ if (!this.head || !this.leftHand || !this.rightHand) {
163
+ const res = { head: this.head, leftHand: this.leftHand, rightHand: this.rightHand };
164
+ NeedleXRUtils.tryFindAvatarObjects(this.gameObject, this.sourceId || "", res);
165
+ if (res.head) this.head = res.head;
166
+ if (res.leftHand) this.leftHand = res.leftHand;
167
+ if (res.rightHand) this.rightHand = res.rightHand;
168
+ }
169
+ }
170
+
171
+ private async prepareAvatar() {
172
+ // if no avatar objects are set, try to find them
173
+ this.tryFindAvatarObjectsIfMissing();
174
+
175
+ if (!this.head) {
176
+ const head = new Object3D();
177
+ head.name = "Head";
178
+ const cube = ObjectUtils.createPrimitive(PrimitiveType.Cube);
179
+ head.add(cube);
180
+ this.gameObject.add(head);
181
+ this.head = new AssetReference("", this.sourceId, head);
182
+ if (debug) console.log("Create head", head);
183
+ }
184
+
185
+ if (!this.rightHand) {
186
+ const rightHand = new Object3D();
187
+ rightHand.name = "Right Hand";
188
+ this.gameObject.add(rightHand);
189
+ this.rightHand = new AssetReference("", this.sourceId, rightHand);
190
+ if (debug) console.log("Create right hand", rightHand);
191
+ }
192
+
193
+ if (!this.leftHand) {
194
+ const leftHand = new Object3D();
195
+ leftHand.name = "Left Hand";
196
+ this.gameObject.add(leftHand);
197
+ this.leftHand = new AssetReference("", this.sourceId, leftHand);
198
+ if (debug) console.log("Create left hand", leftHand);
199
+ }
200
+
201
+ await this.loadAvatarObjects(this.head, this.leftHand, this.rightHand);
202
+
203
+ if (PlayerState.isLocalPlayer(this.gameObject)) {
204
+ this._syncTransforms = GameObject.getComponentsInChildren(this.gameObject, SyncedTransform);
205
+ }
206
+ }
207
+
208
+
209
+ private async loadAvatarObjects(head: AssetReference, left: AssetReference, right: AssetReference) {
210
+ const pHead = head.loadAssetAsync();
211
+ const pHandLeft = left.loadAssetAsync();
212
+ const pHandRight = right.loadAssetAsync();
213
+ const promises = new Array<Promise<any>>();
214
+ if (pHead) promises.push(pHead);
215
+ if (pHandLeft) promises.push(pHandLeft);
216
+ if (pHandRight) promises.push(pHandRight);
217
+ const res = await PromiseAllWithErrors(promises);
218
+ if (debug) console.log("Avatar loaded results:", res);
219
+ }
220
+ }
src/engine/engine_xr.ts ADDED
@@ -0,0 +1,2 @@
1
+
2
+ export * from "./xr/index.js"
src/engine/xr/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./XRRig.js";
2
+ export * from "./NeedleXRSession.js";
3
+ export * from "./NeedleXRController.js";
4
+ export * from "./NeedleXRSync.js"
5
+ export * from "./utils.js"
src/engine/xr/internal.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { Matrix4, Object3D, Quaternion, Scene, Vector3 } from 'three';
2
+ import { IXRRig } from './XRRig.js';
3
+ import { IGameObject } from '../engine_types.js';
4
+ import { getParam } from '../engine_utils.js';
5
+ import { CreateWireCube, Gizmos } from '../engine_gizmos.js';
6
+
7
+ export const flipForwardMatrix = new Matrix4().makeRotationY(Math.PI);
8
+ export const flipForwardQuaternion = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI);
9
+
10
+ const debug = getParam("debugwebxr");
11
+
12
+ export class ImplictXRRig implements IXRRig {
13
+
14
+ priority = -100000;
15
+ gameObject: IGameObject;
16
+
17
+ isXRRig(): boolean {
18
+ return true;
19
+ }
20
+
21
+ get isActive(): boolean {
22
+ return this.gameObject.visible;
23
+ }
24
+
25
+ constructor() {
26
+ this.gameObject = new Object3D() as IGameObject;
27
+ this.gameObject.name = "Implicit XR Rig";
28
+ if (debug) {
29
+ const cube = CreateWireCube(0xff55dd);
30
+ cube.position.y += .5;
31
+ this.gameObject.add(cube);
32
+ }
33
+ }
34
+ }
src/engine/xr/NeedleXRController.ts ADDED
@@ -0,0 +1,558 @@
1
+ import { AxesHelper, Object3D, Quaternion, Ray, Vector3 } from "three";
2
+ import { MotionController, fetchProfile } from "@webxr-input-profiles/motion-controllers";
3
+ import type { ButtonName, Vec3, XRControllerButtonName } from "../engine_types.js";
4
+ import { Context } from "../engine_context.js";
5
+ import { Gizmos } from "../engine_gizmos.js";
6
+ import { InputEvents, NEPointerEventInit, PointerType, NEPointerEvent } from "../engine_input.js";
7
+ import { getTempVector, getTempQuaternion, getWorldQuaternion } from "../engine_three_utils.js";
8
+ import type { NeedleXRHitTestResult, NeedleXRSession } from "./NeedleXRSession.js";
9
+ import { flipForwardMatrix, flipForwardQuaternion } from "./internal.js";
10
+ import { getParam } from "../engine_utils.js";
11
+
12
+ const debug = getParam("debugwebxr");
13
+
14
+ // https://github.com/immersive-web/webxr-input-profiles/blob/4484a05e30bcd43fe86bb4e06b7a707861a26796/packages/registry/profiles/meta/meta-quest-touch-plus.json
15
+ declare type ControllerAxes = "xr-standard-thumbstick";
16
+ declare type StickName = "xr-standard-thumbstick";
17
+ declare type Mapping = "xr-standard";
18
+ declare type ComponentType = "button" | "thumbstick";
19
+ declare type GamepadKey = "button" | "xAxis" | "yAxis";
20
+
21
+
22
+ declare type ComponentMap = {
23
+ type: ComponentType,
24
+ rootNodeName?: string,
25
+ gamepadIndices?: { [key in GamepadKey]?: number },
26
+ visualResponses?: { [key: string]: { states: Array<string> } }
27
+ }
28
+
29
+ declare type InputDeviceLayout = {
30
+ selectComponentId: string,
31
+ components: { [key: string]: ComponentMap }
32
+ mapping: Mapping;
33
+ gamepad: Array<XRControllerButtonName>,
34
+ axes: Array<{
35
+ componentId: ControllerAxes,
36
+ axis: "x-axis" | "y-axis",
37
+ }>,
38
+ }
39
+ declare type InputDeviceProfile = {
40
+ profileId: string,
41
+ fallbackProfileIds: string[],
42
+ layouts: [
43
+ left: InputDeviceLayout,
44
+ right: InputDeviceLayout
45
+ ]
46
+ }
47
+
48
+ // https://github.com/immersive-web/webxr-input-profiles/blob/4484a05e30bcd43fe86bb4e06b7a707861a26796/packages/registry/profiles/meta/meta-quest-touch-plus.json
49
+ const DEFAULT_PROFILES_PATH = 'https://cdn.jsdelivr.net/npm/@webxr-input-profiles/[email protected]/dist/profiles';
50
+ const DEFAULT_PROFILE = 'generic-trigger';
51
+
52
+
53
+ /**
54
+ * A NeedleXRController wraps a connected XRInputDevice that is either a physical controller or a hand
55
+ * You can access specific buttons using `getButton` and `getStick`
56
+ * To get spatial data in rig space (position, rotation) use the `gripPosition`, `gripQuaternion`, `rayPosition` and `rayQuaternion` properties
57
+ * To get spatial data in world space use the `gripWorldPosition`, `gripWorldQuaternion`, `rayWorldPosition` and `rayWorldQuaternion` properties
58
+ * Inputs will also be emitted as pointer events on `this.context.input` - so you can receive controller inputs on objects using the appropriate input events on your components (e.g. `onPointerDown`, `onPointerUp` etc) - use the `pointerType` property to check if the event is from a controller or not
59
+ * @link https://developer.mozilla.org/en-US/docs/Web/API/XRInputSource
60
+ */
61
+ export class NeedleXRController {
62
+ /** the Needle XR Session */
63
+ readonly xr: NeedleXRSession;
64
+ /**
65
+ * https://developer.mozilla.org/en-US/docs/Web/API/XRInputSource
66
+ */
67
+ readonly inputSource: XRInputSource;
68
+ /** the input source index */
69
+ readonly index: number = 0;
70
+
71
+ // EXPOSE API
72
+ /**
73
+ * Is the controller still connected?
74
+ */
75
+ get connected() { return this.inputSource.gamepad?.connected ?? false; }
76
+ get isTracking() { return this._isTracking; }
77
+ private _isTracking: boolean = false;
78
+ /** the input source gamepad giving raw access to the gamepad values
79
+ * You should usually use the `getButton` and `getStick` methods instead to get access to named buttons and sticks
80
+ */
81
+ get gamepad() { return this.inputSource.gamepad; }
82
+ /**
83
+ * If this is a hand then this is the hand info (XRHand)
84
+ * @link https://developer.mozilla.org/en-US/docs/Web/API/XRHand
85
+ */
86
+ get hand() { return this.inputSource.hand; }
87
+ /** The input source profiles */
88
+ get profiles() { return this.inputSource.profiles; }
89
+ /** The device input layout */
90
+ get layout() { return this._layout; }
91
+
92
+ /** shorthand for `inputSource.targetRayMode` */
93
+ get targetRayMode() { return this.inputSource.targetRayMode; }
94
+ /** shorthand for `inputSource.targetRaySpace` */
95
+ get targetRaySpace() { return this.inputSource.targetRaySpace; }
96
+ /** shorthand for `inputSource.gripSpace` */
97
+ get gripSpace() { return this.inputSource.gripSpace; }
98
+ /**
99
+ * If the controller if held in the left or right hand (or if it's a left or right hand)
100
+ **/
101
+ get side() { return this.inputSource.handedness; }
102
+ /** is right side. shorthand for `side === 'right'` */
103
+ get isRight() { return this.side === 'right'; }
104
+ /** is left side. shorthand for `side === 'left'` */
105
+ get isLeft() { return this.side === 'left'; }
106
+
107
+ /** The XRTransientInputHitTestSource can be used to perform hit tests with the controller ray against the real world.
108
+ * see https://developer.mozilla.org/en-US/docs/Web/API/XRSession/requestHitTestSourceForTransientInput for more information
109
+ * Requires the hit-test feature to be enabled in the XRSession
110
+ */
111
+ get hitTestSource() { return this._hitTestSource; }
112
+ private _hitTestSource: XRTransientInputHitTestSource | undefined = undefined;
113
+
114
+ /** Perform a hit test against the XR planes or meshes. shorthand for `xr.getHitTest(controller)` */
115
+ getHitTest(): NeedleXRHitTestResult | null {
116
+ return this.xr.getHitTest(this);
117
+ }
118
+
119
+ private readonly _gripPosition = new Vector3();
120
+ private readonly _gripQuaternion = new Quaternion();
121
+ private readonly _rayPosition = new Vector3();
122
+ private readonly _rayQuaternion = new Quaternion();
123
+
124
+ /** Grip position in rig space */
125
+ get gripPosition() { return getTempVector(this._gripPosition).applyMatrix4(flipForwardMatrix) }
126
+ /** Grip rotation in rig space */
127
+ get gripQuaternion() { return getTempQuaternion(this._gripQuaternion).premultiply(flipForwardQuaternion) }
128
+ /** Ray position in rig space */
129
+ get rayPosition() { return getTempVector(this._rayPosition).applyMatrix4(flipForwardMatrix) }
130
+ /** Ray rotation in rig space */
131
+ get rayQuaternion() { return getTempQuaternion(this._rayQuaternion).premultiply(flipForwardQuaternion) }
132
+
133
+ /** Controller grip position in worldspace */
134
+ get gripWorldPosition() {
135
+ const v = getTempVector(this._gripPosition);
136
+ const space = this.xr.context.mainCamera?.parent;
137
+ if (!space) return v;
138
+ return v.applyMatrix4(space.matrixWorld);
139
+ }
140
+ /** Controller grip rotation in wordspace */
141
+ get gripWorldQuaternion() {
142
+ const q = getTempQuaternion(this._gripQuaternion);
143
+ // flip forward because we want +Z to be forward
144
+ q.multiply(flipForwardQuaternion);
145
+ const space = this.xr.context.mainCamera?.parent;
146
+ if (!space) return q;
147
+ q.premultiply(getWorldQuaternion(space))
148
+ return q;
149
+ }
150
+ /** Controller ray position in worldspace */
151
+ get rayWorldPosition() {
152
+ const v = getTempVector(this._rayPosition);
153
+ const space = this.xr.context.mainCamera?.parent;
154
+ if (!space) return v;
155
+ return v.applyMatrix4(space.matrixWorld);
156
+ }
157
+ /** Controller ray rotation in wordspace */
158
+ get rayWorldQuaternion() {
159
+ const q = getTempQuaternion(this._rayQuaternion)
160
+ // flip forward because we want +Z to be forward
161
+ .multiply(flipForwardQuaternion);
162
+ const space = this.xr.context.mainCamera?.parent;
163
+ if (!space) return q;
164
+ q.premultiply(getWorldQuaternion(space))
165
+ return q;
166
+ }
167
+
168
+ /** The controller ray in worldspace */
169
+ get ray(): Ray {
170
+ this._ray.origin.copy(this.rayWorldPosition);
171
+ this._ray.direction.copy(getTempVector(0, 0, 1).applyQuaternion(this.rayWorldQuaternion));
172
+ return this._ray;
173
+ }
174
+ private readonly _ray;
175
+
176
+ /** The controller object space.
177
+ * You can use it to attach objects to the controller.
178
+ * Children will be automatically detached and put into the scene when the controller disconnects
179
+ */
180
+ get object() { return this._object; }
181
+ private readonly _object: Object3D;
182
+
183
+ private readonly _debugAxesHelper = new AxesHelper(.03);
184
+
185
+ /** returns the URL of the default controller model */
186
+ async getModelUrl(): Promise<string | null> {
187
+ return this.getMotionController?.then(res => res.assetUrl || null);
188
+ }
189
+
190
+ constructor(session: NeedleXRSession, device: XRInputSource, index: number) {
191
+ this.xr = session;
192
+ this.inputSource = device;
193
+ this.index = index;
194
+ this._object = new Object3D();
195
+ if (debug)
196
+ this._object.add(this._debugAxesHelper);
197
+ this.xr.context.scene.add(this._object);
198
+ this._ray = new Ray();
199
+ this.pointerInit = {
200
+ pointerId: -1, // < this will be updated in the emitPointerEvent method
201
+ mode: this.inputSource.targetRayMode,
202
+ ray: this._ray,
203
+ device: this._object,
204
+ buttonName: "none",
205
+ }
206
+ this.initialize();
207
+ this.subscribeEvents();
208
+
209
+ // TODO: change this to check if we have hit-testing enabled instead of pass through.
210
+ if (this.xr.mode === "immersive-ar" && this.inputSource.targetRayMode === "tracked-pointer") {
211
+ // request hittest source
212
+ this.xr.session.requestHitTestSourceForTransientInput?.({
213
+ profile: this.inputSource.profiles[0],
214
+ offsetRay: new XRRay(),
215
+ })?.then(hitTestSource => {
216
+ this._hitTestSource = hitTestSource;
217
+ });
218
+ }
219
+ }
220
+
221
+ onUpdate(frame: XRFrame) {
222
+ this.onUpdateFrame(frame);
223
+ this.updateInputEvents();
224
+ this.onUpdateMove();
225
+ }
226
+
227
+ onRenderDebug() {
228
+ Gizmos.DrawWireSphere(this.rayWorldPosition, .02);
229
+ Gizmos.DrawDirection(this.rayWorldPosition, getTempVector(0, 0, 10).applyQuaternion(this.rayWorldQuaternion));
230
+ }
231
+
232
+ private onUpdateFrame(frame: XRFrame) {
233
+ if (!this.xr.referenceSpace) {
234
+ this._isTracking = false;
235
+ return;
236
+ }
237
+
238
+ // TODO: we might actually want to apply the rotation here now already to avoid the matrix multiplications in the vector and quaternion getters since we now ALWAYS deal witht the rotated data (previously the camera was rotated before calling the update methods hence we needed other data etc but this has been changed in 99a8b96fe03676078e194f5504743576a19a9b1a and now the camera is rotated at the very end of the frame - or at least it should be - which also fixed the issue with selectstart controller events requiring other frame data etc)
239
+
240
+ const rayPose = frame.getPose(this.inputSource.targetRaySpace, this.xr.referenceSpace);
241
+ this._isTracking = rayPose != null;
242
+
243
+ if (rayPose) {
244
+ const t = rayPose.transform;
245
+ this._rayPosition.set(t.position.x, t.position.y, t.position.z);
246
+ this._rayQuaternion.set(t.orientation.x, t.orientation.y, t.orientation.z, t.orientation.w);
247
+ }
248
+
249
+ if (this.inputSource.gripSpace) {
250
+ const gripPose = frame.getPose(this.inputSource.gripSpace, this.xr.referenceSpace!);
251
+ if (gripPose) {
252
+ const t = gripPose.transform;
253
+ this._gripPosition.set(t.position.x, t.position.y, t.position.z);
254
+ this._gripQuaternion.set(t.orientation.x, t.orientation.y, t.orientation.z, t.orientation.w);
255
+ }
256
+ }
257
+
258
+ // update controller object position
259
+ if (this.xr.context.mainCamera?.parent && this._object.parent !== this.xr.context.mainCamera?.parent)
260
+ this.xr.context.mainCamera.parent.add(this._object);
261
+
262
+ // for controllers, we set the position and rotation of the object to the ray position and rotation
263
+ // for hands, we take the wrist position and rotation
264
+ const hand = this.hand;
265
+ if (hand) {
266
+ // https://www.w3.org/TR/webxr-hand-input-1/#xrhand-interface
267
+ let gotWrist = false;
268
+ // TODO check why types are not correct here
269
+ // @ts-ignore
270
+ const wrist = hand.get("wrist");
271
+ if (wrist && frame.getJointPose) {
272
+ const pose = frame.getJointPose(wrist, this.xr.referenceSpace);
273
+ if (pose) {
274
+ gotWrist = true;
275
+ const p = pose.transform.position;
276
+ const q = pose.transform.orientation;
277
+ this._object.position.set(p.x, p.y, p.z);
278
+ this._object.quaternion.set(q.x, q.y, q.z, q.w).multiply(flipForwardQuaternion);
279
+ }
280
+ }
281
+ if (!gotWrist) {
282
+ this._object.position.copy(this._rayPosition);
283
+ this._object.quaternion.copy(this._rayQuaternion).multiply(flipForwardQuaternion);
284
+ }
285
+
286
+ //@ts-ignore
287
+ const middle = hand.get("middle-finger-metacarpal");
288
+ if (middle && frame.getJointPose) {
289
+ const pose = frame.getJointPose(middle, this.xr.referenceSpace);
290
+ if (pose) {
291
+ const p = pose.transform.position;
292
+ const q = pose.transform.orientation;
293
+ // for some reason the grip rotation is different from the wrist rotation
294
+ // but we want to use the wrist rotation for the grip
295
+ this._gripPosition.set(p.x, p.y, p.z);
296
+ this._gripQuaternion.set(q.x, q.y, q.z, q.w);
297
+ }
298
+ }
299
+ }
300
+ else {
301
+ this._object.position.copy(this._rayPosition);
302
+ this._object.quaternion.copy(this._rayQuaternion).multiply(flipForwardQuaternion);
303
+ }
304
+ }
305
+
306
+ /** Called when the input source disconnects */
307
+ onDisconnected() {
308
+ if (this.connected) return;
309
+ // move all attached objects into the scene
310
+ for (const child of this._object.children) {
311
+ this.xr.context.scene.attach(child);
312
+ }
313
+ this._object.removeFromParent();
314
+ this._debugAxesHelper.removeFromParent();
315
+ this.unsubscribeEvents();
316
+ }
317
+
318
+ /**
319
+ * Get a gamepad button
320
+ * @link https://github.com/immersive-web/webxr-gamepads-module/blob/main/gamepads-module-explainer.md
321
+ * @param key the controller button name e.g. x-button
322
+ * @returns the gamepad button if it exists on the controller - otherwise undefined
323
+ */
324
+ getButton(key: ButtonName | "primary-button" | "primary"): GamepadButton | undefined {
325
+ if (!this._layout) return undefined;
326
+
327
+ switch (key) {
328
+ case "primary-button":
329
+ if (this.isLeft) key = "x-button";
330
+ else if (this.isRight) key = "a-button";
331
+ else return undefined;
332
+ break;
333
+ case "primary":
334
+ return this.inputSource.gamepad?.buttons[0];
335
+ }
336
+
337
+
338
+ if (this._buttonMap.has(key))
339
+ return this._buttonMap.get(key)!;
340
+ const componentModel = this._layout?.components[key];
341
+ if (componentModel?.gamepadIndices) {
342
+ switch (componentModel.type) {
343
+ case "button":
344
+ if (this.inputSource.gamepad) {
345
+ const index = componentModel.gamepadIndices!.button!;
346
+ const button = this.inputSource.gamepad?.buttons[index];
347
+ this._buttonMap.set(key, button);
348
+ return button;
349
+ }
350
+ break;
351
+ default:
352
+ console.warn("Unsupported component type", componentModel.type);
353
+ break;
354
+ }
355
+ }
356
+ this._buttonMap.set(key, undefined!);
357
+ return undefined;
358
+ }
359
+
360
+ /**
361
+ * Get the values of a controller joystick
362
+ * @link https://github.com/immersive-web/webxr-gamepads-module/blob/main/gamepads-module-explainer.md
363
+ * @returns the stick values where x is left/right, y is up/down and z is the button value
364
+ */
365
+ getStick(key: StickName | "primary"): Vec3 {
366
+ if (!this._layout) return { x: 0, y: 0, z: 0 };
367
+
368
+ if (key === "primary") {
369
+ const x = this.inputSource.gamepad?.axes[0] || 0;
370
+ const y = this.inputSource.gamepad?.axes[1] || 0;
371
+ // the primary thumbstick is button 3 (see gamepads module explainer)
372
+ const z = this.inputSource.gamepad?.buttons[3].value || 0;
373
+ return { x, y, z }
374
+ }
375
+
376
+ const componentModel = this._layout?.components[key];
377
+ if (componentModel?.gamepadIndices) {
378
+ switch (componentModel.type) {
379
+ case "thumbstick":
380
+ if (this.inputSource.gamepad) {
381
+ const xIndex = componentModel.gamepadIndices!.xAxis!;
382
+ const yIndex = componentModel.gamepadIndices!.yAxis!;
383
+ let x = this.inputSource.gamepad?.axes[xIndex];
384
+ let y = this.inputSource.gamepad?.axes[yIndex];
385
+ x *= -1;
386
+ y *= -1;
387
+ const buttonIndex = componentModel.gamepadIndices!.button!;
388
+ const z = this.inputSource.gamepad?.buttons[buttonIndex].value;
389
+ return { x, y, z }
390
+ }
391
+ }
392
+ }
393
+ return { x: 0, y: 0, z: 0 }
394
+ }
395
+
396
+
397
+ private readonly _buttonMap = new Map<ButtonName, GamepadButton>();
398
+
399
+ // the motion controller contains the controller scheme, we use this to simplify button access
400
+ private _motioncontroller?: MotionController;
401
+ private _layout: InputDeviceLayout | undefined;
402
+ private getMotionController!: Promise<MotionController>;
403
+ private initialize() {
404
+ if (!this._layout) {
405
+ // TODO: we should fetch the profiles or better yet the profile list once and cache it
406
+ const fetchProfileCall = fetchProfile(this.inputSource, DEFAULT_PROFILES_PATH, DEFAULT_PROFILE);
407
+ /** @ts-ignore */
408
+ this.getMotionController = fetchProfileCall.then(res => {
409
+
410
+ if (!this.connected) return null;
411
+
412
+ this._motioncontroller = new MotionController(
413
+ this.inputSource,
414
+ res.profile,
415
+ res.assetPath || ""
416
+ );
417
+
418
+ const profile = res.profile as InputDeviceProfile;
419
+ const layout = profile.layouts[this.inputSource.handedness];
420
+ this._layout = layout;
421
+ if (this._layout) {
422
+ if (!this._layout.gamepad?.length) {
423
+ this._layout.gamepad = [];
424
+ for (const key in this._layout.components) {
425
+ const component = this._layout.components[key];
426
+ this._layout.gamepad[component.gamepadIndices!.button!] = key as XRControllerButtonName;
427
+ }
428
+ }
429
+ }
430
+ if (debug) console.log(this._layout, this.inputSource);
431
+ // debugger;
432
+ // this.getButton("a-button")
433
+ return this._motioncontroller;
434
+ }).catch(err => {
435
+ console.error(err);
436
+ });
437
+ }
438
+ }
439
+
440
+ private subscribeEvents() {
441
+ // https://developer.mozilla.org/en-US/docs/Web/API/XRSession/selectstart_event
442
+ this.xr.session.addEventListener("selectstart", this.onSelectStart);
443
+ this.xr.session.addEventListener("selectend", this.onSelectEnd);
444
+ // https://developer.mozilla.org/en-US/docs/Web/API/XRSession/squeeze_event
445
+ this.xr.session.addEventListener("squeezestart", this.onSequeezeStart);
446
+ this.xr.session.addEventListener("squeezeend", this.onSequeezeEnd);
447
+ }
448
+ private unsubscribeEvents() {
449
+ this.xr.session.removeEventListener("selectstart", this.onSelectStart);
450
+ this.xr.session.removeEventListener("selectend", this.onSelectEnd);
451
+ this.xr.session.removeEventListener("squeezestart", this.onSequeezeStart);
452
+ this.xr.session.removeEventListener("squeezeend", this.onSequeezeEnd);
453
+ }
454
+
455
+ private _selectButtonIndex: number | undefined = undefined;
456
+ private _squeezeButtonIndex: number | undefined = undefined;
457
+
458
+ private onSelectStart = (evt: XRInputSourceEvent) => {
459
+ if (this.inputSource !== evt.inputSource) return;
460
+ const selectComponentId = this._layout?.selectComponentId;
461
+ const i = this._layout?.components[selectComponentId!]?.gamepadIndices?.button;
462
+ if (i !== undefined) this._selectButtonIndex = i;
463
+ if (debug) Gizmos.DrawDirection(this.rayWorldPosition, getTempVector(0, .01, 1).applyQuaternion(this.rayWorldQuaternion), 0xff0000, 10);
464
+ this.emitPointerEvent(InputEvents.PointerDown, this._selectButtonIndex || 0, "xr-standard-trigger", true, evt);
465
+ }
466
+ private onSelectEnd = (evt: XRInputSourceEvent) => {
467
+ if (this.inputSource !== evt.inputSource) return;
468
+ this.emitPointerEvent(InputEvents.PointerUp, this._selectButtonIndex || 0, "xr-standard-trigger", true, evt);
469
+ }
470
+ private onSequeezeStart = (evt: XRInputSourceEvent) => {
471
+ if (this.inputSource !== evt.inputSource) return;
472
+ this._squeezeButtonIndex = this._layout?.components["xr-standard-squeeze"]?.gamepadIndices?.button;
473
+ if (this._squeezeButtonIndex !== undefined) {
474
+ if (debug) Gizmos.DrawDirection(this.rayWorldPosition, getTempVector(0, .01, 1).applyQuaternion(this.rayWorldQuaternion), 0x0000ff, 10);
475
+ this.emitPointerEvent(InputEvents.PointerDown, this._squeezeButtonIndex || 0, "xr-standard-squeeze", true, evt);
476
+ }
477
+ };
478
+ private onSequeezeEnd = (evt: XRInputSourceEvent) => {
479
+ if (this.inputSource !== evt.inputSource) return;
480
+ if (this._squeezeButtonIndex !== undefined)
481
+ this.emitPointerEvent(InputEvents.PointerUp, this._squeezeButtonIndex || 0, "xr-standard-squeeze", true, evt);
482
+ };
483
+
484
+ /** Index = button index */
485
+ private readonly states = new Array<InputState>();
486
+ // If we want to invoke button events for ALL buttons we need to keep track of the previous state
487
+ // instead of using XR input select start events which is only raised for the primary button
488
+ // we should probably do both but then we need to ignore the primary index in the following function (to not raise an event for the same button twice)
489
+ // and start with index = 1
490
+ private updateInputEvents() {
491
+ if (!this._layout) return;
492
+ // index 0 is reserved for the primary button
493
+ let index = 1;
494
+
495
+ // https://immersive-web.github.io/webxr-gamepads-module/#xr-standard-heading
496
+ if (this.gamepad?.buttons) {
497
+ for (let k = 0; k < this.gamepad.buttons.length; k++) {
498
+ // the selection event is handled in the "selectstart" callback
499
+ if (this._selectButtonIndex === k) continue;
500
+
501
+ const button = this.gamepad.buttons[k];
502
+ const i = index++;
503
+ const state = this.states[index] || new InputState();
504
+ let inputEvent: InputEvents | null = null;
505
+
506
+ // is down
507
+ if (button.pressed && !state.pressed) {
508
+ inputEvent = InputEvents.PointerDown;
509
+ }
510
+ // is up
511
+ else if (!button.pressed && state.pressed) {
512
+ inputEvent = InputEvents.PointerUp;
513
+ }
514
+
515
+ state.value = button.value;
516
+ state.pressed = button.pressed;
517
+ this.states[index] = state;
518
+
519
+ if (inputEvent != null) {
520
+ const name = this._layout?.gamepad[k];
521
+ this.emitPointerEvent(inputEvent, i, name ?? "none", false);
522
+ }
523
+ }
524
+ }
525
+ }
526
+ private onUpdateMove() {
527
+ this.emitPointerEvent(InputEvents.PointerMove, 0, "none", false);
528
+ }
529
+
530
+
531
+ /** cached spatial pointer init object. We re-use it to not have */
532
+ private readonly pointerInit: NEPointerEventInit;
533
+ private emitPointerEvent(type: InputEvents, button: number, buttonName: ButtonName | "none", primary: boolean, source: Event | null = null) {
534
+ // Currently we do only want to emit pointer events for NON screen based events
535
+ // that means if the input device is spatial (AR touch on a screen should be handled via touchdown etc still)
536
+ // Not sure if *this* is enough to determine if the event is spatial or not
537
+ if (this.xr.mode === "immersive-vr" || this.xr.isPassThrough) {
538
+ this.pointerInit.pointerId = this.index * 10 + button;
539
+ this.pointerInit.button = button;
540
+ this.pointerInit.buttonName = buttonName;
541
+ this.pointerInit.isPrimary = primary;
542
+ this.pointerInit.mode = this.inputSource.targetRayMode;
543
+ this.pointerInit.ray = this.ray;
544
+ this.pointerInit.device = this.object;
545
+ this.pointerInit.pointerType = this.hand ? PointerType.Hand : PointerType.Controller;
546
+
547
+ const prevContext = Context.Current;
548
+ Context.Current = this.xr.context;
549
+ this.xr.context.input.createInputEvent(new NEPointerEvent(type, source, this.pointerInit));
550
+ Context.Current = prevContext;
551
+ }
552
+ }
553
+ }
554
+
555
+ class InputState {
556
+ pressed: boolean = false;
557
+ value: number = 0;
558
+ }
src/engine/xr/NeedleXRSession.ts ADDED
@@ -0,0 +1,1168 @@
1
+ import { AxesHelper, Matrix4, Object3D, PerspectiveCamera, Quaternion, Ray, Vector3, WebXRArrayCamera } from "three";
2
+ import { Context, FrameEvent } from "../engine_context.js";
3
+ import { ContextEvent, ContextRegistry } from "../engine_context_registry.js";
4
+ import { Gizmos } from "../engine_gizmos.js";
5
+ import { getTempVector, getTempQuaternion, getWorldPosition, getWorldQuaternion, getWorldScale, setWorldPosition, setWorldQuaternion, setWorldScale } from "../engine_three_utils.js";
6
+ import { NeedleXRController } from "./NeedleXRController.js";
7
+ import type { IXRRig } from "./XRRig.js";
8
+ import { ImplictXRRig, flipForwardMatrix, flipForwardQuaternion } from "./internal.js";
9
+ import { delay, getParam, isDesktop, isQuest } from "../engine_utils.js";
10
+ import { ICamera, IComponent } from "../engine_types.js";
11
+ import { NeedleXRSync } from "./NeedleXRSync.js";
12
+ import { isDevEnvironment, showBalloonMessage, showBalloonWarning } from "../debug/index.js";
13
+ import { registerFrameEventCallback, unregisterFrameEventCallback } from "../engine_lifecycle_functions_internal.js";
14
+ import { isDestroyed } from "../engine_gameobject.js";
15
+ import { TemporaryXRContext } from "./TempXRContext.js";
16
+
17
+ export type NeedleXREventArgs = { xr: NeedleXRSession }
18
+ export type SessionChangedEvt = (args: NeedleXREventArgs) => void;
19
+ export type SessionRequestedEvent = (args: { mode: XRSessionMode, init: XRSessionInit }) => void;
20
+ export type SessionRequestedEndEvent = (args: { mode: XRSessionMode, init: XRSessionInit, newSession: XRSession | null }) => void;
21
+ export type NeedleXRHitTestResult = { hit: XRHitTestResult, position: Vector3, quaternion: Quaternion };
22
+
23
+ const debug = getParam("debugwebxr");
24
+
25
+ // TODO: move this into the IComponent interface!?
26
+ export interface INeedleXRSessionEventReceiver extends Pick<IComponent, "destroyed"> {
27
+ get activeAndEnabled(): boolean;
28
+ supportsXR?(mode: XRSessionMode): boolean;
29
+ /** Called before requesting a XR session */
30
+ onBeforeXR?(mode: XRSessionMode, args: XRSessionInit): void;
31
+ onEnterXR?(args: NeedleXREventArgs): void;
32
+ onUpdateXR?(args: NeedleXREventArgs): void;
33
+ onLeaveXR?(args: NeedleXREventArgs): void;
34
+ onXRControllerAdded?(args: NeedleXRControllerEventArgs): void;
35
+ onXRControllerRemoved?(args: NeedleXRControllerEventArgs): void;
36
+ }
37
+
38
+ /** Contains a reference to the currently active webxr session and the controller that has changed */
39
+ export type NeedleXRControllerEventArgs = NeedleXREventArgs & { controller: NeedleXRController, change: "added" | "removed" }
40
+ /** Event Arguments when a controller changed event is invoked (added or removed) */
41
+ export type ControllerChangedEvt = (args: NeedleXRControllerEventArgs) => void;
42
+
43
+
44
+
45
+ function getDOMOverlayElement(domElement: HTMLElement) {
46
+ let arOverlayElement: HTMLElement | null = null;
47
+ // for react cases we dont have an Engine Element
48
+ const element: any = domElement;
49
+ if (element.getAROverlayContainer)
50
+ arOverlayElement = element.getAROverlayContainer();
51
+ else arOverlayElement = domElement;
52
+ return arOverlayElement;
53
+ }
54
+
55
+
56
+
57
+ registerSessionGranted();
58
+ function registerSessionGranted() {
59
+ if ('xr' in navigator) {
60
+ // WebXRViewer (based on Firefox) has a bug where addEventListener
61
+ // throws a silent exception and aborts execution entirely.
62
+ if (/WebXRViewer\//i.test(navigator.userAgent)) {
63
+ console.warn('WebXRViewer does not support addEventListener');
64
+ return;
65
+ }
66
+
67
+ navigator.xr?.addEventListener('sessiongranted', () => {
68
+ console.log("Received Session Granted...")
69
+ const lastSessionMode = sessionStorage.getItem("needle_xr_session_mode");
70
+ const lastSessionInit = sessionStorage.getItem("needle_xr_session_init");
71
+ if (lastSessionMode && lastSessionInit) {
72
+ console.log("Session Granted: Restore last session")
73
+ const init = JSON.parse(lastSessionInit);
74
+ NeedleXRSession.start(lastSessionMode as XRSessionMode, init).catch(e => console.warn(e));
75
+ }
76
+ else {
77
+ // if no session was found we start VR by default
78
+ NeedleXRSession.start("immersive-vr").catch(e => console.warn("Session Granted failed:", e));
79
+ }
80
+ });
81
+ }
82
+ }
83
+ function saveSessionInfo(mode: XRSessionMode, init: XRSessionInit) {
84
+ sessionStorage.setItem("needle_xr_session_mode", mode);
85
+ sessionStorage.setItem("needle_xr_session_init", JSON.stringify(init));
86
+ }
87
+
88
+ function deleteSessionInfo() {
89
+ sessionStorage.removeItem("needle_xr_session_mode");
90
+ sessionStorage.removeItem("needle_xr_session_init");
91
+ }
92
+
93
+ if (isDesktop() && isDevEnvironment()) {
94
+ window.addEventListener("keydown", (evt) => {
95
+ if (evt.key === "x") {
96
+ if (NeedleXRSession.active) {
97
+ NeedleXRSession.stop();
98
+ }
99
+ }
100
+ });
101
+ }
102
+
103
+ if (getParam("simulatewebxrloading")) {
104
+ ContextRegistry.registerCallback(ContextEvent.ContextCreationStart, async _cb => {
105
+ await delay(3000);
106
+ setTimeout(async () => {
107
+ const info = await TemporaryXRContext.handoff();
108
+ if (info) NeedleXRSession.setSession(info.mode, info.session, info.init, Context.Current);
109
+ else
110
+ NeedleXRSession.start("immersive-vr")
111
+ }, 6000)
112
+ });
113
+ let triggered = false;
114
+ window.addEventListener("click", () => {
115
+ if (triggered) return;
116
+ triggered = true;
117
+ TemporaryXRContext.start("immersive-vr", NeedleXRSession.getDefaultSessionInit("immersive-vr"));
118
+ });
119
+ }
120
+
121
+ /**
122
+ * This class manages an XRSession to provide helper methods and events
123
+ * It provides easy access to the XRInputSources (controllers and hands)
124
+ * If a XRSession is active you can use all XR-related event methods on your components to receive XR events
125
+ * - Start a XRSession with `NeedleXRSession.start(...)`
126
+ * - Stop a XRSession with `NeedleXRSession.stop()`
127
+ * - Access running XRSession with `NeedleXRSession.active`
128
+ * - Listen to XRSession start events with `NeedleXRSession.onXRStart(...)`
129
+ * - Listen to XRSession end events with `NeedleXRSession.onXREnd(...)`
130
+ * - Listen to XRSession controller added events with `NeedleXRSession.onControllerAdded(...)`
131
+ * - Listen to XRSession controller removed events with `NeedleXRSession.onControllerRemoved(...)`
132
+ *
133
+ */
134
+ export class NeedleXRSession {
135
+
136
+ private static _sync: NeedleXRSync | null = null;
137
+ static getXRSync(context: Context) {
138
+ if (!this._sync) this._sync = new NeedleXRSync(context);
139
+ return this._sync;
140
+ }
141
+
142
+ static get currentSessionRequest(): XRSessionMode | null { return this._currentSessionRequestMode; }
143
+ private static _currentSessionRequestMode: XRSessionMode | null = null;
144
+
145
+ static get active(): NeedleXRSession | null { return this._activeSession; }
146
+ /** The active xr session mode (if any xr session is active) */
147
+ static get activeMode() { return this._activeSession?.mode ?? null; }
148
+ /** XRSystem via navigator.xr access
149
+ * @link https://developer.mozilla.org/en-US/docs/Web/API/XRSystem
150
+ */
151
+ static get xrSystem(): XRSystem | undefined {
152
+ return ('xr' in navigator) ? navigator.xr : undefined;
153
+ }
154
+ static isXRSupported() { return Promise.all([this.isVRSupported(), this.isARSupported()]).then(res => res.some(e => e)) }
155
+ static isVRSupported() { return this.xrSystem?.isSessionSupported('immersive-vr') ?? Promise.resolve(false); }
156
+ static isARSupported() { return this.xrSystem?.isSessionSupported('immersive-ar') ?? Promise.resolve(false); }
157
+
158
+ private static _currentSessionRequest?: Promise<XRSession>;
159
+ private static _activeSession: NeedleXRSession | null;
160
+
161
+ static onSessionRequestStart(evt: SessionRequestedEvent) {
162
+ this._sessionRequestStartListeners.push(evt);
163
+ }
164
+ static offSessionRequestStart(evt: SessionRequestedEvent) {
165
+ const index = this._sessionRequestStartListeners.indexOf(evt);
166
+ if (index >= 0) this._sessionRequestStartListeners.splice(index, 1);
167
+ }
168
+ private static readonly _sessionRequestStartListeners: SessionRequestedEvent[] = [];
169
+
170
+ /** Called after the session request has finished */
171
+ static onSessionRequestEnd(evt: SessionRequestedEndEvent) {
172
+ this._sessionRequestEndListeners.push(evt);
173
+ }
174
+ /** Unsubscribe from request end evt */
175
+ static offSessionRequestEnd(evt: SessionRequestedEndEvent) {
176
+ const index = this._sessionRequestEndListeners.indexOf(evt);
177
+ if (index >= 0) this._sessionRequestEndListeners.splice(index, 1);
178
+ }
179
+ private static readonly _sessionRequestEndListeners: SessionRequestedEndEvent[] = [];
180
+
181
+ /** Listen to XR session started */
182
+ static onXRStart(evt: SessionChangedEvt) {
183
+ this._xrStartListeners.push(evt);
184
+ };
185
+ /** Unsubscribe from XRSession started events */
186
+ static offXRStart(evt: SessionChangedEvt) {
187
+ const index = this._xrStartListeners.indexOf(evt);
188
+ if (index >= 0) this._xrStartListeners.splice(index, 1);
189
+ }
190
+ private static readonly _xrStartListeners: SessionChangedEvt[] = [];
191
+
192
+ /** Listen to controller added events.
193
+ * Events are cleared when starting a new session
194
+ **/
195
+ static onControllerAdded(evt: ControllerChangedEvt) {
196
+ this._controllerAddedListeners.push(evt);
197
+ }
198
+ /** Unsubscribe from controller added evts */
199
+ static offControllerAdded(evt: ControllerChangedEvt) {
200
+ const index = this._controllerAddedListeners.indexOf(evt);
201
+ if (index >= 0) this._controllerAddedListeners.splice(index, 1);
202
+ }
203
+ private static readonly _controllerAddedListeners: ControllerChangedEvt[] = [];
204
+
205
+ /** Listen to controller removed events
206
+ * Events are cleared when starting a new session
207
+ **/
208
+ static onControllerRemoved(evt: ControllerChangedEvt) {
209
+ this._controllerRemovedListeners.push(evt);
210
+ }
211
+ /** Unsubscribe from controller removed events */
212
+ static offControllerRemoved(evt: ControllerChangedEvt) {
213
+ const index = this._controllerRemovedListeners.indexOf(evt);
214
+ if (index >= 0) this._controllerRemovedListeners.splice(index, 1);
215
+ }
216
+ private static readonly _controllerRemovedListeners: ControllerChangedEvt[] = [];
217
+
218
+ /** If the browser supports offerSession - creating a VR or AR button in the browser navigation bar */
219
+ static offerSession(mode: XRSessionMode, init: XRSessionInit | "default", context: Context): boolean {
220
+ if ('xr' in navigator && navigator.xr && 'offerSession' in navigator.xr) {
221
+ if (typeof navigator.xr.offerSession === "function") {
222
+ console.log("WebXR offerSession is available - requesting mode: " + mode);
223
+ if (init == "default") {
224
+ init = this.getDefaultSessionInit(mode);
225
+ }
226
+ navigator.xr.offerSession(mode, {
227
+ ...init
228
+ }).then((session) => {
229
+ NeedleXRSession.setSession(mode, session, init as XRSessionInit, context);
230
+ }).catch(_ => {
231
+ console.log("XRSession offer rejected (perhaps because another call to offerSession was made or a call to requestSession was made)")
232
+ });
233
+ }
234
+ return true;
235
+ }
236
+ return false;
237
+ }
238
+
239
+ /** @returns a new XRSession init object with defaults */
240
+ static getDefaultSessionInit(mode: Omit<XRSessionMode, "inline">): XRSessionInit {
241
+ switch (mode) {
242
+ case "immersive-ar":
243
+ return {
244
+ optionalFeatures: ['anchors', 'local-floor', 'hand-tracking', 'layers', 'dom-overlay', 'hit-test'],
245
+ }
246
+ case "immersive-vr":
247
+ return {
248
+ optionalFeatures: ['local-floor', 'bounded-floor', 'hand-tracking', 'high-fixed-foveation-level', 'layers'],
249
+ }
250
+ default:
251
+ console.warn("No default session init for mode", mode);
252
+ return {};
253
+ }
254
+ }
255
+
256
+ /** start a new webXR session (make sure to stop already running sessions before calling this method) */
257
+ static async start(mode: XRSessionMode, init?: XRSessionInit, context?: Context): Promise<NeedleXRSession | null> {
258
+
259
+ if (this._currentSessionRequest) {
260
+ console.warn("A XRSession is already being requested");
261
+ if (debug || isDevEnvironment()) showBalloonWarning("A XRSession is already being requested");
262
+ return this._currentSessionRequest.then(() => this._activeSession!);
263
+ }
264
+
265
+ if (this._activeSession) {
266
+ console.error("A XRSession is already running");
267
+ return this._activeSession;
268
+ }
269
+
270
+ // Make sure we have a context
271
+ if (!context) context = Context.Current;
272
+ if (!context) context = ContextRegistry.All[0] as Context;
273
+ if (!context) throw new Error("No Needle Engine Context found");
274
+
275
+ // setup session init args, make sure we have default values
276
+ if (!init) init = {};
277
+ switch (mode) {
278
+
279
+ // Setup VR initialization parameters
280
+ case "immersive-ar":
281
+ {
282
+ const supported = await this.xrSystem?.isSessionSupported('immersive-ar')
283
+ if (supported !== true) {
284
+ console.error(mode + ' is not supported by this browser.');
285
+ return null;
286
+ }
287
+ const defaultInit: XRSessionInit = this.getDefaultSessionInit(mode);
288
+ const domOverlayElement = getDOMOverlayElement(context.domElement);
289
+ if (domOverlayElement && !isQuest()) { // excluding quest since dom-overlay breaks sessiongranted
290
+ defaultInit.domOverlay = { root: domOverlayElement };
291
+ defaultInit.optionalFeatures!.push('dom-overlay');
292
+ }
293
+ init = {
294
+ ...defaultInit,
295
+ ...init,
296
+ }
297
+ }
298
+ break;
299
+
300
+ // Setup AR initialization parameters
301
+ case "immersive-vr":
302
+ {
303
+ const supported = await this.xrSystem?.isSessionSupported('immersive-vr')
304
+ if (supported !== true) {
305
+ console.error(mode + ' is not supported by this browser.');
306
+ return null;
307
+ }
308
+ const defaultInit: XRSessionInit = this.getDefaultSessionInit(mode);
309
+ init = {
310
+ ...defaultInit,
311
+ ...init,
312
+ }
313
+ }
314
+ break;
315
+
316
+ default:
317
+ console.warn("No default session init for mode", mode);
318
+ break;
319
+ }
320
+
321
+ // we stop a temporary session here (if any runs)
322
+ await TemporaryXRContext.stop();
323
+
324
+ const scripts = mode == "immersive-ar" ? context.scripts_immersive_ar : context.scripts_immersive_vr
325
+
326
+ console.log("%c" + `Requesting ${mode} session`, "font-weight:bold;", init, scripts);
327
+ for (const script of scripts) {
328
+ if (script.onBeforeXR) script.onBeforeXR(mode, init);
329
+ }
330
+ for (const listener of this._sessionRequestStartListeners) {
331
+ listener({ mode, init });
332
+ }
333
+ if (debug) showBalloonMessage("Requesting " + mode + " session (" + Date.now() + ")");
334
+ this._currentSessionRequest = navigator.xr?.requestSession(mode, init);
335
+ this._currentSessionRequestMode = mode;
336
+ /**@type {XRSystem} */
337
+ const newSession = await (this._currentSessionRequest)?.catch(e => {
338
+ console.error(e, "Code: " + e.code);
339
+ if (e.code === 9) showBalloonWarning("Make sure your device has the required permissions (e.g. camera access)")
340
+ console.log("If the specified XR configuration is not supported (e.g. entering AR doesnt work) - make sure you access the website on a secure connection (HTTPS) and your device has the required permissions (e.g. camera access)");
341
+ const notSecure = location.protocol === 'http:';
342
+ if (notSecure) showBalloonWarning("XR requires a secure connection (HTTPS)");
343
+ });
344
+ this._currentSessionRequest = undefined;
345
+ this._currentSessionRequestMode = null;
346
+ for (const listener of this._sessionRequestEndListeners) {
347
+ listener({ mode, init, newSession: newSession || null });
348
+ }
349
+ if (!newSession) {
350
+ return null;
351
+ }
352
+ return this.setSession(mode, newSession, init, context);
353
+ }
354
+
355
+ static setSession(mode: XRSessionMode, session: XRSession, init: XRSessionInit, context: Context) {
356
+ if (this._activeSession) {
357
+ console.error("A XRSession is already running");
358
+ return this._activeSession;
359
+ }
360
+ const scripts = mode == "immersive-ar" ? context.scripts_immersive_ar : context.scripts_immersive_vr;
361
+ this._activeSession = new NeedleXRSession(mode, session, context, {
362
+ scripts: scripts,
363
+ controller_added: this._controllerAddedListeners,
364
+ controller_removed: this._controllerRemovedListeners,
365
+ init: init
366
+ });
367
+ session.addEventListener("end", this.onEnd)
368
+ console.log("%c" + `Started ${mode} session`, "font-weight:bold;");
369
+ return this._activeSession;
370
+ }
371
+ /** stops the active XR session */
372
+ static stop() {
373
+ this._activeSession?.end();
374
+ }
375
+ private static onEnd = () => {
376
+ if (debug) console.log("XR Session ended");
377
+ this._activeSession = null;
378
+ }
379
+
380
+
381
+ /** The needle engine context this session was started from */
382
+ readonly context: Context;
383
+
384
+ get sync(): NeedleXRSync | null {
385
+ return NeedleXRSession._sync;
386
+ }
387
+
388
+ /** Returns true if the xr session is still active */
389
+ get running() { return !this._ended && this.session; }
390
+
391
+ /**
392
+ * @link https://developer.mozilla.org/en-US/docs/Web/API/XRSession
393
+ */
394
+ readonly session: XRSession;
395
+
396
+ /** XR Session Mode: AR or VR */
397
+ readonly mode: XRSessionMode;
398
+
399
+ /**
400
+ * The XRSession interface's read-only interactionMode property describes the best space (according to the user agent) for the application to draw an interactive UI for the current session.
401
+ * @link https://developer.mozilla.org/en-US/docs/Web/API/XRSession/interactionMode
402
+ */
403
+ get interactionMode(): "screen-space" | "world-space" { return this.session["interactionMode"]; }
404
+
405
+ /**
406
+ * @link https://developer.mozilla.org/en-US/docs/Web/API/XRSession/visibilityState
407
+ */
408
+ get visibilityState() { return this.session.visibilityState; }
409
+
410
+ /**
411
+ * @link https://developer.mozilla.org/en-US/docs/Web/API/XRSession/environmentBlendMode
412
+ */
413
+ get environmentBlendMode() { return this.session.environmentBlendMode; }
414
+
415
+ /**
416
+ * The current XR frame
417
+ * @link https://developer.mozilla.org/en-US/docs/Web/API/XRFrame
418
+ */
419
+ get frame(): XRFrame { return this.context.xrFrame!; }
420
+
421
+ /** The currently active/connected controllers */
422
+ readonly controllers: NeedleXRController[] = [];
423
+ /** shorthand to query the left controller. Use `controllers` to get access to all connected controllers */
424
+ get leftController() { return this.controllers.find(c => c.isLeft); }
425
+ /** shorthand to query the right controller. Use `controllers` to get access to all connected controllers */
426
+ get rightController() { return this.controllers.find(c => c.isRight); }
427
+ /** @returns the given controller if it is connected */
428
+ getController(side: XRHandedness) { return this.controllers.find(c => c.side === side) || null; }
429
+
430
+ /** Returns true if running in pass through mode in immersive AR */
431
+ get isPassThrough() {
432
+ if (this.environmentBlendMode !== 'opaque' && this.interactionMode === "world-space") return true;
433
+ // since we can not rely on interactionMode check we check the controllers too
434
+ // https://linear.app/needle/issue/NE-4057
435
+ // the following is a workaround for the issue above
436
+ if (this.mode === "immersive-ar" && this.environmentBlendMode !== 'opaque') {
437
+ // if we have any tracked pointer controllers we're also in passthrough
438
+ if (this.controllers.some(c => c.inputSource.targetRayMode === "tracked-pointer"))
439
+ return true;
440
+ }
441
+ return false;
442
+ }
443
+ get isAR() { return this.mode === 'immersive-ar'; }
444
+ get isVR() { return this.mode === 'immersive-vr'; }
445
+
446
+ get posePosition() { return this._transformPosition; }
447
+ get poseOrientation() { return this._transformOrientation; }
448
+ /** @returns the context.renderer.xr.getReferenceSpace() result */
449
+ get referenceSpace(): XRSpace | null { return this.context.renderer.xr.getReferenceSpace(); }
450
+ /** @returns the XRFrame `viewerpose` using the xr `referenceSpace` */
451
+ get viewerPose(): XRViewerPose | undefined { return this._viewerPose; }
452
+
453
+
454
+ /** @returns `true` if any image is currently being tracked */
455
+ /** returns true if images are currently being tracked */
456
+ get isTrackingImages() {
457
+ if (this.frame && "getImageTrackingResults" in this.frame && typeof this.frame.getImageTrackingResults === "function") {
458
+ try {
459
+ const trackingResult = this.frame.getImageTrackingResults();
460
+ for (const result of trackingResult) {
461
+ const state = result.trackingState;
462
+ if (state === "tracked") return true;
463
+ }
464
+ }
465
+ catch {
466
+ // Looks like we get a NotSupportedException on Android since the method is known
467
+ // but the feature is not supported by the session
468
+ // TODO Can we check here if we even requested the image-tracking feature instead of catching?
469
+ return false;
470
+ }
471
+ }
472
+ return false;
473
+ }
474
+
475
+
476
+ /** The currently active XR rig */
477
+ get rig(): IXRRig | null {
478
+ const rig = this._rigs[0] ?? null;
479
+ if (rig?.gameObject && isDestroyed(rig.gameObject) || rig?.isActive === false) {
480
+ this.updateActiveXRRig();
481
+ return this._rigs[0] ?? null;
482
+ }
483
+ return rig;
484
+ }
485
+ private _rigScale: number = 1;
486
+ private _lastRigScaleUpdate: number = -1;
487
+ /** get the XR rig worldscale */
488
+ get rigScale() {
489
+ if (!this._rigs[0]) return 1;
490
+ if (this._lastRigScaleUpdate !== this.context.time.frame) {
491
+ this._lastRigScaleUpdate = this.context.time.frame;
492
+ this._rigScale = this._rigs[0].gameObject.worldScale.x;
493
+ }
494
+ return this._rigScale;
495
+ }
496
+ /** add a rig to the available XR rigs - if it's priority is higher than the currently active rig it will be enabled */
497
+ addRig(rig: IXRRig) {
498
+ const i = this._rigs.indexOf(rig);
499
+ if (i >= 0) return;
500
+ if (rig.priority === undefined) rig.priority = 0;
501
+ this._rigs.push(rig);
502
+ this.updateActiveXRRig();
503
+ }
504
+ /** Remove a rig from the available XR Rigs */
505
+ removeRig(rig: IXRRig) {
506
+ const i = this._rigs.indexOf(rig);
507
+ if (i === -1) return;
508
+ this._rigs.splice(i, 1);
509
+ this.updateActiveXRRig();
510
+ }
511
+ /** Sets a XRRig to be active which will parent the camera to this rig */
512
+ setRigActive(rig: IXRRig) {
513
+ const i = this._rigs.indexOf(rig);
514
+ this._rigs.splice(i, 1);
515
+ this._rigs.unshift(rig);
516
+ this.updateActiveXRRig();
517
+ }
518
+ private updateActiveXRRig() {
519
+ const previouslyActiveRig = this._rigs[0] ?? null;
520
+
521
+ // ensure that the default rig is in the scene
522
+ if (this._defaultRig.gameObject.parent !== this.context.scene)
523
+ this.context.scene.add(this._defaultRig.gameObject);
524
+ // ensure the fallback rig is always active!!!
525
+ this._defaultRig.gameObject.visible = true;
526
+ // ensure that the default rig is in the list of available rigs
527
+ if (!this._rigs.includes(this._defaultRig))
528
+ this._rigs.push(this._defaultRig);
529
+
530
+ // find the rig with the highest priority and make sure it's at the beginning of the array
531
+ let highestPriorityRig: IXRRig = this._rigs[0];
532
+ if (highestPriorityRig && highestPriorityRig.priority === undefined) highestPriorityRig.priority = 0;
533
+
534
+ for (let i = 1; i < this._rigs.length; i++) {
535
+ const rig = this._rigs[i];
536
+ if (!rig.isActive) continue;
537
+ if (isDestroyed(rig.gameObject)) {
538
+ this._rigs.splice(i, 1);
539
+ i--;
540
+ continue;
541
+ }
542
+ if (!highestPriorityRig || highestPriorityRig.isActive === false || (rig.priority !== undefined && rig.priority > highestPriorityRig.priority!)) {
543
+ highestPriorityRig = rig;
544
+ }
545
+ }
546
+
547
+ // make sure the highest priority rig is at the beginning if it isnt already
548
+ if (previouslyActiveRig !== highestPriorityRig) {
549
+ const index = this._rigs.indexOf(highestPriorityRig);
550
+ if (index >= 0) this._rigs.splice(index, 1);
551
+ this._rigs.unshift(highestPriorityRig);
552
+ }
553
+
554
+ if (debug) {
555
+ if (previouslyActiveRig === highestPriorityRig)
556
+ console.log("Updated Active XR Rig:", highestPriorityRig, "prev:", previouslyActiveRig);
557
+ else console.log("Updated Active XRRig:", highestPriorityRig, " (the same as before)");
558
+ }
559
+ }
560
+ private _rigs: IXRRig[] = [];
561
+
562
+
563
+
564
+ private _viewerHitTestSource: XRHitTestSource | null = null;
565
+
566
+ /** Returns a XR hit test result (if hit-testing is available) in rig space
567
+ * @param source If provided, the hit test will be performed for the given controller
568
+ */
569
+ getHitTest(source?: NeedleXRController): NeedleXRHitTestResult | null {
570
+ if (source) {
571
+ return this.getControllerHitTest(source);
572
+ }
573
+
574
+ if (!this._viewerHitTestSource) return null;
575
+ const hitTestSource = this._viewerHitTestSource;
576
+ const hitTestResults = this.frame.getHitTestResults(hitTestSource);
577
+ if (hitTestResults.length > 0) {
578
+ const hit = hitTestResults[0];
579
+ return this.convertHitTestResult(hit);
580
+ }
581
+ return null;
582
+ }
583
+ private getControllerHitTest(controller: NeedleXRController): NeedleXRHitTestResult | null {
584
+ const hitTestSource = controller.hitTestSource;
585
+ if (!hitTestSource) return null;
586
+ const res = this.frame.getHitTestResultsForTransientInput(hitTestSource);
587
+ for (const result of res) {
588
+ if (result.inputSource === controller.inputSource) {
589
+ for (const hit of result.results) {
590
+ return this.convertHitTestResult(hit);
591
+ }
592
+ }
593
+ }
594
+ return null;
595
+ }
596
+ private convertHitTestResult(result: XRHitTestResult): NeedleXRHitTestResult | null {
597
+ const referenceSpace = this.context.renderer.xr.getReferenceSpace();
598
+ const pose = referenceSpace && result.getPose(referenceSpace);
599
+ if (pose) {
600
+ const pos = getTempVector(pose.transform.position);
601
+ const rot = getTempQuaternion(pose.transform.orientation);
602
+ const camera = this.context.mainCamera;
603
+ if (camera?.parent !== this._cameraRenderParent) {
604
+ pos.applyMatrix4(flipForwardMatrix);
605
+ }
606
+ if (camera?.parent) {
607
+ pos.applyMatrix4(camera.parent.matrixWorld);
608
+ rot.multiply(flipForwardQuaternion);
609
+ // apply parent quaternion (if parent is moved/rotated)
610
+ const parentRotation = getWorldQuaternion(camera.parent);
611
+ // ensure that "up" (y+) is pointing away from the wall
612
+ parentRotation.premultiply(flipForwardQuaternion);
613
+ rot.premultiply(parentRotation);
614
+ }
615
+ return { hit: result, position: pos, quaternion: rot };
616
+ }
617
+ return null;
618
+ }
619
+
620
+
621
+ /** convert a XRRigidTransform from XR session space to threejs / Needle Engine XR space */
622
+ convertSpace(transform: XRRigidTransform): { position: Vector3, quaternion: Quaternion } {
623
+ const pos = getTempVector(transform.position);
624
+ pos.applyMatrix4(flipForwardMatrix);
625
+ const rot = getTempQuaternion(transform.orientation);
626
+ rot.premultiply(flipForwardQuaternion);
627
+ return { position: pos, quaternion: rot };
628
+ }
629
+
630
+ /** this is the implictly created XR rig */
631
+ private readonly _defaultRig: IXRRig;
632
+
633
+ /** all scripts that receive some sort of XR update event */
634
+ private readonly _xr_scripts: INeedleXRSessionEventReceiver[];
635
+ /** scripts that have onUpdateXR event methods */
636
+ private readonly _xr_update_scripts: INeedleXRSessionEventReceiver[] = [];
637
+ /** scripts that are in the scene but inactive (e.g. disabled parent gameObject) */
638
+ private readonly _inactive_scripts: INeedleXRSessionEventReceiver[] = [];
639
+ private readonly _controllerAdded: ControllerChangedEvt[];
640
+ private readonly _controllerRemoved: ControllerChangedEvt[];
641
+ private readonly _originalCameraWorldPosition?: Vector3 | null;
642
+ private readonly _originalCameraWorldRotation?: Quaternion | null;
643
+ private readonly _originalCameraWorldScale?: Vector3 | null;
644
+ private readonly _originalCameraParent?: Object3D | null;
645
+ /** we store the main camera reference here each frame to make sure we have a rendering camera
646
+ * this e.g. the case when the XR rig with the camera gets disabled (and thus this.context.mainCamera is unassigned)
647
+ */
648
+ private _mainCamera: ICamera | null = null;
649
+
650
+ private constructor(mode: XRSessionMode, session: XRSession, context: Context, extra: {
651
+ scripts: INeedleXRSessionEventReceiver[],
652
+ controller_added: ControllerChangedEvt[],
653
+ controller_removed: ControllerChangedEvt[],
654
+ /** the initialization arguments */
655
+ init: XRSessionInit,
656
+ }) {
657
+ saveSessionInfo(mode, extra.init);
658
+ this.session = session;
659
+ this.mode = mode;
660
+ this.context = context;
661
+ this.context.xrSessionMode = this.mode;
662
+ this.context.renderer.xr.enabled = true;
663
+ this.context.renderer.xr.setSession(this.session);
664
+
665
+ this._xr_scripts = [...extra.scripts];
666
+ this._xr_update_scripts = this._xr_scripts.filter(e => typeof e.onUpdateXR === "function");
667
+ this._controllerAdded = extra.controller_added;
668
+ this._controllerRemoved = extra.controller_removed;
669
+
670
+ registerFrameEventCallback(this.onBefore, FrameEvent.LateUpdate);
671
+ // this.context.pre_render_callbacks.push(this.onBefore);
672
+ this.context.post_render_callbacks.push(this.onAfter);
673
+
674
+
675
+ if (extra.init.optionalFeatures?.includes("hit-test") || extra.init.requiredFeatures?.includes("hit-test")) {
676
+ session.requestReferenceSpace('viewer').then((referenceSpace) => {
677
+ session.requestHitTestSource?.call(session, { space: referenceSpace })?.then((source) => {
678
+ this._viewerHitTestSource = source;
679
+ });
680
+ })
681
+ }
682
+
683
+ if (this.context.mainCamera) {
684
+ this._originalCameraWorldPosition = getWorldPosition(this.context.mainCamera, new Vector3());
685
+ this._originalCameraWorldRotation = getWorldQuaternion(this.context.mainCamera, new Quaternion());
686
+ this._originalCameraWorldScale = getWorldScale(this.context.mainCamera, new Vector3());
687
+ this._originalCameraParent = this.context.mainCamera.parent;
688
+ }
689
+
690
+ this.context.mainCameraComponent?.applyClearFlags();
691
+
692
+ this._defaultRig = new ImplictXRRig();
693
+ this.context.scene.add(this._defaultRig.gameObject);
694
+ this.addRig(this._defaultRig);
695
+
696
+ // register already connected input sources
697
+ // this is for when the session is already running (via a temporary xr session)
698
+ // and the controllers are already connected
699
+ for (const sources of this.session.inputSources) {
700
+ this.onInputSourceAdded(sources);
701
+ }
702
+
703
+ // handle controller and input source changes changes
704
+ this.session.addEventListener('end', this.onEnd);
705
+ // handle input sources change
706
+ this.session.addEventListener("inputsourceschange", (evt: XRInputSourceChangeEvent) => {
707
+ // handle removed controllers
708
+ for (const removedInputSource of evt.removed) {
709
+ this.disconnectInputSource(removedInputSource);
710
+ }
711
+ for (const newInputSource of evt.added) {
712
+ this.onInputSourceAdded(newInputSource);
713
+ }
714
+ });
715
+ }
716
+ private onInputSourceAdded = (newInputSource: XRInputSource) => {
717
+ // do not create XR controllers for screen input sources
718
+ if (newInputSource.targetRayMode === "screen") {
719
+ return;
720
+ }
721
+ let index = 0;
722
+ for (let i = 0; i < this.session.inputSources.length; i++) {
723
+ if (this.session.inputSources[i] === newInputSource) {
724
+ index = i;
725
+ break;
726
+ }
727
+ }
728
+ // check if an xr controller for this input source already exists
729
+ // in case we have both an event from inputsourceschange and from the construtor initial input sources
730
+ if (this.controllers.find(c => c.inputSource === newInputSource)) return;
731
+
732
+ const newController = new NeedleXRController(this, newInputSource, index);
733
+ this.controllers.push(newController);
734
+ this._newControllers.push(newController);
735
+ this.invokeControllerEvent(newController, this._controllerAdded, "added");
736
+
737
+ }
738
+
739
+ /** End the XR Session */
740
+ end() {
741
+ // this can be called by external code to end the session
742
+ // the actual cleanup happens in onEnd which subscribes to the session end event
743
+ // so users can also just regularly call session.end() and the cleanup will happen automatically
744
+ if (this._ended) return;
745
+ this.session.end().catch(e => console.warn(e));
746
+ }
747
+
748
+ private _ended: boolean = false;
749
+ private readonly _newControllers: NeedleXRController[] = [];
750
+
751
+ private onEnd = (_evt: XRSessionEvent) => {
752
+ if (this._ended) return;
753
+ this._ended = true;
754
+
755
+ if (debug) console.log("XR Session ended");
756
+
757
+ deleteSessionInfo();
758
+
759
+ this.onAfter();
760
+ this.revertCustomForward();
761
+ this._didStart = false;
762
+ this._previousCameraParent = null;
763
+
764
+ // const index = this.context.pre_render_callbacks.indexOf(this.onBefore);
765
+ // if (index >= 0) this.context.pre_render_callbacks.splice(index, 1);
766
+ unregisterFrameEventCallback(this.onBefore, FrameEvent.LateUpdate);
767
+ const index2 = this.context.post_render_callbacks.indexOf(this.onAfter);
768
+ if (index2 >= 0) this.context.post_render_callbacks.splice(index2, 1);
769
+
770
+ this.context.renderer.xr.enabled = false;
771
+ this.context.xrSessionMode = undefined;
772
+ this.context.mainCameraComponent?.applyClearFlags();
773
+
774
+ // make sure we disconnect all controllers
775
+ for (let i = 0; i < this.controllers.length; i++) {
776
+ this.disconnectInputSource(this.controllers[i].inputSource);
777
+ }
778
+
779
+ // we want to call leave XR for *all* scripts that are still registered
780
+ // even if they might already be destroyed e.g. by the WebXR component (it destroys the default controller scripts)
781
+ // they should still receive this callback to be properly cleaned up
782
+ for (const listener of this._xr_scripts) {
783
+ listener?.onLeaveXR?.({ xr: this });
784
+ }
785
+
786
+ this.sync?.onExitXR(this);
787
+
788
+
789
+ if (this.context.mainCamera) {
790
+ // if we have a main camera we want to move it back to it's original parent
791
+ this._originalCameraParent?.add(this.context.mainCamera);
792
+
793
+ if (this._originalCameraWorldPosition) {
794
+ setWorldPosition(this.context.mainCamera, this._originalCameraWorldPosition);
795
+ }
796
+ if (this._originalCameraWorldRotation) {
797
+ setWorldQuaternion(this.context.mainCamera, this._originalCameraWorldRotation);
798
+ }
799
+ if (this._originalCameraWorldScale) {
800
+ setWorldScale(this.context.mainCamera, this._originalCameraWorldScale);
801
+ }
802
+ }
803
+
804
+ // mark for size change since DPI might have changed
805
+ this.context.requestSizeUpdate();
806
+
807
+ this._defaultRig.gameObject.removeFromParent();
808
+ };
809
+
810
+ /** Disconnects the controller, invokes events and notifies previou controller (if any) */
811
+ private disconnectInputSource(inputSource: XRInputSource) {
812
+ for (let i = this.controllers.length - 1; i >= 0; i--) {
813
+ const oldController = this.controllers[i];
814
+ if (oldController.inputSource === inputSource) {
815
+ this.controllers.splice(i, 1);
816
+ this.invokeControllerEvent(oldController, this._controllerRemoved, "removed");
817
+ const args: NeedleXRControllerEventArgs = {
818
+ xr: this,
819
+ controller: oldController,
820
+ change: "removed"
821
+ };
822
+ for (const script of this._xr_scripts) {
823
+ if (script.onXRControllerRemoved) script.onXRControllerRemoved(args);
824
+ }
825
+ oldController.onDisconnected();
826
+ }
827
+ }
828
+ }
829
+
830
+ private _didStart: boolean = false;
831
+
832
+ /** Called every frame by the engine */
833
+ private onBefore = (context: Context) => {
834
+ const frame = context.xrFrame;
835
+ if (!frame) return;
836
+
837
+ // ensure that we always have the correct main camera reference
838
+ // we need to store the main camera reference here because the main camera can be disabled and then it's removed from the context
839
+ // but in XR we want to ensure we always have a active camera (or at least parent the camera to the active rig)
840
+ if (this.context.mainCameraComponent && this.context.mainCameraComponent !== this._mainCamera) {
841
+ this._mainCamera = this.context.mainCameraComponent;
842
+ }
843
+
844
+ if (this.rig?.isActive == false) {
845
+ if (debug) console.warn("Latest rig is not active - trying to activate a different rig", this.rig);
846
+ this.updateActiveXRRig();
847
+ }
848
+
849
+ if (debug && this.rig) {
850
+ const pos = this.rig.gameObject.worldPosition;
851
+ const forward = this.rig.gameObject.worldForward;
852
+ pos.add(forward.multiplyScalar(1.5));
853
+ const upwards = this.rig.gameObject.worldUp;
854
+ pos.add(upwards.multiplyScalar(2.5));
855
+ Gizmos.DrawLabel(pos, this.context.time.smoothedFps.toFixed(1));
856
+ }
857
+
858
+ // make sure the camera is parented to the active rig
859
+ if (this.rig && this._mainCamera?.gameObject) {
860
+ const currentParent = this._mainCamera?.gameObject?.parent;
861
+ if (currentParent !== this.rig.gameObject) {
862
+ this.rig.gameObject.add(this._mainCamera?.gameObject);
863
+ }
864
+ }
865
+
866
+ this.internalUpdateState();
867
+
868
+ // we apply the flip immediately and keep it while in XR so that regular raycasts just work
869
+ // otherwise rendering would fool us
870
+ this.applyCustomForward();
871
+
872
+ const args: NeedleXREventArgs = { xr: this };
873
+
874
+ // we want to invoke ENTERXR for NEW component nad EXITXR for REMOVED components
875
+ // we want to invoke onControllerAdded to components joining a running XR session if controllers are already connected
876
+ //TODO: handle REMOVED components during a session (we want to call leave session events and controller removed events)
877
+
878
+ // deferred start because we need an XR frame
879
+ if (!this._didStart) {
880
+ this._didStart = true;
881
+
882
+ for (const listener of NeedleXRSession._xrStartListeners) {
883
+ listener(args);
884
+ }
885
+
886
+ // invoke session listeners start
887
+ for (const script of this._xr_scripts) {
888
+ if (script.destroyed) {
889
+ this._script_to_remove.push(script);
890
+ continue;
891
+ }
892
+ if (!script.activeAndEnabled) {
893
+ this.markInactive(script);
894
+ continue;
895
+ }
896
+ // if ((script as IComponent).activeAndEnabled === false) continue;
897
+ this.invokeCallback_EnterXR(script);
898
+ // also invoke all events for currently (already) connected controllers
899
+ for (const controller of this.controllers) {
900
+ this.invokeCallback_ControllerAdded(script, controller);
901
+ }
902
+ }
903
+ }
904
+ else if (this.context.new_scripts_xr.length > 0) {
905
+ // invoke start on all new scripts that were added during the session and that support the current mode
906
+ const copy = [...this.context.new_scripts_xr];
907
+ for (let i = 0; i < copy.length; i++) {
908
+ const script = this.context.new_scripts_xr[i] as IComponent & INeedleXRSessionEventReceiver;
909
+ if (!script || script.destroyed || script.supportsXR?.(this.mode) == false) {
910
+ this.context.new_scripts_xr.splice(i, 1);
911
+ continue;
912
+ }
913
+ if (!script.activeAndEnabled) {
914
+ this.markInactive(script);
915
+ continue;
916
+ }
917
+ // ignore inactive scripts
918
+ // if (script.activeAndEnabled === false) continue;
919
+ if (this.addScript(script)) {
920
+ // invoke onEnterXR on those scripts because they joined a running session
921
+ this.invokeCallback_EnterXR(script);
922
+ // also invoke all events for currently (already) connected controllers
923
+ for (const controller of this.controllers) {
924
+ this.invokeCallback_ControllerAdded(script, controller);
925
+ }
926
+ }
927
+ }
928
+ }
929
+
930
+ // make sure camera layers are correct
931
+ // we do this every frame here but I think it would be enough to do it once after the first rendering
932
+ // since we want to override the settings in three's WebXRManager
933
+ // we also want to continuously sync the culling mask if a user changes it on the main cam while in XR
934
+ this.syncCameraCullingMask();
935
+
936
+ // update controllers
937
+ for (const controller of this.controllers) {
938
+ controller.onUpdate(frame);
939
+ }
940
+
941
+ // handle when new controllers have been added
942
+ for (const controller of this._newControllers) {
943
+ for (const script of this._xr_scripts) {
944
+ if (script.destroyed) {
945
+ this._script_to_remove.push(script);
946
+ continue;
947
+ }
948
+ if (script.onXRControllerAdded) script.onXRControllerAdded({ xr: this, controller, change: "added" });
949
+ }
950
+ }
951
+ this._newControllers.length = 0;
952
+
953
+ // invoke update on all scripts
954
+ for (const script of this._xr_update_scripts) {
955
+ if (script.destroyed === true) {
956
+ this._script_to_remove.push(script);
957
+ continue;
958
+ }
959
+ if (script.activeAndEnabled === false) {
960
+ this.markInactive(script);
961
+ continue;
962
+ }
963
+ if (script.onUpdateXR) script.onUpdateXR(args);
964
+ }
965
+
966
+ // handle inactive scripts
967
+ this.handleInactiveScripts();
968
+
969
+ // handle removed scripts
970
+ if (this._script_to_remove.length > 0) {
971
+ // make sure we have no duplicates
972
+ const unique = [...new Set(this._script_to_remove)];
973
+ this._script_to_remove.length = 0;
974
+ for (const script of unique) {
975
+ if (!script.destroyed && this.running) {
976
+ script.onLeaveXR?.(args);
977
+ }
978
+ this.removeScript(script);
979
+ }
980
+ }
981
+
982
+ this.sync?.onUpdate(this);
983
+
984
+ if (debug) {
985
+ for (const controller of this.controllers) {
986
+ controller.onRenderDebug();
987
+ }
988
+ }
989
+ }
990
+
991
+ private onAfter = () => {
992
+ // render spectator view if we're in VR using Link
993
+ if (isDesktop()) {
994
+ const renderer = this.context.renderer;
995
+ if (renderer.xr.isPresenting && this.context.mainCamera) {
996
+ const wasXr = renderer.xr.enabled;
997
+ const previousRenderTarget = renderer.getRenderTarget();
998
+ renderer.xr.enabled = false;
999
+ renderer.setRenderTarget(null);
1000
+ renderer.render(this.context.scene, this.context.mainCamera);
1001
+ renderer.xr.enabled = wasXr;
1002
+ renderer.setRenderTarget(previousRenderTarget);
1003
+ }
1004
+ }
1005
+ }
1006
+
1007
+ /** register a new XR script if it hasnt added yet */
1008
+ private addScript(script: INeedleXRSessionEventReceiver) {
1009
+ if (this._xr_scripts.includes(script)) return false;
1010
+ if (debug) console.log("Register new XRScript", script);
1011
+ this._xr_scripts.push(script);
1012
+ if (typeof script.onUpdateXR === "function") {
1013
+ this._xr_update_scripts.push(script);
1014
+ }
1015
+ return true;
1016
+ }
1017
+
1018
+ /** mark a script as inactive and invokes callbacks */
1019
+ private markInactive(script: INeedleXRSessionEventReceiver) {
1020
+ if (this._inactive_scripts.includes(script)) return;
1021
+ this._inactive_scripts.push(script);
1022
+ // inactive scripts should not receive any regular callbacks anymore
1023
+ this.removeScript(script);
1024
+ // inactive scripts receive callbacks as if the XR session has ended
1025
+ for (const ctrl of this.controllers) this.invokeCallback_ControllerRemoved(script, ctrl);
1026
+ this.invokeCallback_LeaveXR(script);
1027
+ }
1028
+ private handleInactiveScripts() {
1029
+ if (this._inactive_scripts.length > 0) {
1030
+ for (let i = this._inactive_scripts.length - 1; i >= 0; i--) {
1031
+ const script = this._inactive_scripts[i];
1032
+ if (script.activeAndEnabled) {
1033
+ this._inactive_scripts.splice(i, 1);
1034
+ this.addScript(script);
1035
+ this.invokeCallback_EnterXR(script);
1036
+ for (const ctrl of this.controllers) this.invokeCallback_ControllerAdded(script, ctrl);
1037
+ }
1038
+ }
1039
+ }
1040
+ }
1041
+
1042
+ private readonly _script_to_remove: INeedleXRSessionEventReceiver[] = [];
1043
+
1044
+ private removeScript(script: INeedleXRSessionEventReceiver) {
1045
+ if (debug) console.log("Remove XRScript", script);
1046
+ const index = this._xr_scripts.indexOf(script);
1047
+ if (index >= 0) this._xr_scripts.splice(index, 1);
1048
+ const index2 = this._xr_update_scripts.indexOf(script);
1049
+ if (index2 >= 0) this._xr_update_scripts.splice(index2, 1);
1050
+ const index3 = this._inactive_scripts.indexOf(script);
1051
+ if (index3 >= 0) this._inactive_scripts.splice(index3, 1);
1052
+ }
1053
+
1054
+ private invokeCallback_EnterXR(script: INeedleXRSessionEventReceiver) {
1055
+ if (script.onEnterXR) {
1056
+ script.onEnterXR({ xr: this });
1057
+ }
1058
+ }
1059
+ private invokeCallback_ControllerAdded(script: INeedleXRSessionEventReceiver, controller: NeedleXRController) {
1060
+ if (script.onXRControllerAdded) {
1061
+ script.onXRControllerAdded({ xr: this, controller, change: "added" });
1062
+ }
1063
+ }
1064
+ private invokeCallback_ControllerRemoved(script: INeedleXRSessionEventReceiver, controller: NeedleXRController) {
1065
+ if (script.onXRControllerRemoved) {
1066
+ script.onXRControllerRemoved({ xr: this, controller, change: "removed" });
1067
+ }
1068
+ }
1069
+ private invokeCallback_LeaveXR(script: INeedleXRSessionEventReceiver) {
1070
+ if (script.onLeaveXR && !script.destroyed) {
1071
+ script.onLeaveXR({ xr: this });
1072
+ }
1073
+ }
1074
+
1075
+ private syncCameraCullingMask() {
1076
+ // when we set unity layers objects will only be rendered on one eye
1077
+ // we set layers to sync raycasting and have a similar behaviour to unity
1078
+ const cam = this.context.xrCamera;
1079
+ const cull = this.context.mainCameraComponent?.cullingMask;
1080
+ if (cam && cull !== undefined) {
1081
+ for (const c of cam.cameras) {
1082
+ c.layers.mask = cull;
1083
+ }
1084
+ cam.layers.mask = cull;
1085
+ }
1086
+ else if (cam) {
1087
+ for (const c of cam.cameras) {
1088
+ c.layers.enableAll();
1089
+ }
1090
+ cam.layers.enableAll();
1091
+ }
1092
+ }
1093
+
1094
+ private invokeControllerEvent(controller: NeedleXRController, listeners: ControllerChangedEvt[], change: "added" | "removed") {
1095
+ for (let i = listeners.length - 1; i >= 0; i--) {
1096
+ const listener = listeners[i];
1097
+ if (!listener) continue;
1098
+ try {
1099
+ listener({
1100
+ xr: this,
1101
+ controller,
1102
+ change
1103
+ });
1104
+ }
1105
+ catch (e) {
1106
+ console.error(e);
1107
+ }
1108
+ }
1109
+ }
1110
+
1111
+
1112
+ private _camera!: Object3D;
1113
+ private readonly _cameraRenderParent: Object3D = new Object3D().rotateY(Math.PI);
1114
+ private _previousCameraParent!: Object3D | null;
1115
+ private readonly _customforward: boolean = true;
1116
+ private originalCameraNearPlane?: number;
1117
+ /** This is used to have the XR system camera look into threejs Z forward direction (instead of -z) */
1118
+ private applyCustomForward() {
1119
+ if (this.context.mainCamera && this._customforward) {
1120
+ this._camera = this.context.mainCamera;
1121
+ if (this._camera.parent !== this._cameraRenderParent) {
1122
+ this._previousCameraParent = this._camera.parent;
1123
+ this._previousCameraParent?.add(this._cameraRenderParent);
1124
+ }
1125
+ this._cameraRenderParent.name = "XR Camera Render Parent";
1126
+ this._cameraRenderParent.add(this._camera);
1127
+
1128
+ let minNearPlane = .02;
1129
+ if (this.rig) {
1130
+ const rigWorldScale = getWorldScale(this.rig.gameObject);
1131
+ minNearPlane *= rigWorldScale.x;
1132
+ }
1133
+ if (this._camera instanceof PerspectiveCamera && this._camera.near > minNearPlane) {
1134
+ this.originalCameraNearPlane = this._camera.near;
1135
+ this._camera.near = minNearPlane;
1136
+ }
1137
+ }
1138
+ }
1139
+ private revertCustomForward() {
1140
+ if (this._camera && this._previousCameraParent) {
1141
+ this._previousCameraParent.add(this._camera);
1142
+ }
1143
+ this._previousCameraParent = null;
1144
+
1145
+ if (this._camera instanceof PerspectiveCamera && this.originalCameraNearPlane != undefined) {
1146
+ this._camera.near = this.originalCameraNearPlane;
1147
+ }
1148
+ }
1149
+
1150
+
1151
+ private _viewerPose?: XRViewerPose;
1152
+ private readonly _transformOrientation = new Quaternion();
1153
+ private readonly _transformPosition = new Vector3();
1154
+
1155
+ private internalUpdateState() {
1156
+ const referenceSpace = this.context.renderer.xr.getReferenceSpace();
1157
+ if (!referenceSpace) {
1158
+ this._viewerPose = undefined;
1159
+ return;
1160
+ }
1161
+ this._viewerPose = this.frame.getViewerPose(referenceSpace);
1162
+ if (this._viewerPose) {
1163
+ const transform: XRRigidTransform = this._viewerPose.transform;
1164
+ this._transformPosition.set(transform.position.x, transform.position.y, transform.position.z);
1165
+ this._transformOrientation.set(transform.orientation.x, transform.orientation.y, transform.orientation.z, transform.orientation.w);
1166
+ }
1167
+ }
1168
+ }
src/engine/xr/NeedleXRSync.ts ADDED
@@ -0,0 +1,221 @@
1
+ import type { Context } from "../engine_context.js";
2
+ import { getParam } from "../engine_utils.js";
3
+ import { RoomEvents, UserJoinedOrLeftRoomModel } from "../engine_networking.js";
4
+ import { NeedleXRSession } from "./NeedleXRSession.js";
5
+ import { NeedleXRController } from "./NeedleXRController.js";
6
+
7
+ const debug = getParam("debugwebxr");
8
+
9
+
10
+ declare type XRControllerType = "hand" | "controller";
11
+
12
+ declare type XRControllerState = {
13
+ // adding a guid so it's saved on the server, ideally we have a "room lifetime" store that doesnt save state forever on disc but just until the room is disposed (we have to add support for this in the networking backend tho)
14
+ guid: string;
15
+ index: number;
16
+ handedness: XRHandedness;
17
+ isTracking: boolean;
18
+ type: XRControllerType;
19
+ }
20
+
21
+ class XRUserState {
22
+
23
+ readonly controllerStates: XRControllerState[] = [];
24
+
25
+ readonly userId: string;
26
+ readonly context: Context;
27
+
28
+ private readonly userStateEvtName: string;
29
+
30
+ constructor(userId: string, context: Context) {
31
+ this.userId = userId;
32
+ this.context = context;
33
+ this.userStateEvtName = "xr-sync-user-state-" + userId;
34
+ this.context.connection.beginListen(this.userStateEvtName, this.onReceivedControllerState);
35
+ }
36
+
37
+ dispose() {
38
+ this.context.connection.stopListen(this.userStateEvtName, this.onReceivedControllerState);
39
+ }
40
+
41
+ onReceivedControllerState = (state: XRControllerState) => {
42
+ if (debug) console.log(`XRSync: Received change for ${this.userId}: ${state.type} ${state.handedness}; tracked=${state.isTracking}`);
43
+
44
+ let found = false;
45
+ for (let i = 0; i < this.controllerStates.length; i++) {
46
+ const ctrl = this.controllerStates[i];
47
+ if (ctrl.index === state.index) {
48
+ this.controllerStates[i] = state;
49
+ found = true;
50
+ break;
51
+ }
52
+ }
53
+ if (!found) {
54
+ this.controllerStates.push(state);
55
+ }
56
+ }
57
+
58
+ update(session: NeedleXRSession) {
59
+ if (this.context.connection.isConnected == false) return;
60
+
61
+ for (let i = this.controllerStates.length - 1; i >= 0; i--) {
62
+ const state = this.controllerStates[i];
63
+ let foundController = false;
64
+ for (let i = 0; i < session.controllers.length; i++) {
65
+ const ctrl = session.controllers[i];
66
+ if (ctrl.index === state.index) {
67
+ foundController = true;
68
+ }
69
+ }
70
+ if (!foundController) {
71
+ // controller was removed
72
+ if (debug) console.log(`XRSync: ${state.type} ${state.handedness} removed`, state.index);
73
+ this.controllerStates.splice(i, 1);
74
+ this.sendControllerRemoved(state);
75
+ }
76
+ }
77
+
78
+ for (const ctrl of session.controllers) {
79
+ this.updateControllerStates(ctrl);
80
+ }
81
+ }
82
+
83
+ onExitXR(_session: NeedleXRSession) {
84
+ for (const state of this.controllerStates) {
85
+ this.sendControllerRemoved(state);
86
+ }
87
+ this.controllerStates.length = 0;
88
+ }
89
+
90
+ private sendControllerRemoved(state: XRControllerState) {
91
+ state.isTracking = false;
92
+ state.guid = "";
93
+ this.context.connection.send(this.userStateEvtName, state);
94
+ this.context.connection.sendDeleteRemoteState(state.guid);
95
+ }
96
+
97
+ private updateControllerStates(ctrl: NeedleXRController) {
98
+
99
+ // this.context.connection.send(this.userStateEvtName, {});
100
+ const existing = this.controllerStates.find(x => x.index === ctrl.index);
101
+ if (existing) {
102
+ let hasChanged = false;
103
+ hasChanged ||= existing.isTracking != ctrl.isTracking;
104
+ if (hasChanged) {
105
+ existing.isTracking = ctrl.isTracking;
106
+ this.context.connection.send(this.userStateEvtName, existing);
107
+ }
108
+ }
109
+ else {
110
+ const state: XRControllerState = {
111
+ guid: this.userId + "-" + ctrl.index,
112
+ isTracking: ctrl.isTracking,
113
+ handedness: ctrl.side,
114
+ index: ctrl.index,
115
+ type: ctrl.hand ? "hand" : "controller"
116
+ }
117
+ this.controllerStates.push(state);
118
+ this.context.connection.send(this.userStateEvtName, state);
119
+ if (debug) console.log(`XRSync: ${state.type} ${state.handedness} added`, state.index);
120
+ }
121
+ }
122
+
123
+
124
+ }
125
+
126
+ export class NeedleXRSync {
127
+
128
+ hasState(userId: string | null | undefined) {
129
+ if (!userId) return false;
130
+ return this._states.has(userId);
131
+ }
132
+
133
+ /** Is the left controller or hand tracked */
134
+ isTracking(userId: string | null | undefined, handedness: XRHandedness): boolean | undefined {
135
+ if (!userId) return undefined;
136
+ const user = this._states.get(userId);
137
+ if (!user) return undefined;
138
+ const ctrl = user.controllerStates.find(x => x.handedness === handedness);
139
+ return ctrl?.isTracking || false;
140
+ }
141
+
142
+ /** Is it hand tracking or a controller */
143
+ getDeviceType(userId: string, handedness: XRHandedness): XRControllerType | undefined | "unknown" {
144
+ if (!userId) return undefined;
145
+ const user = this._states.get(userId);
146
+ if (!user) return undefined;
147
+ const ctrl = user.controllerStates.find(x => x.handedness === handedness);
148
+ return ctrl?.type || "unknown";
149
+ }
150
+
151
+ private readonly context: Context;
152
+
153
+ constructor(context: Context) {
154
+ this.context = context;
155
+ this.context.connection.beginListen(RoomEvents.JoinedRoom, this.onJoinedRoom);
156
+ this.context.connection.beginListen(RoomEvents.LeftRoom, this.onLeftRoom)
157
+ this.context.connection.beginListen(RoomEvents.UserJoinedRoom, this.onOtherUserJoinedRoom);
158
+ this.context.connection.beginListen(RoomEvents.UserLeftRoom, this.onOtherUserLeftRoom);
159
+ }
160
+ destroy() {
161
+ this.context.connection.stopListen(RoomEvents.JoinedRoom, this.onJoinedRoom);
162
+ this.context.connection.stopListen(RoomEvents.LeftRoom, this.onLeftRoom)
163
+ this.context.connection.stopListen(RoomEvents.UserJoinedRoom, this.onOtherUserJoinedRoom);
164
+ this.context.connection.stopListen(RoomEvents.UserLeftRoom, this.onOtherUserLeftRoom);
165
+ }
166
+
167
+ private onJoinedRoom = () => {
168
+ if (this.context.connection.connectionId) {
169
+ if (!this._states.has(this.context.connection.connectionId)) {
170
+ if (debug) console.log("XRSync: Local user joined room", this.context.connection.connectionId);
171
+ this._states.set(this.context.connection.connectionId, new XRUserState(this.context.connection.connectionId, this.context));
172
+ }
173
+ for (const user of this.context.connection.usersInRoom()) {
174
+ if (!this._states.has(user)) {
175
+ this._states.set(user, new XRUserState(user, this.context));
176
+ }
177
+ }
178
+ }
179
+ }
180
+ private onLeftRoom = () => {
181
+ if (this.context.connection.connectionId) {
182
+ if (!this._states.has(this.context.connection.connectionId)) {
183
+ const state = this._states.get(this.context.connection.connectionId);
184
+ state?.dispose();
185
+ this._states.delete(this.context.connection.connectionId);
186
+ }
187
+ }
188
+ }
189
+ private onOtherUserJoinedRoom = (evt: UserJoinedOrLeftRoomModel) => {
190
+ const userId = evt.userId;
191
+ if (!this._states.has(userId)) {
192
+ if (debug) console.log("XRSync: Remote user joined room", userId);
193
+ this._states.set(userId, new XRUserState(userId, this.context));
194
+ }
195
+ }
196
+ private onOtherUserLeftRoom = (evt: UserJoinedOrLeftRoomModel) => {
197
+ const userId = evt.userId;
198
+ if (!this._states.has(userId)) {
199
+ const state = this._states.get(userId);
200
+ state?.dispose();
201
+ this._states.delete(userId);
202
+ }
203
+ }
204
+
205
+ private _states: Map<string, XRUserState> = new Map();
206
+
207
+ onUpdate(session: NeedleXRSession) {
208
+ if (this.context.connection.isConnected && this.context.connection.connectionId) {
209
+ const localState = this._states.get(this.context.connection.connectionId);
210
+ localState?.update(session);
211
+ }
212
+ }
213
+
214
+ onExitXR(session: NeedleXRSession) {
215
+ if (this.context.connection.isConnected && this.context.connection.connectionId) {
216
+ const localState = this._states.get(this.context.connection.connectionId);
217
+ localState?.onExitXR(session);
218
+ }
219
+ }
220
+
221
+ }
src/engine-components/webxr/TeleportTarget.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { Behaviour } from "../Component.js";
2
+
3
+ /** This component is just used as a marker on objects for WebXR teleportation
4
+ * The default XRControllerMovement script checks if this component is on the object you are pointing at and if so it will teleport to that location if triggered
5
+ * If the component is not present it won't teleport
6
+ */
7
+ export class TeleportTarget extends Behaviour {
8
+
9
+ }
src/engine/xr/TempXRContext.ts ADDED
@@ -0,0 +1,182 @@
1
+ import { AxesHelper, Camera, Color, DirectionalLight, GridHelper, Mesh, MeshBasicMaterial, MeshStandardMaterial, PerspectiveCamera, Scene, WebGLRenderer } from "three";
2
+ import { delay } from "../engine_utils.js";
3
+ import { ObjectUtils, PrimitiveType } from "../engine_create_objects.js";
4
+ import { Mathf } from "../engine_math.js";
5
+
6
+ declare type SessionInfo = { session: XRSession, mode: XRSessionMode, init: XRSessionInit };
7
+
8
+ /** Create with static `start`- used to start an XR session while waiting for session granted */
9
+ export class TemporaryXRContext {
10
+
11
+ private static _active: TemporaryXRContext | null = null;
12
+ static get active() {
13
+ return this._active;
14
+ }
15
+
16
+ private static _requestInFlight = false;
17
+
18
+ static async start(mode: XRSessionMode, init: XRSessionInit) {
19
+ if (this._active) {
20
+ console.error("Cannot start a new XR session while one is already active");
21
+ return null;
22
+ }
23
+ if (this._requestInFlight) {
24
+ console.error("Cannot start a new XR session while a request is already in flight");
25
+ return null;
26
+ }
27
+
28
+ if ('xr' in navigator && navigator.xr) {
29
+ if (!init) {
30
+ console.error("XRSessionInit must be provided");
31
+ return null;
32
+ }
33
+ this._requestInFlight = true;
34
+ const session = await navigator.xr.requestSession(mode, init);
35
+ session.addEventListener("end", () => {
36
+ this._active = null;
37
+ });
38
+ if (!this._requestInFlight) {
39
+ session.end();
40
+ return null;
41
+ }
42
+ this._requestInFlight = false;
43
+ this._active = new TemporaryXRContext(mode, init, session);
44
+ return this._active;
45
+ }
46
+
47
+ return null;
48
+ }
49
+
50
+ static async handoff(): Promise<SessionInfo | null> {
51
+ if (this._active) {
52
+ return this._active.handoff();
53
+ }
54
+ return null;
55
+ }
56
+
57
+ static async stop() {
58
+ this._requestInFlight = false;
59
+ if (this._active) {
60
+ await this._active.end();
61
+ await delay(100);
62
+ }
63
+ this._active = null;
64
+ }
65
+
66
+ private readonly _session: XRSession | null;
67
+ private readonly _mode: XRSessionMode;
68
+ private readonly _init: XRSessionInit;
69
+
70
+ private readonly _renderer: WebGLRenderer;
71
+ private readonly _camera: Camera;
72
+ private readonly _scene: Scene;
73
+
74
+ private constructor(mode: XRSessionMode, init: XRSessionInit, session: XRSession) {
75
+ this._mode = mode;
76
+ this._init = init;
77
+ this._session = session;
78
+ this._session.addEventListener("end", this.onEnd);
79
+
80
+ this._renderer = new WebGLRenderer({ alpha: true });
81
+ this._renderer.setAnimationLoop(this.onFrame);
82
+ this._renderer.xr.setSession(session);
83
+ this._renderer.xr.enabled = true;
84
+ this._camera = new PerspectiveCamera();
85
+ this._scene = new Scene();
86
+ this._scene.add(this._camera);
87
+ this.setupScene();
88
+ }
89
+
90
+ end() {
91
+ if (!this._session) return Promise.resolve();
92
+ return this._session.end();
93
+ }
94
+
95
+ /** returns the session and session info and stops the temporary rendering */
96
+ async handoff() {
97
+ if (!this._session) throw new Error("Cannot handoff a session that has already ended");
98
+ const info: SessionInfo = {
99
+ session: this._session,
100
+ mode: this._mode,
101
+ init: this._init
102
+ };
103
+ await this.onBeforeHandoff();
104
+ // calling onEnd here directly because we dont end the session
105
+ this.onEnd();
106
+ // set the session to null because we dont want this class to accidentaly end the session
107
+ //@ts-ignore
108
+ this._session = null;
109
+ return info;
110
+ }
111
+
112
+ private onEnd = () => {
113
+ this._session?.removeEventListener("end", this.onEnd);
114
+ this._renderer.setAnimationLoop(null);
115
+ this._renderer.dispose();
116
+ this._scene.clear();
117
+ }
118
+
119
+ private _lastTime = 0;
120
+ private onFrame = (time: DOMHighResTimeStamp, _frame: XRFrame) => {
121
+ const dt = time - this._lastTime;
122
+ this.update(time, dt);
123
+ if (this._camera.parent !== this._scene) {
124
+ this._scene.add(this._camera);
125
+ }
126
+ this._renderer.render(this._scene, this._camera);
127
+ }
128
+
129
+ /** can be used to prepare the user or fade to black */
130
+ private async onBeforeHandoff() {
131
+ const obj = ObjectUtils.createPrimitive(PrimitiveType.Cube);
132
+ obj.position.z = -3;
133
+ obj.position.y = .5;
134
+ this._scene.add(obj);
135
+ await delay(4000);
136
+ this._scene.clear();
137
+ await delay(100);
138
+ }
139
+
140
+
141
+ private _spheres: Mesh[] = [];
142
+ private setupScene() {
143
+ this._scene.background = new Color(0x000000);
144
+ this._scene.add(new GridHelper(5, 10, 0x111111, 0x222222));
145
+
146
+ const light = new DirectionalLight(0xffffff, 1);
147
+ light.position.set(2, 2, 2);
148
+ light.castShadow = false;
149
+ this._scene.add(light);
150
+
151
+ const light2 = new DirectionalLight(0xffffff, 1);
152
+ light2.position.set(-2, -2, -2);
153
+ light2.castShadow = false;
154
+ this._scene.add(light2);
155
+
156
+ const sphereRange = 50;
157
+ for (let i = 0; i < 100; i++) {
158
+ const sphere = ObjectUtils.createPrimitive(PrimitiveType.Sphere, {
159
+ material: new MeshStandardMaterial({
160
+ color: 0x222222,
161
+ metalness: 1,
162
+ roughness: .8,
163
+ })
164
+ });
165
+ sphere.position.x = Mathf.random(-sphereRange, sphereRange);
166
+ sphere.position.y = Mathf.random(3, 40);
167
+ sphere.position.z = Mathf.random(-sphereRange, sphereRange);
168
+ sphere.scale.multiplyScalar(2);
169
+ this._spheres.push(sphere);
170
+ this._scene.add(sphere);
171
+ }
172
+ }
173
+
174
+ private update(time: number, _deltaTime: number) {
175
+
176
+ const speed = time * .0004;
177
+ for (let i = 0; i < this._spheres.length; i++) {
178
+ const sphere = this._spheres[i];
179
+ sphere.position.y += Math.sin(speed + i * .5) * 0.002;
180
+ }
181
+ }
182
+ }
src/engine-components/webxr/types.ts ADDED
@@ -0,0 +1,4 @@
1
+
2
+ export interface XRMovementBehaviour {
3
+ isXRMovementHandler: true;
4
+ }
src/engine/xr/utils.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { Object3D } from "three";
2
+ import type { SourceIdentifier } from "../engine_types.js";
3
+ import { AssetReference } from "../engine_addressables.js";
4
+ import { getParam } from "../engine_utils.js";
5
+
6
+ const debug = getParam("debugwebxr");
7
+
8
+ export class NeedleXRUtils {
9
+
10
+ /** Searches the hierarchy for objects following a specific naming scheme */
11
+ static tryFindAvatarObjects(obj: Object3D, sourceId: SourceIdentifier, result: { head?: AssetReference, leftHand?: AssetReference, rightHand?: AssetReference }) {
12
+ if (result.head && result.leftHand && result.rightHand) return;
13
+
14
+ const name = obj.name.toLocaleLowerCase();
15
+
16
+ if (!result.head && name.includes("head")) {
17
+ if (debug) console.log("FOUND AVATAR HEAD", obj.name)
18
+ result.head = new AssetReference("", sourceId, obj);
19
+ }
20
+ if (name.includes("hand")) {
21
+ if (!result.leftHand && name.includes("left")) {
22
+ if (debug) console.log("FOUND AVATAR LEFT HAND", obj.name)
23
+ result.leftHand = new AssetReference("", sourceId, obj);
24
+ }
25
+ if (!result.rightHand && name.includes("right")) {
26
+ if (debug) console.log("FOUND AVATAR RIGHT HAND", obj.name)
27
+ result.rightHand = new AssetReference("", sourceId, obj);
28
+ }
29
+ }
30
+
31
+ for (let i = 0; i < obj.children.length; i++) {
32
+ if (result.head && result.leftHand && result.rightHand) return;
33
+ const child = obj.children[i];
34
+ this.tryFindAvatarObjects(child, sourceId, result);
35
+ }
36
+ }
37
+
38
+
39
+ }
src/engine-components/webxr/WebXRButtons.ts ADDED
@@ -0,0 +1,265 @@
1
+ import { GameObject } from "../Component.js";
2
+ import { NeedleXRSession } from "../../engine/engine_xr.js";
3
+ import { USDZExporter } from "../export/usdz/USDZExporter.js";
4
+ import { isDevEnvironment } from "../../engine/debug/index.js";
5
+ import { generateQRCode } from "../../engine/engine_utils.js";
6
+ import { isMozillaXR } from "../../engine/engine_utils.js";
7
+
8
+ const webXRElementName = "needle-webxr-buttons";
9
+
10
+ // TODO: add feedback process to the buttons when XR session is requested/pending... perhaps we need to expose an event on the NeedleXRSession class for this
11
+
12
+ export class NeedleWebXRHtmlElement extends HTMLElement {
13
+
14
+ static create() {
15
+ return document.createElement(webXRElementName) as NeedleWebXRHtmlElement;
16
+ }
17
+
18
+ constructor() {
19
+ super();
20
+ this.attachShadow({ mode: 'open' });
21
+ const template = document.createElement('template');
22
+ template.innerHTML = `<style>
23
+ :host {
24
+ position: absolute;
25
+ display: flex;
26
+ z-index: 100;
27
+ bottom: 100px;
28
+ left: 50%;
29
+ transform: translateX(-50%);
30
+ }
31
+ :host button {
32
+ font-family: Roboto, sans-serif, Arial;
33
+ border: none;
34
+ color: black;
35
+ background: rgba(255, 255, 255, 1);
36
+ margin: 0 5px;
37
+ padding: 0.5rem .7rem;
38
+ font-size: 1rem;
39
+ white-space: nowrap;
40
+ transition: all 0.2s ease-in-out;
41
+ border-radius: .2rem;
42
+ border: rgba(255, 255, 255, 0.2) solid 1px;
43
+ box-shadow: 0 0 12px rgba(0, 0, 0, 0.2);
44
+ font-weight: normal;
45
+ }
46
+ :host button:hover {
47
+ cursor: pointer;
48
+ background: rgba(255, 255, 255, 1);
49
+ box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.1), 0 0 15px rgba(255, 255, 255, 0.2);
50
+ transition: all 0.1s ease-in-out;
51
+ }
52
+ :host button:disabled {
53
+ background: rgba(255, 255, 255, 1);
54
+ color: rgba(100, 100, 100, 1);
55
+ border: rgba(0,0,0,0) 1px solid;
56
+ box-shadow: none;
57
+ cursor: initial;
58
+ }
59
+ :host button.this-mode-is-requested {
60
+ font-weight: bold;
61
+ background: repeating-linear-gradient(to right, #fff 0%, #fff 40%, #aaffff 55%, #fff 80%);
62
+ background-size: 200% auto;
63
+ background-position: 0 100%;
64
+ animation: AnimationName .7s ease infinite forwards;
65
+ }
66
+ :host button.other-mode-is-requested {
67
+ }
68
+
69
+ @keyframes AnimationName {
70
+ 0% { background-position: 0% 0 }
71
+ 100% { background-position: -200% 0 }
72
+ }
73
+
74
+ :host .qr-code-container {
75
+ position: absolute;
76
+ display: initial;
77
+ bottom: 100%;
78
+ left: 50%;
79
+ transform: translateX(-50%) translateY(-10px);
80
+ background-color: white;
81
+ padding: 0.8rem;
82
+ border-radius: 0.2rem;
83
+ pointer-events: all;
84
+ opacity: 1;
85
+ transition: opacity 0.2s ease-in-out;
86
+ }
87
+
88
+ :host .qr-code-container img {
89
+ max-width: calc(min(100vw, 300px) - 20px);
90
+ }
91
+
92
+ :host .qr-code-container.hidden {
93
+ opacity: 0;
94
+ display: none; /* prevents the QR code from overflowing the body when it's actually disabled but breaks animation */
95
+ pointer-events: none;
96
+ }
97
+ </style>
98
+ `;
99
+ if (this.shadowRoot)
100
+ this.shadowRoot.appendChild(template.content.cloneNode(true));
101
+ }
102
+
103
+ /** @returns the quicklook button if it was created */
104
+ get quicklookButton() { return this.shadowRoot?.querySelector("[data-needle='quicklook-button']") as HTMLButtonElement | null; }
105
+ /** get or create the quicklook button */
106
+ createQuicklookButton(): HTMLButtonElement {
107
+ const existingButton = this.shadowRoot?.querySelector("[data-needle='quicklook-button']") as HTMLButtonElement | null;
108
+ if (existingButton) return existingButton;
109
+ const button = document.createElement("button");
110
+ button.dataset["needle"] = "quicklook-button";
111
+ button.innerText = "Open in Quicklook";
112
+ button.addEventListener("click", () => {
113
+ const usdzExporter = GameObject.findObjectOfType(USDZExporter);
114
+ if (usdzExporter) {
115
+ usdzExporter.exportAsync();
116
+ }
117
+ });
118
+ this.shadowRoot?.appendChild(button);
119
+ return button;
120
+ }
121
+
122
+ /** @returns the WebXR AR button if it was created */
123
+ get arButton() { return this.shadowRoot?.querySelector("[data-needle='webxr-ar-button']") as HTMLButtonElement | null; }
124
+ /** get or create the WebXR AR button */
125
+ createARButton(init?: XRSessionInit): HTMLButtonElement {
126
+ const existingButton = this.shadowRoot?.querySelector("[data-needle='webxr-ar-button']") as HTMLButtonElement | null;
127
+ if (existingButton) return existingButton;
128
+ const mode: XRSessionMode = "immersive-ar";
129
+ const button = document.createElement("button");
130
+ button.dataset["needle"] = "webxr-ar-button";
131
+ button.innerText = "Enter AR";
132
+ button.addEventListener("click", () => NeedleXRSession.start(mode, init));
133
+ this.updateSessionSupported(button, mode);
134
+ this.listenToXRSessionState(button, mode);
135
+ this.shadowRoot?.appendChild(button);
136
+
137
+ if (!isMozillaXR()) // WebXR Viewer can't attach events before session start
138
+ navigator.xr?.addEventListener("devicechange", () => this.updateSessionSupported(button, mode));
139
+
140
+ return button;
141
+ }
142
+
143
+ /** @returns the WebXR VR button if it was created */
144
+ get vrButton() { return this.shadowRoot?.querySelector("[data-needle='webxr-vr-button']") as HTMLButtonElement | null; }
145
+ /** get or create the WebXR VR button */
146
+ createVRButton(init?: XRSessionInit): HTMLButtonElement {
147
+ const hasButton = this.shadowRoot?.querySelector("[data-needle='webxr-vr-button']");
148
+ if (hasButton) return hasButton as HTMLButtonElement;
149
+ const mode: XRSessionMode = "immersive-vr";
150
+ const button = document.createElement("button");
151
+ button.dataset["needle"] = "webxr-vr-button";
152
+ button.innerText = "Enter VR";
153
+ button.addEventListener("click", () => NeedleXRSession.start(mode, init));
154
+ this.updateSessionSupported(button, mode);
155
+ this.listenToXRSessionState(button, mode);
156
+ this.shadowRoot?.appendChild(button);
157
+
158
+ if (!isMozillaXR()) // WebXR Viewer can't attach events before session start
159
+ navigator.xr?.addEventListener("devicechange", () => this.updateSessionSupported(button, mode));
160
+
161
+ return button;
162
+ }
163
+
164
+ /** @returns the Send to Quest button */
165
+ get sendToQuestButton() { return this.shadowRoot?.querySelector("[data-needle='webxr-sendtoquest-button']") as HTMLButtonElement | null; }
166
+ /** get or create the Send To Quest button */
167
+ createSendToQuestButton(): HTMLButtonElement {
168
+ const hasButton = this.shadowRoot?.querySelector("[data-needle='webxr-sendtoquest-button']");
169
+ if (hasButton) return hasButton as HTMLButtonElement;
170
+ const baseUrl = `https://oculus.com/open_url/?url=`
171
+ const button = document.createElement("button");
172
+ button.dataset["needle"] = "webxr-sendtoquest-button";
173
+ button.innerText = "Open on Quest";
174
+ button.addEventListener("click", () => {
175
+ const urlParameter = encodeURIComponent(window.location.href);
176
+ window.open(baseUrl + urlParameter);
177
+ });
178
+ // make sure to hide the button when we have VR support directly on the device
179
+ if (!isMozillaXR()) {// WebXR Viewer can't attach events before session start
180
+ navigator.xr?.addEventListener("devicechange", () => {
181
+ if (navigator.xr?.isSessionSupported("immersive-vr")) {
182
+ button.style.display = "none";
183
+ }
184
+ else {
185
+ button.style.display = "";
186
+ }
187
+ });
188
+ }
189
+ this.shadowRoot?.appendChild(button);
190
+ return button;
191
+ }
192
+
193
+ async createQRCode() {
194
+ const wrapper = document.createElement("div");
195
+ wrapper.style.position = "relative";
196
+ wrapper.style.display = "inline-block";
197
+
198
+ const qrCodeContainer = document.createElement("div");
199
+ qrCodeContainer.classList.add("qr-code-container");
200
+ qrCodeContainer.classList.add("hidden");
201
+ generateAndInsertQRCode();
202
+
203
+ const qrCodeButton = document.createElement("button");
204
+ qrCodeButton.innerText = "QR Code";
205
+ qrCodeButton.title = "Scan this QR code with your phone to open this page";
206
+
207
+ qrCodeButton.addEventListener("click", () => {
208
+ qrCodeContainer.classList.toggle("hidden");
209
+ if (qrCodeContainer.classList.contains("hidden")) return;
210
+ // generate the qr code when the button is clicked - this ensures that we get the QRcode with the latest URL
211
+ generateAndInsertQRCode();
212
+ });
213
+ async function generateAndInsertQRCode() {
214
+ const size = 256;
215
+ const code = await generateQRCode({
216
+ text: window.location.href,
217
+ width: size,
218
+ height: size,
219
+ });
220
+ qrCodeContainer.innerHTML = "";
221
+ qrCodeContainer.appendChild(code);
222
+ }
223
+
224
+ wrapper.appendChild(qrCodeButton);
225
+ wrapper.appendChild(qrCodeContainer);
226
+
227
+ this.shadowRoot?.appendChild(wrapper);
228
+ }
229
+
230
+ private updateSessionSupported(button: HTMLButtonElement, mode: XRSessionMode) {
231
+ if(!navigator.xr){
232
+ button.style.display = "none";
233
+ return;
234
+ }
235
+ navigator.xr.isSessionSupported(mode).then(supported => {
236
+ button.style.display = !supported ? "none" : "";
237
+ if (isDevEnvironment() && !supported) console.warn(mode + " is not supported on this device - make sure your server runs using HTTPS and you have a device connected that supports " + mode);
238
+ });
239
+ }
240
+
241
+ private listenToXRSessionState(button: HTMLButtonElement, mode: XRSessionMode) {
242
+ NeedleXRSession.onSessionRequestStart(args => {
243
+ if (args.mode === mode) {
244
+ button.classList.add("this-mode-is-requested");
245
+ // button["original-text"] = button.innerText;
246
+ // let modeText = mode === "immersive-vr" ? "VR" : "AR";
247
+ // button.innerText = "Starting " + modeText + "...";
248
+ }
249
+ else {
250
+ button["was-disabled"] = button.disabled;
251
+ button.disabled = true;
252
+ button.classList.add("other-mode-is-requested");
253
+ }
254
+ });
255
+ NeedleXRSession.onSessionRequestEnd(_ => {
256
+ button.classList.remove("this-mode-is-requested");
257
+ button.classList.remove("other-mode-is-requested");
258
+ button.disabled = button["was-disabled"];
259
+ // button.innerText = button["original-text"];
260
+ });
261
+ }
262
+ }
263
+
264
+ if (!customElements.get(webXRElementName))
265
+ customElements.define(webXRElementName, NeedleWebXRHtmlElement);
src/engine-components/webxr/controllers/XRControllerFollow.ts ADDED
@@ -0,0 +1,58 @@
1
+
2
+ import { serializable } from "../../../engine/engine_serialization_decorator.js";
3
+ import { NeedleXREventArgs } from "../../../engine/engine_xr.js";
4
+ import { Behaviour } from "../../Component.js";
5
+
6
+
7
+ /** Add this script to an object and set `side` to make the object follow a specific controller */
8
+ export class XRControllerFollow extends Behaviour {
9
+
10
+ // override active and enabled here so that we always receive xr update events
11
+ get activeAndEnabled() {
12
+ return true;
13
+ }
14
+
15
+ /** should this object follow a right hand/controller or left hand/controller */
16
+ @serializable()
17
+ side: XRHandedness = "none";
18
+
19
+ /** should it follow controllers (the physics controller) */
20
+ @serializable()
21
+ controller: boolean = true;
22
+
23
+ /** should it follow hands (when using hand tracking in WebXR) */
24
+ hands: boolean = false;
25
+
26
+ /** Disable if you don't want this script to modify the object's visibility
27
+ * If enabled the object will be hidden when the configured controller or hand is not available
28
+ * If disabled this script will not modify the object's visibility
29
+ */
30
+ controlVisibility: boolean = true;
31
+
32
+ onUpdateXR(args: NeedleXREventArgs): void {
33
+
34
+ // try to get the controller
35
+ const ctrl = args.xr.getController(this.side);
36
+ if (ctrl) {
37
+ // check if this is a hand and hands are allowed
38
+ if (ctrl.hand && !this.hands) {
39
+ if (this.controlVisibility)
40
+ this.gameObject.visible = false;
41
+ return;
42
+ }
43
+ // check if this is a controller and controllers are allowed
44
+ else if (!this.controller) {
45
+ if (this.controlVisibility)
46
+ this.gameObject.visible = false;
47
+ return;
48
+ }
49
+ // we're following a controller (or hand)
50
+ if (this.controlVisibility)
51
+ this.gameObject.visible = true;
52
+ this.gameObject.worldPosition = ctrl.gripWorldPosition;
53
+ this.gameObject.worldQuaternion = ctrl.gripWorldQuaternion;
54
+ }
55
+
56
+ }
57
+
58
+ }
src/engine-components/webxr/controllers/XRControllerModel.ts ADDED
@@ -0,0 +1,252 @@
1
+ import { Behaviour, GameObject } from "../../Component.js"
2
+ import { XRControllerModelFactory } from "three/examples/jsm/webxr/XRControllerModelFactory.js";
3
+ import { AssetReference } from "../../../engine/engine_addressables.js";
4
+ import { NeedleXRController, NeedleXRControllerEventArgs, NeedleXREventArgs, NeedleXRSession } from "../../../engine/engine_xr.js";
5
+ import { serializable } from "../../../engine/engine_serialization_decorator.js";
6
+ import { IGameObject } from "../../../engine/engine_types.js";
7
+ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
8
+ import { addDracoAndKTX2Loaders } from "../../../engine/engine_loaders.js";
9
+ import { XRHandMeshModel } from "three/examples/jsm/webxr/XRHandMeshModel.js";
10
+ import { AxesHelper, Group, Material, Mesh, Object3D } from "three";
11
+ import { getParam } from "../../../engine/engine_utils.js";
12
+ import { showBalloonWarning } from "../../../engine/debug/index.js";
13
+
14
+ const debug = getParam("debugwebxr");
15
+
16
+ export class XRControllerModel extends Behaviour {
17
+
18
+ @serializable()
19
+ createControllerModel: boolean = true;
20
+
21
+ @serializable()
22
+ createHandModel: boolean = true;
23
+
24
+ /** assign a model or model url to create custom hand models */
25
+ @serializable(AssetReference)
26
+ customLeftHand?: AssetReference;
27
+ /** assign a model or model url to create custom hand models */
28
+ @serializable(AssetReference)
29
+ customRightHand?: AssetReference;
30
+
31
+
32
+ static readonly factory: XRControllerModelFactory = new XRControllerModelFactory();
33
+
34
+ supportsXR(mode: XRSessionMode): boolean {
35
+ return mode === "immersive-vr" || mode === "immersive-ar";
36
+ }
37
+
38
+ private _models = new Array<{ model?: IGameObject, controller: NeedleXRController, handmesh?: XRHandMeshModel }>();
39
+
40
+
41
+ async onXRControllerAdded(args: NeedleXRControllerEventArgs) {
42
+
43
+ // TODO we may want to treat controllers differently in AR/Passthrough mode
44
+ const isSupportedSession = args.xr.isVR || args.xr.isPassThrough;
45
+ if (!isSupportedSession) return;
46
+
47
+ const { controller } = args;
48
+
49
+ if (debug) console.warn("Add Controller Model for", controller.side, controller.index)
50
+
51
+ if (this.createControllerModel) {
52
+ if (controller.hand) {
53
+ if (this.createHandModel) {
54
+ const res = await this.loadHandModel(controller);
55
+ if (!res || !controller.connected) return;
56
+ this._models[controller.index] = { controller: controller, model: res.handObject, handmesh: res.handmesh };
57
+ this.scene.add(res.handObject);
58
+ }
59
+ }
60
+ else {
61
+ if (this.createControllerModel) {
62
+ const assetUrl = await controller.getModelUrl();
63
+ if (assetUrl) {
64
+ const model = await this.loadModel(controller, assetUrl);
65
+ if (!model || !controller.connected) return;
66
+ this._models[controller.index] = { controller: controller, model };
67
+ this.scene.add(model);
68
+ // The controller mesh should by default inherit layers.
69
+ model.traverse(child => {
70
+ child.layers.disableAll();
71
+ child.layers.enable(2);
72
+ });
73
+ }
74
+ else {
75
+ console.warn("XRControllerModel: no model found for " + controller.side);
76
+ }
77
+ }
78
+ }
79
+ }
80
+ }
81
+ onXRControllerRemoved(args: NeedleXRControllerEventArgs): void {
82
+ // we need to find the index by the controller because if controller 0 is removed first then args.controller.index 1 will be at index 0
83
+ const indexInArray = this._models.findIndex(m => m?.controller === args.controller);
84
+ const entry = this._models[indexInArray];
85
+ if (!entry) return;
86
+ this._models.splice(indexInArray, 1);
87
+
88
+ if (entry.handmesh) {
89
+ entry.handmesh.handModel?.removeFromParent();
90
+ }
91
+ if (entry.model) {
92
+ entry.model.removeFromParent();
93
+ }
94
+ }
95
+ onBeforeRender() {
96
+ if (!NeedleXRSession.active) return;
97
+
98
+ const xr = NeedleXRSession.active;
99
+
100
+ for (let i = 0; i < this._models.length; i++) {
101
+ const entry = this._models[i];
102
+ if (!entry) continue;
103
+ const ctrl = entry.controller;
104
+ if (!ctrl.connected) {
105
+ // the actual removal of the model happens in onXRControllerRemoved
106
+ if (debug) console.warn("XRControllerModel.onUpdateXR: controller is not connected anymore", ctrl.side, ctrl.hand);
107
+ continue;
108
+ }
109
+
110
+ // do we have a controller model?
111
+ if (entry.model && !entry.handmesh) {
112
+ // TODO: if the model would just be in the XR rig, we could just set grip position and we would not need to override the scale
113
+ // entry.model.position.copy(ctrl.gripWorldPosition);
114
+ entry.model.position.copy(ctrl.gripPosition);
115
+ // entry.model.quaternion.copy(ctrl.gripWorldQuaternion);
116
+ entry.model.quaternion.copy(ctrl.gripQuaternion);
117
+ entry.model.visible = ctrl.isTracking;
118
+ // ensure that controller models are in rig space
119
+ xr.rig?.gameObject.add(entry.model);
120
+ }
121
+ // do we have a hand mesh?
122
+ else if (ctrl.inputSource.hand && entry.handmesh) {
123
+ const referenceSpace = xr.referenceSpace;
124
+ const hand = this.context.renderer.xr.getHand(ctrl.index);
125
+ if (referenceSpace && xr.frame.getJointPose) {
126
+ for (const inputjoint of ctrl.inputSource.hand.values()) {
127
+ // Update the joints groups with the XRJoint poses
128
+ const jointPose = xr.frame.getJointPose(inputjoint, referenceSpace);
129
+ // The transform of this joint will be updated with the joint pose on each frame
130
+ const joint = hand.joints[inputjoint.jointName];
131
+ if (joint) {
132
+ if (jointPose) {
133
+ const { position, quaternion } = xr.convertSpace(jointPose.transform);
134
+ joint.position.copy(position);
135
+ joint.quaternion.copy(quaternion);
136
+ joint.matrixWorldNeedsUpdate = true;
137
+ // joint.jointRadius = jointPose.radius;
138
+ }
139
+ joint.visible = jointPose != null;
140
+ }
141
+ }
142
+ // ensure that the hand renders in rig space
143
+ if (entry.model) {
144
+ entry.model.visible = ctrl.isTracking;
145
+ if (entry.model.parent !== xr.rig?.gameObject) {
146
+ entry.model.position.set(0, 0, 0);
147
+ xr.rig?.gameObject.add(entry.model);
148
+ }
149
+ }
150
+
151
+ entry.handmesh?.updateMesh();
152
+ }
153
+ }
154
+ }
155
+ }
156
+ onLeaveXR(_args: NeedleXREventArgs): void {
157
+ for (const entry of this._models) {
158
+ if (!entry) continue;
159
+ entry.model?.removeFromParent();
160
+ }
161
+ this._models = [];
162
+ }
163
+
164
+ protected async loadModel(controller: NeedleXRController, url: string): Promise<IGameObject | null> {
165
+ if (!controller.connected) {
166
+ console.warn("XRControllerModel.onXRControllerAdded: controller is not connected anymore", controller.side);
167
+ return null;
168
+ }
169
+ const assetReference = AssetReference.getOrCreate("", url);
170
+ const model = await assetReference.instantiate() as GameObject;
171
+
172
+ if (NeedleXRSession.active?.isPassThrough) {
173
+ model.traverseVisible((obj: Object3D) => {
174
+ this.makeOccluder(obj);
175
+ })
176
+ }
177
+ return model as IGameObject;
178
+ }
179
+
180
+ protected async loadHandModel(controller: NeedleXRController): Promise<{ handObject: IGameObject, handmesh: XRHandMeshModel } | null> {
181
+
182
+ const context = this.context;
183
+ const hand = context.renderer.xr.getHand(controller.index);
184
+
185
+ const loader = new GLTFLoader();
186
+ addDracoAndKTX2Loaders(loader, context);
187
+ loader.setPath('https://cdn.jsdelivr.net/npm/@webxr-input-profiles/[email protected]/dist/profiles/generic-hand/');
188
+
189
+ // TODO: we should handle the loading here ourselves to not have this requirement of a specific model name
190
+ const expectedHandModelName = controller.side === "left" ? "left." : "right.";
191
+ const customHand = controller.side === "left" ? this.customLeftHand : this.customRightHand;
192
+ if (customHand) {
193
+ if (!customHand.uri.includes(expectedHandModelName)) {
194
+ console.warn("XRControllerModel: custom hand model must be named " + expectedHandModelName);
195
+ showBalloonWarning("Custom Hand: unexpected name, please see the console for details");
196
+ }
197
+ else {
198
+ const basePath = customHand.uri.substring(0, customHand.uri.indexOf(expectedHandModelName));
199
+ loader.setPath(basePath);
200
+ if(debug) console.log("XRControllerModel: loading custom hand model from " + basePath);
201
+ }
202
+ }
203
+
204
+
205
+ const handObject = new Object3D();
206
+ // @ts-ignore
207
+ const handmesh = new XRHandMeshModel(handObject, hand, loader.path, controller.inputSource.handedness, loader, (object: Object3D) => {
208
+ // The hand mesh should not receive raycasts
209
+ object.traverseVisible(child => {
210
+ child.layers.disableAll();
211
+ child.layers.enable(2);
212
+ if (NeedleXRSession.active?.isPassThrough)
213
+ this.makeOccluder(child);
214
+ });
215
+ });
216
+
217
+ if (debug) handObject.add(new AxesHelper(.5));
218
+
219
+ if (controller.inputSource.hand) {
220
+ if (debug) console.log(controller.inputSource.hand);
221
+ for (const inputjoint of controller.inputSource.hand.values()) {
222
+
223
+ if (hand.joints[inputjoint.jointName] === undefined) {
224
+
225
+ const joint = new Group();
226
+ joint.matrixAutoUpdate = false;
227
+ joint.visible = true;
228
+ // joint.jointRadius = 0.01;
229
+ // @ts-ignore
230
+ hand.joints[inputjoint.jointName] = joint;
231
+ hand.add(joint);
232
+
233
+ }
234
+ }
235
+ }
236
+ return { handObject: handObject as IGameObject, handmesh: handmesh };
237
+ }
238
+
239
+ private makeOccluder(obj: Object3D) {
240
+ if (obj instanceof Mesh) {
241
+ let mat = obj.material;
242
+ if (mat instanceof Material) {
243
+ mat = obj.material = mat.clone();
244
+ // depth only
245
+ mat.depthWrite = true;
246
+ mat.depthTest = true;
247
+ mat.colorWrite = false;
248
+ obj.renderOrder = -100;
249
+ }
250
+ }
251
+ }
252
+ }
src/engine-components/webxr/controllers/XRControllerMovement.ts ADDED
@@ -0,0 +1,282 @@
1
+ import { Behaviour, GameObject } from "../../Component.js"
2
+ import { AdditiveBlending, BufferAttribute, Color, DoubleSide, Line, Line3, Mesh, MeshBasicMaterial, Object3D, RingGeometry, SubtractiveBlending, Vector3 } from "three";
3
+ import { Mathf } from "../../../engine/engine_math.js";
4
+ import { Gizmos } from "../../../engine/engine_gizmos.js";
5
+ import { NeedleXRController, NeedleXREventArgs, NeedleXRSession } from "../../../engine/engine_xr.js";
6
+ import { TeleportTarget } from "../TeleportTarget.js";
7
+ import { XRMovementBehaviour } from "../types.js";
8
+ import { serializable } from "../../../engine/engine_serialization.js"
9
+ import { IGameObject } from "../../../engine/engine_types.js";
10
+ import { getWorldQuaternion, getWorldScale } from "../../../engine/engine_three_utils.js";
11
+ import { getParam } from "../../../engine/engine_utils.js";
12
+
13
+ import { Line2 } from "three/examples/jsm/lines/Line2.js";
14
+ import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
15
+ import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
16
+
17
+ const debug = getParam("debugwebxr");
18
+
19
+ export class XRControllerMovement extends Behaviour implements XRMovementBehaviour {
20
+
21
+ /** Movement speed in meters per second */
22
+ @serializable()
23
+ movementSpeed = 1;
24
+
25
+ /** How many degrees to rotate the XR rig when using the rotation trigger */
26
+ @serializable()
27
+ rotationStep = 60;
28
+
29
+ /** Enable to only oallow teleporting on objects with a teleport target component */
30
+ @serializable()
31
+ useTeleportTarget = false;
32
+
33
+ readonly isXRMovementHandler: true = true;
34
+
35
+ readonly xrSessionMode = "immersive-vr";
36
+
37
+ private _didApplyRotation = false;
38
+ private _didTeleport = false;
39
+
40
+ onUpdateXR(args: NeedleXREventArgs): void {
41
+ const rig = args.xr.rig;
42
+ if (!rig?.gameObject) return;
43
+
44
+ // in AR pass through mode we dont want to move the rig
45
+ if (args.xr.isPassThrough) {
46
+ this.renderRays(args.xr);
47
+ this.renderHits(args.xr);
48
+ return;
49
+ }
50
+
51
+ const movementController = args.xr.leftController;
52
+ const teleportController = args.xr.rightController;
53
+
54
+ if (movementController)
55
+ this.onHandleMovement(movementController, rig.gameObject);
56
+ if (teleportController) {
57
+ this.onHandleRotation(teleportController, rig.gameObject);
58
+ this.onHandleTeleport(teleportController, rig.gameObject);
59
+ }
60
+
61
+ this.renderRays(args.xr);
62
+ this.renderHits(args.xr);
63
+ }
64
+ onLeaveXR(_: NeedleXREventArgs): void {
65
+ for (const line of this._lines) {
66
+ line.removeFromParent();
67
+ }
68
+ for (const disc of this._hitDiscs) {
69
+ disc?.removeFromParent();
70
+ }
71
+ }
72
+
73
+ protected onHandleMovement(controller: NeedleXRController, rig: IGameObject) {
74
+ const stick = controller.getStick("xr-standard-thumbstick");
75
+ const vec = new Vector3(stick.x, 0, stick.y);
76
+ vec.multiplyScalar(this.context.time.deltaTime * this.movementSpeed);
77
+ const scale = getWorldScale(rig);
78
+ vec.multiplyScalar(scale.x);
79
+ vec.applyQuaternion(controller.xr.poseOrientation);
80
+ vec.y = 0;
81
+ vec.applyQuaternion(rig.worldQuaternion);
82
+ rig.position.add(vec);
83
+
84
+ // TODO: if we dont do this here the XRControllerModel will be frame-delayed - maybe we need to introduce a priority order for XR components?
85
+ rig.updateMatrixWorld();
86
+ }
87
+
88
+
89
+ protected onHandleRotation(controller: NeedleXRController, rig: IGameObject) {
90
+ const stick = controller.getStick("xr-standard-thumbstick");
91
+ const rotationInput = stick.x;
92
+ if (this._didApplyRotation) {
93
+ if (Math.abs(rotationInput) < .3) {
94
+ this._didApplyRotation = false;
95
+ }
96
+ }
97
+ else if (Math.abs(rotationInput) > .5) {
98
+ this._didApplyRotation = true;
99
+ const dir = rotationInput > 0 ? 1 : -1;
100
+ rig.rotateY(dir * Mathf.toRadians(this.rotationStep));
101
+ }
102
+
103
+ const pos = controller.rayWorldPosition;
104
+ pos.y += .1
105
+ if (debug) Gizmos.DrawLabel(pos, stick.x.toFixed(2) + ", " + stick.y.toFixed(2), .02, 0)
106
+ }
107
+
108
+ protected onHandleTeleport(controller: NeedleXRController, rig: IGameObject) {
109
+ const teleportInput = controller.getStick("xr-standard-thumbstick")
110
+ if (this._didTeleport) {
111
+ if (teleportInput.y < .2) {
112
+ this._didTeleport = false;
113
+ }
114
+ }
115
+ else if (teleportInput.y > .8) {
116
+ this._didTeleport = true;
117
+ const hit = this.context.physics.raycastFromRay(controller.ray)[0];
118
+ if (hit) {
119
+ if (this.useTeleportTarget) {
120
+ const teleportTarget = GameObject.getComponentInParent(hit.object, TeleportTarget);
121
+ if (!teleportTarget) return;
122
+ }
123
+ rig.worldPosition = hit.point;
124
+ if (debug) Gizmos.DrawSphere(hit.point, .025, 0xff0000, 5);
125
+ }
126
+ else {
127
+ // TODO: add option to allow teleportation on current ground plane
128
+ }
129
+ }
130
+ }
131
+
132
+ private readonly _lines: Object3D[] = [];
133
+ private readonly _hitDiscs: Object3D[] = [];
134
+
135
+ protected renderRays(session: NeedleXRSession) {
136
+
137
+ if (session.controllers.length < this._lines.length) {
138
+ for (let i = session.controllers.length; i < this._lines.length; i++) {
139
+ const line = this._lines[i];
140
+ line.visible = false;
141
+ }
142
+ }
143
+
144
+ for (const disc of this._hitDiscs) {
145
+ if (disc) disc.visible = false;
146
+ }
147
+
148
+ for (let i = 0; i < session.controllers.length; i++) {
149
+ const ctrl = session.controllers[i];
150
+ let line = this._lines[i];
151
+ if (!line) {
152
+ line = createGradientLine();
153
+ line.scale.z = .5;
154
+ this._lines[i] = line;
155
+ }
156
+
157
+ const pos = ctrl.rayWorldPosition;
158
+ const rot = ctrl.rayWorldQuaternion;
159
+ line.position.copy(pos);
160
+ line.quaternion.copy(rot);
161
+ line.visible = true;
162
+ line.layers.disableAll();
163
+ line.layers.enable(2);
164
+ if (line.parent !== this.context.scene)
165
+ this.context.scene.add(line);
166
+ }
167
+ }
168
+
169
+ protected renderHits(session: NeedleXRSession) {
170
+ for (const disc of this._hitDiscs) {
171
+ if (disc) disc.visible = false;
172
+ }
173
+ for (let i = 0; i < session.controllers.length; i++) {
174
+ const ctrl = session.controllers[i];
175
+
176
+ const hit = this.context.physics.raycastFromRay(ctrl.ray, {})[0];
177
+ if (hit) {
178
+ const rigScale = (session.rig?.gameObject.worldScale.x ?? 1);
179
+ if (debug) Gizmos.DrawWireSphere(hit.point, .025 * rigScale, 0xff0000, .2);
180
+
181
+ let disc = this._hitDiscs[i];
182
+ if (!disc) {
183
+ disc = createHitDisc();
184
+ this._hitDiscs[i] = disc;
185
+ }
186
+ disc.visible = true;
187
+ const size = .01 * (1 + hit.distance) * rigScale;
188
+ disc.scale.set(size, size, size);
189
+ disc.layers.disableAll();
190
+ disc.layers.enable(2);
191
+
192
+ if (hit.normal) {
193
+ const factor = 0.02 * rigScale;
194
+ disc.position.set(0, 0, -.1 * factor).applyQuaternion(ctrl.rayWorldQuaternion);
195
+ disc.position.add(hit.point);
196
+ const worldNormal = hit.normal.applyQuaternion(getWorldQuaternion(hit.object));
197
+ disc.quaternion.setFromUnitVectors(up, worldNormal);
198
+ }
199
+ else {
200
+ disc.position.add(hit.point);
201
+ }
202
+
203
+ if (disc.parent !== this.context.scene) {
204
+ this.context.scene.add(disc);
205
+ }
206
+ }
207
+ else {
208
+ if (this._hitDiscs[i]) {
209
+ this._hitDiscs[i].visible = false;
210
+ }
211
+ }
212
+ }
213
+ }
214
+ }
215
+
216
+
217
+ const up = new Vector3(0, 1, 0);
218
+ function createHitDisc(): Object3D {
219
+ var container = new Object3D();
220
+ const disc = new Mesh(
221
+ new RingGeometry(.3, 0.5, 32).rotateX(- Math.PI / 2),
222
+ new MeshBasicMaterial({
223
+ color: 0xeeeeee,
224
+ opacity: .7,
225
+ transparent: true,
226
+ side: DoubleSide,
227
+ })
228
+ );
229
+ disc.layers.disableAll();
230
+ disc.layers.enable(2);
231
+ container.add(disc);
232
+
233
+ const disc2 = new Mesh(
234
+ new RingGeometry(.43, 0.5, 32).rotateX(- Math.PI / 2),
235
+ new MeshBasicMaterial({
236
+ color: 0x000000,
237
+ opacity: .2,
238
+ transparent: true,
239
+ side: DoubleSide,
240
+ })
241
+ );
242
+ disc2.layers.disableAll();
243
+ disc2.layers.enable(2);
244
+ disc2.position.z -= .01;
245
+ container.add(disc2);
246
+
247
+ return container;
248
+ }
249
+
250
+ function createGradientLine() {
251
+ const line = new Line2();
252
+ line.layers.disableAll();
253
+ line.layers.enable(2);
254
+
255
+ const geometry = new LineGeometry();
256
+ line.geometry = geometry;
257
+
258
+ const positions = new Float32Array(9);
259
+ positions.set([0, 0, .02, 0, 0, .4, 0, 0, 1]);
260
+ geometry.setPositions(positions)
261
+
262
+ const colors = new Float32Array(9);
263
+ colors.set([1, 1, 1, .1, .1, .1, 0, 0, 0]);
264
+ geometry.setColors(colors);
265
+
266
+ const mat = new LineMaterial({
267
+ color: 0xffffff,
268
+ vertexColors: true,
269
+ worldUnits: true,
270
+ linewidth: .004,
271
+
272
+ transparent: true,
273
+ // TODO: this doesnt work with passthrough
274
+ blending: AdditiveBlending,
275
+ dashed: false,
276
+ alphaToCoverage: true,
277
+
278
+ });
279
+ line.material = mat;
280
+
281
+ return line;
282
+ }
src/engine-components/webxr/XRFlag.ts ADDED
@@ -0,0 +1,143 @@
1
+ import { Behaviour, GameObject } from "../Component.js";
2
+ import { getParam } from "../../engine/engine_utils.js";
3
+ import { serializable } from "../../engine/engine_serialization_decorator.js";
4
+
5
+
6
+ const debug = getParam("debugxrflags");
7
+ const disable = getParam("disablexrflags");
8
+ if (disable) { console.warn("XRFlags are disabled") }
9
+
10
+ export enum XRStateFlag {
11
+ Never = 0,
12
+ Browser = 1 << 0,
13
+ AR = 1 << 1,
14
+ VR = 1 << 2,
15
+ FirstPerson = 1 << 3,
16
+ ThirdPerson = 1 << 4,
17
+ All = 0xffffffff
18
+ }
19
+
20
+ export class XRState {
21
+
22
+ public static Global: XRState = new XRState();
23
+
24
+ public Mask: XRStateFlag = XRStateFlag.Browser | XRStateFlag.ThirdPerson;
25
+
26
+ public Has(state: XRStateFlag) {
27
+ const res = (this.Mask & state);
28
+ return res !== 0;
29
+ }
30
+
31
+ public Set(state: number) {
32
+ if (debug) console.warn("Set XR flag state to", state)
33
+ this.Mask = state as number;
34
+ XRFlag.Apply();
35
+ }
36
+
37
+ public Enable(state: number) {
38
+ this.Mask |= state;
39
+ XRFlag.Apply();
40
+ }
41
+
42
+ public Disable(state: number) {
43
+ this.Mask &= ~state;
44
+ XRFlag.Apply();
45
+ }
46
+
47
+ public Toggle(state: number) {
48
+ this.Mask ^= state;
49
+ XRFlag.Apply();
50
+ }
51
+
52
+ public EnableAll() {
53
+ this.Mask = 0xffffffff | 0;
54
+ XRFlag.Apply();
55
+ }
56
+
57
+ public DisableAll() {
58
+ this.Mask = 0;
59
+ XRFlag.Apply();
60
+ }
61
+ }
62
+
63
+ export class XRFlag extends Behaviour {
64
+
65
+ private static registry: XRFlag[] = [];
66
+
67
+ public static Apply() {
68
+ for (const r of this.registry) r.UpdateVisible(XRState.Global);
69
+ }
70
+
71
+ private static firstApply: boolean;
72
+ private static buffer: XRState = new XRState();
73
+
74
+ @serializable()
75
+ public visibleIn!: number;
76
+
77
+ awake() {
78
+ XRFlag.registry.push(this);
79
+ }
80
+
81
+ onEnable(): void {
82
+ if (!XRFlag.firstApply) {
83
+ XRFlag.firstApply = true;
84
+ XRFlag.Apply();
85
+ }
86
+ else {
87
+ this.UpdateVisible(XRState.Global);
88
+ }
89
+ }
90
+
91
+ onDestroy(): void {
92
+ const i = XRFlag.registry.indexOf(this);
93
+ if (i >= 0)
94
+ XRFlag.registry.splice(i, 1);
95
+ }
96
+
97
+ public get isOn(): boolean { return this.gameObject.visible; }
98
+
99
+ public UpdateVisible(state: XRState | XRStateFlag | null = null) {
100
+ if (disable) {
101
+ return;
102
+ }
103
+ // XR flags set visibility of whole hierarchy which is like setting the whole object inactive
104
+ // so we need to ignore the enabled state of the XRFlag component
105
+ // if(!this.enabled) return;
106
+ let res: boolean | undefined = undefined;
107
+
108
+ const flag = state as number;
109
+ if (flag && typeof flag === "number") {
110
+ console.assert(typeof flag === "number", "XRFlag.UpdateVisible: state must be a number", flag);
111
+ if (debug)
112
+ console.log(flag);
113
+ XRFlag.buffer.Mask = flag;
114
+ state = XRFlag.buffer;
115
+ }
116
+
117
+ if (state instanceof XRState) {
118
+ if (debug)
119
+ console.warn(this.name, "use passed in mask", state.Mask, this.visibleIn)
120
+ res = state.Has(this.visibleIn);
121
+ }
122
+ else {
123
+ if (debug)
124
+ console.log(this.name, "use global mask")
125
+ XRState.Global.Has(this.visibleIn);
126
+ }
127
+ if (res === undefined) return;
128
+ if (res) {
129
+ if (debug)
130
+ console.log(this.name, "is visible", this.gameObject.uuid)
131
+ // this.gameObject.visible = true;
132
+ GameObject.setActive(this.gameObject, true);
133
+ } else {
134
+ if (debug)
135
+ console.log(this.name, "is not visible", this.gameObject.uuid);
136
+ const isVisible = this.gameObject.visible;
137
+ if (!isVisible) return;
138
+ this.gameObject.visible = false;
139
+ // console.trace("DISABLE", this.name);
140
+ // GameObject.setActive(this.gameObject, false);
141
+ }
142
+ }
143
+ }
src/engine/xr/XRRig.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { IComponent } from "../engine_types.js";
2
+
3
+
4
+ export interface IXRRig extends Pick<IComponent, "gameObject"> {
5
+ isXRRig(): boolean;
6
+ get isActive(): boolean;
7
+ /** The rig with the highest priority will be chosen */
8
+ priority?: number;
9
+ }