Needle Engine

Changes between version 3.32.24-alpha and 3.32.25-alpha
Files changed (11) hide show
  1. src/engine-components/export/usdz/extensions/Animation.ts +30 -6
  2. src/engine-components/export/usdz/utils/animationutils.ts +4 -2
  3. src/engine-components/AudioSource.ts +3 -24
  4. src/engine/debug/debug_spatial_console.ts +30 -17
  5. src/engine/engine_application.ts +35 -1
  6. src/engine/engine_element_loading.ts +53 -46
  7. src/engine/engine_three_utils.ts +2 -0
  8. src/engine/xr/NeedleXRSession.ts +48 -36
  9. src/engine-components/export/usdz/USDZExporter.ts +2 -1
  10. src/engine-components/webxr/WebXRButtons.ts +2 -0
  11. src/engine-components/webxr/controllers/XRControllerModel.ts +13 -6
src/engine-components/export/usdz/extensions/Animation.ts CHANGED
@@ -281,7 +281,10 @@
281
281
  this.dict.set(animationTarget, []);
282
282
  }
283
283
  const transformDataForTarget = this.dict.get(animationTarget);
284
- if (!transformDataForTarget) continue;
284
+ if (!transformDataForTarget) {
285
+ console.warn("no transform data found for target ", animationTarget, "at slot " + currentCount + ", this is likely a bug");
286
+ continue;
287
+ }
285
288
 
286
289
  // this node has animation data for this clip – no need for additional padding
287
290
  unregisteredNodesForThisClip.delete(animationTarget);
@@ -332,7 +335,7 @@
332
335
 
333
336
  let model = transformDataForTarget[currentCount];
334
337
  if (!model) {
335
- if (debug) console.log("Adding padding clip for ", target, clip);
338
+ if (debug) console.log("Adding padding clip for ", target, clip, "at slot", currentCount);
336
339
  model = new TransformData(root, target, clip);
337
340
  transformDataForTarget[currentCount] = model;
338
341
  }
@@ -471,20 +474,30 @@
471
474
 
472
475
  // We should have a proper rectangular array,
473
476
  // Where for each bone we have the same number of TransformData entries.
477
+ let numberOfEntries: number | undefined = undefined;
474
478
  let allBonesHaveSameNumberOfTransformDataEntries = true;
475
- let numberOfEntries: number | undefined = undefined;
479
+ const undefinedBoneEntries = new Map<Object3D, Array<number>>();
476
480
  for (const [bone, transformDatas] of boneToTransformData) {
477
481
  if (numberOfEntries === undefined) numberOfEntries = transformDatas.length;
478
482
  if (numberOfEntries !== transformDatas.length) {
479
483
  allBonesHaveSameNumberOfTransformDataEntries = false;
480
- break;
481
484
  }
485
+ let index = 0;
486
+ for (const transformData of transformDatas) {
487
+ index++;
488
+ if (!transformData) {
489
+ if (!undefinedBoneEntries.has(bone)) undefinedBoneEntries.set(bone, []);
490
+ undefinedBoneEntries.get(bone)!.push(index);
491
+ }
492
+ }
482
493
  }
483
494
 
484
495
  // TODO not working yet for multiple skinned characters at the same time
485
- if (debug) console.log("Bone count: ", boneToTransformData.size, "TransformData entries per bone: ", numberOfEntries);//, ...dict);
496
+ if (debug) {
497
+ console.log("Bone count: ", boneToTransformData.size, "TransformData entries per bone: ", numberOfEntries, "Undefined bone entries: ", undefinedBoneEntries);
498
+ }
486
499
  console.assert(allBonesHaveSameNumberOfTransformDataEntries, "All bones should have the same number of TransformData entries", boneToTransformData);
487
-
500
+ console.assert(undefinedBoneEntries.size === 0, "All TransformData entries should be set", undefinedBoneEntries);
488
501
  const times: AnimationClipFrameTimes[] = [];
489
502
 
