Needle Engine

Changes between version 3.5.13-beta.1 and 3.6.0-alpha
Files changed (24) hide show
  1. plugins/vite/defines.js +2 -4
  2. plugins/vite/dependency-watcher.js +14 -0
  3. plugins/vite/index.js +26 -0
  4. plugins/vite/poster-client.js +1 -1
  5. plugins/vite/reload.js +1 -1
  6. src/engine/api.ts +1 -1
  7. src/engine-components/CameraUtils.ts +12 -5
  8. src/engine-components/Component.ts +1 -0
  9. src/engine/debug/debug_overlay.ts +17 -8
  10. src/engine/engine_constants.ts +13 -3
  11. src/engine/engine_context_registry.ts +12 -1
  12. src/engine/engine_context.ts +84 -19
  13. src/engine/engine_element_attributes.ts +16 -1
  14. src/engine/engine_element_loading.ts +1 -0
  15. src/engine/engine_element.ts +85 -67
  16. src/engine/engine_license.ts +10 -5
  17. src/engine/engine_mainloop_utils.ts +4 -0
  18. src/engine/engine_physics_rapier.ts +1 -1
  19. src/engine/engine_utils.ts +63 -1
  20. src/needle-engine.ts +0 -8
  21. src/engine-components/OrbitControls.ts +5 -3
  22. src/engine-components/SceneSwitcher.ts +12 -11
  23. src/engine-components/Skybox.ts +87 -28
  24. src/engine/engine_element_extras.ts +11 -0
