Needle Engine

Changes between version 3.46.0-beta.5 and 3.46.1-beta
Files changed (14) hide show
  1. plugins/vite/pwa.js +34 -19
  2. src/engine-components/Animation.ts +33 -15
  3. src/engine-components/DragControls.ts +8 -28
  4. src/engine-components/Duplicatable.ts +60 -13
  5. src/engine/engine_context.ts +22 -3
  6. src/engine/engine_input.ts +20 -17
  7. src/engine/engine_physics.ts +38 -8
  8. src/engine/engine_utils.ts +29 -1
  9. src/engine-components/GroundProjection.ts +2 -6
  10. src/engine/xr/NeedleXRController.ts +92 -20
  11. src/engine-components/OrbitControls.ts +25 -6
  12. src/engine-components/ui/PointerEvents.ts +0 -2
  13. src/engine-components/SyncedRoom.ts +3 -1
  14. src/engine-components/webxr/controllers/XRControllerMovement.ts +96 -42
plugins/vite/pwa.js CHANGED
@@ -1,5 +1,5 @@
1
1
 
2
- import { copyFileSync, existsSync, mkdirSync,readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
2
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
3
3
 
4
4
  import { getOutputDirectory } from './config.js';
5
5
  import { getPosterPath } from './poster.js';
@@ -8,7 +8,6 @@
8
8
  /** Provides reasonable defaults for a PWA manifest and workbox settings.
9
9
  * @param {import('../types').userSettings} userSettings
10
10
  * @param {import("../types/needleConfig").needleMeta | null} config
11
- * @param {import("../types/userconfig").userSettings | null} userSettings
12
11
  * @returns {import('vite').Plugin | void}
13
12
  */
14
13
  export const needlePWA = (command, config, userSettings) => {
@@ -19,7 +18,7 @@
19
18
  /** The context contains files that are generated by the plugin and should be deleted after
20
19
  * @type {import('../types').NeedlePWAProcessContext} */
21
20
  const context = { generatedFiles: [] }
22
-
21
+
23
22
  // early out, explicitly disabled
24
23
  if (pwaOptions === false) return;
25
24
 
@@ -28,24 +27,20 @@
28
27
  return {
29
28
  name: "needle-pwa",
30
29
  apply: "build",
31
- configResolved(config) {
32
- if (findVitePWAPlugin(config)) {
30
+ configResolved(viteConfig) {
31
+ if (findVitePWAPlugin(viteConfig)) {
33
32
  errorThrow("It seems that you're trying to build a PWA using `vite-plugin-pwa`!\nNeedle can manage PWA settings for you – just pass the same `pwaOptions` to the `needlePlugin` and `VitePWA` plugins:\n\n import { VitePWA } from 'vite-plugin-pwa';\n ...\n needlePlugins(command, needleConfig, { pwa: pwaOptions }),\n VitePWA(pwaOptions),\n\nIf you want to manage PWA building yourself and skip this check, please pass `{ pwa: false }` to needlePlugins.");
34
33
  }
35
34
  },
36
35
  }
37
36
  }
38
-
37
+
39
38
  // @ts-ignore // TODO need an extra type so we can add more options into the VitePWAOptions object
40
39
  /** @type {number | undefined} */
41
40
  const updateInterval = pwaOptions.updateInterval || undefined;
42
41
 
43
42
  if ((command !== "build" && !pwaOptions?.devOptions?.enabled) || pwaOptions?.disable) return;
44
43
 
45
- // The PWA files should not have gzip compression – this will mess up with the precache and the service worker.
46
- // If gzip is wanted, the server should serve files with gzip compression on the fly.
47
- if (config) config.gzip = false;
48
-
49
44
  if (!pwaOptions.registerType) {
50
45
  // log("Set PWA registerType to autoUpdate. This will automatically update the service worker when the content changes. If you want to manually update the service worker set the registerType to manual. See https://vite-pwa-org.netlify.app/guide/#configuring-vite-plugin-pwa");
51
46
  pwaOptions.registerType = "autoUpdate";
@@ -87,11 +82,27 @@
87
82
  name: 'needle-pwa',
88
83
  apply: 'build',
89
84
  enforce: "post",
85
+ config(viteConfig) {
86
+ // Remove the gzip plugin
87
+ if(viteConfig.plugins){
88
+ for(let i = viteConfig.plugins.length-1; i >= 0; i--) {
89
+ const plugin = viteConfig.plugins[i];
90
+ if(plugin && "name" in plugin && plugin.name === "vite:compression") {
91
+ // override the closeBundle method so gzip is not be applied
92
+ // The PWA files should not have gzip compression – this will mess up with the precache and the service worker.
93
+ // If gzip is wanted, the server should serve files with gzip compression on the fly.
94
+ plugin.closeBundle = () => {
95
+ console.log("[needle-pwa] gzip compression is disabled because you're building a PWA. This will otherwise mess up with the precache and the service worker.");
96
+ }
97
+ }
98
+ }
99
+ }
100
+ },
90
101
  configResolved(config) {
91
102
  try {
92
103
  const plugin = findVitePWAPlugin(config);
93
104
  if (!plugin) {
94
- errorThrow("It seems that you're trying to build a PWA!.\nRun `npm install vite-plugin-pwa`, and add VitePWA to the vite config:\n\n import { VitePWA } from 'vite-plugin-pwa';\n ...\n needlePlugins(command, needleConfig, { pwa: pwaOptions }),\n VitePWA(pwaOptions),\n\nIf you don't intend to build a PWA, pass `{ pwa: false }` to needlePlugins or remove the `pwa` entry.");
105
+ errorThrow("It seems that you're trying to build a PWA!.\nRun `npm install vite-plugin-pwa --save-dev` to install the plugin\nThen add VitePWA to your vite.config.js and pass the pwaOptions to both Needle and VitePWA:\n\n import { VitePWA } from 'vite-plugin-pwa';\n \n const pwaOptions = {};\n \n plugins: [\n needlePlugins(command, needleConfig, { pwa: pwaOptions }),\n VitePWA(pwaOptions),\n ]\n\nIf you don't intend to build a PWA, pass `{ pwa: false }` to needlePlugins or remove the `pwa` entry.");
95
106
  }
96
107
 
97
108
  // check if the index header contains the webmanifest ALSO
@@ -201,7 +212,7 @@
201
212
  }
202
213
 
203
214
  /** Checks if the vite-plugin-pwa is present in the vite config
204
- * @param {import('vite').UserConfig} config
215
+ * @param {import('vite').ResolvedConfig} config
205
216
  * @returns {import('vite-plugin-pwa').VitePWAOptions | null}
206
217
  */
207
218
  function findVitePWAPlugin(config) {
@@ -233,7 +244,7 @@
233
244
 
234
245
  /** Throws an error with defined stacktrace.
235
246
  * @param {string} message
236
- * @param {number} stackTraceLimit How many stack frames to show in the error message
247
+ * @param {number} traceLimit How many stack frames to show in the error message
237
248
  */
238
249
  function errorThrow(message, traceLimit = 0) {
239
250
  const { stackTraceLimit } = Error;
@@ -267,16 +278,20 @@
267
278
  }
268
279
 
269
280
  processIcons(manifest, outDir, context);
270
-
281
+
271
282
  // TODO include assets in the manifest instead of manually copying
272
283
  // if (pwaOptions.includeAssets === undefined) pwaOptions.includeAssets = [];
273
284
  // pwaOptions.includeAssets = [...pwaOptions.includeAssets, ...manifest.icons?.map(i => i.src)];
274
-
285
+
275
286
  const packageJsonPath = process.cwd() + "/package.json";
276
287
  const packageJson = existsSync(packageJsonPath) ? JSON.parse(readFileSync(packageJsonPath, 'utf8')) : {};
277
288
 
278
289
  const name = packageJson.name;
279
290
  const appName = config?.sceneName || packageJson.name || "Needle App";
291
+ const description = typeof config?.meta === "string"
292
+ ? config.meta : config?.meta?.description
293
+ || packageJson.description
294
+ || "Made with Needle Engine";
280
295
 
281
296
  /** @type {Partial<import("vite-plugin-pwa").ManifestOptions>} */
282
297
  const defaultManifest = {
@@ -284,7 +299,7 @@
284
299
  name: appName,
285
300
  short_name: appName,
286
301
  id: "app.made-with-needle." + name.toLowerCase().replace(/[^a-z0-9\/\-]/g, '').replace("\/", "."),
287
- description: config.description || packageJson.description || "Made with Needle Engine",
302
+ description,
288
303
  start_url: "./index.html",
289
304
  display: "standalone",
290
305
  display_override: [
@@ -298,11 +313,11 @@
298
313
  prefer_related_applications: false,
299
314
  categories: ["spatial", "3D", "needle", "webgl", "webxr", "xr"],
300
315
  };
301
-
316
+
302
317
  // Use the poster image if that exists
303
- if (!userSettings.noPoster) {
318
+ if (!userSettings?.noPoster) {
304
319
  const posterPath = getPosterPath();
305
- defaultManifest.screenshots = [{
320
+ defaultManifest.screenshots = [{
306
321
  src: "./" + posterPath,
307
322
  type: "image/webp",
308
323
  sizes: "1080x1080", // TODO get actual size
src/engine-components/Animation.ts CHANGED
@@ -113,14 +113,18 @@
113
113
  * @default 0
114
114
  */
115
115
  get time() {
116
- for (const action of this.actions) {
117
- if (action.isRunning()) return action.time;
116
+ if (this.actions) {
117
+ for (const action of this.actions) {
118
+ if (action.isRunning()) return action.time;
119
+ }
118
120
  }
119
121
  return 0;
120
122
  }
121
123
  set time(val: number) {
122
- for (const act of this.actions) {
123
- act.time = val;
124
+ if (this.actions) {
125
+ for (const act of this.actions) {
126
+ act.time = val;
127
+ }
124
128
  }
125
129
  }
126
130
 
@@ -330,6 +334,16 @@
330
334
  }
331
335
 
332
336
  /**
337
+ * Resume all paused animations.
338
+ * Note that this will not fade animations in or out and just unpause previous animations. If an animation was faded out which means it's not running anymore, it will not be resumed.
339
+ */
340
+ resume() {
341
+ for (const act of this.actions) {
342
+ act.paused = false;
343
+ }
344
+ }
345
+
346
+ /**
333
347
  * Play an animation clip or an clip at the specified index.
334
348
  * @param clipOrNumber the animation clip, index or name to play. If undefined, the first animation in the animations array will be played
335
349
  * @param options the play options. Use to set the fade duration, loop, speed, start time, end time, clampWhenFinished
@@ -376,25 +390,28 @@
376
390
  }
377
391
 
378
392
  private internalOnPlay(action: AnimationAction, options: PlayOptions): Promise<AnimationAction> {
379
- var prev = this.actions.find(a => a === action);
380
- if (prev === action && prev.isRunning() && prev.time < prev.getClip().duration) {
393
+ var existing = this.actions.find(a => a === action);
394
+ if (existing === action && existing.isRunning() && existing.time < existing.getClip().duration) {
381
395
  const handle = this.tryFindHandle(action);
382
- if (prev.paused) {
383
- prev.paused = false;
396
+ if (existing.paused) {
397
+ existing.paused = false;
384
398
  }
385
399
  if (handle) return handle.waitForFinish();
386
400
  }
387
401
 
388
402
  // Assign defaults
389
- if (!options.minMaxOffsetNormalized) options.minMaxOffsetNormalized = this.minMaxOffsetNormalized;
390
- if (!options.minMaxSpeed) options.minMaxSpeed = this.minMaxSpeed;
391
403
  if (options.loop === undefined) options.loop = this.loop;
392
404
  if (options.clampWhenFinished === undefined) options.clampWhenFinished = this.clampWhenFinished;
405
+ if (options.minMaxOffsetNormalized === undefined && this.randomStartTime) options.minMaxOffsetNormalized = this.minMaxOffsetNormalized;
406
+ if (options.minMaxSpeed === undefined) options.minMaxSpeed = this.minMaxSpeed;
393
407
 
394
408
  // Reset currently running animations
395
409
  const stopOther = options?.exclusive ?? true;
396
410
  if (stopOther) {
397
- this.stopAll(options);
411
+ for (const act of this.actions) {
412
+ if (act != existing)
413
+ act.stop();
414
+ }
398
415
  }
399
416
  if (options?.fadeDuration) {
400
417
  action.fadeIn(options.fadeDuration);
@@ -402,13 +419,14 @@
402
419
  action.enabled = true;
403
420
 
404
421
  // Apply start time
405
- if (options?.minMaxOffsetNormalized) {
422
+ if (options?.startTime != undefined) {
423
+ action.time = options.startTime;
424
+ }
425
+ // Only apply random start offset if it's not 0:0 (default). Otherwise `play` will not resume paused animations but instead restart them
426
+ else if (options?.minMaxOffsetNormalized && options.minMaxOffsetNormalized.x != 0 && options.minMaxOffsetNormalized.y != 0) {
406
427
  const clip = action.getClip();
407
428
  action.time = Mathf.lerp(options.minMaxOffsetNormalized.x, options.minMaxOffsetNormalized.y, Math.random()) * clip.duration;
408
429
  }
409
- else if (options?.startTime != undefined) {
410
- action.time = options.startTime;
411
- }
412
430
 
413
431
  // Apply speed
414
432
  if (options?.minMaxSpeed) {
src/engine-components/DragControls.ts CHANGED
@@ -13,7 +13,6 @@
13
13
  import { Avatar_POI } from "./avatar/Avatar_Brain_LookAt.js";
14
14
  import { Behaviour, GameObject } from "./Component.js";
15
15
  import { UsageMarker } from "./Interactable.js";
16
- import { OrbitControls } from "./OrbitControls.js";
17
16
  import { Rigidbody } from "./RigidBody.js";
18
17
  import { SyncedTransform } from "./SyncedTransform.js";
19
18
  import type { IPointerEventHandler, PointerEventData } from "./ui/PointerEvents.js";
@@ -85,7 +84,6 @@
85
84
 
86
85
  /** The object to be dragged – we pass this to handlers when they are created */
87
86
  private targetObject: GameObject | null = null;
88
- private orbit: OrbitControls | null = null;
89
87
  private _dragHelper: LegacyDragVisualsHelper | null = null;
90
88
  private static lastHovered: Object3D;
91
89
  private _draggingRigidbodies: Rigidbody[] = [];
@@ -117,7 +115,6 @@
117
115
  }
118
116
 
119
117
  start() {
120
- this.orbit = GameObject.findObjectOfType(OrbitControls, this.context);
121
118
  if (!this.gameObject.getComponentInParent(ObjectRaycaster))
122
119
  this.gameObject.addComponent(ObjectRaycaster);
123
120
  }
@@ -171,10 +168,6 @@
171
168
  const newDragHandler = new DragPointerHandler(this, this.targetObject || this.gameObject);
172
169
  this._dragHandlers.set(args.event.space, newDragHandler);
173
170
 
174
- // We need to turn off OrbitControls immediately, otherwise they still get data for a short moment
175
- // and they don't properly handle being disabled while already processing data (smoothing happens when enabling again)
176
- if (this.orbit) this.orbit.enabled = false;
177
-
178
171
  newDragHandler.onDragStart(args);
179
172
 
180
173
  if (this._dragHandlers.size === 2) {
@@ -223,10 +216,6 @@
223
216
  }
224
217
  args.use();
225
218
  }
226
-
227
- if (DragControls._active === 0) {
228
- if (this.orbit) this.orbit.enabled = true;
229
- }
230
219
  }
231
220
 
232
221
  update(): void {
@@ -261,12 +250,6 @@
261
250
 
262
251
  /** Called when the first pointer starts dragging on this object. Not called for subsequent pointers on the same object. */
263
252
  private onFirstDragStart(evt: PointerEventData) {
264
- if (!this._dragHelper) {
265
- if (this.context.mainCamera)
266
- this._dragHelper = new LegacyDragVisualsHelper(this.context.mainCamera);
267
- else
268
- return;
269
- }
270
253
  if (!evt || !evt.object) return;
271
254
 
272
255
  const dc = GameObject.getComponentInParent(evt.object, DragControls);
@@ -280,8 +263,6 @@
280
263
  if (!object) return;
281
264
 
282
265
  this._isDragging = true;
283
- this._dragHelper.setSelected(object, this.context);
284
- if (this.orbit) this.orbit.enabled = false;
285
266
 
286
267
  const sync = GameObject.getComponentInChildren(object, SyncedTransform);
287
268
  if (debug) console.log("DRAG START", sync, object);
@@ -328,7 +309,6 @@
328
309
  const selected = this._dragHelper.selected;
329
310
  if (debug) console.log("DRAG END", selected, selected?.visible)
330
311
  this._dragHelper.setSelected(null, this.context);
331
- if (this.orbit) this.orbit.enabled = true;
332
312
  if (evt?.object) {
333
313
  const sync = GameObject.getComponentInChildren(evt.object, SyncedTransform);
334
314
  if (sync) {
@@ -939,9 +919,9 @@
939
919
  // Idea: Do a sphere cast if we're still in the proximity of the current draggedObject.
940
920
  // This would allow dragging slightly out of the object's bounds and still continue snapping to it.
941
921
  // Do a regular raycast (without the dragged object) to determine if we should change what is dragged onto.
942
- const opts = new RaycastOptions();
943
- opts.testObject = o => o !== this.followObject && o !== dragSource && o !== draggedObject;
944
- const hits = this.context.physics.raycastFromRay(ray, opts);
922
+ const hits = this.context.physics.raycastFromRay(ray, {
923
+ testObject: o => o !== this.followObject && o !== dragSource && o !== draggedObject// && !(o instanceof GroundedSkybox)
924
+ });
945
925
 
946
926
  if (hits.length > 0) {
947
927
  const hit = hits[0];
@@ -1257,11 +1237,11 @@
1257
1237
 
1258
1238
  if (!this._selected) return;
1259
1239
 
1260
- const wp = getWorldPosition(this._selected);
1261
- this.onUpdateWorldPosition(wp, this._groundPlanePoint, false);
1262
- this.onUpdateGroundPlane();
1263
- this._didDragOnGroundPlaneLastFrame = true;
1264
- this._hasGroundPlane = true;
1240
+ // const wp = getWorldPosition(this._selected);
1241
+ // this.onUpdateWorldPosition(wp, this._groundPlanePoint, false);
1242
+ // this.onUpdateGroundPlane();
1243
+ // this._didDragOnGroundPlaneLastFrame = true;
1244
+ // this._hasGroundPlane = true;
1265
1245
 
1266
1246
  /*
1267
1247
  if (!this._context) return;
src/engine-components/Duplicatable.ts CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  import { isDevEnvironment } from "../engine/debug/index.js";
4
4
  import { InstantiateOptions } from "../engine/engine_gameobject.js";
5
+ import { InstantiateIdProvider } from "../engine/engine_networking_instantiate.js";
5
6
  import { serializable } from "../engine/engine_serialization_decorator.js";
6
7
  import { Behaviour, GameObject } from "./Component.js";
7
- import { DragControls } from "./DragControls.js";
8
+ import { DragControls, DragMode } from "./DragControls.js";
9
+ import { SyncedTransform } from "./SyncedTransform.js";
8
10
  import { type IPointerEventHandler, PointerEventData } from "./ui/PointerEvents.js";
9
11
  import { ObjectRaycaster } from "./ui/Raycaster.js";
10
12
 
@@ -18,18 +20,22 @@
18
20
  @serializable(Object3D)
19
21
  parent: GameObject | null = null;
20
22
 
21
- /** The object to be duplicated */
23
+ /** The object to be duplicated. If no object is assigned then the object the Duplicatable component is attached to will be used for cloning.
24
+ * @default null
25
+ */
22
26
  @serializable(Object3D)
23
27
  object: GameObject | null = null;
24
28
 
25
29
  /**
26
30
  * The maximum number of objects that can be duplicated in the interval.
31
+ * @default 10
27
32
  */
28
33
  @serializable()
29
34
  limitCount = 10;
30
35
 
31
36
  /**
32
37
  * The interval in seconds in which the limitCount is reset.
38
+ * @default 60
33
39
  */
34
40
  @serializable()
35
41
  limitInterval = 60;
@@ -39,25 +45,50 @@
39
45
  private _startQuaternion: Quaternion | null = null;
40
46
 
41
47
  start(): void {
48
+ this._currentCount = 0;
49
+ this._startPosition = null;
50
+ this._startQuaternion = null;
51
+
52
+ if (!this.object) {
53
+ this.object = this.gameObject;
54
+ }
55
+
42
56
  if (this.object) {
43
- if (this.object as any === this.gameObject) {
44
- console.error("Can not duplicate self");
45
- return;
57
+ if (this.object === this.gameObject) {
58
+ // console.error("Can not duplicate self");
59
+ // return;
60
+ const instanceIdProvider = new InstantiateIdProvider(this.guid);
61
+ this.object = GameObject.instantiate(this.object, { idProvider: instanceIdProvider, keepWorldPosition: false, });
62
+ const duplicatable = GameObject.getComponent(this.object, Duplicatable);
63
+ duplicatable?.destroy();
64
+ let dragControls = this.object.getComponentInChildren(DragControls);
65
+ if (!dragControls) {
66
+ dragControls = this.object.addComponent(DragControls, {
67
+ dragMode: DragMode.SnapToSurfaces
68
+ });
69
+ dragControls.guid = instanceIdProvider.generateUUID();
70
+ }
71
+ let syncedTransfrom = GameObject.getComponent(dragControls.gameObject, SyncedTransform);
72
+ if (!syncedTransfrom) {
73
+ syncedTransfrom = dragControls.gameObject.addComponent(SyncedTransform);
74
+ syncedTransfrom.guid = instanceIdProvider.generateUUID();
75
+ }
46
76
  }
47
77
  this.object.visible = false;
48
78
 
79
+ // legacy – DragControls was required for duplication and so often the component is still there; we work around that by disabling it here
80
+ const dragControls = this.gameObject.getComponent(DragControls);
81
+ if (dragControls) {
82
+ // if (isDevEnvironment()) console.warn(`Please remove DragControls from \"${dragControls.name}\": it's not needed anymore when the object also has a Duplicatable component`);
83
+ dragControls.enabled = false;
84
+ }
85
+
49
86
  // when this is in a moveable parent in multiuser scenario somehow the object position gets an offset and might stay that way
50
87
  // this is just a workaround to set the object position before duplicating
51
88
  this._startPosition = this.object.position?.clone() ?? new Vector3(0, 0, 0);
52
89
  this._startQuaternion = this.object.quaternion?.clone() ?? new Quaternion(0, 0, 0, 1);
53
90
  }
54
91
 
55
- // legacy – DragControls was required for duplication and so often the component is still there; we work around that by disabling it here
56
- const dragControls = this.gameObject.getComponent(DragControls);
57
- if (dragControls) {
58
- if (isDevEnvironment()) console.warn(`Please remove DragControls from \"${dragControls.name}\": it's not needed anymore when the object also has a Duplicatable component`);
59
- dragControls.enabled = false;
60
- }
61
92
 
62
93
  if (!this.gameObject.getComponentInParent(ObjectRaycaster))
63
94
  this.gameObject.addComponent(ObjectRaycaster);
@@ -67,12 +98,27 @@
67
98
 
68
99
  private _forwardPointerEvents: Map<Object3D, DragControls> = new Map();
69
100
 
101
+ onPointerEnter(args: PointerEventData) {
102
+ if (args.used) return;
103
+ if (!this.object) return;
104
+ if (!this.context.connection.allowEditing) return;
105
+ if (args.button !== 0) return;
106
+ this.context.input.setCursorPointer();
107
+ }
108
+ onPointerExit(args: PointerEventData) {
109
+ if (args.used) return;
110
+ if (!this.object) return;
111
+ if (!this.context.connection.allowEditing) return;
112
+ if (args.button !== 0) return;
113
+ this.context.input.setCursorNormal();
114
+ }
115
+
70
116
  /** @internal */
71
117
  onPointerDown(args: PointerEventData) {
118
+ if (args.used) return;
72
119
  if (!this.object) return;
73
120
  if (!this.context.connection.allowEditing) return;
74
121
  if (args.button !== 0) return;
75
-
76
122
  const res = this.handleDuplication();
77
123
  if (res) {
78
124
  const dragControls = GameObject.getComponent(res, DragControls);
@@ -86,6 +132,7 @@
86
132
 
87
133
  /** @internal */
88
134
  onPointerUp(args: PointerEventData) {
135
+ if (args.used) return;
89
136
  const dragControls = this._forwardPointerEvents.get(args.event.space);
90
137
  if (dragControls) {
91
138
  dragControls.onPointerUp(args);
@@ -106,7 +153,7 @@
106
153
  private handleDuplication(): Object3D | null {
107
154
  if (!this.object) return null;
108
155
  if (this._currentCount >= this.limitCount) return null;
109
- if (this.object as any === this.gameObject) return null;
156
+ if (this.object === this.gameObject) return null;
110
157
 
111
158
  this.object.visible = true;
112
159
 
src/engine/engine_context.ts CHANGED
@@ -151,7 +151,8 @@
151
151
  private static _defaultWebglRendererParameters: WebGLRendererParameters = {
152
152
  antialias: true,
153
153
  alpha: false,
154
- powerPreference: "high-performance",
154
+ // Note: this is due to a bug on OSX devices. See NE-5370
155
+ powerPreference: (utils.isiOS() || utils.isMacOS()) ? "default" : "high-performance",
155
156
  };
156
157
  /** The default parameters that will be used when creating a new WebGLRenderer.
157
158
  * Modify in global context to change the default parameters for all new contexts.
@@ -808,8 +809,26 @@
808
809
  if (debug) console.log("Waiting for dependencies to be ready");
809
810
  await dependenciesReady
810
811
  .catch(err => {
811
- if (debug || isDevEnvironment())
812
- console.error("Needle Engine dependencies failed to load", err)
812
+ if (debug || isDevEnvironment()) {
813
+ showBalloonError("Needle Engine dependencies failed to load. Please check the console for more details");
814
+ const printedError = false;
815
+ if (err instanceof ReferenceError) {
816
+ let offendingComponentName = "YourComponentName";
817
+ const offendingComponentStartIndex = err.message.indexOf("'");
818
+ if (offendingComponentStartIndex > 0) {
819
+ const offendingComponentEndIndex = err.message.indexOf("'", offendingComponentStartIndex + 1);
820
+ if (offendingComponentEndIndex > 0) {
821
+ const name = err.message.substring(offendingComponentStartIndex + 1, offendingComponentEndIndex);
822
+ if (name.length > 3) offendingComponentName = name;
823
+ }
824
+ }
825
+ console.error(`Needle Engine dependencies failed to load:\n\n# Make sure you don't have circular imports in your scripts!\n→ Possible solution: Replace @serializable(${offendingComponentName}) in your script with @serializable(Behaviour)\n\n---`, err)
826
+ return;
827
+ }
828
+ if (!printedError) {
829
+ console.error("Needle Engine dependencies failed to load", err);
830
+ }
831
+ }
813
832
  })
814
833
  .then(() => {
815
834
  if (debug) console.log("Needle Engine dependencies are ready");
src/engine/engine_input.ts CHANGED
@@ -224,7 +224,7 @@
224
224
  /** For addEventListener: The queue to add the listener to. Listeners in the same queue are called in the order they were added. Default is 0.
225
225
  * For removeEventListener: The queue to remove the listener from. If no queue is specified the listener will be removed from all queues
226
226
  */
227
- queue?: InputEventQueue;
227
+ queue?: InputEventQueue | number;
228
228
  /** If true, the listener will be removed after it is invoked once. */
229
229
  once?: boolean;
230
230
  /** The listener will be removed when the given AbortSignal object's `abort()` method is called. If not specified, no AbortSignal is associated with the listener. */
@@ -390,20 +390,17 @@
390
390
  this.context.domElement.style.cursor = "default";
391
391
  }
392
392
 
393
- setPointerUsed(i: number, used: boolean = true) {
394
- if (i >= this._pointerUsed.length) {
395
- if (i >= this._pointerIds.length)
396
- return;
397
- while (this._pointerUsed.length <= i) {
398
- this._pointerUsed.push(false);
393
+ /**
394
+ * Check if a pointer id is currently used.
395
+ */
396
+ getIsPointerIdInUse(pointerId: number) {
397
+ for (const evt of this._pointerEventsPressed) {
398
+ if (evt.pointerId === pointerId) {
399
+ if (evt.used) return true;
399
400
  }
400
401
  }
401
- this._pointerUsed[i] = used;
402
+ return false;
402
403
  }
403
- getPointerUsed(i: number) {
404
- if (i >= this._pointerUsed.length) return false;
405
- return this._pointerUsed[i];
406
- }
407
404
  /** how many pointers are currently pressed */
408
405
  getPointerPressedCount(): number {
409
406
  let count = 0;
@@ -552,12 +549,12 @@
552
549
  private _mouseWheelChanged: boolean[] = [false];
553
550
  private _mouseWheelDeltaY: number[] = [0];
554
551
  private _pointerEvent: Event[] = [];
555
- private _pointerUsed: boolean[] = [];
552
+ /** current pressed pointer events. Used to check if any of those events was used */
553
+ private _pointerEventsPressed: NEPointerEvent[] = [];
556
554
  /** This is added/updated for pointers. screenspace pointers set this to the camera near plane */
557
555
  private _pointerSpace: IGameObject[] = [];
558
556
 
559
557
 
560
-
561
558
  private readonly _pressedStack = new Map<number, number[]>();
562
559
  private onDownButton(pointerId: number, button: number) {
563
560
  let stack = this._pressedStack.get(pointerId);
@@ -1028,6 +1025,7 @@
1028
1025
 
1029
1026
  this.updatePointerPosition(evt);
1030
1027
 
1028
+ this._pointerEventsPressed.push(evt);
1031
1029
  this.onDispatchEvent(evt);
1032
1030
  }
1033
1031
  // moveEvent?: Event;
@@ -1058,11 +1056,16 @@
1058
1056
  this.setPointerStateT(index, this._pointerEvent, evt.source);
1059
1057
  this.setPointerState(index, this._pointerUp, true);
1060
1058
 
1061
- while (index >= this._pointerUsed.length) this._pointerUsed.push(false);
1062
- this.setPointerState(index, this._pointerUsed, false);
1063
-
1064
1059
  this.updatePointerPosition(evt);
1065
1060
 
1061
+ for (let i = this._pointerEventsPressed.length - 1; i >= 0; i--) {
1062
+ const ptr = this._pointerEventsPressed[i];
1063
+ if (ptr.pointerId === index) {
1064
+ this._pointerEventsPressed.splice(i, 1);
1065
+ break;
1066
+ }
1067
+ }
1068
+
1066
1069
  if (!this._pointerPositionDown[index]) {
1067
1070
  if (debug) showBalloonWarning("Received pointer up event without matching down event for button: " + index);
1068
1071
  console.warn("Received pointer up event without matching down event for button: " + index)
src/engine/engine_physics.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { getRaycastMesh } from '@needle-tools/gltf-progressive';
2
- import { ArrayCamera, AxesHelper, Box3, BufferGeometry, Camera, type Intersection, Layers, Line, Mesh, Object3D, PerspectiveCamera, Ray, Raycaster, Sphere, Vector2, Vector3 } from 'three'
2
+ import { ArrayCamera, AxesHelper, Box3, BufferGeometry, Camera, type Intersection, Layers, Line, Matrix3, Matrix4, Mesh, Object3D, PerspectiveCamera, Plane, Ray, Raycaster, Sphere, SphereGeometry,Vector2, Vector3 } from 'three'
3
+ import { GroundedSkybox } from 'three/examples/jsm/objects/GroundedSkybox.js';
3
4
 
4
5
  import { Gizmos } from './engine_gizmos.js';
5
6
  import { Context } from './engine_setup.js';
@@ -321,7 +322,15 @@
321
322
  const raycastMesh = getRaycastMesh(obj);
322
323
  if (raycastMesh) mesh.geometry = raycastMesh;
323
324
  const lastResultsCount = results.length;
324
- if (options.precise === false && customRaycast(mesh, raycaster, results)) {
325
+
326
+ let usePrecise = true;
327
+ if (options.precise === false) usePrecise = false;
328
+ usePrecise ||= geometry.getAttribute("position").array?.length < 64;
329
+ if (mesh instanceof GroundedSkybox) {
330
+ usePrecise = false;
331
+ }
332
+
333
+ if (!usePrecise && customRaycast(mesh, raycaster, results)) {
325
334
  // did handle raycast
326
335
  }
327
336
  else {
@@ -343,6 +352,9 @@
343
352
  }
344
353
 
345
354
  const tempSphere = new Sphere();
355
+ const tempPlane = new Plane();
356
+ const normalUpMatrix = new Matrix3();
357
+
346
358
  /**
347
359
  * @returns false if custom raycasting can not run, otherwise true
348
360
  */
@@ -361,16 +373,34 @@
361
373
  const self = this as Mesh;
362
374
  const boundingSphere = self.geometry.boundingSphere;
363
375
  if (boundingSphere) {
376
+
377
+ if (self instanceof GroundedSkybox) {
378
+ tempPlane.setFromNormalAndCoplanarPoint(getTempVector(0, 1, 0), getTempVector(0, -self.position.y, 0));
379
+ tempPlane.applyMatrix4(self.matrixWorld, normalUpMatrix);
380
+ const point = raycaster.ray.intersectPlane(tempPlane, getTempVector());
381
+ if (point) {
382
+ tempSphere.copy(boundingSphere);
383
+ tempSphere.applyMatrix4(self.matrixWorld);
384
+ const dir = getTempVector(point).sub(raycaster.ray.origin);
385
+ const distance = dir.length();
386
+ const groundProjectionFloorRadius = tempSphere.radius * .5;
387
+ if (distance < groundProjectionFloorRadius) // make sure we're inside the sphere
388
+ intersects.push({ distance: distance, point, object: self, normal: tempPlane.normal.clone() });
389
+ }
390
+ return;
391
+ }
392
+
364
393
  tempSphere.copy(boundingSphere);
365
394
  tempSphere.applyMatrix4(self.matrixWorld);
366
395
  const point = raycaster.ray.intersectSphere(tempSphere, getTempVector());
367
396
  if (point) {
368
- // const dir = getTempVector(tempSphere.center).sub(raycaster.ray.origin);
369
- // const pointOnSphere = getTempVector(dir).normalize().multiplyScalar(tempSphere.radius);
370
- // // const pointOnBoundingSphere = raycaster.ray.closestPointToPoint(boundingSphere.center, getTempVector());
371
- // const distance = dir.length();//pointOnBoundingSphere.distanceTo(raycaster.ray.origin);
372
- const distance = point.distanceTo(raycaster.ray.origin);
373
- intersects.push({ object: self, distance: distance, point: point });
397
+ const dir = getTempVector(point).sub(raycaster.ray.origin);
398
+ const distance = dir.length();
399
+ // Ignore hits when we're inside the sphere
400
+ if (distance > tempSphere.radius) {
401
+ const normal = dir.clone().normalize();
402
+ intersects.push({ distance: distance, point, object: self, normal });
403
+ }
374
404
  }
375
405
  }
376
406
  }
src/engine/engine_utils.ts CHANGED
@@ -554,6 +554,18 @@
554
554
  };
555
555
 
556
556
 
557
+
558
+
559
+
560
+ declare global {
561
+ interface NavigatorUAData {
562
+ platform: string;
563
+ }
564
+ interface Navigator {
565
+ userAgentData?: NavigatorUAData;
566
+ }
567
+ }
568
+
557
569
  /** Is MacOS or Windows (and not hololens) */
558
570
  export function isDesktop() {
559
571
  const ua = window.navigator.userAgent;
@@ -581,10 +593,26 @@
581
593
  return /WebXRViewer\//i.test(navigator.userAgent);
582
594
  }
583
595
 
596
+ let __isMacOs: boolean | undefined;
597
+ // https://developer.mozilla.org/en-US/docs/Web/API/Navigator/userAgentData
598
+ export function isMacOS() {
599
+ if (__isMacOs !== undefined) return __isMacOs;
600
+ if (navigator.userAgentData) {
601
+ // Use modern UA Client Hints API if available
602
+ return __isMacOs = navigator.userAgentData.platform === 'macOS';
603
+ } else {
604
+ // Fallback to user agent string parsing
605
+ const userAgent = navigator.userAgent.toLowerCase();
606
+ return __isMacOs = userAgent.includes('mac os x') || userAgent.includes('macintosh');
607
+ }
608
+ }
609
+
610
+ let __isiOS: boolean | undefined;
584
611
  const iosDevices = ['iPad Simulator', 'iPhone Simulator', 'iPod Simulator', 'iPad', 'iPhone', 'iPod'];
585
612
  /** @returns `true` for iOS devices like iPad, iPhone, iPod... */
586
613
  export function isiOS() {
587
- return iosDevices.includes(navigator.platform)
614
+ if (__isiOS !== undefined) return __isiOS;
615
+ return __isiOS = iosDevices.includes(navigator.platform)
588
616
  // iPad on iOS 13 detection
589
617
  || (navigator.userAgent.includes("Mac") && "ontouchend" in document)
590
618
  }
src/engine-components/GroundProjection.ts CHANGED
@@ -153,6 +153,7 @@
153
153
  this._projection?.removeFromParent();
154
154
  this._projection = new GroundProjection(this.context.scene.environment, this._height, this.radius, 64);
155
155
  this._projection.position.y = this._height - offset;
156
+ this._projection.name = "GroundProjection";
156
157
  setVisibleInCustomShadowRendering(this._projection, false);
157
158
  }
158
159
 
@@ -181,12 +182,7 @@
181
182
  this.env.radius = this._radius;
182
183
  this.env.height = this._height;
183
184
  */
184
-
185
- // dont make the ground projection raycastable by default
186
- if (this._projection.isObject3D === true) {
187
- this._projection.layers.set(2);
188
- }
189
-
185
+
190
186
  if (this.context.scene.backgroundBlurriness > 0.001 || this._needsTextureUpdate) {
191
187
  this.updateBlurriness();
192
188
  }
src/engine/xr/NeedleXRController.ts CHANGED
@@ -30,6 +30,7 @@
30
30
  declare type ComponentType = "button" | "thumbstick" | "squeeze";
31
31
  declare type GamepadKey = "button" | "xAxis" | "yAxis";
32
32
 
33
+ declare type NeedleXRControllerButtonName = ButtonName | "primary-button" | "primary";
33
34
 
34
35
  declare type ComponentMap = {
35
36
  type: ComponentType,
@@ -250,7 +251,35 @@
250
251
  }
251
252
  private readonly _ray;
252
253
 
254
+ /** Recalculated once per update */
255
+ private _hand_wristDotUp: number | undefined = undefined;
256
+ /**
257
+ * The dot product of the hand palm with the up vector.
258
+ * This is a number between -1 and 1, where 1 means the palm is directly up and -1 means the palm is directly down (upside down).
259
+ * This value is undefined if there's no hand
260
+ */
261
+ get handWristDotUp(): number | undefined {
262
+ if (this._hand_wristDotUp !== undefined) return this._hand_wristDotUp;
263
+ const handPalm = this.handObject?.joints["wrist"];
264
+ if (handPalm) {
265
+ const up = getTempVector(0, 1, 0).applyQuaternion(handPalm.quaternion);
266
+ const dot = getTempVector(0, 1, 0).dot(up);
267
+ return this._hand_wristDotUp = dot;
268
+ }
269
+ return undefined;
270
+ }
271
+ /**
272
+ * Uses
273
+ * @returns true if the hand is upside down
274
+ */
275
+ get isHandUpsideDown() {
276
+ return this.handWristDotUp !== undefined ? this.handWristDotUp < -.7 : false;
277
+ }
278
+ get isTeleportGesture() {
279
+ return this.isHandUpsideDown;
280
+ }
253
281
 
282
+
254
283
  /** The controller object space.
255
284
  * You can use it to attach objects to the controller.
256
285
  * Children will be automatically detached and put into the scene when the controller disconnects
@@ -343,6 +372,7 @@
343
372
  private onUpdateFrame(frame: XRFrame) {
344
373
  // make sure this is cleared every frame
345
374
  this._handJointPoses.clear();
375
+ this._hand_wristDotUp = undefined;
346
376
 
347
377
  if (!this.xr.referenceSpace) {
348
378
  this._isTracking = false;
@@ -478,7 +508,7 @@
478
508
  * @param key the controller button name e.g. x-button
479
509
  * @returns the gamepad button if it exists on the controller - otherwise undefined
480
510
  */
481
- getButton(key: ButtonName | "primary-button" | "primary"): NeedleGamepadButton | undefined {
511
+ getButton(key: NeedleXRControllerButtonName): NeedleGamepadButton | undefined {
482
512
  if (!this._layout) return undefined;
483
513
 
484
514
  switch (key) {
@@ -488,12 +518,12 @@
488
518
  else return undefined;
489
519
  break;
490
520
  case "primary":
491
- return this.toNeedleGamepadButton(0);
521
+ return this.toNeedleGamepadButton(0, key);
492
522
  }
493
523
 
494
524
 
495
525
  if (this._buttonMap.has(key)) {
496
- return this.toNeedleGamepadButton(this._buttonMap.get(key)!);
526
+ return this.toNeedleGamepadButton(this._buttonMap.get(key)!, key);
497
527
  }
498
528
  const componentModel = this._layout?.components[key];
499
529
  if (componentModel?.gamepadIndices) {
@@ -503,7 +533,7 @@
503
533
  if (this.inputSource.gamepad) {
504
534
  const index = componentModel.gamepadIndices!.button!;
505
535
  this._buttonMap.set(key, index);
506
- return this.toNeedleGamepadButton(index);
536
+ return this.toNeedleGamepadButton(index, key);
507
537
  }
508
538
  break;
509
539
  default:
@@ -520,7 +550,7 @@
520
550
  const state = this.states[key];
521
551
  if (!state) return null;
522
552
  this.states[key] = state;
523
- const needleButton = this._needleGamepadButtons[key] || new NeedleGamepadButton();
553
+ const needleButton = this._needleGamepadButtons[key] || new NeedleGamepadButton(undefined, key);
524
554
  needleButton.pressed = state.pressed;
525
555
  needleButton.value = state.value;
526
556
  needleButton.isDown = state.isDown;
@@ -529,13 +559,45 @@
529
559
  return needleButton;
530
560
  }
531
561
 
562
+ /**
563
+ * Get the pointer id for a specific button of this input device.
564
+ * This is useful if you want to check if a button (e.g. trigger) is currently being in use which can be queried on the inputsystem.
565
+ * @returns the pointer id for the button or undefined if the button is not supported
566
+ * @example
567
+ * ```ts
568
+ * const pointerId = controller.getPointerId("primary");
569
+ * if (pointerId !== undefined) {
570
+ * const isUsed = this.context.input.getPointerUsed(pointerId);
571
+ * console.log(controller.side, "used?", isUsed);
572
+ * }
573
+ * ```
574
+ */
575
+ getPointerId(button: number): number;
576
+ getPointerId(button: NeedleXRControllerButtonName | XRGestureName): number | undefined;
577
+ getPointerId(button: number | NeedleXRControllerButtonName | XRGestureName): number | undefined {
578
+ if (button === "primary") {
579
+ button = 0;
580
+ }
581
+ else if (button === "pinch") {
582
+ button = 0;
583
+ }
584
+ if (typeof button !== "number") {
585
+ const needleButton = this._buttonMap.get(button);
586
+ if (needleButton === undefined) {
587
+ return undefined;
588
+ }
589
+ button = needleButton;
590
+ }
591
+ return this.index * 10 + button;
592
+ }
593
+
532
594
  private readonly _needleGamepadButtons: { [key: number | string]: NeedleGamepadButton } = {};
533
595
  /** combine the InputState information + the GamepadButton information (since GamepadButtons can not be extended) */
534
- private toNeedleGamepadButton(index: number): NeedleGamepadButton | undefined {
596
+ private toNeedleGamepadButton(index: number, name: string): NeedleGamepadButton | undefined {
535
597
  if (!this.inputSource.gamepad?.buttons) return undefined
536
598
  const button = this.inputSource.gamepad?.buttons[index];
537
599
  const state = this.states[index];
538
- const needleButton = this._needleGamepadButtons[index] || new NeedleGamepadButton();
600
+ const needleButton = this._needleGamepadButtons[index] || new NeedleGamepadButton(index, name);
539
601
  if (button) {
540
602
  needleButton.pressed = button.pressed;
541
603
  needleButton.value = button.value;
@@ -585,7 +647,7 @@
585
647
  return { x: 0, y: 0, z: 0 }
586
648
  }
587
649
 
588
- private readonly _buttonMap = new Map<ButtonName, number>();
650
+ private readonly _buttonMap = new Map<NeedleXRControllerButtonName, number>();
589
651
 
590
652
  // the motion controller contains the controller scheme, we use this to simplify button access
591
653
  private _motioncontroller?: MotionController;
@@ -706,9 +768,9 @@
706
768
  private updateInputEvents() {
707
769
  // https://immersive-web.github.io/webxr-gamepads-module/#xr-standard-heading
708
770
  if (this.gamepad?.buttons) {
709
- for (let k = 0; k < this.gamepad.buttons.length; k++) {
710
- const button = this.gamepad.buttons[k];
711
- const state = this.states[k] || new InputState();
771
+ for (let index = 0; index < this.gamepad.buttons.length; index++) {
772
+ const button = this.gamepad.buttons[index];
773
+ const state = this.states[index] || new InputState();
712
774
  let eventName: InputEventNames | null = null;
713
775
 
714
776
  // is down
@@ -730,15 +792,15 @@
730
792
 
731
793
  state.value = button.value;
732
794
  state.pressed = button.pressed;
733
- this.states[k] = state;
795
+ this.states[index] = state;
734
796
 
735
797
  // the selection event is handled in the "selectstart" callback
736
- const emitEvent = k !== this._selectButtonIndex && k !== this._squeezeButtonIndex;
798
+ const emitEvent = index !== this._selectButtonIndex && index !== this._squeezeButtonIndex;
737
799
 
738
800
  if (eventName != null && emitEvent) {
739
- const name = this._layout?.gamepad[k];
740
- if (debug || debugCustomGesture) console.log("Emitting pointer event", eventName, k, name, button.value, this.gamepad, this._layout);
741
- this.emitPointerEvent(eventName, k, name ?? "none", false, null, button.value);
801
+ const name = this._layout?.gamepad[index];
802
+ if (debug || debugCustomGesture) console.log("Emitting pointer event", eventName, index, name, button.value, this.gamepad, this._layout);
803
+ this.emitPointerEvent(eventName, index, name ?? "none", false, null, button.value);
742
804
  }
743
805
  }
744
806
  }
@@ -752,14 +814,13 @@
752
814
  const thumbTip = handObject.joints["thumb-tip"];
753
815
  if (indexTip && thumbTip) {
754
816
  const distance = indexTip.position.distanceTo(thumbTip.position);
755
- if(distance !== 0) { // ignore exactly 0 which happens when we switch from hands to controllers
817
+ if (distance !== 0) { // ignore exactly 0 which happens when we switch from hands to controllers
756
818
  const pinchThreshold = .02;
757
819
  const pinchHysteresis = .01;
758
820
  const state = this.states["pinch"] || new InputState();
759
821
  const maxDistance = (pinchThreshold + pinchHysteresis) * 1.5;
760
- if (this.isRight) console.log(distance);
761
822
  state.value = 1 - ((distance - pinchThreshold) / maxDistance);
762
-
823
+
763
824
  const isPressed = distance < (pinchThreshold - pinchHysteresis);
764
825
  const isReleased = distance > (pinchThreshold + pinchHysteresis);
765
826
  if (isPressed && !state.pressed) {
@@ -847,7 +908,7 @@
847
908
  // Not sure if *this* is enough to determine if the event is spatial or not
848
909
  if (this.xr.mode === "immersive-vr" || this.xr.isPassThrough) {
849
910
  this.pointerInit.origin = this;
850
- this.pointerInit.pointerId = this.index * 10 + button;
911
+ this.pointerInit.pointerId = this.getPointerId(button);
851
912
  this.pointerInit.pointerType = this.hand ? "hand" : "controller";
852
913
  this.pointerInit.button = button;
853
914
  this.pointerInit.buttonName = buttonName;
@@ -864,6 +925,8 @@
864
925
  Context.Current = prevContext;
865
926
  }
866
927
  }
928
+
929
+
867
930
  }
868
931
 
869
932
  class InputState {
@@ -878,6 +941,10 @@
878
941
 
879
942
  /** Enhanced GamepadButton with `isDown` and `isUp` information */
880
943
  class NeedleGamepadButton {
944
+ /** The index of the button in the input gamepad */
945
+ readonly index: number | undefined;
946
+ readonly name: string;
947
+
881
948
  touched: boolean = false;
882
949
  pressed: boolean = false;
883
950
  value: number = 0;
@@ -885,4 +952,9 @@
885
952
  isDown: boolean = false;
886
953
  /** was the button just released the last update */
887
954
  isUp: boolean = false;
955
+
956
+ constructor(index: number | undefined, name: string) {
957
+ this.index = index;
958
+ this.name = name;
959
+ }
888
960
  }
src/engine-components/OrbitControls.ts CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { setCameraController } from "../engine/engine_camera.js";
6
6
  import { Gizmos } from "../engine/engine_gizmos.js";
7
- import { NEPointerEvent } from "../engine/engine_input.js";
7
+ import { InputEventQueue, NEPointerEvent } from "../engine/engine_input.js";
8
8
  import { Mathf } from "../engine/engine_math.js";
9
9
  import { RaycastOptions } from "../engine/engine_physics.js";
10
10
  import { serializable } from "../engine/engine_serialization_decorator.js";
@@ -324,8 +324,11 @@
324
324
  }
325
325
  }
326
326
  this._syncedTransform = GameObject.getComponent(this.gameObject, SyncedTransform) ?? undefined;
327
- this.context.input.addEventListener("pointerup", this._onPointerDown);
328
327
  this.context.pre_render_callbacks.push(this.__onPreRender);
328
+
329
+ this._activePointerEvents = [];
330
+ this.context.input.addEventListener("pointerdown", this._onPointerDown, { queue: InputEventQueue.Early });
331
+ this.context.input.addEventListener("pointerup", this._onPointerUp, { queue: InputEventQueue.Early });
329
332
  }
330
333
 
331
334
  /** @internal */
@@ -339,13 +342,29 @@
339
342
  this._controls.removeEventListener("start", this.onControlsChangeStarted);
340
343
  // this._controls.reset();
341
344
  }
342
- this.context.input.removeEventListener("pointerup", this._onPointerDown);
345
+ this._activePointerEvents.length = 0;
346
+ this.context.input.removeEventListener("pointerdown", this._onPointerDown);
347
+ this.context.input.removeEventListener("pointerup", this._onPointerUp);
343
348
  }
344
349
 
350
+ private _activePointerEvents!: NEPointerEvent[];
345
351
  private _lastTimeClickOnBackground: number = -1;
346
352
  private _clickOnBackgroundCount: number = 0;
347
- private _onPointerDown = (evt: NEPointerEvent) => {
348
353
 
354
+ private _onPointerDown = (_evt: NEPointerEvent) => {
355
+ this._activePointerEvents.push(_evt);
356
+ }
357
+
358
+ private _onPointerUp = (evt: NEPointerEvent) => {
359
+ // make sure we cleanup the active pointer events
360
+ for (let i = this._activePointerEvents.length - 1; i >= 0; i--) {
361
+ const registered = this._activePointerEvents[i];
362
+ if (registered.pointerId === evt.pointerId && registered.button === evt.button) {
363
+ this._activePointerEvents.splice(i, 1);
364
+ break;
365
+ }
366
+ }
367
+
349
368
  if (this.clickBackgroundToFitScene > 0 && evt.isClick && evt.button === 0) {
350
369
 
351
370
  // it's possible that we didnt raycast in this frame
@@ -414,8 +433,8 @@
414
433
  this._lookTargetLerpActive = false;
415
434
  }
416
435
  this._inputs = 0;
417
-
418
436
 
437
+
419
438
  if (this.autoTarget) {
420
439
  // we want to wait one frame so all matrixWorlds are updated
421
440
  // otherwise raycasting will not work correctly
@@ -486,7 +505,7 @@
486
505
  if (this._controls) {
487
506
  if (this.debugLog)
488
507
  this._controls.domElement = this.context.renderer.domElement;
489
- this._controls.enabled = !this._shouldDisable && this._camera === this.context.mainCameraComponent && !this.context.isInXR;
508
+ this._controls.enabled = !this._shouldDisable && this._camera === this.context.mainCameraComponent && !this.context.isInXR && !this._activePointerEvents.some(e => e.used);
490
509
  this._controls.keys = this.enableKeys ? defaultKeys : disabledKeys;
491
510
  this._controls.autoRotate = this.autoRotate;
492
511
  this._controls.autoRotateSpeed = this.autoRotateSpeed;
src/engine-components/ui/PointerEvents.ts CHANGED
@@ -45,8 +45,6 @@
45
45
  if (this._used) return;
46
46
  this._used = true;
47
47
  this.event.use();
48
- if (this.pointerId !== undefined)
49
- this.input.setPointerUsed(this.pointerId);
50
48
  }
51
49
 
52
50
  private _propagationStopped: boolean = false;
src/engine-components/SyncedRoom.ts CHANGED
@@ -262,7 +262,9 @@
262
262
  }
263
263
  else {
264
264
  if (this.urlParameterName) {
265
- if (!getParam(this.urlParameterName)) {
265
+ const name = getParam(this.urlParameterName);
266
+ // true check for ?room= without an actual name
267
+ if (!name || name === true) {
266
268
  if (this._lastJoinedRoom)
267
269
  utils.setParamWithoutReload(this.urlParameterName, this._lastJoinedRoom);
268
270
  else
src/engine-components/webxr/controllers/XRControllerMovement.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { AdditiveBlending, BufferAttribute, Color, DoubleSide, Intersection, Line, Line3, Mesh, MeshBasicMaterial, Object3D, Plane, RingGeometry, SphereGeometry, SubtractiveBlending, Vector3 } from "three";
1
+ import { AdditiveBlending, BufferAttribute, Color, DoubleSide, Intersection, Line, Line3, Material, Mesh, MeshBasicMaterial, Object3D, Plane, RingGeometry, Scene, SphereGeometry, SubtractiveBlending, Vector3 } from "three";
2
2
  import { Line2 } from "three/examples/jsm/lines/Line2.js";
3
3
  import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
4
4
  import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
5
+ import { GroundedSkybox } from "three/examples/jsm/objects/GroundedSkybox.js";
5
6
 
6
7
  import { Gizmos } from "../../../engine/engine_gizmos.js";
7
8
  import { Mathf } from "../../../engine/engine_math.js";
@@ -18,6 +19,7 @@
18
19
 
19
20
  const debug = getParam("debugwebxr");
20
21
 
22
+ declare type HitPointObject = Object3D & { material: Material & { opacity: number } }
21
23
  /**
22
24
  * XRControllerMovement is a component that allows to move the XR rig using the XR controller input.
23
25
  */
@@ -151,7 +153,7 @@
151
153
  protected onHandleTeleport(controller: NeedleXRController, rig: IGameObject) {
152
154
  let teleportInput = 0;
153
155
 
154
- if (controller.hand && this.usePinchToTeleport) {
156
+ if (controller.hand && this.usePinchToTeleport && controller.isTeleportGesture) {
155
157
  const pinch = controller.getGesture("pinch");
156
158
  if (pinch) {
157
159
  teleportInput = pinch.value;
@@ -169,10 +171,12 @@
169
171
  else if (teleportInput > .8) {
170
172
  this._didTeleport = true;
171
173
  const hit = this.context.physics.raycastFromRay(controller.ray)[0];
172
-
173
- // Ignore hits on objects with interactive components if teleport target is disabled
174
- if (hit && controller.hand && !this.useTeleportTarget) {
175
- if (this.isObjectWithInteractiveComponent(hit.object)) return;
174
+ if (hit.object instanceof GroundedSkybox) {
175
+ const dot_up = hit.normal?.dot(getTempVector(0, 1, 0));
176
+ // Make sure we can only teleport on the ground / floor plane
177
+ if (dot_up !== undefined && dot_up < 0.4) {
178
+ return;
179
+ }
176
180
  }
177
181
 
178
182
  let point: Vector3 | null = hit?.point;
@@ -214,8 +218,9 @@
214
218
  private _plane: Plane | null = null;
215
219
 
216
220
  private readonly _lines: Line2[] = [];
217
- private readonly _hitDiscs: Object3D[] = [];
221
+ private readonly _hitDiscs: HitPointObject[] = [];
218
222
  private readonly _hitDistances: Array<number | null> = [];
223
+ private readonly _lastHitDistances: Array<number | null | undefined> = [];
219
224
 
220
225
  protected renderRays(session: NeedleXRSession) {
221
226
 
@@ -247,15 +252,24 @@
247
252
  line.position.copy(pos);
248
253
  line.quaternion.copy(rot);
249
254
  const scale = session.rigScale;
250
- const distance = this._hitDistances[i];
251
- const hasHit = distance != null;
252
- const dist = hasHit ? distance : scale;
255
+
256
+ const forceShowRay = this.usePinchToTeleport && ctrl.isTeleportGesture;
257
+ const distance = this._lastHitDistances[i];
258
+ const hasHit = this._hitDistances[i] != null;
259
+ const dist = distance != null ? distance : scale;
260
+
253
261
  line.scale.set(scale, scale, dist);
254
262
  line.visible = true;
255
263
  line.layers.disableAll();
256
264
  line.layers.enable(2);
257
265
  let targetOpacity = line.material.opacity;
258
- if (ctrl.getButton("primary")?.pressed) {
266
+ if (forceShowRay) {
267
+ targetOpacity = 1;
268
+ }
269
+ else if (this.showHits && dist < session.rigScale * 0.5) {
270
+ targetOpacity = 0;
271
+ }
272
+ else if (ctrl.getButton("primary")?.pressed) {
259
273
  targetOpacity = .5;
260
274
  }
261
275
  else {
@@ -279,26 +293,49 @@
279
293
  for (let i = 0; i < session.controllers.length; i++) {
280
294
  const ctrl = session.controllers[i];
281
295
  if (!ctrl.connected || !ctrl.isTracking || !ctrl.ray || !ctrl.hasSelectEvent) continue;
296
+ let disc = this._hitDiscs[i];
297
+ let runRaycast = true;
282
298
 
299
+
300
+ // Check if the primary input button is in use
301
+ const pointerId: number | undefined = ctrl.getPointerId("primary");
302
+ if (pointerId != undefined) {
303
+ const isCurrentlyUsed = this.context.input.getIsPointerIdInUse(pointerId);
304
+ // if the input is being used then we hide the ray
305
+ if (isCurrentlyUsed) {
306
+ if (disc) disc.visible = false;
307
+ this._hitDistances[i] = null;
308
+ this._lastHitDistances[i] = 0;
309
+ runRaycast = false;
310
+ }
311
+ }
312
+
283
313
  // save performance by only raycasting every nth frame
284
314
  const interval = this.context.time.smoothedFps >= 59 ? 1 : 10;
285
315
  if ((this.context.time.frame + ctrl.index) % interval !== 0) {
316
+ runRaycast = false;
317
+ }
318
+
319
+ if (!runRaycast) {
286
320
  const disc = this._hitDiscs[i];
287
- // if the disc had a hit last frame, we can show it again
288
- if (disc && disc["hit"]) {
289
- disc.visible = true;
290
- this.hitPointerSetPosition(ctrl, disc, disc["hit"].distance);
321
+ // if the disc had a hit last frame, we can update it here
322
+ if (disc && disc.visible && disc["hit"]) {
323
+ this.updateHitPointerPosition(ctrl, disc, disc["hit"].distance);
291
324
  }
292
325
  continue;
293
326
  }
294
327
 
295
328
  const hits = this.context.physics.raycastFromRay(ctrl.ray, { testObject: this.hitPointRaycastFilter, precise: false });
296
- const hit = hits.find(h => {
329
+ let hit = hits.find(hit => {
330
+ if (this.usePinchToTeleport && ctrl.isTeleportGesture) return true;
297
331
  // Only render hits on interactable objects
298
- return this.isObjectWithInteractiveComponent(h.object);
332
+ return this.isObjectWithInteractiveComponent(hit.object);
299
333
  });
334
+ // Fallback to use the first hit
335
+ if (!hit) {
336
+ hit = hits[0];
337
+ }
300
338
 
301
- let disc = this._hitDiscs[i];
302
339
  if (disc) // save the hit object on the disc
303
340
  {
304
341
  disc["controller"] = ctrl;
@@ -307,6 +344,7 @@
307
344
  this._hitDistances[i] = hit?.distance || null;
308
345
 
309
346
  if (hit) {
347
+ this._lastHitDistances[i] = hit.distance;
310
348
 
311
349
  const rigScale = (session.rigScale ?? 1);
312
350
  if (debug) {
@@ -318,24 +356,36 @@
318
356
  disc = this.createHitPointObject();
319
357
  this._hitDiscs[i] = disc;
320
358
  }
321
- disc.visible = true;
322
- const size = (.01 * (rigScale + hit.distance));
323
- disc.scale.set(size, size, size);
324
- disc.layers.disableAll();
325
- disc.layers.enable(2);
326
359
  disc["hit"] = hit;
327
360
 
328
- if (hit.normal) {
329
- this.hitPointerSetPosition(ctrl, disc, hit.distance);
330
- const worldNormal = hit.normal.applyQuaternion(getWorldQuaternion(hit.object));
331
- disc.quaternion.setFromUnitVectors(up, worldNormal);
361
+ // hide the disc if the hit point is too close
362
+ disc.visible = hit.distance > rigScale * 0.05;
363
+ let size = (.01 * (rigScale + hit.distance));
364
+ const primaryPressed = ctrl.getButton("primary")?.pressed;
365
+ if (primaryPressed) size *= 1.1;
366
+ disc.scale.set(size, size, size);
367
+ disc.layers.set(2);
368
+ let targetOpacity = disc.material.opacity;
369
+ if (primaryPressed) {
370
+ targetOpacity = 1;
332
371
  }
333
372
  else {
334
- this.hitPointerSetPosition(ctrl, disc, hit.distance);
373
+ targetOpacity = hit.distance < .15 * rigScale ? .2 : .6;
335
374
  }
375
+ disc.material.opacity = Mathf.lerp(disc.material.opacity, targetOpacity, this.context.time.deltaTimeUnscaled / .1);
336
376
 
337
- if (disc.parent !== this.context.scene) {
338
- this.context.scene.add(disc);
377
+ if (disc.visible) {
378
+ if (hit.normal) {
379
+ this.updateHitPointerPosition(ctrl, disc, hit.distance);
380
+ const worldNormal = hit.normal.applyQuaternion(getWorldQuaternion(hit.object));
381
+ disc.quaternion.setFromUnitVectors(up, worldNormal);
382
+ }
383
+ else {
384
+ this.updateHitPointerPosition(ctrl, disc, hit.distance);
385
+ }
386
+ if (disc.parent !== this.context.scene) {
387
+ this.context.scene.add(disc);
388
+ }
339
389
  }
340
390
  }
341
391
  else {
@@ -346,13 +396,17 @@
346
396
  }
347
397
  }
348
398
 
349
- private isObjectWithInteractiveComponent(object: Object3D) {
350
- return hasPointerEventComponent(object) || (object["isUI"] === true || object.parent?.["isUI"] === true)
399
+ private isObjectWithInteractiveComponent(object: Object3D, level: number = 0) {
400
+ if (hasPointerEventComponent(object) || (object["isUI"] === true)) return true;
401
+ if ((object as Scene).isScene) return false;
402
+ if (object.parent) return this.isObjectWithInteractiveComponent(object.parent, level + 1);
403
+ return false;
351
404
  }
352
405
 
353
- private hitPointerSetPosition(ctrl: NeedleXRController, disc: Object3D, distance: number) {
354
- disc.position.copy(ctrl.rayWorldPosition)
355
- disc.position.add(getTempVector(0, 0, distance - .01).applyQuaternion(ctrl.rayWorldQuaternion));
406
+ private updateHitPointerPosition(ctrl: NeedleXRController, pt: Object3D, distance: number) {
407
+ const targetPos = getTempVector(ctrl.rayWorldPosition);
408
+ targetPos.add(getTempVector(0, 0, distance - .01).applyQuaternion(ctrl.rayWorldQuaternion));
409
+ pt.position.lerp(targetPos, this.context.time.deltaTimeUnscaled / .05);
356
410
  }
357
411
 
358
412
  protected hitPointRaycastFilter: RaycastTestObjectCallback = (obj: Object3D) => {
@@ -362,9 +416,9 @@
362
416
  }
363
417
 
364
418
  /** create an object to visualize hit points in the scene */
365
- protected createHitPointObject(): Object3D {
366
- var container = new Object3D();
367
- const disc = new Mesh(
419
+ protected createHitPointObject(): HitPointObject {
420
+ // var container = new Object3D();
421
+ const mesh = new Mesh(
368
422
  new SphereGeometry(.3, 6, 6),// new RingGeometry(.3, 0.5, 32).rotateX(- Math.PI / 2),
369
423
  new MeshBasicMaterial({
370
424
  color: 0xeeeeee,
@@ -374,9 +428,9 @@
374
428
  side: DoubleSide,
375
429
  })
376
430
  );
377
- disc.layers.disableAll();
378
- disc.layers.enable(2);
379
- container.add(disc);
431
+ mesh.layers.disableAll();
432
+ mesh.layers.enable(2);
433
+ // container.add(disc);
380
434
 
381
435
  // const disc2 = new Mesh(
382
436
  // new RingGeometry(.43, 0.5, 32).rotateX(- Math.PI / 2),
@@ -391,7 +445,7 @@
391
445
  // disc2.layers.enable(2);
392
446
  // disc2.position.y = .01;
393
447
  // container.add(disc2);
394
- return container;
448
+ return mesh;
395
449
  }
396
450
 
397
451
  /** create an object to visualize controller rays */