490
503
  for (const [bone, transformDatas] of boneToTransformData) {
@@ -731,6 +744,17 @@
731
744
  onSerialize(writer, _context) {
732
745
  if (!this.model) return;
733
746
 
747
+ // Workaround: Sanitize TransformData for this object.
748
+ // This works around an issue with wrongly detected animation roots, where some of the indices
749
+ // in the TransformData array are not property set. Reproduces with golem_yokai.glb
750
+ const arr0 = this.dict.get(this.object);
751
+ if (arr0) {
752
+ for (let i = 0; i < arr0.length; i++) {
753
+ if (arr0[i] !== undefined) continue;
754
+ arr0[i] = new TransformData(null, this.object, undefined);
755
+ }
756
+ }
757
+
734
758
  this.skinnedMeshExport(writer, _context);
735
759
 
736
760
  const object = this.object;
src/engine-components/export/usdz/utils/animationutils.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  import { Animation } from "../../../Animation.js";
5
5
  import { Animator } from "../../../Animator.js";
6
6
  import { AudioSource } from "../../../AudioSource.js";
7
- import { Behaviour, GameObject } from "../../../Component.js";
7
+ import { GameObject } from "../../../Component.js";
8
8
  import type { AnimationExtension } from "../extensions/Animation.js";
9
9
  import type { AudioExtension } from "../extensions/behavior/AudioExtension.js";
10
10
  import { PlayAnimationOnClick, PlayAudioOnClick } from "../extensions/behavior/BehaviourComponents.js";
@@ -31,7 +31,6 @@
31
31
 
32
32
  if (ext.injectImplicitBehaviours) {
33
33
  // We're registering animators with implicit PlayAnimationOnClick (with hacked "start" trigger) here.
34
- // TODO need to remove these extra components again after export.
35
34
  for (const animator of animators) {
36
35
  if (!animator || !animator.runtimeAnimatorController) continue;
37
36
  const activeState = animator.runtimeAnimatorController.activeState;
@@ -55,6 +54,9 @@
55
54
  // the behaviour can be anywhere in the hierarchy
56
55
  root.add(go);
57
56
  }
57
+
58
+ // TODO add Animation handling, otherwise multi-animation files
59
+ // directly loaded from GLB may glitch due to the added rest poses
58
60
  }
59
61
  else {
60
62
  for (const animator of animators) {
src/engine-components/AudioSource.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import { PositionalAudioHelper } from 'three/examples/jsm/helpers/PositionalAudioHelper.js';
3
3
 
4
4
  import { isDevEnvironment } from "../engine/debug/index.js";
5
- import { ApplicationEvents } from "../engine/engine_application.js";
5
+ import { Application, ApplicationEvents } from "../engine/engine_application.js";
6
6
  import { Mathf } from "../engine/engine_math.js";
7
7
  import { serializable } from "../engine/engine_serialization_decorator.js";
8
8
  import { getTempVector } from "../engine/engine_three_utils.js";
@@ -29,36 +29,15 @@
29
29
  }
30
30
 
31
31
 
32
- let userInteractionRegistered = false;
33
- const userInteractionCallbacks: Function[] = [];
34
- function onUserInteraction() {
35
- if(userInteractionRegistered) return;
36
- if(isDevEnvironment()) console.log("User interaction registered: audio can now be played");
37
- userInteractionRegistered = true;
38
- const copy = [...userInteractionCallbacks];
39
- userInteractionCallbacks.length = 0;
40
- copy.forEach(cb => cb());
41
- }
42
- document.addEventListener('pointerdown', onUserInteraction);
43
- document.addEventListener('click', onUserInteraction);
44
- document.addEventListener('dragstart', onUserInteraction);
45
- document.addEventListener('touchstart', onUserInteraction);
46
32
 
47
33
  export class AudioSource extends Behaviour {
48
34
 
49
35
  public static get userInteractionRegistered(): boolean {
50
- return userInteractionRegistered;
36
+ return Application.userInteractionRegistered;
51
37
  }
52
38
 
53
39
  public static registerWaitForAllowAudio(cb: Function) {
54
- if (cb !== null) {
55
- if (userInteractionRegistered) {
56
- cb();
57
- return;
58
- }
59
- if (userInteractionCallbacks.indexOf(cb) === -1)
60
- userInteractionCallbacks.push(cb);
61
- }
40
+ Application.registerWaitForAllowAudio(cb);
62
41
  }
63
42
 
64
43
  @serializable(URL)
src/engine/debug/debug_spatial_console.ts CHANGED
@@ -55,7 +55,9 @@
55
55
  }
56
56
 
57
57
  private readonly targetObject = new Object3D();
58
- private readonly oneEuroFilter = new OneEuroFilterXYZ(90, .1);
58
+ /** this is a point in forward view of the user */
59
+ private readonly userForwardViewPoint = new Vector3();
60
+ private readonly oneEuroFilter = new OneEuroFilterXYZ(90, .2);
59
61
  private _lastElementRemoveTime = 0;
60
62
 
61
63
  private onBeforeRender = () => {
@@ -66,31 +68,40 @@
66
68
  // TODO: need to figure out why this happens when entering VR (in the simulator at least)
67
69
  if (Number.isNaN(root.position.x))
68
70
  root.position.set(0, 0, 0);
71
+ if (Number.isNaN(root.quaternion.x))
72
+ root.quaternion.set(0, 0, 0, 1);
69
73
 
70
74
  this.context.scene.add(this.targetObject);
71
75
 
72
- const dist = 1.8;
76
+ const dist = 3.5;
73
77
  const forward = cam.worldForward;
74
78
  forward.y = 0;
75
79
  forward.normalize().multiplyScalar((dist * 100) / cam.fov);
76
- this.targetObject.position.copy(cam.worldPosition).sub(forward);
77
- lookAtObject(this.targetObject, cam, false, true);
80
+ this.userForwardViewPoint.copy(cam.worldPosition).sub(forward);
78
81
 
82
+ const distFromForwardView = this.targetObject.position.distanceTo(this.userForwardViewPoint);
83
+ if (distFromForwardView > 2) {
84
+ this.targetObject.position.copy(this.userForwardViewPoint);
85
+ lookAtObject(this.targetObject, cam, true, true);
86
+ this.targetObject.rotateY(Math.PI);
87
+ }
88
+
79
89
  this.oneEuroFilter.filter(this.targetObject.position, root.position, this.context.time.time);
80
- const step = this.context.time.deltaTime * 10;
90
+ const step = this.context.time.deltaTime;
81
91
  root.quaternion.slerp(this.targetObject.quaternion, step);
82
92
 
83
93
  this.targetObject.removeFromParent();
84
94
  this.context.scene.add(this.getRoot() as any);
85
95
 
86
- if (this.context.time.time - this._lastElementRemoveTime > .3) {
96
+ if (this.context.time.time - this._lastElementRemoveTime > .1) {
87
97
  this._lastElementRemoveTime = this.context.time.time;
88
98
  const now = Date.now();
89
- for (let i = 0; i < root.children.length; i++) {
90
- const el = root.children[i];
91
- if (el instanceof ThreeMeshUI.Text && now - el["_activatedTime"] > 10000) {
99
+ for (let i = 0; i < this._activeTexts.length; i++) {
100
+ const el = this._activeTexts[i];
101
+ if (el instanceof ThreeMeshUI.Text && now - el["_activatedTime"] > 20000) {
92
102
  el.removeFromParent();
93
103
  this._textBuffer.push(el);
104
+ this._activeTexts.splice(i, 1);
94
105
  break;
95
106
  }
96
107
  }
@@ -121,19 +132,19 @@
121
132
  break;
122
133
  }
123
134
 
124
- text.set({
125
- backgroundColor: backgroundColor,
126
- color: fontColor,
127
- });
128
-
129
135
  if (message.length > 1000) message = message.substring(0, 1000) + "...";
130
136
 
131
- const hourMinuteSecond = new Date().toLocaleTimeString().split(" ")[0];
132
- text.textContent = "[" + hourMinuteSecond + "] " + message;
137
+ const minuteSecondMilliSecond = new Date().toISOString().split("T")[1].split(".")[0];
138
+ text.textContent = "[" + minuteSecondMilliSecond + "] " + message;
133
139
  text.visible = true;
134
140
  text["_activatedTime"] = Date.now();
135
141
  root.add(text as any);
142
+ this._activeTexts.push(text);
136
143
  if (this.context) this.context.scene.add(root as any);
144
+ text.set({
145
+ backgroundColor: backgroundColor,
146
+ color: fontColor,
147
+ });
137
148
 
138
149
  ThreeMeshUI.update();
139
150
  }
@@ -162,6 +173,7 @@
162
173
  borderRadius: .03,
163
174
  };
164
175
  private readonly _textBuffer: ThreeMeshUI.Text[] = [];
176
+ private readonly _activeTexts: ThreeMeshUI.Text[] = [];
165
177
  private getText() {
166
178
  const root = this.getRoot();
167
179
  if (this._textBuffer.length > 0) {
@@ -171,7 +183,8 @@
171
183
  return text;
172
184
  }
173
185
  if (root.children.length > 20) {
174
- return root.children[0] as any as ThreeMeshUI.Text;
186
+ const active = this._activeTexts[0];
187
+ return active;
175
188
  }
176
189
  const newText = new ThreeMeshUI.Text(this.textOptions);
177
190
  setTimeout(() => this.disableDepthTestRecursive(newText as any), 500);
src/engine/engine_application.ts CHANGED
@@ -1,4 +1,6 @@
1
+ import { isDevEnvironment } from "./debug/debug.js";
1
2
  import { Context } from "./engine_setup.js";
3
+ import { NeedleXRSession } from "./engine_xr.js";
2
4
 
3
5
  export enum ApplicationEvents {
4
6
  Visible = "application-visible",
@@ -6,8 +8,41 @@
6
8
  MuteChanged = "application-mutechanged",
7
9
  }
8
10
 
11
+ let userInteractionRegistered = false;
12
+ const userInteractionCallbacks: Function[] = [];
13
+ function onUserInteraction() {
14
+ if (userInteractionRegistered) return;
15
+ if (isDevEnvironment()) console.log("User interaction registered: audio can now be played");
16
+ userInteractionRegistered = true;
17
+ const copy = [...userInteractionCallbacks];
18
+ userInteractionCallbacks.length = 0;
19
+ copy.forEach(cb => cb());
20
+ }
21
+ document.addEventListener('pointerdown', onUserInteraction);
22
+ document.addEventListener('click', onUserInteraction);
23
+ document.addEventListener('dragstart', onUserInteraction);
24
+ document.addEventListener('touchstart', onUserInteraction);
25
+ NeedleXRSession.onXRSessionStart(() => {
26
+ onUserInteraction();
27
+ })
28
+
9
29
  export class Application extends EventTarget {
10
30
 
31
+ public static get userInteractionRegistered(): boolean {
32
+ return userInteractionRegistered;
33
+ }
34
+
35
+ public static registerWaitForAllowAudio(cb: Function) {
36
+ if (cb !== null) {
37
+ if (userInteractionRegistered) {
38
+ cb();
39
+ return;
40
+ }
41
+ if (userInteractionCallbacks.indexOf(cb) === -1)
42
+ userInteractionCallbacks.push(cb);
43
+ }
44
+ }
45
+
11
46
  private _mute: boolean = false;
12
47
  /** audio muted? */
13
48
  get muted() { return this._mute; }
@@ -33,7 +68,6 @@
33
68
  constructor(context: Context) {
34
69
  super();
35
70
  this.context = context;
36
- // console.log("APP");
37
71
  window.addEventListener("visibilitychange", this.onVisiblityChanged.bind(this), false);
38
72
  }
39
73
 
src/engine/engine_element_loading.ts CHANGED
@@ -178,7 +178,8 @@
178
178
  this._loadingElement = existing || document.createElement("div");
179
179
 
180
180
  let loadingStyle: LoadingStyleOption = this._element.getAttribute("loading-style") as LoadingStyleOption;
181
- if (loadingStyle === "auto") {
181
+ // if nothing is defined OR loadingStyle is set to auto
182
+ if (!loadingStyle || loadingStyle === "auto") {
182
183
  if (window.matchMedia('(prefers-color-scheme: dark)').matches)
183
184
  loadingStyle = "dark";
184
185
  else
@@ -205,9 +206,9 @@
205
206
  this._loadingElement.style.color = "white";
206
207
  this._loadingElement.style.fontFamily = "Roboto, sans-serif, Arial";
207
208
  if (loadingStyle === "light")
208
- this._loadingElement.style.color = "rgba(0,0,0,.8)";
209
+ this._loadingElement.style.color = "rgba(0,0,0,.6)";
209
210
  else
210
- this._loadingElement.style.color = "rgba(255,255,255,.5)";
211
+ this._loadingElement.style.color = "rgba(255,255,255,.3)";
211
212
  if (hasLicense && this._element) {
212
213
  const loadingBackgroundColor = this._element.getAttribute("loading-background-color");
213
214
  if (loadingBackgroundColor) {
@@ -228,46 +229,21 @@
228
229
  }
229
230
  }
230
231
 
231
- const container = document.createElement("div");
232
- container.style.cssText = `
233
- display: flex;
234
- flex-direction: column;
235
- align-items: center;
236
- justify-content: center;
237
- width: 100%;
238
- opacity: 0;
239
- transition: opacity 1.2s ease-in-out .2s;
240
- `;
241
- setTimeout(() => { container.style.opacity = "1"; }, 1);
242
- this._loadingElement.appendChild(container);
232
+ const content = document.createElement("div");
233
+ this._loadingElement.appendChild(content);
243
234
 
244
- const loadingBarContainer = document.createElement("div");
245
- const maxWidth = 30;
246
- loadingBarContainer.style.display = "flex";
247
- loadingBarContainer.style.width = maxWidth + "%";
248
- loadingBarContainer.style.height = "3px";
249
- if (loadingStyle === "light")
250
- loadingBarContainer.style.backgroundColor = "rgba(0,0,0,.2)"
251
- else
252
- loadingBarContainer.style.backgroundColor = "rgba(255,255,255,.2)"
253
- // loadingBarContainer.style.alignItems = "center";
254
-
255
235
  const logo = document.createElement("img");
256
- const logoSize = 64;
236
+ const logoSize = 120;
257
237
  logo.style.width = `${logoSize}px`;
258
238
  logo.style.height = `${logoSize}px`;
259
- logo.style.marginBottom = "20px";
239
+ logo.style.marginBottom = "10px";
260
240
  logo.style.userSelect = "none";
261
241
  logo.style.objectFit = "contain";
262
- if (!hasCommercialLicense()) {
263
- logo.style.transition = "transform 1s ease-in-out, opacity 1s ease-in-out";
264
- logo.style.transform = "translateY(10px)";
265
- logo.style.opacity = "1";
266
- setTimeout(() => {
267
- logo.style.transform = "translateY(0px)";
268
- logo.style.opacity = "1";
269
- }, 1);
270
- }
242
+ logo.style.transition = "transform 2s ease-out, opacity 1s ease-in-out";
243
+ logo.style.transform = "translateY(10px)";
244
+ setTimeout(() => {
245
+ logo.style.transform = "translateY(0px)";
246
+ }, 1);
271
247
  logo.src = logoSVG;
272
248
  let isUsingCustomLogo = false;
273
249
  if (hasLicense && this._element) {
@@ -282,17 +258,48 @@
282
258
  logo.style.pointerEvents = "all";
283
259
  logo.addEventListener("click", () => window.open("https://needle.tools", "_blank"));
284
260
  }
285
- container.appendChild(logo);
286
- container.appendChild(loadingBarContainer);
261
+ content.appendChild(logo);
287
262
 
263
+ const details = document.createElement("div");
264
+ details.style.cssText = `
265
+ display: flex;
266
+ flex-direction: column;
267
+ align-items: center;
268
+ justify-content: center;
269
+ width: 100%;
270
+ opacity: 0;
271
+ transition: opacity 1s ease-in-out 4s;
272
+ `;
273
+ setTimeout(() => { details.style.opacity = "1"; }, 1);
274
+ this._loadingElement.appendChild(details);
288
275
 
276
+ const loadingBarContainer = document.createElement("div");
277
+ const maxWidth = 100;
278
+ loadingBarContainer.style.display = "flex";
279
+ loadingBarContainer.style.width = maxWidth + "%";
280
+ loadingBarContainer.style.height = "3px";
281
+ loadingBarContainer.style.position = "absolute";
282
+ loadingBarContainer.style.left = "0";
283
+ loadingBarContainer.style.bottom = "0px";
284
+ loadingBarContainer.style.opacity = "0";
285
+ loadingBarContainer.style.transition = "opacity 1s ease-in-out 2s";
286
+ setTimeout(() => { loadingBarContainer.style.opacity = "1"; }, 1);
287
+ if (loadingStyle === "light")
288
+ loadingBarContainer.style.backgroundColor = "rgba(0,0,0,.2)"
289
+ else
290
+ loadingBarContainer.style.backgroundColor = "rgba(255,255,255,.2)"
291
+ // loadingBarContainer.style.alignItems = "center";
292
+
293
+ this._loadingElement.appendChild(loadingBarContainer);
294
+
295
+
289
296
  this._loadingBar = document.createElement("div");
290
297
  loadingBarContainer.appendChild(this._loadingBar);
291
298
  const getGradientPos = function (t: number): string {
292
- return Mathf.lerp(maxWidth * .5, 100 - maxWidth * .5, t) + "%";
299
+ return Mathf.lerp(0, maxWidth, t) + "%";
293
300
  }
294
301
  this._loadingBar.style.background =
295
- `linear-gradient(90deg, #02022B ${getGradientPos(0)}, #0BA398 ${getGradientPos(.4)}, #99CC33 ${getGradientPos(.5)}, #D7DB0A ${getGradientPos(1)})`;
302
+ `linear-gradient(90deg, #204f49 ${getGradientPos(0)}, #0BA398 ${getGradientPos(.3)}, #66A22F ${getGradientPos(.6)}, #D7DB0A ${getGradientPos(1)})`;
296
303
  this._loadingBar.style.backgroundAttachment = "fixed";
297
304
  this._loadingBar.style.width = "0%";
298
305
  this._loadingBar.style.height = "100%";
@@ -313,18 +320,18 @@
313
320
  this._loadingTextContainer = document.createElement("div");
314
321
  this._loadingTextContainer.style.display = "flex";
315
322
  this._loadingTextContainer.style.justifyContent = "center";
316
- this._loadingTextContainer.style.marginTop = "1.2em";
317
- container.appendChild(this._loadingTextContainer);
323
+ this._loadingTextContainer.style.marginTop = ".2rem";
324
+ details.appendChild(this._loadingTextContainer);
318
325
 
319
326
  const messageContainer = document.createElement("div");
320
327
  this._messageContainer = messageContainer;
321
328
  messageContainer.style.display = "flex";
322
329
  messageContainer.style.fontSize = ".8em";
323
- messageContainer.style.paddingTop = ".5em";
330
+ messageContainer.style.paddingTop = ".2rem";
324
331
  messageContainer.style.fontWeight = "200";
325
332
  // messageContainer.style.border = "1px solid rgba(255,255,255,.1)";
326
333
  messageContainer.style.justifyContent = "center";
327
- container.appendChild(messageContainer);
334
+ details.appendChild(messageContainer);
328
335
 
329
336
  if (hasLicense && this._element) {
330
337
  const loadingTextColor = this._element.getAttribute("loading-text-color");
@@ -333,7 +340,7 @@
333
340
  }
334
341
  }
335
342
 
336
- this.handleRuntimeLicense(container);
343
+ this.handleRuntimeLicense(details);
337
344
 
338
345
  return this._loadingElement;
339
346
  }
src/engine/engine_three_utils.ts CHANGED
@@ -34,7 +34,9 @@
34
34
  setWorldQuaternion(object, getWorldQuaternion(target));
35
35
  // look at forward again so we don't get any roll
36
36
  if (keepUpDirection) {
37
+ const ypos = lookFrom.y;
37
38
  const forwardPoint = lookFrom.sub(getWorldDirection(object));
39
+ forwardPoint.y = ypos;
38
40
  object.lookAt(forwardPoint);
39
41
  }
40
42
  return;
src/engine/xr/NeedleXRSession.ts CHANGED
@@ -714,11 +714,8 @@
714
714
  this.mode = mode;
715
715
  this.context = context;
716
716
 
717
- if(debug || getParam("console")) enableSpatialConsole(true);
717
+ if (debug || getParam("console")) enableSpatialConsole(true);
718
718
 
719
- this.context.xr = this;
720
- this.context.renderer.xr.setSession(this.session).then(this.onRendererSessionSet)
721
-
722
719
  this._xr_scripts = [...extra.scripts];
723
720
  this._xr_update_scripts = this._xr_scripts.filter(e => typeof e.onUpdateXR === "function");
724
721
  this._controllerAdded = extra.controller_added;
@@ -769,6 +766,11 @@
769
766
  this.onInputSourceAdded(newInputSource);
770
767
  }
771
768
  });
769
+
770
+ // we set the session on the webxr manager at the end because we want to receive inputsource events first
771
+ // e.g. in case there's a bug in the threejs codebase
772
+ this.context.xr = this;
773
+ this.context.renderer.xr.setSession(this.session).then(this.onRendererSessionSet)
772
774
  }
773
775
 
774
776
  /** called when renderer.setSession is fulfilled */
@@ -793,14 +795,33 @@
793
795
  }
794
796
  // check if an xr controller for this input source already exists
795
797
  // in case we have both an event from inputsourceschange and from the construtor initial input sources
796
- if (this.controllers.find(c => c.inputSource === newInputSource)) return;
797
-
798
+ if (this.controllers.find(c => c.inputSource === newInputSource)) {
799
+ console.warn("Controller already exists for input source", index);
800
+ return;
801
+ }
798
802
  const newController = new NeedleXRController(this, newInputSource, index);
799
- this.controllers.push(newController);
800
- this.controllers.sort((a, b) => a.index - b.index);
801
803
  this._newControllers.push(newController);
802
- this.invokeControllerEvent(newController, this._controllerAdded, "added");
804
+ }
803
805
 
806
+ /** Disconnects the controller, invokes events and notifies previou controller (if any) */
807
+ private disconnectInputSource(inputSource: XRInputSource) {
808
+ for (let i = this.controllers.length - 1; i >= 0; i--) {
809
+ const oldController = this.controllers[i];
810
+ if (oldController.inputSource === inputSource) {
811
+ console.log("Disconnecting controller", oldController.index);
812
+ this.controllers.splice(i, 1);
813
+ this.invokeControllerEvent(oldController, this._controllerRemoved, "removed");
814
+ const args: NeedleXRControllerEventArgs = {
815
+ xr: this,
816
+ controller: oldController,
817
+ change: "removed"
818
+ };
819
+ for (const script of this._xr_scripts) {
820
+ if (script.onXRControllerRemoved) script.onXRControllerRemoved(args);
821
+ }
822
+ oldController.onDisconnected();
823
+ }
824
+ }
804
825
  }
805
826
 
806
827
  /** End the XR Session */
@@ -880,26 +901,6 @@
880
901
  enableSpatialConsole(false);
881
902
  };
