@needle-tools/engine 5.1.0-next.1780579670.dramatic-pierogi.b8fa941 → 5.1.0

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 (122) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/components.needle.json +1 -1
  3. package/dist/gltf-progressive-BGQ-cjly.umd.cjs +10 -0
  4. package/dist/gltf-progressive-BvQHrERj.min.js +10 -0
  5. package/dist/{gltf-progressive-CH7aqHHr.js → gltf-progressive-CtYnVfLc.js} +420 -351
  6. package/dist/{materialx-B85WjP7E.min.js → materialx-B5gKeM65.min.js} +1 -1
  7. package/dist/{materialx-B47Bz-xs.umd.cjs → materialx-CFRcCYWy.umd.cjs} +1 -1
  8. package/dist/{materialx-jkHmVPez.js → materialx-DqGmteNO.js} +1 -1
  9. package/dist/{needle-engine.bundle-WQeWxmZD.js → needle-engine.bundle-5oIL49YP.js} +6826 -6685
  10. package/dist/{needle-engine.bundle-BSPk6RkD.min.js → needle-engine.bundle-DAAfj8Cs.min.js} +161 -149
  11. package/dist/{needle-engine.bundle-DCPHDPAd.umd.cjs → needle-engine.bundle-DvOrfXws.umd.cjs} +159 -147
  12. package/dist/needle-engine.d.ts +106 -44
  13. package/dist/needle-engine.js +610 -608
  14. package/dist/needle-engine.min.js +1 -1
  15. package/dist/needle-engine.umd.cjs +1 -1
  16. package/dist/three.js +3 -0
  17. package/dist/three.min.js +22 -22
  18. package/dist/three.umd.cjs +24 -24
  19. package/dist/{vendor-BQ2Vuntm.min.js → vendor-2--bD1c9.min.js} +19 -19
  20. package/dist/{vendor-BslSKZPo.js → vendor-BNtcIZkv.js} +1141 -1138
  21. package/dist/{vendor-1UvpPPSB.umd.cjs → vendor-vbe9sv1t.umd.cjs} +20 -20
  22. package/lib/engine/api.d.ts +1 -1
  23. package/lib/engine/api.js +1 -1
  24. package/lib/engine/api.js.map +1 -1
  25. package/lib/engine/engine_context.js +16 -3
  26. package/lib/engine/engine_context.js.map +1 -1
  27. package/lib/engine/engine_init.js +2 -2
  28. package/lib/engine/engine_init.js.map +1 -1
  29. package/lib/engine/engine_license.d.ts +7 -7
  30. package/lib/engine/engine_license.js +72 -72
  31. package/lib/engine/engine_license.js.map +1 -1
  32. package/lib/engine/engine_networking_blob.js +3 -3
  33. package/lib/engine/engine_networking_blob.js.map +1 -1
  34. package/lib/engine/engine_utils_qrcode.js +2 -2
  35. package/lib/engine/engine_utils_qrcode.js.map +1 -1
  36. package/lib/engine/postprocessing/postprocessing.d.ts +2 -1
  37. package/lib/engine/postprocessing/postprocessing.js +3 -2
  38. package/lib/engine/postprocessing/postprocessing.js.map +1 -1
  39. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js +2 -2
  40. package/lib/engine/webcomponents/needle menu/needle-menu.d.ts +2 -2
  41. package/lib/engine/webcomponents/needle menu/needle-menu.js +9 -9
  42. package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
  43. package/lib/engine/webcomponents/needle-engine.js +2 -2
  44. package/lib/engine/webcomponents/needle-engine.js.map +1 -1
  45. package/lib/engine/webcomponents/needle-engine.loading.js +2 -2
  46. package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
  47. package/lib/engine/xr/NeedleXRSession.d.ts +24 -0
  48. package/lib/engine/xr/NeedleXRSession.js +52 -7
  49. package/lib/engine/xr/NeedleXRSession.js.map +1 -1
  50. package/lib/engine/xr/TempXRContext.js +2 -2
  51. package/lib/engine/xr/TempXRContext.js.map +1 -1
  52. package/lib/engine/xr/init.js +2 -3
  53. package/lib/engine/xr/init.js.map +1 -1
  54. package/lib/engine-components/Component.js +14 -1
  55. package/lib/engine-components/Component.js.map +1 -1
  56. package/lib/engine-components/ContactShadows.d.ts +7 -2
  57. package/lib/engine-components/ContactShadows.js +65 -45
  58. package/lib/engine-components/ContactShadows.js.map +1 -1
  59. package/lib/engine-components/GroundProjection.d.ts +6 -0
  60. package/lib/engine-components/GroundProjection.js +69 -7
  61. package/lib/engine-components/GroundProjection.js.map +1 -1
  62. package/lib/engine-components/NeedleMenu.d.ts +1 -1
  63. package/lib/engine-components/NeedleMenu.js +5 -2
  64. package/lib/engine-components/NeedleMenu.js.map +1 -1
  65. package/lib/engine-components/SceneSwitcher.d.ts +2 -0
  66. package/lib/engine-components/SceneSwitcher.js +26 -14
  67. package/lib/engine-components/SceneSwitcher.js.map +1 -1
  68. package/lib/engine-components/SeeThrough.d.ts +39 -0
  69. package/lib/engine-components/SeeThrough.js +69 -1
  70. package/lib/engine-components/SeeThrough.js.map +1 -1
  71. package/lib/engine-components/Skybox.js +9 -0
  72. package/lib/engine-components/Skybox.js.map +1 -1
  73. package/lib/engine-components/api.d.ts +1 -0
  74. package/lib/engine-components/api.js +1 -0
  75. package/lib/engine-components/api.js.map +1 -1
  76. package/lib/engine-components/export/usdz/USDZExporter.js +4 -4
  77. package/lib/engine-components/export/usdz/USDZExporter.js.map +1 -1
  78. package/lib/engine-components/export/usdz/extensions/behavior/BehaviourComponents.js +9 -5
  79. package/lib/engine-components/export/usdz/extensions/behavior/BehaviourComponents.js.map +1 -1
  80. package/lib/engine-components/particlesystem/ParticleSystem.js +4 -0
  81. package/lib/engine-components/particlesystem/ParticleSystem.js.map +1 -1
  82. package/lib/engine-components/postprocessing/Volume.d.ts +1 -9
  83. package/lib/engine-components/postprocessing/Volume.js +2 -14
  84. package/lib/engine-components/postprocessing/Volume.js.map +1 -1
  85. package/lib/engine-components/webxr/WebXRImageTracking.js +3 -0
  86. package/lib/engine-components/webxr/WebXRImageTracking.js.map +1 -1
  87. package/package.json +4 -4
  88. package/plugins/common/license.js +4 -4
  89. package/plugins/types/userconfig.d.ts +10 -0
  90. package/plugins/vite/build-pipeline.d.ts +19 -0
  91. package/plugins/vite/build-pipeline.js +67 -5
  92. package/plugins/vite/license.js +4 -4
  93. package/src/engine/api.ts +1 -1
  94. package/src/engine/engine_context.ts +15 -2
  95. package/src/engine/engine_gameobject.ts +1 -1
  96. package/src/engine/engine_init.ts +2 -2
  97. package/src/engine/engine_license.ts +69 -69
  98. package/src/engine/engine_networking_blob.ts +3 -3
  99. package/src/engine/engine_utils_qrcode.ts +2 -2
  100. package/src/engine/postprocessing/postprocessing.ts +3 -2
  101. package/src/engine/webcomponents/needle menu/needle-menu-spatial.ts +2 -2
  102. package/src/engine/webcomponents/needle menu/needle-menu.ts +10 -10
  103. package/src/engine/webcomponents/needle-engine.loading.ts +6 -6
  104. package/src/engine/webcomponents/needle-engine.ts +2 -2
  105. package/src/engine/xr/NeedleXRSession.ts +53 -7
  106. package/src/engine/xr/TempXRContext.ts +2 -2
  107. package/src/engine/xr/init.ts +2 -3
  108. package/src/engine-components/Component.ts +13 -1
  109. package/src/engine-components/ContactShadows.ts +75 -49
  110. package/src/engine-components/GroundProjection.ts +71 -7
  111. package/src/engine-components/NeedleMenu.ts +6 -3
  112. package/src/engine-components/SceneSwitcher.ts +23 -13
  113. package/src/engine-components/SeeThrough.ts +75 -1
  114. package/src/engine-components/Skybox.ts +8 -0
  115. package/src/engine-components/api.ts +1 -0
  116. package/src/engine-components/export/usdz/USDZExporter.ts +4 -4
  117. package/src/engine-components/export/usdz/extensions/behavior/BehaviourComponents.ts +12 -5
  118. package/src/engine-components/particlesystem/ParticleSystem.ts +4 -0
  119. package/src/engine-components/postprocessing/Volume.ts +2 -13
  120. package/src/engine-components/webxr/WebXRImageTracking.ts +1 -0
  121. package/dist/gltf-progressive-9tlS5iCx.umd.cjs +0 -10
  122. package/dist/gltf-progressive-CVg80COO.min.js +0 -10