plugins/vite/defines.js CHANGED
@@ -25,10 +25,8 @@
25
25
  config(viteConfig) {
26
26
  // console.log("Update vite defines -------------------------------------------");
27
27
  if (!viteConfig.define) viteConfig.define = {};
28
- viteConfig.define.NEEDLE_ENGINE_META = {
29
- version: tryGetNeedleEngineVersion(),
30
- generator: needleEngineConfig?.generator,
31
- }
28
+ viteConfig.define.NEEDLE_ENGINE_VERSION = "\"" + tryGetNeedleEngineVersion() + "\"";
29
+ viteConfig.define.NEEDLE_ENGINE_GENERATOR = "\"" + needleEngineConfig.generator + "\"";
32
30
 
33
31
  if (useRapier && userSettings?.useRapier !== true) {
34
32
  const meta = loadConfig();
plugins/vite/dependency-watcher.js CHANGED
@@ -117,6 +117,20 @@
117
117
  // make sure the dependency is installed
118
118
  const depPath = path.join(projectDir, "node_modules", key);
119
119
  if (!existsSync(depPath)) {
120
+ const val = packageJson.dependencies[key];
121
+ if (val.startsWith("file:")) {
122
+ // check if the local path exists:
123
+ const dirPath = val.substr(5);
124
+ // check first the value in case it's a absolute path
125
+ if (!existsSync(dirPath)) {
126
+ // then check concatenated with the project dir in case it's a relative path
127
+ const relPath = path.join(projectDir, val.substr(5));
128
+ if (!existsSync(relPath)) {
129
+ // if neither directories exist then the local path is invalid and we ignore it
130
+ continue;
131
+ }
132
+ }
133
+ }
120
134
  log("Dependency not installed", key)
121
135
  return true;
122
136
  }
plugins/vite/index.js CHANGED
@@ -1,17 +1,43 @@
1
1
  import { needleDefines } from "./defines.js";
2
+ export { needleDefines } from "./defines.js";
3
+
2
4
  import { needleBuild } from "./build.js";
5
+ export { needleBuild } from "./build.js";
6
+
3
7
  import { needleMeta } from "./meta.js"
8
+ export { needleMeta } from "./meta.js"
9
+
4
10
  import { needlePoster } from "./poster.js"
11
+ export { needlePoster } from "./poster.js"
12
+
5
13
  import { needleReload } from "./reload.js"
14
+ export { needleReload } from "./reload.js"
15
+
6
16
  import { needleDrop } from "./drop.js";
17
+ export { needleDrop } from "./drop.js";
18
+
7
19
  import { editorConnection } from "./editor-connection.js";
20
+ export { editorConnection } from "./editor-connection.js";
21
+
8
22
  import { needleCopyFiles } from "./copyfiles.js";
23
+ export { needleCopyFiles } from "./copyfiles.js";
24
+
9
25
  import { needleViteAlias } from "./alias.js";
26
+ export { needleViteAlias } from "./alias.js";
27
+
10
28
  import { needleTransformCodegen } from "./transform-codegen.js";
29
+ export { needleTransformCodegen } from "./transform-codegen.js";
30
+
11
31
  import { needleLicense } from "./license.js";
32
+ export { needleLicense } from "./license.js";
33
+
12
34
  import { needlePeerjs } from "./peer.js";
35
+ export { needlePeerjs } from "./peer.js";
36
+
13
37
  import { needleDependencyWatcher } from "./dependency-watcher.js";
38
+ export { needleDependencyWatcher } from "./dependency-watcher.js";
14
39
 
40
+
15
41
  export * from "./gzip.js";
16
42
  export * from "./config.js";
17
43
 
plugins/vite/poster-client.js CHANGED
@@ -1,6 +1,6 @@
1
1
 
2
2
  async function generatePoster() {
3
- const { screenshot } = await import("@needle-tools/engine/src/engine/engine_utils_screenshot");
3
+ const { screenshot } = await import("@needle-tools/engine");
4
4
 
5
5
  try {
6
6
  const needleEngine = document.querySelector("needle-engine");
plugins/vite/reload.js CHANGED
@@ -303,7 +303,7 @@
303
303
  const originalFilePath = filePath;
304
304
 
305
305
  // default import path when outside package
306
- let importPath = "@needle-tools/engine/src/engine/engine_hot_reload";
306
+ let importPath = "@needle-tools/engine";
307
307
 
308
308
  if (filePath.includes("package~/engine")) {
309
309
  // convert local dev path to project node_modules path
src/engine/api.ts CHANGED
@@ -48,4 +48,4 @@
48
48
 
49
49
  export { InstancingUtil } from "./engine_instancing";
50
50
  export { validate, prefix } from "./engine_util_decorator"
51
- export { hasProLicense } from "./engine_license";
51
+ export { hasProLicense, hasIndieLicense } from "./engine_license";
src/engine-components/CameraUtils.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { OrbitControls } from "./OrbitControls";
2
- import { addNewComponent } from "../engine/engine_components";
2
+ import { addNewComponent, getOrAddComponent } from "../engine/engine_components";
3
3
  import { Object3D } from "three";
4
4
  import { ICamera } from "../engine/engine_types";
5
5
  import { RGBAColor } from "./js-extensions/RGBAColor";
@@ -7,8 +7,12 @@
7
7
  import { getCameraController } from "../engine/engine_camera";
8
8
  import { Camera } from "./Camera";
9
9
  import { NeedleEngineHTMLElement } from "../engine/engine_element";
10
+ import { getParam } from "../engine/engine_utils";
10
11
 
12
+ const debug = getParam("debugmissingcamera");
13
+
11
14
  ContextRegistry.registerCallback(ContextEvent.MissingCamera, (evt) => {
15
+ if (debug) console.warn("Creating missing camera")
12
16
  const scene = evt.context.scene;
13
17
  const srcId = "unknown";
14
18
 
@@ -46,11 +50,14 @@
46
50
  const cam = evt.context.mainCameraComponent;
47
51
  const cameraObject = cam?.gameObject;
48
52
  if (cameraObject) {
49
- const orbit = addNewComponent(cameraObject, new OrbitControls(), false) as OrbitControls;
53
+ const orbit = getOrAddComponent(cameraObject, OrbitControls) as OrbitControls;
50
54
  orbit.sourceId = "unknown";
51
- setTimeout(() => {
52
- orbit.fitCameraToObjects(evt.context.scene.children);
53
- }, 100);
55
+ const ctx = evt.context;
56
+ const fitCamera = () => {
57
+ ctx.pre_render_callbacks.splice(ctx.pre_render_callbacks.indexOf(fitCamera), 1);
58
+ orbit.fitCamera();
59
+ }
60
+ ctx.pre_render_callbacks.push(fitCamera);
54
61
  }
55
62
  else {
56
63
  console.warn("Missing camera object, can not add orbit controls")
src/engine-components/Component.ts CHANGED
@@ -518,6 +518,7 @@
518
518
  if (this.__destroyed) return;
519
519
  this.__destroyed = true;
520
520
  this.destroy?.call(this);
521
+ this.dispatchEvent(new CustomEvent("destroyed", { detail: this }));
521
522
  destroyComponentInstance(this as any);
522
523
  }
523
524
 
src/engine/debug/debug_overlay.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  const hide = getParam("noerrors");
9
9
 
10
10
  const arContainerClassName = "ar";
11
- const errorContainer: Map<HTMLElement, HTMLElement> = new Map();
11
+ const globalErrorContainerKey = "needle_engine_global_error_container";
12
12
  const locationRegex = new RegExp(" at .+\/(.+?\.ts)", "g");
13
13
 
14
14
  export enum LogType {
@@ -24,7 +24,7 @@
24
24
  export function makeErrorsVisibleForDevelopment() {
25
25
  if (hide) return;
26
26
  const isLocal = isLocalNetwork();
27
- if(debug) console.log("Is this a local network?", isLocal);
27
+ if (debug) console.log("Is this a local network?", isLocal);
28
28
  if (isLocal) {
29
29
  if (debug)
30
30
  console.log(window.location.hostname);
@@ -82,12 +82,17 @@
82
82
  if (Array.isArray(message)) {
83
83
  let newMessage = "";
84
84
  for (let i = 0; i < message.length; i++) {
85
- if (typeof message[i] === "object") continue;
85
+ let msg = message[i];
86
+ if (msg instanceof Error) {
87
+ msg = msg.message;
88
+ }
89
+ if (typeof msg === "object") continue;
86
90
  if (i > 0) newMessage += " ";
87
- newMessage += message[i];
91
+ newMessage += msg;
88
92
  }
89
93
  message = newMessage;
90
94
  }
95
+ if (message.length <= 0) return;
91
96
  showMessage(type, domElement, message);
92
97
  }
93
98
 
@@ -113,7 +118,7 @@
113
118
  returnMessageContainer(last as HTMLElement);
114
119
  }
115
120
  // truncate long messages before they go into the cache/set
116
- if(msg.length > 300) msg = msg.substring(0, 300) + "...";
121
+ if (msg.length > 300) msg = msg.substring(0, 300) + "...";
117
122
  if (currentMessages.has(msg)) return;
118
123
  currentMessages.add(msg);
119
124
  const msgcontainer = getMessageContainer(type, msg);
@@ -165,10 +170,15 @@
165
170
  `;
166
171
 
167
172
  function getLogsContainer(domElement: HTMLElement): HTMLElement {
168
- if (errorContainer.has(domElement)) {
169
- return errorContainer.get(domElement)!;
173
+ if (!globalThis[globalErrorContainerKey]) {
174
+ globalThis[globalErrorContainerKey] = new Map<HTMLElement, HTMLElement>();
175
+ }
176
+ const errorsMap = globalThis[globalErrorContainerKey] as Map<HTMLElement, HTMLElement>;
177
+ if (errorsMap.has(domElement)) {
178
+ return errorsMap.get(domElement)!;
170
179
  } else {
171
180
  const container = document.createElement("div");
181
+ errorsMap.set(domElement, container);
172
182
  container.setAttribute("data-needle_engine_debug_overlay", "");
173
183
  container.classList.add(arContainerClassName);
174
184
  container.classList.add("desktop");
@@ -190,7 +200,6 @@
190
200
  container.style.overflow = "auto";
191
201
  // container.style.border = "1px solid red";
192
202
  domElement.appendChild(container);
193
- errorContainer.set(domElement, container);
194
203
 
195
204
  const style = document.createElement("style");
196
205
  style.innerHTML = logsContainerStyles;
src/engine/engine_constants.ts CHANGED
@@ -1,7 +1,17 @@
1
- declare const NEEDLE_ENGINE_META: { version: string, generator: string };
2
- export const NEEDLE_ENGINE_VERSION = NEEDLE_ENGINE_META.version;
3
- export const NEEDLE_ENGINE_GENERATOR = NEEDLE_ENGINE_META.generator;
1
+ declare const NEEDLE_ENGINE_VERSION: string;
2
+ declare const NEEDLE_ENGINE_GENERATOR: string;
3
+ // https://esbuild.github.io/content-types/#direct-eval
4
+ (0, eval)(`
5
+ if(!globalThis["NEEDLE_ENGINE_VERSION"])
6
+ globalThis["NEEDLE_ENGINE_VERSION"] = "0.0.0";
7
+ if(!globalThis["NEEDLE_ENGINE_GENERATOR"])
8
+ globalThis["NEEDLE_ENGINE_GENERATOR"] = "unknown";
9
+ `);
4
10
 
11
+ export const VERSION = NEEDLE_ENGINE_VERSION;
12
+ export const GENERATOR = NEEDLE_ENGINE_GENERATOR;
13
+
14
+
5
15
  export const activeInHierarchyFieldName = "needle_isActiveInHierarchy";
6
16
  export const builtinComponentKeyName = "builtin_components";
7
17
  // It's easier to use a string than a symbol here because the symbol might not be the same when imported in other packages
src/engine/engine_context_registry.ts CHANGED
@@ -17,22 +17,30 @@
17
17
 
18
18
  export type ContextCallback = (evt: ContextEventArgs) => void | Promise<any> | IComponent;
19
19
 
20
+ /** Use to register to various Needle Engine context events and to get access to all current instances
21
+ * e.g. when being created in the DOM
22
+ * */
20
23
  export class ContextRegistry {
21
24
 
25
+ /** The currently active (rendering) Needle Engine context */
22
26
  static get Current(): IContext {
23
27
  return globalThis["NeedleEngine.Context.Current"]
24
28
  }
29
+ /** @internal */
25
30
  static set Current(ctx: IContext) {
26
31
  globalThis["NeedleEngine.Context.Current"] = ctx;
27
32
  }
28
33
 
34
+ /** All currently registered Needle Engine contexts. Do not modify */
29
35
  static Registered: IContext[] = [];
30
36
 
37
+ /** @internal Internal use only */
31
38
  static register(ctx: IContext) {
32
39
  this.Registered.push(ctx);
33
40
  this.dispatchCallback(ContextEvent.ContextRegistered, ctx);
34
41
  }
35
42
 
43
+ /** @internal Internal use only */
36
44
  static unregister(ctx: IContext) {
37
45
  const index = this.Registered.indexOf(ctx);
38
46
  if (index === -1) return;
@@ -53,6 +61,7 @@
53
61
  this._callbacks[evt].splice(index, 1);
54
62
  }
55
63
 
64
+ /** @internal */
56
65
  static dispatchCallback(evt: ContextEvent, context: IContext) {
57
66
  if (!this._callbacks[evt]) return true;
58
67
  const args = { event: evt, context }
@@ -70,4 +79,6 @@
70
79
  static addContextDestroyedCallback(callback: ContextCallback) {
71
80
  this.registerCallback(ContextEvent.ContextDestroyed, callback);
72
81
  }
73
- }
82
+ }
83
+
84
+ export { ContextRegistry as NeedleEngine };
src/engine/engine_context.ts CHANGED
@@ -29,8 +29,8 @@
29
29
  import { destroy, foreachComponent } from './engine_gameobject';
30
30
  import { ContextEvent, ContextRegistry } from './engine_context_registry';
31
31
  import { delay } from './engine_utils';
32
- import { NEEDLE_ENGINE_VERSION } from './engine_constants';
33
- // import { createCameraWithOrbitControl } from '../engine-components/CameraUtils';
32
+ import { VERSION } from './engine_constants';
33
+ import { isDevEnvironment, LogType, showBalloonMessage } from './debug';
34
34
 
35
35
 
36
36
  const debug = utils.getParam("debugSetup");
@@ -96,7 +96,7 @@
96
96
 
97
97
  /** the needle engine version */
98
98
  get version() {
99
- return NEEDLE_ENGINE_VERSION;
99
+ return VERSION;
100
100
  }
101
101
 
102
102
  static get Current(): Context {
@@ -245,6 +245,7 @@
245
245
 
246
246
  private _sizeChanged: boolean = false;
247
247
  private _isCreated: boolean = false;
248
+ private _isCreating: boolean = false;
248
249
  private _isVisible: boolean = false;
249
250
 
250
251
  private _stats: Stats.default | null = stats ? Stats.default() : null;
@@ -262,7 +263,7 @@
262
263
  const params: WebGLRendererParameters = {
263
264
  antialias: true,
264
265
  };
265
-
266
+
266
267
  // get canvas already configured in the Needle Engine Web Component
267
268
  const canvas = args?.domElement?.shadowRoot?.querySelector("canvas");
268
269
  if (canvas) params.canvas = canvas;
@@ -361,19 +362,37 @@
361
362
  }
362
363
 
363
364
  async onCreate(buildScene?: (context: Context, loadingOptions?: LoadingOptions) => Promise<void>, opts?: LoadingOptions) {
364
- if (this._isCreated) {
365
- console.warn("Context already created");
366
- return null;
365
+ try {
366
+ this._isCreating = true;
367
+ const res = await this.internalOnCreate(buildScene, opts);
368
+ return res;
367
369
  }
368
- this._isCreated = true;
369
- await delay(1);
370
- return this.internalOnCreate(buildScene, opts);
370
+ finally {
371
+ this._isCreating = false;
372
+ }
371
373
  }
372
374
 
373
- onDestroy() {
374
- if (!this._isCreated) return;
375
- this._isCreated = false;
376
- destroy(this.scene, true);
375
+ /** Will destroy all scenes and objects in the scene
376
+ */
377
+ clear() {
378
+ if (this.scene?.children.length) {
379
+ destroy(this.scene, true, true);
380
+ this.scene = new Scene();
381
+ }
382
+ if (this.renderer) {
383
+ this.renderer.setClearAlpha(0);
384
+ this.renderer.clear();
385
+ }
386
+ }
387
+
388
+ dispose() {
389
+ this.internalOnDestroy();
390
+ }
391
+
392
+ /**@deprecated use dispose() */
393
+ onDestroy() { this.internalOnDestroy(); }
394
+ private internalOnDestroy() {
395
+ this.clear();
377
396
  this.renderer?.setAnimationLoop(null);
378
397
  if (!this.isManagedExternally) {
379
398
  this.renderer?.dispose();
@@ -537,6 +556,13 @@
537
556
 
538
557
  private async internalOnCreate(buildScene?: (context: Context, opts?: LoadingOptions) => Promise<void>, opts?: LoadingOptions) {
539
558
 
559
+ this.clear();
560
+ // stop the animation loop if its running during creation
561
+ // since we do not want to start enabling scripts etc before they are deserialized
562
+ this.renderer?.setAnimationLoop(null);
563
+
564
+ await delay(1);
565
+
540
566
  Context.Current = this;
541
567
  await ContextRegistry.dispatchCallback(ContextEvent.ContextCreationStart, this);
542
568
 
@@ -646,18 +672,56 @@
646
672
  this.domElement.appendChild(this._stats.dom);
647
673
  }
648
674
 
649
- this.renderer.setAnimationLoop(this.render.bind(this));
650
-
651
675
  if (debug)
652
676
  logHierarchy(this.scene, true);
653
677
 
678
+ this._isCreating = false;
679
+ this.restartRenderLoop();
680
+ this._dispatchReadyAfterFrame = true;
654
681
  return ContextRegistry.dispatchCallback(ContextEvent.ContextCreated, this);
655
682
  }
656
683
 
657
684
  private _accumulatedTime = 0;
658
685
  private _framerateClock = new Clock();
686
+ private _dispatchReadyAfterFrame = false;
659
687
 
660
- private render(_, frame: XRFrame) {
688
+ /** Sets the animation loop.
689
+ * Can not be done while creating the context or when disposed
690
+ **/
691
+ public restartRenderLoop(): boolean {
692
+ if (!this.renderer) {
693
+ console.error("Can not start render loop without renderer");
694
+ return false;
695
+ }
696
+ if (this._isCreating) {
697
+ console.warn("Can not start render loop while creating context");
698
+ return false;
699
+ }
700
+ const renderMethod = this.render.bind(this);
701
+ this.renderer.setAnimationLoop(renderMethod);
702
+ return true;
703
+ }
704
+
705
+ private render(_, frame: XRFrame | null) {
706
+ if (isDevEnvironment() || debug || looputils.hasNewScripts()) {
707
+ try {
708
+ this.internalRender(_, frame);
709
+ }
710
+ catch (err) {
711
+ if ((isDevEnvironment() || debug) && err instanceof Error)
712
+ showBalloonMessage("Exception during render-loop.<br/>See console for details.", LogType.Error);
713
+ console.error(err);
714
+ console.warn("Stopping render loop due to error")
715
+ this.renderer.setAnimationLoop(null);
716
+ this.domElement.dispatchEvent(new CustomEvent("error", { detail: err }));
717
+ }
718
+ }
719
+ else {
720
+ this.internalRender(_, frame);
721
+ }
722
+ }
723
+
724
+ private internalRender(_, frame: XRFrame | null) {
661
725
  this._xrFrame = frame;
662
726
 
663
727
  this._currentFrameEvent = FrameEvent.Undefined;
@@ -750,7 +814,7 @@
750
814
  }
751
815
  this.physics.engine.postStep();
752
816
  }
753
-
817
+
754
818
  if (this.onHandlePaused()) return;
755
819
 
756
820
  if (this.isVisibleToUser) {
@@ -812,7 +876,8 @@
812
876
 
813
877
  this._stats?.end();
814
878
 
815
- if (this.time.frame === 1) {
879
+ if (this._dispatchReadyAfterFrame) {
880
+ this._dispatchReadyAfterFrame = false;
816
881
  this.domElement.dispatchEvent(new CustomEvent("ready"));
817
882
  }
818
883
  }
src/engine/engine_element_attributes.ts CHANGED
@@ -22,6 +22,9 @@
22
22
  "dracoDecoderType"?: string;
23
23
  /** override the default ktx2 decoder path */
24
24
  "ktx2DecoderPath"?: string;
25
+
26
+ addEventListener(event: "ready", callback: (event: CustomEvent) => void): void;
27
+ addEventListener(event: "error", callback: (event: CustomEvent) => void): void;
25
28
  }
26
29
 
27
30
  type LoadingAttributes = {
@@ -38,5 +41,17 @@
38
41
  "loading-text-color"?: string,
39
42
  }
40
43
 
44
+ type SkyboxAttributes = {
45
+ /** URL to .exr, .hdr, .png, .jpg to be used as skybox */
46
+ "skybox-image"?: string,
47
+ /** URL to .exr, .hdr, .png, .jpg to be used as skybox */
48
+ "environment-image"?: string,
49
+ }
41
50
 
42
- export type NeedleEngineAttributes = MainAttributes & LoadingAttributes & Partial<Omit<HTMLElement, "style">>;
51
+
52
+ export type NeedleEngineAttributes =
53
+ MainAttributes
54
+ & Partial<Omit<HTMLElement, "style">>
55
+ & LoadingAttributes
56
+ & SkyboxAttributes
57
+ ;
src/engine/engine_element_loading.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  onLoadingBegin(message?: string);
20
20
  onLoadingUpdate(progress: LoadingProgressArgs | number, message?: string);
21
21
  onLoadingFinished(message?: string);
22
+ setMessage(string:string);
22
23
  }
23
24
 
24
25
  let currentFileProgress = 0;
src/engine/engine_element.ts CHANGED
@@ -1,18 +1,16 @@
1
- import { Context, build_scene_functions, LoadingOptions, LoadingProgressArgs } from "./engine_setup";
1
+ import { Context, LoadingProgressArgs } from "./engine_setup";
2
2
  import { AROverlayHandler, arContainerClassName } from "./engine_element_overlay";
3
3
  import { GameObject } from "../engine-components/Component";
4
- import { processNewScripts } from "./engine_mainloop_utils";
5
4
  import { calculateProgress01, EngineLoadingView, ILoadingViewHandler } from "./engine_element_loading";
6
- import { delay, getParam } from "./engine_utils";
5
+ import { getParam } from "./engine_utils";
7
6
  import { setDracoDecoderPath, setDracoDecoderType, setKtx2TranscoderPath } from "./engine_loaders";
8
7
  import { getLoader, registerLoader } from "../engine/engine_gltf";
9
8
  import { NeedleGltfLoader } from "./engine_scenetools";
10
9
  import { INeedleEngineComponent } from "./engine_types";
11
10
  import { isLocalNetwork } from "./engine_networking_utils";
12
11
  import { showBalloonWarning } from "./debug";
13
- import { NeedleEngineAttributes } from "./engine_element_attributes";
12
+ import { destroy } from "./engine_gameobject";
14
13
 
15
-
16
14
  //
17
15
  // registering loader here too to make sure it's imported when using engine via vanilla js
18
16
  registerLoader(NeedleGltfLoader);
@@ -40,7 +38,7 @@
40
38
 
41
39
  // https://developers.google.com/web/fundamentals/web-components/customelements
42
40
  /** <needle-engine> web component. See @type {NeedleEngineAttributes} attributes for supported attributes
43
- * @type {NeedleEngineAttributes}
41
+ * @type {import ("./engine_element_attributes").NeedleEngineAttributes}
44
42
  */
45
43
  export class NeedleEngineHTMLElement extends HTMLElement implements INeedleEngineComponent {
46
44
 
@@ -95,6 +93,7 @@
95
93
  this._context = new Context({ domElement: this });
96
94
  // TODO: do we want to rename this event?
97
95
  this.addEventListener("ready", this.onReady);
96
+ this.addEventListener("error", this.onError);
98
97
  }
99
98
 
100
99
  async connectedCallback() {
@@ -104,23 +103,32 @@
104
103
  }
105
104
 
106
105
  this.onSetupDesktop();
107
- const src = this.getAttribute("src") ?? globalThis["needle:codegen_files"];
106
+
107
+ if (!this.getAttribute("src")) {
108
+ const glob = globalThis["needle:codegen_files"];
109
+ if (glob)
110
+ this.setAttribute("src", glob);
111
+ return;
112
+ }
113
+
114
+ const src = this.getAttribute("src");
108
115
  if (src && src.length > 0) {
109
- await this.onLoad(src, true);
116
+ await this.onLoad();
110
117
  }
111
118
  this.onSetupDesktop();
112
119
  }
113
120
 
114
121
  disconnectedCallback() {
115
- this._context?.onDestroy();
122
+ if(debug) console.warn("<needle-engine> disconnected");
123
+ this._context?.dispose();
116
124
  }
117
125
 
118
126
  attributeChangedCallback(name: string, _oldValue: string, newValue: string) {
119
127
  // console.log(name, oldValue, newValue);
120
128
  switch (name) {
121
129
  case "src":
122
- if (debug) console.log("src changed to type:", typeof newValue, ", from \"", _oldValue, "\" to \"", newValue)
123
- this.onLoad(newValue, false);
130
+ if (debug) console.warn("src changed to type:", typeof newValue, ", from \"", _oldValue, "\" to \"", newValue, "\"")
131
+ this.onLoad();
124
132
  // this._watcher?.onSourceChanged(newValue);
125
133
  break;
126
134
  case "hash":
@@ -154,37 +162,31 @@
154
162
  }
155
163
  }
156
164
 
157
- private _isCurrentlySettingsSourceAttribute = false;
165
+ private _loadId: number = 0;
166
+ private _lastSourceFiles: Array<string> | null = null;
158
167
 
159
- private async onLoad(src: string | null | string[], isStartup: boolean) {
160
- if (this._isCurrentlySettingsSourceAttribute) {
168
+ private async onLoad() {
169
+
170
+ if (!this._context) {
171
+ console.error("Needle Engine: Context not initialized");
161
172
  return;
162
173
  }
163
- if (src === this._previousSrc) {
174
+
175
+ const filesToLoad = this.getSourceFiles();
176
+ if (filesToLoad === null || filesToLoad === undefined || filesToLoad.length <= 0) {
177
+ if (debug) console.warn("Clear scene", filesToLoad);
178
+ this._context.clear();
164
179
  return;
165
180
  }
166
- if (!src || !src.length) {
167
- console.error("Needle Engine: No source specified", src);
181
+ else if (!this.checkIfSourceHasChanged(filesToLoad, this._lastSourceFiles)) {
168
182
  return;
169
183
  }
170
- if (!this._context) {
171
- console.error("Needle Engine: Context not initialized");
172
- return;
173
- }
174
184
 
175
-
176
- this._previousSrc = src;
177
-
178
- // Set the source attribute so codegen doesnt try to re-assign it again and we communicate to the outside which root files are being loaded
179
- this._isCurrentlySettingsSourceAttribute = true;
180
- this.setAttribute("src", src?.toString());
181
- this._isCurrentlySettingsSourceAttribute = false;
182
-
185
+ this._lastSourceFiles = filesToLoad;
186
+ const loadId = ++this._loadId;
183
187
  const alias = this.getAttribute("alias");
184
188
  this.classList.add("loading");
185
- if (debug) console.log("Needle Engine: Begin loading", alias ?? "");
186
189
 
187
-
188
190
  // Loading start events
189
191
  const allowOverridingDefaultLoading = true;
190
192
  // default loading can be overriden by calling preventDefault in the onload start event
@@ -199,23 +201,11 @@
199
201
  this._loadingView = new EngineLoadingView(this);
200
202
  if (useDefaultLoading)
201
203
  this._loadingView?.onLoadingBegin("begin load");
204
+ if (debug)
205
+ console.warn("--------------", loadId, "Needle Engine: Begin loading", alias ?? "", filesToLoad);
202
206
  this.onBeforeBeginLoading();
203
207
 
204
- if (debug) console.log(src);
205
- let filesToLoad: Array<string>;
206
- // When using globalThis the src is an array already
207
- if (Array.isArray(src)) {
208
- filesToLoad = src;
209
- }
210
- // When assigned from codegen the src is a stringified array
211
- else if (src.startsWith("[") && src.endsWith("]")) {
212
- filesToLoad = JSON.parse(src);
213
- }
214
- // src.toString for an array produces a comma separated list
215
- else if (src.includes(",")) {
216
- filesToLoad = src.split(",");
217
- }
218
- else filesToLoad = [src];
208
+ if (debug) console.log(filesToLoad);
219
209
 
220
210
  let loadFunction: any = null;
221
211
 
@@ -225,6 +215,7 @@
225
215
  if (ctx.hash) hash = Number.parseInt(ctx.hash) ?? 0;
226
216
  const loader = getLoader();
227
217
  for (let i = 0; i < filesToLoad.length; i++) {
218
+ if (this._loadId !== loadId) return;
228
219
  const url = filesToLoad[i];
229
220
  // Check if the url contains glb or gltf, this is mainly to handle legacy projects/codegen files were the src attribute was a function name
230
221
  if (!url.includes(".glb") && !url.includes(".gltf")) {
@@ -261,54 +252,81 @@
261
252
  this.dispatchEvent(new CustomEvent("progress", progressEventArgs));
262
253
  });
263
254
  const obj = res?.scene;
264
- if (obj) GameObject.add(obj, ctx.scene, ctx);
255
+ if (obj) {
256
+ // if the loading ID changed but the scene has already been loaded we want to dispose it
257
+ if (this._loadId !== loadId) {
258
+ destroy(obj, true, true)
259
+ return;
260
+ }
261
+ GameObject.add(obj, ctx.scene, ctx);
262
+ }
265
263
  }
266
- if (!isStartup)
267
- processNewScripts(ctx);
264
+ if (this._loadId !== loadId) return;
268
265
  };
269
266
  }
270
267
  if (!loadFunction) {
271
- console.error("Needle Engine: No files to load", src);
268
+ console.error("Needle Engine: No files to load", filesToLoad);
272
269
  return;
273
270
  }
274
-
275
271
  const currentHash = this.getAttribute("hash");
276
272
  if (currentHash !== null && currentHash !== undefined)
277
273
  this._context.hash = currentHash;
278
274
  this._context.alias = alias;
279
- const contextWasCreated = this._context.isCreated;
280
- if (!contextWasCreated) {
281
- await this._context.onCreate(loadFunction);
282
- }
283
- else {
284
- await loadFunction(this._context);
285
- }
286
-
287
-
288
-
289
- console.log("Needle Engine: finished loading", alias ?? "")
275
+ await this._context.onCreate(loadFunction);
276
+ if (this._loadId !== loadId) return;
277
+ if (debug)
278
+ console.warn("--------------", loadId, "Needle Engine: finished loading", alias ?? "", filesToLoad);
290
279
  this._loadingProgress01 = 1;
291
280
  if (useDefaultLoading) {
292
281
  this._loadingView?.onLoadingUpdate(1, "creating scene");
293
- if (contextWasCreated) this.onReady();
294
282
  }
295
283
  this.classList.remove("loading");
296
284
  this.classList.add("loading-finished");
297
- if (debug)
298
- console.log("Needle Engine: finished loading", alias ?? "");
299
285
  this.dispatchEvent(new CustomEvent("loadfinished", {
300
286
  detail: {
301
287
  context: this._context,
302
- src: alias ?? src
288
+ src: alias
303
289
  }
304
290
  }));
305
-
306
291
  }
307
292
 
308
293
  /** called by the context when the first frame has been rendered */
309
294
  private onReady = () => this._loadingView?.onLoadingFinished();
295
+ private onError = () => this._loadingView?.setMessage("Loading failed!");
310
296
 
297
+ private getSourceFiles(): Array<string> | null {
298
+ const src: string | null | string[] = this.getAttribute("src");
299
+ if (!src) return null;
311
300
 
301
+ let filesToLoad: Array<string>;
302
+ // When using globalThis the src is an array already
303
+ if (Array.isArray(src)) {
304
+ filesToLoad = src;
305
+ }
306
+ // When assigned from codegen the src is a stringified array
307
+ else if (src.startsWith("[") && src.endsWith("]")) {
308
+ filesToLoad = JSON.parse(src);
309
+ }
310
+ // src.toString for an array produces a comma separated list
311
+ else if (src.includes(",")) {
312
+ filesToLoad = src.split(",");
313
+ }
314
+ else filesToLoad = [src];
315
+ return filesToLoad;
316
+ }
317
+
318
+ private checkIfSourceHasChanged(current: Array<string> | null, previous: Array<string> | null): boolean {
319
+ if (current?.length !== previous?.length) return true;
320
+ if (current == null && previous !== null) return true;
321
+ if (current !== null && previous == null) return true;
322
+ if (current !== null && previous !== null) {
323
+ for (let i = 0; i < current?.length; i++) {
324
+ if (current[i] !== previous[i]) return true;
325
+ }
326
+ }
327
+ return false;
328
+ }
329
+
312
330
  private registerEventFromAttribute(eventName: string, code: string) {
313
331
  if (typeof code === "string" && code.length > 0) {
314
332
  // indirect eval https://esbuild.github.io/content-types/#direct-eval
src/engine/engine_license.ts CHANGED
@@ -53,7 +53,12 @@
53
53
  const licenseDelay = 200;
54
54
 
55
55
  function onNonCommercialVersionDetected(ctx: IContext) {
56
- setTimeout(() => insertNonCommercialUseHint(ctx), 2000);
56
+ logNonCommercialUse();
57
+ ctx.domElement.addEventListener("ready", () => {
58
+ setTimeout(()=>{
59
+ insertNonCommercialUseHint(ctx);
60
+ }, 1000);
61
+ });
57
62
  sendUsageMessageToAnalyticsBackend();
58
63
  }
59
64
 
@@ -71,9 +76,6 @@
71
76
  }
72
77
  }, 100);
73
78
 
74
- if (!hasCommercialLicense())
75
- logNonCommercialUse();
76
-
77
79
  let svg = `<img class="logo" src="${logoSVG}" style="width: 40px; height: 40px; margin-right: 2px; vertical-align: middle; margin-bottom: 2px;"/>`;
78
80
  const logoElement = document.createElement("div");
79
81
  logoElement.innerHTML = svg;
@@ -101,8 +103,11 @@
101
103
  }, removeDelay);
102
104
 
103
105
  }
104
-
106
+ let lastLogTime = 0;
105
107
  async function logNonCommercialUse(_logo?: string) {
108
+ const now = Date.now();
109
+ if (now - lastLogTime < 2000) return;
110
+ lastLogTime = now;
106
111
  const logo = "data:image/webp;base64,UklGRrABAABXRUJQVlA4WAoAAAAQAAAAHwAAHwAAQUxQSKEAAAARN6CmbSM4WR7vdARON11EBDq3fLiNbVtVzpMCPlKAEzsx0Y/x+Ovuv4dn0EFE/ydAvz6YggXzgh5sVgXM/zOC/4sii7qgGvB5N7hmuQYwkvazWAu1JPW41FXSHq6pnaQWvqYH18Fc0j1hO/BFTtIeSBlJi5w6qIIO7IOrwhFsB2Yxukif0FTRLpXswHR8MxbslKe9VZsn/Ub5C7YFOpqSTABWUDgg6AAAAFAGAJ0BKiAAIAA+7VyoTqmkpCI3+qgBMB2JbACdMt69DwMIQBLhkTO6XwY00UEDK6cNIDnuNibPf0EgAP7Y1myuiQHLDsF/0h5unrGh6WAbv7aegg2ZMd3uRKfT/3SJztcaujYfTvMXspfCTmYcoO6a+vhC3ss4M8uM58t4siiu59I4aOl59e9Sr6xoxYlHf2v+NnBNpJYeJf8jABQAId/PXuBkLEFkiCucgSGEcfhvajql/j3reCGl0M5/9gQWy7ayNPs+wlvIxFnNfSlfuND4CZOCyxOHhRqOmHN4ULHo3tCSrUNvgAA=";
107
112
  const style = `
108
113
  font-size: 18px;
src/engine/engine_mainloop_utils.ts CHANGED
@@ -14,6 +14,10 @@
14
14
  // so we use this copy buffer
15
15
  const new_scripts_buffer: any[] = [];
16
16
 
17
+ export function hasNewScripts() {
18
+ return new_scripts_buffer.length > 0;
19
+ }
20
+
17
21
  export function processNewScripts(context: IContext) {
18
22
  if (context.new_scripts.length <= 0) return;
19
23
  if (debug)
src/engine/engine_physics_rapier.ts CHANGED
@@ -38,8 +38,8 @@
38
38
 
39
39
  let RAPIER: undefined | any = undefined;
40
40
  declare const NEEDLE_USE_RAPIER: boolean;
41
+ globalThis["NEEDLE_USE_RAPIER"] = globalThis["NEEDLE_USE_RAPIER"] || true;
41
42
 
42
-
43
43
  if (NEEDLE_USE_RAPIER) {
44
44
  ContextRegistry.registerCallback(ContextEvent.ContextCreationStart, evt => {
45
45
  if (debugPhysics)
src/engine/engine_utils.ts CHANGED
@@ -448,4 +448,66 @@
448
448
  const res = (await fetch("https://api.db-ip.com/v2/free/self").catch(() => null))!;
449
449
  const json = await res.json() as IpAndLocation;
450
450
  return json;
451
- }
451
+ }
452
+
453
+
454
+
455
+
456
+
457
+ declare type AttributeChangeCallback = (value: string | null) => void;
458
+ declare type HtmlElementExtra = {
459
+ observer: MutationObserver,
460
+ attributeChangedListeners: Map<string, Array<AttributeChangeCallback>>,
461
+ }
462
+ const mutationObserverMap = new WeakMap<HTMLElement, HtmlElementExtra>();
463
+
464
+ /** Register a callback when a attribute changes */
465
+ export function addAttributeChangeCallback(domElement: HTMLElement, name: string, callback: AttributeChangeCallback) {
466
+ if (!mutationObserverMap.get(domElement)) {
467
+ const observer = new MutationObserver((mutations) => {
468
+ handleMutations(domElement, mutations);
469
+ });
470
+ mutationObserverMap.set(domElement, {
471
+ observer,
472
+ attributeChangedListeners: new Map<string, Array<AttributeChangeCallback>>(),
473
+ });
474
+ observer.observe(domElement, { attributes: true });
475
+ }
476
+
477
+ const listeners = mutationObserverMap.get(domElement)!.attributeChangedListeners;
478
+ if (!listeners.has(name)) {
479
+ listeners.set(name, []);
480
+ }
481
+ listeners.get(name)!.push(callback);
482
+ };
483
+ export function removeAttributeChangeCallback(domElement: HTMLElement, name: string, callback: AttributeChangeCallback) {
484
+ if (!mutationObserverMap.get(domElement)) return;
485
+ const listeners = mutationObserverMap.get(domElement)!.attributeChangedListeners;
486
+ if (!listeners.has(name)) return;
487
+ const arr = listeners.get(name);
488
+ const index = arr!.indexOf(callback);
489
+ if (index === -1) return;
490
+ arr!.splice(index, 1);
491
+ if (arr!.length <= 0) {
492
+ listeners.delete(name);
493
+ const entry = mutationObserverMap.get(domElement);
494
+ entry?.observer.disconnect();
495
+ mutationObserverMap.delete(domElement);
496
+ }
497
+ }
498
+
499
+ function handleMutations(domElement: HTMLElement, mutations: Array<MutationRecord>) {
500
+ const listeners = mutationObserverMap.get(domElement)!.attributeChangedListeners;
501
+ for (const mut of mutations) {
502
+ if (mut.type === "attributes") {
503
+ const name = mut.attributeName!;
504
+ const value = domElement.getAttribute(name);
505
+ if (listeners.has(name)) {
506
+ for (const listener of listeners.get(name)!) {
507
+ listener(value);
508
+ }
509
+ }
510
+ }
511
+ }
512
+
513
+ }
src/needle-engine.ts CHANGED
@@ -1,8 +1,6 @@
1
1
  import { makeErrorsVisibleForDevelopment } from "./engine/debug/debug_overlay";
2
2
  makeErrorsVisibleForDevelopment();
3
3
 
4
- import { NEEDLE_ENGINE_GENERATOR, NEEDLE_ENGINE_VERSION } from "./engine/engine_constants";
5
-
6
4
  import "./engine/engine_element";
7
5
  import "./engine/engine_setup";
8
6
  import "./engine-components/CameraUtils"
@@ -11,7 +9,6 @@
11
9
  export * from "./engine-components/api";
12
10
  export * from "./engine-components-experimental/api";
13
11
 
14
-
15
12
  // make accessible for external javascript
16
13
  import { Context } from "./engine/engine_setup";
17
14
  const Needle = { Context: Context };
@@ -30,12 +27,7 @@
30
27
  import * as Components from "./engine-components/codegen/components";
31
28
  registerGlobal(Components);
32
29
 
33
- Needle["$meta"] = {
34
- version: NEEDLE_ENGINE_VERSION,
35
- generator: NEEDLE_ENGINE_GENERATOR
36
- };
37
30
 
38
-
39
31
  import { GameObject } from "./engine-components/Component";
40
32
  for (const method of Object.getOwnPropertyNames(GameObject)) {
41
33
  switch (method) {
src/engine-components/OrbitControls.ts CHANGED
@@ -352,9 +352,11 @@
352
352
 
353
353
  // Adapted from https://discourse.threejs.org/t/camera-zoom-to-fit-object/936/24
354
354
  // Slower but better implementation that takes bones and exact vertex positions into account: https://github.com/google/model-viewer/blob/04e900c5027de8c5306fe1fe9627707f42811b05/packages/model-viewer/src/three-components/ModelScene.ts#L321
355
- fitCameraToObjects(objects: Array<Object3D>, fitOffset: number = 1.1) {
355
+ /** Fits the camera to show the objects provided (defaults to the scene if no objects are passed in) */
356
+ fitCamera(objects?: Array<Object3D>, fitOffset: number = 1.1) {
356
357
  const camera = this._cameraObject as PerspectiveCamera;
357
358
  const controls = this._controls as ThreeOrbitControls | null;
359
+ if (!objects?.length) objects = this.context.scene.children;
358
360
 
359
361
  if (!camera || !controls) return;
360
362
 
@@ -366,7 +368,7 @@
366
368
  // we would get proper view-dependant fit.
367
369
  // Right now it's independent from where the camera is actually looking from,
368
370
  // and thus we're just getting some maximum that will work for sure.
369
-
371
+
370
372
  box.makeEmpty();
371
373
  for (const object of objects) {
372
374
  // ignore Box3Helpers
@@ -423,7 +425,7 @@
423
425
  this._haveAttachedKeyboardEvents = true;
424
426
  document.body.addEventListener("keydown", (e) => {
425
427
  if (e.code === "KeyF") {
426
- this.fitCameraToObjects(objects);
428
+ this.fitCamera(objects);
427
429
  }
428
430
  });
429
431
  }
src/engine-components/SceneSwitcher.ts CHANGED
@@ -1,24 +1,25 @@
1
1
  import { AssetReference } from "../engine/engine_addressables";
2
2
  import { InputEvents } from "../engine/engine_input";
3
3
  import { isLocalNetwork } from "../engine/engine_networking_utils";
4
- import { ContextEvent, ContextRegistry } from "../engine/engine_context_registry";
5
- import { getParam, isMobileDevice, setParamWithoutReload } from "../engine/engine_utils";
4
+ import { getParam, setParamWithoutReload } from "../engine/engine_utils";
6
5
  import { serializable } from "../engine/engine_serialization";
7
6
  import { Behaviour, GameObject } from "./Component";
7
+ import { registerObservableAttribute } from "../engine/engine_element_extras";
8
8
 
9
9
  const debug = getParam("debugsceneswitcher");
10
10
 
11
11
  const ENGINE_ELEMENT_SCENE_ATTRIBUTE_NAME = "scene";
12
+ registerObservableAttribute(ENGINE_ELEMENT_SCENE_ATTRIBUTE_NAME);
12
13
 
13
- ContextRegistry.registerCallback(ContextEvent.ContextRegistered, async _ => {
14
- // We need to defer import to not get issues with circular dependencies
15
- import("../engine/engine_element").then(res => {
16
- const webcomponent = res.NeedleEngineHTMLElement;
17
- if (debug) console.log("SceneSwitcher: registering scene attribute", webcomponent.observedAttributes);
18
- if (!webcomponent.observedAttributes.includes(ENGINE_ELEMENT_SCENE_ATTRIBUTE_NAME))
19
- webcomponent.observedAttributes.push(ENGINE_ELEMENT_SCENE_ATTRIBUTE_NAME);
20
- });
21
- });
14
+ // ContextRegistry.registerCallback(ContextEvent.ContextRegistered, async _ => {
15
+ // // We need to defer import to not get issues with circular dependencies
16
+ // import("../engine/engine_element").then(res => {
17
+ // const webcomponent = res.NeedleEngineHTMLElement;
18
+ // if (debug) console.log("SceneSwitcher: registering scene attribute", webcomponent.observedAttributes);
19
+ // if (!webcomponent.observedAttributes.includes(ENGINE_ELEMENT_SCENE_ATTRIBUTE_NAME))
20
+ // webcomponent.observedAttributes.push(ENGINE_ELEMENT_SCENE_ATTRIBUTE_NAME);
21
+ // });
22
+ // });
22
23
 
23
24
  const couldNotLoadScenePromise = Promise.resolve(false);
24
25
 
src/engine-components/Skybox.ts CHANGED
@@ -2,40 +2,82 @@
2
2
  import { Behaviour, GameObject } from "./Component";
3
3
  import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader.js';
4
4
  import { EXRLoader } from "three/examples/jsm/loaders/EXRLoader";
5
- import { EquirectangularRefractionMapping, sRGBEncoding, Texture, TextureLoader } from "three"
5
+ import { EquirectangularRefractionMapping, NeverDepth, sRGBEncoding, Texture, TextureLoader } from "three"
6
6
  import { syncField } from "../engine/engine_networking_auto";
7
7
  import { Camera } from "./Camera";
8
- import { getParam } from "../engine/engine_utils";
8
+ import { addAttributeChangeCallback, getParam, removeAttributeChangeCallback } from "../engine/engine_utils";
9
9
  import { ContextRegistry } from "../engine/engine_context_registry";
10
+ import { registerObservableAttribute } from "../engine/engine_element_extras";
11
+ import { type IContext } from "../engine/engine_types";
10
12
 
11
13
  const debug = getParam("debugskybox");
12
14
 
15
+ registerObservableAttribute("skybox-image");
16
+ registerObservableAttribute("environment-image");
17
+
18
+ function createRemoteSkyboxComponent(context: IContext, url: string, skybox: boolean, environment: boolean, attribute: "skybox-image" | "environment-image") {
19
+ const remote = new RemoteSkybox();
20
+ remote.url = url;
21
+ remote.allowDrop = false;
22
+ remote.allowNetworking = false;
23
+ remote.background = skybox;
24
+ remote.environment = environment;
25
+ GameObject.addComponent(context.scene, remote);
26
+ const urlChanged = newValue => {
27
+ if (typeof newValue !== "string") return;
28
+ if (debug) console.log(attribute, "CHANGED TO", newValue)
29
+ remote.setSkybox(newValue);
30
+ };
31
+ addAttributeChangeCallback(context.domElement, attribute, urlChanged);
32
+ remote.addEventListener("destroy", () => {
33
+ if (debug) console.log("Destroyed attribute remote skybox", attribute);
34
+ removeAttributeChangeCallback(context.domElement, attribute, urlChanged);
35
+ });
36
+ }
37
+
13
38
  ContextRegistry.addContextCreatedCallback((args) => {
14
39
  const context = args.context;
15
40
  const skyboxImage = context.domElement.getAttribute("skybox-image");
16
41
  const environmentImage = context.domElement.getAttribute("environment-image");
42
+
17
43
  if (skyboxImage) {
18
44
  if (debug) console.log("Creating remote skybox to load " + skyboxImage);
19
- const remote = new RemoteSkybox();
20
- remote.url = skyboxImage;
21
- remote.allowDrop = false;
22
- remote.environment = false;
23
- remote.background = true;
24
- GameObject.addComponent(context.scene, remote);
45
+ createRemoteSkyboxComponent(context, skyboxImage, true, false, "skybox-image");
25
46
  }
26
47
  if (environmentImage) {
27
- const remote = new RemoteSkybox();
28
- remote.url = environmentImage;
29
- remote.allowDrop = false;
30
- remote.environment = true;
31
- remote.background = false;
32
- GameObject.addComponent(context.scene, remote);
48
+ if (debug) console.log("Creating remote environment to load " + environmentImage);
49
+ createRemoteSkyboxComponent(context, environmentImage, false, true, "environment-image");
33
50
  }
34
51
  });
35
52
 
53
+
54
+ function ensureGlobalCache() {
55
+ if (!globalThis["NEEDLE_ENGINE_SKYBOX_TEXTURES"])
56
+ globalThis["NEEDLE_ENGINE_SKYBOX_TEXTURES"] = new Array<{ src: string, texture: Promise<Texture> }>();
57
+ return globalThis["NEEDLE_ENGINE_SKYBOX_TEXTURES"];
58
+ }
59
+
60
+ function tryGetPreviouslyLoadedTexture(src: string) {
61
+ const cache = ensureGlobalCache();
62
+ const found = cache.find(x => x.src === src);
63
+ if (found) {
64
+ if (debug) console.log("Skybox: Found previously loaded texture for " + src);
65
+ return found.texture;
66
+ }
67
+ return null;
68
+ }
69
+ function registerLoadedTexture(src: string, texture: Promise<Texture>) {
70
+ const cache = ensureGlobalCache();
71
+ // Make sure the cache doesnt get too big
72
+ while (cache.length > 5) {
73
+ cache.shift();
74
+ }
75
+ cache.push({ src, texture });
76
+ }
77
+
36
78
  export class RemoteSkybox extends Behaviour {
37
79
 
38
- @syncField("setSkybox")
80
+ @syncField(RemoteSkybox.prototype.urlChangedSyncField)
39
81
  @serializable(URL)
40
82
  url?: string;
41
83
 
@@ -48,6 +90,9 @@
48
90
  @serializable()
49
91
  environment: boolean = true;
50
92
 
93
+ /* set to false if you do not want to apply url change events via networking */
94
+ allowNetworking: boolean = true;
95
+
51
96
  private _loader?: RGBELoader | EXRLoader | TextureLoader;
52
97
  private _prevUrl?: string;
53
98
  private _prevLoadedEnvironment?: Texture;
@@ -71,7 +116,13 @@
71
116
  this.context.mainCameraComponent?.applyClearFlags();
72
117
  }
73
118
 
119
+ private urlChangedSyncField() {
120
+ if (this.allowNetworking)
121
+ this.setSkybox(this.url);
122
+ }
123
+
74
124
  async setSkybox(url: string | undefined | null) {
125
+ if (!this.activeAndEnabled) return;
75
126
  if (!url) return;
76
127
  if (!url?.endsWith(".hdr") && !url.endsWith(".exr") && !url.endsWith(".jpg") && !url.endsWith(".png") && !url.endsWith(".jpeg")) {
77
128
  console.warn("Potentially invalid skybox url", this.url, "on", this.name);
@@ -89,7 +140,24 @@
89
140
  }
90
141
  this._prevUrl = url;
91
142
 
143
+ const envMap = await this.loadTexture(url);
144
+ if (!envMap) return;
145
+ // Check if we're still enabled
146
+ if (!this.enabled) return;
147
+ // Update the current url
148
+ this.url = url;
149
+ const nameIndex = url.lastIndexOf("/");
150
+ envMap.name = url.substring(nameIndex >= 0 ? nameIndex + 1 : 0);
151
+ if (this._loader instanceof TextureLoader) {
152
+ envMap.encoding = sRGBEncoding;
153
+ }
154
+ this._prevLoadedEnvironment = envMap;
155
+ this.applySkybox();
156
+ }
92
157
 
158
+ private async loadTexture(url: string) {
159
+ const cached = tryGetPreviouslyLoadedTexture(url);
160
+ if (cached) return await cached;
93
161
  const isEXR = url.endsWith(".exr");
94
162
  const isHdr = url.endsWith(".hdr");
95
163
  if (isEXR) {
@@ -106,19 +174,10 @@
106
174
  }
107
175
 
108
176
  if (debug) console.log("Loading skybox: " + url);
109
- const envMap = await this._loader.loadAsync(url);
110
- if (!envMap) return;
111
- // Check if we're still enabled
112
- if (!this.enabled) return;
113
- // Update the current url
114
- this.url = url;
115
- const nameIndex = url.lastIndexOf("/");
116
- envMap.name = url.substring(nameIndex >= 0 ? nameIndex + 1 : 0);
117
- if (this._loader instanceof TextureLoader) {
118
- envMap.encoding = sRGBEncoding;
119
- }
120
- this._prevLoadedEnvironment = envMap;
121
- this.applySkybox();
177
+ const loadingTask = this._loader.loadAsync(url);
178
+ registerLoadedTexture(url, loadingTask);
179
+ const envMap = await loadingTask;
180
+ return envMap;
122
181
  }
123
182
 
124
183
  private applySkybox() {
src/engine/engine_element_extras.ts ADDED
@@ -0,0 +1,11 @@
1
+
2
+
3
+ /*
4
+ DO NOT IMPORT ENGINE_ELEMENT FROM HERE
5
+ */
6
+
7
+ export async function registerObservableAttribute(name: string) {
8
+ const { NeedleEngineHTMLElement } = await import("./engine_element");
9
+ if (!NeedleEngineHTMLElement.observedAttributes.includes(name))
10
+ NeedleEngineHTMLElement.observedAttributes.push(name);
11
+ }