@needle-tools/engine 5.0.4 → 5.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/components.needle.json +1 -1
  3. package/dist/{needle-engine.bundle-CYrPktak.umd.cjs → needle-engine.bundle-CwycbG-K.umd.cjs} +137 -136
  4. package/dist/{needle-engine.bundle-B3Km2VZ4.js → needle-engine.bundle-DLNQOFNZ.js} +7197 -7012
  5. package/dist/{needle-engine.bundle-CX-SJZzp.min.js → needle-engine.bundle-yYWZqy6w.min.js} +139 -138
  6. package/dist/needle-engine.d.ts +125 -13
  7. package/dist/needle-engine.js +560 -557
  8. package/dist/needle-engine.min.js +1 -1
  9. package/dist/needle-engine.umd.cjs +1 -1
  10. package/dist/{vendor-vHLk8sXu.js → vendor-CAcsI0eU.js} +116 -115
  11. package/dist/{vendor-CntUvmJu.umd.cjs → vendor-CEM38hLE.umd.cjs} +2 -2
  12. package/dist/{vendor-DPbfJJ4d.min.js → vendor-HRlxIBga.min.js} +2 -2
  13. package/lib/engine/api.d.ts +2 -0
  14. package/lib/engine/api.js +2 -0
  15. package/lib/engine/api.js.map +1 -1
  16. package/lib/engine/debug/debug_spatial_console.d.ts +2 -0
  17. package/lib/engine/debug/debug_spatial_console.js +10 -7
  18. package/lib/engine/debug/debug_spatial_console.js.map +1 -1
  19. package/lib/engine/engine_addressables.d.ts +2 -0
  20. package/lib/engine/engine_addressables.js +6 -3
  21. package/lib/engine/engine_addressables.js.map +1 -1
  22. package/lib/engine/engine_audio.d.ts +68 -0
  23. package/lib/engine/engine_audio.js +172 -0
  24. package/lib/engine/engine_audio.js.map +1 -1
  25. package/lib/engine/engine_gameobject.js +2 -2
  26. package/lib/engine/engine_gameobject.js.map +1 -1
  27. package/lib/engine/engine_init.js +8 -0
  28. package/lib/engine/engine_init.js.map +1 -1
  29. package/lib/engine/engine_mainloop_utils.js +5 -2
  30. package/lib/engine/engine_mainloop_utils.js.map +1 -1
  31. package/lib/engine/engine_serialization_builtin_serializer.js +27 -0
  32. package/lib/engine/engine_serialization_builtin_serializer.js.map +1 -1
  33. package/lib/engine/webcomponents/needle-engine.d.ts +9 -3
  34. package/lib/engine/webcomponents/needle-engine.js.map +1 -1
  35. package/lib/engine/xr/NeedleXRSession.d.ts +3 -2
  36. package/lib/engine/xr/NeedleXRSession.js +50 -14
  37. package/lib/engine/xr/NeedleXRSession.js.map +1 -1
  38. package/lib/engine-components/Animation.js +17 -16
  39. package/lib/engine-components/Animation.js.map +1 -1
  40. package/lib/engine-components/AnimatorController.d.ts +2 -0
  41. package/lib/engine-components/AnimatorController.js +4 -1
  42. package/lib/engine-components/AnimatorController.js.map +1 -1
  43. package/lib/engine-components/AudioSource.d.ts +19 -3
  44. package/lib/engine-components/AudioSource.js +121 -68
  45. package/lib/engine-components/AudioSource.js.map +1 -1
  46. package/lib/engine-components/NestedGltf.d.ts +19 -3
  47. package/lib/engine-components/NestedGltf.js +19 -3
  48. package/lib/engine-components/NestedGltf.js.map +1 -1
  49. package/lib/engine-components/Networking.d.ts +1 -1
  50. package/lib/engine-components/Networking.js +1 -1
  51. package/lib/engine-components/postprocessing/VolumeParameter.d.ts +2 -0
  52. package/lib/engine-components/postprocessing/VolumeParameter.js +4 -1
  53. package/lib/engine-components/postprocessing/VolumeParameter.js.map +1 -1
  54. package/lib/engine-components/ui/Canvas.d.ts +1 -1
  55. package/lib/engine-components/ui/Canvas.js +2 -8
  56. package/lib/engine-components/ui/Canvas.js.map +1 -1
  57. package/lib/engine-components/webxr/WebXRImageTracking.js +4 -0
  58. package/lib/engine-components/webxr/WebXRImageTracking.js.map +1 -1
  59. package/package.json +1 -1
  60. package/src/engine/api.ts +3 -0
  61. package/src/engine/debug/debug_spatial_console.ts +10 -7
  62. package/src/engine/engine_addressables.ts +6 -3
  63. package/src/engine/engine_audio.ts +184 -0
  64. package/src/engine/engine_gameobject.ts +2 -2
  65. package/src/engine/engine_init.ts +8 -0
  66. package/src/engine/engine_mainloop_utils.ts +5 -2
  67. package/src/engine/engine_serialization_builtin_serializer.ts +31 -3
  68. package/src/engine/webcomponents/needle-engine.ts +9 -3
  69. package/src/engine/xr/NeedleXRSession.ts +48 -13
  70. package/src/engine-components/Animation.ts +19 -16
  71. package/src/engine-components/AnimatorController.ts +4 -1
  72. package/src/engine-components/AudioSource.ts +130 -79
  73. package/src/engine-components/NestedGltf.ts +20 -4
  74. package/src/engine-components/Networking.ts +1 -1
  75. package/src/engine-components/postprocessing/VolumeParameter.ts +4 -1
  76. package/src/engine-components/ui/Canvas.ts +2 -8
  77. package/src/engine-components/webxr/WebXRImageTracking.ts +2 -0