882
903
 
883
- /** Disconnects the controller, invokes events and notifies previou controller (if any) */
884
- private disconnectInputSource(inputSource: XRInputSource) {
885
- for (let i = this.controllers.length - 1; i >= 0; i--) {
886
- const oldController = this.controllers[i];
887
- if (oldController.inputSource === inputSource) {
888
- this.controllers.splice(i, 1);
889
- this.invokeControllerEvent(oldController, this._controllerRemoved, "removed");
890
- const args: NeedleXRControllerEventArgs = {
891
- xr: this,
892
- controller: oldController,
893
- change: "removed"
894
- };
895
- for (const script of this._xr_scripts) {
896
- if (script.onXRControllerRemoved) script.onXRControllerRemoved(args);
897
- }
898
- oldController.onDisconnected();
899
- }
900
- }
901
- }
902
-
903
904
  private _didStart: boolean = false;
904
905
 
905
906
  /** Called every frame by the engine */
@@ -1010,16 +1011,27 @@
1010
1011
  }
1011
1012
 
1012
1013
  // handle when new controllers have been added
1013
- for (const controller of this._newControllers) {
1014
- for (const script of this._xr_scripts) {
1015
- if (script.destroyed) {
1016
- this._script_to_remove.push(script);
1017
- continue;
1014
+ if (this._newControllers.length > 0) {
1015
+ const copy = [...this._newControllers];
1016
+ this._newControllers.length = 0;
1017
+ for (const controller of copy) {
1018
+ if (!controller.connected) continue;
1019
+ this.controllers.push(controller);
1020
+ for (const script of this._xr_scripts) {
1021
+ if (script.destroyed) {
1022
+ this._script_to_remove.push(script);
1023
+ continue;
1024
+ }
1025
+ if (script.activeAndEnabled === false) {
1026
+ continue;
1027
+ }
1028
+ this.invokeCallback_ControllerAdded(script, controller);
1029
+ // if (script.onXRControllerAdded)
1030
+ // script.onXRControllerAdded({ xr: this, controller, change: "added" });
1018
1031
  }
1019
- if (script.onXRControllerAdded) script.onXRControllerAdded({ xr: this, controller, change: "added" });
1020
1032
  }
1033
+ this.controllers.sort((a, b) => a.index - b.index);
1021
1034
  }
1022
- this._newControllers.length = 0;
1023
1035
 
1024
1036
  // invoke update on all scripts
1025
1037
  for (const script of this._xr_update_scripts) {
src/engine-components/export/usdz/USDZExporter.ts CHANGED
@@ -300,8 +300,9 @@
300
300
  quickLookCompatible: this.quickLookCompatible,
301
301
  maxTextureSize: this.maxTextureSize,
302
302
  });
303
- const blob = new Blob([arraybuffer], { type: 'application/octet-stream' });
304
303
 
304
+ const blob = new Blob([arraybuffer], { type: 'vnd.usdz+zip' });
305
+
305
306
  // cleanup – implicit animation behaviors need to be removed again
306
307
  for (const go of implicitBehaviors) {
307
308
  GameObject.destroy(go);
src/engine-components/webxr/WebXRButtons.ts CHANGED
@@ -44,6 +44,7 @@
44
44
  bottom: 100px;
45
45
  left: 50%;
46
46
  transform: translateX(-50%);
47
+ pointer-events: none;
47
48
  }
48
49
  :host button {
49
50
  font-family: Roboto, sans-serif, Arial;
@@ -59,6 +60,7 @@
59
60
  border: rgba(255, 255, 255, 0.2) solid 1px;
60
61
  box-shadow: 0 0 12px rgba(0, 0, 0, 0.2);
61
62
  font-weight: normal;
63
+ pointer-events: all;
62
64
  }
63
65
  :host button:hover {
64
66
  cursor: pointer;
src/engine-components/webxr/controllers/XRControllerModel.ts CHANGED
@@ -41,7 +41,7 @@
41
41
  return mode === "immersive-vr" || mode === "immersive-ar";
42
42
  }
43
43
 
44
- private _models = new Array<{ model?: IGameObject, controller: NeedleXRController, handmesh?: XRHandMeshModel }>();
44
+ private readonly _models = new Array<{ model?: IGameObject, controller: NeedleXRController, handmesh?: XRHandMeshModel }>();
45
45
 
46
46
 
47
47
  async onXRControllerAdded(args: NeedleXRControllerEventArgs) {
@@ -57,7 +57,8 @@
57
57
  if (controller.hand) {
58
58
  if (this.createHandModel) {
59
59
  const res = await this.loadHandModel(controller);
60
- if (!res || !controller.connected) {
60
+ // check if the model doesnt exist, the hand disconnected or it's suddenly a controller
61
+ if (!res || !controller.connected || !controller.isHand) {
61
62
  res?.handObject?.removeFromParent();
62
63
  res?.handmesh?.controller?.removeFromParent();
63
64
  return;
@@ -72,7 +73,10 @@
72
73
  const assetUrl = await controller.getModelUrl();
73
74
  if (assetUrl) {
74
75
  const model = await this.loadModel(controller, assetUrl);
75
- if (!model || !controller.connected) return;
76
+ // check if the model doesnt exist, the controller disconnected or it's suddenly a hand
77
+ if (!model || !controller.connected || controller.isHand) {
78
+ return;
79
+ }
76
80
  this._models.push({ controller: controller, model });
77
81
  this._models.sort((a, b) => a.controller.index - b.controller.index);
78
82
  this.scene.add(model);
@@ -90,16 +94,19 @@
90
94
  }
91
95
  onXRControllerRemoved(args: NeedleXRControllerEventArgs): void {
92
96
  // 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
93
- const indexInArray = this._models.findIndex(m => m?.controller === args.controller);
97
+ const indexInArray = this._models.findIndex(m => m.controller === args.controller);
94
98
  const entry = this._models[indexInArray];
95
99
  if (!entry) return;
100
+
96
101
  this._models.splice(indexInArray, 1);
97
-
102
+
98
103
  if (entry.handmesh) {
99
104
  entry.handmesh.handModel?.removeFromParent();
105
+ entry.handmesh = undefined;
100
106
  }
101
107
  if (entry.model) {
102
108
  entry.model.removeFromParent();
109
+ entry.model = undefined;
103
110
  }
104
111
  }
105
112
  onBeforeRender() {
@@ -125,7 +132,7 @@
125
132
  if (!entry) continue;
126
133
  entry.model?.removeFromParent();
127
134
  }
128
- this._models = [];
135
+ this._models.length = 0;
129
136
  }
130
137
 
131
138
  private updateRendering(xr: NeedleXRSession) {