@@ -12,9 +12,8 @@ export function initXR() {
12
12
 
13
13
  if (DeviceUtilities.isiOS()) {
14
14
 
15
- // Prefetch
16
- const url = new URL("https://appclip.apple.com/id?p=tools.needle.launch-app.Clip");
17
- url.searchParams.set("url", location.href);
15
+ // Prefetch (uses the custom branded AppClip experience if NeedleXRSession.appClipUrl is set, otherwise the default Needle AppClip)
16
+ const url = NeedleXRSession.getAppClipLaunchUrl();
18
17
  const urlStr = url.toString();
19
18
  fetch(urlStr, { method: "HEAD", mode: "no-cors" }).catch(() => {
20
19
  // appclip prefetch - to get metadata faster on iOS devices, this seems to fix the double tap issue when opening the appclip for AR sessions.
@@ -1107,7 +1107,8 @@ export abstract class Component implements IComponent, EventTarget,
1107
1107
  this.__didCompleteStart = false;
1108
1108
  this.__inEnableOrDisableCallback = false;
1109
1109
  this.__didEnable = false;
1110
- this.__isEnabled = undefined;
1110
+ // Preserve copied/constructor-assigned enabled state while resetting lifecycle flags.
1111
+ // Cloned disabled components must not fall back to the default enabled=true state.
1111
1112
  this.__destroyed = false;
1112
1113
  this._internalInit(init);
1113
1114
  return this;
@@ -1165,6 +1166,17 @@ export abstract class Component implements IComponent, EventTarget,
1165
1166
  }
1166
1167
  this.__didEnable = true;
1167
1168
  this.__isEnabled = true;
1169
+ // Enforce "enabled + not-yet-started ⇒ queued for start". A component can be
1170
+ // re-enabled before it ever started — e.g. removed from the scene (which
1171
+ // strips it from new_script_start via processRemoveFromScene) and then
1172
+ // re-activated through a path other than GameObject.add (raw scene re-add +
1173
+ // setActive, a parent activeInHierarchy flip, …). Those paths re-run onEnable
1174
+ // but never re-enqueue start, leaving the component enabled+active but with
1175
+ // start() never called. Re-queue it here so processStart picks it up.
1176
+ if (!this.__didStart) {
1177
+ const startQueue = this.context?.new_script_start;
1178
+ if (startQueue && !startQueue.includes(this)) startQueue.push(this);
1179
+ }
1168
1180
  this.__inEnableOrDisableCallback = true;
1169
1181
  this.onEnable();
1170
1182
  this.__inEnableOrDisableCallback = false;
@@ -83,7 +83,7 @@ type FitParameters = {
83
83
  */
84
84
  export class ContactShadows extends Behaviour {
85
85
 
86
- private static readonly _instances: Map<Context, ContactShadows> = new Map();
86
+ private static readonly _instances: WeakMap<Context, ContactShadows> = new WeakMap();
87
87
  /**
88
88
  * Create contact shadows for the scene. Automatically fits the shadows to the scene.
89
89
  * The instance of contact shadows will be created only once.
@@ -179,8 +179,9 @@ export class ContactShadows extends Behaviour {
179
179
  * in which case ContactShadows scale would affect the projection
180
180
  **/
181
181
  private readonly shadowsRoot: IGameObject = new Object3D() as IGameObject;
182
- private shadowCamera?: OrthographicCamera;
183
182
  private readonly shadowGroup: Group = new Group();
183
+
184
+ private shadowCamera?: OrthographicCamera;
184
185
 
185
186
  private renderTarget?: WebGLRenderTarget;
186
187
  private renderTargetBlur?: WebGLRenderTarget;
@@ -240,19 +241,86 @@ export class ContactShadows extends Behaviour {
240
241
  if (debug) console.log("Fitted shadows to scene", this.shadowsRoot.scale.clone());
241
242
  }
242
243
 
244
+ /**
245
+ * Call to update the shadows immediately. Only needed if `manualUpdate` is enabled, usually the shadows will update automatically when the scene changes or when the component is enabled.
246
+ */
247
+ renderShadowsNow() {
248
+ this._renderShadows();
249
+ }
243
250
 
244
251
  /** @internal */
245
252
  awake() {
246
253
  ContactShadows._instances.set(this.context, this);
247
- this.shadowsRoot.hideFlags = HideFlags.DontExport;
254
+ }
248
255
 
256
+ /** @internal */
257
+ onEnable(): void {
258
+ this.shadowsRoot.hideFlags = HideFlags.DontExport;
249
259
  // ignore self for autofitting
250
260
  setAutoFitEnabled(this.shadowsRoot, false);
261
+
262
+ this._needsUpdate = true;
263
+ this.autoCleanup(this.context.events.on("scene-content-changed", () => {
264
+ if (!this.autoFit) return;
265
+ this._needsFit = true;
266
+ this._needsUpdate = true;
267
+ }));
268
+
269
+ if (this.autoFit) this.fitShadows();
270
+ else this.applyMinSize();
251
271
  }
252
272
 
273
+ /** @internal */
274
+ onDestroy(): void {
275
+ if (debug) console.warn("Destroying ContactShadows on " + this.gameObject.name);
276
+
277
+ const instance = ContactShadows._instances.get(this.context);
278
+ if (instance === this) {
279
+ ContactShadows._instances.delete(this.context);
280
+ }
281
+
282
+ // dispose the render targets
283
+ this.renderTarget?.dispose();
284
+ this.renderTargetBlur?.dispose();
285
+
286
+ // dispose the materials
287
+ this.depthMaterial?.dispose();
288
+ this.horizontalBlurMaterial?.dispose();
289
+ this.verticalBlurMaterial?.dispose();
290
+
291
+ // dispose the geometries
292
+ this.blurPlane?.geometry.dispose();
293
+ this.plane?.geometry.dispose();
294
+ this.occluderMesh?.geometry.dispose();
295
+ }
253
296
 
254
297
  /** @internal */
255
- start(): void {
298
+ onBeforeRender(_frame: XRFrame | null): void {
299
+
300
+ if (this._needsFit && this.autoFit) {
301
+ this._needsFit = false;
302
+ this.fitShadows();
303
+ }
304
+
305
+ if (this.manualUpdate) {
306
+ if (!this._needsUpdate) return;
307
+ }
308
+ this._needsUpdate = false;
309
+ this._renderShadows();
310
+ }
311
+
312
+ private _ensureSetup() {
313
+
314
+ // Make sure the shadows root is parented to the game object. This is needed for the shadows to be in the correct position and scale relative to the game object. We need to do this before checking if the materials are set up because some properties (e.g. darkness) can be changed in the editor before the component is enabled, so we want to avoid re-creating everything in that case.
315
+ if (this.shadowsRoot.parent !== this.gameObject) {
316
+ this.gameObject.add(this.shadowsRoot);
317
+ }
318
+
319
+ // Check if everything is already set up. We need to do this because some properties (e.g. darkness) can be changed in the editor before the component is enabled, so we want to avoid re-creating everything in that case.
320
+ if (this.depthMaterial && this.horizontalBlurMaterial && this.verticalBlurMaterial && this.planeMaterial) {
321
+ return;
322
+ }
323
+
256
324
  if (debug) console.log("Create ContactShadows on " + this.gameObject.name, this)
257
325
 
258
326
  this.gameObject.add(this.shadowsRoot);
@@ -363,54 +431,11 @@ export class ContactShadows extends Behaviour {
363
431
  this.verticalBlurMaterial.depthTest = false;
364
432
 
365
433
  this.shadowGroup.visible = false;
366
-
367
- if (this.autoFit) this.fitShadows();
368
- else this.applyMinSize();
369
434
  }
370
435
 
371
- onEnable(): void {
372
- this._needsUpdate = true;
373
- this.autoCleanup(this.context.events.on("scene-content-changed", () => {
374
- if (!this.autoFit) return;
375
- this._needsFit = true;
376
- this._needsUpdate = true;
377
- }));
378
- }
436
+ private _renderShadows() {
379
437
 
380
- /** @internal */
381
- onDestroy(): void {
382
- const instance = ContactShadows._instances.get(this.context);
383
- if (instance === this) {
384
- ContactShadows._instances.delete(this.context);
385
- }
386
-
387
- // dispose the render targets
388
- this.renderTarget?.dispose();
389
- this.renderTargetBlur?.dispose();
390
-
391
- // dispose the materials
392
- this.depthMaterial?.dispose();
393
- this.horizontalBlurMaterial?.dispose();
394
- this.verticalBlurMaterial?.dispose();
395
-
396
- // dispose the geometries
397
- this.blurPlane?.geometry.dispose();
398
- this.plane?.geometry.dispose();
399
- this.occluderMesh?.geometry.dispose();
400
- }
401
-
402
- /** @internal */
403
- onBeforeRender(_frame: XRFrame | null): void {
404
-
405
- if (this._needsFit && this.autoFit) {
406
- this._needsFit = false;
407
- this.fitShadows();
408
- }
409
-
410
- if (this.manualUpdate) {
411
- if (!this._needsUpdate) return;
412
- }
413
- this._needsUpdate = false;
438
+ this._ensureSetup();
414
439
 
415
440
  if (!this.renderTarget || !this.renderTargetBlur ||
416
441
  !this.depthMaterial || !this.shadowCamera ||
@@ -536,6 +561,7 @@ export class ContactShadows extends Behaviour {
536
561
  scene.background = initialBackground;
537
562
  renderer.xr.enabled = prevXRState;
538
563
  this.context.scene.matrixWorldAutoUpdate = prevSceneMatrixAutoUpdate;
564
+
539
565
  }
540
566
 
541
567
  // renderTarget --> blurPlane (horizontalBlur) --> renderTargetBlur --> blurPlane (verticalBlur) --> renderTarget
@@ -1,4 +1,4 @@
1
- import { CubeUVReflectionMapping, ShaderMaterial, Texture } from "three";
1
+ import { CubeUVReflectionMapping, Euler, Matrix3, Matrix4, ShaderMaterial, Texture } from "three";
2
2
  import { GroundedSkybox as GroundProjection } from 'three/examples/jsm/objects/GroundedSkybox.js';
3
3
 
4
4
  import { Gizmos } from "../engine/engine_gizmos.js";
@@ -21,8 +21,13 @@ type GroundProjectionShaderUniforms = {
21
21
  needleGroundProjectionBlending: { value: number };
22
22
  needleGroundProjectionAlphaFactor: { value: number };
23
23
  needleGroundProjectionBackgroundIntensity: { value: number };
24
+ needleGroundProjectionBackgroundRotation: { value: Matrix3 };
24
25
  };
25
26
 
27
+ // Reused per-frame to build the background-rotation matrix without allocating in onBeforeRender.
28
+ const _backgroundRotationEuler = new Euler();
29
+ const _backgroundRotationMatrix = new Matrix4();
30
+
26
31
  const needleCubeUvMapVarying = /* glsl */`
27
32
  #ifdef NEEDLE_USE_CUBE_UV_MAP
28
33
  varying vec3 vNeedleGroundProjectionWorldDirection;
@@ -79,7 +84,10 @@ const needleCubeUvMapFragment = /* glsl */`
79
84
 
80
85
  #endif
81
86
 
82
- sampledDiffuseColor.rgb *= mix(1.0, needleGroundProjectionBackgroundIntensity, needleGroundProjectionBlurFactorValue);
87
+ // Apply backgroundIntensity uniformly across the whole projection (center included), matching how the
88
+ // skybox applies it (backgroundCube.glsl: texColor.rgb *= backgroundIntensity). Only the blurriness
89
+ // ramps toward the horizon - the intensity must not.
90
+ sampledDiffuseColor.rgb *= needleGroundProjectionBackgroundIntensity;
83
91
  diffuseColor *= sampledDiffuseColor;
84
92
 
85
93
  #endif
@@ -122,7 +130,8 @@ function getGroundProjectionShaderUniforms(material: GroundProjectionMaterial):
122
130
  needleGroundProjectionBlurriness: { value: 0 },
123
131
  needleGroundProjectionBlending: { value: 0 },
124
132
  needleGroundProjectionAlphaFactor: { value: 1 },
125
- needleGroundProjectionBackgroundIntensity: { value: 1 }
133
+ needleGroundProjectionBackgroundIntensity: { value: 1 },
134
+ needleGroundProjectionBackgroundRotation: { value: new Matrix3() }
126
135
  };
127
136
  }
128
137
 
@@ -133,15 +142,25 @@ function configureGroundProjectionMaterial(material: GroundProjectionMaterial, t
133
142
  shader.uniforms.needleGroundProjectionBlending = projectionUniforms.needleGroundProjectionBlending;
134
143
  shader.uniforms.needleGroundProjectionAlphaFactor = projectionUniforms.needleGroundProjectionAlphaFactor;
135
144
  shader.uniforms.needleGroundProjectionBackgroundIntensity = projectionUniforms.needleGroundProjectionBackgroundIntensity;
145
+ shader.uniforms.needleGroundProjectionBackgroundRotation = projectionUniforms.needleGroundProjectionBackgroundRotation;
136
146
 
137
147
  shader.vertexShader = shader.vertexShader
138
- .replace("#include <uv_pars_vertex>", `#include <uv_pars_vertex>\n${needleCubeUvMapVarying}`)
148
+ .replace("#include <uv_pars_vertex>", `#include <uv_pars_vertex>\n${needleCubeUvMapVarying}
149
+ #ifdef NEEDLE_USE_CUBE_UV_MAP
150
+ uniform mat3 needleGroundProjectionBackgroundRotation;
151
+ #endif`)
139
152
  .replace(
140
153
  "#include <worldpos_vertex>",
141
154
  `#include <worldpos_vertex>
142
155
  #ifdef NEEDLE_USE_CUBE_UV_MAP
143
- // GroundedSkybox mirrors geometry on Z, so undo that before deriving the sampling direction.
144
- vNeedleGroundProjectionWorldDirection = transformDirection( vec3( position.x, position.y, -position.z ), modelMatrix );
156
+ // Sample the environment in the fragment's actual world direction. GroundedSkybox mirrors the geometry
157
+ // on Z (geometry.scale(1,1,-1)) and that mirrored vertex IS the on-screen direction; the skybox and
158
+ // environment lighting are likewise sampled in true world directions. So we must NOT undo the mirror
159
+ // here - doing so reflects the sampling azimuth and flips the panorama left-right vs. the skybox.
160
+ // backgroundRotation is applied to the sampling direction (exactly like the skybox does in
161
+ // backgroundCube.glsl), NOT by rotating the mesh - that keeps the floor flat and matches the skybox at
162
+ // the horizon for any background rotation.
163
+ vNeedleGroundProjectionWorldDirection = needleGroundProjectionBackgroundRotation * transformDirection( position, modelMatrix );
145
164
  #endif`
146
165
  );
147
166
 
@@ -271,6 +290,25 @@ export class GroundProjectedEnv extends Behaviour {
271
290
  private _projection?: GroundProjection;
272
291
  private _watcher?: Watch;
273
292
 
293
+ /** The scene's real backgroundBlurriness, stored while we temporarily override it for the render. */
294
+ private _savedBackgroundBlur: number | null = null;
295
+
296
+ /** pre_render_callback: force the skybox blur just before the render (after all component onBeforeRender). */
297
+ private readonly _forceBackgroundBlur = (_frame?: XRFrame | null) => {
298
+ if (this._savedBackgroundBlur !== null) return; // already overriding (safety)
299
+ if (!(this.scene.background instanceof Texture)) return; // no skybox to blur
300
+ const current = this.scene.backgroundBlurriness ?? 0;
301
+ this._savedBackgroundBlur = current;
302
+ this.scene.backgroundBlurriness = 1;
303
+ };
304
+
305
+ /** post_render_callback: restore the scene's real blur right after the render. */
306
+ private readonly _restoreBackgroundBlur = () => {
307
+ if (this._savedBackgroundBlur === null) return;
308
+ this.scene.backgroundBlurriness = this._savedBackgroundBlur;
309
+ this._savedBackgroundBlur = null;
310
+ };
311
+
274
312
 
275
313
  /** @internal */
276
314
  awake() {
@@ -291,11 +329,23 @@ export class GroundProjectedEnv extends Behaviour {
291
329
  this._needsTextureUpdate = true;
292
330
  });
293
331
  }
332
+ // Force the skybox blur via the render-bracketing callbacks (these run after every component's
333
+ // onBeforeRender, so the override can't be clobbered by e.g. Camera.applyClearFlags).
334
+ if (this.context.pre_render_callbacks.indexOf(this._forceBackgroundBlur) < 0)
335
+ this.context.pre_render_callbacks.push(this._forceBackgroundBlur);
336
+ if (this.context.post_render_callbacks.indexOf(this._restoreBackgroundBlur) < 0)
337
+ this.context.post_render_callbacks.push(this._restoreBackgroundBlur);
294
338
  }
295
339
  /** @internal */
296
340
  onDisable() {
297
341
  this._watcher?.revoke();
298
342
  this._projection?.removeFromParent();
343
+ const pre = this.context.pre_render_callbacks.indexOf(this._forceBackgroundBlur);
344
+ if (pre >= 0) this.context.pre_render_callbacks.splice(pre, 1);
345
+ const post = this.context.post_render_callbacks.indexOf(this._restoreBackgroundBlur);
346
+ if (post >= 0) this.context.post_render_callbacks.splice(post, 1);
347
+ // If we were disabled mid-frame (after force, before restore), put the real value back.
348
+ this._restoreBackgroundBlur();
299
349
  }
300
350
  /** @internal */
301
351
  onEnterXR(): void {
@@ -312,7 +362,21 @@ export class GroundProjectedEnv extends Behaviour {
312
362
  /** @internal */
313
363
  onBeforeRender(): void {
314
364
  if (this._projection && this.scene.backgroundRotation) {
315
- this._projection.rotation.copy(this.scene.backgroundRotation);
365
+ // Mirror three's WebGLBackground: build the same mat3 it applies to the skybox sampling
366
+ // direction and feed it to our shader. This rotates the sampled environment exactly like the
367
+ // skybox (backgroundCube.glsl) instead of rotating the mesh - keeping the floor flat and the
368
+ // horizon aligned with the skybox for any background rotation.
369
+ _backgroundRotationEuler.copy(this.scene.backgroundRotation);
370
+ // accommodate left-handed frame (same as WebGLBackground.js)
371
+ _backgroundRotationEuler.x *= -1;
372
+ _backgroundRotationEuler.y *= -1;
373
+ _backgroundRotationEuler.z *= -1;
374
+ // Note: the extra cube-texture flip in WebGLBackground only applies to raw CubeTextures, not the
375
+ // PMREM render-target env this shader path samples - so it is intentionally omitted here.
376
+ const uniforms = getGroundProjectionShaderUniforms(this._projection.material);
377
+ uniforms.needleGroundProjectionBackgroundRotation.value.setFromMatrix4(
378
+ _backgroundRotationMatrix.makeRotationFromEuler(_backgroundRotationEuler)
379
+ );
316
380
  }
317
381
 
318
382
  if (this._projection && this.context.scene.background instanceof Texture) {
@@ -49,8 +49,8 @@ export class NeedleMenu extends Behaviour {
49
49
  @serializable()
50
50
  position: "top" | "bottom" = "bottom";
51
51
 
52
- /**
53
- * Controls the visibility of the Needle logo in the menu (requires PRO license)
52
+ /**
53
+ * Controls the visibility of the Needle logo in the menu (requires a commercial license)
54
54
  */
55
55
  @serializable()
56
56
  showNeedleLogo: boolean = false;
@@ -102,7 +102,10 @@ export class NeedleMenu extends Behaviour {
102
102
  this.context.menu.showFullscreenOption(true);
103
103
  if (this.createMuteButton === true)
104
104
  this.context.menu.showAudioPlaybackOption(true);
105
- if (this.showSpatialMenu === true)
105
+ // Apply the value in BOTH directions: the spatial menu is enabled by
106
+ // default, so unchecking the option must explicitly disable it. Only skip
107
+ // when the field is unset (undefined) to keep the engine default.
108
+ if (this.showSpatialMenu !== undefined)
106
109
  this.context.menu.showSpatialMenu(this.showSpatialMenu);
107
110
  if (this.createQRCodeButton === true) {
108
111
  if (!DeviceUtilities.isMobileDevice()) {
@@ -614,7 +614,8 @@ export class SceneSwitcher extends Behaviour {
614
614
  this._currentScene = undefined;
615
615
  if (current) {
616
616
  if (debug) console.log("[SceneSwitcher] UNLOAD", current.url, "HasURL?: " + current.hasUrl)
617
- const sceneListener = this.tryGetSceneEventListener(current.asset as any as Object3D);
617
+ const activeSceneInstance = this._currentSceneAsset;
618
+ const sceneListener = this.tryGetSceneEventListener(activeSceneInstance as any as Object3D);
618
619
  if (sceneListener?.sceneClosing) {
619
620
  const res = sceneListener.sceneClosing();
620
621
  if (res instanceof Promise) await res;
@@ -624,20 +625,24 @@ export class SceneSwitcher extends Behaviour {
624
625
  // // then we unload it
625
626
  if (current.hasUrl) {
626
627
  current.unload();
627
- if (this._currentSceneAsset) destroy(this._currentSceneAsset, true, false);
628
+ if (activeSceneInstance) destroy(activeSceneInstance, true, false);
628
629
  }
629
630
  else {
630
- GameObject.remove(this._currentSceneAsset);
631
+ GameObject.remove(activeSceneInstance);
631
632
  }
633
+ this._currentSceneAsset = undefined;
632
634
  }
633
635
  }
634
636
 
635
637
  private _currentlyLoadingScene?: AssetReference;
638
+ private _loadingRequestId: number = 0;
639
+ private _activeLoadingRequestId: number = 0;
636
640
  /** @internal */
637
641
  private async __internalSwitchScene(scene: AssetReference): Promise<boolean> {
638
642
 
639
643
  await this.__unloadCurrentScene();
640
644
  const index = this._currentIndex = this.scenes?.indexOf(scene) ?? -1;
645
+ const loadingRequestId = ++this._loadingRequestId;
641
646
 
642
647
  try {
643
648
  if (debug) console.debug(`${Date.now()} [SceneSwitcher] Loading scene start: ${scene.url} (index: ${index})`);
@@ -647,9 +652,10 @@ export class SceneSwitcher extends Behaviour {
647
652
  const loadStartEvt = new CustomEvent<LoadSceneEvent>("loadscene-start", { detail: { scene: scene, switcher: this, index: index } })
648
653
  this.dispatchEvent(loadStartEvt);
649
654
  this.sceneLoadingStart?.invoke(loadStartEvt.detail);
650
- await this.onStartLoading();
655
+ await this.onStartLoading(loadingRequestId);
651
656
  // start loading and wait for the scene to be loaded
652
657
  await scene.loadAssetAsync((_, prog) => {
658
+ if (loadingRequestId !== this._activeLoadingRequestId) return;
653
659
  if (debug) {
654
660
  const t01 = prog.loaded / prog.total;
655
661
  const progressBarString = "[" + "=".repeat(Math.floor(t01 * 20)) + "-".repeat(20 - Math.floor(t01 * 20)) + "]";
@@ -659,12 +665,14 @@ export class SceneSwitcher extends Behaviour {
659
665
  this.dispatchEvent(prog);
660
666
  this.sceneLoadingProgress?.invoke(prog);
661
667
  }).catch(console.error);
662
- await this.onEndLoading();
668
+ await this.onEndLoading(loadingRequestId);
663
669
  const finishedEvt = new CustomEvent<LoadSceneEvent>("loadscene-finished", { detail: { scene: scene, switcher: this, index: index } });
664
670
  this.dispatchEvent(finishedEvt);
665
671
 
666
- this._currentLoadingProgress = undefined;
667
- this._currentlyLoadingScene = undefined;
672
+ if (loadingRequestId === this._activeLoadingRequestId) {
673
+ this._currentLoadingProgress = undefined;
674
+ this._currentlyLoadingScene = undefined;
675
+ }
668
676
 
669
677
  if (finishedEvt.defaultPrevented) {
670
678
  if (debug) console.warn("[SceneSwitcher] Adding loaded scene prevented:", scene, finishedEvt);
@@ -737,8 +745,8 @@ export class SceneSwitcher extends Behaviour {
737
745
  history.pushState(stateName, "unused", location.href);
738
746
  }
739
747
  }
740
- // Call SceneListener opened callback (if a SceneListener is in the scene)
741
- const sceneListener = this.tryGetSceneEventListener(scene.asset as any as Object3D);
748
+ // Call SceneListener opened callback on the scene instance that was actually added.
749
+ const sceneListener = this.tryGetSceneEventListener(this._currentSceneAsset as any as Object3D);
742
750
  if (sceneListener?.sceneOpened) {
743
751
  const res = sceneListener.sceneOpened(this);
744
752
  if (res instanceof Promise) await res;
@@ -826,7 +834,8 @@ export class SceneSwitcher extends Behaviour {
826
834
  private _isCurrentlyLoading: boolean = false;
827
835
  private _currentLoadingProgress: ProgressEvent | undefined = undefined;
828
836
 
829
- private async onStartLoading() {
837
+ private async onStartLoading(loadingRequestId: number) {
838
+ this._activeLoadingRequestId = loadingRequestId;
830
839
  this._isCurrentlyLoading = true;
831
840
  if (this.loadingScene) {
832
841
  // save the last loading scene reference so that it can be changed at runtime
@@ -839,7 +848,7 @@ export class SceneSwitcher extends Behaviour {
839
848
  this._loadingScenePromise = this.loadingScene?.loadAssetAsync().then(res => res != null);
840
849
  }
841
850
  await this._loadingScenePromise;
842
- if (this._isCurrentlyLoading && this.loadingScene?.asset) {
851
+ if (loadingRequestId === this._activeLoadingRequestId && this._isCurrentlyLoading && this.loadingScene?.asset) {
843
852
  if (debug) console.log("Add loading scene", this.loadingScene.url, this.loadingScene.asset)
844
853
  const loadingScene = this.loadingScene.asset as any as Object3D;
845
854
  GameObject.add(loadingScene, this.gameObject);
@@ -851,7 +860,7 @@ export class SceneSwitcher extends Behaviour {
851
860
  }
852
861
  }
853
862
  // Invoke the event on a loading listener on the same object as the scene switcher
854
- if (this._isCurrentlyLoading) {
863
+ if (loadingRequestId === this._activeLoadingRequestId && this._isCurrentlyLoading) {
855
864
  const listener = this.tryGetSceneEventListener(this.gameObject);
856
865
  if (listener) {
857
866
  if (listener.sceneOpened) {
@@ -861,7 +870,8 @@ export class SceneSwitcher extends Behaviour {
861
870
  }
862
871
  }
863
872
  }
864
- private async onEndLoading() {
873
+ private async onEndLoading(loadingRequestId: number) {
874
+ if (loadingRequestId !== this._activeLoadingRequestId) return;
865
875
  this._isCurrentlyLoading = false;
866
876
  if (this.loadingScene?.asset) {
867
877
  if (debug) console.log("Remove loading scene", this.loadingScene.url);
@@ -11,7 +11,7 @@ import { Behaviour } from "./Component.js";
11
11
  import { IUSDExporterExtension } from "./export/usdz/Extension.js";
12
12
  import { USDZExporter } from "./export/usdz/USDZExporter.js";
13
13
  import type { OrbitControls } from "./OrbitControls.js";
14
- import { Renderer } from "./Renderer.js";
14
+ import { Renderer, ShadowCastingMode } from "./Renderer.js";
15
15
 
16
16
  const debugSeeThrough = getParam("debugseethrough");
17
17
 
@@ -164,6 +164,35 @@ export class SeeThrough extends Behaviour {
164
164
  @serializable()
165
165
  autoUpdate: boolean = true;
166
166
 
167
+ /**
168
+ * The alpha (opacity) the object is currently faded to, in the `[0, 1]` range.
169
+ * `1` means fully visible, `0` means fully hidden. This is the live, animated value
170
+ * (it lerps toward the target over {@link fadeDuration}), updated every frame the fade runs.
171
+ *
172
+ * External components can read this to react to the see-through state — e.g. toggling a
173
+ * collider, muting audio, or driving a UI prompt as the object fades in or out.
174
+ *
175
+ * @example React to the object becoming (almost) hidden
176
+ * ```ts
177
+ * if (seeThrough.currentAlpha < 0.1) {
178
+ * // object is essentially see-through now
179
+ * }
180
+ * ```
181
+ * @default 1
182
+ */
183
+ get currentAlpha(): number {
184
+ return this._currentAlpha;
185
+ }
186
+
187
+
188
+ /**
189
+ * Below this alpha the object stops casting a shadow. The shadow map only stores depth, so a
190
+ * faded object would otherwise keep casting a fully solid shadow (three.js does not fade shadows
191
+ * by opacity). Toggling shadow casting off near-invisible is the cleanest fix; we drive it via
192
+ * the {@link Renderer.shadowCastingMode} so it cooperates with the per-frame shadow settings the
193
+ * Renderer re-applies. Note this is a hard on/off, not a fade.
194
+ */
195
+ public static readonly shadowHideThreshold = 0.05;
167
196
 
168
197
  private readonly _referencePointVector: Vector3 = new Vector3();
169
198
  private readonly _referencePointDir: Vector3 = new Vector3();
@@ -171,6 +200,9 @@ export class SeeThrough extends Behaviour {
171
200
  private _renderer: Renderer[] | null = null;
172
201
  private _needsUpdate = true;
173
202
  private _id = i++;
203
+ private _currentAlpha: number = 1;
204
+ /** Renderers whose shadow casting we turned off while hidden, mapped to their original mode for restore. */
205
+ private readonly _suppressedShadowCasting = new Map<Renderer, ShadowCastingMode>();
174
206
 
175
207
  /** * @internal */
176
208
  onEnable() {
@@ -178,6 +210,12 @@ export class SeeThrough extends Behaviour {
178
210
  this._renderer = null;
179
211
  }
180
212
 
213
+ /** @internal */
214
+ onDestroy() {
215
+ // Don't leave renderers permanently non-casting if this component is removed.
216
+ this.restoreSuppressedShadowCasting();
217
+ }
218
+
181
219
  /**
182
220
  * @internal
183
221
  */
@@ -258,13 +296,49 @@ export class SeeThrough extends Behaviour {
258
296
  if (newAlpha >= 0.99) newAlpha = 1;
259
297
  else if (newAlpha <= 0.01) newAlpha = 0;
260
298
 
299
+ // Expose the live fade value so external components can react to it (see `currentAlpha`).
300
+ // Renderers are driven by the same target/duration, so the last applied value is representative.
301
+ this._currentAlpha = newAlpha;
302
+
261
303
  // const currentTransparent = (block.getOverride("transparent")?.value ?? materials[0].transparent ?? false) as boolean;
262
304
 
263
305
  block.setOverride("alphaHash", this.useAlphaHash);
264
306
  block.setOverride("opacity", newAlpha);
265
307
  block.setOverride("transparent", newAlpha >= 0.99999 ? false : !this.useAlphaHash);
308
+
309
+ // Stop casting a shadow once the object is essentially invisible (and restore it when it
310
+ // comes back). A faded object otherwise keeps a fully solid shadow — see shadowHideThreshold.
311
+ this.updateShadowCasting(renderer, newAlpha);
266
312
  });
267
313
  }
268
314
 
315
+ /**
316
+ * Suppress or restore a renderer's shadow casting based on its current alpha.
317
+ * Driving {@link Renderer.shadowCastingMode} (rather than the raw `mesh.castShadow`) is required
318
+ * because the Renderer re-applies that setting every frame; we remember the original mode so it
319
+ * is restored exactly when the object fades back in.
320
+ */
321
+ private updateShadowCasting(renderer: Renderer, alpha: number) {
322
+ const shouldHideShadow = alpha < SeeThrough.shadowHideThreshold;
323
+ if (shouldHideShadow) {
324
+ if (renderer.shadowCastingMode !== ShadowCastingMode.Off) {
325
+ this._suppressedShadowCasting.set(renderer, renderer.shadowCastingMode);
326
+ renderer.shadowCastingMode = ShadowCastingMode.Off;
327
+ }
328
+ }
329
+ else if (this._suppressedShadowCasting.has(renderer)) {
330
+ renderer.shadowCastingMode = this._suppressedShadowCasting.get(renderer)!;
331
+ this._suppressedShadowCasting.delete(renderer);
332
+ }
333
+ }
334
+
335
+ /** Restore every renderer we suppressed shadow casting on, then clear the bookkeeping. */
336
+ private restoreSuppressedShadowCasting() {
337
+ for (const [renderer, mode] of this._suppressedShadowCasting) {
338
+ if (!renderer.destroyed) renderer.shadowCastingMode = mode;
339
+ }
340
+ this._suppressedShadowCasting.clear();
341
+ }
342
+
269
343
  }
270
344
 
@@ -56,6 +56,14 @@ function applyRotationAttribute(attr: string, target: { set(x: number, y: number
56
56
  }
57
57
 
58
58
  function applyTexture(context: IContext, texture: Texture, isBackground: boolean, isEnvironment: boolean) {
59
+ // Textures load asynchronously. The context can be disposed/torn down while a
60
+ // load is in flight (element removed, context disposed, scene switched) — at
61
+ // which point context.scene is set to null on dispose. Bail instead of
62
+ // dereferencing it (the rest of this function also touches context.scene).
63
+ if (!context.scene) {
64
+ if (debug) console.warn("Skybox: skipping applyTexture — context was torn down before the texture finished loading");
65
+ return;
66
+ }
59
67
  if (!(texture instanceof CubeTexture || texture instanceof CompressedCubeTexture)
60
68
  && texture.mapping !== CubeUVReflectionMapping) {
61
69
  texture.mapping = EquirectangularRefractionMapping;
@@ -44,6 +44,7 @@ export { Behaviour, Component, GameObject } from "./Component.js";
44
44
  export { AudioRolloffMode } from "./AudioSource.js";
45
45
  export { ClearFlags } from "./Camera.js"
46
46
  export { DragMode } from "./DragControls.js";
47
+ export { ShadowCastingMode, ReflectionProbeUsage } from "./Renderer.js";
47
48
  export type { DropListenerNetworkEventArguments, DropListenerOnDropArguments } from "./DropListener.js";
48
49
  export * from "./export/index.js"
49
50
  export * from "./particlesystem/api.js"