@@ -21,4 +21,188 @@ export function ensureAudioContextIsResumed() {
21
21
  }, 500);
22
22
  });
23
23
  });
24
+ }
25
+
26
+
27
+ /**
28
+ * Represents an audio clip that can be loaded and played independently.
29
+ * The AudioClip class encapsulates the URL of the audio resource and provides
30
+ * methods for playback control (play, pause, stop) and querying duration.
31
+ */
32
+ export class AudioClip {
33
+
34
+ /**
35
+ * Creates a new AudioClip instance with the specified URL.
36
+ * @param url The URL of the audio resource to load. This can be a path to an audio file or a MediaStream URL.
37
+ */
38
+ constructor(public readonly url: string) {
39
+ }
40
+
41
+ /** Whether the clip is currently playing.
42
+ * @returns `true` if the clip is actively playing audio.
43
+ */
44
+ get isPlaying(): boolean {
45
+ return this._audioElement !== undefined
46
+ && !this._audioElement.paused
47
+ && !this._audioElement.ended;
48
+ }
49
+
50
+ /**
51
+ * The total duration of the audio clip in seconds.
52
+ * Loads the audio metadata if not already available.
53
+ * @returns A promise that resolves with the duration in seconds.
54
+ */
55
+ getDuration(): Promise<number> {
56
+ if (this._duration !== undefined) {
57
+ return Promise.resolve(this._duration);
58
+ }
59
+ return this.ensureAudioElement().then(audio => {
60
+ this._duration = audio.duration;
61
+ return audio.duration;
62
+ });
63
+ }
64
+
65
+ /**
66
+ * Plays the audio clip from the current position.
67
+ * @returns A promise that resolves when playback finishes, or rejects on error.
68
+ * If the clip is looping, the promise will never resolve on its own – call {@link stop} or {@link pause} to end playback.
69
+ */
70
+ // #region Play
71
+ play(): Promise<void> {
72
+ return this.ensureAudioElement().then(audio => {
73
+ return new Promise<void>((resolve, reject) => {
74
+ const onEnded = () => {
75
+ cleanup();
76
+ resolve();
77
+ };
78
+ const onError = () => {
79
+ cleanup();
80
+ reject(new Error(`Playback error for ${this.url}`));
81
+ };
82
+ const onPause = () => {
83
+ // pause/stop also resolve the promise
84
+ cleanup();
85
+ resolve();
86
+ };
87
+ const cleanup = () => {
88
+ audio.removeEventListener("ended", onEnded);
89
+ audio.removeEventListener("error", onError);
90
+ audio.removeEventListener("pause", onPause);
91
+ };
92
+ audio.addEventListener("ended", onEnded);
93
+ audio.addEventListener("error", onError);
94
+ audio.addEventListener("pause", onPause);
95
+ audio.play().catch(err => {
96
+ cleanup();
97
+ reject(err);
98
+ });
99
+ });
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Pauses playback at the current position.
105
+ * Call {@link play} to resume.
106
+ */
107
+ // #region Pause/Stop
108
+ pause(): void {
109
+ this._audioElement?.pause();
110
+ }
111
+
112
+ /**
113
+ * Stops playback and resets the position to the beginning.
114
+ */
115
+ stop(): void {
116
+ if (this._audioElement) {
117
+ this._audioElement.pause();
118
+ this._audioElement.currentTime = 0;
119
+ }
120
+ }
121
+
122
+ /** Whether the clip should loop when reaching the end. */
123
+ get loop(): boolean { return this._loop; }
124
+ set loop(value: boolean) {
125
+ this._loop = value;
126
+ if (this._audioElement) this._audioElement.loop = value;
127
+ }
128
+
129
+ /** Playback volume from 0 (silent) to 1 (full). */
130
+ get volume(): number { return this._volume; }
131
+ set volume(value: number) {
132
+ this._volume = value;
133
+ if (this._audioElement) this._audioElement.volume = value;
134
+ }
135
+
136
+ /** Current playback position in seconds. */
137
+ get currentTime(): number { return this._audioElement?.currentTime ?? 0; }
138
+ set currentTime(value: number) {
139
+ if (this._audioElement) this._audioElement.currentTime = value;
140
+ }
141
+
142
+ /** Normalized playback progress from 0 to 1.
143
+ * @returns The current playback position as a value between 0 and 1, or 0 if the duration is unknown.
144
+ */
145
+ get progress(): number {
146
+ if (!this._audioElement || !this._duration) return 0;
147
+ return this._audioElement.currentTime / this._duration;
148
+ }
149
+
150
+ /**
151
+ * Seeks to a normalized position (0–1) in the clip.
152
+ * @param position A value between 0 (start) and 1 (end).
153
+ */
154
+ // #region Seek
155
+ seek(position: number): void {
156
+ if (this._audioElement && this._duration) {
157
+ this._audioElement.currentTime = Math.max(0, Math.min(1, position)) * this._duration;
158
+ }
159
+ }
160
+
161
+ /** The underlying HTMLAudioElement, or `undefined` if not yet created.
162
+ * Use this to connect the element to the Web Audio API via `createMediaElementSource()`.
163
+ * @returns The HTMLAudioElement if the clip has been loaded or played, otherwise `undefined`.
164
+ */
165
+ get audioElement(): HTMLAudioElement | undefined { return this._audioElement; }
166
+
167
+ private _audioElement?: HTMLAudioElement;
168
+ private _duration?: number;
169
+ private _loadPromise?: Promise<HTMLAudioElement>;
170
+ private _loop: boolean = false;
171
+ private _volume: number = 1;
172
+
173
+ /** Lazily creates and loads the shared HTMLAudioElement. */
174
+ private ensureAudioElement(): Promise<HTMLAudioElement> {
175
+ if (this._audioElement && this._loadPromise) {
176
+ return this._loadPromise;
177
+ }
178
+ const audio = this._audioElement ?? new Audio(this.url);
179
+ this._audioElement = audio;
180
+ audio.loop = this._loop;
181
+ audio.volume = this._volume;
182
+
183
+ if (audio.readyState >= HTMLMediaElement.HAVE_METADATA) {
184
+ this._duration = audio.duration;
185
+ this._loadPromise = Promise.resolve(audio);
186
+ return this._loadPromise;
187
+ }
188
+
189
+ this._loadPromise = new Promise<HTMLAudioElement>((resolve, reject) => {
190
+ const onLoaded = () => {
191
+ cleanup();
192
+ this._duration = audio.duration;
193
+ resolve(audio);
194
+ };
195
+ const onError = (e: Event) => {
196
+ cleanup();
197
+ reject(new Error(`Failed to load audio clip from ${this.url}: ${e}`));
198
+ };
199
+ const cleanup = () => {
200
+ audio.removeEventListener("loadedmetadata", onLoaded);
201
+ audio.removeEventListener("error", onError);
202
+ };
203
+ audio.addEventListener("loadedmetadata", onLoaded);
204
+ audio.addEventListener("error", onError);
205
+ });
206
+ return this._loadPromise;
207
+ }
24
208
  }
@@ -207,8 +207,8 @@ function internalDestroy(instance: Object3D | Component, recursive: boolean = tr
207
207
  if (comp[$isDontDestroy]) return;
208
208
  destroyed_components.push(comp);
209
209
  const go = comp.gameObject;
210
- comp.__internalDisable();
211
- comp.__internalDestroy();
210
+ comp.__internalDisable?.();
211
+ comp.__internalDestroy?.();
212
212
  comp.gameObject = go;
213
213
  return;
214
214
  }
@@ -1,7 +1,10 @@
1
+ import { initAnimatorControllerSerializer } from "../engine-components/AnimatorController.js";
1
2
  import { initAnimationAutoplay } from "../engine-components/AnimationUtilsAutoplay.js";
2
3
  import { initCameraUtils } from "../engine-components/CameraUtils.js";
4
+ import { initVolumeParameterSerializer } from "../engine-components/postprocessing/VolumeParameter.js";
3
5
  import { initSceneSwitcherAttributes } from "../engine-components/SceneSwitcher.js";
4
6
  import { initSkyboxAttributes } from "../engine-components/Skybox.js";
7
+ import { initAddressableSerializers } from "./engine_addressables.js";
5
8
  import { initBuiltinTypes } from "./codegen/register_types.js";
6
9
  import { ensureAudioContextIsResumed } from "./engine_audio.js";
7
10
  import { initNeedleLoader } from "./engine_loaders.js";
@@ -13,6 +16,7 @@ import { patchLayers } from "./js-extensions/Layers.js";
13
16
  import { initObject3DExtensions } from "./js-extensions/Object3D.js";
14
17
  import { initVectorExtensions } from "./js-extensions/Vector.js";
15
18
  import { initWebComponents } from "./webcomponents/init.js";
19
+ import { initSpatialConsole } from "./debug/debug_spatial_console.js";
16
20
 
17
21
  let initialized = false;
18
22
 
@@ -32,6 +36,9 @@ export function initEngine() {
32
36
  initWebComponents();
33
37
  initShims();
34
38
  initBuiltinSerializers();
39
+ initAddressableSerializers();
40
+ initAnimatorControllerSerializer();
41
+ initVolumeParameterSerializer();
35
42
  patchTonemapping();
36
43
  patchLayers();
37
44
  initCameraExtensions();
@@ -44,4 +51,5 @@ export function initEngine() {
44
51
  initAnimationAutoplay();
45
52
  initSkyboxAttributes();
46
53
  initSceneSwitcherAttributes();
54
+ initSpatialConsole();
47
55
  }
@@ -344,8 +344,11 @@ function updateIsActiveInHierarchyRecursiveRuntime(go: Object3D, activeInHierarc
344
344
  if (allowEventCall) {
345
345
  const components = go.userData?.components;
346
346
  if (components) {
347
- for (let ci = components.length - 1, cl = -1; ci > cl; ci--) {
348
- const comp = components[ci];
347
+ // We need to iterate on components in the original order right now to work-around UI related initialization bugs where RectTransform must be initialized before e.g. Graphic components. https://linear.app/needle/issue/NE-6986
348
+ // In the future we can reverse iterate this once the UI system has been replaced (that being said it's probably expected that component enable in the order in which they're added to an object)
349
+ const componentsCopy = [...components];
350
+ for (let ci = 0; ci < componentsCopy.length; ci++) {
351
+ const comp = componentsCopy[ci];
349
352
  if (activeInHierarchy) {
350
353
  if (comp?.enabled) {
351
354
  try { comp.__internalAwake(); }
@@ -4,6 +4,7 @@ import { isDevEnvironment, showBalloonMessage, showBalloonWarning } from "../eng
4
4
  import { Behaviour, Component, GameObject } from "../engine-components/Component.js";
5
5
  import { CallInfo, EventList } from "../engine-components/EventList.js";
6
6
  import { AssetReference } from "./engine_addressables.js";
7
+ import { AudioClip } from "./engine_audio.js";
7
8
  import { debugExtension } from "./engine_default_parameters.js";
8
9
  import { SerializationContext, TypeSerializer } from "./engine_serialization_core.js";
9
10
  import { RenderTexture } from "./engine_texture.js";
@@ -11,7 +12,7 @@ import { IComponent } from "./engine_types.js";
11
12
  import { resolveUrl } from "./engine_utils.js";
12
13
  import { RGBAColor } from "./js-extensions/index.js";
13
14
 
14
-
15
+ // #region Color
15
16
  class ColorSerializer extends TypeSerializer {
16
17
  constructor() {
17
18
  super([Color, RGBAColor], "ColorSerializer")
@@ -35,6 +36,8 @@ class ColorSerializer extends TypeSerializer {
35
36
  }
36
37
  }
37
38
 
39
+ // #region Euler
40
+
38
41
  class EulerSerializer extends TypeSerializer {
39
42
  constructor() {
40
43
  super([Euler], "EulerSerializer");
@@ -58,6 +61,8 @@ declare type ObjectData = {
58
61
  node?: number;
59
62
  guid?: string;
60
63
  }
64
+
65
+ // #region ObjectSerializer
61
66
  class ObjectSerializer extends TypeSerializer {
62
67
  constructor() {
63
68
  super(Object3D, "ObjectSerializer");
@@ -137,7 +142,7 @@ class ObjectSerializer extends TypeSerializer {
137
142
  }
138
143
  }
139
144
 
140
-
145
+ // #region ComponentSerializer
141
146
  class ComponentSerializer extends TypeSerializer {
142
147
 
143
148
  constructor() {
@@ -226,6 +231,7 @@ declare type EventListCall = {
226
231
 
227
232
  const $eventListDebugInfo = Symbol("eventListDebugInfo");
228
233
 
234
+ // #region EventListSerializer
229
235
  class EventListSerializer extends TypeSerializer {
230
236
  constructor() {
231
237
  super([EventList]);
@@ -371,6 +377,7 @@ class EventListSerializer extends TypeSerializer {
371
377
  */
372
378
  const cloneOriginalMap = new WeakMap<Texture, Texture>();
373
379
 
380
+ // #region RenderTextureSerializer
374
381
  export class RenderTextureSerializer extends TypeSerializer {
375
382
  constructor() {
376
383
  super([RenderTexture, WebGLRenderTarget]);
@@ -412,7 +419,7 @@ export class RenderTextureSerializer extends TypeSerializer {
412
419
  }
413
420
  }
414
421
 
415
-
422
+ // #region UriSerializer
416
423
  export class UriSerializer extends TypeSerializer {
417
424
  constructor() {
418
425
  super([URL]);
@@ -430,7 +437,27 @@ export class UriSerializer extends TypeSerializer {
430
437
  }
431
438
  }
432
439
 
440
+ // #region AudioClipSerializer
441
+ class AudioClipSerializer extends TypeSerializer {
442
+ constructor() {
443
+ super([AudioClip]);
444
+ }
445
+
446
+ onSerialize(_data: AudioClip, _context: SerializationContext) {
447
+ return null;
448
+ }
449
+
450
+ onDeserialize(data: string, context: SerializationContext) {
451
+ if (typeof data === "string" && data.length > 0) {
452
+ const url = resolveUrl(context.gltfId, data);
453
+ if (url) return new AudioClip(url);
454
+ }
455
+ return undefined;
456
+ }
457
+ }
458
+
433
459
 
460
+ // #region Init serializer
434
461
  // Module-level references used by EventListSerializer internally
435
462
  export let colorSerializer: ColorSerializer;
436
463
  export let objectSerializer: ObjectSerializer;
@@ -460,4 +487,5 @@ export function initBuiltinSerializers() {
460
487
  eventListSerializer = new EventListSerializer();
461
488
  new RenderTextureSerializer();
462
489
  new UriSerializer();
490
+ new AudioClipSerializer();
463
491
  }
@@ -40,11 +40,17 @@ export interface NeedleEngineAttributes {
40
40
  'hash': string;
41
41
  /** Set to automatically add OrbitControls to the loaded scene. */
42
42
  'camera-controls': string;
43
- /** Override the default draco decoder path location. */
43
+ /** Override the default Draco decoder/decompressor path. Can be a URL or a local path to a directory containing the Draco decoder files.
44
+ * @default "https://www.gstatic.com/draco/versioned/decoders/1.5.7/"
45
+ * @example <needle-engine dracoDecoderPath="./decoders/draco/"></needle-engine>
46
+ */
44
47
  'dracoDecoderPath': string;
45
- /** Override the default draco library type. */
48
+ /** Override the default Draco decoder type. */
46
49
  'dracoDecoderType': 'wasm' | 'js';
47
- /** Override the default KTX2 transcoder/decoder path. */
50
+ /** Override the default KTX2 transcoder/decoder path. Can be a URL or a local path to a directory containing the KTX2 transcoder files.
51
+ * @default "https://cdn.needle.tools/static/three/0.179.1/basis2/"
52
+ * @example <needle-engine ktx2DecoderPath="./decoders/ktx2/"></needle-engine>
53
+ */
48
54
  'ktx2DecoderPath': string;
49
55
  /** Prevent context from being disposed when element is removed from DOM. */
50
56
  'keep-alive': 'true' | 'false';
@@ -805,15 +805,16 @@ export class NeedleXRSession implements INeedleXRSession {
805
805
  get viewerPose(): XRViewerPose | undefined { return this._viewerPose; }
806
806
 
807
807
 
808
- /** @returns `true` if any image is currently being tracked */
809
- /** returns true if images are currently being tracked */
808
+ /** @returns `true` if any image is currently being tracked or emulated */
810
809
  get isTrackingImages() {
811
810
  if (this.frame && "getImageTrackingResults" in this.frame && typeof this.frame.getImageTrackingResults === "function") {
812
811
  try {
813
812
  const trackingResult = this.frame.getImageTrackingResults();
814
813
  for (const result of trackingResult) {
815
814
  const state = result.trackingState;
816
- if (state === "tracked") return true;
815
+ if (state === "tracked" || state === "emulated") {
816
+ return true;
817
+ }
817
818
  }
818
819
  }
819
820
  catch {
@@ -1018,6 +1019,8 @@ export class NeedleXRSession implements INeedleXRSession {
1018
1019
  private readonly _xr_update_scripts: INeedleXRSessionEventReceiver[] = [];
1019
1020
  /** scripts that are in the scene but inactive (e.g. disabled parent gameObject) */
1020
1021
  private readonly _inactive_scripts: INeedleXRSessionEventReceiver[] = [];
1022
+ /** tracks scripts that have received onEnterXR — prevents spurious onLeaveXR calls */
1023
+ private readonly _scripts_in_xr = new Set<INeedleXRSessionEventReceiver>();
1021
1024
  private readonly _controllerAdded: ControllerChangedEvt[];
1022
1025
  private readonly _controllerRemoved: ControllerChangedEvt[];
1023
1026
  private readonly _originalCameraWorldPosition?: Vector3 | null;
@@ -1128,7 +1131,18 @@ export class NeedleXRSession implements INeedleXRSession {
1128
1131
  // we set the session on the webxr manager at the end because we want to receive inputsource events first
1129
1132
  // e.g. in case there's a bug in the threejs codebase
1130
1133
  this.context.xr = this;
1131
- this.context.renderer.xr.setSession(this.session).then(this.onRendererSessionSet);
1134
+ this.context.renderer.xr.setSession(this.session).then(this.onRendererSessionSet)
1135
+ .catch(err => {
1136
+ // Workaround for WebXR emulators on localhost: the polyfilled XRSession fails the
1137
+ // native XRWebGLBinding constructor's instanceof check. Retry with layers disabled.
1138
+ // See https://github.com/meta-quest/immersive-web-emulator/issues/80
1139
+ if (isDevEnvironment() && err instanceof TypeError && typeof XRWebGLBinding !== "undefined") {
1140
+ console.error(`XRWebGLBinding failed (${err.message}).\nSee https://github.com/meta-quest/immersive-web-emulator/issues/80`);
1141
+ }
1142
+ else {
1143
+ console.error("renderer.xr.setSession failed:", err);
1144
+ }
1145
+ });
1132
1146
  // disable three.js renderer controller autoUpdate (added in ac67b31e3548386f8a93e23a4176554c92bbd0d9)
1133
1147
  if ("controllerAutoUpdate" in this.context.renderer.xr) {
1134
1148
  console.debug("Disabling three.js controllerAutoUpdate");
@@ -1296,8 +1310,10 @@ export class NeedleXRSession implements INeedleXRSession {
1296
1310
  // even if they might already be destroyed e.g. by the WebXR component (it destroys the default controller scripts)
1297
1311
  // they should still receive this callback to be properly cleaned up
1298
1312
  for (const listener of this._xr_scripts) {
1313
+ this._scripts_in_xr.delete(listener);
1299
1314
  listener?.onLeaveXR?.({ xr: this });
1300
1315
  }
1316
+ this._scripts_in_xr.clear();
1301
1317
 
1302
1318
  this.sync?.onExitXR(this);
1303
1319
 
@@ -1364,10 +1380,17 @@ export class NeedleXRSession implements INeedleXRSession {
1364
1380
  }
1365
1381
 
1366
1382
  // make sure the camera is parented to the active rig
1367
- if (this.rig && this._mainCamera?.gameObject) {
1368
- const currentParent = this._mainCamera?.gameObject?.parent;
1369
- if (currentParent !== this.rig.gameObject) {
1370
- this.rig.gameObject.add(this._mainCamera?.gameObject);
1383
+ // Note: applyCustomForward() inserts _cameraRenderParent between rig and camera,
1384
+ // so the hierarchy is: rig → _cameraRenderParent → camera. We check the effective parent.
1385
+ if (this.rig) {
1386
+ const cameraObject = this._mainCamera?.gameObject ?? this.context.mainCamera;
1387
+ if (cameraObject) {
1388
+ const effectiveParent = cameraObject.parent === this._cameraRenderParent
1389
+ ? this._cameraRenderParent.parent
1390
+ : cameraObject.parent;
1391
+ if (effectiveParent !== this.rig.gameObject) {
1392
+ this.rig.gameObject.add(cameraObject);
1393
+ }
1371
1394
  }
1372
1395
  }
1373
1396
 
@@ -1499,8 +1522,12 @@ export class NeedleXRSession implements INeedleXRSession {
1499
1522
  }
1500
1523
 
1501
1524
  //performance.mark('NeedleXRSession update scripts start');
1502
- // invoke update on all scripts
1503
- for (const script of this._xr_update_scripts) {
1525
+ // check all XR scripts for destroyed or inactive state
1526
+ // this must cover _xr_scripts (not just _xr_update_scripts) so that scripts
1527
+ // without onUpdateXR are also detected as inactive when removed from the scene
1528
+ // iterate backwards since markInactive/removeScript modifies the array
1529
+ for (let i = this._xr_scripts.length - 1; i >= 0; i--) {
1530
+ const script = this._xr_scripts[i];
1504
1531
  if (script.destroyed === true) {
1505
1532
  this._script_to_remove.push(script);
1506
1533
  continue;
@@ -1509,6 +1536,10 @@ export class NeedleXRSession implements INeedleXRSession {
1509
1536
  this.markInactive(script);
1510
1537
  continue;
1511
1538
  }
1539
+ }
1540
+ // invoke update on scripts that have onUpdateXR
1541
+ for (const script of this._xr_update_scripts) {
1542
+ if (script.destroyed || script.activeAndEnabled === false) continue;
1512
1543
  if (script.onUpdateXR) script.onUpdateXR(args);
1513
1544
  }
1514
1545
  //performance.mark('NeedleXRSession update scripts end');
@@ -1631,9 +1662,11 @@ export class NeedleXRSession implements INeedleXRSession {
1631
1662
  const script = this._inactive_scripts[i];
1632
1663
  if (script.activeAndEnabled) {
1633
1664
  this._inactive_scripts.splice(i, 1);
1634
- this.addScript(script);
1635
- this.invokeCallback_EnterXR(script);
1636
- for (const ctrl of this.controllers) this.invokeCallback_ControllerAdded(script, ctrl);
1665
+ // addScript returns false if already re-added (e.g. via new_scripts_xr processing)
1666
+ if (this.addScript(script)) {
1667
+ this.invokeCallback_EnterXR(script);
1668
+ for (const ctrl of this.controllers) this.invokeCallback_ControllerAdded(script, ctrl);
1669
+ }
1637
1670
  }
1638
1671
  }
1639
1672
  }
@@ -1654,6 +1687,7 @@ export class NeedleXRSession implements INeedleXRSession {
1654
1687
  }
1655
1688
 
1656
1689
  private invokeCallback_EnterXR(script: INeedleXRSessionEventReceiver) {
1690
+ this._scripts_in_xr.add(script);
1657
1691
  if (script.onEnterXR) {
1658
1692
  script.onEnterXR({ xr: this });
1659
1693
  }
@@ -1669,6 +1703,7 @@ export class NeedleXRSession implements INeedleXRSession {
1669
1703
  }
1670
1704
  }
1671
1705
  private invokeCallback_LeaveXR(script: INeedleXRSessionEventReceiver) {
1706
+ if (!this._scripts_in_xr.delete(script)) return;
1672
1707
  if (script.onLeaveXR && !script.destroyed) {
1673
1708
  script.onLeaveXR({ xr: this });
1674
1709
  }
@@ -447,25 +447,13 @@ export class Animation extends Behaviour implements IAnimationComponent {
447
447
 
448
448
  private internalOnPlay(action: AnimationAction, options: PlayOptions): Promise<AnimationAction> {
449
449
  var existing = this.actions.find(a => a === action);
450
- if (existing === action && existing.isRunning() && existing.time < existing.getClip().duration) {
451
- const handle = this.tryFindHandle(action);
452
- if (existing.paused) {
453
- existing.paused = false;
454
- }
455
- if (handle) return handle.waitForFinish();
456
- }
457
450
 
458
- // Assign defaults
459
- if (options.loop === undefined) options.loop = this.loop;
460
- if (options.clampWhenFinished === undefined) options.clampWhenFinished = this.clampWhenFinished;
461
- if (options.minMaxOffsetNormalized === undefined && this.randomStartTime) options.minMaxOffsetNormalized = this.minMaxOffsetNormalized;
462
- if (options.minMaxSpeed === undefined) options.minMaxSpeed = this.minMaxSpeed;
463
-
464
- // Reset currently running animations
451
+ // Stop other animations before the early-return check so exclusive always applies,
452
+ // even when the target animation is already running.
465
453
  const stopOther = options?.exclusive ?? true;
466
454
  if (stopOther) {
467
455
  for (const act of this.actions) {
468
- if (act != existing) {
456
+ if (act !== action) {
469
457
  if (options.fadeDuration) {
470
458
  act.fadeOut(options.fadeDuration);
471
459
  }
@@ -475,6 +463,20 @@ export class Animation extends Behaviour implements IAnimationComponent {
475
463
  }
476
464
  }
477
465
  }
466
+
467
+ if (existing === action && existing.isRunning() && existing.time < existing.getClip().duration) {
468
+ const handle = this.tryFindHandle(action);
469
+ if (existing.paused) {
470
+ existing.paused = false;
471
+ }
472
+ if (handle) return handle.waitForFinish();
473
+ }
474
+
475
+ // Assign defaults
476
+ if (options.loop === undefined) options.loop = this.loop;
477
+ if (options.clampWhenFinished === undefined) options.clampWhenFinished = this.clampWhenFinished;
478
+ if (options.minMaxOffsetNormalized === undefined && this.randomStartTime) options.minMaxOffsetNormalized = this.minMaxOffsetNormalized;
479
+ if (options.minMaxSpeed === undefined) options.minMaxSpeed = this.minMaxSpeed;
478
480
  if (options?.fadeDuration) {
479
481
  action.fadeIn(options.fadeDuration);
480
482
  }
@@ -515,10 +517,11 @@ export class Animation extends Behaviour implements IAnimationComponent {
515
517
 
516
518
  action.paused = false;
517
519
  action.play();
520
+ if (debug) console.log("[Animation] Now Playing", action.getClip().name, action)
521
+
518
522
 
519
523
  window.requestAnimationFrame(() => AnimationUtils.testIfRootCanAnimate(action));
520
524
 
521
- if (debug) console.log("PLAY", action.getClip().name, action)
522
525
  const handle = new AnimationHandle(action, this.mixer!, options, _ => {
523
526
  this._handles.splice(this._handles.indexOf(handle), 1);
524
527
  });
@@ -1316,4 +1316,7 @@ class AnimatorControllerSerializator extends TypeSerializer {
1316
1316
  return undefined;
1317
1317
  }
1318
1318
  }
1319
- new AnimatorControllerSerializator(AnimatorController);
1319
+ /** @internal */
1320
+ export function initAnimatorControllerSerializer() {
1321
+ new AnimatorControllerSerializator(AnimatorController);
1322
+ }