@needle-tools/engine 4.16.10 → 4.16.12
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.
- package/CHANGELOG.md +9 -0
- package/SKILL.md +64 -4
- package/dist/gltf-progressive-DLDvZ8Xm.umd.cjs +10 -0
- package/dist/{gltf-progressive-CzxjNmG6.js → gltf-progressive-DMK8kKVB.js} +352 -346
- package/dist/gltf-progressive-RPgYR6rv.min.js +10 -0
- package/dist/{needle-engine.bundle-a0Pw5htf.js → needle-engine.bundle-BfsnYTeP.js} +7329 -7105
- package/dist/{needle-engine.bundle-D_nuVSFb.umd.cjs → needle-engine.bundle-COr8D1vc.umd.cjs} +132 -132
- package/dist/{needle-engine.bundle-DlyYXu9v.min.js → needle-engine.bundle-DG1sGkxn.min.js} +131 -131
- package/dist/needle-engine.d.ts +60 -3
- package/dist/needle-engine.js +3 -3
- package/dist/needle-engine.min.js +1 -1
- package/dist/needle-engine.umd.cjs +1 -1
- package/dist/three-examples.js +52 -52
- package/dist/three-examples.min.js +4 -4
- package/dist/three-examples.umd.cjs +2 -2
- package/lib/engine/api.d.ts +2 -1
- package/lib/engine/api.js +1 -6
- package/lib/engine/api.js.map +1 -1
- package/lib/engine/debug/debug_spector.js +1 -1
- package/lib/engine/debug/debug_spector.js.map +1 -1
- package/lib/engine/engine_addressables.d.ts +32 -1
- package/lib/engine/engine_addressables.js +71 -9
- package/lib/engine/engine_addressables.js.map +1 -1
- package/lib/engine/engine_application.d.ts +2 -0
- package/lib/engine/engine_application.js +7 -3
- package/lib/engine/engine_application.js.map +1 -1
- package/lib/engine/engine_audio.d.ts +30 -1
- package/lib/engine/engine_audio.js +57 -13
- package/lib/engine/engine_audio.js.map +1 -1
- package/lib/engine/engine_context.js +25 -1
- package/lib/engine/engine_context.js.map +1 -1
- package/lib/engine/engine_context_registry.js +1 -1
- package/lib/engine/engine_context_registry.js.map +1 -1
- package/lib/engine/engine_input.js +1 -1
- package/lib/engine/engine_input.js.map +1 -1
- package/lib/engine/engine_license.d.ts +1 -1
- package/lib/engine/engine_license.js +31 -5
- package/lib/engine/engine_license.js.map +1 -1
- package/lib/engine/engine_lifecycle_functions_internal.js +20 -2
- package/lib/engine/engine_lifecycle_functions_internal.js.map +1 -1
- package/lib/engine/engine_networking_streams.d.ts +5 -1
- package/lib/engine/engine_networking_streams.js +64 -17
- package/lib/engine/engine_networking_streams.js.map +1 -1
- package/lib/engine/engine_shims.js +1 -1
- package/lib/engine/engine_shims.js.map +1 -1
- package/lib/engine/physics/workers/mesh-bvh/GenerateMeshBVHWorker.js +5 -0
- package/lib/engine/physics/workers/mesh-bvh/GenerateMeshBVHWorker.js.map +1 -1
- package/lib/engine/webcomponents/fonts.d.ts +1 -1
- package/lib/engine/webcomponents/fonts.js +7 -0
- package/lib/engine/webcomponents/fonts.js.map +1 -1
- package/lib/engine/webcomponents/icons.js +8 -1
- package/lib/engine/webcomponents/icons.js.map +1 -1
- package/lib/engine/webcomponents/needle menu/needle-menu.d.ts +5 -0
- package/lib/engine/webcomponents/needle menu/needle-menu.js +31 -13
- package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
- package/lib/engine/webcomponents/needle-engine.d.ts +1 -0
- package/lib/engine/webcomponents/needle-engine.js +16 -4
- package/lib/engine/webcomponents/needle-engine.js.map +1 -1
- package/lib/engine/webcomponents/needle-engine.loading.d.ts +2 -0
- package/lib/engine/webcomponents/needle-engine.loading.js +10 -0
- package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
- package/lib/engine-components/AudioSource.d.ts +49 -2
- package/lib/engine-components/AudioSource.js +261 -66
- package/lib/engine-components/AudioSource.js.map +1 -1
- package/lib/engine-components/CameraUtils.js +1 -1
- package/lib/engine-components/CameraUtils.js.map +1 -1
- package/lib/engine-components/postprocessing/Effects/Tonemapping.utils.d.ts +1 -1
- package/package.json +2 -2
- package/src/engine/api.ts +3 -1
- package/src/engine/debug/debug_spector.ts +1 -1
- package/src/engine/engine_addressables.ts +75 -9
- package/src/engine/engine_application.ts +7 -2
- package/src/engine/engine_audio.ts +68 -15
- package/src/engine/engine_context.ts +25 -1
- package/src/engine/engine_context_registry.ts +1 -1
- package/src/engine/engine_input.ts +2 -2
- package/src/engine/engine_license.ts +28 -5
- package/src/engine/engine_lifecycle_functions_internal.ts +21 -2
- package/src/engine/engine_networking_streams.ts +64 -19
- package/src/engine/engine_shims.ts +1 -1
- package/src/engine/physics/workers/mesh-bvh/GenerateMeshBVHWorker.js +8 -1
- package/src/engine/webcomponents/fonts.ts +6 -0
- package/src/engine/webcomponents/icons.ts +9 -2
- package/src/engine/webcomponents/needle menu/needle-menu.ts +30 -12
- package/src/engine/webcomponents/needle-engine.loading.ts +13 -1
- package/src/engine/webcomponents/needle-engine.ts +15 -5
- package/src/engine-components/AudioSource.ts +244 -63
- package/src/engine-components/CameraUtils.ts +1 -1
- package/dist/gltf-progressive-BacJPTD6.umd.cjs +0 -10
- package/dist/gltf-progressive-BmXoz_HR.min.js +0 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@needle-tools/engine",
|
|
3
|
-
"version": "4.16.
|
|
3
|
+
"version": "4.16.12",
|
|
4
4
|
"description": "Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.",
|
|
5
5
|
"main": "dist/needle-engine.min.js",
|
|
6
6
|
"exports": {
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
],
|
|
115
115
|
"dependencies": {
|
|
116
116
|
"@dimforge/rapier3d-compat": "0.19.x",
|
|
117
|
-
"@needle-tools/gltf-progressive": "3.4.0-beta.
|
|
117
|
+
"@needle-tools/gltf-progressive": "3.4.0-beta.4",
|
|
118
118
|
"@needle-tools/materialx": "1.4.6",
|
|
119
119
|
"@needle-tools/three-animation-pointer": "1.0.7",
|
|
120
120
|
"@webxr-input-profiles/motion-controllers": "^1.0.0",
|
package/src/engine/api.ts
CHANGED
|
@@ -109,7 +109,9 @@ export * from "./debug/index.js";
|
|
|
109
109
|
// ============================================================================
|
|
110
110
|
|
|
111
111
|
/** Addressable asset system for loading assets by address/label */
|
|
112
|
-
|
|
112
|
+
// Keep cache disposal internal: closing shared bitmaps can invalidate active consumers.
|
|
113
|
+
export type { ProgressCallback } from "./engine_addressables.js";
|
|
114
|
+
export { Addressables, AssetReference, FileReference, FileReferenceSerializer, ImageReference, ImageReferenceSerializer } from "./engine_addressables.js";
|
|
113
115
|
|
|
114
116
|
/** Animation playback and control utilities */
|
|
115
117
|
export { AnimationUtils } from "./engine_animation.js";
|
|
@@ -18,7 +18,7 @@ declare global {
|
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
export function initSpectorIfAvailable(_context: Context, canvas: HTMLCanvasElement): void {
|
|
21
|
-
if (typeof window !== undefined && window.SPECTOR) {
|
|
21
|
+
if (typeof window !== "undefined" && window.SPECTOR) {
|
|
22
22
|
console.log(window.SPECTOR);
|
|
23
23
|
const params = new URLSearchParams(window.location.search);
|
|
24
24
|
if (params.has("spector")) {
|
|
@@ -619,14 +619,36 @@ export class ImageReference {
|
|
|
619
619
|
|
|
620
620
|
private _bitmap?: Promise<ImageBitmap | null>;
|
|
621
621
|
private _bitmapObject?: ImageBitmap;
|
|
622
|
+
private _bitmapGeneration = 0;
|
|
622
623
|
|
|
623
624
|
dispose() {
|
|
625
|
+
// Invalidate image loads and decodes that may complete after teardown.
|
|
626
|
+
this._bitmapGeneration++;
|
|
624
627
|
if (this._bitmapObject) {
|
|
625
628
|
this._bitmapObject.close();
|
|
629
|
+
// drop the reference too: close() frees the underlying image data, but keeping the
|
|
630
|
+
// object alive keeps the wrapper (and anything reachable from it) in the heap.
|
|
631
|
+
this._bitmapObject = undefined;
|
|
626
632
|
}
|
|
627
633
|
this._bitmap = undefined;
|
|
628
634
|
}
|
|
629
635
|
|
|
636
|
+
/**
|
|
637
|
+
* @internal Only used during final context teardown; see {@link clearAssetCaches}.
|
|
638
|
+
* Dispose every cached {@link ImageReference} and empty the cache.
|
|
639
|
+
*
|
|
640
|
+
* The cache is static, so it outlives any {@link Context}: without this, images loaded by a
|
|
641
|
+
* page that is later torn down (e.g. navigating between routes in a SPA) stay in memory for
|
|
642
|
+
* the lifetime of the document, and the heap grows with every navigation.
|
|
643
|
+
* Called automatically once the last context is disposed — see {@link clearAssetCaches}.
|
|
644
|
+
*/
|
|
645
|
+
static clearCache() {
|
|
646
|
+
for (const ref of ImageReference.imageReferences.values()) {
|
|
647
|
+
ref.dispose();
|
|
648
|
+
}
|
|
649
|
+
ImageReference.imageReferences.clear();
|
|
650
|
+
}
|
|
651
|
+
|
|
630
652
|
createHTMLImage(): HTMLImageElement {
|
|
631
653
|
const img = new Image();
|
|
632
654
|
img.src = this.url;
|
|
@@ -659,22 +681,33 @@ export class ImageReference {
|
|
|
659
681
|
// });
|
|
660
682
|
}
|
|
661
683
|
|
|
662
|
-
/** Loads the bitmap data of the image */
|
|
684
|
+
/** Loads the bitmap data of the image. Returns null if disposed before loading completes. */
|
|
663
685
|
getBitmap(): Promise<ImageBitmap | null> {
|
|
664
686
|
if (this._bitmap) return this._bitmap;
|
|
665
|
-
|
|
687
|
+
const generation = this._bitmapGeneration;
|
|
688
|
+
this._bitmap = new Promise((resolve, reject) => {
|
|
666
689
|
const imageElement = document.createElement("img") as HTMLImageElement;
|
|
667
690
|
imageElement.addEventListener("load", () => {
|
|
668
|
-
|
|
691
|
+
if (generation !== this._bitmapGeneration) {
|
|
692
|
+
resolve(null);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
createImageBitmap(imageElement).then(bitmap => {
|
|
696
|
+
if (generation !== this._bitmapGeneration) {
|
|
697
|
+
// The cache no longer owns this request. Do not revive disposed state
|
|
698
|
+
// or overwrite a newer load on a reference that has been reused.
|
|
699
|
+
bitmap.close();
|
|
700
|
+
resolve(null);
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
669
703
|
this._bitmapObject = bitmap;
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
});
|
|
704
|
+
resolve(bitmap);
|
|
705
|
+
}, reject);
|
|
706
|
+
}, { once: true });
|
|
674
707
|
imageElement.addEventListener("error", err => {
|
|
675
708
|
console.error("Failed to load image:" + this.url, err);
|
|
676
|
-
|
|
677
|
-
});
|
|
709
|
+
resolve(null);
|
|
710
|
+
}, { once: true });
|
|
678
711
|
imageElement.src = this.url;
|
|
679
712
|
});
|
|
680
713
|
return this._bitmap;
|
|
@@ -748,6 +781,39 @@ export class FileReference {
|
|
|
748
781
|
constructor(url: string) {
|
|
749
782
|
this.url = url;
|
|
750
783
|
}
|
|
784
|
+
|
|
785
|
+
/** Drop the cached response so its body can be garbage collected. A subsequent load re-fetches. */
|
|
786
|
+
dispose() {
|
|
787
|
+
this.res = undefined;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* @internal Only used during final context teardown; see {@link clearAssetCaches}.
|
|
792
|
+
* Dispose every cached {@link FileReference} and empty the cache.
|
|
793
|
+
*
|
|
794
|
+
* The cache is static, so it outlives any {@link Context}: each entry holds the fetch response
|
|
795
|
+
* for its url, which keeps the file's binary data alive for the lifetime of the document.
|
|
796
|
+
* Called automatically once the last context is disposed — see {@link clearAssetCaches}.
|
|
797
|
+
*/
|
|
798
|
+
static clearCache() {
|
|
799
|
+
for (const ref of FileReference.cache.values()) {
|
|
800
|
+
ref.dispose();
|
|
801
|
+
}
|
|
802
|
+
FileReference.cache.clear();
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* @internal Clear the shared file and image caches after the last context is disposed.
|
|
808
|
+
*
|
|
809
|
+
* Intentionally not exposed through the public engine API: clearing these caches closes
|
|
810
|
+
* ImageBitmaps already returned to callers. It does not destroy scenes or remove consumers,
|
|
811
|
+
* so calling it while a context is alive can invalidate assets that are still in use.
|
|
812
|
+
* A public API needs resource ownership tracking and defined handling of pending loads first.
|
|
813
|
+
*/
|
|
814
|
+
export function clearAssetCaches() {
|
|
815
|
+
FileReference.clearCache();
|
|
816
|
+
ImageReference.clearCache();
|
|
751
817
|
}
|
|
752
818
|
|
|
753
819
|
|
|
@@ -102,10 +102,15 @@ export class Application extends EventTarget {
|
|
|
102
102
|
constructor(context: Context) {
|
|
103
103
|
super();
|
|
104
104
|
this.context = context;
|
|
105
|
-
window.addEventListener("visibilitychange", this.onVisiblityChanged
|
|
105
|
+
window.addEventListener("visibilitychange", this.onVisiblityChanged, false);
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
/** @internal Remove the global listener when the owning context is disposed. */
|
|
109
|
+
dispose() {
|
|
110
|
+
window.removeEventListener("visibilitychange", this.onVisiblityChanged, false);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private onVisiblityChanged = (evt) => {
|
|
109
114
|
// console.log(evt.target.visibilityState)
|
|
110
115
|
switch (evt.target.visibilityState) {
|
|
111
116
|
case "hidden":
|
|
@@ -1,25 +1,78 @@
|
|
|
1
1
|
import { AudioContext } from "three";
|
|
2
2
|
|
|
3
3
|
import { Application } from "./engine_application.js";
|
|
4
|
+
import { getParam } from "./engine_utils.js";
|
|
4
5
|
|
|
5
|
-
/**
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
/** Verbose audio logging, opt-in via `?debugaudio` (same flag AudioSource uses). */
|
|
7
|
+
const debug = getParam("debugaudio");
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* iOS Safari exposes a non-standard `"interrupted"` state in addition to the
|
|
11
|
+
* spec'd `AudioContextState`s when the audio session is taken away (e.g. the
|
|
12
|
+
* device is locked or another app starts playing audio).
|
|
13
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/state#resuming_interrupted_play_states_in_ios_safari
|
|
14
|
+
*/
|
|
15
|
+
export type InterruptibleAudioContextState = AudioContextState | "interrupted";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @internal
|
|
19
|
+
* Pure policy deciding whether `AudioContext.resume()` should be attempted right now.
|
|
20
|
+
*
|
|
21
|
+
* The context needs resuming when it is `"suspended"` or (on iOS) `"interrupted"`.
|
|
22
|
+
* But resuming is only allowed while the document is **visible**: on iOS Safari,
|
|
23
|
+
* calling `resume()` while the page is backgrounded or the device is locked fails
|
|
24
|
+
* with `InvalidStateError: Failed to start the audio device`, because the audio
|
|
25
|
+
* hardware cannot be started in the background. In that case the resume must be
|
|
26
|
+
* deferred until the page becomes visible again.
|
|
27
|
+
*
|
|
28
|
+
* @param state The current (possibly iOS-interrupted) audio context state.
|
|
29
|
+
* @param visibility The current `document.visibilityState`.
|
|
30
|
+
* @returns `true` if `resume()` should be called now, `false` if it should be deferred.
|
|
31
|
+
*/
|
|
32
|
+
export function shouldResumeAudioContext(
|
|
33
|
+
state: InterruptibleAudioContextState,
|
|
34
|
+
visibility: DocumentVisibilityState,
|
|
35
|
+
): boolean {
|
|
36
|
+
// Resuming while hidden throws InvalidStateError on iOS — defer until visible.
|
|
37
|
+
if (visibility !== "visible") return false;
|
|
38
|
+
return state === "suspended" || state === "interrupted";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Resume the shared AudioContext if it is suspended/interrupted and the page is visible. */
|
|
42
|
+
function recoverAudioContext() {
|
|
43
|
+
const ctx = AudioContext.getContext();
|
|
44
|
+
const state = ctx.state as InterruptibleAudioContextState;
|
|
45
|
+
const visibility = typeof document !== "undefined" ? document.visibilityState : "visible";
|
|
46
|
+
if (!shouldResumeAudioContext(state, visibility)) return;
|
|
47
|
+
ctx.resume()
|
|
48
|
+
.then(() => { if (debug) console.log("AudioContext resumed successfully"); })
|
|
49
|
+
.catch(e => { if (debug) console.warn("Failed to resume AudioContext: " + e); });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @internal
|
|
54
|
+
* Keep the shared AudioContext alive across suspends/interruptions (tab switch, device lock, audio
|
|
55
|
+
* session interruption). On returning to the foreground we simply resume() the EXISTING context —
|
|
56
|
+
* the same approach three.js uses, which recovers cleanly because AudioSource plays clips through a
|
|
57
|
+
* MediaElementAudioSourceNode (an <audio> element), whose media session iOS keeps alive across a
|
|
58
|
+
* lock. We deliberately do NOT close/recreate the context: on-device testing showed that makes iOS
|
|
59
|
+
* worse (a recreated context's output stays dead) and it is unnecessary.
|
|
60
|
+
*/
|
|
8
61
|
export function ensureAudioContextIsResumed() {
|
|
9
62
|
Application.registerWaitForInteraction(() => {
|
|
10
|
-
//
|
|
63
|
+
// https://github.com/mrdoob/three.js/issues/27779 & https://linear.app/needle/issue/NE-4257
|
|
11
64
|
const ctx = AudioContext.getContext();
|
|
12
|
-
ctx.addEventListener("statechange", () =>
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
}
|
|
65
|
+
ctx.addEventListener("statechange", () => setTimeout(recoverAudioContext, 300));
|
|
66
|
+
|
|
67
|
+
// iOS frequently rejects the first resume() right after foregrounding with InvalidStateError,
|
|
68
|
+
// then accepts one a few hundred ms later with no follow-up statechange — so retry with a
|
|
69
|
+
// short backoff. Each attempt is a no-op once running.
|
|
70
|
+
if (typeof document !== "undefined") {
|
|
71
|
+
document.addEventListener("visibilitychange", () => {
|
|
72
|
+
if (document.visibilityState !== "visible") return;
|
|
73
|
+
for (const delay of [0, 250, 600, 1200, 2000]) setTimeout(recoverAudioContext, delay);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
23
76
|
});
|
|
24
77
|
}
|
|
25
78
|
setTimeout(ensureAudioContextIsResumed, 1000);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import 'three/examples/jsm/renderers/webgl-legacy/nodes/WebGLNodes.js';
|
|
2
2
|
|
|
3
|
+
import { NEEDLE_progressive } from '@needle-tools/gltf-progressive';
|
|
3
4
|
import type { EffectComposer } from "postprocessing";
|
|
4
5
|
import {
|
|
5
6
|
BufferGeometry, Camera, Color, DepthTexture, Group,
|
|
@@ -16,7 +17,7 @@ import { nodeFrame } from "three/examples/jsm/renderers/webgl-legacy/nodes/WebGL
|
|
|
16
17
|
import { initSpectorIfAvailable } from './debug/debug_spector.js';
|
|
17
18
|
import { isDevEnvironment, LogType, showBalloonError, showBalloonMessage } from './debug/index.js';
|
|
18
19
|
import { AccessibilityManager } from './engine_accessibility.js';
|
|
19
|
-
import { Addressables } from './engine_addressables.js';
|
|
20
|
+
import { Addressables, clearAssetCaches } from './engine_addressables.js';
|
|
20
21
|
import { AnimationsRegistry } from './engine_animation.js';
|
|
21
22
|
import { Application } from './engine_application.js';
|
|
22
23
|
import { AssetDatabase } from './engine_assetdatabase.js';
|
|
@@ -850,6 +851,19 @@ export class Context implements IContext {
|
|
|
850
851
|
Context.Current = this;
|
|
851
852
|
ContextRegistry.dispatchCallback(ContextEvent.ContextDestroying, this);
|
|
852
853
|
invokeLifecycleFunctions(this, ContextEvent.ContextDestroying);
|
|
854
|
+
// The global legacy node frame must not retain this context's scene after teardown.
|
|
855
|
+
if (nodeFrame.camera === this._mainCamera || nodeFrame.camera === this._fallbackCamera
|
|
856
|
+
|| nodeFrame.camera === this.mainCameraComponent?.threeCamera) {
|
|
857
|
+
nodeFrame.camera = null;
|
|
858
|
+
}
|
|
859
|
+
if (this.renderer && nodeFrame.renderer === this.renderer) {
|
|
860
|
+
nodeFrame.camera = null;
|
|
861
|
+
nodeFrame.renderer = null;
|
|
862
|
+
nodeFrame.object = null;
|
|
863
|
+
nodeFrame.material = null;
|
|
864
|
+
nodeFrame.scene = null;
|
|
865
|
+
nodeFrame.geometry = null;
|
|
866
|
+
}
|
|
853
867
|
this.clear();
|
|
854
868
|
this.renderer?.setAnimationLoop(null);
|
|
855
869
|
if (this.renderer) {
|
|
@@ -862,6 +876,7 @@ export class Context implements IContext {
|
|
|
862
876
|
}
|
|
863
877
|
this.scene = null!;
|
|
864
878
|
this.renderer = null!;
|
|
879
|
+
this.application.dispose();
|
|
865
880
|
this.input.dispose();
|
|
866
881
|
this.menu.onDestroy();
|
|
867
882
|
this.animations.onDestroy();
|
|
@@ -880,6 +895,15 @@ export class Context implements IContext {
|
|
|
880
895
|
ContextRegistry.dispatchCallback(ContextEvent.ContextDestroyed, this);
|
|
881
896
|
invokeLifecycleFunctions(this, ContextEvent.ContextDestroyed);
|
|
882
897
|
ContextRegistry.unregister(this);
|
|
898
|
+
// The asset caches (FileReference/ImageReference) are static and shared by all contexts, so
|
|
899
|
+
// they can only be released once nothing is left that could still be using them. Without
|
|
900
|
+
// this they are never emptied: a SPA that repeatedly creates and destroys <needle-engine>
|
|
901
|
+
// elements keeps every loaded file and image alive for the lifetime of the document.
|
|
902
|
+
if (ContextRegistry.Registered.length === 0) {
|
|
903
|
+
clearAssetCaches();
|
|
904
|
+
// Geometry promises in this shared cache retain resource users and their textures.
|
|
905
|
+
NEEDLE_progressive.dispose();
|
|
906
|
+
}
|
|
883
907
|
if (Context.Current === this) {
|
|
884
908
|
//@ts-ignore
|
|
885
909
|
Context.Current = null;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type IComponent, type IContext, type LoadedModel } from "./engine_types.js";
|
|
2
2
|
|
|
3
|
-
const debug = typeof window !== undefined ? window.location.search.includes("debugcontext") : false;
|
|
3
|
+
const debug = typeof window !== "undefined" ? window.location.search.includes("debugcontext") : false;
|
|
4
4
|
|
|
5
5
|
/** The various events that can be dispatched by a Needle Engine {@link IContext} instance
|
|
6
6
|
*/
|
|
@@ -942,7 +942,7 @@ export class Input implements IInput {
|
|
|
942
942
|
window.removeEventListener('contextmenu', this.onContextMenu);
|
|
943
943
|
|
|
944
944
|
this._htmlEventSource?.removeEventListener('pointerdown', this.onPointerDown);
|
|
945
|
-
window.removeEventListener('pointermove', this.onPointerMove);
|
|
945
|
+
window.removeEventListener('pointermove', this.onPointerMove, true);
|
|
946
946
|
window.removeEventListener('pointerup', this.onPointerUp);
|
|
947
947
|
window.removeEventListener('pointercancel', this.onPointerCancel);
|
|
948
948
|
|
|
@@ -1597,4 +1597,4 @@ export declare type KeyCode =
|
|
|
1597
1597
|
// OPEN_BRACKET = 219,
|
|
1598
1598
|
// BACK_SLASH = 220,
|
|
1599
1599
|
// CLOSE_BRACKET = 221,
|
|
1600
|
-
// SINGLE_QUOTE = 222
|
|
1600
|
+
// SINGLE_QUOTE = 222
|
|
@@ -10,6 +10,23 @@ import type { IContext } from "./engine_types.js";
|
|
|
10
10
|
import { getParam } from "./engine_utils.js";
|
|
11
11
|
import { InternalAttributeUtils } from "./engine_utils_attributes.js";
|
|
12
12
|
|
|
13
|
+
// License UI polling must stop when its context goes away; otherwise timers retain scenes.
|
|
14
|
+
const licenseIntervals = new WeakMap<IContext, Set<ReturnType<typeof setInterval>>>();
|
|
15
|
+
const disposedLicenseContexts = new WeakSet<IContext>();
|
|
16
|
+
ContextRegistry.registerCallback(ContextEvent.ContextDestroying, ({ context }) => {
|
|
17
|
+
disposedLicenseContexts.add(context);
|
|
18
|
+
for (const timer of licenseIntervals.get(context) ?? []) clearInterval(timer);
|
|
19
|
+
licenseIntervals.delete(context);
|
|
20
|
+
});
|
|
21
|
+
function setLicenseInterval(ctx: IContext, callback: () => void, delay: number) {
|
|
22
|
+
if (disposedLicenseContexts.has(ctx)) return undefined;
|
|
23
|
+
let timers = licenseIntervals.get(ctx);
|
|
24
|
+
if (!timers) licenseIntervals.set(ctx, timers = new Set());
|
|
25
|
+
const timer = setInterval(callback, delay);
|
|
26
|
+
timers.add(timer);
|
|
27
|
+
return timer;
|
|
28
|
+
}
|
|
29
|
+
|
|
13
30
|
const debug = getParam("debuglicense");
|
|
14
31
|
|
|
15
32
|
const _licenseCheckResultChangedCallbacks: ((result: boolean) => void)[] = [];
|
|
@@ -56,9 +73,15 @@ export function hasCommercialLicense() {
|
|
|
56
73
|
|
|
57
74
|
/** @internal */
|
|
58
75
|
export function onLicenseCheckResultChanged(cb: (result: boolean) => void) {
|
|
59
|
-
if (hasProLicense() || hasIndieLicense() || hasEduLicense())
|
|
60
|
-
|
|
76
|
+
if (hasProLicense() || hasIndieLicense() || hasEduLicense()) {
|
|
77
|
+
cb(true);
|
|
78
|
+
return () => {};
|
|
79
|
+
}
|
|
61
80
|
_licenseCheckResultChangedCallbacks.push(cb);
|
|
81
|
+
return () => {
|
|
82
|
+
const index = _licenseCheckResultChangedCallbacks.indexOf(cb);
|
|
83
|
+
if (index >= 0) _licenseCheckResultChangedCallbacks.splice(index, 1);
|
|
84
|
+
};
|
|
62
85
|
}
|
|
63
86
|
|
|
64
87
|
function invokeLicenseCheckResultChanged(result: boolean) {
|
|
@@ -328,7 +351,7 @@ async function handleForbidden(ctx: IContext) {
|
|
|
328
351
|
const expectedTextStyle = text.style.cssText;
|
|
329
352
|
const forbiddenText = applicationForbiddenText?.length > 1 ? applicationForbiddenText : "This web application has been paused.<br/>You might be in violation of the Needle Engine terms of use.<br/>Please contact the Needle support if you think this is a mistake.";
|
|
330
353
|
text.innerHTML = forbiddenText;
|
|
331
|
-
|
|
354
|
+
setLicenseInterval(ctx, () => {
|
|
332
355
|
if (text.innerHTML !== forbiddenText) text.innerHTML = forbiddenText;
|
|
333
356
|
if (text.parentNode !== div) div.appendChild(text);
|
|
334
357
|
if (div.style.cssText !== expectedStyle) div.style.cssText = expectedStyle;
|
|
@@ -338,7 +361,7 @@ async function handleForbidden(ctx: IContext) {
|
|
|
338
361
|
}
|
|
339
362
|
let forbiddenElement = createForbiddenElement();
|
|
340
363
|
const expectedCSS = forbiddenElement.style.cssText;
|
|
341
|
-
|
|
364
|
+
setLicenseInterval(ctx, () => {
|
|
342
365
|
if (applicationIsForbidden === true) {
|
|
343
366
|
if (forbiddenElement.style.cssText !== expectedCSS) forbiddenElement = createForbiddenElement();
|
|
344
367
|
if (ctx.domElement.shadowRoot) {
|
|
@@ -452,7 +475,7 @@ function insertNonCommercialUseHint(ctx: IContext) {
|
|
|
452
475
|
}, 100);
|
|
453
476
|
|
|
454
477
|
// ensure the banner is always visible
|
|
455
|
-
const interval =
|
|
478
|
+
const interval = setLicenseInterval(ctx, () => {
|
|
456
479
|
const parent = ctx.domElement.shadowRoot || ctx.domElement;
|
|
457
480
|
if (banner.parentNode !== parent) {
|
|
458
481
|
parent.appendChild(banner);
|
|
@@ -141,13 +141,27 @@ function internalInvokeLifecycleFunctions(ctx: Context, evt: Event) {
|
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
const
|
|
144
|
+
const buffers: Array<Array<RegisteredLifecycleMethod>> = [];
|
|
145
|
+
let invocationDepth = 0;
|
|
145
146
|
const hookContext: LifecycleHookContext = {
|
|
146
147
|
context: null as any as Context
|
|
147
148
|
};
|
|
148
149
|
|
|
149
150
|
function invoke(ctx: Context, methods: Array<RegisteredLifecycleMethod>, invokeOnce: boolean) {
|
|
150
|
-
|
|
151
|
+
// Reuse storage per nesting depth: hooks may synchronously invoke other hooks.
|
|
152
|
+
const bufferArray = buffers[invocationDepth] ?? (buffers[invocationDepth] = []);
|
|
153
|
+
invocationDepth++;
|
|
154
|
+
try {
|
|
155
|
+
invokeWithBuffer(ctx, methods, invokeOnce, bufferArray);
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
// One-time/unregistered callbacks can capture entire scenes as well.
|
|
159
|
+
bufferArray.length = 0;
|
|
160
|
+
invocationDepth--;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function invokeWithBuffer(ctx: Context, methods: Array<RegisteredLifecycleMethod>, invokeOnce: boolean, bufferArray: Array<RegisteredLifecycleMethod>) {
|
|
151
165
|
for (let i = 0; i < methods.length; i++) {
|
|
152
166
|
bufferArray.push(methods[i]);
|
|
153
167
|
}
|
|
@@ -161,6 +175,7 @@ function invoke(ctx: Context, methods: Array<RegisteredLifecycleMethod>, invokeO
|
|
|
161
175
|
}
|
|
162
176
|
|
|
163
177
|
if (invoke) {
|
|
178
|
+
const previousContext = hookContext.context;
|
|
164
179
|
try {
|
|
165
180
|
hookContext.context = ctx;
|
|
166
181
|
entry.method?.call(hookContext, ctx);
|
|
@@ -168,6 +183,10 @@ function invoke(ctx: Context, methods: Array<RegisteredLifecycleMethod>, invokeO
|
|
|
168
183
|
catch (e) {
|
|
169
184
|
console.error("Error in lifecycle method", e);
|
|
170
185
|
}
|
|
186
|
+
finally {
|
|
187
|
+
// This shared receiver must not keep the last invoked context alive.
|
|
188
|
+
hookContext.context = previousContext;
|
|
189
|
+
}
|
|
171
190
|
}
|
|
172
191
|
|
|
173
192
|
// Remove the method if it's a one time call
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import Peer
|
|
1
|
+
import type Peer from "peerjs";
|
|
2
|
+
import type { CallOption, MediaConnection } from "peerjs";
|
|
2
3
|
import { EventDispatcher } from "three";
|
|
3
4
|
|
|
4
5
|
import { RoomEvents } from "../engine/engine_networking.js";
|
|
@@ -7,6 +8,7 @@ import { getPeerjsInstance } from "../engine/engine_networking_peer.js";
|
|
|
7
8
|
import { showBalloonMessage } from "./debug/index.js";
|
|
8
9
|
import { Application } from "./engine_application.js";
|
|
9
10
|
import { Context } from "./engine_context.js";
|
|
11
|
+
import { ContextEvent, ContextRegistry } from "./engine_context_registry.js";
|
|
10
12
|
import type { IModel } from "./engine_networking_types.js";
|
|
11
13
|
import { type IComponent, isComponent } from "./engine_types.js";
|
|
12
14
|
import { getParam } from "./engine_utils.js";
|
|
@@ -120,17 +122,31 @@ function applySdpTransform(sdp) {
|
|
|
120
122
|
|
|
121
123
|
export class PeerHandle extends EventDispatcher<any> {
|
|
122
124
|
|
|
123
|
-
|
|
125
|
+
// Weak ownership prevents this cache from rooting a context and its scene indefinitely.
|
|
126
|
+
// IDs are scoped to contexts: separate engine elements may use the same stream ID.
|
|
127
|
+
private static readonly instances = new WeakMap<Context, Map<string, PeerHandle>>();
|
|
128
|
+
|
|
129
|
+
static {
|
|
130
|
+
ContextRegistry.registerCallback(ContextEvent.ContextDestroying, ({ context }) => {
|
|
131
|
+
const handles = PeerHandle.instances.get(context as Context);
|
|
132
|
+
if (!handles) return;
|
|
133
|
+
PeerHandle.instances.delete(context as Context);
|
|
134
|
+
for (const handle of handles.values()) handle.dispose();
|
|
135
|
+
handles.clear();
|
|
136
|
+
});
|
|
137
|
+
}
|
|
124
138
|
|
|
125
139
|
static getOrCreate(context: Context, guid: string): PeerHandle {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
140
|
+
let handles = PeerHandle.instances.get(context);
|
|
141
|
+
if (!handles) {
|
|
142
|
+
handles = new Map();
|
|
143
|
+
PeerHandle.instances.set(context, handles);
|
|
144
|
+
}
|
|
145
|
+
let peer = handles.get(guid);
|
|
146
|
+
if (!peer) {
|
|
147
|
+
peer = new PeerHandle(context, guid);
|
|
148
|
+
handles.set(guid, peer);
|
|
149
|
+
}
|
|
134
150
|
return peer;
|
|
135
151
|
}
|
|
136
152
|
|
|
@@ -150,6 +166,7 @@ export class PeerHandle extends EventDispatcher<any> {
|
|
|
150
166
|
}
|
|
151
167
|
|
|
152
168
|
makeCall(peerId: string, stream: MediaStream): CallHandle | undefined {
|
|
169
|
+
if (this._disposed) return undefined;
|
|
153
170
|
if (!stream?.id) {
|
|
154
171
|
if (debug) console.warn("Can not make a call: mediastream has no id or is undefined");
|
|
155
172
|
else console.debug("Can not make a call: mediastream has no id or is undefined");
|
|
@@ -178,10 +195,8 @@ export class PeerHandle extends EventDispatcher<any> {
|
|
|
178
195
|
}
|
|
179
196
|
|
|
180
197
|
closeAll() {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
for (const call of this._outgoingCalls) {
|
|
198
|
+
// Closing a call synchronously removes it from the original array.
|
|
199
|
+
for (const call of [...this._incomingCalls, ...this._outgoingCalls]) {
|
|
185
200
|
call.close();
|
|
186
201
|
}
|
|
187
202
|
this.updateCalls();
|
|
@@ -252,16 +267,33 @@ export class PeerHandle extends EventDispatcher<any> {
|
|
|
252
267
|
}
|
|
253
268
|
}
|
|
254
269
|
|
|
270
|
+
private _disposed = false;
|
|
271
|
+
private readonly _callIntervals = new Set<ReturnType<typeof setInterval>>();
|
|
272
|
+
|
|
273
|
+
private dispose() {
|
|
274
|
+
if (this._disposed) return;
|
|
275
|
+
this._disposed = true;
|
|
276
|
+
this.disable();
|
|
277
|
+
this.unsubscribePeerEvents();
|
|
278
|
+
for (const interval of this._callIntervals) clearInterval(interval);
|
|
279
|
+
this._callIntervals.clear();
|
|
280
|
+
this.closeAll();
|
|
281
|
+
this._incomingCalls.length = 0;
|
|
282
|
+
this._outgoingCalls.length = 0;
|
|
283
|
+
this._peer?.destroy();
|
|
284
|
+
this._peer = undefined;
|
|
285
|
+
}
|
|
286
|
+
|
|
255
287
|
private _enabled: boolean = false;
|
|
256
288
|
private _enabledPeer: boolean = false;
|
|
257
|
-
private onConnectRoomFn:
|
|
289
|
+
private onConnectRoomFn: () => void = this.onConnectRoom.bind(this);
|
|
258
290
|
// private onUserJoinedOrLeftRoomFn: Function = this.onUserJoinedOrLeftRoom.bind(this);
|
|
259
291
|
// private onPeerConnectFn: (id) => void = this.onPeerConnect.bind(this);
|
|
260
292
|
// private onPeerReceiveCallFn: (call) => void = this.onPeerReceivingCall.bind(this);
|
|
261
293
|
// private _connectionPeerIdMap : Map<string, string> = new Map();
|
|
262
294
|
|
|
263
295
|
enable() {
|
|
264
|
-
if (this._enabled) return;
|
|
296
|
+
if (this._disposed || this._enabled) return;
|
|
265
297
|
this._enabled = true;
|
|
266
298
|
this.context.connection.beginListen(RoomEvents.JoinedRoom, this.onConnectRoomFn);
|
|
267
299
|
// this.context.connection.beginListen(RoomEvents.UserJoinedRoom, this.onUserJoinedOrLeftRoomFn);
|
|
@@ -286,13 +318,19 @@ export class PeerHandle extends EventDispatcher<any> {
|
|
|
286
318
|
// };
|
|
287
319
|
|
|
288
320
|
private setupPeer() {
|
|
289
|
-
if (!this.context.connection.connectionId) return;
|
|
321
|
+
if (this._disposed || !this.context.connection.connectionId) return;
|
|
290
322
|
if (this._enabledPeer) return;
|
|
291
323
|
this._enabledPeer = true;
|
|
292
324
|
if (!this._peer) {
|
|
293
325
|
const peerId = this.getMyPeerId();
|
|
294
|
-
if (peerId)
|
|
295
|
-
|
|
326
|
+
if (peerId) {
|
|
327
|
+
const peer = getPeerjsInstance(peerId);
|
|
328
|
+
if (this._disposed) {
|
|
329
|
+
peer.destroy();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
this._peer = peer;
|
|
333
|
+
}
|
|
296
334
|
else console.error("Failed to setup peerjs because we dont have a connection id", this.context.connection.connectionId);
|
|
297
335
|
}
|
|
298
336
|
if (this._enabled)
|
|
@@ -414,6 +452,7 @@ export class PeerHandle extends EventDispatcher<any> {
|
|
|
414
452
|
});
|
|
415
453
|
|
|
416
454
|
call.on("stream", () => {
|
|
455
|
+
if (this._disposed) return;
|
|
417
456
|
if (debug) console.log("Received stream for call", call.metadata);
|
|
418
457
|
// workaround for https://github.com/peers/peerjs/issues/636
|
|
419
458
|
let intervalCounter = 0;
|
|
@@ -423,9 +462,15 @@ export class PeerHandle extends EventDispatcher<any> {
|
|
|
423
462
|
if (debug) console.warn("Close call because stream is not active", call.metadata);
|
|
424
463
|
intervalCounter += 1;
|
|
425
464
|
clearInterval(closeInterval);
|
|
465
|
+
this._callIntervals.delete(closeInterval);
|
|
426
466
|
handle.close();
|
|
427
467
|
}
|
|
428
468
|
}, 2000);
|
|
469
|
+
this._callIntervals.add(closeInterval);
|
|
470
|
+
call.on("close", () => {
|
|
471
|
+
clearInterval(closeInterval);
|
|
472
|
+
this._callIntervals.delete(closeInterval);
|
|
473
|
+
});
|
|
429
474
|
});
|
|
430
475
|
}
|
|
431
476
|
return handle;
|
|
@@ -10,7 +10,7 @@ export declare type OffscreenCanvasExt = OffscreenCanvas & {
|
|
|
10
10
|
|
|
11
11
|
// REMOVE once usage of browsers that don't support OffscreenCanvas is low
|
|
12
12
|
// Shim for missing OffscreenCanvas in iOS 16.x
|
|
13
|
-
if (typeof globalThis !== undefined && !('OffscreenCanvas' in globalThis)) {
|
|
13
|
+
if (typeof globalThis !== "undefined" && !('OffscreenCanvas' in globalThis)) {
|
|
14
14
|
// @ts-ignore
|
|
15
15
|
globalThis['OffscreenCanvas'] = class OffscreenCanvas {
|
|
16
16
|
canvas: HTMLCanvasElement;
|
|
@@ -120,7 +120,14 @@ export class GenerateMeshBVHWorker extends WorkerBase {
|
|
|
120
120
|
|
|
121
121
|
}, transferable.map( arr => arr.buffer ).filter( v => ( typeof SharedArrayBuffer === 'undefined' ) || ! ( v instanceof SharedArrayBuffer ) ) );
|
|
122
122
|
|
|
123
|
-
} )
|
|
123
|
+
} ).finally( () => {
|
|
124
|
+
|
|
125
|
+
// Pooled workers outlive each job. Both handlers capture the job scope,
|
|
126
|
+
// including its geometry, even after the promise has settled.
|
|
127
|
+
worker.onmessage = null;
|
|
128
|
+
worker.onerror = null;
|
|
129
|
+
|
|
130
|
+
} );
|
|
124
131
|
|
|
125
132
|
}
|
|
126
133
|
|