Needle Engine

Changes between version 4.2.6 and 4.3.1
Files changed (52) hide show
  1. plugins/vite/alias.js +6 -3
  2. plugins/common/buildinfo.js +8 -3
  3. plugins/vite/copyfiles.js +1 -1
  4. src/engine-components/Animator.ts +142 -22
  5. src/engine-components/AnimatorController.ts +184 -34
  6. src/engine-components/AudioListener.ts +16 -5
  7. src/engine-components/AudioSource.ts +137 -39
  8. src/engine-components/AvatarLoader.ts +61 -2
  9. src/engine-components/AxesHelper.ts +21 -1
  10. src/engine-components/BoxHelperComponent.ts +26 -0
  11. src/engine-components/Camera.ts +147 -41
  12. src/engine-components/CameraUtils.ts +20 -0
  13. src/engine-components/ui/Canvas.ts +2 -2
  14. src/engine-components/Collider.ts +102 -27
  15. src/engine-components/Component.ts +605 -129
  16. src/engine-components/DragControls.ts +134 -38
  17. src/engine-components/DropListener.ts +143 -23
  18. src/engine-components/Duplicatable.ts +21 -19
  19. src/engine/engine_addressables.ts +33 -2
  20. src/engine/engine_context.ts +1 -1
  21. src/engine/engine_input.ts +20 -1
  22. src/engine/engine_mainloop_utils.ts +2 -4
  23. src/engine/engine_math.ts +25 -7
  24. src/engine/engine_serialization_core.ts +1 -1
  25. src/engine/engine_three_utils.ts +22 -14
  26. src/engine/engine_types.ts +179 -18
  27. src/engine/engine_utils_screenshot.ts +17 -3
  28. src/engine-components/ui/EventSystem.ts +9 -7
  29. src/engine-components/Light.ts +105 -44
  30. src/needle-engine.ts +8 -6
  31. src/engine-components/NeedleMenu.ts +29 -11
  32. src/engine/xr/NeedleXRSession.ts +7 -1
  33. src/engine-components/Networking.ts +37 -6
  34. src/engine-components/OrbitControls.ts +8 -0
  35. src/engine-components/particlesystem/ParticleSystem.ts +2 -2
  36. src/engine-components-experimental/networking/PlayerSync.ts +85 -13
  37. src/engine-components/postprocessing/PostProcessingEffect.ts +7 -1
  38. src/engine-components/postprocessing/PostProcessingHandler.ts +6 -6
  39. src/engine-components/Renderer.ts +12 -5
  40. src/engine-components/RendererInstancing.ts +28 -13
  41. src/engine-components/RigidBody.ts +6 -4
  42. src/engine-components/SceneSwitcher.ts +78 -9
  43. src/engine-components/postprocessing/Effects/ScreenspaceAmbientOcclusionN8.ts +55 -8
  44. src/engine-components/SpatialTrigger.ts +80 -3
  45. src/engine-components/SpectatorCamera.ts +136 -18
  46. src/engine-components/SpriteRenderer.ts +22 -6
  47. src/engine-components/SyncedTransform.ts +50 -7
  48. src/engine-components/TransformGizmo.ts +49 -4
  49. src/engine-components/postprocessing/Volume.ts +5 -7
  50. src/engine-components/postprocessing/VolumeProfile.ts +10 -2
  51. src/engine-components/webxr/WebARSessionRoot.ts +31 -8
  52. src/engine-components/webxr/WebXR.ts +173 -29
plugins/vite/alias.js CHANGED
@@ -57,6 +57,8 @@
57
57
  }
58
58
  if (debug) {
59
59
  const outputFilePath = path.resolve(projectDir, 'node_modules/.vite/needle.alias.log');
60
+ const outputDirectory = path.dirname(outputFilePath);
61
+ if (!existsSync(outputDirectory)) mkdirSync(outputDirectory, { recursive: true });
60
62
  outputDebugFile = createWriteStream(outputFilePath, { flags: "a" });
61
63
  const timestamp = new Date().toISOString();
62
64
  outputDebugFile.write("\n\n\n--------------------------\n");
@@ -68,7 +70,7 @@
68
70
  const aliasPlugin = {
69
71
  name: "needle-alias",
70
72
  config(config) {
71
- if (debug) console.log('[needle-alias] ProjectDirectory: ' + projectDir);
73
+ if (debug) console.log('[needle-alias] ProjectDirectory: ' + projectDir);
72
74
  if (!config.resolve) config.resolve = {};
73
75
  if (!config.resolve.alias) config.resolve.alias = {};
74
76
  const aliasDict = config.resolve.alias;
@@ -97,6 +99,7 @@
97
99
  let lastImporter = "";
98
100
  /** This plugin logs all imports. This helps to find cases where incorrect folders are found/resolved. */
99
101
 
102
+ /** @type {import("vite").Plugin} */
100
103
  const debuggingPlugin = {
101
104
  name: "needle:alias-debug",
102
105
  // needs to run before regular resolver
@@ -115,9 +118,9 @@
115
118
  // verbose logging for all imports
116
119
  if (lastImporter !== importer) {
117
120
  lastImporter = importer;
118
- log('[needle-alias] Resolving: ', importer, "(file)");
121
+ log(`[needle-alias] Resolving: ${importer} (file${options?.ssr ? ", SSR" : ""})`);
119
122
  }
120
- log('[needle-alias] ' + id);
123
+ log(`[needle-alias] ${id}`);
121
124
  return;
122
125
  },
123
126
  }
plugins/common/buildinfo.js CHANGED
@@ -1,4 +1,4 @@
1
- import fs, { statSync } from 'fs';
1
+ import fs, { existsSync, statSync } from 'fs';
2
2
  import crypto from 'crypto';
3
3
 
4
4
 
@@ -21,7 +21,12 @@
21
21
  const buildInfoPath = `${buildDirectory}/needle.buildinfo.json`;
22
22
  const totalSizeInMB = buildInfo.totalsize / 1024 / 1024;
23
23
  console.log(`[needle-buildinfo] - Collected ${buildInfo.files.length} files (${totalSizeInMB.toFixed(2)} MB). Writing build info to \"${buildInfoPath}\"`);
24
- fs.writeFileSync(buildInfoPath, JSON.stringify(buildInfo));
24
+ const str = JSON.stringify(buildInfo);
25
+ fs.writeFileSync(buildInfoPath, str);
26
+ if(!existsSync(buildDirectory)) {
27
+ console.warn(`WARN: Could not write build info file to \"${buildInfoPath}\"`);
28
+ return;
29
+ }
25
30
  }
26
31
 
27
32
  /** Recursively collect all files in a directory
@@ -42,7 +47,7 @@
42
47
  console.log(`[needle-buildinfo] - Collect files in \"/${newPath}\"`);
43
48
  recursivelyCollectFiles(newDirectory, newPath, info);
44
49
  } else {
45
- const relpath = `${path}/${entry.name}`;
50
+ const relpath = path?.length <= 0 ? entry.name : `${path}/${entry.name}`;
46
51
  // console.log("[needle-buildinfo] - New File: " + relpath);
47
52
  const filehash = crypto.createHash('sha256');
48
53
  const fullpath = `${directory}/${entry.name}`;
plugins/vite/copyfiles.js CHANGED
@@ -65,7 +65,7 @@
65
65
 
66
66
  const outDir = resolve(baseDir, outdirName);
67
67
  if (!existsSync(outDir)) {
68
- mkdirSync(outDir);
68
+ mkdirSync(outDir, { recursive: true });
69
69
  }
70
70
 
71
71
  // copy a list of files or directories declared in build.copy = [] in the needle.config.json
src/engine-components/Animator.ts CHANGED
@@ -11,38 +11,73 @@
11
11
 
12
12
  const debug = getParam("debuganimator");
13
13
 
14
-
14
+ /**
15
+ * Represents an event emitted by an animation mixer
16
+ * @category Animation and Sequencing
17
+ */
15
18
  export declare class MixerEvent {
19
+ /** The type of event that occurred */
16
20
  type: string;
21
+ /** The animation action that triggered this event */
17
22
  action: AnimationAction;
23
+ /** Number of loops completed in this cycle */
18
24
  loopDelta: number;
25
+ /** The animation mixer that emitted this event */
19
26
  target: AnimationMixer;
20
27
  }
21
28
 
29
+ /**
30
+ * Configuration options for playing animations
31
+ * @category Animation and Sequencing
32
+ */
22
33
  export declare class PlayOptions {
34
+ /** Whether the animation should loop, and if so, which loop style to use */
23
35
  loop?: boolean | AnimationActionLoopStyles;
36
+ /** Whether the final animation state should be maintained after playback completes */
24
37
  clampWhenFinished?: boolean;
25
38
  }
26
39
 
27
- /** The Animator component is used to play animations on a GameObject. It is used in combination with an AnimatorController (which is a state machine for animations)
28
- * A new AnimatorController can be created from code via `AnimatorController.createFromClips`
40
+ /**
41
+ * The Animator component plays and manages animations on a GameObject.
42
+ * It works with an AnimatorController to handle state transitions and animation blending.
43
+ * A new AnimatorController can be created from code via `AnimatorController.createFromClips`.
29
44
  * @category Animation and Sequencing
30
45
  * @group Components
31
46
  */
32
47
  export class Animator extends Behaviour implements IAnimationComponent {
33
48
 
49
+ /**
50
+ * Identifies this component as an animation component in the engine
51
+ */
34
52
  get isAnimationComponent() {
35
53
  return true;
36
54
  }
37
55
 
56
+ /**
57
+ * When enabled, animation will affect the root transform position and rotation
58
+ */
38
59
  @serializable()
39
60
  applyRootMotion: boolean = false;
61
+
62
+ /**
63
+ * Indicates whether this animator contains root motion data
64
+ */
40
65
  @serializable()
41
66
  hasRootMotion: boolean = false;
67
+
68
+ /**
69
+ * When enabled, the animator will maintain its state when the component is disabled
70
+ */
42
71
  @serializable()
43
72
  keepAnimatorControllerStateOnDisable: boolean = false;
44
73
 
45
74
  // set from needle animator extension
75
+ /**
76
+ * Sets or replaces the animator controller for this component.
77
+ * Handles binding the controller to this animator instance and ensures
78
+ * proper initialization when the controller changes.
79
+ * @param val The animator controller model or instance to use
80
+ */
46
81
  @serializable()
47
82
  set runtimeAnimatorController(val: AnimatorControllerModel | AnimatorController | undefined | null) {
48
83
  if (this._animatorController && this._animatorController.model === val) {
@@ -69,41 +104,53 @@
69
104
  }
70
105
  else this._animatorController = null;
71
106
  }
107
+
108
+ /**
109
+ * Gets the current animator controller instance
110
+ * @returns The current animator controller or null if none is assigned
111
+ */
72
112
  get runtimeAnimatorController(): AnimatorController | undefined | null {
73
113
  return this._animatorController;
74
114
  }
75
115
 
76
- /** The current state info of the animator.
77
- * If you just want to access the currently playing animation action you can use currentAction
78
- * @returns {AnimatorStateInfo} The current state info of the animator or null if no state is playing
79
- */
116
+ /**
117
+ * Retrieves information about the current animation state
118
+ * @returns The current state information, or undefined if no state is playing
119
+ */
80
120
  getCurrentStateInfo() {
81
121
  return this.runtimeAnimatorController?.getCurrentStateInfo();
82
122
  }
83
- /** The current action playing. It can be used to modify the action
84
- * @returns {AnimationAction | null} The current action playing or null if no state is playing
85
- */
123
+ /**
124
+ * The currently playing animation action that can be used to modify animation properties
125
+ * @returns The current animation action, or null if no animation is playing
126
+ */
86
127
  get currentAction() {
87
128
  return this.runtimeAnimatorController?.currentAction || null;
88
129
  }
89
130
 
90
- /** @returns {boolean} True if parameters have been changed */
131
+ /**
132
+ * Indicates whether animation parameters have been modified since the last update
133
+ * @returns True if parameters have been changed
134
+ */
91
135
  get parametersAreDirty() { return this._parametersAreDirty; }
92
136
  private _parametersAreDirty: boolean = false;
93
137
 
94
- /** @returns {boolean} True if the animator has been changed */
138
+ /**
139
+ * Indicates whether the animator state has changed since the last update
140
+ * @returns True if the animator has been changed
141
+ */
95
142
  get isDirty() { return this._isDirty; }
96
143
  private _isDirty: boolean = false;
97
144
 
98
145
  /**@deprecated use play() */
99
146
  Play(name: string | number, layer: number = -1, normalizedTime: number = Number.NEGATIVE_INFINITY, transitionDurationInSec: number = 0) { this.play(name, layer, normalizedTime, transitionDurationInSec); }
100
- /** Plays an animation on the animator
101
- * @param {string | number} name The name of the animation to play. Can also be the hash of the animation
102
- * @param {number} layer The layer to play the animation on. Default is -1
103
- * @param {number} normalizedTime The normalized time to start the animation at. Default is Number.NEGATIVE_INFINITY
104
- * @param {number} transitionDurationInSec The duration of the transition to the new animation. Default is 0
105
- * @returns {void}
106
- * */
147
+ /**
148
+ * Plays an animation on the animator
149
+ * @param name The name or hash of the animation to play
150
+ * @param layer The layer to play the animation on (-1 for default layer)
151
+ * @param normalizedTime The time position to start playing (0-1 range, NEGATIVE_INFINITY for current position)
152
+ * @param transitionDurationInSec The duration of the blend transition in seconds
153
+ */
107
154
  play(name: string | number, layer: number = -1, normalizedTime: number = Number.NEGATIVE_INFINITY, transitionDurationInSec: number = 0) {
108
155
  this.runtimeAnimatorController?.play(name, layer, normalizedTime, transitionDurationInSec);
109
156
  this._isDirty = true;
@@ -111,7 +158,9 @@
111
158
 
112
159
  /**@deprecated use reset */
113
160
  Reset() { this.reset(); }
114
- /** Resets the animatorcontroller */
161
+ /**
162
+ * Resets the animator controller to its initial state
163
+ */
115
164
  reset() {
116
165
  this._animatorController?.reset();
117
166
  this._isDirty = true;
@@ -119,6 +168,12 @@
119
168
 
120
169
  /**@deprecated use setBool */
121
170
  SetBool(name: string | number, val: boolean) { this.setBool(name, val); }
171
+
172
+ /**
173
+ * Sets a boolean parameter in the animator
174
+ * @param name The name or hash of the parameter
175
+ * @param value The boolean value to set
176
+ */
122
177
  setBool(name: string | number, value: boolean) {
123
178
  if (debug) console.log("setBool", name, value);
124
179
  if (this.runtimeAnimatorController?.getBool(name) !== value)
@@ -128,18 +183,34 @@
128
183
 
129
184
  /**@deprecated use getBool */
130
185
  GetBool(name: string | number) { return this.getBool(name); }
186
+
187
+ /**
188
+ * Gets a boolean parameter from the animator
189
+ * @param name The name or hash of the parameter
190
+ * @returns The value of the boolean parameter, or false if not found
191
+ */
131
192
  getBool(name: string | number): boolean {
132
193
  const res = this.runtimeAnimatorController?.getBool(name) ?? false;
133
194
  if (debug) console.log("getBool", name, res);
134
195
  return res;
135
196
  }
136
197
 
198
+ /**
199
+ * Toggles a boolean parameter between true and false
200
+ * @param name The name or hash of the parameter
201
+ */
137
202
  toggleBool(name: string | number) {
138
203
  this.setBool(name, !this.getBool(name));
139
204
  }
140
205
 
141
206
  /**@deprecated use setFloat */
142
207
  SetFloat(name: string | number, val: number) { this.setFloat(name, val); }
208
+
209
+ /**
210
+ * Sets a float parameter in the animator
211
+ * @param name The name or hash of the parameter
212
+ * @param val The float value to set
213
+ */
143
214
  setFloat(name: string | number, val: number) {
144
215
  if (this.runtimeAnimatorController?.getFloat(name) !== val)
145
216
  this._parametersAreDirty = true;
@@ -149,6 +220,12 @@
149
220
 
150
221
  /**@deprecated use getFloat */
151
222
  GetFloat(name: string | number) { return this.getFloat(name); }
223
+
224
+ /**
225
+ * Gets a float parameter from the animator
226
+ * @param name The name or hash of the parameter
227
+ * @returns The value of the float parameter, or -1 if not found
228
+ */
152
229
  getFloat(name: string | number): number {
153
230
  const res = this.runtimeAnimatorController?.getFloat(name) ?? -1;
154
231
  if (debug) console.log("getFloat", name, res);
@@ -157,6 +234,12 @@
157
234
 
158
235
  /**@deprecated use setInteger */
159
236
  SetInteger(name: string | number, val: number) { this.setInteger(name, val); }
237
+
238
+ /**
239
+ * Sets an integer parameter in the animator
240
+ * @param name The name or hash of the parameter
241
+ * @param val The integer value to set
242
+ */
160
243
  setInteger(name: string | number, val: number) {
161
244
  if (this.runtimeAnimatorController?.getInteger(name) !== val)
162
245
  this._parametersAreDirty = true;
@@ -166,6 +249,12 @@
166
249
 
167
250
  /**@deprecated use getInteger */
168
251
  GetInteger(name: string | number) { return this.getInteger(name); }
252
+
253
+ /**
254
+ * Gets an integer parameter from the animator
255
+ * @param name The name or hash of the parameter
256
+ * @returns The value of the integer parameter, or -1 if not found
257
+ */
169
258
  getInteger(name: string | number): number {
170
259
  const res = this.runtimeAnimatorController?.getInteger(name) ?? -1;
171
260
  if (debug) console.log("getInteger", name, res);
@@ -174,6 +263,11 @@
174
263
 
175
264
  /**@deprecated use setTrigger */
176
265
  SetTrigger(name: string | number) { this.setTrigger(name); }
266
+
267
+ /**
268
+ * Activates a trigger parameter in the animator
269
+ * @param name The name or hash of the trigger parameter
270
+ */
177
271
  setTrigger(name: string | number) {
178
272
  this._parametersAreDirty = true;
179
273
  if (debug) console.log("setTrigger", name);
@@ -182,6 +276,11 @@
182
276
 
183
277
  /**@deprecated use resetTrigger */
184
278
  ResetTrigger(name: string | number) { this.resetTrigger(name); }
279
+
280
+ /**
281
+ * Resets a trigger parameter in the animator
282
+ * @param name The name or hash of the trigger parameter
283
+ */
185
284
  resetTrigger(name: string | number) {
186
285
  this._parametersAreDirty = true;
187
286
  if (debug) console.log("resetTrigger", name);
@@ -190,6 +289,12 @@
190
289
 
191
290
  /**@deprecated use getTrigger */
192
291
  GetTrigger(name: string | number) { this.getTrigger(name); }
292
+
293
+ /**
294
+ * Gets the state of a trigger parameter from the animator
295
+ * @param name The name or hash of the trigger parameter
296
+ * @returns The state of the trigger parameter
297
+ */
193
298
  getTrigger(name: string | number) {
194
299
  const res = this.runtimeAnimatorController?.getTrigger(name);
195
300
  if (debug) console.log("getTrigger", name, res);
@@ -198,13 +303,21 @@
198
303
 
199
304
  /**@deprecated use isInTransition */
200
305
  IsInTransition() { return this.isInTransition(); }
201
- /** @returns `true` if the animator is currently in a transition */
306
+ /**
307
+ * Checks if the animator is currently in a transition between states
308
+ * @returns True if the animator is currently blending between animations
309
+ */
202
310
  isInTransition(): boolean {
203
311
  return this.runtimeAnimatorController?.isInTransition() ?? false;
204
312
  }
205
313
 
206
314
  /**@deprecated use setSpeed */
207
315
  SetSpeed(speed: number) { return this.setSpeed(speed); }
316
+
317
+ /**
318
+ * Sets the playback speed of the animator
319
+ * @param speed The new playback speed multiplier
320
+ */
208
321
  setSpeed(speed: number) {
209
322
  if (speed === this._speed) return;
210
323
  if (debug) console.log("setSpeed", speed);
@@ -213,13 +326,20 @@
213
326
  this._animatorController.setSpeed(speed);
214
327
  }
215
328
 
216
- /** Will generate a random speed between the min and max values and set it to the animatorcontroller */
329
+ /**
330
+ * Sets a random playback speed between the min and max values
331
+ * @param minMax Object with x (minimum) and y (maximum) speed values
332
+ */
217
333
  set minMaxSpeed(minMax: { x: number, y: number }) {
218
334
  this._speed = Mathf.lerp(minMax.x, minMax.y, Math.random());
219
335
  if (this._animatorController?.animator == this)
220
336
  this._animatorController.setSpeed(this._speed);
221
337
  }
222
338
 
339
+ /**
340
+ * Sets a random normalized time offset for animations between min (x) and max (y) values
341
+ * @param minMax Object with x (min) and y (max) values for the offset range
342
+ */
223
343
  set minMaxOffsetNormalized(minMax: { x: number, y: number }) {
224
344
  this._normalizedStartOffset = Mathf.lerp(minMax.x, minMax.y, Math.random());
225
345
  if (this.runtimeAnimatorController?.animator == this)
src/engine-components/AnimatorController.ts CHANGED
@@ -15,6 +15,11 @@
15
15
  const debug = getParam("debuganimatorcontroller");
16
16
  const debugRootMotion = getParam("debugrootmotion");
17
17
 
18
+ /**
19
+ * Generates a hash code for a string
20
+ * @param str - The string to hash
21
+ * @returns A numeric hash value
22
+ */
18
23
  function stringToHash(str): number {
19
24
  let hash = 0;
20
25
  for (let i = 0; i < str.length; i++) {
@@ -25,27 +30,38 @@
25
30
  return hash;
26
31
  }
27
32
 
33
+ /**
34
+ * Configuration options for creating an AnimatorController
35
+ */
28
36
  declare type CreateAnimatorControllerOptions = {
29
- /** Should each animationstate loop */
37
+ /** Should each animation state loop */
30
38
  looping?: boolean,
31
- /** Set to false to disable generating transitions between animationclips */
39
+ /** Set to false to disable generating transitions between animation clips */
32
40
  autoTransition?: boolean,
33
- /** Set to a positive value in seconds for transition duration between states */
41
+ /** Duration in seconds for transitions between states */
34
42
  transitionDuration?: number,
35
43
  }
36
44
 
37
45
  /**
38
- * The AnimatorController is used to control the playback of animations. It is used by the {@link Animator} component.
39
- * It is using a state machine to control the playback of animations.
40
- * To create an animator controller use the static method `AnimatorController.createFromClips(clips: AnimationClip[], options: CreateAnimatorControllerOptions)`
41
- */
46
+ * Controls the playback of animations using a state machine architecture.
47
+ *
48
+ * The AnimatorController manages animation states, transitions between states,
49
+ * and parameters that affect those transitions. It is used by the {@link Animator}
50
+ * component to control animation behavior on 3D models.
51
+ *
52
+ * Use the static method {@link AnimatorController.createFromClips} to create
53
+ * an animator controller from a set of animation clips.
54
+ */
42
55
  export class AnimatorController {
43
56
 
44
- /** Create an animatorcontroller. States are created from the clips array.
45
- * @param clips the clips to assign to the controller
46
- * @param options options to control the creation of the controller.
47
- * @returns the created animator controller
48
- */
57
+ /**
58
+ * Creates an AnimatorController from a set of animation clips.
59
+ * Each clip becomes a state in the controller's state machine.
60
+ *
61
+ * @param clips - The animation clips to use for creating states
62
+ * @param options - Configuration options for the controller including looping behavior and transitions
63
+ * @returns A new AnimatorController instance
64
+ */
49
65
  static createFromClips(clips: AnimationClip[], options: CreateAnimatorControllerOptions = { looping: false, autoTransition: true, transitionDuration: 0 }): AnimatorController {
50
66
  const states: State[] = [];
51
67
  for (let i = 0; i < clips.length; i++) {
@@ -99,6 +115,14 @@
99
115
  return controller;
100
116
  }
101
117
 
118
+ /**
119
+ * Plays an animation state by name or hash.
120
+ *
121
+ * @param name - The name or hash identifier of the state to play
122
+ * @param layerIndex - The layer index (defaults to 0)
123
+ * @param normalizedTime - The normalized time to start the animation from (0-1)
124
+ * @param durationInSec - Transition duration in seconds
125
+ */
102
126
  play(name: string | number, layerIndex: number = -1, normalizedTime: number = Number.NEGATIVE_INFINITY, durationInSec: number = 0) {
103
127
  if (layerIndex < 0) layerIndex = 0;
104
128
  else if (layerIndex >= this.model.layers.length) {
@@ -118,20 +142,42 @@
118
142
  console.warn("Could not find " + name + " to play");
119
143
  }
120
144
 
145
+ /**
146
+ * Resets the controller to its initial state.
147
+ */
121
148
  reset() {
122
149
  this.setStartTransition();
123
150
  }
124
151
 
152
+ /**
153
+ * Sets a boolean parameter value by name or hash.
154
+ *
155
+ * @param name - The name or hash identifier of the parameter
156
+ * @param value - The boolean value to set
157
+ */
125
158
  setBool(name: string | number, value: boolean) {
126
159
  const key = typeof name === "string" ? "name" : "hash";
127
160
  return this.model?.parameters?.filter(p => p[key] === name).forEach(p => p.value = value);
128
161
  }
129
162
 
163
+ /**
164
+ * Gets a boolean parameter value by name or hash.
165
+ *
166
+ * @param name - The name or hash identifier of the parameter
167
+ * @returns The boolean value of the parameter, or false if not found
168
+ */
130
169
  getBool(name: string | number): boolean {
131
170
  const key = typeof name === "string" ? "name" : "hash";
132
171
  return this.model?.parameters?.find(p => p[key] === name)?.value as boolean ?? false;
133
172
  }
134
173
 
174
+ /**
175
+ * Sets a float parameter value by name or hash.
176
+ *
177
+ * @param name - The name or hash identifier of the parameter
178
+ * @param val - The float value to set
179
+ * @returns True if the parameter was found and set, false otherwise
180
+ */
135
181
  setFloat(name: string | number, val: number) {
136
182
  const key = typeof name === "string" ? "name" : "hash";
137
183
  const filtered = this.model?.parameters?.filter(p => p[key] === name);
@@ -139,21 +185,45 @@
139
185
  return filtered?.length > 0;
140
186
  }
141
187
 
188
+ /**
189
+ * Gets a float parameter value by name or hash.
190
+ *
191
+ * @param name - The name or hash identifier of the parameter
192
+ * @returns The float value of the parameter, or 0 if not found
193
+ */
142
194
  getFloat(name: string | number): number {
143
195
  const key = typeof name === "string" ? "name" : "hash";
144
196
  return this.model?.parameters?.find(p => p[key] === name)?.value as number ?? 0;
145
197
  }
146
198
 
199
+ /**
200
+ * Sets an integer parameter value by name or hash.
201
+ *
202
+ * @param name - The name or hash identifier of the parameter
203
+ * @param val - The integer value to set
204
+ */
147
205
  setInteger(name: string | number, val: number) {
148
206
  const key = typeof name === "string" ? "name" : "hash";
149
207
  return this.model?.parameters?.filter(p => p[key] === name).forEach(p => p.value = val);
150
208
  }
151
209
 
210
+ /**
211
+ * Gets an integer parameter value by name or hash.
212
+ *
213
+ * @param name - The name or hash identifier of the parameter
214
+ * @returns The integer value of the parameter, or 0 if not found
215
+ */
152
216
  getInteger(name: string | number): number {
153
217
  const key = typeof name === "string" ? "name" : "hash";
154
218
  return this.model?.parameters?.find(p => p[key] === name)?.value as number ?? 0;
155
219
  }
156
220
 
221
+ /**
222
+ * Sets a trigger parameter to active (true).
223
+ * Trigger parameters are automatically reset after they are consumed by a transition.
224
+ *
225
+ * @param name - The name or hash identifier of the trigger parameter
226
+ */
157
227
  setTrigger(name: string | number) {
158
228
  if (debug)
159
229
  console.log("SET TRIGGER", name);
@@ -161,16 +231,32 @@
161
231
  return this.model?.parameters?.filter(p => p[key] === name).forEach(p => p.value = true);
162
232
  }
163
233
 
234
+ /**
235
+ * Resets a trigger parameter to inactive (false).
236
+ *
237
+ * @param name - The name or hash identifier of the trigger parameter
238
+ */
164
239
  resetTrigger(name: string | number) {
165
240
  const key = typeof name === "string" ? "name" : "hash";
166
241
  return this.model?.parameters?.filter(p => p[key] === name).forEach(p => p.value = false);
167
242
  }
168
243
 
244
+ /**
245
+ * Gets the current state of a trigger parameter.
246
+ *
247
+ * @param name - The name or hash identifier of the trigger parameter
248
+ * @returns The boolean state of the trigger, or false if not found
249
+ */
169
250
  getTrigger(name: string | number): boolean {
170
251
  const key = typeof name === "string" ? "name" : "hash";
171
252
  return this.model?.parameters?.find(p => p[key] === name)?.value as boolean ?? false;
172
253
  }
173
254
 
255
+ /**
256
+ * Checks if the controller is currently in a transition between states.
257
+ *
258
+ * @returns True if a transition is in progress, false otherwise
259
+ */
174
260
  isInTransition(): boolean {
175
261
  return this._activeStates.length > 1;
176
262
  }
@@ -182,8 +268,21 @@
182
268
  private _speed: number = 1;
183
269
 
184
270
 
185
- /**@deprecated use findState */
271
+ /**
272
+ * Finds an animation state by name or hash.
273
+ * @deprecated Use findState instead
274
+ *
275
+ * @param name - The name or hash identifier of the state to find
276
+ * @returns The found state or null if not found
277
+ */
186
278
  FindState(name: string | number | undefined | null): State | null { return this.findState(name); }
279
+
280
+ /**
281
+ * Finds an animation state by name or hash.
282
+ *
283
+ * @param name - The name or hash identifier of the state to find
284
+ * @returns The found state or null if not found
285
+ */
187
286
  findState(name: string | number | undefined | null): State | null {
188
287
  if (!name) return null;
189
288
  if (Array.isArray(this.model.layers)) {
@@ -196,9 +295,11 @@
196
295
  return null;
197
296
  }
198
297
 
199
- /** Get the current state info
200
- * @returns the current state info or null if no state is active
201
- */
298
+ /**
299
+ * Gets information about the current playing animation state.
300
+ *
301
+ * @returns An AnimatorStateInfo object with data about the current state, or null if no state is active
302
+ */
202
303
  getCurrentStateInfo() {
203
304
  if (!this._activeState) return null;
204
305
  const action = this._activeState.motion.action;
@@ -208,10 +309,11 @@
208
309
  return new AnimatorStateInfo(this._activeState, normalizedTime, dur, this._speed);
209
310
  }
210
311
 
211
- /** Get the current action (shorthand for activeState.motion.action)
212
- * @returns the current action that is playing. This is the action that is currently transitioning to or playing.
213
- * If no action is playing null is returned.
214
- **/
312
+ /**
313
+ * Gets the animation action currently playing.
314
+ *
315
+ * @returns The current animation action, or null if no action is playing
316
+ */
215
317
  get currentAction(): AnimationAction | null {
216
318
  if (!this._activeState) return null;
217
319
  const action = this._activeState.motion.action;
@@ -219,24 +321,37 @@
219
321
  return action;
220
322
  }
221
323
 
222
- /** The normalized time of the start state. This is used to determine the start time of the first state. */
324
+ /**
325
+ * The normalized time (0-1) to start playing the first state at.
326
+ * This affects the initial state when the animator is first enabled.
327
+ */
223
328
  normalizedStartOffset: number = 0;
224
329
 
225
- /** the animator that this controller is bound to */
330
+ /**
331
+ * The Animator component this controller is bound to.
332
+ */
226
333
  animator?: Animator;
227
- /** the model that this controller is based on */
334
+
335
+ /**
336
+ * The data model describing the animation states and transitions.
337
+ */
228
338
  model: AnimatorControllerModel;
229
339
 
230
- /** Get the context of the animator */
340
+ /**
341
+ * Gets the engine context from the bound animator.
342
+ */
231
343
  get context(): Context | undefined | null { return this.animator?.context; }
232
344
 
233
- /** Get the animation mixer that is used to play the animations */
345
+ /**
346
+ * Gets the animation mixer used by this controller.
347
+ */
234
348
  get mixer() {
235
349
  return this._mixer;
236
350
  }
237
351
 
238
352
  /**
239
- * Clears the animation mixer and unregisters it from the context.
353
+ * Cleans up resources used by this controller.
354
+ * Stops all animations and unregisters the mixer from the animation system.
240
355
  */
241
356
  dispose() {
242
357
  this._mixer.stopAllAction();
@@ -254,7 +369,12 @@
254
369
  // // this.internalApplyRootMotion(obj);
255
370
  // }
256
371
 
257
- /** Bind the animator to the controller. Only one animator can be bound to a controller at a time. */
372
+ /**
373
+ * Binds this controller to an animator component.
374
+ * Creates a new animation mixer and sets up animation actions.
375
+ *
376
+ * @param animator - The animator to bind this controller to
377
+ */
258
378
  bind(animator: Animator) {
259
379
  if (!animator) console.error("AnimatorController.bind: animator is null");
260
380
  else if (this.animator !== animator) {
@@ -269,7 +389,12 @@
269
389
  }
270
390
  }
271
391
 
272
- /** Create a clone of the controller. This will clone the model but not the runtime state. */
392
+ /**
393
+ * Creates a deep copy of this controller.
394
+ * Clones the model data but does not copy runtime state.
395
+ *
396
+ * @returns A new AnimatorController instance with the same configuration
397
+ */
273
398
  clone() {
274
399
  if (typeof this.model === "string") {
275
400
  console.warn("AnimatorController has not been resolved, can not create model from string", this.model);
@@ -297,7 +422,12 @@
297
422
  return controller;
298
423
  }
299
424
 
300
- /** Called by the animator. This will update the active states and transitions as well as the animation mixer. */
425
+ /**
426
+ * Updates the controller's state machine and animations.
427
+ * Called each frame by the animator component.
428
+ *
429
+ * @param weight - The weight to apply to the animations (for blending)
430
+ */
301
431
  update(weight: number) {
302
432
  if (!this.animator) return;
303
433
  this.evaluateTransitions();
@@ -318,9 +448,11 @@
318
448
  private _mixer!: AnimationMixer;
319
449
  private _activeState?: State;
320
450
 
321
- /** Get the currently active state playing
322
- * @returns the currently active state or undefined if no state is active
323
- **/
451
+ /**
452
+ * Gets the currently active animation state.
453
+ *
454
+ * @returns The active state or undefined if no state is active
455
+ */
324
456
  get activeState(): State | undefined { return this._activeState; }
325
457
 
326
458
  constructor(model: AnimatorControllerModel) {
@@ -770,6 +902,10 @@
770
902
  }
771
903
  }
772
904
 
905
+ /**
906
+ * Yields all animation actions managed by this controller.
907
+ * Iterates through all states in all layers and returns their actions.
908
+ */
773
909
  *enumerateActions() {
774
910
  if (!this.model.layers) return;
775
911
  for (const layer of this.model.layers) {
@@ -807,6 +943,10 @@
807
943
  // }
808
944
  }
809
945
 
946
+ /**
947
+ * Wraps a KeyframeTrack to allow custom evaluation of animation values.
948
+ * Used internally to modify animation behavior without changing the original data.
949
+ */
810
950
  class TrackEvaluationWrapper {
811
951
 
812
952
  track?: KeyframeTrack;
@@ -844,6 +984,10 @@
844
984
  }
845
985
  }
846
986
 
987
+ /**
988
+ * Handles root motion extraction from animation tracks.
989
+ * Captures movement from animations and applies it to the root object.
990
+ */
847
991
  class RootMotionAction {
848
992
 
849
993
  private static lastObjPosition: { [key: string]: Vector3 } = {};
@@ -1043,6 +1187,10 @@
1043
1187
  }
1044
1188
  }
1045
1189
 
1190
+ /**
1191
+ * Manages root motion for a character.
1192
+ * Extracts motion from animation tracks and applies it to the character's transform.
1193
+ */
1046
1194
  class RootMotionHandler {
1047
1195
 
1048
1196
  private controller: AnimatorController;
@@ -1129,8 +1277,10 @@
1129
1277
  }
1130
1278
  }
1131
1279
 
1132
-
1133
-
1280
+ /**
1281
+ * Serialization handler for AnimatorController instances.
1282
+ * Handles conversion between serialized data and runtime objects.
1283
+ */
1134
1284
  class AnimatorControllerSerializator extends TypeSerializer {
1135
1285
  onSerialize(_: any, _context: SerializationContext) {
1136
1286
 
src/engine-components/AudioListener.ts CHANGED
@@ -5,15 +5,18 @@
5
5
  import { Behaviour, GameObject } from "./Component.js";
6
6
 
7
7
  /**
8
- * AudioListener represents a listener that can be attached to a GameObject to listen to audio sources in the scene.
8
+ * AudioListener represents a listener that can hear audio sources in the scene.
9
+ * This component creates and manages a Three.js {@link three#AudioListener}, automatically connecting it
10
+ * to the main camera or a Camera in the parent hierarchy.
9
11
  * @category Multimedia
10
12
  * @group Components
11
13
  */
12
14
  export class AudioListener extends Behaviour {
13
15
 
14
16
  /**
15
- * Gets the existing or creates a new {@link ThreeAudioListener} instance
16
- * @returns The {@link ThreeAudioListener} instance
17
+ * Gets the existing Three.js {@link three#AudioListener} instance or creates a new one if it doesn't exist.
18
+ * This listener is responsible for capturing audio in the 3D scene.
19
+ * @returns The {@link three#AudioListener} instance
17
20
  */
18
21
  get listener(): ThreeAudioListener {
19
22
  if (this._listener == null)
@@ -23,13 +26,21 @@
23
26
 
24
27
  private _listener: ThreeAudioListener | null = null;
25
28
 
26
- /** @internal */
29
+ /**
30
+ * Registers for interaction events and initializes the audio listener
31
+ * when this component is enabled.
32
+ * @internal
33
+ */
27
34
  onEnable(): void {
28
35
  Application.registerWaitForInteraction(this.onInteraction);
29
36
  this.addListenerIfItExists();
30
37
  }
31
38
 
32
- /** @internal */
39
+ /**
40
+ * Cleans up event registrations and removes the audio listener
41
+ * when this component is disabled.
42
+ * @internal
43
+ */
33
44
  onDisable(): void {
34
45
  Application.unregisterWaitForInteraction(this.onInteraction);
35
46
  this.removeListenerIfItExists();
src/engine-components/AudioSource.ts CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { isDevEnvironment } from "../engine/debug/index.js";
5
5
  import { Application, ApplicationEvents } from "../engine/engine_application.js";
6
+ import { findObjectOfType } from "../engine/engine_components.js";
6
7
  import { Mathf } from "../engine/engine_math.js";
7
8
  import { serializable } from "../engine/engine_serialization_decorator.js";
8
9
  import { DeviceUtilities, getParam } from "../engine/engine_utils.js";
@@ -13,84 +14,121 @@
13
14
  const debug = getParam("debugaudio");
14
15
 
15
16
  /**
16
- * The AudioRolloffMode enum describes different ways that audio can attenuate with distance.
17
+ * Defines how audio volume attenuates over distance from the listener.
17
18
  */
18
19
  export enum AudioRolloffMode {
19
- /// <summary>
20
- /// <para>Use this mode when you want a real-world rolloff.</para>
21
- /// </summary>
20
+ /**
21
+ * Logarithmic rolloff provides a natural, real-world attenuation where volume decreases
22
+ * exponentially with distance.
23
+ */
22
24
  Logarithmic = 0,
23
- /// <summary>
24
- /// <para>Use this mode when you want to lower the volume of your sound over the distance.</para>
25
- /// </summary>
25
+
26
+ /**
27
+ * Linear rolloff provides a straightforward volume reduction that decreases at a constant
28
+ * rate with distance.
29
+ */
26
30
  Linear = 1,
27
- /// <summary>
28
- /// <para>Use this when you want to use a custom rolloff.</para>
29
- /// </summary>
31
+
32
+ /**
33
+ * Custom rolloff allows for defining specialized distance-based attenuation curves.
34
+ * Note: Custom rolloff is not fully implemented in this version.
35
+ */
30
36
  Custom = 2,
31
37
  }
32
38
 
33
39
 
34
- /** The AudioSource can be used to play audio in the scene.
35
- * Use `clip` to set the audio file to play.
40
+ /**
41
+ * Plays audio clips in the scene, with support for spatial positioning.
42
+ *
43
+ * The AudioSource component can play audio files or media streams with
44
+ * options for spatial blending, volume control, looping, and more.
45
+ *
46
+ * When a page loses visibility (tab becomes inactive), audio will automatically
47
+ * pause unless {@link playInBackground} is set to true. On mobile devices, audio always
48
+ * pauses regardless of this setting. When the page becomes visible again,
49
+ * previously playing audio will resume.
50
+ *
51
+ * AudioSource also responds to application mute state changes. When the application
52
+ * is muted, the volume is set to 0. When unmuted, the volume
53
+ * returns to its previous value.
54
+ *
36
55
  * @category Multimedia
37
56
  * @group Components
38
57
  */
39
58
  export class AudioSource extends Behaviour {
40
59
 
41
- /** Check if the user has interacted with the page to allow audio playback.
42
- * Internally calling {@link Application.userInteractionRegistered}
60
+ /**
61
+ * Checks if the user has interacted with the page to allow audio playback.
62
+ * Audio playback often requires a user gesture first due to browser autoplay policies.
63
+ * This is the same as calling {@link Application.userInteractionRegistered}.
64
+ *
65
+ * @returns Whether user interaction has been registered to allow audio playback
43
66
  */
44
67
  public static get userInteractionRegistered(): boolean {
45
68
  return Application.userInteractionRegistered;
46
69
  }
47
70
 
48
- /** Register a callback that is called when the user has interacted with the page to allow audio playback.
49
- * Internally calling {@link Application.registerWaitForInteraction}
71
+ /**
72
+ * Registers a callback that will be executed once the user has interacted with the page,
73
+ * allowing audio playback to begin.
74
+ * This is the same as calling {@link Application.registerWaitForInteraction}.
75
+ *
76
+ * @param cb - The callback function to execute when user interaction is registered
50
77
  */
51
78
  public static registerWaitForAllowAudio(cb: Function) {
52
79
  Application.registerWaitForInteraction(cb);
53
80
  }
54
81
 
55
82
  /**
56
- * The audio clip to play. Can be a string (URL) or a MediaStream.
83
+ * The audio clip to play. Can be a URL string pointing to an audio file or a {@link MediaStream} object.
57
84
  */
58
85
  @serializable(URL)
59
86
  clip: string | MediaStream = "";
60
87
 
61
88
  /**
62
- * If true, the audio source will start playing as soon as the scene starts.
63
- * If false, you can call play() to start the audio.
89
+ * When true, the audio will automatically start playing when the component is enabled.
90
+ * When false, you must call play() manually to start audio playback.
64
91
  * @default false
65
- */
92
+ */
66
93
  @serializable()
67
94
  playOnAwake: boolean = false;
68
95
 
69
96
  /**
70
- * If true, the audio source will start loading the audio clip as soon as the scene starts.
71
- * If false, the audio clip will be loaded when play() is called.
97
+ * When true, the audio clip will be loaded during initialization rather than when play() is called.
98
+ * This can reduce playback delay but increases initial loading time.
72
99
  * @default false
73
100
  */
74
101
  @serializable()
75
102
  preload: boolean = false;
76
103
 
77
104
  /**
78
- * When true, the audio will play in the background. This means it will continue playing if the browser tab is not focused/active or minimized
105
+ * When true, audio will continue playing when the browser tab loses focus.
106
+ * When false, audio will pause when the tab is minimized or not active.
79
107
  * @default true
80
108
  */
81
109
  @serializable()
82
110
  playInBackground: boolean = true;
83
111
 
84
112
  /**
85
- * If true, the audio is currently playing.
113
+ * Indicates whether the audio is currently playing.
114
+ *
115
+ * @returns True if the audio is playing, false otherwise
86
116
  */
87
117
  get isPlaying(): boolean { return this.sound?.isPlaying ?? false; }
88
118
 
89
- /** The duration of the audio clip in seconds. */
119
+ /**
120
+ * The total duration of the current audio clip in seconds.
121
+ *
122
+ * @returns Duration in seconds or undefined if no clip is loaded
123
+ */
90
124
  get duration() {
91
125
  return this.sound?.buffer?.duration;
92
126
  }
93
- /** The current time of the audio clip in 0-1 range. */
127
+
128
+ /**
129
+ * The current playback position as a normalized value between 0 and 1.
130
+ * Can be set to seek to a specific position in the audio.
131
+ */
94
132
  get time01() {
95
133
  const duration = this.duration;
96
134
  if (duration && this.sound) {
@@ -106,7 +144,8 @@
106
144
  }
107
145
 
108
146
  /**
109
- * The current time of the audio clip in seconds.
147
+ * The current playback position in seconds.
148
+ * Can be set to seek to a specific time in the audio.
110
149
  */
111
150
  get time(): number { return this.sound?.source ? (this.sound.source?.context.currentTime - this._lastContextTime + this.sound.offset) : 0; }
112
151
  set time(val: number) {
@@ -121,8 +160,8 @@
121
160
  }
122
161
 
123
162
  /**
124
- * If true, the audio source will loop the audio clip.
125
- * If false, the audio clip will play once.
163
+ * When true, the audio will repeat after reaching the end.
164
+ * When false, audio will play once and stop.
126
165
  * @default false
127
166
  */
128
167
  @serializable()
@@ -134,10 +173,12 @@
134
173
  this._loop = val;
135
174
  if (this.sound) this.sound.setLoop(val);
136
175
  }
137
- /** Can be used to play the audio clip in 2D or 3D space.
138
- * 2D Playback is currently not fully supported.
139
- * 0 = 2D, 1 = 3D
140
- * */
176
+
177
+ /**
178
+ * Controls how the audio is positioned in space.
179
+ * Values range from 0 (2D, non-positional) to 1 (fully 3D positioned).
180
+ * Note: 2D playback is not fully supported in the current implementation.
181
+ */
141
182
  @serializable()
142
183
  get spatialBlend(): number {
143
184
  return this._spatialBlend;
@@ -147,6 +188,11 @@
147
188
  this._spatialBlend = val;
148
189
  this._needUpdateSpatialDistanceSettings = true;
149
190
  }
191
+
192
+ /**
193
+ * The minimum distance from the audio source at which the volume starts to attenuate.
194
+ * Within this radius, the audio plays at full volume regardless of distance.
195
+ */
150
196
  @serializable()
151
197
  get minDistance(): number {
152
198
  return this._minDistance;
@@ -156,6 +202,11 @@
156
202
  this._minDistance = val;
157
203
  this._needUpdateSpatialDistanceSettings = true;
158
204
  }
205
+
206
+ /**
207
+ * The maximum distance from the audio source beyond which the volume no longer decreases.
208
+ * This defines the outer limit of the attenuation curve.
209
+ */
159
210
  @serializable()
160
211
  get maxDistance(): number {
161
212
  return this._maxDistance;
@@ -170,6 +221,11 @@
170
221
  private _minDistance: number = 1;
171
222
  private _maxDistance: number = 100;
172
223
 
224
+ /**
225
+ * Controls the overall volume/loudness of the audio.
226
+ * Values range from 0 (silent) to 1 (full volume).
227
+ * @default 1
228
+ */
173
229
  @serializable()
174
230
  get volume(): number { return this._volume; }
175
231
  set volume(val: number) {
@@ -181,6 +237,12 @@
181
237
  }
182
238
  private _volume: number = 1;
183
239
 
240
+ /**
241
+ * Controls the playback rate (speed) of the audio.
242
+ * Values greater than 1 increase speed, values less than 1 decrease it.
243
+ * This affects both speed and pitch of the audio.
244
+ * @default 1
245
+ */
184
246
  @serializable()
185
247
  set pitch(val: number) {
186
248
  if (this.sound) this.sound.setPlaybackRate(val);
@@ -189,6 +251,11 @@
189
251
  return this.sound ? this.sound.getPlaybackRate() : 1;
190
252
  }
191
253
 
254
+ /**
255
+ * Determines how audio volume decreases with distance from the listener.
256
+ * @default AudioRolloffMode.Logarithmic
257
+ * @see {@link AudioRolloffMode}
258
+ */
192
259
  @serializable()
193
260
  rollOffMode: AudioRolloffMode = 0;
194
261
 
@@ -204,10 +271,21 @@
204
271
  private _lastClipStartedLoading: string | MediaStream | null = null;
205
272
  private _audioElement: HTMLAudioElement | null = null;
206
273
 
274
+ /**
275
+ * Returns the underlying {@link PositionalAudio} object, creating it if necessary.
276
+ * The audio source needs a user interaction to be initialized due to browser autoplay policies.
277
+ *
278
+ * @returns The three.js PositionalAudio object or null if unavailable
279
+ */
207
280
  public get Sound(): PositionalAudio | null {
208
281
  if (!this.sound && AudioSource.userInteractionRegistered) {
209
- let listener = GameObject.getComponent(this.context.mainCamera, AudioListener) ?? GameObject.findObjectOfType(AudioListener, this.context);
210
- if (!listener && this.context.mainCamera) listener = GameObject.addComponent(this.context.mainCamera, AudioListener);
282
+ // Get or create an audiolistener in the scene
283
+ let listener = this.gameObject.getComponent(AudioListener) // AudioListener on AudioSource?
284
+ ?? this.context.mainCamera.getComponent(AudioListener) // AudioListener on current main camera?
285
+ ?? findObjectOfType(AudioListener, this.context, false); // Active AudioListener in scene?
286
+
287
+ if (!listener && this.context.mainCamera) listener = this.context.mainCamera.addComponent(AudioListener);
288
+
211
289
  if (listener?.listener) {
212
290
  this.sound = new PositionalAudio(listener.listener);
213
291
  this.gameObject?.add(this.sound);
@@ -250,9 +328,19 @@
250
328
  // }
251
329
  // }
252
330
 
331
+ /**
332
+ * Indicates whether the audio source is queued to play when possible.
333
+ * This may be true before user interaction has been registered.
334
+ *
335
+ * @returns Whether the audio source intends to play
336
+ */
253
337
  public get ShouldPlay(): boolean { return this.shouldPlay; }
254
338
 
255
- /** Get the audio context from the Sound */
339
+ /**
340
+ * Returns the Web Audio API context associated with this audio source.
341
+ *
342
+ * @returns The {@link AudioContext} or null if not available
343
+ */
256
344
  public get audioContext() {
257
345
  return this.sound?.context;
258
346
  }
@@ -344,6 +432,9 @@
344
432
  if (sound.isPlaying)
345
433
  sound.stop();
346
434
 
435
+ // const panner = sound.panner;
436
+ // panner.coneOuterGain = 1;
437
+ // sound.setDirectionalCone(360, 360, 1);
347
438
  // const src = sound.context.createBufferSource();
348
439
  // src.buffer = sound.buffer;
349
440
  // src.connect(sound.panner);
@@ -424,7 +515,12 @@
424
515
  }
425
516
  }
426
517
 
427
- /** Play a audioclip or mediastream */
518
+ /**
519
+ * Plays the audio clip or media stream.
520
+ * If no argument is provided, plays the currently assigned clip.
521
+ *
522
+ * @param clip - Optional audio clip or {@link MediaStream} to play
523
+ */
428
524
  play(clip: string | MediaStream | undefined = undefined) {
429
525
  // use audio source's clip when no clip is passed in
430
526
  if (!clip && this.clip)
@@ -483,7 +579,8 @@
483
579
  }
484
580
 
485
581
  /**
486
- * Pause the audio
582
+ * Pauses audio playback while maintaining the current position.
583
+ * Use play() to resume from the paused position.
487
584
  */
488
585
  pause() {
489
586
  if (debug) console.log("Pause", this);
@@ -497,7 +594,8 @@
497
594
  }
498
595
 
499
596
  /**
500
- * Stop the audio and reset the time to 0
597
+ * Stops audio playback completely and resets the playback position to the beginning.
598
+ * Unlike pause(), calling play() after stop() will start from the beginning.
501
599
  */
502
600
  stop() {
503
601
  if (debug) console.log("Pause", this);
src/engine-components/AvatarLoader.ts CHANGED
@@ -10,17 +10,37 @@
10
10
 
11
11
  const debug = utils.getParam("debugavatar");
12
12
 
13
+ /**
14
+ * Represents an avatar model with head and hands references.
15
+ * Used for representing characters in 3D space.
16
+ */
13
17
  export class AvatarModel {
18
+ /** The root object of the avatar model */
14
19
  root: Object3D;
20
+ /** The head object of the avatar model */
15
21
  head: Object3D;
22
+ /** The left hand object of the avatar model, if available */
16
23
  leftHand: Object3D | null;
24
+ /** The right hand object of the avatar model, if available */
17
25
  rigthHand: Object3D | null;
18
26
 
19
27
 
28
+ /**
29
+ * Checks if the avatar model has a valid configuration.
30
+ * An avatar is considered valid if it has a head.
31
+ * @returns Whether the avatar has a valid setup
32
+ */
20
33
  get isValid(): boolean {
21
34
  return this.head !== null && this.head !== undefined;
22
35
  }
23
36
 
37
+ /**
38
+ * Creates a new avatar model.
39
+ * @param root The root object of the avatar
40
+ * @param head The head object of the avatar
41
+ * @param leftHand The left hand object of the avatar
42
+ * @param rigthHand The right hand object of the avatar
43
+ */
24
44
  constructor(root: Object3D, head: Object3D, leftHand: Object3D | null, rigthHand: Object3D | null) {
25
45
  this.root = root;
26
46
  this.head = head;
@@ -33,12 +53,25 @@
33
53
  }
34
54
  }
35
55
 
56
+ /**
57
+ * Handles loading and instantiating avatar models from various sources.
58
+ * Provides functionality to find and extract important parts of an avatar (head, hands).
59
+ *
60
+ * Debug mode can be enabled with the URL parameter `?debugavatar`,
61
+ * which will log detailed information about avatar loading and configuration.
62
+ */
36
63
  export class AvatarLoader {
37
64
 
38
65
  private readonly avatarRegistryUrl: string | null = null;
39
66
  // private loader: GLTFLoader | null;
40
67
  // private avatarModelCache: Map<string, AvatarModel | null> = new Map<string, AvatarModel | null>();
41
68
 
69
+ /**
70
+ * Retrieves or creates a new avatar instance from an ID or existing Object3D.
71
+ * @param context The application context
72
+ * @param avatarId Either a string ID to load an avatar or an existing Object3D to use as avatar
73
+ * @returns Promise resolving to an AvatarModel if successful, or null if failed
74
+ */
42
75
  public async getOrCreateNewAvatarInstance(context: Context, avatarId: string | Object3D): Promise<AvatarModel | null> {
43
76
 
44
77
  if (!avatarId) {
@@ -76,6 +109,12 @@
76
109
  }
77
110
 
78
111
 
112
+ /**
113
+ * Loads an avatar model from a file or registry using the provided ID.
114
+ * @param context The engine context
115
+ * @param avatarId The ID of the avatar to load
116
+ * @returns Promise resolving to the loaded avatar's Object3D, or null if failed
117
+ */
79
118
  private async loadAvatar(context: Context, avatarId: string): Promise<Object3D | null> {
80
119
 
81
120
  console.assert(avatarId !== undefined && avatarId !== null && typeof avatarId === "string", "Avatar id must not be null");
@@ -140,11 +179,20 @@
140
179
  });
141
180
  }
142
181
 
182
+ /**
183
+ * Caches an avatar model for reuse.
184
+ * @param _id The ID to associate with the model
185
+ * @param _model The avatar model to cache
186
+ */
143
187
  private cacheModel(_id: string, _model: AvatarModel) {
144
188
  // this.avatarModelCache.set(id, model);
145
189
  }
146
190
 
147
- // TODO this should be burned to the ground once 🤞 we have proper extras that define object relations.
191
+ /**
192
+ * Analyzes an Object3D to find avatar parts (head, hands) based on naming conventions.
193
+ * @param obj The Object3D to search for avatar parts
194
+ * @returns A structured AvatarModel with references to found parts
195
+ */
148
196
  private findAvatar(obj: Object3D): AvatarModel {
149
197
 
150
198
  const root: Object3D = obj;
@@ -175,7 +223,12 @@
175
223
  return model;
176
224
  }
177
225
 
178
-
226
+ /**
227
+ * Recursively searches for an avatar part by name within an Object3D hierarchy.
228
+ * @param obj The Object3D to search within
229
+ * @param searchString Array of strings that should all be present in the object name
230
+ * @returns The found Object3D part or null if not found
231
+ */
179
232
  private findAvatarPart(obj: Object3D, searchString: string[]): Object3D | null {
180
233
 
181
234
  const name = obj.name.toLowerCase();
@@ -196,6 +249,12 @@
196
249
  return null;
197
250
  }
198
251
 
252
+ /**
253
+ * Handles HTTP response errors from avatar loading operations.
254
+ * @param response The fetch API response to check
255
+ * @returns The response if it was ok
256
+ * @throws Error with status text if response was not ok
257
+ */
199
258
  private handleCustomAvatarErrors(response) {
200
259
  if (!response.ok) {
201
260
  throw Error(response.statusText);
src/engine-components/AxesHelper.ts CHANGED
@@ -5,20 +5,37 @@
5
5
  import { Behaviour } from "./Component.js";
6
6
 
7
7
  /**
8
- * AxesHelper is a component that displays the axes of the object in the scene.
8
+ * Component that visualizes the axes of an object in the scene.
9
+ * Renders colored lines representing the X (red), Y (green) and Z (blue) axes.
9
10
  * @category Helpers
10
11
  * @group Components
11
12
  */
12
13
  export class AxesHelper extends Behaviour {
14
+ /**
15
+ * The length of each axis line in scene units.
16
+ */
13
17
  @serializable()
14
18
  public length: number = 1;
19
+
20
+ /**
21
+ * Whether the axes should be occluded by objects in the scene.
22
+ * When set to false, axes will always appear on top regardless of their depth.
23
+ */
15
24
  @serializable()
16
25
  public depthTest: boolean = true;
26
+
27
+ /**
28
+ * When true, this helper will only be visible if the debug flag `?gizmos` is enabled.
29
+ */
17
30
  @serializable()
18
31
  public isGizmo: boolean = false;
19
32
 
20
33
  private _axes: _AxesHelper | null = null;
21
34
 
35
+ /**
36
+ * Creates and adds the axes visualization to the scene when the component is enabled.
37
+ * If marked as a gizmo, it will only be shown when gizmos are enabled in the global parameters.
38
+ */
22
39
  onEnable(): void {
23
40
  if (this.isGizmo && !params.showGizmos) return;
24
41
  if (!this._axes)
@@ -33,6 +50,9 @@
33
50
  }
34
51
  }
35
52
 
53
+ /**
54
+ * Removes the axes visualization from the scene when the component is disabled.
55
+ */
36
56
  onDisable(): void {
37
57
  if (!this._axes) return;
38
58
  this.gameObject.remove(this._axes);
src/engine-components/BoxHelperComponent.ts CHANGED
@@ -9,11 +9,17 @@
9
9
  const debug = getParam("debugboxhelper");
10
10
 
11
11
  /**
12
+ * A component that creates a bounding box around an object and provides intersection testing functionality.
13
+ *
14
+ * Debug mode can be enabled with the URL parameter `?debugboxhelper`, which will visualize intersection tests.
15
+ * Helper visualization can be enabled with the URL parameter `?gizmos`.
16
+ *
12
17
  * @category Helpers
13
18
  * @group Components
14
19
  */
15
20
  export class BoxHelperComponent extends Behaviour {
16
21
 
22
+ /** The bounding box for this component */
17
23
  private box: Box3 | null = null;
18
24
  private static testBox: Box3 = new Box3();
19
25
  private _lastMatrixUpdateFrame: number = -1;
@@ -21,6 +27,11 @@
21
27
  private static _size: Vector3 = new Vector3(.01, .01, .01);
22
28
  private static _emptyObjectSize: Vector3 = new Vector3(.01, .01, .01);
23
29
 
30
+ /**
31
+ * Tests if an object intersects with this helper's bounding box
32
+ * @param obj The object to test for intersection
33
+ * @returns True if objects intersect, false if not, undefined if the provided object is invalid
34
+ */
24
35
  public isInBox(obj: Object3D): boolean | undefined {
25
36
  if (!obj) return undefined;
26
37
 
@@ -44,11 +55,21 @@
44
55
  return intersects;
45
56
  }
46
57
 
58
+ /**
59
+ * Tests if this helper's bounding box intersects with another box
60
+ * @param box The {@link Box3} to test for intersection
61
+ * @returns True if boxes intersect, false otherwise
62
+ */
47
63
  public intersects(box: Box3): boolean {
48
64
  if (!box) return false;
49
65
  return this.updateBox(false).intersectsBox(box);
50
66
  }
51
67
 
68
+ /**
69
+ * Updates the helper's bounding box based on the gameObject's position and scale
70
+ * @param force Whether to force an update regardless of frame count
71
+ * @returns The updated {@link Box3}
72
+ */
52
73
  public updateBox(force: boolean = false): Box3 {
53
74
  if (!this.box) {
54
75
  this.box = new Box3();
@@ -74,6 +95,11 @@
74
95
  this.box = null;
75
96
  }
76
97
 
98
+ /**
99
+ * Creates and displays a visual wireframe representation of this box helper
100
+ * @param col Optional color for the wireframe. If not provided, uses default color
101
+ * @param force If true, shows the helper even if gizmos are disabled
102
+ */
77
103
  public showHelper(col: ColorRepresentation | null = null, force: boolean = false) {
78
104
  if (!gizmos && !force) return;
79
105
  if (this._helper) {
src/engine-components/Camera.ts CHANGED
@@ -13,8 +13,11 @@
13
13
  import { Behaviour, GameObject } from "./Component.js";
14
14
  import { OrbitControls } from "./OrbitControls.js";
15
15
 
16
- /** The ClearFlags enum is used to determine how the camera clears the background */
16
+ /**
17
+ * The ClearFlags enum is used to determine how the camera clears the background
18
+ */
17
19
  export enum ClearFlags {
20
+ /** Don't clear the background */
18
21
  None = 0,
19
22
  /** Clear the background with a skybox */
20
23
  Skybox = 1,
@@ -28,16 +31,28 @@
28
31
  const debugscreenpointtoray = getParam("debugscreenpointtoray");
29
32
 
30
33
  /**
34
+ * Camera component that handles rendering from a specific viewpoint in the scene.
35
+ * Supports both perspective and orthographic cameras with various rendering options.
36
+ * Internally, this component uses {@link PerspectiveCamera} and {@link OrthographicCamera} three.js objects.
37
+ *
31
38
  * @category Camera Controls
32
39
  * @group Components
33
40
  */
34
41
  export class Camera extends Behaviour implements ICamera {
35
42
 
43
+ /**
44
+ * Returns whether this component is a camera
45
+ * @returns {boolean} Always returns true
46
+ */
36
47
  get isCamera() {
37
48
  return true;
38
49
  }
39
50
 
40
- /** The camera's aspect ratio (width divided by height) if it is a perspective camera */
51
+ /**
52
+ * Gets or sets the camera's aspect ratio (width divided by height).
53
+ * For perspective cameras, this directly affects the camera's projection matrix.
54
+ * When set, automatically updates the projection matrix.
55
+ */
41
56
  get aspect(): number {
42
57
  if (this._cam instanceof PerspectiveCamera) return this._cam.aspect;
43
58
  return (this.context.domWidth / this.context.domHeight);
@@ -51,7 +66,11 @@
51
66
  }
52
67
  }
53
68
  }
54
- /** The camera's field of view in degrees if it is a perspective camera. Calls updateProjectionMatrix when set */
69
+
70
+ /**
71
+ * Gets or sets the camera's field of view in degrees for perspective cameras.
72
+ * When set, automatically updates the projection matrix.
73
+ */
55
74
  get fieldOfView(): number | undefined {
56
75
  if (this._cam instanceof PerspectiveCamera) {
57
76
  return this._cam.fov;
@@ -74,7 +93,11 @@
74
93
  }
75
94
  }
76
95
 
77
- /** The camera's near clipping plane. Calls updateProjectionMatrix when set */
96
+ /**
97
+ * Gets or sets the camera's near clipping plane distance.
98
+ * Objects closer than this distance won't be rendered.
99
+ * When set, automatically updates the projection matrix.
100
+ */
78
101
  get nearClipPlane(): number { return this._nearClipPlane; }
79
102
  @serializable()
80
103
  set nearClipPlane(val) {
@@ -87,7 +110,11 @@
87
110
  }
88
111
  private _nearClipPlane: number = 0.1;
89
112
 
90
- /** The camera's far clipping plane. Calls updateProjectionMatrix when set */
113
+ /**
114
+ * Gets or sets the camera's far clipping plane distance.
115
+ * Objects farther than this distance won't be rendered.
116
+ * When set, automatically updates the projection matrix.
117
+ */
91
118
  get farClipPlane(): number { return this._farClipPlane; }
92
119
  @serializable()
93
120
  set farClipPlane(val) {
@@ -101,7 +128,8 @@
101
128
  private _farClipPlane: number = 1000;
102
129
 
103
130
  /**
104
- * Applys both the camera's near and far plane and calls updateProjectionMatrix on the camera.
131
+ * Applies both the camera's near and far clipping planes and updates the projection matrix.
132
+ * This ensures rendering occurs only within the specified distance range.
105
133
  */
106
134
  applyClippingPlane() {
107
135
  if (this._cam) {
@@ -111,7 +139,10 @@
111
139
  }
112
140
  }
113
141
 
114
- /** The camera's clear flags - determines if the background is a skybox or a solid color or transparent */
142
+ /**
143
+ * Gets or sets the camera's clear flags that determine how the background is rendered.
144
+ * Options include skybox, solid color, or transparent background.
145
+ */
115
146
  @serializable()
116
147
  public get clearFlags(): ClearFlags {
117
148
  return this._clearFlags;
@@ -121,18 +152,31 @@
121
152
  this._clearFlags = val;
122
153
  this.applyClearFlagsIfIsActiveCamera();
123
154
  }
155
+
156
+ /**
157
+ * Determines if the camera should use orthographic projection instead of perspective.
158
+ */
124
159
  @serializable()
125
160
  public orthographic: boolean = false;
161
+
162
+ /**
163
+ * The size of the orthographic camera's view volume when in orthographic mode.
164
+ * Larger values show more of the scene.
165
+ */
126
166
  @serializable()
127
167
  public orthographicSize: number = 5;
128
168
 
169
+ /**
170
+ * Controls the transparency level of the camera background in AR mode on supported devices.
171
+ * Value from 0 (fully transparent) to 1 (fully opaque).
172
+ */
129
173
  @serializable()
130
174
  public ARBackgroundAlpha: number = 0;
131
175
 
132
176
  /**
133
- * The [`mask`](https://threejs.org/docs/#api/en/core/Layers.mask) value of the three camera object layers
134
- * If you want to just see objects on one layer (e.g. layer 2) then you can use `cullingLayer = 2` on this camera component instead
135
- */
177
+ * Gets or sets the layers mask that determines which objects this camera will render.
178
+ * Uses the {@link https://threejs.org/docs/#api/en/core/Layers.mask|three.js layers mask} convention.
179
+ */
136
180
  @serializable()
137
181
  public set cullingMask(val: number) {
138
182
  this._cullingMask = val;
@@ -146,14 +190,19 @@
146
190
  }
147
191
  private _cullingMask: number = 0xffffffff;
148
192
 
149
- /** Set only a specific layer active to be rendered by the camera.
150
- * This is equivalent to calling `layers.set(val)`
151
- **/
193
+ /**
194
+ * Sets only a specific layer to be active for rendering by this camera.
195
+ * This is equivalent to calling `layers.set(val)` on the three.js camera object.
196
+ * @param val The layer index to set active
197
+ */
152
198
  public set cullingLayer(val: number) {
153
199
  this.cullingMask = (1 << val | 0) >>> 0;
154
200
  }
155
201
 
156
- /** The blurriness of the background texture (when using a skybox) */
202
+ /**
203
+ * Gets or sets the blurriness of the skybox background.
204
+ * Values range from 0 (sharp) to 1 (maximum blur).
205
+ */
157
206
  @serializable()
158
207
  public set backgroundBlurriness(val: number | undefined) {
159
208
  if (val === this._backgroundBlurriness) return;
@@ -168,7 +217,10 @@
168
217
  }
169
218
  private _backgroundBlurriness?: number = undefined;
170
219
 
171
- /** The intensity of the background texture (when using a skybox) */
220
+ /**
221
+ * Gets or sets the intensity of the skybox background.
222
+ * Values range from 0 (dark) to 10 (very bright).
223
+ */
172
224
  @serializable()
173
225
  public set backgroundIntensity(val: number | undefined) {
174
226
  if (val === this._backgroundIntensity) return;
@@ -183,7 +235,10 @@
183
235
  }
184
236
  private _backgroundIntensity?: number = undefined;
185
237
 
186
- /** the rotation of the background texture (when using a skybox) */
238
+ /**
239
+ * Gets or sets the rotation of the skybox background.
240
+ * Controls the orientation of the environment map.
241
+ */
187
242
  @serializable(Euler)
188
243
  public set backgroundRotation(val: Euler | undefined) {
189
244
  if (val === this._backgroundRotation) return;
@@ -198,7 +253,10 @@
198
253
  }
199
254
  private _backgroundRotation?: Euler = undefined;
200
255
 
201
- /** The intensity of the environment map */
256
+ /**
257
+ * Gets or sets the intensity of the environment lighting.
258
+ * Controls how strongly the environment map affects scene lighting.
259
+ */
202
260
  @serializable()
203
261
  public set environmentIntensity(val: number | undefined) {
204
262
  this._environmentIntensity = val;
@@ -208,7 +266,10 @@
208
266
  }
209
267
  private _environmentIntensity?: number = undefined;
210
268
 
211
- /** The background color of the camera when {@link ClearFlags} are set to `SolidColor` */
269
+ /**
270
+ * Gets or sets the background color of the camera when {@link ClearFlags} is set to {@link ClearFlags.SolidColor}.
271
+ * The alpha component controls transparency.
272
+ */
212
273
  @serializable(RGBAColor)
213
274
  public get backgroundColor(): RGBAColor | null {
214
275
  return this._backgroundColor ?? null;
@@ -225,9 +286,10 @@
225
286
  this.applyClearFlagsIfIsActiveCamera();
226
287
  }
227
288
 
228
- /** The texture that the camera should render to
229
- * It can be used to render to a {@link Texture} instead of the screen.
230
- */
289
+ /**
290
+ * Gets or sets the texture that the camera should render to instead of the screen.
291
+ * Useful for creating effects like mirrors, portals or custom post processing.
292
+ */
231
293
  @serializable(RenderTexture)
232
294
  public set targetTexture(rt: RenderTexture | null) {
233
295
  this._targetTexture = rt;
@@ -244,16 +306,17 @@
244
306
  private _skybox?: CameraSkybox;
245
307
 
246
308
  /**
247
- * Get the three.js camera object. This will create a camera if it does not exist yet.
248
- * @returns {PerspectiveCamera | OrthographicCamera} the three camera
249
- * @deprecated use {@link threeCamera} instead
309
+ * Gets the three.js camera object. Creates one if it doesn't exist yet.
310
+ * @returns {PerspectiveCamera | OrthographicCamera} The three.js camera object
311
+ * @deprecated Use {@link threeCamera} instead
250
312
  */
251
313
  public get cam(): PerspectiveCamera | OrthographicCamera {
252
314
  return this.threeCamera;
253
315
  }
316
+
254
317
  /**
255
- * Get the three.js camera object. This will create a camera if it does not exist yet.
256
- * @returns {PerspectiveCamera | OrthographicCamera} the three camera
318
+ * Gets the three.js camera object. Creates one if it doesn't exist yet.
319
+ * @returns {PerspectiveCamera | OrthographicCamera} The three.js camera object
257
320
  */
258
321
  public get threeCamera(): PerspectiveCamera | OrthographicCamera {
259
322
  if (this.activeAndEnabled)
@@ -263,6 +326,16 @@
263
326
 
264
327
  private static _origin: Vector3 = new Vector3();
265
328
  private static _direction: Vector3 = new Vector3();
329
+
330
+ /**
331
+ * Converts screen coordinates to a ray in world space.
332
+ * Useful for implementing picking or raycasting from screen to world.
333
+ *
334
+ * @param x The x screen coordinate
335
+ * @param y The y screen coordinate
336
+ * @param ray Optional ray object to reuse instead of creating a new one
337
+ * @returns {Ray} A ray originating from the camera position pointing through the screen point
338
+ */
266
339
  public screenPointToRay(x: number, y: number, ray?: Ray): Ray {
267
340
  const cam = this.threeCamera;
268
341
  const origin = Camera._origin;
@@ -285,9 +358,12 @@
285
358
  }
286
359
 
287
360
  private _frustum?: Frustum;
361
+
288
362
  /**
289
- * Get a frustum - it will be created the first time this method is called and updated every frame in onBeforeRender when it exists.
290
- * You can also manually update it using the updateFrustum method.
363
+ * Gets the camera's view frustum for culling and visibility checks.
364
+ * Creates the frustum if it doesn't exist and returns it.
365
+ *
366
+ * @returns {Frustum} The camera's view frustum
291
367
  */
292
368
  public getFrustum(): Frustum {
293
369
  if (!this._frustum) {
@@ -296,13 +372,22 @@
296
372
  }
297
373
  return this._frustum;
298
374
  }
299
- /** Force frustum update - note that this also happens automatically every frame in onBeforeRender */
375
+
376
+ /**
377
+ * Forces an update of the camera's frustum.
378
+ * This is automatically called every frame in onBeforeRender.
379
+ */
300
380
  public updateFrustum() {
301
381
  if (!this._frustum) this._frustum = new Frustum();
302
382
  this._frustum.setFromProjectionMatrix(this.getProjectionScreenMatrix(this._projScreenMatrix, true), this.context.renderer.coordinateSystem);
303
383
  }
384
+
304
385
  /**
305
- * @returns {Matrix4} this camera's projection screen matrix.
386
+ * Gets this camera's projection-screen matrix.
387
+ *
388
+ * @param target Matrix4 object to store the result in
389
+ * @param forceUpdate Whether to force recalculation of the matrix
390
+ * @returns {Matrix4} The requested projection screen matrix
306
391
  */
307
392
  public getProjectionScreenMatrix(target: Matrix4, forceUpdate?: boolean) {
308
393
  if (forceUpdate) {
@@ -313,7 +398,6 @@
313
398
  }
314
399
  private readonly _projScreenMatrix = new Matrix4();
315
400
 
316
-
317
401
  /** @internal */
318
402
  awake() {
319
403
  if (debugscreenpointtoray) {
@@ -382,8 +466,9 @@
382
466
  }
383
467
 
384
468
  /**
385
- * Creates a {@link PerspectiveCamera} if it does not exist yet and set the camera's properties. This is internally also called when accessing the {@link cam} property.
386
- **/
469
+ * Creates a three.js camera object if it doesn't exist yet and sets its properties.
470
+ * This is called internally when accessing the {@link threeCamera} property.
471
+ */
387
472
  buildCamera() {
388
473
  if (this._cam) return;
389
474
 
@@ -428,13 +513,21 @@
428
513
  }
429
514
  }
430
515
 
516
+ /**
517
+ * Applies clear flags if this is the active main camera.
518
+ * @param opts Options for applying clear flags
519
+ */
431
520
  applyClearFlagsIfIsActiveCamera(opts?: { applySkybox: boolean }) {
432
521
  if (this.context.mainCameraComponent === this) {
433
522
  this.applyClearFlags(opts);
434
523
  }
435
524
  }
436
525
 
437
- /** Apply this camera's clear flags and related settings to the renderer */
526
+ /**
527
+ * Applies this camera's clear flags and related settings to the renderer.
528
+ * This controls how the background is rendered (skybox, solid color, transparent).
529
+ * @param opts Options for applying clear flags
530
+ */
438
531
  applyClearFlags(opts?: { applySkybox: boolean }) {
439
532
  if (!this._cam) {
440
533
  if (debug) console.log("Camera does not exist (apply clear flags)")
@@ -503,7 +596,7 @@
503
596
  }
504
597
 
505
598
  /**
506
- * Apply the skybox to the scene
599
+ * Applies the skybox texture to the scene background.
507
600
  */
508
601
  applySceneSkybox() {
509
602
  if (!this._skybox)
@@ -511,9 +604,12 @@
511
604
  this._skybox.apply();
512
605
  }
513
606
 
514
- /** Used to determine if the background should be transparent when in pass through AR
515
- * @returns true when in XR on a pass through device where the background shouldbe invisible
516
- **/
607
+ /**
608
+ * Determines if the background should be transparent when in passthrough AR mode.
609
+ *
610
+ * @param context The current rendering context
611
+ * @returns {boolean} True when in XR on a pass through device where the background should be invisible
612
+ */
517
613
  static backgroundShouldBeTransparent(context: Context) {
518
614
  const session = context.renderer.xr?.getSession();
519
615
  if (!session) return false;
@@ -543,7 +639,10 @@
543
639
  }
544
640
  }
545
641
 
546
-
642
+ /**
643
+ * Helper class for managing skybox textures for cameras.
644
+ * Handles retrieving and applying skybox textures to the scene.
645
+ */
547
646
  class CameraSkybox {
548
647
 
549
648
  private _camera: Camera;
@@ -555,6 +654,10 @@
555
654
  this._camera = camera;
556
655
  }
557
656
 
657
+ /**
658
+ * Applies the skybox texture to the scene background.
659
+ * Retrieves the texture based on the camera's source ID.
660
+ */
558
661
  apply() {
559
662
  this._skybox = this.context.lightmaps.tryGetSkybox(this._camera.sourceId) as Texture;
560
663
  if (!this._skybox) {
@@ -572,13 +675,16 @@
572
675
  }
573
676
  }
574
677
 
575
-
678
+ /**
679
+ * Adds orbit controls to the camera if the freecam URL parameter is enabled.
680
+ *
681
+ * @param cam The camera to potentially add orbit controls to
682
+ */
576
683
  function handleFreeCam(cam: Camera) {
577
684
  const isFreecam = getParam("freecam");
578
685
  if (isFreecam) {
579
686
  if (cam.context.mainCameraComponent === cam) {
580
687
  GameObject.getOrAddComponent(cam.gameObject, OrbitControls);
581
688
  }
582
-
583
689
  }
584
690
  }
src/engine-components/CameraUtils.ts CHANGED
@@ -13,6 +13,13 @@
13
13
 
14
14
  const debug = getParam("debugmissingcamera");
15
15
 
16
+ /**
17
+ * Handler for missing camera events. Creates a default fallback camera when no camera is found in the scene.
18
+ * Sets up camera properties based on the context and HTML element attributes.
19
+ *
20
+ * @param evt The context event containing scene and configuration information
21
+ * @returns The created camera component
22
+ */
16
23
  ContextRegistry.registerCallback(ContextEvent.MissingCamera, (evt) => {
17
24
  if (debug) console.warn("Creating missing camera")
18
25
  const scene = evt.context.scene;
@@ -57,6 +64,12 @@
57
64
  return cam;
58
65
  });
59
66
 
67
+ /**
68
+ * Handler for context creation events. Checks if camera controls should be added
69
+ * to the main camera when the context is created.
70
+ *
71
+ * @param evt The context creation event containing the context information
72
+ */
60
73
  ContextRegistry.registerCallback(ContextEvent.ContextCreated, (evt) => {
61
74
  if (!evt.context.mainCamera) {
62
75
  if (debug) console.log("Will not auto-fit because a default camera exists");
@@ -77,6 +90,13 @@
77
90
  }
78
91
  })
79
92
 
93
+ /**
94
+ * Creates default orbit camera controls for the specified camera.
95
+ * Configures auto-rotation and auto-fit settings based on HTML attributes.
96
+ *
97
+ * @param context The rendering context
98
+ * @param cam Optional camera component to attach controls to (uses main camera if not specified)
99
+ */
80
100
  function createDefaultCameraControls(context: IContext, cam?: ICamera) {
81
101
 
82
102
  cam = cam ?? context.mainCameraComponent;
src/engine-components/ui/Canvas.ts CHANGED
@@ -141,14 +141,14 @@
141
141
  }
142
142
 
143
143
  start() {
144
- this.onUpdateRenderMode();
144
+ this.applyRenderSettings();
145
145
  }
146
146
 
147
147
  onEnable() {
148
148
  super.onEnable();
149
149
  this._updateRenderSettingsRoutine = undefined;
150
150
  this._lastMatrixWorld = new Matrix4();
151
- this.onUpdateRenderMode();
151
+ this.applyRenderSettings();
152
152
  document.addEventListener("resize", this._boundRenderSettingsChanged);
153
153
  // We want to run AFTER all regular onBeforeRender callbacks
154
154
  this.context.pre_render_callbacks.push(this.onBeforeRenderRoutine);
src/engine-components/Collider.ts CHANGED
@@ -1,59 +1,63 @@
1
1
  import { BufferGeometry, Group, Mesh, Object3D, Vector3 } from "three"
2
2
 
3
+ import { isDevEnvironment } from "../engine/debug/index.js";
3
4
  import { addComponent } from "../engine/engine_components.js";
4
5
  import { Gizmos } from "../engine/engine_gizmos.js";
5
6
  import type { PhysicsMaterial } from "../engine/engine_physics.types.js";
6
7
  import { serializable } from "../engine/engine_serialization_decorator.js";
7
- import { getBoundingBox, getWorldScale } from "../engine/engine_three_utils.js";
8
- // import { IColliderProvider, registerColliderProvider } from "../engine/engine_physics.js";
8
+ import { getBoundingBox } from "../engine/engine_three_utils.js";
9
9
  import type { IBoxCollider, ICollider, ISphereCollider } from "../engine/engine_types.js";
10
10
  import { validate } from "../engine/engine_util_decorator.js";
11
- import { unwatchWrite, watchWrite } from "../engine/engine_utils.js";
11
+ import { getParam, unwatchWrite, watchWrite } from "../engine/engine_utils.js";
12
12
  import { NEEDLE_progressive } from "../engine/extensions/NEEDLE_progressive.js";
13
13
  import { Behaviour } from "./Component.js";
14
14
  import { Rigidbody } from "./RigidBody.js";
15
15
 
16
16
  /**
17
17
  * Collider is the base class for all colliders. A collider is a physical shape that is used to detect collisions with other objects in the scene.
18
- * Colliders are used in combination with a Rigidbody to create physical interactions between objects.
18
+ * Colliders are used in combination with a {@link Rigidbody} to create physical interactions between objects.
19
19
  * Colliders are registered with the physics engine when they are enabled and removed when they are disabled.
20
20
  * @category Physics
21
21
  * @group Components
22
22
  */
23
23
  export class Collider extends Behaviour implements ICollider {
24
24
 
25
- /** @internal */
25
+ /**
26
+ * Identifies this component as a collider.
27
+ * @internal
28
+ */
26
29
  get isCollider(): any {
27
30
  return true;
28
31
  }
29
32
 
30
33
  /**
31
- * The Rigidbody that this collider is attached to.
34
+ * The {@link Rigidbody} that this collider is attached to. This handles the physics simulation for this collider.
32
35
  */
33
36
  @serializable(Rigidbody)
34
37
  attachedRigidbody: Rigidbody | null = null;
35
38
 
36
39
  /**
37
40
  * When `true` the collider will not be used for collision detection but will still trigger events.
41
+ * Trigger colliders can trigger events when other colliders enter their space, without creating a physical response/collision.
38
42
  */
39
43
  @serializable()
40
44
  isTrigger: boolean = false;
41
45
 
42
46
  /**
43
- * The physics material that is used for the collider. This material defines physical properties of the collider such as friction and bounciness.
47
+ * The physics material that defines physical properties of the collider such as friction and bounciness.
44
48
  */
45
49
  @serializable()
46
50
  sharedMaterial?: PhysicsMaterial;
47
51
 
48
52
  /**
49
- * The layers that the collider is assigned to.
53
+ * The layers that this collider belongs to. Used for filtering collision detection.
54
+ * @default [0]
50
55
  */
51
56
  @serializable()
52
57
  membership: number[] = [0];
53
58
 
54
59
  /**
55
- * The layers that the collider will interact with.
56
- * @inheritdoc
60
+ * The layers that this collider will interact with. Used for filtering collision detection.
57
61
  */
58
62
  @serializable()
59
63
  filter?: number[];
@@ -80,62 +84,91 @@
80
84
  this.context.physics.engine?.removeBody(this);
81
85
  }
82
86
 
83
- /** Returns the underlying physics body from the physics engine (if any) - the component must be enabled and active in the scene */
87
+ /**
88
+ * Returns the underlying physics body from the physics engine.
89
+ * Only available if the component is enabled and active in the scene.
90
+ */
84
91
  get body() {
85
92
  return this.context.physics.engine?.getBody(this);
86
93
  }
87
94
 
88
95
  /**
89
- * Apply the collider properties to the physics engine.
96
+ * Updates the collider's properties in the physics engine.
97
+ * Use this when you've changed collider properties and need to sync with the physics engine.
90
98
  */
91
99
  updateProperties = () => {
92
100
  this.context.physics.engine?.updateProperties(this);
93
101
  }
94
102
 
95
- /** Requests an update of the physics material in the physics engine */
103
+ /**
104
+ * Updates the physics material in the physics engine.
105
+ * Call this after changing the sharedMaterial property.
106
+ */
96
107
  updatePhysicsMaterial() {
97
108
  this.context.physics.engine?.updatePhysicsMaterial(this);
98
-
99
109
  }
100
110
  }
101
111
 
102
112
  /**
103
- * SphereCollider is a collider that represents a sphere shape.
113
+ * SphereCollider represents a sphere-shaped collision volume.
114
+ * Useful for objects that are roughly spherical in shape or need a simple collision boundary.
104
115
  * @category Physics
105
116
  * @group Components
106
117
  */
107
118
  export class SphereCollider extends Collider implements ISphereCollider {
108
119
 
120
+ /**
121
+ * The radius of the sphere collider.
122
+ */
109
123
  @validate()
110
124
  @serializable()
111
125
  radius: number = .5;
112
126
 
127
+ /**
128
+ * The center position of the sphere collider relative to the transform's position.
129
+ */
113
130
  @serializable(Vector3)
114
131
  center: Vector3 = new Vector3(0, 0, 0);
115
132
 
133
+ /**
134
+ * Registers the sphere collider with the physics engine and sets up scale change monitoring.
135
+ */
116
136
  onEnable() {
117
137
  super.onEnable();
118
138
  this.context.physics.engine?.addSphereCollider(this);
119
139
  watchWrite(this.gameObject.scale, this.updateProperties);
120
140
  }
121
141
 
142
+ /**
143
+ * Removes scale change monitoring when the collider is disabled.
144
+ */
122
145
  onDisable(): void {
123
146
  super.onDisable();
124
147
  unwatchWrite(this.gameObject.scale, this.updateProperties);
125
148
  }
126
149
 
150
+ /**
151
+ * Updates collider properties when validated in the editor or inspector.
152
+ */
127
153
  onValidate(): void {
128
154
  this.updateProperties();
129
155
  }
130
156
  }
131
157
 
132
158
  /**
133
- * BoxCollider is a collider that represents a box shape.
159
+ * BoxCollider represents a box-shaped collision volume.
160
+ * Ideal for rectangular objects or objects that need a simple cuboid collision boundary.
134
161
  * @category Physics
135
162
  * @group Components
136
163
  */
137
164
  export class BoxCollider extends Collider implements IBoxCollider {
138
165
 
166
+ /**
167
+ * Creates and adds a BoxCollider to the given object.
168
+ * @param obj The object to add the collider to
169
+ * @param opts Configuration options for the collider and optional rigidbody
170
+ * @returns The newly created BoxCollider
171
+ */
139
172
  static add(obj: Mesh | Object3D, opts?: { rigidbody: boolean, debug?: boolean }) {
140
173
  const collider = addComponent(obj, BoxCollider);
141
174
  collider.autoFit();
@@ -146,31 +179,51 @@
146
179
  return collider;
147
180
  }
148
181
 
182
+ /**
183
+ * The size of the box collider along each axis.
184
+ */
149
185
  @validate()
150
186
  @serializable(Vector3)
151
187
  size: Vector3 = new Vector3(1, 1, 1);
152
188
 
189
+ /**
190
+ * The center position of the box collider relative to the transform's position.
191
+ */
153
192
  @serializable(Vector3)
154
193
  center: Vector3 = new Vector3(0, 0, 0);
155
194
 
156
- /** @internal */
195
+ /**
196
+ * Registers the box collider with the physics engine and sets up scale change monitoring.
197
+ * @internal
198
+ */
157
199
  onEnable() {
158
200
  super.onEnable();
159
201
  this.context.physics.engine?.addBoxCollider(this, this.size);
160
202
  watchWrite(this.gameObject.scale, this.updateProperties);
161
203
  }
162
204
 
163
- /** @internal */
205
+ /**
206
+ * Removes scale change monitoring when the collider is disabled.
207
+ * @internal
208
+ */
164
209
  onDisable(): void {
165
210
  super.onDisable();
166
211
  unwatchWrite(this.gameObject.scale, this.updateProperties);
167
212
  }
168
213
 
169
- /** @internal */
214
+ /**
215
+ * Updates collider properties when validated in the editor or inspector.
216
+ * @internal
217
+ */
170
218
  onValidate(): void {
171
219
  this.updateProperties();
172
220
  }
173
221
 
222
+ /**
223
+ * Automatically fits the collider to the geometry of the object.
224
+ * Sets the size and center based on the object's bounding box.
225
+ * @param opts Options object with a debug flag to visualize the bounding box
226
+ */
174
227
  autoFit(opts?: { debug?: boolean }) {
175
228
  const obj = this.gameObject;
176
229
 
@@ -205,24 +258,31 @@
205
258
  }
206
259
 
207
260
  /**
208
- * MeshCollider is a collider that represents a mesh shape.
209
- * The mesh collider can be used to create a collider from a mesh.
261
+ * MeshCollider creates a collision shape from a mesh geometry.
262
+ * Allows for complex collision shapes that match the exact geometry of an object.
210
263
  * @category Physics
211
264
  * @group Components
212
265
  */
213
266
  export class MeshCollider extends Collider {
214
267
 
215
268
  /**
216
- * The mesh that is used for the collider.
269
+ * The mesh that is used to create the collision shape.
270
+ * If not set, the collider will try to use the mesh of the object it's attached to.
217
271
  */
218
272
  @serializable(Mesh)
219
273
  sharedMesh?: Mesh;
220
274
 
221
- /** When `true` the collider won't have holes or entrances.
222
- * If you wan't this mesh collider to be able to *contain* other objects this should be set to `false` */
275
+ /**
276
+ * When `true` the collider is treated as a solid object without holes.
277
+ * Set to `false` if you want this mesh collider to be able to contain other objects.
278
+ */
223
279
  @serializable()
224
280
  convex: boolean = false;
225
281
 
282
+ /**
283
+ * Creates and registers the mesh collider with the physics engine.
284
+ * Handles both individual meshes and mesh groups.
285
+ */
226
286
  onEnable() {
227
287
  super.onEnable();
228
288
  if (!this.context.physics.engine) return;
@@ -272,29 +332,44 @@
272
332
  });
273
333
  }
274
334
  else {
275
- console.warn("A MeshCollider mesh is assigned, but it's neither a Mesh nor a Group. Please report a bug!", this, this.sharedMesh);
335
+ if (isDevEnvironment() || getParam("showcolliders")) {
336
+ console.warn(`[MeshCollider] A MeshCollider mesh is assigned to an unknown object on \"${this.gameObject.name}\", but it's neither a Mesh nor a Group. Please double check that you attached the collider component to the right object and report a bug otherwise!`, this);
337
+ }
276
338
  }
277
339
  }
278
340
  }
279
341
  }
280
342
 
281
343
  /**
282
- * CapsuleCollider is a collider that represents a capsule shape.
344
+ * CapsuleCollider represents a capsule-shaped collision volume (cylinder with hemispherical ends).
345
+ * Ideal for character controllers and objects that need a rounded collision shape.
283
346
  * @category Physics
284
347
  * @group Components
285
348
  */
286
349
  export class CapsuleCollider extends Collider {
350
+ /**
351
+ * The center position of the capsule collider relative to the transform's position.
352
+ */
287
353
  @serializable(Vector3)
288
354
  center: Vector3 = new Vector3(0, 0, 0);
289
355
 
356
+ /**
357
+ * The radius of the capsule's cylindrical body and hemispherical ends.
358
+ */
290
359
  @serializable()
291
360
  radius: number = .5;
361
+
362
+ /**
363
+ * The total height of the capsule including both hemispherical ends.
364
+ */
292
365
  @serializable()
293
366
  height: number = 2;
294
367
 
368
+ /**
369
+ * Registers the capsule collider with the physics engine.
370
+ */
295
371
  onEnable() {
296
372
  super.onEnable();
297
373
  this.context.physics.engine?.addCapsuleCollider(this, this.height, this.radius);
298
374
  }
299
-
300
375
  }
src/engine-components/Component.ts CHANGED
@@ -23,57 +23,167 @@
23
23
  // }
24
24
 
25
25
  /**
26
- * All {@type Object3D} types that are loaded in Needle Engine do automatically receive the GameObject extensions like `addComponent` etc.
27
- * Many of the GameObject methods can be imported directly via `@needle-tools/engine` as well:
26
+ * Base class for objects in Needle Engine. Extends {@link Object3D} from three.js.
27
+ * GameObjects can have components attached to them, which can be used to add functionality to the object.
28
+ * They manage their components and provide methods to add, remove and get components.
29
+ *
30
+ * All {@link Object3D} types loaded in Needle Engine have methods like {@link addComponent}.
31
+ * These methods are available directly on the GameObject instance:
28
32
  * ```typescript
29
- * import { addComponent } from "@needle-tools/engine";
33
+ * target.addComponent(MyComponent);
30
34
  * ```
35
+ *
36
+ * And can be called statically on the GameObject class as well:
37
+ * ```typescript
38
+ * GameObject.setActive(target, true);
39
+ * ```
31
40
  */
32
41
  export abstract class GameObject extends Object3D implements Object3D, IGameObject {
33
42
 
34
- // these are implemented via threejs object extensions
43
+ /**
44
+ * Indicates if the GameObject is currently active. Inactive GameObjects will not be rendered or updated.
45
+ * When the activeSelf state changes, components will receive {@link Component.onEnable} or {@link Component.onDisable} callbacks.
46
+ */
35
47
  abstract activeSelf: boolean;
36
48
 
37
- /** @deprecated use `addComponent` */
49
+ /** @deprecated Use {@link addComponent} instead */
38
50
  // eslint-disable-next-line deprecation/deprecation
39
51
  abstract addNewComponent<T extends IComponent>(type: ConstructorConcrete<T>, init?: ComponentInit<T>): T;
40
- /** creates a new component on this gameObject */
52
+
53
+ /**
54
+ * Creates a new component on this gameObject or adds an existing component instance
55
+ * @param comp Component type constructor or existing component instance
56
+ * @param init Optional initialization values for the component
57
+ * @returns The newly created or added component
58
+ */
41
59
  abstract addComponent<T extends IComponent>(comp: T | ConstructorConcrete<T>, init?: ComponentInit<T>): T;
60
+
61
+ /**
62
+ * Removes a component from this GameObject
63
+ * @param comp Component instance to remove
64
+ * @returns The removed component
65
+ */
42
66
  abstract removeComponent<T extends IComponent>(comp: T): T;
67
+
68
+ /**
69
+ * Gets an existing component of the specified type or adds a new one if it doesn't exist
70
+ * @param typeName Constructor of the component type to get or add
71
+ * @returns The existing or newly added component
72
+ */
43
73
  abstract getOrAddComponent<T>(typeName: ConstructorConcrete<T> | null): T;
74
+
75
+ /**
76
+ * Gets a component of the specified type attached to this GameObject
77
+ * @param type Constructor of the component type to get
78
+ * @returns The component if found, otherwise null
79
+ */
44
80
  abstract getComponent<T>(type: Constructor<T>): T | null;
81
+
82
+ /**
83
+ * Gets all components of the specified type attached to this GameObject
84
+ * @param type Constructor of the component type to get
85
+ * @param arr Optional array to populate with the components
86
+ * @returns Array of components
87
+ */
45
88
  abstract getComponents<T>(type: Constructor<T>, arr?: T[]): Array<T>;
89
+
90
+ /**
91
+ * Gets a component of the specified type in this GameObject's children hierarchy
92
+ * @param type Constructor of the component type to get
93
+ * @returns The first matching component if found, otherwise null
94
+ */
46
95
  abstract getComponentInChildren<T>(type: Constructor<T>): T | null;
96
+
97
+ /**
98
+ * Gets all components of the specified type in this GameObject's children hierarchy
99
+ * @param type Constructor of the component type to get
100
+ * @param arr Optional array to populate with the components
101
+ * @returns Array of components
102
+ */
47
103
  abstract getComponentsInChildren<T>(type: Constructor<T>, arr?: T[]): Array<T>;
104
+
105
+ /**
106
+ * Gets a component of the specified type in this GameObject's parent hierarchy
107
+ * @param type Constructor of the component type to get
108
+ * @returns The first matching component if found, otherwise null
109
+ */
48
110
  abstract getComponentInParent<T>(type: Constructor<T>): T | null;
111
+
112
+ /**
113
+ * Gets all components of the specified type in this GameObject's parent hierarchy
114
+ * @param type Constructor of the component type to get
115
+ * @param arr Optional array to populate with the components
116
+ * @returns Array of components
117
+ */
49
118
  abstract getComponentsInParent<T>(type: Constructor<T>, arr?: T[]): Array<T>;
50
119
 
51
120
 
121
+ /**
122
+ * The position of this GameObject in world space
123
+ */
52
124
  abstract get worldPosition(): Vector3
53
125
  abstract set worldPosition(val: Vector3);
126
+
127
+ /**
128
+ * The rotation of this GameObject in world space as a quaternion
129
+ */
54
130
  abstract set worldQuaternion(val: Quaternion);
55
131
  abstract get worldQuaternion(): Quaternion;
132
+
133
+ /**
134
+ * The rotation of this GameObject in world space in euler angles (degrees)
135
+ */
56
136
  abstract set worldRotation(val: Vector3);
57
137
  abstract get worldRotation(): Vector3;
138
+
139
+ /**
140
+ * The scale of this GameObject in world space
141
+ */
58
142
  abstract set worldScale(val: Vector3);
59
143
  abstract get worldScale(): Vector3;
60
144
 
145
+ /**
146
+ * The forward direction vector of this GameObject in world space
147
+ */
61
148
  abstract get worldForward(): Vector3;
149
+
150
+ /**
151
+ * The right direction vector of this GameObject in world space
152
+ */
62
153
  abstract get worldRight(): Vector3;
154
+
155
+ /**
156
+ * The up direction vector of this GameObject in world space
157
+ */
63
158
  abstract get worldUp(): Vector3;
64
159
 
160
+ /**
161
+ * Unique identifier for this GameObject
162
+ */
65
163
  guid: string | undefined;
66
164
 
67
- // Added to the threejs Object3D prototype
165
+ /**
166
+ * Destroys this GameObject and all its components.
167
+ * Internally, this is added to the three.js {@link Object3D} prototype.
168
+ */
68
169
  abstract destroy();
69
170
 
70
171
 
71
-
72
-
172
+ /**
173
+ * Checks if a GameObject has been destroyed
174
+ * @param go The GameObject to check
175
+ * @returns True if the GameObject has been destroyed
176
+ */
73
177
  public static isDestroyed(go: Object3D): boolean {
74
178
  return isDestroyed(go);
75
179
  }
76
180
 
181
+ /**
182
+ * Sets the active state of a GameObject
183
+ * @param go The GameObject to modify
184
+ * @param active Whether the GameObject should be active
185
+ * @param processStart Whether to process the start callbacks if being activated
186
+ */
77
187
  public static setActive(go: Object3D, active: boolean, processStart: boolean = true) {
78
188
  if (!go) return;
79
189
  setActive(go, active);
@@ -84,47 +194,68 @@
84
194
  main.processStart(Context.Current, go);
85
195
  }
86
196
 
87
- /** If the object is active (same as go.visible) */
197
+ /**
198
+ * Checks if the GameObject itself is active (same as go.visible)
199
+ * @param go The GameObject to check
200
+ * @returns True if the GameObject is active
201
+ */
88
202
  public static isActiveSelf(go: Object3D): boolean {
89
203
  return isActiveSelf(go);
90
204
  }
91
205
 
92
- /** If the object is active in the hierarchy (e.g. if any parent is invisible or not in the scene it will be false)
93
- * @param go object to check
94
- */
206
+ /**
207
+ * Checks if the GameObject is active in the hierarchy (e.g. if any parent is invisible or not in the scene it will be false)
208
+ * @param go The GameObject to check
209
+ * @returns True if the GameObject is active in the hierarchy
210
+ */
95
211
  public static isActiveInHierarchy(go: Object3D): boolean {
96
212
  return isActiveInHierarchy(go);
97
213
  }
98
214
 
215
+ /**
216
+ * Marks a GameObject to be rendered using instancing
217
+ * @param go The GameObject to mark
218
+ * @param instanced Whether the GameObject should use instanced rendering
219
+ */
99
220
  public static markAsInstancedRendered(go: Object3D, instanced: boolean) {
100
221
  markAsInstancedRendered(go, instanced);
101
222
  }
102
223
 
224
+ /**
225
+ * Checks if a GameObject is using instanced rendering
226
+ * @param instance The GameObject to check
227
+ * @returns True if the GameObject is using instanced rendering
228
+ */
103
229
  public static isUsingInstancing(instance: Object3D): boolean { return isUsingInstancing(instance); }
104
230
 
105
- /** Run a callback for all components of the provided type on the provided object and its children (if recursive is true)
106
- * @param instance object to run the method on
107
- * @param cb callback to run on each component, "return undefined;" to continue and "return <anything>;" to break the loop
108
- * @param recursive if true, the method will be run on all children as well
109
- * @returns the last return value of the callback
231
+ /**
232
+ * Executes a callback for all components of the provided type on the provided object and its children
233
+ * @param instance Object to run the method on
234
+ * @param cb Callback to run on each component, "return undefined;" to continue and "return <anything>;" to break the loop
235
+ * @param recursive If true, the method will be run on all children as well
236
+ * @returns The last return value of the callback
110
237
  */
111
238
  public static foreachComponent(instance: Object3D, cb: (comp: Component) => any, recursive: boolean = true): any {
112
239
  return foreachComponent(instance, cb as (comp: IComponent) => any, recursive);
113
240
  }
114
241
 
115
- /** Creates a new instance of the provided object. The new instance will be created on all connected clients
116
- * @param instance object to instantiate
117
- * @param opts options for the instantiation
242
+ /**
243
+ * Creates a new instance of the provided object that will be replicated to all connected clients
244
+ * @param instance Object to instantiate
245
+ * @param opts Options for the instantiation
246
+ * @returns The newly created instance or null if creation failed
118
247
  */
119
248
  public static instantiateSynced(instance: GameObject | Object3D | null, opts: SyncInstantiateOptions): GameObject | null {
120
249
  if (!instance) return null;
121
250
  return syncInstantiate(instance, opts) as GameObject | null;
122
251
  }
123
252
 
124
- /** Creates a new instance of the provided object (like cloning it including all components and children)
125
- * @param instance object to instantiate
126
- * @param opts options for the instantiation (e.g. with what parent, position, etc.)
127
- */
253
+ /**
254
+ * Creates a new instance of the provided object (like cloning it including all components and children)
255
+ * @param instance Object to instantiate
256
+ * @param opts Options for the instantiation (e.g. with what parent, position, etc.)
257
+ * @returns The newly created instance
258
+ */
128
259
  public static instantiate(instance: AssetReference, opts?: IInstantiateOptions | null | undefined): Promise<Object3D | null>
129
260
  public static instantiate(instance: GameObject | Object3D, opts?: IInstantiateOptions | null | undefined): GameObject
130
261
  public static instantiate(instance: AssetReference | GameObject | Object3D, opts: IInstantiateOptions | null | undefined = null): GameObject | Promise<Object3D | null> {
@@ -134,9 +265,12 @@
134
265
  return instantiate(instance as GameObject | Object3D, opts) as GameObject;
135
266
  }
136
267
 
137
- /** Destroys a object on all connected clients (if you are in a networked session)
138
- * @param instance object to destroy
139
- */
268
+ /**
269
+ * Destroys an object on all connected clients (if in a networked session)
270
+ * @param instance Object to destroy
271
+ * @param context Optional context to use
272
+ * @param recursive If true, all children will be destroyed as well
273
+ */
140
274
  public static destroySynced(instance: Object3D | Component, context?: Context, recursive: boolean = true) {
141
275
  if (!instance) return;
142
276
  const go = instance as GameObject;
@@ -144,16 +278,20 @@
144
278
  syncDestroy(go as any, context.connection, recursive);
145
279
  }
146
280
 
147
- /** Destroys a object
148
- * @param instance object to destroy
149
- * @param recursive if true, all children will be destroyed as well. true by default
281
+ /**
282
+ * Destroys an object
283
+ * @param instance Object to destroy
284
+ * @param recursive If true, all children will be destroyed as well. Default: true
150
285
  */
151
286
  public static destroy(instance: Object3D | Component, recursive: boolean = true) {
152
287
  return destroy(instance, recursive);
153
288
  }
154
289
 
155
290
  /**
156
- * Add an object to parent and also ensure all components are being registered
291
+ * Adds an object to parent and ensures all components are properly registered
292
+ * @param instance Object to add
293
+ * @param parent Parent to add the object to
294
+ * @param context Optional context to use
157
295
  */
158
296
  public static add(instance: Object3D | null | undefined, parent: Object3D, context?: Context) {
159
297
  if (!instance || !parent) return;
@@ -183,6 +321,7 @@
183
321
 
184
322
  /**
185
323
  * Removes the object from its parent and deactivates all of its components
324
+ * @param instance Object to remove
186
325
  */
187
326
  public static remove(instance: Object3D | null | undefined) {
188
327
  if (!instance) return;
@@ -194,14 +333,22 @@
194
333
  }, true);
195
334
  }
196
335
 
197
- /** Invokes a method on all components including children (if a method with that name exists) */
336
+ /**
337
+ * Invokes a method on all components including children (if a method with that name exists)
338
+ * @param go GameObject to invoke the method on
339
+ * @param functionName Name of the method to invoke
340
+ * @param args Arguments to pass to the method
341
+ */
198
342
  public static invokeOnChildren(go: Object3D | null | undefined, functionName: string, ...args: any) {
199
343
  this.invoke(go, functionName, true, args);
200
344
  }
201
345
 
202
- /** Invokes a method on all components that have a method matching the provided name
203
- * @param go object to invoke the method on all components
204
- * @param functionName name of the method to invoke
346
+ /**
347
+ * Invokes a method on all components that have a method matching the provided name
348
+ * @param go GameObject to invoke the method on
349
+ * @param functionName Name of the method to invoke
350
+ * @param children Whether to invoke on children as well
351
+ * @param args Arguments to pass to the method
205
352
  */
206
353
  public static invoke(go: Object3D | null | undefined, functionName: string, children: boolean = false, ...args: any) {
207
354
  if (!go) return;
@@ -220,38 +367,53 @@
220
367
  }
221
368
 
222
369
  /**
223
- * Add a new component (or move an existing component) to the provided object
224
- * @param go object to add the component to
225
- * @param instanceOrType if an instance is provided it will be moved to the new object, if a type is provided a new instance will be created and moved to the new object
226
- * @param init optional init object to initialize the component with
227
- * @param callAwake if true, the component will be added and awake will be called immediately
370
+ * Adds a new component (or moves an existing component) to the provided object
371
+ * @param go Object to add the component to
372
+ * @param instanceOrType If an instance is provided it will be moved to the new object, if a type is provided a new instance will be created
373
+ * @param init Optional init object to initialize the component with
374
+ * @param opts Optional options for adding the component
375
+ * @returns The added or moved component
228
376
  */
229
377
  public static addComponent<T extends IComponent>(go: IGameObject | Object3D, instanceOrType: T | ConstructorConcrete<T>, init?: ComponentInit<T>, opts?: { callAwake: boolean }): T {
230
378
  return addComponent(go, instanceOrType, init, opts);
231
379
  }
232
380
 
233
381
  /**
234
- * Moves a component to a new object
235
- * @param go component to move the component to
236
- * @param instance component to move to the GO
382
+ * Moves a component to a new object
383
+ * @param go GameObject to move the component to
384
+ * @param instance Component to move
385
+ * @returns The moved component
237
386
  */
238
387
  public static moveComponent<T extends IComponent>(go: IGameObject | Object3D, instance: T | ConstructorConcrete<T>): T {
239
388
  return addComponent(go, instance);
240
389
  }
241
390
 
242
- /** Removes a component from its object
243
- * @param instance component to remove
391
+ /**
392
+ * Removes a component from its object
393
+ * @param instance Component to remove
394
+ * @returns The removed component
244
395
  */
245
396
  public static removeComponent<T extends IComponent>(instance: T): T {
246
397
  removeComponent(instance.gameObject, instance as any);
247
398
  return instance;
248
399
  }
249
400
 
401
+ /**
402
+ * Gets or adds a component of the specified type
403
+ * @param go GameObject to get or add the component to
404
+ * @param typeName Constructor of the component type
405
+ * @returns The existing or newly added component
406
+ */
250
407
  public static getOrAddComponent<T extends IComponent>(go: IGameObject | Object3D, typeName: ConstructorConcrete<T>): T {
251
408
  return getOrAddComponent<any>(go, typeName);
252
409
  }
253
410
 
254
- /** Gets a component on the provided object */
411
+ /**
412
+ * Gets a component on the provided object
413
+ * @param go GameObject to get the component from
414
+ * @param typeName Constructor of the component type
415
+ * @returns The component if found, otherwise null
416
+ */
255
417
  public static getComponent<T extends IComponent>(go: IGameObject | Object3D | null, typeName: Constructor<T> | null): T | null {
256
418
  if (go === null) return null;
257
419
  // if names are minified we could also use the type store and work with strings everywhere
@@ -261,42 +423,99 @@
261
423
  return getComponent(go, typeName as any);
262
424
  }
263
425
 
426
+ /**
427
+ * Gets all components of the specified type on the provided object
428
+ * @param go GameObject to get the components from
429
+ * @param typeName Constructor of the component type
430
+ * @param arr Optional array to populate with the components
431
+ * @returns Array of components
432
+ */
264
433
  public static getComponents<T extends IComponent>(go: IGameObject | Object3D | null, typeName: Constructor<T>, arr: T[] | null = null): T[] {
265
434
  if (go === null) return arr ?? [];
266
435
  return getComponents(go, typeName, arr);
267
436
  }
268
437
 
438
+ /**
439
+ * Finds an object or component by its unique identifier
440
+ * @param guid Unique identifier to search for
441
+ * @param hierarchy Root object to search in
442
+ * @returns The found GameObject or Component, or null/undefined if not found
443
+ */
269
444
  public static findByGuid(guid: string, hierarchy: Object3D): GameObject | Component | null | undefined {
270
445
  const res = findByGuid(guid, hierarchy);
271
446
  return res as GameObject | Component | null | undefined;
272
447
  }
273
448
 
449
+ /**
450
+ * Finds the first object of the specified component type in the scene
451
+ * @param typeName Constructor of the component type
452
+ * @param context Context or root object to search in
453
+ * @param includeInactive Whether to include inactive objects in the search
454
+ * @returns The first matching component if found, otherwise null
455
+ */
274
456
  public static findObjectOfType<T extends IComponent>(typeName: Constructor<T>, context?: Context | Object3D, includeInactive: boolean = true): T | null {
275
457
  return findObjectOfType(typeName, context ?? Context.Current, includeInactive);
276
458
  }
277
459
 
460
+ /**
461
+ * Finds all objects of the specified component type in the scene
462
+ * @param typeName Constructor of the component type
463
+ * @param context Context or root object to search in
464
+ * @returns Array of matching components
465
+ */
278
466
  public static findObjectsOfType<T extends IComponent>(typeName: Constructor<T>, context?: Context | Object3D): Array<T> {
279
467
  const arr = [];
280
468
  findObjectsOfType(typeName, arr, context);
281
469
  return arr;
282
470
  }
283
471
 
472
+ /**
473
+ * Gets a component of the specified type in the gameObject's children hierarchy
474
+ * @param go GameObject to search in
475
+ * @param typeName Constructor of the component type
476
+ * @returns The first matching component if found, otherwise null
477
+ */
284
478
  public static getComponentInChildren<T extends IComponent>(go: IGameObject | Object3D, typeName: Constructor<T>): T | null {
285
479
  return getComponentInChildren(go, typeName);
286
480
  }
287
481
 
482
+ /**
483
+ * Gets all components of the specified type in the gameObject's children hierarchy
484
+ * @param go GameObject to search in
485
+ * @param typeName Constructor of the component type
486
+ * @param arr Optional array to populate with the components
487
+ * @returns Array of components
488
+ */
288
489
  public static getComponentsInChildren<T extends IComponent>(go: IGameObject | Object3D, typeName: Constructor<T>, arr: T[] | null = null): Array<T> {
289
490
  return getComponentsInChildren<T>(go, typeName, arr ?? undefined) as T[]
290
491
  }
291
492
 
493
+ /**
494
+ * Gets a component of the specified type in the gameObject's parent hierarchy
495
+ * @param go GameObject to search in
496
+ * @param typeName Constructor of the component type
497
+ * @returns The first matching component if found, otherwise null
498
+ */
292
499
  public static getComponentInParent<T extends IComponent>(go: IGameObject | Object3D, typeName: Constructor<T>): T | null {
293
500
  return getComponentInParent(go, typeName);
294
501
  }
295
502
 
503
+ /**
504
+ * Gets all components of the specified type in the gameObject's parent hierarchy
505
+ * @param go GameObject to search in
506
+ * @param typeName Constructor of the component type
507
+ * @param arr Optional array to populate with the components
508
+ * @returns Array of components
509
+ */
296
510
  public static getComponentsInParent<T extends IComponent>(go: IGameObject | Object3D, typeName: Constructor<T>, arr: Array<T> | null = null): Array<T> {
297
511
  return getComponentsInParent(go, typeName, arr);
298
512
  }
299
513
 
514
+ /**
515
+ * Gets all components on the gameObject
516
+ * @param go GameObject to get components from
517
+ * @returns Array of all components
518
+ */
300
519
  public static getAllComponents(go: IGameObject | Object3D): Component[] {
301
520
  const componentsList = go.userData?.components;
302
521
  if (!componentsList) return [];
@@ -304,6 +523,11 @@
304
523
  return newList;
305
524
  }
306
525
 
526
+ /**
527
+ * Iterates through all components on the gameObject
528
+ * @param go GameObject to iterate components on
529
+ * @returns Generator yielding each component
530
+ */
307
531
  public static *iterateComponents(go: IGameObject | Object3D) {
308
532
  const list = go?.userData?.components;
309
533
  if (list && Array.isArray(list)) {
@@ -346,27 +570,43 @@
346
570
  Partial<INeedleXRSessionEventReceiver>,
347
571
  Partial<IPointerEventHandler>
348
572
  {
349
- /** @internal */
573
+ /**
574
+ * Indicates whether this object is a component
575
+ * @internal
576
+ */
350
577
  get isComponent(): boolean { return true; }
351
578
 
352
579
  private __context: Context | undefined;
353
- /** Use the context to get access to many Needle Engine features and use physics, timing, access the camera or scene */
580
+
581
+ /**
582
+ * The context this component belongs to, providing access to the runtime environment
583
+ * including physics, timing utilities, camera, and scene
584
+ */
354
585
  get context(): Context {
355
586
  return this.__context ?? Context.Current;
356
587
  }
357
588
  set context(context: Context) {
358
589
  this.__context = context;
359
590
  }
360
- /** shorthand for `this.context.scene`
361
- * @returns the scene of the context */
591
+
592
+ /**
593
+ * Shorthand accessor for the current scene from the context
594
+ * @returns The scene this component belongs to
595
+ */
362
596
  get scene(): Scene { return this.context.scene; }
363
597
 
364
- /** @returns the layer of the gameObject this component is attached to */
598
+ /**
599
+ * The layer value of the GameObject this component is attached to
600
+ * Used for visibility and physics filtering
601
+ */
365
602
  get layer(): number {
366
603
  return this.gameObject?.userData?.layer;
367
604
  }
368
605
 
369
- /** @returns the name of the gameObject this component is attached to */
606
+ /**
607
+ * The name of the GameObject this component is attached to
608
+ * Used for debugging and finding objects
609
+ */
370
610
  get name(): string {
371
611
  if (this.gameObject?.name) {
372
612
  return this.gameObject.name;
@@ -384,7 +624,11 @@
384
624
  this.__name = str;
385
625
  }
386
626
  }
387
- /** @returns the tag of the gameObject this component is attached to */
627
+
628
+ /**
629
+ * The tag of the GameObject this component is attached to
630
+ * Used for categorizing objects and efficient lookup
631
+ */
388
632
  get tag(): string {
389
633
  return this.gameObject?.userData.tag;
390
634
  }
@@ -394,7 +638,11 @@
394
638
  this.gameObject.userData.tag = str;
395
639
  }
396
640
  }
397
- /** Is the gameObject marked as static */
641
+
642
+ /**
643
+ * Indicates whether the GameObject is marked as static
644
+ * Static objects typically don't move and can be optimized by the engine
645
+ */
398
646
  get static() {
399
647
  return this.gameObject?.userData.static;
400
648
  }
@@ -408,7 +656,11 @@
408
656
  // return this.gameObject?.hideFlags;
409
657
  // }
410
658
 
411
- /** @returns true if the object is enabled and active in the hierarchy */
659
+ /**
660
+ * Checks if this component is currently active (enabled and part of an active GameObject hierarchy)
661
+ * Components that are inactive won't receive lifecycle method calls
662
+ * @returns True if the component is enabled and all parent GameObjects are active
663
+ */
412
664
  get activeAndEnabled(): boolean {
413
665
  if (this.destroyed) return false;
414
666
  if (this.__isEnabled === false) return false;
@@ -426,6 +678,7 @@
426
678
  private get __isActive(): boolean {
427
679
  return this.gameObject.visible;
428
680
  }
681
+
429
682
  private get __isActiveInHierarchy(): boolean {
430
683
  if (!this.gameObject) return false;
431
684
  const res = this.gameObject[activeInHierarchyFieldName];
@@ -438,139 +691,286 @@
438
691
  this.gameObject[activeInHierarchyFieldName] = val;
439
692
  }
440
693
 
441
- /** the object this component is attached to. Note that this is a threejs Object3D with some additional features */
694
+ /**
695
+ * Reference to the GameObject this component is attached to
696
+ * This is a three.js Object3D with additional GameObject functionality
697
+ */
442
698
  gameObject!: GameObject;
443
- /** the unique identifier for this component */
699
+
700
+ /**
701
+ * Unique identifier for this component instance,
702
+ * used for finding and tracking components
703
+ */
444
704
  guid: string = "invalid";
445
- /** holds the source identifier this object was created with/from (e.g. if it was part of a glTF file the sourceId holds the url to the glTF) */
705
+
706
+ /**
707
+ * Identifier for the source asset that created this component.
708
+ * For example, URL to the glTF file this component was loaded from
709
+ */
446
710
  sourceId?: SourceIdentifier;
447
- // transform: Object3D = nullObject;
448
711
 
449
- /** called on a component with a map of old to new guids (e.g. when instantiate generated new guids and e.g. timeline track bindings needs to remape them) */
712
+ /**
713
+ * Called when this component needs to remap guids after an instantiate operation.
714
+ * @param guidsMap Mapping from old guids to newly generated guids
715
+ */
450
716
  resolveGuids?(guidsMap: GuidsMap): void;
451
717
 
452
- /** called once when the component becomes active for the first time (once per component)
453
- * This is the first callback to be called */
718
+ /**
719
+ * Called once when the component becomes active for the first time.
720
+ * This is the first lifecycle callback to be invoked
721
+ */
454
722
  awake() { }
455
- /** called every time when the component gets enabled (this is invoked after awake and before start)
456
- * or when it becomes active in the hierarchy (e.g. if a parent object or this.gameObject gets set to visible)
457
- */
723
+
724
+ /**
725
+ * Called every time the component becomes enabled or active in the hierarchy.
726
+ * Invoked after {@link awake} and before {@link start}.
727
+ */
458
728
  onEnable() { }
459
- /** called every time the component gets disabled or if a parent object (or this.gameObject) gets set to invisible */
729
+
730
+ /**
731
+ * Called every time the component becomes disabled or inactive in the hierarchy.
732
+ * Invoked when the component or any parent GameObject becomes invisible
733
+ */
460
734
  onDisable() { }
461
- /** Called when the component gets destroyed */
735
+
736
+ /**
737
+ * Called when the component is destroyed.
738
+ * Use for cleanup operations like removing event listeners
739
+ */
462
740
  onDestroy() {
463
741
  this.__destroyed = true;
464
742
  }
465
- /** called when you decorate fields with the @validate() decorator
466
- * @param prop the name of the field that was changed
743
+
744
+ /**
745
+ * Called when a field decorated with @validate() is modified.
746
+ * @param prop The name of the field that was changed
467
747
  */
468
748
  onValidate?(prop?: string): void;
469
749
 
470
- /** Called for all scripts when the context gets paused or unpaused */
750
+ /**
751
+ * Called when the context's pause state changes.
752
+ * @param isPaused Whether the context is currently paused
753
+ * @param wasPaused The previous pause state
754
+ */
471
755
  onPausedChanged?(isPaused: boolean, wasPaused: boolean): void;
472
756
 
473
- /** called at the beginning of a frame (once per component) */
757
+ /**
758
+ * Called once at the beginning of the first frame after the component is enabled.
759
+ * Use for initialization that requires other components to be awake.
760
+ */
474
761
  start?(): void;
475
- /** first callback in a frame (called every frame when implemented) */
762
+
763
+ /**
764
+ * Called at the beginning of each frame before regular updates.
765
+ * Use for logic that needs to run before standard update callbacks.
766
+ */
476
767
  earlyUpdate?(): void;
477
- /** regular callback in a frame (called every frame when implemented) */
768
+
769
+ /**
770
+ * Called once per frame during the main update loop.
771
+ * The primary location for frame-based game logic.
772
+ */
478
773
  update?(): void;
479
- /** late callback in a frame (called every frame when implemented) */
774
+
775
+ /**
776
+ * Called after all update functions have been called.
777
+ * Use for calculations that depend on other components being updated first.
778
+ */
480
779
  lateUpdate?(): void;
481
- /** called before the scene gets rendered in the main update loop */
780
+
781
+ /**
782
+ * Called immediately before the scene is rendered.
783
+ * @param frame Current XRFrame if in an XR session, null otherwise
784
+ */
482
785
  onBeforeRender?(frame: XRFrame | null): void;
483
- /** called after the scene was rendered */
786
+
787
+ /**
788
+ * Called after the scene has been rendered.
789
+ * Use for post-processing or UI updates that should happen after rendering
790
+ */
484
791
  onAfterRender?(): void;
485
792
 
793
+ /**
794
+ * Called when this component's collider begins colliding with another collider.
795
+ * @param col Information about the collision that occurred
796
+ */
486
797
  onCollisionEnter?(col: Collision);
798
+
799
+ /**
800
+ * Called when this component's collider stops colliding with another collider.
801
+ * @param col Information about the collision that ended
802
+ */
487
803
  onCollisionExit?(col: Collision);
804
+
805
+ /**
806
+ * Called each frame while this component's collider is colliding with another collider
807
+ * @param col Information about the ongoing collision
808
+ */
488
809
  onCollisionStay?(col: Collision);
489
810
 
811
+ /**
812
+ * Called when this component's trigger collider is entered by another collider
813
+ * @param col The collider that entered this trigger
814
+ */
490
815
  onTriggerEnter?(col: ICollider);
816
+
817
+ /**
818
+ * Called each frame while another collider is inside this component's trigger collider
819
+ * @param col The collider that is inside this trigger
820
+ */
491
821
  onTriggerStay?(col: ICollider);
822
+
823
+ /**
824
+ * Called when another collider exits this component's trigger collider
825
+ * @param col The collider that exited this trigger
826
+ */
492
827
  onTriggerExit?(col: ICollider);
493
828
 
494
-
495
- /** Optional callback, you can implement this to only get callbacks for VR or AR sessions if necessary.
496
- * @returns true if the mode is supported (if false the mode is not supported by this component and it will not receive XR callbacks for this mode)
497
- */
829
+ /**
830
+ * Determines if this component supports a specific XR mode
831
+ * @param mode The XR session mode to check support for
832
+ * @returns True if the component supports the specified mode
833
+ */
498
834
  supportsXR?(mode: XRSessionMode): boolean;
499
- /** Called before the XR session is requested. Use this callback if you want to modify the session init features */
835
+
836
+ /**
837
+ * Called before an XR session is requested
838
+ * Use to modify session initialization parameters
839
+ * @param mode The XR session mode being requested
840
+ * @param args The session initialization parameters that can be modified
841
+ */
500
842
  onBeforeXR?(mode: XRSessionMode, args: XRSessionInit): void;
501
- /** Callback when this component joins a xr session (or becomes active in a running XR session) */
843
+
844
+ /**
845
+ * Called when this component joins an XR session or becomes active in a running session
846
+ * @param args Event data for the XR session
847
+ */
502
848
  onEnterXR?(args: NeedleXREventArgs): void;
503
- /** Callback when a xr session updates (while it is still active in XR session) */
849
+
850
+ /**
851
+ * Called each frame while this component is active in an XR session
852
+ * @param args Event data for the current XR frame
853
+ */
504
854
  onUpdateXR?(args: NeedleXREventArgs): void;
505
- /** Callback when this component exists a xr session (or when it becomes inactive in a running XR session) */
855
+
856
+ /**
857
+ * Called when this component exits an XR session or becomes inactive during a session
858
+ * @param args Event data for the XR session
859
+ */
506
860
  onLeaveXR?(args: NeedleXREventArgs): void;
507
- /** Callback when a controller is connected/added while in a XR session
508
- * OR when the component joins a running XR session that has already connected controllers
509
- * OR when the component becomes active during a running XR session that has already connected controllers */
861
+
862
+ /**
863
+ * Called when an XR controller is connected or when this component becomes active
864
+ * in a session with existing controllers
865
+ * @param args Event data for the controller that was added
866
+ */
510
867
  onXRControllerAdded?(args: NeedleXRControllerEventArgs): void;
511
- /** callback when a controller is removed while in a XR session
512
- * OR when the component becomes inactive during a running XR session
513
- */
868
+
869
+ /**
870
+ * Called when an XR controller is disconnected or when this component becomes inactive
871
+ * during a session with controllers
872
+ * @param args Event data for the controller that was removed
873
+ */
514
874
  onXRControllerRemoved?(args: NeedleXRControllerEventArgs): void;
515
875
 
516
-
517
- /* IPointerEventReceiver */
518
- /* @inheritdoc */
876
+ /**
877
+ * Called when a pointer enters this component's GameObject
878
+ * @param args Data about the pointer event
879
+ */
519
880
  onPointerEnter?(args: PointerEventData);
881
+
882
+ /**
883
+ * Called when a pointer moves while over this component's GameObject
884
+ * @param args Data about the pointer event
885
+ */
520
886
  onPointerMove?(args: PointerEventData);
887
+
888
+ /**
889
+ * Called when a pointer exits this component's GameObject
890
+ * @param args Data about the pointer event
891
+ */
521
892
  onPointerExit?(args: PointerEventData);
893
+
894
+ /**
895
+ * Called when a pointer button is pressed while over this component's GameObject
896
+ * @param args Data about the pointer event
897
+ */
522
898
  onPointerDown?(args: PointerEventData);
899
+
900
+ /**
901
+ * Called when a pointer button is released while over this component's GameObject
902
+ * @param args Data about the pointer event
903
+ */
523
904
  onPointerUp?(args: PointerEventData);
905
+
906
+ /**
907
+ * Called when a pointer completes a click interaction with this component's GameObject
908
+ * @param args Data about the pointer event
909
+ */
524
910
  onPointerClick?(args: PointerEventData);
525
911
 
526
-
527
- /** starts a coroutine (javascript generator function)
528
- * `yield` will wait for the next frame:
529
- * - Use `yield WaitForSeconds(1)` to wait for 1 second.
530
- * - Use `yield WaitForFrames(10)` to wait for 10 frames.
531
- * - Use `yield new Promise(...)` to wait for a promise to resolve.
532
- * @param routine generator function to start
533
- * @param evt event to register the coroutine for (default: FrameEvent.Update). Note that all coroutine FrameEvent callbacks are invoked after the matching regular component callbacks. For example `FrameEvent.Update` will be called after regular component `update()` methods)
534
- * @returns the generator function (use it to stop the coroutine with `stopCoroutine`)
535
- * @example
912
+ /**
913
+ * Starts a coroutine that can yield to wait for events.
914
+ * Coroutines allow for time-based sequencing of operations without blocking.
915
+ * Coroutines are based on generator functions, a JavaScript language feature.
916
+ *
917
+ * @param routine Generator function to start
918
+ * @param evt Event to register the coroutine for (default: FrameEvent.Update)
919
+ * @returns The generator function that can be used to stop the coroutine
920
+ * @example
921
+ * Time-based sequencing of operations
536
922
  * ```ts
537
- * onEnable() { this.startCoroutine(this.myCoroutine()); }
923
+ * *myCoroutine() {
924
+ * yield WaitForSeconds(1); // wait for 1 second
925
+ * yield WaitForFrames(10); // wait for 10 frames
926
+ * yield new Promise(resolve => setTimeout(resolve, 1000)); // wait for a promise to resolve
927
+ * }
928
+ * ```
929
+ * @example
930
+ * Coroutine that logs a message every 5 frames
931
+ * ```ts
932
+ * onEnable() {
933
+ * this.startCoroutine(this.myCoroutine());
934
+ * }
538
935
  * private *myCoroutine() {
539
- * while(this.activeAndEnabled) {
540
- * console.log("Hello World", this.context.time.frame);
541
- * // wait for 5 frames
542
- * for(let i = 0; i < 5; i++) yield;
543
- * }
936
+ * while(this.activeAndEnabled) {
937
+ * console.log("Hello World", this.context.time.frame);
938
+ * // wait for 5 frames
939
+ * for(let i = 0; i < 5; i++) yield;
940
+ * }
544
941
  * }
545
942
  * ```
546
943
  */
547
944
  startCoroutine(routine: Generator, evt: FrameEvent = FrameEvent.Update): Generator {
548
945
  return this.context.registerCoroutineUpdate(this, routine, evt);
549
946
  }
947
+
550
948
  /**
551
- * Stop a coroutine that was previously started with `startCoroutine`
552
- * @param routine the routine to be stopped
553
- * @param evt the frame event to unregister the routine from (default: FrameEvent.Update)
949
+ * Stops a coroutine that was previously started with startCoroutine
950
+ * @param routine The routine to be stopped
951
+ * @param evt The frame event the routine was registered with
554
952
  */
555
953
  stopCoroutine(routine: Generator, evt: FrameEvent = FrameEvent.Update): void {
556
954
  this.context.unregisterCoroutineUpdate(routine, evt);
557
955
  }
558
956
 
559
- /** @returns true if this component was destroyed (`this.destroy()`) or the whole object this component was part of */
957
+ /**
958
+ * Checks if this component has been destroyed
959
+ * @returns True if the component or its GameObject has been destroyed
960
+ */
560
961
  public get destroyed(): boolean {
561
962
  return this.__destroyed;
562
963
  }
563
964
 
564
965
  /**
565
- * Destroys this component (and removes it from the object)
966
+ * Destroys this component and removes it from its GameObject
967
+ * After destruction, the component will no longer receive lifecycle callbacks
566
968
  */
567
969
  public destroy() {
568
970
  if (this.__destroyed) return;
569
971
  this.__internalDestroy();
570
972
  }
571
973
 
572
-
573
-
574
974
  /** @internal */
575
975
  protected __didAwake: boolean = false;
576
976
 
@@ -589,7 +989,6 @@
589
989
  /** @internal */
590
990
  get __internalDidAwakeAndStart() { return this.__didAwake && this.__didStart; }
591
991
 
592
-
593
992
  /** @internal */
594
993
  constructor(init?: ComponentInit<Component>) {
595
994
  this.__didAwake = false;
@@ -611,6 +1010,11 @@
611
1010
  return this;
612
1011
  }
613
1012
 
1013
+ /**
1014
+ * Initializes component properties from an initialization object
1015
+ * @param init Object with properties to copy to this component
1016
+ * @internal
1017
+ */
614
1018
  _internalInit(init?: ComponentInit<this>) {
615
1019
  if (typeof init === "object") {
616
1020
  for (const key of Object.keys(init)) {
@@ -690,7 +1094,10 @@
690
1094
  destroyComponentInstance(this as any);
691
1095
  }
692
1096
 
693
-
1097
+ /**
1098
+ * Controls whether this component is enabled
1099
+ * Disabled components don't receive lifecycle callbacks
1100
+ */
694
1101
  get enabled(): boolean {
695
1102
  return typeof this.__isEnabled === "boolean" ? this.__isEnabled : true; // if it has no enabled field it is always enabled
696
1103
  }
@@ -720,85 +1127,154 @@
720
1127
  }
721
1128
  }
722
1129
 
1130
+ /**
1131
+ * Gets the position of this component's GameObject in world space
1132
+ */
723
1133
  get worldPosition(): Vector3 {
724
1134
  return threeutils.getWorldPosition(this.gameObject);
725
1135
  }
726
1136
 
1137
+ /**
1138
+ * Sets the position of this component's GameObject in world space
1139
+ * @param val The world position vector to set
1140
+ */
727
1141
  set worldPosition(val: Vector3) {
728
1142
  threeutils.setWorldPosition(this.gameObject, val);
729
1143
  }
730
1144
 
1145
+ /**
1146
+ * Sets the position of this component's GameObject in world space using individual coordinates
1147
+ * @param x X-coordinate in world space
1148
+ * @param y Y-coordinate in world space
1149
+ * @param z Z-coordinate in world space
1150
+ */
731
1151
  setWorldPosition(x: number, y: number, z: number) {
732
1152
  threeutils.setWorldPositionXYZ(this.gameObject, x, y, z);
733
1153
  }
734
1154
 
735
-
1155
+ /**
1156
+ * Gets the rotation of this component's GameObject in world space as a quaternion
1157
+ */
736
1158
  get worldQuaternion(): Quaternion {
737
1159
  return threeutils.getWorldQuaternion(this.gameObject);
738
1160
  }
1161
+
1162
+ /**
1163
+ * Sets the rotation of this component's GameObject in world space using a quaternion
1164
+ * @param val The world rotation quaternion to set
1165
+ */
739
1166
  set worldQuaternion(val: Quaternion) {
740
1167
  threeutils.setWorldQuaternion(this.gameObject, val);
741
1168
  }
1169
+
1170
+ /**
1171
+ * Sets the rotation of this component's GameObject in world space using quaternion components
1172
+ * @param x X component of the quaternion
1173
+ * @param y Y component of the quaternion
1174
+ * @param z Z component of the quaternion
1175
+ * @param w W component of the quaternion
1176
+ */
742
1177
  setWorldQuaternion(x: number, y: number, z: number, w: number) {
743
1178
  threeutils.setWorldQuaternionXYZW(this.gameObject, x, y, z, w);
744
1179
  }
745
1180
 
746
- // world euler (in radians)
1181
+ /**
1182
+ * Gets the rotation of this component's GameObject in world space as Euler angles (in radians)
1183
+ */
747
1184
  get worldEuler(): Euler {
748
1185
  return threeutils.getWorldEuler(this.gameObject);
749
1186
  }
750
1187
 
751
- // world euler (in radians)
1188
+ /**
1189
+ * Sets the rotation of this component's GameObject in world space using Euler angles (in radians)
1190
+ * @param val The world rotation Euler angles to set
1191
+ */
752
1192
  set worldEuler(val: Euler) {
753
1193
  threeutils.setWorldEuler(this.gameObject, val);
754
1194
  }
755
1195
 
756
- // returns rotation in degrees
1196
+ /**
1197
+ * Gets the rotation of this component's GameObject in world space as Euler angles (in degrees)
1198
+ */
757
1199
  get worldRotation(): Vector3 {
758
- return this.gameObject.worldRotation;;
1200
+ return this.gameObject.worldRotation;
759
1201
  }
760
1202
 
1203
+ /**
1204
+ * Sets the rotation of this component's GameObject in world space using Euler angles (in degrees)
1205
+ * @param val The world rotation vector to set (in degrees)
1206
+ */
761
1207
  set worldRotation(val: Vector3) {
762
1208
  this.setWorldRotation(val.x, val.y, val.z, true);
763
1209
  }
764
1210
 
1211
+ /**
1212
+ * Sets the rotation of this component's GameObject in world space using individual Euler angles
1213
+ * @param x X-axis rotation
1214
+ * @param y Y-axis rotation
1215
+ * @param z Z-axis rotation
1216
+ * @param degrees Whether the values are in degrees (true) or radians (false)
1217
+ */
765
1218
  setWorldRotation(x: number, y: number, z: number, degrees: boolean = true) {
766
1219
  threeutils.setWorldRotationXYZ(this.gameObject, x, y, z, degrees);
767
1220
  }
768
1221
 
769
1222
  private static _forward: Vector3 = new Vector3();
770
- /** Forward (0,0,-1) vector in world space */
1223
+ /**
1224
+ * Gets the forward direction vector (0,0,-1) of this component's GameObject in world space
1225
+ */
771
1226
  public get forward(): Vector3 {
772
1227
  return Component._forward.set(0, 0, -1).applyQuaternion(this.worldQuaternion);
773
1228
  }
774
1229
  private static _right: Vector3 = new Vector3();
775
- /** Right (1,0,0) vector in world space */
1230
+ /**
1231
+ * Gets the right direction vector (1,0,0) of this component's GameObject in world space
1232
+ */
776
1233
  public get right(): Vector3 {
777
1234
  return Component._right.set(1, 0, 0).applyQuaternion(this.worldQuaternion);
778
1235
  }
779
1236
  private static _up: Vector3 = new Vector3();
780
- /** Up (0,1,0) vector in world space */
1237
+ /**
1238
+ * Gets the up direction vector (0,1,0) of this component's GameObject in world space
1239
+ */
781
1240
  public get up(): Vector3 {
782
1241
  return Component._up.set(0, 1, 0).applyQuaternion(this.worldQuaternion);
783
1242
  }
784
1243
 
785
-
786
-
787
1244
  // EventTarget implementation:
788
1245
 
1246
+ /**
1247
+ * Storage for event listeners registered to this component
1248
+ * @private
1249
+ */
789
1250
  private _eventListeners = new Map<string, EventListener[]>();
790
1251
 
1252
+ /**
1253
+ * Registers an event listener for the specified event type
1254
+ * @param type The event type to listen for
1255
+ * @param listener The callback function to execute when the event occurs
1256
+ */
791
1257
  addEventListener<T extends Event>(type: string, listener: (evt: T) => any) {
792
1258
  this._eventListeners[type] = this._eventListeners[type] || [];
793
1259
  this._eventListeners[type].push(listener);
794
1260
  }
795
1261
 
1262
+ /**
1263
+ * Removes a previously registered event listener
1264
+ * @param type The event type the listener was registered for
1265
+ * @param listener The callback function to remove
1266
+ */
796
1267
  removeEventListener<T extends Event>(type: string, listener: (arg: T) => any) {
797
1268
  if (!this._eventListeners[type]) return;
798
1269
  const index = this._eventListeners[type].indexOf(listener);
799
1270
  if (index >= 0) this._eventListeners[type].splice(index, 1);
800
1271
  }
801
1272
 
1273
+ /**
1274
+ * Dispatches an event to all registered listeners
1275
+ * @param evt The event object to dispatch
1276
+ * @returns Always returns false (standard implementation of EventTarget)
1277
+ */
802
1278
  dispatchEvent(evt: Event): boolean {
803
1279
  if (!evt || !this._eventListeners[evt.type]) return false;
804
1280
  const listeners = this._eventListeners[evt.type];
src/engine-components/DragControls.ts CHANGED
@@ -18,8 +18,10 @@
18
18
  import type { IPointerEventHandler, PointerEventData } from "./ui/PointerEvents.js";
19
19
  import { ObjectRaycaster } from "./ui/Raycaster.js";
20
20
 
21
+ /** Enable debug visualization and logging for DragControls by using the URL parameter `?debugdrag`. */
21
22
  const debug = getParam("debugdrag");
22
23
 
24
+ /** Buffer to store currently active DragControls components */
23
25
  const dragControlsBuffer: DragControls[] = [];
24
26
 
25
27
  /**
@@ -34,7 +36,7 @@
34
36
  HitNormal = 2,
35
37
  /** Combination of XZ and Screen based on the viewing angle. Low angles result in Screen dragging and higher angles in XZ dragging. */
36
38
  DynamicViewAngle = 3,
37
- /** The drag plane is adjusted dynamically while dragging. */
39
+ /** The drag plane is snapped to surfaces in the scene while dragging. */
38
40
  SnapToSurfaces = 4,
39
41
  /** Don't allow dragging the object */
40
42
  None = 5,
@@ -42,18 +44,24 @@
42
44
 
43
45
  /**
44
46
  * DragControls allows you to drag objects around in the scene. It can be used to move objects in 2D (screen space) or 3D (world space).
47
+ * Debug mode can be enabled with the URL parameter `?debugdrag`, which shows visual helpers and logs drag operations.
48
+ *
45
49
  * @category Interactivity
46
50
  * @group Components
47
51
  */
48
52
  export class DragControls extends Behaviour implements IPointerEventHandler {
49
53
 
50
54
  /**
55
+ * Checks if any DragControls component is currently active with selected objects
51
56
  * @returns True if any DragControls component is currently active
52
57
  */
53
58
  public static get HasAnySelected(): boolean { return this._active > 0; }
54
59
  private static _active: number = 0;
55
60
 
56
- /** @returns a list of DragControl components that are currently active */
61
+ /**
62
+ * Retrieves a list of all DragControl components that are currently dragging objects.
63
+ * @returns Array of currently active DragControls components
64
+ */
57
65
  public static get CurrentlySelected() {
58
66
  dragControlsBuffer.length = 0;
59
67
  for (const dc of this._instances) {
@@ -63,51 +71,71 @@
63
71
  }
64
72
  return dragControlsBuffer;
65
73
  }
66
- /** Currently active and enabled DragControls components */
74
+ /** Registry of currently active and enabled DragControls components */
67
75
  private static _instances: DragControls[] = [];
68
76
 
69
-
70
-
71
- // dragPlane (floor, object, view)
72
- // snap to surface (snap orientation?)
73
- // two-handed drag (scale, rotate, move)
74
- // keep upright (no tilt)
75
-
76
- /** How and where the object is dragged along. */
77
+ /**
78
+ * Determines how and where the object is dragged along. Different modes include
79
+ * dragging along a plane, attached to the pointer, or following surface normals.
80
+ */
77
81
  @serializable()
78
82
  public dragMode: DragMode = DragMode.DynamicViewAngle;
79
83
 
80
- /** Snap dragged objects to a XYZ grid – 0 means: no snapping. */
84
+ /**
85
+ * Snaps dragged objects to a 3D grid with the specified resolution.
86
+ * Set to 0 to disable snapping.
87
+ */
81
88
  @serializable()
82
89
  public snapGridResolution: number = 0.0;
83
90
 
84
- /** Keep the original rotation of the dragged object. */
91
+ /**
92
+ * When true, maintains the original rotation of the dragged object while moving it.
93
+ * When false, allows the object to rotate freely during dragging.
94
+ */
85
95
  @serializable()
86
96
  public keepRotation: boolean = true;
87
97
 
88
- /** How and where the object is dragged along while dragging in XR. */
98
+ /**
99
+ * Determines how and where the object is dragged along while dragging in XR.
100
+ * Uses a separate setting from regular drag mode for better XR interaction.
101
+ */
89
102
  @serializable()
90
103
  public xrDragMode: DragMode = DragMode.Attached;
91
104
 
92
- /** Keep the original rotation of the dragged object while dragging in XR. */
105
+ /**
106
+ * When true, maintains the original rotation of the dragged object during XR dragging.
107
+ * When false, allows the object to rotate freely during XR dragging.
108
+ */
93
109
  @serializable()
94
110
  public xrKeepRotation: boolean = false;
95
111
 
96
- /** Accelerate dragging objects closer / further away when in XR */
112
+ /**
113
+ * Multiplier that affects how quickly objects move closer or further away when dragging in XR.
114
+ * Higher values make distance changes more pronounced.
115
+ * This is similar to mouse acceleration on a screen.
116
+ */
97
117
  @serializable()
98
118
  public xrDistanceDragFactor: number = 1;
99
119
 
100
- /** When enabled, draws a line from the dragged object downwards to the next raycast hit. */
120
+ /**
121
+ * When enabled, draws a visual line from the dragged object downwards to the next raycast hit,
122
+ * providing visual feedback about the object's position relative to surfaces below it.
123
+ */
101
124
  @serializable()
102
125
  public showGizmo: boolean = false;
103
126
 
104
- /** The currently dragged object (if any) */
127
+ /**
128
+ * Returns the object currently being dragged by this DragControls component, if any.
129
+ * @returns The object being dragged or null if no object is currently dragged
130
+ */
105
131
  get draggedObject() {
106
132
  return this._targetObject;
107
133
  }
108
134
 
109
135
  /**
110
- * Use to update the object that is being dragged by the DragControls
136
+ * Updates the object that is being dragged by the DragControls.
137
+ * This can be used to change the target during a drag operation.
138
+ * @param obj The new object to drag, or null to stop dragging
111
139
  */
112
140
  setTargetObject(obj: Object3D | null) {
113
141
  this._targetObject = obj;
@@ -133,8 +161,8 @@
133
161
  this._rigidbody[wasKinematicKey] = false;
134
162
  }
135
163
  }
136
-
137
164
  }
165
+
138
166
  private _rigidbody: Rigidbody | null = null;
139
167
 
140
168
  // future:
@@ -182,11 +210,20 @@
182
210
  DragControls._instances = DragControls._instances.filter(i => i !== this);
183
211
  }
184
212
 
213
+ /**
214
+ * Checks if editing is allowed for the current networking connection.
215
+ * @param _obj Optional object to check edit permissions for
216
+ * @returns True if editing is allowed
217
+ */
185
218
  private allowEdit(_obj: Object3D | null = null) {
186
219
  return this.context.connection.allowEditing;
187
220
  }
188
221
 
189
- /** @internal */
222
+ /**
223
+ * Handles pointer enter events. Sets the cursor style and tracks the hovered object.
224
+ * @param evt Pointer event data containing information about the interaction
225
+ * @internal
226
+ */
190
227
  onPointerEnter?(evt: PointerEventData) {
191
228
  if (!this.allowEdit(this.gameObject)) return;
192
229
  if (evt.mode !== "screen") return;
@@ -202,12 +239,20 @@
202
239
  this.context.domElement.style.cursor = 'pointer';
203
240
  }
204
241
 
205
- /** @internal */
242
+ /**
243
+ * Handles pointer movement events. Marks the event as used if dragging is active.
244
+ * @param args Pointer event data containing information about the movement
245
+ * @internal
246
+ */
206
247
  onPointerMove?(args: PointerEventData) {
207
248
  if (this._isDragging || this._potentialDragStartEvt !== null) args.use();
208
249
  }
209
250
 
210
- /** @internal */
251
+ /**
252
+ * Handles pointer exit events. Resets the cursor style when the pointer leaves a draggable object.
253
+ * @param evt Pointer event data containing information about the interaction
254
+ * @internal
255
+ */
211
256
  onPointerExit?(evt: PointerEventData) {
212
257
  if (!this.allowEdit(this.gameObject)) return;
213
258
  if (evt.mode !== "screen") return;
@@ -215,7 +260,11 @@
215
260
  this.context.domElement.style.cursor = 'auto';
216
261
  }
217
262
 
218
- /** @internal */
263
+ /**
264
+ * Handles pointer down events. Initiates the potential drag operation if conditions are met.
265
+ * @param args Pointer event data containing information about the interaction
266
+ * @internal
267
+ */
219
268
  onPointerDown(args: PointerEventData) {
220
269
  if (!this.allowEdit(this.gameObject)) return;
221
270
  if (args.used) return;
@@ -262,7 +311,11 @@
262
311
  }
263
312
  }
264
313
 
265
- /** @internal */
314
+ /**
315
+ * Handles pointer up events. Finalizes or cancels the drag operation.
316
+ * @param args Pointer event data containing information about the interaction
317
+ * @internal
318
+ */
266
319
  onPointerUp(args: PointerEventData) {
267
320
  if (debug) Gizmos.DrawLabel(args.point ?? this.gameObject.worldPosition, "POINTERUP:" + args.pointerId + ", " + args.button, .03, 3);
268
321
  if (!this.allowEdit(this.gameObject)) return;
@@ -293,9 +346,12 @@
293
346
  }
294
347
  }
295
348
 
296
- /** @internal */
349
+ /**
350
+ * Updates the drag operation every frame. Processes pointer movement, accumulates drag distance
351
+ * and triggers drag start once there's enough movement.
352
+ * @internal
353
+ */
297
354
  update(): void {
298
-
299
355
  for (const handler of this._dragHandlers.values()) {
300
356
  if (handler.collectMovementInfo) handler.collectMovementInfo();
301
357
  // TODO this doesn't make sense, we should instead just use the max here
@@ -324,7 +380,12 @@
324
380
  this.onAnyDragUpdate();
325
381
  }
326
382
 
327
- /** Called when the first pointer starts dragging on this object. Not called for subsequent pointers on the same object. */
383
+ /**
384
+ * Called when the first pointer starts dragging on this object.
385
+ * Sets up network synchronization and marks rigidbodies for dragging.
386
+ * Not called for subsequent pointers on the same object.
387
+ * @param evt Pointer event data that initiated the drag
388
+ */
328
389
  private onFirstDragStart(evt: PointerEventData) {
329
390
  if (!evt || !evt.object) return;
330
391
 
@@ -356,7 +417,10 @@
356
417
  this._draggingRigidbodies.push(...rbs);
357
418
  }
358
419
 
359
- /** Called each frame as long as any pointer is dragging this object. */
420
+ /**
421
+ * Called each frame as long as any pointer is dragging this object.
422
+ * Updates visuals and keeps rigidbodies awake during the drag.
423
+ */
360
424
  private onAnyDragUpdate() {
361
425
  if (!this._dragHelper) return;
362
426
  this._dragHelper.showGizmo = this.showGizmo;
@@ -373,7 +437,11 @@
373
437
  InstancingUtil.markDirty(object);
374
438
  }
375
439
 
376
- /** Called when the last pointer has been removed from this object. */
440
+ /**
441
+ * Called when the last pointer has been removed from this object.
442
+ * Cleans up drag state and applies final velocities to rigidbodies.
443
+ * @param evt Pointer event data for the last pointer that was lifted
444
+ */
377
445
  private onLastDragEnd(evt: PointerEventData | null) {
378
446
  if (!this || !this._isDragging) return;
379
447
  this._isDragging = false;
@@ -399,7 +467,10 @@
399
467
  }
400
468
  }
401
469
 
402
- /** Common interface for pointer handlers (single touch and multi touch) */
470
+ /**
471
+ * Common interface for pointer handlers (single touch and multi touch).
472
+ * Defines methods for tracking movement and managing target objects during drag operations.
473
+ */
403
474
  interface IDragHandler {
404
475
  /** Used to determine if a drag has happened for this handler */
405
476
  getTotalMovement?(): Vector3;
@@ -414,7 +485,10 @@
414
485
  onDragUpdate?(numberOfPointers: number): void;
415
486
  }
416
487
 
417
- /** Handles two touch points affecting one object. Allows movement, scale and rotation of objects. */
488
+ /**
489
+ * Handles two touch points affecting one object.
490
+ * Enables multi-touch interactions that allow movement, scaling, and rotation of objects.
491
+ */
418
492
  class MultiTouchDragHandler implements IDragHandler {
419
493
 
420
494
  handlerA: DragPointerHandler;
@@ -660,15 +734,27 @@
660
734
  }
661
735
 
662
736
 
663
- /** Handles a single pointer on an object. DragPointerHandlers are created on pointer enter,
664
- * help with determining if there's actually a drag, and then perform operations based on spatial pointer data.
737
+ /**
738
+ * Handles a single pointer on an object.
739
+ * DragPointerHandlers manage determining if a drag operation has started, tracking pointer movement,
740
+ * and controlling object translation based on the drag mode.
665
741
  */
666
742
  class DragPointerHandler implements IDragHandler {
667
743
 
668
- /** Absolute movement of the pointer. Used for determining if a motion/drag is happening.
669
- * This is in world units, so very small for screens (near-plane space change) */
744
+ /**
745
+ * Returns the accumulated movement of the pointer in world units.
746
+ * Used for determining if enough motion has occurred to start a drag.
747
+ */
670
748
  getTotalMovement(): Vector3 { return this._totalMovement; }
749
+
750
+ /**
751
+ * Returns the object that follows the pointer during dragging operations.
752
+ */
671
753
  get followObject(): GameObject { return this._followObject; }
754
+
755
+ /**
756
+ * Returns the point where the pointer initially hit the object in local space.
757
+ */
672
758
  get hitPointInLocalSpace(): Vector3 { return this._hitPointInLocalSpace; }
673
759
 
674
760
  private context: Context;
@@ -1249,18 +1335,28 @@
1249
1335
  }
1250
1336
  }
1251
1337
 
1252
- /** Currently does _only_ provide visuals support for DragControls operations.
1253
- * Previously it also provided the actual drag functionality, but that has been moved to DragControls for now.
1338
+ /**
1339
+ * Provides visual helper elements for DragControls.
1340
+ * Shows where objects will be placed and their relation to surfaces below them.
1254
1341
  */
1255
1342
  class LegacyDragVisualsHelper {
1256
1343
 
1344
+ /** Controls whether visual helpers like lines and markers are displayed */
1257
1345
  showGizmo: boolean = true;
1346
+
1347
+ /** When true, drag plane alignment changes based on view angle */
1258
1348
  useViewAngle: boolean = true;
1259
1349
 
1350
+ /**
1351
+ * Checks if there is a currently selected object being visualized
1352
+ */
1260
1353
  public get hasSelected(): boolean {
1261
1354
  return this._selected !== null && this._selected !== undefined;
1262
1355
  }
1263
1356
 
1357
+ /**
1358
+ * Returns the currently selected object being visualized, if any
1359
+ */
1264
1360
  public get selected(): Object3D | null {
1265
1361
  return this._selected;
1266
1362
  }
src/engine-components/DropListener.ts CHANGED
@@ -19,63 +19,104 @@
19
19
  import { Behaviour } from "./Component.js";
20
20
  import { EventList } from "./EventList.js";
21
21
 
22
+ /**
23
+ * Debug mode can be enabled with the URL parameter `?debugdroplistener`, which
24
+ * logs additional information during drag and drop events and visualizes hit points.
25
+ */
22
26
  const debug = getParam("debugdroplistener");
23
27
 
28
+ /**
29
+ * Events dispatched by the DropListener component
30
+ * @enum {string}
31
+ */
24
32
  export enum DropListenerEvents {
25
33
  /**
26
- * Dispatched when a file is dropped into the scene. The detail of the event is the file that was dropped.
34
+ * Dispatched when a file is dropped into the scene. The detail of the event is the {@link File} that was dropped.
35
+ * The event is called once for each dropped file.
27
36
  */
28
37
  FileDropped = "file-dropped",
29
38
  /**
30
- * Dispatched when a new object is added to the scene. The detail of the event is the glTF that was added.
39
+ * Dispatched when a new object is added to the scene. The detail of the event contains {@link DropListenerOnDropArguments} for the content that was added.
31
40
  */
32
41
  ObjectAdded = "object-added",
33
42
  }
34
43
 
44
+ /**
45
+ * Context information for a drop operation
46
+ */
35
47
  declare type DropContext = {
48
+ /** Position where the file was dropped in screen coordinates */
36
49
  screenposition: Vector2;
50
+ /** URL of the dropped content, if applicable */
37
51
  url?: string,
52
+ /** File object of the dropped content, if applicable */
38
53
  file?: File;
54
+ /** 3D position where the content should be placed */
39
55
  point?: Vec3;
56
+ /** Size dimensions for the content */
40
57
  size?: Vec3;
41
58
  }
42
59
 
43
60
 
44
- /** Networking event arguments for the DropListener component */
61
+ /**
62
+ * Network event arguments passed between clients when using the DropListener with networking
63
+ */
45
64
  export declare type DropListenerNetworkEventArguments = {
65
+ /** Unique identifier of the sender */
46
66
  guid: string,
67
+ /** Name of the dropped object */
47
68
  name: string,
69
+ /** URL or array of URLs to the dropped content */
48
70
  url: string | string[],
49
71
  /** Worldspace point where the object was placed in the scene */
50
72
  point: Vec3;
51
73
  /** Bounding box size */
52
74
  size: Vec3;
75
+ /** MD5 hash of the content for verification */
53
76
  contentMD5: string;
54
77
  }
55
78
 
79
+ /**
80
+ * Arguments provided to handlers when an object is dropped or added to the scene
81
+ */
56
82
  export declare type DropListenerOnDropArguments = {
83
+ /** The DropListener component that processed the drop event */
57
84
  sender: DropListener,
58
- /** the root object added to the scene */
85
+ /** The root object added to the scene */
59
86
  object: Object3D,
60
- /** The whole dropped model */
87
+ /** The complete model with all associated data */
61
88
  model: Model,
89
+ /** MD5 hash of the content for verification */
62
90
  contentMD5: string;
91
+ /** The original dropped URL or File object */
63
92
  dropped: URL | File | undefined;
64
93
  }
65
94
 
66
- /** Dispatched when an object is dropped/changed */
95
+ /**
96
+ * CustomEvent dispatched when an object is added to the scene via the DropListener
97
+ */
67
98
  class DropListenerAddedEvent<T extends DropListenerOnDropArguments> extends CustomEvent<T> {
99
+ /**
100
+ * Creates a new added event with the provided details
101
+ * @param detail Information about the added object
102
+ */
68
103
  constructor(detail: T) {
69
104
  super(DropListenerEvents.ObjectAdded, { detail });
70
105
  }
71
106
  }
72
107
 
108
+ /**
109
+ * Key name used for blob storage parameters
110
+ */
73
111
  const blobKeyName = "blob";
74
112
 
75
113
  /** The DropListener component is used to listen for drag and drop events in the browser and add the dropped files to the scene
76
114
  * It can be used to allow users to drag and drop glTF files into the scene to add new objects.
77
115
  *
78
- * ## Events
116
+ * If {@link useNetworking} is enabled, the DropListener will automatically synchronize dropped files to other connected clients.
117
+ * Enable {@link fitIntoVolume} to automatically scale dropped objects to fit within the volume defined by {@link fitVolumeSize}.
118
+ *
119
+ * The following events are dispatched by the DropListener:
79
120
  * - **object-added** - dispatched when a new object is added to the scene
80
121
  * - **file-dropped** - dispatched when a file is dropped into the scene
81
122
  *
@@ -103,42 +144,46 @@
103
144
  export class DropListener extends Behaviour {
104
145
 
105
146
  /**
106
- * When enabled the DropListener will automatically network dropped files to other clients.
147
+ * When enabled, the DropListener will automatically synchronize dropped files to other connected clients.
148
+ * When a file is dropped locally, it will be uploaded to blob storage and the URL will be shared with other clients.
107
149
  */
108
150
  @serializable()
109
151
  useNetworking: boolean = true;
110
152
 
111
153
  /**
112
- * When assigned the Droplistener will only accept files that are dropped on this object.
154
+ * When assigned, the DropListener will only accept files that are dropped on this specific object.
155
+ * This allows creating designated drop zones in your scene.
113
156
  */
114
157
  @serializable(Object3D)
115
158
  dropArea?: Object3D;
116
159
 
117
160
  /**
118
- * When enabled the object will be fitted into a volume. Use {@link fitVolumeSize} to specify the volume size.
161
+ * When enabled, dropped objects will be automatically scaled to fit within the volume defined by fitVolumeSize.
162
+ * Useful for ensuring dropped models appear at an appropriate scale.
119
163
  * @default false
120
164
  */
121
165
  @serializable()
122
166
  fitIntoVolume: boolean = false;
123
167
 
124
168
  /**
125
- * The volume size will be used to fit the object into the volume. Use {@link fitIntoVolume} to enable this feature.
169
+ * Defines the dimensions of the volume that dropped objects will be scaled to fit within.
170
+ * Only used when fitIntoVolume is enabled.
126
171
  */
127
172
  @serializable(Vector3)
128
173
  fitVolumeSize = new Vector3(1, 1, 1);
129
174
 
130
- /** When enabled the object will be placed at the drop position (under the cursor)
175
+ /**
176
+ * When enabled, dropped objects will be positioned at the point where the cursor hit the scene.
177
+ * When disabled, objects will be placed at the origin of the DropListener.
131
178
  * @default true
132
179
  */
133
180
  @serializable()
134
181
  placeAtHitPosition: boolean = true;
135
182
 
136
-
137
183
  /**
138
- * Invoked after a file has been **added** to the scene.
139
- * Arguments are {@link DropListenerOnDropArguments}
184
+ * Event list that gets invoked after a file has been successfully added to the scene.
185
+ * Receives {@link DropListenerOnDropArguments} containing the added object and related information.
140
186
  * @event object-added
141
- * @param {DropListenerOnDropArguments} evt
142
187
  * @example
143
188
  * ```typescript
144
189
  * dropListener.onDropped.addEventListener((evt) => {
@@ -178,6 +223,10 @@
178
223
  this.removePreviouslyAddedObjects(false);
179
224
  }
180
225
 
226
+ /**
227
+ * Handles network events received from other clients containing information about dropped objects
228
+ * @param evt Network event data containing object information, position, and content URL
229
+ */
181
230
  private onNetworkEvent = (evt: DropListenerNetworkEventArguments) => {
182
231
  if (!this.useNetworking) {
183
232
  if (debug) console.debug("[DropListener] Ignoring networked event because networking is disabled", evt);
@@ -199,6 +248,11 @@
199
248
  }
200
249
  }
201
250
 
251
+ /**
252
+ * Handles clipboard paste events and processes them as potential URL drops
253
+ * Only URLs are processed by this handler, and only when editing is allowed
254
+ * @param evt The paste event
255
+ */
202
256
  private handlePaste = (evt: Event) => {
203
257
  if (this.context.connection.allowEditing === false) return;
204
258
  if (evt.defaultPrevented) return;
@@ -217,12 +271,22 @@
217
271
  .catch(console.warn);
218
272
  }
219
273
 
274
+ /**
275
+ * Handles drag events over the renderer's canvas
276
+ * Prevents default behavior to enable drop events
277
+ * @param evt The drag event
278
+ */
220
279
  private onDrag = (evt: DragEvent) => {
221
280
  if (this.context.connection.allowEditing === false) return;
222
281
  // necessary to get drop event
223
282
  evt.preventDefault();
224
283
  }
225
284
 
285
+ /**
286
+ * Processes drop events to add files to the scene
287
+ * Handles both file drops and text/URL drops
288
+ * @param evt The drop event
289
+ */
226
290
  private onDrop = async (evt: DragEvent) => {
227
291
  if (this.context.connection.allowEditing === false) return;
228
292
 
@@ -266,6 +330,14 @@
266
330
  }
267
331
  }
268
332
 
333
+ /**
334
+ * Processes a dropped or pasted URL and tries to load it as a 3D model
335
+ * Handles special cases like GitHub URLs and Polyhaven asset URLs
336
+ * @param url The URL to process
337
+ * @param ctx Context information about where the drop occurred
338
+ * @param isRemote Whether this URL was shared from a remote client
339
+ * @returns The added object or null if loading failed
340
+ */
269
341
  private async addFromUrl(url: string, ctx: DropContext, isRemote: boolean) {
270
342
  if (debug) console.log("dropped url", url);
271
343
 
@@ -315,6 +387,12 @@
315
387
 
316
388
  private _abort: AbortController | null = null;
317
389
 
390
+ /**
391
+ * Processes dropped files, loads them as 3D models, and handles networking if enabled
392
+ * Creates an abort controller to cancel previous uploads if new files are dropped
393
+ * @param fileList Array of dropped files
394
+ * @param ctx Context information about where the drop occurred
395
+ */
318
396
  private async addDroppedFiles(fileList: Array<File>, ctx: DropContext) {
319
397
  if (debug) console.log("Add files", fileList)
320
398
  if (!Array.isArray(fileList)) return;
@@ -361,7 +439,10 @@
361
439
  private readonly _addedObjects = new Array<Object3D>();
362
440
  private readonly _addedModels = new Array<Model>();
363
441
 
364
- /** Removes all previously added objects from the scene and removes those object references */
442
+ /**
443
+ * Removes all previously added objects from the scene
444
+ * @param doDestroy When true, destroys the objects; when false, just clears the references
445
+ */
365
446
  private removePreviouslyAddedObjects(doDestroy: boolean = true) {
366
447
  if (doDestroy) {
367
448
  for (const prev of this._addedObjects) {
@@ -375,7 +456,13 @@
375
456
  }
376
457
 
377
458
  /**
378
- * Adds the object to the scene and fits it into the volume if {@link fitIntoVolume} is enabled.
459
+ * Adds a loaded model to the scene with proper positioning and scaling.
460
+ * Handles placement based on component settings and raycasting.
461
+ * If {@link fitIntoVolume} is enabled, the object will be scaled to fit within the volume defined by {@link fitVolumeSize}.
462
+ * @param data The loaded model data and content hash
463
+ * @param ctx Context information about where the drop occurred
464
+ * @param isRemote Whether this object was shared from a remote client
465
+ * @returns The added object or null if adding failed
379
466
  */
380
467
  private addObject(data: { model: Model, contentMD5: string }, ctx: DropContext, isRemote: boolean): Object3D | null {
381
468
 
@@ -444,6 +531,13 @@
444
531
  return obj;
445
532
  }
446
533
 
534
+ /**
535
+ * Sends a network event to other clients about a dropped object
536
+ * Only triggered when networking is enabled and the connection is established
537
+ * @param url The URL to the content that was dropped
538
+ * @param obj The object that was added to the scene
539
+ * @param contentmd5 The content hash for verification
540
+ */
447
541
  private async sendDropEvent(url: string, obj: Object3D, contentmd5: string) {
448
542
  if (!this.useNetworking) {
449
543
  if (debug) console.debug("[DropListener] Ignoring networked event because networking is disabled", url);
@@ -463,12 +557,20 @@
463
557
  this.context.connection.send("droplistener", evt);
464
558
  }
465
559
  }
560
+
561
+ /**
562
+ * Deletes remote state for this DropListener's objects
563
+ * Called when new files are dropped to clean up previous state
564
+ */
466
565
  private deleteDropEvent() {
467
566
  this.context.connection.sendDeleteRemoteState(this.guid);
468
567
  }
469
568
 
470
-
471
-
569
+ /**
570
+ * Tests if a drop event occurred within the designated drop area if one is specified
571
+ * @param ctx The drop context containing screen position information
572
+ * @returns True if the drop is valid (either no drop area is set or the drop occurred inside it)
573
+ */
472
574
  private testIfIsInDropArea(ctx: DropContext): boolean {
473
575
  if (this.dropArea) {
474
576
  const screenPoint = this.context.input.convertScreenspaceToRaycastSpace(ctx.screenposition.clone());
@@ -492,7 +594,11 @@
492
594
 
493
595
  }
494
596
 
495
-
597
+ /**
598
+ * Attempts to convert a Polyhaven website URL to a direct glTF model download URL
599
+ * @param urlStr The original Polyhaven URL
600
+ * @returns The direct download URL for the glTF model if it's a valid Polyhaven asset URL, otherwise returns the original URL
601
+ */
496
602
  function tryResolvePolyhavenAssetUrl(urlStr: string) {
497
603
  if (!urlStr.startsWith("https://polyhaven.com/")) return urlStr;
498
604
  // Handle dropping polyhaven image url
@@ -506,10 +612,18 @@
506
612
  return assetUrl;
507
613
  }
508
614
 
509
-
510
-
615
+ /**
616
+ * Helper namespace for loading files and models from various sources
617
+ */
511
618
  namespace FileHelper {
512
619
 
620
+ /**
621
+ * Loads and processes a File object into a 3D model
622
+ * @param file The file to load (supported formats: gltf, glb, fbx, obj, usdz, vrm)
623
+ * @param context The application context
624
+ * @param args Additional arguments including a unique guid for instantiation
625
+ * @returns Promise containing the loaded model and its content hash, or null if loading failed
626
+ */
513
627
  export async function loadFile(file: File, context: Context, args: { guid: string }): Promise<{ model: Model, contentMD5: string } | null> {
514
628
  const name = file.name.toLowerCase();
515
629
  if (name.endsWith(".gltf") ||
@@ -544,6 +658,12 @@
544
658
  return null;
545
659
  }
546
660
 
661
+ /**
662
+ * Loads a 3D model from a URL with progress visualization
663
+ * @param url The URL to load the model from
664
+ * @param args Arguments including context, parent object, and optional placement information
665
+ * @returns Promise containing the loaded model and its content hash, or null if loading failed
666
+ */
547
667
  export async function loadFileFromURL(url: URL, args: { guid: string, context: Context, parent: Object3D, point?: Vec3, size?: Vec3 }): Promise<{ model: Model, contentMD5: string } | null> {
548
668
  return new Promise(async (resolve, _reject) => {
549
669
 
src/engine-components/Duplicatable.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Object3D, Quaternion, Vector3 } from "three";
2
2
 
3
3
  import { isDevEnvironment } from "../engine/debug/index.js";
4
+ import { WaitForSeconds } from "../engine/engine_coroutine.js";
4
5
  import { InstantiateOptions } from "../engine/engine_gameobject.js";
5
6
  import { InstantiateIdProvider } from "../engine/engine_networking_instantiate.js";
6
7
  import { serializable } from "../engine/engine_serialization_decorator.js";
@@ -30,17 +31,10 @@
30
31
 
31
32
  /**
32
33
  * The maximum number of objects that can be duplicated in the interval.
33
- * @default 10
34
- */
35
- @serializable()
36
- limitCount = 10;
37
-
38
- /**
39
- * The interval in seconds in which the limitCount is reset.
40
34
  * @default 60
41
35
  */
42
36
  @serializable()
43
- limitInterval = 60;
37
+ limitCount = 60;
44
38
 
45
39
  private _currentCount = 0;
46
40
  private _startPosition: Vector3 | null = null;
@@ -94,9 +88,10 @@
94
88
 
95
89
  if (!this.gameObject.getComponentInParent(ObjectRaycaster))
96
90
  this.gameObject.addComponent(ObjectRaycaster);
97
-
98
- this.cloneLimitIntervalFn();
99
91
  }
92
+ onEnable(): void {
93
+ this.startCoroutine(this.cloneLimitIntervalFn());
94
+ }
100
95
 
101
96
  private _forwardPointerEvents: Map<Object3D, DragControls> = new Map();
102
97
 
@@ -131,7 +126,12 @@
131
126
  }
132
127
  }
133
128
  else {
134
- console.warn("Could not duplicate object. Has the target object been destroyed?", this);
129
+ if (this._currentCount >= this.limitCount) {
130
+ console.warn(`[Duplicatable] Limit of ${this.limitCount} objects created within a few seconds reached. Please wait a moment before creating more objects.`);
131
+ }
132
+ else {
133
+ console.warn(`[Duplicatable] Could not duplicate object.`);
134
+ }
135
135
  }
136
136
  }
137
137
 
@@ -145,19 +145,21 @@
145
145
  }
146
146
  }
147
147
 
148
- private cloneLimitIntervalFn() {
149
- if (this.destroyed) return;
150
- if (this._currentCount > 0) {
151
- this._currentCount -= 1;
148
+ private *cloneLimitIntervalFn() {
149
+ while (this.activeAndEnabled && !this.destroyed) {
150
+ if (this._currentCount > 0) {
151
+ this._currentCount -= 1;
152
+ }
153
+ else if (this._currentCount < 0) {
154
+ this._currentCount = 0;
155
+ }
156
+ yield WaitForSeconds(1);
152
157
  }
153
- setTimeout(() => {
154
- this.cloneLimitIntervalFn();
155
- }, (this.limitInterval / this.limitCount) * 1000);
156
158
  }
157
159
 
158
160
  private handleDuplication(): Object3D | null {
159
161
  if (!this.object) return null;
160
- if (this._currentCount >= this.limitCount) return null;
162
+ if (this.limitCount > 0 && this._currentCount >= this.limitCount) return null;
161
163
  if (this.object === this.gameObject) return null;
162
164
  if (GameObject.isDestroyed(this.object)) {
163
165
  this.object = null;
src/engine/engine_addressables.ts CHANGED
@@ -164,6 +164,11 @@
164
164
  return this._url;
165
165
  }
166
166
 
167
+ /** The name of the assigned url. This name is deduced from the url and might not reflect the actual name of the asset */
168
+ get urlName(): string {
169
+ return this._urlName;
170
+ };
171
+
167
172
  /**
168
173
  * @returns true if the uri is a valid URL (http, https, blob)
169
174
  */
@@ -180,6 +185,7 @@
180
185
  private _asset: any;
181
186
  private _glbRoot?: Object3D | null;
182
187
  private _url: string;
188
+ private _urlName: string;
183
189
  private _progressListeners: ProgressCallback[] = [];
184
190
 
185
191
  private _hash?: string;
@@ -191,12 +197,27 @@
191
197
  /** @internal */
192
198
  constructor(uri: string, hash?: string, asset: any = null) {
193
199
  this._url = uri;
200
+
201
+ const lastUriPart = uri.lastIndexOf("/");
202
+ if (lastUriPart >= 0) {
203
+ this._urlName = uri.substring(lastUriPart + 1);
204
+ // remove file extension
205
+ const lastDot = this._urlName.lastIndexOf(".");
206
+ if (lastDot >= 0) {
207
+ this._urlName = this._urlName.substring(0, lastDot);
208
+ }
209
+ }
210
+ else {
211
+ this._urlName = uri;
212
+ }
213
+
194
214
  this._hash = hash;
195
215
  if (uri.includes("?v="))
196
216
  this._hashedUri = uri;
197
217
  else
198
218
  this._hashedUri = hash ? uri + "?v=" + hash : uri;
199
219
  if (asset !== null) this.asset = asset;
220
+
200
221
  registerPrefabProvider(this._url, this.onResolvePrefab.bind(this));
201
222
  }
202
223
 
@@ -527,10 +548,20 @@
527
548
 
528
549
  private loader: TextureLoader | null = null;
529
550
  createTexture(): Promise<Texture | null> {
530
- if (!this.url) return failedTexturePromise;
551
+ if (!this.url) {
552
+ console.error("Can not load texture without url");
553
+ return failedTexturePromise;
554
+ }
555
+
531
556
  if (!this.loader) this.loader = new TextureLoader();
532
557
  this.loader.setCrossOrigin("anonymous");
533
- return this.loader.loadAsync(this.url);
558
+ return this.loader.loadAsync(this.url).then(res => {
559
+ if (res && !res.name?.length) {
560
+ // default name if no name is defined
561
+ res.name = this.url.split("/").pop() ?? this.url;
562
+ }
563
+ return res;
564
+ })
534
565
  // return this.getBitmap().then((bitmap) => {
535
566
  // if (bitmap) {
536
567
  // const texture = new Texture(bitmap);
src/engine/engine_context.ts CHANGED
@@ -864,7 +864,7 @@
864
864
  if (name.length > 3) offendingComponentName = name;
865
865
  }
866
866
  }
867
- console.error(`Needle Engine dependencies failed to load:\n\n# Make sure you don't have circular imports in your scripts!\n Possible solution: Replace @serializable(${offendingComponentName}) in your script with @serializable(Behaviour)\n\n---`, err)
867
+ console.error(`Needle Engine dependencies failed to load:\n\n# Make sure you don't have circular imports in your scripts!\n\nPossible solutions: \n→ Replace @serializable(${offendingComponentName}) in your script with @serializable(Behaviour)\n→ If you only need type information try importing the type only, e.g: import { type ${offendingComponentName} }\n\n---`, err)
868
868
  return;
869
869
  }
870
870
  if (!printedError) {
src/engine/engine_input.ts CHANGED
@@ -255,7 +255,26 @@
255
255
  /** Adds an event listener for the specified event type. The callback will be called when the event is triggered.
256
256
  * @param type The event type to listen for
257
257
  * @param callback The callback to call when the event is triggered
258
- * @param options The options for adding the event listener
258
+ * @param options The options for adding the event listener.
259
+ * @example Basic usage:
260
+ * ```ts
261
+ * input.addEventListener("pointerdown", (evt) => {
262
+ * console.log("Pointer down", evt.pointerId, evt.pointerType);
263
+ * });
264
+ * ```
265
+ * @example Adding a listener that is called after all other listeners
266
+ * By using a higher value for the queue the listener will be called after other listeners (default queue is 0).
267
+ * ```ts
268
+ * input.addEventListener("pointerdown", (evt) => {
269
+ * console.log("Pointer down", evt.pointerId, evt.pointerType);
270
+ * }, { queue: 10 });
271
+ * ```
272
+ * @example Adding a listener that is only called once
273
+ * ```ts
274
+ * input.addEventListener("pointerdown", (evt) => {
275
+ * console.log("Pointer down", evt.pointerId, evt.pointerType);
276
+ * }, { once: true });
277
+ * ```
259
278
  */
260
279
  addEventListener(type: PointerEventNames, callback: PointerEventListener, options?: EventListenerOptions);
261
280
  addEventListener(type: KeyboardEventNames, callback: KeyboardEventListener, options?: EventListenerOptions);
src/engine/engine_mainloop_utils.ts CHANGED
@@ -326,8 +326,7 @@
326
326
  if (comp.enabled) {
327
327
  safeInvoke(comp.__internalAwake.bind(comp));
328
328
  if (comp.enabled) {
329
- comp["__didEnable"] = true;
330
- comp.onEnable();
329
+ comp.__internalEnable();
331
330
  }
332
331
  }
333
332
  }
@@ -343,9 +342,8 @@
343
342
 
344
343
  let success = true;
345
344
  if (go.children) {
346
- const nextLevel = level + 1;
347
345
  for (const ch of go.children) {
348
- const res = updateIsActiveInHierarchyRecursiveRuntime(ch, activeInHierarchy, allowEventCall, nextLevel);
346
+ const res = updateIsActiveInHierarchyRecursiveRuntime(ch, activeInHierarchy, allowEventCall, level + 1);
349
347
  if (res === false) success = false;
350
348
  }
351
349
  }
src/engine/engine_math.ts CHANGED
@@ -43,16 +43,30 @@
43
43
  return this.clamp(value, 0, 1);
44
44
  }
45
45
 
46
+ /**
47
+ * Linear interpolate
48
+ */
46
49
  lerp(value1: number, value2: number, t: number) {
47
50
  t = t < 0 ? 0 : t;
48
51
  t = t > 1 ? 1 : t;
49
52
  return value1 + (value2 - value1) * t;
50
53
  }
51
54
 
55
+ /**
56
+ *
57
+ */
52
58
  inverseLerp(value1: number, value2: number, t: number) {
53
59
  return (t - value1) / (value2 - value1);
54
60
  }
55
61
 
62
+ /**
63
+ * Remaps a value from one range to another.
64
+ * @param value The value to remap.
65
+ * @param min1 The minimum value of the current range.
66
+ * @param max1 The maximum value of the current range.
67
+ * @param min2 The minimum value of the target range.
68
+ * @param max2 The maximum value of the target range.
69
+ */
56
70
  remap(value: number, min1: number, max1: number, min2: number, max2: number) {
57
71
  return min2 + (max2 - min2) * (value - min1) / (max1 - min1);
58
72
  }
@@ -64,20 +78,24 @@
64
78
  return value1;
65
79
  }
66
80
 
81
+
82
+
83
+ readonly Rad2Deg = 180 / Math.PI;
84
+ readonly Deg2Rad = Math.PI / 180;
85
+ readonly Epsilon = 0.00001;
86
+ /**
87
+ * Converts radians to degrees
88
+ */
67
89
  toDegrees(radians: number) {
68
90
  return radians * 180 / Math.PI;
69
91
  }
70
-
71
- readonly Rad2Deg = 180 / Math.PI;
72
-
92
+ /**
93
+ * Converts degrees to radians
94
+ */
73
95
  toRadians(degrees: number) {
74
96
  return degrees * Math.PI / 180;
75
97
  }
76
98
 
77
- readonly Deg2Rad = Math.PI / 180;
78
-
79
- readonly Epsilon = 0.00001;
80
-
81
99
  tan(radians: number) {
82
100
  return Math.tan(radians);
83
101
  }
src/engine/engine_serialization_core.ts CHANGED
@@ -406,7 +406,7 @@
406
406
  }
407
407
  const hasOtherKeys = value !== undefined && Object.keys(value).length > 1;
408
408
  if (!hasOtherKeys) {
409
- addLog(LogType.Warn, `<strong>Missing serialization for object reference!</strong>\n\nPlease change to: \n@serializable(Object3D)\n${key}? : Object3D;\n\nin script ${typeName}.ts\n<a href="https://docs.needle.tools/serializable" target="_blank">documentation</a>`);
409
+ addLog(LogType.Warn, `<strong>Missing serialization for object reference!</strong>\n\nPlease change to: \n@serializable(Object3D)\n${key}? : Object3D;\n\nin ${typeName}.ts\n<a href="https://docs.needle.tools/serializable" target="_blank">See documentation</a>`);
410
410
  console.warn(typeName, key, obj[key], obj);
411
411
  continue;
412
412
  }
src/engine/engine_three_utils.ts CHANGED
@@ -398,7 +398,7 @@
398
398
  gl_FragColor = texture2D( map, uv);
399
399
  // gl_FragColor = vec4(uv.xy, 0, 1);
400
400
  }`;
401
- private static blipMaterial: ShaderMaterial | undefined = undefined;
401
+ private static blitMaterial: ShaderMaterial | undefined = undefined;
402
402
 
403
403
  /**
404
404
  * Create a blit material for copying textures
@@ -419,27 +419,28 @@
419
419
  * @returns the newly created, copied texture
420
420
  */
421
421
  static copyTexture(texture: Texture, blitMaterial?: ShaderMaterial): Texture {
422
- const material = blitMaterial ?? this.blipMaterial!;
423
-
424
- material.uniforms.map.value = texture;
425
- material.needsUpdate = true;
426
- material.uniformsNeedUpdate = true;
427
-
428
422
  // ensure that a blit material exists
429
- if (!this.blipMaterial) {
430
- this.blipMaterial = new ShaderMaterial({
423
+ if (!this.blitMaterial) {
424
+ this.blitMaterial = new ShaderMaterial({
431
425
  uniforms: { map: new Uniform(null) },
432
426
  vertexShader: this.vertex,
433
427
  fragmentShader: this.fragment
434
428
  });
435
429
  }
436
430
 
431
+ const material = blitMaterial || this.blitMaterial;
432
+
433
+ material.uniforms.map.value = texture;
434
+ material.needsUpdate = true;
435
+ material.uniformsNeedUpdate = true;
436
+
437
+
437
438
  // ensure that the blit material has the correct vertex shader
438
439
  const origVertexShader = material.vertexShader;
439
440
  material.vertexShader = this.vertex;
440
441
 
441
442
  if (!this.mesh)
442
- this.mesh = new Mesh(this.planeGeometry, this.blipMaterial);
443
+ this.mesh = new Mesh(this.planeGeometry, this.blitMaterial);
443
444
  const mesh = this.mesh;
444
445
  mesh.material = material;
445
446
  mesh.frustumCulled = false;
@@ -474,15 +475,22 @@
474
475
  // return new Texture(this.renderer.domElement);
475
476
  // }
476
477
 
477
- static textureToCanvas(texture: Texture, force: boolean) {
478
- if (!texture) return null;
478
+ /**
479
+ * Copy a texture to a HTMLCanvasElement
480
+ * @param texture the texture convert
481
+ * @param force if true the texture will be copied to a new texture before converting
482
+ * @returns the HTMLCanvasElement with the texture or null if the texture could not be copied
483
+ */
484
+ static textureToCanvas(texture: Texture, force: boolean = false): HTMLCanvasElement | null {
485
+ if (!texture) {
486
+ return null;
487
+ }
479
488
 
480
489
  if (force === true || texture["isCompressedTexture"] === true) {
481
490
  texture = copyTexture(texture);
482
491
  }
483
492
  const image = texture.image;
484
493
  if (isImageBitmap(image)) {
485
-
486
494
  const canvas = document.createElement('canvas');
487
495
  canvas.width = image.width;
488
496
  canvas.height = image.height;
@@ -494,8 +502,8 @@
494
502
  }
495
503
  context.drawImage(image, 0, 0, image.width, image.height, 0, 0, canvas.width, canvas.height);
496
504
  return canvas;
505
+ }
497
506
 
498
- }
499
507
  return null;
500
508
  }
501
509
  }
src/engine/engine_types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { QueryFilterFlags } from "@dimforge/rapier3d-compat";
1
+ import type { QueryFilterFlags, World } from "@dimforge/rapier3d-compat";
2
2
  import { AnimationClip, Color, Material, Mesh, Object3D, Quaternion } from "three";
3
3
  import { Vector3 } from "three";
4
4
  import { type GLTF as THREE_GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
@@ -442,80 +442,241 @@
442
442
 
443
443
 
444
444
  export interface IPhysicsEngine {
445
+ /** Initializes the physics engine */
445
446
  initialize(): Promise<boolean>;
447
+ /** Indicates whether the physics engine has been initialized */
446
448
  get isInitialized(): boolean;
449
+ /** Advances physics simulation by the given time step */
447
450
  step(dt: number): void;
448
451
  postStep();
452
+ /** Indicates whether the physics engine is currently updating */
449
453
  get isUpdating(): boolean;
450
- /** clear all possibly cached data (e.g. mesh data when creating scaled mesh colliders) */
454
+ /** Clears all cached data (e.g., mesh data when creating scaled mesh colliders) */
451
455
  clearCaches();
452
456
 
457
+ /** Enables or disables the physics engine */
453
458
  enabled: boolean;
454
- get world(): any;
459
+ /** Returns the underlying physics world object */
460
+ get world(): World | undefined;
455
461
 
462
+ /** Sets the gravity vector for the physics simulation */
456
463
  set gravity(vec3: Vec3);
464
+ /** Gets the current gravity vector */
457
465
  get gravity(): Vec3;
458
466
 
459
- /** Get the rapier body for a Needle component */
467
+ /**
468
+ * Gets the rapier physics body for a Needle component
469
+ * @param obj The collider or rigidbody component
470
+ * @returns The underlying physics body or null if not found
471
+ */
460
472
  getBody(obj: ICollider | IRigidbody): null | any;
461
- /** Get the Needle Engine component for a rapier object */
473
+ /**
474
+ * Gets the Needle Engine component for a rapier physics object
475
+ * @param rapierObject The rapier physics object
476
+ * @returns The associated component or null if not found
477
+ */
462
478
  getComponent(rapierObject: object): IComponent | null;
463
479
 
464
- // raycasting
465
- /** Fast raycast against physics colliders
466
- * @param origin ray origin in screen or worldspace
467
- * @param direction ray direction in worldspace
468
- * @param options additional options
480
+ /**
481
+ * Performs a fast raycast against physics colliders
482
+ * @param origin Ray origin in screen or worldspace
483
+ * @param direction Ray direction in worldspace
484
+ * @param options Additional raycast configuration options
485
+ * @returns Raycast result containing hit point and collider, or null if no hit
469
486
  */
470
487
  raycast(origin?: Vec2 | Vec3, direction?: Vec3, options?: {
471
488
  maxDistance?: number,
472
489
  /** True if you want to also hit objects when the raycast starts from inside a collider */
473
490
  solid?: boolean,
474
491
  queryFilterFlags?: QueryFilterFlags,
475
- /** Raycast filter groups. Groups are used to apply the collision group rules for the scene query. The scene query will only consider hits with colliders with collision groups compatible with this collision group (using the bitwise test described in the collision groups section). See https://rapier.rs/docs/user_guides/javascript/colliders#collision-groups-and-solver-groups
476
- * For example membership 0x0001 and filter 0x0002 should be 0x00010002 */
492
+ /**
493
+ * Raycast filter groups. Groups are used to apply the collision group rules for the scene query.
494
+ * The scene query will only consider hits with colliders with collision groups compatible with
495
+ * this collision group (using the bitwise test described in the collision groups section).
496
+ * For example membership 0x0001 and filter 0x0002 should be 0x00010002
497
+ * @see https://rapier.rs/docs/user_guides/javascript/colliders#collision-groups-and-solver-groups
498
+ */
477
499
  filterGroups?: number,
478
- /** Return false to ignore this collider */
500
+ /**
501
+ * Predicate to filter colliders in raycast results
502
+ * @param collider The collider being tested
503
+ * @returns False to ignore this collider, true to include it
504
+ */
479
505
  filterPredicate?: (collider: ICollider) => boolean
480
506
  }): RaycastResult;
481
- /** raycast that also gets the normal vector. If you don't need it use raycast() */
507
+
508
+ /**
509
+ * Performs a raycast that also returns the normal vector at the hit point
510
+ * @param origin Ray origin in screen or worldspace
511
+ * @param direction Ray direction in worldspace
512
+ * @param options Additional raycast configuration options
513
+ * @returns Raycast result containing hit point, normal, and collider, or null if no hit
514
+ */
482
515
  raycastAndGetNormal(origin?: Vec2 | Vec3, direction?: Vec3, options?: {
483
516
  maxDistance?: number,
484
517
  /** True if you want to also hit objects when the raycast starts from inside a collider */
485
518
  solid?: boolean,
486
519
  queryFilterFlags?: QueryFilterFlags,
487
- /** Raycast filter groups. Groups are used to apply the collision group rules for the scene query. The scene query will only consider hits with colliders with collision groups compatible with this collision group (using the bitwise test described in the collision groups section). See https://rapier.rs/docs/user_guides/javascript/colliders#collision-groups-and-solver-groups
488
- * For example membership 0x0001 and filter 0x0002 should be 0x00010002 */
520
+ /**
521
+ * Raycast filter groups. Groups are used to apply the collision group rules for the scene query.
522
+ * The scene query will only consider hits with colliders with collision groups compatible with
523
+ * this collision group (using the bitwise test described in the collision groups section).
524
+ * For example membership 0x0001 and filter 0x0002 should be 0x00010002
525
+ * @see https://rapier.rs/docs/user_guides/javascript/colliders#collision-groups-and-solver-groups
526
+ */
489
527
  filterGroups?: number,
490
- /** Return false to ignore this collider */
528
+ /**
529
+ * Predicate to filter colliders in raycast results
530
+ * @param collider The collider being tested
531
+ * @returns False to ignore this collider, true to include it
532
+ */
491
533
  filterPredicate?: (collider: ICollider) => boolean
492
534
  }): RaycastResult;
535
+
536
+ /**
537
+ * Finds all colliders within a sphere
538
+ * @param point The center point of the sphere
539
+ * @param radius The radius of the sphere
540
+ * @returns Array of objects that overlap with the sphere
541
+ */
493
542
  sphereOverlap(point: Vector3, radius: number): Array<SphereOverlapResult>;
494
543
 
495
544
  // Collider methods
545
+ /**
546
+ * Adds a sphere collider to the physics world
547
+ * @param collider The collider component to add
548
+ */
496
549
  addSphereCollider(collider: ICollider);
550
+
551
+ /**
552
+ * Adds a box collider to the physics world
553
+ * @param collider The collider component to add
554
+ * @param size The size of the box
555
+ */
497
556
  addBoxCollider(collider: ICollider, size: Vector3);
557
+
558
+ /**
559
+ * Adds a capsule collider to the physics world
560
+ * @param collider The collider component to add
561
+ * @param radius The radius of the capsule
562
+ * @param height The height of the capsule
563
+ */
498
564
  addCapsuleCollider(collider: ICollider, radius: number, height: number);
565
+
566
+ /**
567
+ * Adds a mesh collider to the physics world
568
+ * @param collider The collider component to add
569
+ * @param mesh The mesh to use for collision
570
+ * @param convex Whether the collision mesh should be treated as convex
571
+ * @param scale Optional scale to apply to the mesh
572
+ */
499
573
  addMeshCollider(collider: ICollider, mesh: Mesh, convex: boolean, scale?: Vector3 | undefined);
500
574
 
575
+ /**
576
+ * Updates the physics material properties of a collider
577
+ * @param collider The collider to update
578
+ */
501
579
  updatePhysicsMaterial(collider: ICollider);
502
580
 
503
581
  // Rigidbody methods
582
+ /**
583
+ * Wakes up a sleeping rigidbody
584
+ * @param rb The rigidbody to wake up
585
+ */
504
586
  wakeup(rb: IRigidbody);
587
+
588
+ /**
589
+ * Checks if a rigidbody is currently sleeping
590
+ * @param rb The rigidbody to check
591
+ * @returns Whether the rigidbody is sleeping or undefined if cannot be determined
592
+ */
505
593
  isSleeping(rb: IRigidbody): boolean | undefined;
594
+
595
+ /**
596
+ * Updates the physical properties of a rigidbody or collider
597
+ * @param rb The rigidbody or collider to update
598
+ */
506
599
  updateProperties(rb: IRigidbody | ICollider);
600
+
601
+ /**
602
+ * Resets all forces acting on a rigidbody
603
+ * @param rb The rigidbody to reset forces on
604
+ * @param wakeup Whether to wake up the rigidbody
605
+ */
507
606
  resetForces(rb: IRigidbody, wakeup: boolean);
607
+
608
+ /**
609
+ * Resets all torques acting on a rigidbody
610
+ * @param rb The rigidbody to reset torques on
611
+ * @param wakeup Whether to wake up the rigidbody
612
+ */
508
613
  resetTorques(rb: IRigidbody, wakeup: boolean);
614
+
615
+ /**
616
+ * Adds a continuous force to a rigidbody
617
+ * @param rb The rigidbody to add force to
618
+ * @param vec The force vector to add
619
+ * @param wakeup Whether to wake up the rigidbody
620
+ */
509
621
  addForce(rb: IRigidbody, vec: Vec3, wakeup: boolean);
622
+
623
+ /**
624
+ * Applies an instantaneous impulse to a rigidbody
625
+ * @param rb The rigidbody to apply impulse to
626
+ * @param vec The impulse vector to apply
627
+ * @param wakeup Whether to wake up the rigidbody
628
+ */
510
629
  applyImpulse(rb: IRigidbody, vec: Vec3, wakeup: boolean);
511
- /** Returns the linear velocity of a rigidbody or the rigidbody of a collider */
630
+
631
+ /**
632
+ * Gets the linear velocity of a rigidbody or the rigidbody attached to a collider
633
+ * @param rb The rigidbody or collider to get velocity from
634
+ * @returns The linear velocity vector or null if not available
635
+ */
512
636
  getLinearVelocity(rb: IRigidbody | ICollider): Vec3 | null;
637
+
638
+ /**
639
+ * Gets the angular velocity of a rigidbody
640
+ * @param rb The rigidbody to get angular velocity from
641
+ * @returns The angular velocity vector or null if not available
642
+ */
513
643
  getAngularVelocity(rb: IRigidbody): Vec3 | null;
644
+
645
+ /**
646
+ * Sets the angular velocity of a rigidbody
647
+ * @param rb The rigidbody to set angular velocity for
648
+ * @param vec The angular velocity vector to set
649
+ * @param wakeup Whether to wake up the rigidbody
650
+ */
514
651
  setAngularVelocity(rb: IRigidbody, vec: Vec3, wakeup: boolean);
652
+
653
+ /**
654
+ * Sets the linear velocity of a rigidbody
655
+ * @param rb The rigidbody to set linear velocity for
656
+ * @param vec The linear velocity vector to set
657
+ * @param wakeup Whether to wake up the rigidbody
658
+ */
515
659
  setLinearVelocity(rb: IRigidbody, vec: Vec3, wakeup: boolean);
516
660
 
661
+ /**
662
+ * Updates the position and/or rotation of a physics body
663
+ * @param comp The collider or rigidbody component to update
664
+ * @param translation Whether to update the position
665
+ * @param rotation Whether to update the rotation
666
+ */
517
667
  updateBody(comp: ICollider | IRigidbody, translation: boolean, rotation: boolean);
668
+
669
+ /**
670
+ * Removes a physics body from the simulation
671
+ * @param body The component whose physics body should be removed
672
+ */
518
673
  removeBody(body: IComponent);
674
+
675
+ /**
676
+ * Gets the physics body for a component
677
+ * @param obj The collider or rigidbody component
678
+ * @returns The underlying physics body or null if not found
679
+ */
519
680
  getBody(obj: ICollider | IRigidbody): null | any;
520
681
 
521
682
  // Joints
src/engine/engine_utils_screenshot.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  import { registerFrameEventCallback } from "./engine_lifecycle_functions_internal.js";
11
11
  import { Context, FrameEvent } from "./engine_setup.js";
12
12
  import { ICamera } from "./engine_types.js";
13
+ import { DeviceUtilities } from "./engine_utils.js";
13
14
  import { updateTextureFromXRFrame } from "./engine_utils_screenshot.xr.js";
14
15
  import { RGBAColor } from "./js-extensions/index.js";
15
16
  import { setCustomVisibility } from "./js-extensions/Layers.js";
@@ -395,8 +396,22 @@
395
396
 
396
397
  if ("download_filename" in opts && opts.download_filename) {
397
398
  let download_name = opts.download_filename;
398
- const ext = download_name.split(".").pop()?.toLowerCase();
399
- download_name = `${download_name}-${Date.now()}.${ext}`;
399
+ // On mobile we don't want to see the dialogue for every screenshot
400
+ if (DeviceUtilities.isMobileDevice() && typeof window !== "undefined") {
401
+ const key = download_name + "_screenshots";
402
+ const parts = download_name.split(".");
403
+ const ext = parts.pop()?.toLowerCase();
404
+ let count = 0;
405
+ if (localStorage.getItem(key)) {
406
+ count = parseInt(sessionStorage.getItem(key) || "0");
407
+ }
408
+ if (count > 0) {
409
+ // const timestamp = new Date().toLocaleString();
410
+ download_name = `${parts.join()}-${count}.${ext}`;
411
+ }
412
+ count += 1;
413
+ sessionStorage.setItem(key, count.toString());
414
+ }
400
415
  saveImage(dataUrl, download_name);
401
416
  }
402
417
  return dataUrl;
@@ -424,7 +439,6 @@
424
439
 
425
440
 
426
441
 
427
-
428
442
  // trim to transparent pixels
429
443
  function trimCanvas(originalCanvas: HTMLCanvasElement): HTMLCanvasElement | null {
430
444
  if (!("document" in globalThis)) return null;
src/engine-components/ui/EventSystem.ts CHANGED
@@ -59,6 +59,7 @@
59
59
  return ctx.scene.getComponent(EventSystem);
60
60
  }
61
61
 
62
+ /** Get the currently active event system */
62
63
  static get instance(): EventSystem | null {
63
64
  return this.get(Context.Current);
64
65
  }
@@ -75,7 +76,10 @@
75
76
  }
76
77
  }
77
78
 
78
- get hasActiveUI() { return this.currentActiveMeshUIComponents.length > 0; }
79
+ get hasActiveUI() {
80
+ return this.currentActiveMeshUIComponents.length > 0;
81
+ }
82
+
79
83
  get isHoveringObjects() { return this.hoveredByID.size > 0; }
80
84
 
81
85
  awake(): void {
@@ -424,7 +428,6 @@
424
428
  const res = UIRaycastUtils.isInteractable(actualGo, this.out);
425
429
  if (!res) return false;
426
430
  canvasGroup = this.out.canvasGroup ?? null;
427
-
428
431
  const handled = this.handleMeshUIIntersection(object, pressedOrClicked);
429
432
  if (!clicked && handled) {
430
433
  // return true;
@@ -753,7 +756,6 @@
753
756
  }
754
757
 
755
758
  private resetMeshUIStates() {
756
-
757
759
  if (this.context.input.getPointerPressedCount() > 0) {
758
760
  MeshUIHelper.resetLastSelected();
759
761
  }
@@ -816,7 +818,7 @@
816
818
  let foundBlock: Object3D | null = null;
817
819
 
818
820
  if (intersect) {
819
- foundBlock = this.findBlockInParent(intersect);
821
+ foundBlock = this.findBlockOrTextInParent(intersect);
820
822
  // console.log(intersect, "-- found block:", foundBlock)
821
823
  if (foundBlock && foundBlock !== this.lastSelected) {
822
824
  const interactable = foundBlock["interactable"];
@@ -840,13 +842,13 @@
840
842
  this.needsUpdate = true;
841
843
  }
842
844
 
843
- static findBlockInParent(elem: any): Object3D | null {
845
+ static findBlockOrTextInParent(elem: any): Object3D | null {
844
846
  if (!elem) return null;
845
- if (elem.isBlock) {
847
+ if (elem.isBlock || (elem.isText)) {
846
848
  // @TODO : Replace states managements
847
849
  // if (Object.keys(elem.states).length > 0)
848
850
  return elem;
849
851
  }
850
- return this.findBlockInParent(elem.parent);
852
+ return this.findBlockOrTextInParent(elem.parent);
851
853
  }
852
854
  }
src/engine-components/Light.ts CHANGED
@@ -24,82 +24,86 @@
24
24
  const debug = getParam("debuglights");
25
25
 
26
26
 
27
- /// <summary>
28
- /// <para>The type of a Light.</para>
29
- /// </summary>
27
+ /**
28
+ * Defines the type of light in a scene.
29
+ */
30
30
  export enum LightType {
31
- /// <summary>
32
- /// <para>The light is a spot light.</para>
33
- /// </summary>
31
+ /** Spot light that emits light in a cone shape */
34
32
  Spot = 0,
35
- /// <summary>
36
- /// <para>The light is a directional light.</para>
37
- /// </summary>
33
+ /** Directional light that emits parallel light rays in a specific direction */
38
34
  Directional = 1,
39
- /// <summary>
40
- /// <para>The light is a point light.</para>
41
- /// </summary>
35
+ /** Point light that emits light in all directions from a single point */
42
36
  Point = 2,
37
+ /** Area light */
43
38
  Area = 3,
44
- /// <summary>
45
- /// <para>The light is a rectangle shaped area light. It affects only baked lightmaps and lightprobes.</para>
46
- /// </summary>
39
+ /** Rectangle shaped area light that only affects baked lightmaps and light probes */
47
40
  Rectangle = 3,
48
- /// <summary>
49
- /// <para>The light is a disc shaped area light. It affects only baked lightmaps and lightprobes.</para>
50
- /// </summary>
41
+ /** Disc shaped area light that only affects baked lightmaps and light probes */
51
42
  Disc = 4,
52
43
  }
44
+
45
+ /**
46
+ * Defines how a light contributes to the scene lighting.
47
+ */
53
48
  export enum LightmapBakeType {
54
- /// <summary>
55
- /// <para>Realtime lights cast run time light and shadows. They can change position, orientation, color, brightness, and many other properties at run time. No lighting gets baked into lightmaps or light probes..</para>
56
- /// </summary>
49
+ /** Light affects the scene in real-time with no baking */
57
50
  Realtime = 4,
58
- /// <summary>
59
- /// <para>Baked lights cannot move or change in any way during run time. All lighting for static objects gets baked into lightmaps. Lighting and shadows for dynamic objects gets baked into Light Probes.</para>
60
- /// </summary>
51
+ /** Light is completely baked into lightmaps and light probes */
61
52
  Baked = 2,
62
- /// <summary>
63
- /// <para>Mixed lights allow a mix of realtime and baked lighting, based on the Mixed Lighting Mode used. These lights cannot move, but can change color and intensity at run time. Changes to color and intensity only affect direct lighting as indirect lighting gets baked. If using Subtractive mode, changes to color or intensity are not calculated at run time on static objects.</para>
64
- /// </summary>
53
+ /** Combines aspects of realtime and baked lighting */
65
54
  Mixed = 1,
66
55
  }
67
56
 
68
- /// <summary>
69
- /// <para>Shadow casting options for a Light.</para>
70
- /// </summary>
57
+ /**
58
+ * Defines the shadow casting options for a Light.
59
+ * @enum {number}
60
+ */
71
61
  enum LightShadows {
72
- /// <summary>
73
- /// <para>Do not cast shadows (default).</para>
74
- /// </summary>
62
+ /** No shadows are cast */
75
63
  None = 0,
76
- /// <summary>
77
- /// <para>Cast "hard" shadows (with no shadow filtering).</para>
78
- /// </summary>
64
+ /** Hard-edged shadows without filtering */
79
65
  Hard = 1,
80
- /// <summary>
81
- /// <para>Cast "soft" shadows (with 4x PCF filtering).</para>
82
- /// </summary>
66
+ /** Soft shadows with PCF filtering */
83
67
  Soft = 2,
84
68
  }
85
69
 
86
- /** The Light component can be used to create a light source in the scene.
87
- * It can be used to create a directional light, a spot light or a point light.
88
- * The light can be set to cast shadows and the shadow type can be set to hard or soft shadows.
89
- * The light can be set to be baked or realtime.
90
- * The light can be set to be a main light which will be used for the main directional light in the scene.
70
+ /**
71
+ * The Light component creates a light source in the scene.
72
+ * Supports directional, spot, and point light types with various customization options.
73
+ * Lights can cast shadows with configurable settings and can be set to baked or realtime rendering.
74
+ *
75
+ * Debug mode can be enabled with the URL parameter `?debuglights`, which shows
76
+ * additional console output and visual helpers for lights.
77
+ *
91
78
  * @category Rendering
92
79
  * @group Components
93
80
  */
94
81
  export class Light extends Behaviour implements ILight {
95
82
 
83
+ /**
84
+ * The type of light (spot, directional, point, etc.)
85
+ */
96
86
  @serializable()
97
87
  private type: LightType = 0;
98
88
 
89
+ /**
90
+ * The maximum distance the light affects
91
+ */
99
92
  public range: number = 1;
93
+
94
+ /**
95
+ * The full outer angle of the spotlight cone in degrees
96
+ */
100
97
  public spotAngle: number = 1;
98
+
99
+ /**
100
+ * The angle of the inner cone in degrees for soft-edge spotlights
101
+ */
101
102
  public innerSpotAngle: number = 1;
102
103
 
104
+ /**
105
+ * The color of the light
106
+ */
103
107
  @serializable(Color)
104
108
  set color(val: Color) {
105
109
  this._color = val;
@@ -113,6 +117,9 @@
113
117
  }
114
118
  public _color: Color = new Color(0xffffff);
115
119
 
120
+ /**
121
+ * The near plane distance for shadow projection
122
+ */
116
123
  @serializable()
117
124
  set shadowNearPlane(val: number) {
118
125
  if (val === this._shadowNearPlane) return;
@@ -125,6 +132,9 @@
125
132
  get shadowNearPlane(): number { return this._shadowNearPlane; }
126
133
  private _shadowNearPlane: number = .1;
127
134
 
135
+ /**
136
+ * Shadow bias value to reduce shadow acne and peter-panning
137
+ */
128
138
  @serializable()
129
139
  set shadowBias(val: number) {
130
140
  if (val === this._shadowBias) return;
@@ -137,6 +147,9 @@
137
147
  get shadowBias(): number { return this._shadowBias; }
138
148
  private _shadowBias: number = 0;
139
149
 
150
+ /**
151
+ * Shadow normal bias to reduce shadow acne on sloped surfaces
152
+ */
140
153
  @serializable()
141
154
  set shadowNormalBias(val: number) {
142
155
  if (val === this._shadowNormalBias) return;
@@ -152,6 +165,9 @@
152
165
  /** when enabled this will remove the multiplication when setting the shadow bias settings initially */
153
166
  private _overrideShadowBiasSettings: boolean = false;
154
167
 
168
+ /**
169
+ * Shadow casting mode (None, Hard, or Soft)
170
+ */
155
171
  @serializable()
156
172
  set shadows(val: LightShadows) {
157
173
  this._shadows = val;
@@ -163,9 +179,16 @@
163
179
  get shadows(): LightShadows { return this._shadows; }
164
180
  private _shadows: LightShadows = 1;
165
181
 
182
+ /**
183
+ * Determines if the light contributes to realtime lighting, baked lighting, or a mix
184
+ */
166
185
  @serializable()
167
186
  private lightmapBakeType: LightmapBakeType = LightmapBakeType.Realtime;
168
187
 
188
+ /**
189
+ * Brightness of the light. In WebXR experiences, the intensity is automatically
190
+ * adjusted based on the AR session scale to maintain consistent lighting.
191
+ */
169
192
  @serializable()
170
193
  set intensity(val: number) {
171
194
  this._intensity = val;
@@ -184,6 +207,9 @@
184
207
  get intensity(): number { return this._intensity; }
185
208
  private _intensity: number = -1;
186
209
 
210
+ /**
211
+ * Maximum distance the shadow is projected
212
+ */
187
213
  @serializable()
188
214
  get shadowDistance(): number {
189
215
  const light = this.light;
@@ -207,6 +233,9 @@
207
233
  private shadowWidth?: number;
208
234
  private shadowHeight?: number;
209
235
 
236
+ /**
237
+ * Resolution of the shadow map in pixels (width and height)
238
+ */
210
239
  @serializable()
211
240
  get shadowResolution(): number {
212
241
  const light = this.light;
@@ -226,10 +255,16 @@
226
255
  }
227
256
  private _shadowResolution?: number = undefined;
228
257
 
258
+ /**
259
+ * Whether this light's illumination is entirely baked into lightmaps
260
+ */
229
261
  get isBaked() {
230
262
  return this.lightmapBakeType === LightmapBakeType.Baked;
231
263
  }
232
264
 
265
+ /**
266
+ * Checks if the GameObject itself is a {@link ThreeLight} object
267
+ */
233
268
  private get selfIsLight(): boolean {
234
269
  if (this.gameObject["isLight"] === true) return true;
235
270
  switch (this.gameObject.type) {
@@ -241,8 +276,16 @@
241
276
  return false;
242
277
  }
243
278
 
279
+ /**
280
+ * The underlying three.js {@link ThreeLight} instance
281
+ */
244
282
  private light: ThreeLight | undefined = undefined;
245
283
 
284
+ /**
285
+ * Gets the world position of the light
286
+ * @param vec Vector3 to store the result
287
+ * @returns The world position as a Vector3
288
+ */
246
289
  public getWorldPosition(vec: Vector3): Vector3 {
247
290
  if (this.light) {
248
291
  if (this.type === LightType.Directional) {
@@ -311,6 +354,10 @@
311
354
  // this.updateIntensity();
312
355
  }
313
356
 
357
+ /**
358
+ * Creates the appropriate three.js light based on the configured light type
359
+ * and applies all settings like shadows, intensity, and color.
360
+ */
314
361
  createLight() {
315
362
  const lightAlreadyCreated = this.selfIsLight;
316
363
 
@@ -447,6 +494,10 @@
447
494
 
448
495
  }
449
496
 
497
+ /**
498
+ * Coroutine that updates the main light reference in the context
499
+ * if this directional light should be the main light
500
+ */
450
501
  *updateMainLightRoutine() {
451
502
  while (true) {
452
503
  if (this.type === LightType.Directional) {
@@ -459,8 +510,14 @@
459
510
  }
460
511
  }
461
512
 
513
+ /**
514
+ * Controls whether the renderer's shadow map type can be changed when soft shadows are used
515
+ */
462
516
  static allowChangingRendererShadowMapType: boolean = true;
463
517
 
518
+ /**
519
+ * Updates shadow settings based on whether the shadows are set to hard or soft
520
+ */
464
521
  private updateShadowSoftHard() {
465
522
  if (!this.light) return;
466
523
  if (!this.light.shadow) return;
@@ -486,6 +543,10 @@
486
543
  }
487
544
  }
488
545
 
546
+ /**
547
+ * Configures a directional light by adding and positioning its target
548
+ * @param dirLight The directional light to set up
549
+ */
489
550
  private setDirectionalLight(dirLight: DirectionalLight) {
490
551
  dirLight.add(dirLight.target);
491
552
  dirLight.target.position.set(0, 0, -1);
src/needle-engine.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  export * from "./engine-schemes/api.js";
8
8
 
9
9
  // make accessible for external javascript
10
- import { Context, loadSync, NeedleXRSession, onBeforeRender, onStart, onUpdate, VERSION } from "./engine/api.js";
10
+ import { Context, loadSync, NeedleXRSession, onAfterRender, onBeforeRender, onClear, onDestroy, onInitialized, onStart, onUpdate, VERSION } from "./engine/api.js";
11
11
  const Needle = {
12
12
  VERSION: VERSION,
13
13
  Context: Context,
@@ -15,11 +15,13 @@
15
15
  gltf: {
16
16
  loadFromURL: loadSync,
17
17
  },
18
- hooks: {
19
- onStart: onStart,
20
- onUpdate: onUpdate,
21
- onBeforeRender: onBeforeRender,
22
- },
18
+ onStart: onStart,
19
+ onUpdate: onUpdate,
20
+ onBeforeRender: onBeforeRender,
21
+ onAfterRender: onAfterRender,
22
+ onInitializedContext: onInitialized,
23
+ onDestroyContext: onDestroy,
24
+ onClearContext: onClear,
23
25
  };
24
26
  if (globalThis["Needle"]?.VERSION !== undefined) {
25
27
  console.warn(`Needle Engine is already imported: ${globalThis["Needle"].VERSION}`);
src/engine-components/NeedleMenu.ts CHANGED
@@ -4,50 +4,68 @@
4
4
  import { Behaviour } from './Component.js';
5
5
 
6
6
  /**
7
- * Exposes options to customize the built-in Needle Menu.
7
+ * Provides configuration options for the built-in Needle Menu.
8
+ * Needle Menu uses HTML in 2D mode, and automatically switches to a 3D menu in VR/AR mode.
9
+ *
10
+ * Controls display options, button visibility, and menu positioning.
8
11
  * From code, you can access the menu via {@link Context.menu}.
9
12
  * @category User Interface
10
13
  * @group Components
11
14
  **/
12
15
  export class NeedleMenu extends Behaviour {
13
16
 
17
+ /**
18
+ * Determines the vertical positioning of the menu on the screen
19
+ */
14
20
  @serializable()
15
21
  position: "top" | "bottom" = "bottom";
16
22
 
17
- /** Show the Needle logo in the menu (requires PRO license) */
23
+ /**
24
+ * Controls the visibility of the Needle logo in the menu (requires PRO license)
25
+ */
18
26
  @serializable()
19
27
  showNeedleLogo: boolean = true;
20
28
 
21
- /** When enabled the menu will also be visible in VR/AR when you look up
29
+ /**
30
+ * When enabled, displays the menu in VR/AR mode when the user looks up
22
31
  * @default undefined
23
- */
32
+ */
24
33
  @serializable()
25
34
  showSpatialMenu?: boolean;
26
35
 
27
- /** When enabled a button to enter fullscreen will be added to the menu
36
+ /**
37
+ * When enabled, adds a fullscreen toggle button to the menu
28
38
  * @default undefined
29
- */
39
+ */
30
40
  @serializable()
31
41
  createFullscreenButton?: boolean;
32
- /** When enabled a button to mute the application will be added to the menu
42
+
43
+ /**
44
+ * When enabled, adds an audio mute/unmute button to the menu
33
45
  * @default undefined
34
- */
46
+ */
35
47
  @serializable()
36
48
  createMuteButton?: boolean;
37
49
 
38
50
  /**
39
- * When enabled a button to show a QR code will be added to the menu.
51
+ * When enabled, adds a button to display a QR code for sharing the application.
52
+ * The QR code is only displayed on desktop devices.
40
53
  * @default undefined
41
54
  */
42
55
  @serializable()
43
56
  createQRCodeButton?: boolean;
44
57
 
45
- /** @hidden */
58
+ /**
59
+ * Applies the configured menu options when the component is enabled
60
+ * @hidden
61
+ */
46
62
  onEnable() {
47
63
  this.applyOptions();
48
64
  }
49
65
 
50
- /** applies the options to `this.context.menu` */
66
+ /**
67
+ * Applies all configured options to the active {@link Context.menu}.
68
+ */
51
69
  applyOptions() {
52
70
  this.context.menu.setPosition(this.position);
53
71
  this.context.menu.showNeedleLogo(this.showNeedleLogo);
src/engine/xr/NeedleXRSession.ts CHANGED
@@ -693,7 +693,13 @@
693
693
  }
694
694
  private _rigScale: number = 1;
695
695
  private _lastRigScaleUpdate: number = -1;
696
- /** get the XR rig worldscale */
696
+
697
+ /** Get the XR Rig worldscale.
698
+ *
699
+ * **For AR**
700
+ * If you want to modify the scale in AR at runtime get the WebARSessionRoot component via `findObjectOfType(WebARSessionRoot)` and then set the `arScale` value.
701
+ *
702
+ */
697
703
  get rigScale() {
698
704
  if (!this._rigs[0]) return 1;
699
705
  if (this._lastRigScaleUpdate !== this.context.time.frame) {
src/engine-components/Networking.ts CHANGED
@@ -7,21 +7,33 @@
7
7
  const debug = getParam("debugnet");
8
8
 
9
9
  /**
10
- * The networking component is used to provide a websocket url to the networking system. It implements the {@link INetworkingWebsocketUrlProvider} interface.
10
+ * Provides configuration to the built-in networking system.
11
+ * This component supplies websocket URLs for establishing connections.
12
+ * It implements the {@link INetworkingWebsocketUrlProvider} interface.
13
+ *
11
14
  * @category Networking
12
15
  * @group Components
13
16
  */
14
17
  export class Networking extends Behaviour implements INetworkingWebsocketUrlProvider {
15
18
 
16
- /** The url that should be used for the websocket connection */
19
+ /**
20
+ * The websocket URL to connect to for networking functionality.
21
+ * Can be a complete URL or a relative path that will be resolved against the current origin.
22
+ */
17
23
  @serializable()
18
24
  url: string | null = null;
19
25
 
20
- /** The name of the url parameter that should be used to override the url. When set the url will be overridden by the url parameter e.g. when `urlParameterName=ws` `?ws=ws://localhost:8080` */
26
+ /**
27
+ * Name of the URL parameter that can override the websocket connection URL.
28
+ * When set, the URL will be overridden by the parameter value from the browser URL.
29
+ * For example, with `urlParameterName="ws"`, adding `?ws=ws://localhost:8080` to the browser URL will override the connection URL.
30
+ */
21
31
  @serializable()
22
32
  urlParameterName: string | null = null;
23
33
 
24
- /** Thie localhost url that should be used when the networking is running on a local network. This is useful when the server is running on the same machine as the client.
34
+ /**
35
+ * Alternative URL to use when running on a local network.
36
+ * This is particularly useful for development, when the server is running on the same machine as the client.
25
37
  */
26
38
  @serializable()
27
39
  localhost: string | null = null;
@@ -33,7 +45,13 @@
33
45
  this.context.connection.registerProvider(this);
34
46
  }
35
47
 
36
- /** @internal */
48
+ /**
49
+ * Determines the websocket URL to use for networking connections.
50
+ * Processes the configured URL, applying localhost fallbacks when appropriate and
51
+ * handling URL parameter overrides if specified.
52
+ * @returns The formatted websocket URL string or null if no valid URL could be determined
53
+ * @internal
54
+ */
37
55
  getWebsocketUrl(): string | null {
38
56
 
39
57
  let socketurl = this.url ? Networking.GetUrl(this.url, this.localhost) : null;
@@ -58,7 +76,13 @@
58
76
  return "wss://" + match?.groups["url"];
59
77
  }
60
78
 
61
-
79
+ /**
80
+ * Processes a URL string applying various transformations based on network environment.
81
+ * Handles relative paths and localhost fallbacks for local network environments.
82
+ * @param url The original URL to process
83
+ * @param localhostFallback Alternative URL to use when on a local network
84
+ * @returns The processed URL string or null/undefined if input was invalid
85
+ */
62
86
  public static GetUrl(url: string | null | undefined, localhostFallback?: string | null): string | null | undefined {
63
87
 
64
88
  let result = url;
@@ -78,6 +102,13 @@
78
102
  return result;
79
103
  }
80
104
 
105
+ /**
106
+ * Determines if the current connection is on a local network.
107
+ * Useful for applying different networking configurations in local development environments.
108
+ * This is the same as calling {@link isLocalNetwork}.
109
+ * @param hostname Optional hostname to check instead of the current window location
110
+ * @returns True if the connection is on a local network, false otherwise
111
+ */
81
112
  public static IsLocalNetwork(hostname = window.location.hostname) {
82
113
  return isLocalNetwork(hostname);
83
114
  }
src/engine-components/OrbitControls.ts CHANGED
@@ -332,6 +332,7 @@
332
332
 
333
333
  this._activePointerEvents = [];
334
334
  this.context.input.addEventListener("pointerdown", this._onPointerDown, { queue: InputEventQueue.Early });
335
+ this.context.input.addEventListener("pointerdown", this._onPointerDownLate, { queue: InputEventQueue.Late });
335
336
  this.context.input.addEventListener("pointerup", this._onPointerUp, { queue: InputEventQueue.Early });
336
337
  this.context.input.addEventListener("pointerup", this._onPointerUpLate, { queue: InputEventQueue.Late });
337
338
  }
@@ -352,6 +353,7 @@
352
353
  }
353
354
  this._activePointerEvents.length = 0;
354
355
  this.context.input.removeEventListener("pointerdown", this._onPointerDown);
356
+ this.context.input.removeEventListener("pointerdown", this._onPointerDownLate);
355
357
  this.context.input.removeEventListener("pointerup", this._onPointerUp);
356
358
  this.context.input.removeEventListener("pointerup", this._onPointerUpLate);
357
359
  }
@@ -363,6 +365,12 @@
363
365
  private _onPointerDown = (_evt: NEPointerEvent) => {
364
366
  this._activePointerEvents.push(_evt);
365
367
  }
368
+ private _onPointerDownLate = (evt: NEPointerEvent) => {
369
+ if(evt.used && this._controls) {
370
+ // Disabling orbit controls here because otherwise we get a slight movement when e.g. using DragControls
371
+ this._controls.enabled = false;
372
+ }
373
+ }
366
374
 
367
375
  private _onPointerUp = (evt: NEPointerEvent) => {
368
376
  // make sure we cleanup the active pointer events
src/engine-components/particlesystem/ParticleSystem.ts CHANGED
@@ -94,7 +94,7 @@
94
94
  else this.particleMaterial = newMaterial;
95
95
  }
96
96
 
97
-
97
+
98
98
  if (material.map) {
99
99
  material.map.colorSpace = LinearSRGBColorSpace;
100
100
  material.map.premultiplyAlpha = false;
@@ -916,7 +916,7 @@
916
916
  if (particleSystemBehaviour instanceof ParticleSystemBaseBehaviour) {
917
917
  particleSystemBehaviour.system = this;
918
918
  }
919
- if (isDevEnvironment() || debug) console.debug("Add custom ParticleSystem Behaviour", particleSystemBehaviour);
919
+ if (debug) console.debug("Add custom ParticleSystem Behaviour", particleSystemBehaviour);
920
920
  this._particleSystem.addBehavior(particleSystemBehaviour);
921
921
  return true;
922
922
  }
src/engine-components-experimental/networking/PlayerSync.ts CHANGED
@@ -12,6 +12,7 @@
12
12
 
13
13
  const debug = getParam("debugplayersync");
14
14
 
15
+ /** Type definition for a PlayerSync instance with a guaranteed asset property */
15
16
  declare type PlayerSyncWithAsset = PlayerSync & Required<Pick<PlayerSync, "asset">>;
16
17
 
17
18
  /**
@@ -23,7 +24,10 @@
23
24
 
24
25
  /**
25
26
  * This API is experimental and may change or be removed in the future.
26
- * Create a PlayerSync instance at runtime from a given URL
27
+ * Creates a PlayerSync instance at runtime from a given URL and sets it up for networking
28
+ * @param url Path to the asset that should be instantiated for each player
29
+ * @param init Optional initialization parameters for the PlayerSync component
30
+ * @returns Promise resolving to a PlayerSync instance with a guaranteed asset property
27
31
  * @example
28
32
  * ```typescript
29
33
  * const res = await PlayerSync.setupFrom("/assets/demo.glb");
@@ -47,15 +51,22 @@
47
51
  return ps as PlayerSyncWithAsset;
48
52
  }
49
53
 
50
- /** when enabled PlayerSync will automatically load and instantiate the assigned asset when joining a networked room */
54
+ /**
55
+ * When enabled, PlayerSync will automatically load and instantiate the assigned asset when joining a networked room
56
+ */
51
57
  @serializable()
52
58
  autoSync: boolean = true;
53
59
 
54
- /** This asset will be loaded and instantiated when PlayerSync becomes active and joins a networked room */
60
+ /**
61
+ * The asset that will be loaded and instantiated when PlayerSync becomes active and joins a networked room
62
+ */
55
63
  @serializable(AssetReference)
56
64
  asset?: AssetReference;
57
65
 
58
- /** Event called when an instance is spawned */
66
+ /**
67
+ * Event invoked when a player instance is spawned with the spawned {@link Object3D} as parameter
68
+ * @serializable
69
+ */
59
70
  @serializable(EventList)
60
71
  onPlayerSpawned?: EventList<Object3D>;
61
72
 
@@ -86,6 +97,10 @@
86
97
  if (this.autoSync) this.getInstance();
87
98
  }
88
99
 
100
+ /**
101
+ * Gets or creates an instance of the assigned asset for the local player
102
+ * @returns Promise resolving to the instantiated player object or null if creation failed
103
+ */
89
104
  async getInstance() {
90
105
  if (this._localInstance) return this._localInstance;
91
106
 
@@ -123,6 +138,9 @@
123
138
  return this._localInstance;
124
139
  }
125
140
 
141
+ /**
142
+ * Destroys the current player instance and cleans up networking state
143
+ */
126
144
  destroyInstance = () => {
127
145
  this._localInstance?.then(go => {
128
146
  if (debug) console.log("PlayerSync.destroyInstance", go);
@@ -131,8 +149,9 @@
131
149
  this._localInstance = undefined;
132
150
  }
133
151
 
134
-
135
-
152
+ /**
153
+ * Sets up visibility change listeners to handle player cleanup when browser tab visibility changes
154
+ */
136
155
  private watchTabVisible() {
137
156
  window.addEventListener("visibilitychange", _ => {
138
157
  if (document.visibilityState === "visible") {
@@ -147,32 +166,50 @@
147
166
  }
148
167
  }
149
168
 
169
+ /**
170
+ * Enum defining events that can be triggered by PlayerState
171
+ */
150
172
  export enum PlayerStateEvent {
173
+ /** Triggered when a PlayerState's owner property changes */
151
174
  OwnerChanged = "ownerChanged",
152
175
  }
153
176
 
177
+ /** Arguments passed when a PlayerState's owner changes */
154
178
  export declare interface PlayerStateOwnerChangedArgs {
179
+ /** The PlayerState instance that changed */
155
180
  playerState: PlayerState;
181
+ /** Previous owner's connection ID */
156
182
  oldValue: string;
183
+ /** New owner's connection ID */
157
184
  newValue: string;
158
185
  }
159
186
 
187
+ /** Callback type for PlayerState events */
160
188
  export declare type PlayerStateEventCallback = (args: CustomEvent<PlayerStateOwnerChangedArgs>) => void;
161
189
 
190
+ /**
191
+ * Represents a player instance in the networked environment.
192
+ * Handles ownership, synchronization, and lifecycle management of player objects.
193
+ */
162
194
  export class PlayerState extends Behaviour {
163
195
 
164
196
  private static _all: PlayerState[] = [];
165
- /** all instances for all players */
197
+ /** All PlayerState instances for all players in the scene */
166
198
  static get all(): PlayerState[] {
167
199
  return PlayerState._all;
168
200
  };
169
201
 
170
202
  private static _local: PlayerState[] = [];
171
- /** all instances for the local player */
203
+ /** All PlayerState instances that belong to the local player */
172
204
  static get local(): PlayerState[] {
173
205
  return PlayerState._local;
174
206
  }
175
207
 
208
+ /**
209
+ * Gets the PlayerState component for a given object or component
210
+ * @param obj Object3D or Component to find the PlayerState for
211
+ * @returns The PlayerState component if found, undefined otherwise
212
+ */
176
213
  static getFor(obj: Object3D | Component) {
177
214
  if (obj instanceof Object3D) {
178
215
  return GameObject.getComponentInParent(obj, PlayerState);
@@ -183,22 +220,34 @@
183
220
  return undefined;
184
221
  }
185
222
 
186
- //** use to check if a component or gameobject is part of a instance owned by the local player */
223
+ /**
224
+ * Checks if a given object or component belongs to the local player
225
+ * @param obj Object3D or Component to check
226
+ * @returns True if the object belongs to the local player, false otherwise
227
+ */
187
228
  static isLocalPlayer(obj: Object3D | Component): boolean {
188
229
  const state = PlayerState.getFor(obj);
189
230
  return state?.isLocalPlayer ?? false;
190
231
  }
191
232
 
192
- // static Callback
193
233
  private static _callbacks: { [key: string]: PlayerStateEventCallback[] } = {};
194
234
  /**
195
- * Add a callback for a PlayerStateEvent
235
+ * Registers a callback for a specific PlayerState event
236
+ * @param event The event to listen for
237
+ * @param cb Callback function that will be invoked when the event occurs
238
+ * @returns The provided callback function for chaining
196
239
  */
197
240
  static addEventListener(event: PlayerStateEvent, cb: PlayerStateEventCallback) {
198
241
  if (!this._callbacks[event]) this._callbacks[event] = [];
199
242
  this._callbacks[event].push(cb);
200
243
  return cb;
201
244
  }
245
+
246
+ /**
247
+ * Removes a previously registered event callback
248
+ * @param event The event type to remove the callback from
249
+ * @param cb The callback function to remove
250
+ */
202
251
  static removeEventListener(event: PlayerStateEvent, cb: PlayerStateEventCallback) {
203
252
  if (!this._callbacks[event]) return;
204
253
  const index = this._callbacks[event].indexOf(cb);
@@ -212,20 +261,39 @@
212
261
  }
213
262
 
214
263
 
264
+ /** Event triggered when the owner of this PlayerState changes */
215
265
  public onOwnerChangeEvent = new EventList();
266
+
267
+ /** Event triggered the first time an owner is assigned to this PlayerState */
216
268
  public onFirstOwnerChangeEvent = new EventList();
269
+
270
+ /** Indicates if this PlayerState has an owner assigned */
217
271
  public hasOwner = false;
218
272
 
273
+ /**
274
+ * The connection ID of the player who owns this PlayerState instance
275
+ * @syncField Synchronized across the network
276
+ */
219
277
  @syncField(PlayerState.prototype.onOwnerChange)
220
278
  owner?: string;
221
279
 
222
- /** when enabled PlayerSync will not destroy itself when not connected anymore */
280
+ /**
281
+ * When enabled, PlayerState will not destroy itself when the owner is not connected anymore
282
+ */
223
283
  dontDestroy: boolean = false;
224
284
 
285
+ /**
286
+ * Indicates if this PlayerState belongs to the local player
287
+ */
225
288
  get isLocalPlayer(): boolean {
226
289
  return this.owner === this.context.connection.connectionId;
227
290
  }
228
291
 
292
+ /**
293
+ * Handles owner change events and updates relevant state
294
+ * @param newOwner The new owner's connection ID
295
+ * @param oldOwner The previous owner's connection ID
296
+ */
229
297
  private onOwnerChange(newOwner: string, oldOwner: string) {
230
298
  if (debug) console.log(`PlayerSync.onOwnerChange: ${oldOwner} → ${newOwner} (me: ${this.context.connection.connectionId})`);
231
299
 
@@ -300,7 +368,7 @@
300
368
  }
301
369
  }
302
370
 
303
- /** this tells the server that this client has been destroyed and the networking message for the instantiate will be removed */
371
+ /** Tells the server that this client has been destroyed, and the networking message for the instantiate will be removed */
304
372
  doDestroy() {
305
373
  if (debug) console.log("PlayerSync.doDestroy → syncDestroy", this.name);
306
374
  syncDestroy(this.gameObject, this.context.connection, true, { saveInRoom: false });
@@ -319,6 +387,10 @@
319
387
  }
320
388
  }
321
389
 
390
+ /**
391
+ * Handler for when a user leaves the networked room
392
+ * @param model Object containing the ID of the user who left
393
+ */
322
394
  private onUserLeftRoom = (model: { userId: string }) => {
323
395
  if (model.userId === this.owner) {
324
396
  if (debug)
src/engine-components/postprocessing/PostProcessingEffect.ts CHANGED
@@ -75,6 +75,10 @@
75
75
 
76
76
  abstract get typeName(): string;
77
77
 
78
+ /**
79
+ * Whether the effect is active or not. Prefer using `enabled` instead.
80
+ * @deprecated
81
+ */
78
82
  @serializable()
79
83
  active: boolean = true;
80
84
 
@@ -82,14 +86,16 @@
82
86
 
83
87
  onEnable(): void {
84
88
  super.onEnable();
85
- this.onEffectEnabled();
89
+ if (debug) console.warn("onEnable effect", this, this.__internalDidAwakeAndStart)
86
90
  // Dont override the serialized value by enabling (we could also just disable this component / map enabled to active)
87
91
  if (this.__internalDidAwakeAndStart)
88
92
  this.active = true;
93
+ this.onEffectEnabled();
89
94
  }
90
95
 
91
96
  onDisable(): void {
92
97
  super.onDisable();
98
+ if (debug) console.warn("onDisable effect", this)
93
99
  this._manager?.removeEffect(this);
94
100
  this.active = false;
95
101
  }
src/engine-components/postprocessing/PostProcessingHandler.ts CHANGED
@@ -219,12 +219,12 @@
219
219
  if (ef instanceof MODULES.POSTPROCESSING.MODULE.Effect)
220
220
  effects.push(ef as Effect);
221
221
  else if (ef instanceof MODULES.POSTPROCESSING.MODULE.Pass) {
222
- const pass = new MODULES.POSTPROCESSING.MODULE.EffectPass(cam, ...effects);
223
- pass.mainScene = scene;
224
- pass.name = effects.map(e => e.constructor.name).join(", ");
225
- pass.enabled = true;
222
+ // const pass = new MODULES.POSTPROCESSING.MODULE.EffectPass(cam, ...effects);
223
+ // pass.mainScene = scene;
224
+ // pass.name = effects.map(e => e.constructor.name).join(", ");
225
+ // pass.enabled = true;
226
226
  // composer.addPass(pass);
227
- effects.length = 0;
227
+ // effects.length = 0;
228
228
  composer.addPass(ef as Pass);
229
229
  }
230
230
  else {
@@ -262,7 +262,7 @@
262
262
  }
263
263
 
264
264
  if (debug)
265
- console.log("PostProcessing Passes", effectsOrPasses, "->", composer.passes);
265
+ console.log("[PostProcessing] Passes ", composer.passes);
266
266
  }
267
267
 
268
268
  private orderEffects() {
src/engine-components/Renderer.ts CHANGED
@@ -847,11 +847,18 @@
847
847
  for (const mesh of this.sharedMeshes) {
848
848
  if (mesh instanceof SkinnedMesh) {
849
849
  this._needUpdateBoundingSphere = false;
850
- const geometry = mesh.geometry;
851
- const raycastmesh = getRaycastMesh(mesh);
852
- if (raycastmesh) mesh.geometry = raycastmesh;
853
- mesh.computeBoundingSphere();
854
- mesh.geometry = geometry;
850
+ try {
851
+ const geometry = mesh.geometry;
852
+ const raycastmesh = getRaycastMesh(mesh);
853
+ if (raycastmesh) {
854
+ mesh.geometry = raycastmesh;
855
+ }
856
+ mesh.computeBoundingSphere();
857
+ mesh.geometry = geometry;
858
+ }
859
+ catch(err) {
860
+ console.error(`Error updating bounding sphere for ${mesh.name}`, err);
861
+ }
855
862
  }
856
863
  }
857
864
  }
src/engine-components/RendererInstancing.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { BatchedMesh, BufferGeometry, Color, Material, Matrix4, Mesh, MeshStandardMaterial, Object3D, RawShaderMaterial } from "three";
2
2
 
3
3
  import { isDevEnvironment, showBalloonError } from "../engine/debug/index.js";
4
+ import { Gizmos } from "../engine/engine_gizmos.js";
4
5
  import { $instancingAutoUpdateBounds, $instancingRenderer, NEED_UPDATE_INSTANCE_KEY } from "../engine/engine_instancing.js";
5
6
  import { Context } from "../engine/engine_setup.js";
6
7
  import { getParam, makeIdFromRandomWords } from "../engine/engine_utils.js";
@@ -337,6 +338,12 @@
337
338
  this._batchedMesh.computeBoundingBox();
338
339
  if (sphere)
339
340
  this._batchedMesh.computeBoundingSphere();
341
+ if (debugInstancing && this._batchedMesh.boundingSphere) {
342
+ const sphere = this._batchedMesh.boundingSphere;
343
+ // const worldPos = this._batchedMesh.worldPosition.add(sphere.center);
344
+ // const worldRadius = sphere!.radius;
345
+ Gizmos.DrawWireSphere(sphere.center, sphere.radius, 0x00ff00);
346
+ }
340
347
  }
341
348
 
342
349
  private _context: Context;
@@ -401,7 +408,7 @@
401
408
  // break;
402
409
  // }
403
410
  // }
404
- if(!canMergeMaterial) {
411
+ if (!canMergeMaterial) {
405
412
  return false;
406
413
  }
407
414
  }
@@ -588,7 +595,7 @@
588
595
  this._batchedMesh.layers.enableAll();
589
596
 
590
597
  if (this._needUpdateBounds && this._batchedMesh[$instancingAutoUpdateBounds] === true) {
591
- if (debugInstancing) console.log("Update instancing bounds", this.name, this._batchedMesh.matrixWorldNeedsUpdate);
598
+ if (debugInstancing === "verbose") console.log("Update instancing bounds", this.name, this._batchedMesh.matrixWorldNeedsUpdate);
592
599
  this.updateBounds();
593
600
  }
594
601
  }
@@ -623,7 +630,9 @@
623
630
  }
624
631
 
625
632
  private markNeedsUpdate() {
626
- if (debugInstancing) console.warn("Marking instanced mesh dirty", this.name);
633
+ if (debugInstancing === "verbose") {
634
+ console.warn("Marking instanced mesh dirty", this.name);
635
+ }
627
636
  this._needUpdateBounds = true;
628
637
  // this.inst.instanceMatrix.needsUpdate = true;
629
638
  }
@@ -641,19 +650,23 @@
641
650
  }
642
651
 
643
652
  private grow(geometry: BufferGeometry) {
644
- const newSize = this._maxInstanceCount * 2;
653
+ const growFactor = 2;
654
+ const newSize = Math.ceil(this._maxInstanceCount * growFactor);
645
655
 
646
656
  // create a new BatchedMesh instance
647
657
  const estimatedSpace = this.tryEstimateVertexCountSize(newSize, [geometry]);// geometry.attributes.position.count;
648
658
  // const indices = geometry.index ? geometry.index.count : 0;
649
659
  const newMaxVertexCount = Math.max(this._maxVertexCount, estimatedSpace.vertexCount);
650
- const newMaxIndexCount = Math.max(this._maxIndexCount, estimatedSpace.indexCount, this._maxVertexCount * 2);
660
+ const newMaxIndexCount = Math.max(this._maxIndexCount, estimatedSpace.indexCount, Math.ceil(this._maxVertexCount * growFactor));
651
661
 
652
662
  if (debugInstancing) {
653
663
  const geometryInfo = getMeshInformation(geometry);
654
- console.warn(`Growing batched mesh for \"${this.name}/${geometry.name}\" ${geometryInfo.vertexCount} vertices, ${geometryInfo.indexCount} indices\nMax count ${this._maxInstanceCount} → ${newSize}\nMax vertex count ${this._maxVertexCount} -> ${newMaxVertexCount}\nMax index count ${this._maxIndexCount} -> ${newMaxIndexCount}`);
664
+ console.warn(`[Instancing] Growing Buffer\nMesh: \"${this.name}${geometry.name?.length ? "/" + geometry.name : ""}\"\n${geometryInfo.vertexCount} vertices, ${geometryInfo.indexCount} indices\nMax count ${this._maxInstanceCount} → ${newSize}\nMax vertex count ${this._maxVertexCount} -> ${newMaxVertexCount}\nMax index count ${this._maxIndexCount} -> ${newMaxIndexCount}`);
655
665
  this._debugMaterial = createDebugMaterial();
656
666
  }
667
+ else if (isDevEnvironment()) {
668
+ console.debug(`[Instancing] Growing Buffer\nMesh: \"${this.name}${geometry.name?.length ? "/" + geometry.name : ""}\"\nMax count ${this._maxInstanceCount} → ${newSize}\nMax vertex count ${this._maxVertexCount} -> ${newMaxVertexCount}\nMax index count ${this._maxIndexCount} -> ${newMaxIndexCount}`);
669
+ }
657
670
 
658
671
  this._maxVertexCount = newMaxVertexCount;
659
672
  this._maxIndexCount = newMaxIndexCount;
@@ -750,7 +763,8 @@
750
763
 
751
764
  private addGeometry(handle: InstanceHandle) {
752
765
 
753
- const geo = handle.object.geometry as BufferGeometry;
766
+ const obj = handle.object;
767
+ const geo = obj.geometry as BufferGeometry;
754
768
  if (!geo) {
755
769
  // if the geometry is null we cannot add it
756
770
  return;
@@ -776,7 +790,7 @@
776
790
  if (smallestBucket != null) {
777
791
  const bucket = smallestBucket;
778
792
  if (debugInstancing)
779
- console.debug(`RE-USE SPACE #${bucket.geometryIndex}, ${handle.maxVertexCount} vertices, ${handle.maxIndexCount} indices, ${handle.name}`);
793
+ console.debug(`[Instancing] SET GEOMETRY \"${handle.name}\"\nGEOMETRY_ID=${bucket.geometryIndex}\n${this._currentInstanceCount} instances\n${handle.maxVertexCount} vertices, ${handle.maxIndexCount} indices, `);
780
794
  try {
781
795
  this._batchedMesh.setGeometryAt(bucket.geometryIndex, handle.object.geometry as BufferGeometry);
782
796
  const newIndex = this._batchedMesh.addInstance(bucket.geometryIndex);
@@ -800,15 +814,13 @@
800
814
  let geometryId = this._geometryIds.get(geo);
801
815
  if (geometryId === undefined || geometryId === null) {
802
816
  if (debugInstancing)
803
- console.debug("ADD GEOMETRY & INSTANCE", geo.name, "\nvertex:", `${this._currentVertexCount} + ${handle.maxVertexCount} < ${this._maxVertexCount}?`, "\nindex:", handle.maxIndexCount, this._currentIndexCount, this._maxIndexCount);
817
+ console.debug(`[Instancing] > ADD GEOMETRY \"${handle.name}\"\n${this._currentInstanceCount} instances, ${handle.maxVertexCount} max vertices, ${handle.maxIndexCount} max indices`);
804
818
 
805
-
806
-
807
819
  geometryId = this._batchedMesh.addGeometry(geo, handle.maxVertexCount, handle.maxIndexCount);
808
820
  this._geometryIds.set(geo, geometryId);
809
821
  }
810
822
  else {
811
- if (debugInstancing) console.log("ADD INSTANCE", geometryId, geo.name);
823
+ if (debugInstancing === "verbose") console.log(`[Instancing] > ADD INSTANCE \"${handle.name}\"\nGEOMETRY_ID=${geometryId}\n${this._currentInstanceCount} instances`);
812
824
  }
813
825
  this._currentVertexCount += handle.maxVertexCount;
814
826
  this._currentIndexCount += handle.maxIndexCount;
@@ -820,7 +832,7 @@
820
832
  this._usedBuckets[i] = { geometryIndex: geometryId, vertexCount: handle.maxVertexCount, indexCount: handle.maxIndexCount };
821
833
  this._batchedMesh.setMatrixAt(i, handle.object.matrixWorld);
822
834
  if (debugInstancing)
823
- console.debug(`ADD MESH & RESERVE SPACE #${i}, ${handle.maxVertexCount} vertices, ${handle.maxIndexCount} indices, ${handle.name} ${handle.object.uuid}`);
835
+ console.debug(`[Instancing] > ADDED INSTANCE \"${handle.name}\"\nGEOMETRY_ID=${geometryId}\n${this._currentInstanceCount} instances\nIndex: ${handle.__instanceIndex}`);
824
836
 
825
837
  }
826
838
 
@@ -837,6 +849,9 @@
837
849
  // this.inst.deleteGeometry(handle.__instanceIndex);
838
850
  // else
839
851
  // this._batchedMesh.setVisibleAt(handle.__instanceIndex, false);
852
+ if(debugInstancing) {
853
+ console.debug(`[Instancing] < REMOVE INSTANCE \"${handle.name}\" at [${handle.__instanceIndex}]\nGEOMETRY_ID=${handle.__geometryIndex}\n${this._currentInstanceCount} instances\nIndex: ${handle.__instanceIndex}`);
854
+ }
840
855
  this._batchedMesh.deleteInstance(handle.__instanceIndex);
841
856
  this._availableBuckets.push({
842
857
  geometryIndex: handle.__geometryIndex,
src/engine-components/RigidBody.ts CHANGED
@@ -498,7 +498,9 @@
498
498
  else this.setAngularVelocity(x);
499
499
  }
500
500
 
501
- /** Returns the rigidbody velocity smoothed over ~ 10 frames */
501
+ /**
502
+ * Returns the rigidbody velocity smoothed over ~ 10 frames
503
+ */
502
504
  public get smoothedVelocity(): Vector3 {
503
505
  this._smoothedVelocityGetter.copy(this._smoothedVelocity);
504
506
  return this._smoothedVelocityGetter.multiplyScalar(1 / this.context.time.deltaTime);
@@ -512,9 +514,9 @@
512
514
 
513
515
 
514
516
  private captureVelocity() {
515
- const wp = getWorldPosition(this.gameObject);
516
- this.gameObject.matrixWorld.decompose(Rigidbody.tempPosition, tempQuaternion, tempScale);
517
- const vel = wp.sub(this._lastPosition);
517
+ const matrixWorld = this.gameObject.matrixWorld;
518
+ Rigidbody.tempPosition.setFromMatrixPosition(matrixWorld);
519
+ const vel = Rigidbody.tempPosition.sub(this._lastPosition);
518
520
  this._lastPosition.copy(Rigidbody.tempPosition);
519
521
  this._smoothedVelocity.lerp(vel, this.context.time.deltaTime / .1);
520
522
  }
src/engine-components/SceneSwitcher.ts CHANGED
@@ -46,6 +46,10 @@
46
46
  index: number;
47
47
  }
48
48
 
49
+ export type LoadSceneProgressEvent = LoadSceneEvent & {
50
+ progress: number,
51
+ }
52
+
49
53
  /**
50
54
  * The ISceneEventListener is called by the {@link SceneSwitcher} when a scene is loaded or unloaded.
51
55
  * It must be added to the root object of your scene (that is being loaded) or on the same object as the SceneSwitcher
@@ -226,6 +230,15 @@
226
230
  get currentlyLoadedScene() { return this._currentScene; }
227
231
 
228
232
  /**
233
+ * Called when a scene starts loading
234
+ */
235
+ @serializable(EventList)
236
+ sceneLoadingStart: EventList<LoadSceneEvent> = new EventList();
237
+
238
+ @serializable(EventList)
239
+ sceneLoadingProgress: EventList<ProgressEvent> = new EventList();
240
+
241
+ /**
229
242
  * The sceneLoaded event is called when a scene/glTF is loaded and added to the scene
230
243
  */
231
244
  @serializable(EventList)
@@ -281,7 +294,7 @@
281
294
  this._preloadScheduler.maxLoadAhead = this.preloadNext;
282
295
  this._preloadScheduler.maxLoadBehind = this.preloadPrevious;
283
296
  this._preloadScheduler.maxConcurrent = this.preloadConcurrent;
284
- this._preloadScheduler.begin();
297
+ this._preloadScheduler.begin(2000);
285
298
 
286
299
  // Begin loading the loading scene
287
300
  if (this.autoLoadFirstScene && this._currentIndex === -1 && !await this.tryLoadFromQueryParam()) {
@@ -602,11 +615,13 @@
602
615
 
603
616
  const loadStartEvt = new CustomEvent<LoadSceneEvent>("loadscene-start", { detail: { scene: scene, switcher: this, index: index } })
604
617
  this.dispatchEvent(loadStartEvt);
618
+ this.sceneLoadingStart?.invoke(loadStartEvt.detail);
605
619
  await this.onStartLoading();
606
620
  // start loading and wait for the scene to be loaded
607
621
  await scene.loadAssetAsync((_, prog) => {
608
622
  this._currentLoadingProgress = prog;
609
623
  this.dispatchEvent(prog);
624
+ this.sceneLoadingProgress?.invoke(prog);
610
625
  }).catch(console.error);
611
626
  await this.onEndLoading();
612
627
  const finishedEvt = new CustomEvent<LoadSceneEvent>("loadscene-finished", { detail: { scene: scene, switcher: this, index: index } });
@@ -842,9 +857,18 @@
842
857
 
843
858
 
844
859
 
860
+ /**
861
+ * PreLoadScheduler is responsible for managing preloading of scenes.
862
+ * It handles scheduling and limiting concurrent downloads of scenes based on specified parameters.
863
+ */
845
864
  class PreLoadScheduler {
865
+ /** Maximum number of scenes to preload ahead of the current scene */
846
866
  maxLoadAhead: number;
867
+
868
+ /** Maximum number of scenes to preload behind the current scene */
847
869
  maxLoadBehind: number;
870
+
871
+ /** Maximum number of scenes that can be preloaded concurrently */
848
872
  maxConcurrent: number;
849
873
 
850
874
  private _isRunning: boolean = false;
@@ -852,6 +876,13 @@
852
876
  private _loadTasks: LoadTask[] = [];
853
877
  private _maxConcurrentLoads: number = 1;
854
878
 
879
+ /**
880
+ * Creates a new PreLoadScheduler instance
881
+ * @param rooms The SceneSwitcher that this scheduler belongs to
882
+ * @param ahead Number of scenes to preload ahead of current scene
883
+ * @param behind Number of scenes to preload behind current scene
884
+ * @param maxConcurrent Maximum number of concurrent preloads allowed
885
+ */
855
886
  constructor(rooms: SceneSwitcher, ahead: number = 1, behind: number = 1, maxConcurrent: number = 2) {
856
887
  this._switcher = rooms;
857
888
  this.maxLoadAhead = ahead;
@@ -859,26 +890,34 @@
859
890
  this.maxConcurrent = maxConcurrent;
860
891
  }
861
892
 
862
- begin() {
893
+ /**
894
+ * Starts the preloading process after a specified delay
895
+ * @param delay Time in milliseconds to wait before starting preload
896
+ */
897
+ begin(delay: number) {
863
898
  if (this._isRunning) return;
864
- if (debug) console.log("Preload begin")
899
+ if (debug) console.log("Preload begin", { delay })
865
900
  this._isRunning = true;
866
- let lastRoom: number = -1;
901
+ let lastRoom: number = -10;
867
902
  let searchDistance: number;
868
903
  let searchCall: number;
869
904
  const array = this._switcher.scenes;
905
+ const startTime = Date.now() + delay;
870
906
  const interval = setInterval(() => {
871
907
  if (this.allLoaded()) {
872
908
  if (debug)
873
- console.log("All scenes loaded");
909
+ console.log("All scenes (pre-)loaded");
874
910
  this.stop();
875
911
  }
876
912
  if (!this._isRunning) {
877
913
  clearInterval(interval);
878
914
  return;
879
915
  }
916
+
917
+ if (Date.now() < startTime) return;
918
+
880
919
  if (this.canLoadNewScene() === false) return;
881
- if (lastRoom !== this._switcher.currentIndex) {
920
+ if (lastRoom === -10 || lastRoom !== this._switcher.currentIndex) {
882
921
  lastRoom = this._switcher.currentIndex;
883
922
  searchCall = 0;
884
923
  searchDistance = 0;
@@ -892,19 +931,33 @@
892
931
  if (roomIndex < 0) return;
893
932
  // if (roomIndex < 0) roomIndex = array.length + roomIndex;
894
933
  if (roomIndex < 0 || roomIndex >= array.length) return;
895
- const scene = array[roomIndex];
896
- new LoadTask(roomIndex, scene, this._loadTasks);
934
+ if (!this._loadTasks.some(t => t.index === roomIndex)) {
935
+ const scene = array[roomIndex];
936
+ if (debug) console.log("Preload scene", { roomIndex, searchForward, lastRoom, currentIndex: this._switcher.currentIndex, tasks: this._loadTasks.length }, scene?.url);
937
+ new LoadTask(roomIndex, scene, this._loadTasks);
938
+ }
897
939
  }, 200);
898
940
  }
899
941
 
942
+ /**
943
+ * Stops the preloading process
944
+ */
900
945
  stop() {
901
946
  this._isRunning = false;
902
947
  }
903
948
 
949
+ /**
950
+ * Checks if a new scene can be loaded based on current load limits
951
+ * @returns True if a new scene can be loaded, false otherwise
952
+ */
904
953
  canLoadNewScene(): boolean {
905
954
  return this._loadTasks.length < this._maxConcurrentLoads;
906
955
  }
907
956
 
957
+ /**
958
+ * Checks if all scenes in the SceneSwitcher have been loaded
959
+ * @returns True if all scenes are loaded, false otherwise
960
+ */
908
961
  allLoaded(): boolean {
909
962
  if (this._switcher.scenes) {
910
963
  for (const scene of this._switcher.scenes) {
@@ -915,12 +968,25 @@
915
968
  }
916
969
  }
917
970
 
971
+ /**
972
+ * Represents a single preloading task for a scene
973
+ */
918
974
  class LoadTask {
919
-
975
+ /** The index of the scene in the scenes array */
920
976
  index: number;
977
+
978
+ /** The AssetReference to be loaded */
921
979
  asset: AssetReference;
980
+
981
+ /** The collection of active load tasks this task belongs to */
922
982
  tasks: LoadTask[];
923
983
 
984
+ /**
985
+ * Creates a new LoadTask and begins loading immediately
986
+ * @param index The index of the scene in the scenes array
987
+ * @param asset The AssetReference to preload
988
+ * @param tasks The collection of active load tasks
989
+ */
924
990
  constructor(index: number, asset: AssetReference, tasks: LoadTask[]) {
925
991
  this.index = index;
926
992
  this.asset = asset;
@@ -929,6 +995,9 @@
929
995
  this.awaitLoading();
930
996
  }
931
997
 
998
+ /**
999
+ * Asynchronously loads the asset and removes this task from the active tasks list when complete
1000
+ */
932
1001
  private async awaitLoading() {
933
1002
  if (this.asset && !this.asset.isLoaded()) {
934
1003
  if (debug)
src/engine-components/postprocessing/Effects/ScreenspaceAmbientOcclusionN8.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  import type { N8AOPostPass } from "n8ao";
2
- import { Color, PerspectiveCamera } from "three";
2
+ import { Color, DepthFormat, DepthStencilFormat, DepthTexture, PerspectiveCamera, UnsignedInt248Type, UnsignedIntType, WebGLRenderTarget } from "three";
3
3
 
4
4
  import { MODULES } from "../../../engine/engine_modules.js";
5
5
  import { serializable } from "../../../engine/engine_serialization.js";
6
6
  import { validate } from "../../../engine/engine_util_decorator.js";
7
+ import { getParam } from "../../../engine/engine_utils.js";
7
8
  import { type EffectProviderResult, PostProcessingEffect } from "../PostProcessingEffect.js";
8
9
  import { VolumeParameter } from "../VolumeParameter.js";
9
10
  import { registerCustomEffectType } from "../VolumeProfile.js";
10
11
 
12
+ const debugN8AO = getParam("debugN8AO");
13
+
11
14
  // https://github.com/N8python/n8ao
12
15
 
13
16
  /**See (N8AO documentation)[https://github.com/N8python/n8ao] */
@@ -30,6 +33,10 @@
30
33
  return "ScreenSpaceAmbientOcclusionN8";
31
34
  }
32
35
 
36
+ get pass(): N8AOPostPass {
37
+ return this._ssao;
38
+ }
39
+
33
40
  @validate()
34
41
  @serializable()
35
42
  gammaCorrection: boolean = true;
@@ -109,19 +116,51 @@
109
116
 
110
117
  const cam = this.context.mainCamera! as PerspectiveCamera;
111
118
 
119
+ const width = this.context.domWidth;
120
+ const height = this.context.domHeight;
121
+
112
122
  const ssao = this._ssao = new MODULES.POSTPROCESSING_AO.MODULE.N8AOPostPass(
113
123
  this.context.scene,
114
124
  cam,
115
- this.context.domWidth,
116
- this.context.domHeight
117
- );
125
+ width, height
126
+ ) as N8AOPostPass;
127
+
118
128
  const mode = ScreenSpaceAmbientOcclusionN8QualityMode[this.quality];
119
129
  ssao.setQualityMode(mode);
120
130
 
131
+ ssao.configuration.transparencyAware = false;
132
+ // ssao.needsSwap = false;
133
+
134
+
135
+ const renderTarget = new WebGLRenderTarget(width, height);
136
+ ssao.configuration.beautyRenderTarget = renderTarget;
137
+ ssao.configuration.autoRenderBeauty = false;
138
+ // // If you just want a depth buffer
139
+ // renderTarget.depthTexture = new DepthTexture(width, height, UnsignedIntType);
140
+ // renderTarget.depthTexture.format = DepthFormat;
141
+ // // If you want a depth buffer and a stencil buffer
142
+ // renderTarget.depthTexture = new DepthTexture(width, height, UnsignedInt248Type);
143
+ // renderTarget.depthTexture.format = DepthStencilFormat;
144
+
145
+
121
146
  ssao.configuration.gammaCorrection = this.gammaCorrection;
122
-
123
147
  ssao.configuration.screenSpaceRadius = this.screenspaceRadius;
124
148
 
149
+
150
+ if (debugN8AO) {
151
+ // ssao.debug = debugN8AO;
152
+ ssao.enableDebugMode();
153
+ console.log(ssao);
154
+ setInterval(() => {
155
+ console.log("SSAO", ssao.lastTime);
156
+ }, 1000);
157
+ setInterval(() => {
158
+ // ssao.enabled = !ssao.enabled;
159
+ console.log("SSAO", ssao.enabled, { ssao, autoRenderBeauty: ssao.configuration.autoRenderBeauty });
160
+ }, 4000)
161
+ }
162
+
163
+
125
164
  this.intensity.onValueChanged = newValue => {
126
165
  ssao.configuration.intensity = newValue;
127
166
  }
@@ -135,10 +174,18 @@
135
174
  if (!ssao.color) ssao.color = new Color();
136
175
  ssao.configuration.color.copy(newValue);
137
176
  }
177
+
138
178
 
139
- const arr = new Array();
140
- arr.push(ssao);
141
- return arr;
179
+ // const normalPass = new MODULES.POSTPROCESSING.MODULE.NormalPass(this.context.scene, cam);
180
+ // const depthDownsamplingPass = new MODULES.POSTPROCESSING.MODULE.DepthDownsamplingPass({
181
+ // normalBuffer: normalPass.texture,
182
+ // resolutionScale: .1
183
+ // });
184
+ // const arr = new Array();
185
+ // arr.push(normalPass);
186
+ // arr.push(depthDownsamplingPass);
187
+ // arr.push(ssao);
188
+ return ssao;
142
189
  }
143
190
 
144
191
  }
src/engine-components/SpatialTrigger.ts CHANGED
@@ -8,8 +8,15 @@
8
8
 
9
9
  const debug = getParam("debugspatialtrigger");
10
10
 
11
+ /** Layer instances used for mask comparison */
11
12
  const layer1 = new Layers();
12
13
  const layer2 = new Layers();
14
+ /**
15
+ * Tests if two layer masks intersect
16
+ * @param mask1 First layer mask
17
+ * @param mask2 Second layer mask
18
+ * @returns True if the layers intersect
19
+ */
13
20
  function testMask(mask1, mask2) {
14
21
  layer1.mask = mask1;
15
22
  layer2.mask = mask2;
@@ -17,25 +24,45 @@
17
24
  }
18
25
 
19
26
  /**
27
+ * Component that receives and responds to spatial events, like entering or exiting a trigger zone.
28
+ * Used in conjunction with {@link SpatialTrigger} to create interactive spatial events.
20
29
  * @category Interactivity
21
30
  * @group Components
22
31
  */
23
32
  export class SpatialTriggerReceiver extends Behaviour {
24
33
 
25
- // currently Layers in unity but maybe this should be a string or plane number? Or should it be a bitmask to allow receivers use multiple triggers?
34
+ /**
35
+ * Bitmask determining which triggers this receiver responds to
36
+ * Only triggers with matching masks will interact with this receiver
37
+ */
26
38
  @serializable()
27
39
  triggerMask: number = 0;
40
+
41
+ /** Event invoked when this object enters a trigger zone */
28
42
  @serializable(EventList)
29
43
  onEnter?: EventList<any>;
44
+
45
+ /** Event invoked continuously while this object is inside a trigger zone */
30
46
  @serializable(EventList)
31
47
  onStay?: EventList<any>;
48
+
49
+ /** Event invoked when this object exits a trigger zone */
32
50
  @serializable(EventList)
33
51
  onExit?: EventList<any>;
34
52
 
53
+ /**
54
+ * Initializes the receiver and logs debug info if enabled
55
+ * @internal
56
+ */
35
57
  start() {
36
58
  if (debug) console.log(this.name, this.triggerMask, this);
37
59
  }
38
60
 
61
+ /**
62
+ * Checks for intersections with spatial triggers and fires appropriate events
63
+ * Handles enter, stay, and exit events for all relevant triggers
64
+ * @internal
65
+ */
39
66
  update(): void {
40
67
  this.currentIntersected.length = 0;
41
68
  for (const trigger of SpatialTrigger.triggers) {
@@ -59,23 +86,38 @@
59
86
  }
60
87
  this.lastIntersected.length = 0;
61
88
  this.lastIntersected.push(...this.currentIntersected);
62
-
63
89
  }
64
90
 
91
+ /** Array of triggers currently intersecting with this receiver */
65
92
  readonly currentIntersected: SpatialTrigger[] = [];
93
+
94
+ /** Array of triggers that intersected with this receiver in the previous frame */
66
95
  readonly lastIntersected: SpatialTrigger[] = [];
67
96
 
97
+ /**
98
+ * Handles trigger enter events.
99
+ * @param trigger The spatial trigger that was entered
100
+ */
68
101
  onEnterTrigger(trigger: SpatialTrigger): void {
69
102
  if(debug) console.log("ENTER TRIGGER", this.name, trigger.name, this, trigger);
70
103
  trigger.raiseOnEnterEvent(this);
71
104
  this.onEnter?.invoke();
72
105
  }
106
+
107
+ /**
108
+ * Handles trigger exit events.
109
+ * @param trigger The spatial trigger that was exited
110
+ */
73
111
  onExitTrigger(trigger: SpatialTrigger): void {
74
112
  if(debug) console.log("EXIT TRIGGER", this.name, trigger.name, );
75
113
  trigger.raiseOnExitEvent(this);
76
114
  this.onExit?.invoke();
77
115
  }
78
116
 
117
+ /**
118
+ * Handles trigger stay events.
119
+ * @param trigger The spatial trigger that the receiver is staying in
120
+ */
79
121
  onStayTrigger(trigger: SpatialTrigger): void {
80
122
  trigger.raiseOnStayEvent(this);
81
123
  this.onStay?.invoke();
@@ -83,24 +125,38 @@
83
125
  }
84
126
 
85
127
  /**
86
- * A trigger that can be used to detect if an object is inside a box.
128
+ * A spatial trigger component that detects objects within a box-shaped area.
129
+ * Used to trigger events when objects enter, stay in, or exit the defined area
87
130
  * @category Interactivity
88
131
  * @group Components
89
132
  */
90
133
  export class SpatialTrigger extends Behaviour {
91
134
 
135
+ /** Global registry of all active spatial triggers in the scene */
92
136
  static triggers: SpatialTrigger[] = [];
93
137
 
138
+ /**
139
+ * Bitmask determining which receivers this trigger affects.
140
+ * Only receivers with matching masks will be triggered.
141
+ */
142
+ // currently Layers in unity but maybe this should be a string or plane number? Or should it be a bitmask to allow receivers use multiple triggers?
94
143
  @serializable()
95
144
  triggerMask?: number;
96
145
 
146
+ /** Box helper component used to visualize and calculate the trigger area */
97
147
  private boxHelper?: BoxHelperComponent;
98
148
 
149
+ /**
150
+ * Initializes the trigger and logs debug info if enabled
151
+ */
99
152
  start() {
100
153
  if (debug)
101
154
  console.log(this.name, this.triggerMask, this);
102
155
  }
103
156
 
157
+ /**
158
+ * Registers this trigger in the global registry and sets up debug visualization if enabled
159
+ */
104
160
  onEnable(): void {
105
161
  SpatialTrigger.triggers.push(this);
106
162
  if (!this.boxHelper) {
@@ -108,10 +164,19 @@
108
164
  this.boxHelper?.showHelper(null, debug as boolean);
109
165
  }
110
166
  }
167
+
168
+ /**
169
+ * Removes this trigger from the global registry when disabled
170
+ */
111
171
  onDisable(): void {
112
172
  SpatialTrigger.triggers.splice(SpatialTrigger.triggers.indexOf(this), 1);
113
173
  }
114
174
 
175
+ /**
176
+ * Tests if an object is inside this trigger's box
177
+ * @param obj The object to test against this trigger
178
+ * @returns True if the object is inside the trigger box
179
+ */
115
180
  test(obj: Object3D): boolean {
116
181
  if (!this.boxHelper) return false;
117
182
  return this.boxHelper.isInBox(obj) ?? false;
@@ -119,6 +184,10 @@
119
184
 
120
185
  // private args: SpatialTriggerEventArgs = new SpatialTriggerEventArgs();
121
186
 
187
+ /**
188
+ * Raises the onEnter event on any SpatialTriggerReceiver components attached to this trigger's GameObject
189
+ * @param rec The receiver that entered this trigger
190
+ */
122
191
  raiseOnEnterEvent(rec: SpatialTriggerReceiver) {
123
192
  // this.args.trigger = this;
124
193
  // this.args.source = rec;
@@ -130,6 +199,10 @@
130
199
  }, false);
131
200
  }
132
201
 
202
+ /**
203
+ * Raises the onStay event on any SpatialTriggerReceiver components attached to this trigger's GameObject
204
+ * @param rec The receiver that is staying in this trigger
205
+ */
133
206
  raiseOnStayEvent(rec: SpatialTriggerReceiver) {
134
207
  // this.args.trigger = this;
135
208
  // this.args.source = rec;
@@ -141,6 +214,10 @@
141
214
  }, false);
142
215
  }
143
216
 
217
+ /**
218
+ * Raises the onExit event on any SpatialTriggerReceiver components attached to this trigger's GameObject
219
+ * @param rec The receiver that exited this trigger
220
+ */
144
221
  raiseOnExitEvent(rec: SpatialTriggerReceiver) {
145
222
  GameObject.foreachComponent(this.gameObject, c => {
146
223
  if (c === rec) return;
src/engine-components/SpectatorCamera.ts CHANGED
@@ -17,50 +17,77 @@
17
17
  import { XRStateFlag } from "./webxr/XRFlag.js";
18
18
 
19
19
 
20
+ /**
21
+ * Defines the viewing perspective in spectator mode
22
+ */
20
23
  export enum SpectatorMode {
24
+ /** View from the perspective of the followed player */
21
25
  FirstPerson = 0,
26
+ /** Freely view from a third-person perspective */
22
27
  ThirdPerson = 1,
23
28
  }
24
29
 
25
30
  const debug = getParam("debugspectator");
26
31
 
27
32
  /**
33
+ * Provides functionality to follow and spectate other users in a networked environment.
34
+ * Handles camera switching, following behavior, and network synchronization for spectator mode.
35
+ *
36
+ * Debug mode can be enabled with the URL parameter `?debugspectator`, which provides additional console output.
37
+ *
28
38
  * @category Networking
29
39
  * @group Components
30
40
  */
31
41
  export class SpectatorCamera extends Behaviour {
32
42
 
43
+ /** Reference to the Camera component on this GameObject */
33
44
  cam: Camera | null = null;
34
45
 
35
- /** when enabled pressing F will send a request to all connected users to follow me, ESC to stop */
46
+ /**
47
+ * When enabled, pressing F will send a request to all connected users to follow the local player.
48
+ * Pressing ESC will stop spectating.
49
+ */
36
50
  @serializable()
37
51
  useKeys: boolean = true;
38
52
 
39
53
  private _mode: SpectatorMode = SpectatorMode.FirstPerson;
40
54
 
55
+ /** Gets the current spectator perspective mode */
41
56
  get mode() { return this._mode; }
57
+ /** Sets the current spectator perspective mode */
42
58
  set mode(val: SpectatorMode) {
43
59
  this._mode = val;
44
60
  }
45
61
 
46
- /** if this user is currently spectating someone else */
62
+ /** Returns whether this user is currently spectating another user */
47
63
  get isSpectating(): boolean {
48
64
  return this._handler?.currentTarget !== undefined;
49
65
  }
50
66
 
67
+ /**
68
+ * Checks if this instance is spectating the user with the given ID
69
+ * @param userId The user ID to check
70
+ * @returns True if spectating the specified user, false otherwise
71
+ */
51
72
  isSpectatingUser(userId: string): boolean {
52
73
  return this.target?.userId === userId;
53
74
  }
54
75
 
76
+ /**
77
+ * Checks if the user with the specified ID is following this user
78
+ * @param userId The user ID to check
79
+ * @returns True if the specified user is following this user, false otherwise
80
+ */
55
81
  isFollowedBy(userId: string): boolean {
56
82
  return this.followers?.includes(userId);
57
83
  }
58
84
 
59
- /** list of other users that are following me */
85
+ /** List of user IDs that are currently following the user */
60
86
  get followers(): string[] {
61
87
  return this._networking.followers;
62
88
  }
63
89
 
90
+ /** Stops the current spectating session */
64
91
  stopSpectating() {
65
92
  if (this.context.isInXR) {
66
93
  this.followSelf();
@@ -69,11 +96,15 @@
69
96
  this.target = undefined;
70
97
  }
71
98
 
99
+ /** Gets the local player's connection ID */
72
100
  private get localId() : string {
73
101
  return this.context.connection.connectionId ?? "local";
74
102
  }
75
103
 
76
- /** player view to follow */
104
+ /**
105
+ * Sets the player view to follow
106
+ * @param target The PlayerView to follow, or undefined to stop spectating
107
+ */
77
108
  set target(target: PlayerView | undefined) {
78
109
  if (this._handler) {
79
110
 
@@ -106,14 +137,17 @@
106
137
  }
107
138
  }
108
139
 
140
+ /** Gets the currently followed player view */
109
141
  get target(): PlayerView | undefined {
110
142
  return this._handler?.currentTarget;
111
143
  }
112
144
 
145
+ /** Sends a network request for all users to follow this player */
113
146
  requestAllFollowMe() {
114
147
  this._networking.onRequestFollowMe();
115
148
  }
116
149
 
150
+ /** Determines if the camera is spectating the local player */
117
151
  private get isSpectatingSelf() {
118
152
  return this.isSpectating && this.target?.currentObject === this.context.players.getPlayerView(this.localId)?.currentObject;
119
153
  }
@@ -131,7 +165,6 @@
131
165
  private _networking!: SpectatorCamNetworking;
132
166
 
133
167
  awake(): void {
134
-
135
168
  this._debug = new SpectatorSelectionController(this.context, this);
136
169
  this._networking = new SpectatorCamNetworking(this.context, this);
137
170
  this._networking.awake();
@@ -144,7 +177,6 @@
144
177
  return;
145
178
  }
146
179
 
147
-
148
180
  if (!this._handler && this.cam)
149
181
  this._handler = new SpectatorHandler(this.context, this.cam, this);
150
182
 
@@ -157,6 +189,10 @@
157
189
  this._networking?.destroy();
158
190
  }
159
191
 
192
+ /**
193
+ * Checks if the current platform supports spectator mode
194
+ * @returns True if the platform is supported, false otherwise
195
+ */
160
196
  private isSupportedPlatform() {
161
197
  const ua = window.navigator.userAgent;
162
198
  const standalone = /Windows|MacOS/.test(ua);
@@ -164,12 +200,19 @@
164
200
  return standalone && !isHololens;
165
201
  }
166
202
 
203
+ /**
204
+ * Called before entering WebXR mode
205
+ * @param _evt The WebXR event
206
+ */
167
207
  onBeforeXR(_evt) {
168
208
  if (!this.isSupportedPlatform()) return;
169
209
  GameObject.setActive(this.gameObject, true);
170
210
  }
171
211
 
172
-
212
+ /**
213
+ * Called when entering WebXR mode
214
+ * @param _evt The WebXR event
215
+ */
173
216
  onEnterXR(_evt) {
174
217
  if (!this.isSupportedPlatform()) return;
175
218
  if (debug) console.log(this.context.mainCamera);
@@ -178,6 +221,10 @@
178
221
  }
179
222
  }
180
223
 
224
+ /**
225
+ * Called when exiting WebXR mode
226
+ * @param _evt The WebXR event
227
+ */
181
228
  onLeaveXR(_evt) {
182
229
  this.context.removeCamera(this.cam as ICamera);
183
230
  GameObject.setActive(this.gameObject, false);
@@ -188,7 +235,9 @@
188
235
  this.stopSpectating();
189
236
  }
190
237
 
191
-
238
+ /**
239
+ * Sets the target to follow the local player
240
+ */
192
241
  private followSelf() {
193
242
  this.target = this.context.players.getPlayerView(this.context.connection.connectionId);
194
243
  if (!this.target) {
@@ -201,6 +250,9 @@
201
250
  // TODO: only show Spectator cam for DesktopVR;
202
251
  // don't show for AR, don't show on Quest
203
252
  // TODO: properly align cameras on enter/exit VR, seems currently spectator cam breaks alignment
253
+ /**
254
+ * Called after the main rendering pass to render the spectator view
255
+ */
204
256
  onAfterRender(): void {
205
257
  if (!this.cam) return;
206
258
 
@@ -278,6 +330,9 @@
278
330
  this.resetAvatarFlags();
279
331
  }
280
332
 
333
+ /**
334
+ * Updates avatar visibility flags for rendering in spectator mode
335
+ */
281
336
  private setAvatarFlagsBeforeRender() {
282
337
  const isFirstPersonMode = this._mode === SpectatorMode.FirstPerson;
283
338
 
@@ -295,6 +350,9 @@
295
350
  }
296
351
  }
297
352
 
353
+ /**
354
+ * Restores avatar visibility flags after spectator rendering
355
+ */
298
356
  private resetAvatarFlags() {
299
357
  for (const av of AvatarMarker.instances) {
300
358
  if (av.avatar && "flags" in av.avatar) {
@@ -313,6 +371,9 @@
313
371
  }
314
372
  }
315
373
 
374
+ /**
375
+ * Interface for handling spectator camera behavior
376
+ */
316
377
  interface ISpectatorHandler {
317
378
  context: Context;
318
379
  get currentTarget(): PlayerView | undefined;
@@ -322,6 +383,9 @@
322
383
  destroy();
323
384
  }
324
385
 
386
+ /**
387
+ * Handles the smooth following behavior for the spectator camera
388
+ */
325
389
  class SpectatorHandler implements ISpectatorHandler {
326
390
 
327
391
  readonly context: Context;
@@ -333,6 +397,7 @@
333
397
  private view?: PlayerView;
334
398
  private currentObject: Object3D | undefined;
335
399
 
400
+ /** Gets the currently targeted player view */
336
401
  get currentTarget(): PlayerView | undefined {
337
402
  return this.view;
338
403
  }
@@ -343,6 +408,10 @@
343
408
  this.spectator = spectator;
344
409
  }
345
410
 
411
+ /**
412
+ * Sets the target player view to follow
413
+ * @param view The PlayerView to follow
414
+ */
346
415
  set(view?: PlayerView): void {
347
416
  const followObject = view?.currentObject;
348
417
  if (!followObject) {
@@ -368,6 +437,7 @@
368
437
  else this.context.removeCamera(this.cam as ICamera);
369
438
  }
370
439
 
440
+ /** Disables the spectator following behavior */
371
441
  disable() {
372
442
  if (debug) console.log("STOP FOLLOW", this.currentObject);
373
443
  this.view = undefined;
@@ -377,12 +447,17 @@
377
447
  this.follow.enabled = false;
378
448
  }
379
449
 
450
+ /** Cleans up resources used by the handler */
380
451
  destroy() {
381
452
  this.target?.removeFromParent();
382
453
  if (this.follow)
383
454
  GameObject.destroy(this.follow);
384
455
  }
385
456
 
457
+ /**
458
+ * Updates the camera position and orientation based on the spectator mode
459
+ * @param mode The current spectator mode (first or third person)
460
+ */
386
461
  update(mode: SpectatorMode) {
387
462
  if (this.currentTarget?.isConnected === false || this.currentTarget?.removed === true) {
388
463
  if (debug) console.log("Target disconnected or timeout", this.currentTarget);
@@ -431,13 +506,13 @@
431
506
  target.quaternion.copy(_inverseYQuat);
432
507
  else target.quaternion.identity();
433
508
  }
434
-
435
-
436
509
  }
437
510
 
438
511
  const _inverseYQuat = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI);
439
512
 
440
-
513
+ /**
514
+ * Handles user input for selecting targets to spectate
515
+ */
441
516
  class SpectatorSelectionController {
442
517
 
443
518
  private readonly context: Context;
@@ -468,6 +543,9 @@
468
543
  });
469
544
  }
470
545
 
546
+ /**
547
+ * Attempts to select an avatar to spectate through raycasting
548
+ */
471
549
  private trySelectObject() {
472
550
  const opts = new RaycastOptions();
473
551
  opts.setMask(0xffffff);
@@ -491,17 +569,17 @@
491
569
  }
492
570
  }
493
571
 
494
-
495
-
496
-
497
-
572
+ /**
573
+ * Network model for communicating follower changes
574
+ */
498
575
  class SpectatorFollowerChangedEventModel implements IModel {
499
- /** the user that is following */
576
+ /** The user ID that is following */
500
577
  guid: string;
501
578
  readonly dontSave: boolean = true;
502
579
 
503
- /** the user being followed */
580
+ /** The user ID being followed */
504
581
  targetUserId: string | undefined;
582
+ /** Indicates if the user stopped following */
505
583
  stoppedFollowing: boolean;
506
584
 
507
585
  constructor(connectionId: string, userId: string | undefined, stoppedFollowing: boolean) {
@@ -511,6 +589,9 @@
511
589
  }
512
590
  }
513
591
 
592
+ /**
593
+ * Network model for requesting users to follow a specific player
594
+ */
514
595
  class SpectatorFollowEventModel implements IModel {
515
596
  guid: string;
516
597
  userId: string | undefined;
@@ -521,11 +602,14 @@
521
602
  }
522
603
  }
523
604
 
605
+ /**
606
+ * Handles network communication for spectator functionality
607
+ */
524
608
  class SpectatorCamNetworking {
525
609
 
610
+ /** List of user IDs currently following this player */
526
611
  readonly followers: string[] = [];
527
612
 
528
-
529
613
  private readonly context: Context;
530
614
  private readonly spectator: SpectatorCamera;
531
615
  private _followerEventMethod: Function;
@@ -540,6 +624,9 @@
540
624
  this._joinedRoomMethod = this.onUserJoinedRoom.bind(this);
541
625
  }
542
626
 
627
+ /**
628
+ * Initializes network event listeners
629
+ */
543
630
  awake() {
544
631
  this.context.connection.beginListen("spectator-follower-changed", this._followerEventMethod);
545
632
  this.context.connection.beginListen("spectator-request-follow", this._requestFollowMethod);
@@ -555,12 +642,20 @@
555
642
  });
556
643
  }
557
644
 
645
+ /**
646
+ * Removes network event listeners
647
+ */
558
648
  destroy() {
559
649
  this.context.connection.stopListen("spectator-follower-changed", this._followerEventMethod);
560
650
  this.context.connection.stopListen("spectator-request-follow", this._requestFollowMethod);
561
651
  this.context.connection.stopListen(RoomEvents.JoinedRoom, this._joinedRoomMethod);
562
652
  }
563
653
 
654
+ /**
655
+ * Notifies other users about spectating target changes
656
+ * @param target The new target being spectated
657
+ * @param _prevId The previous target's user ID
658
+ */
564
659
  onSpectatedObjectChanged(target: PlayerView | undefined, _prevId?: string) {
565
660
  if (debug)
566
661
  console.log(this.context.connection.connectionId, "onSpectatedObjectChanged", target, _prevId);
@@ -572,6 +667,10 @@
572
667
  }
573
668
  }
574
669
 
670
+ /**
671
+ * Requests other users to follow this player or stop following
672
+ * @param stop Whether to request users to stop following
673
+ */
575
674
  onRequestFollowMe(stop: boolean = false) {
576
675
  if (debug)
577
676
  console.log("Request follow", this.context.connection.connectionId);
@@ -583,12 +682,19 @@
583
682
  }
584
683
  }
585
684
 
685
+ /**
686
+ * Handles room join events
687
+ */
586
688
  private onUserJoinedRoom() {
587
689
  if (getParam("followme")) {
588
690
  this.onRequestFollowMe();
589
691
  }
590
692
  }
591
693
 
694
+ /**
695
+ * Processes follower status change events from the network
696
+ * @param evt The follower change event data
697
+ */
592
698
  private onFollowerEvent(evt: SpectatorFollowerChangedEventModel) {
593
699
  const userBeingFollowed = evt.targetUserId;
594
700
  const userThatIsFollowing = evt.guid;
@@ -615,6 +721,9 @@
615
721
  }
616
722
  }
617
723
 
724
+ /**
725
+ * Removes followers that are no longer connected to the room
726
+ */
618
727
  private removeDisconnectedFollowers() {
619
728
  for (let i = this.followers.length - 1; i >= 0; i--) {
620
729
  const id = this.followers[i];
@@ -626,6 +735,11 @@
626
735
 
627
736
  private _lastRequestFollowUser: SpectatorFollowEventModel | undefined;
628
737
 
738
+ /**
739
+ * Handles follow requests from other users
740
+ * @param evt The follow request event
741
+ * @returns True if the request was handled successfully
742
+ */
629
743
  private onRequestFollowEvent(evt: SpectatorFollowEventModel) {
630
744
  this._lastRequestFollowUser = evt;
631
745
 
@@ -652,6 +766,10 @@
652
766
  }
653
767
 
654
768
  private _enforceFollowInterval: any;
769
+
770
+ /**
771
+ * Periodically retries following a user if the initial attempt failed
772
+ */
655
773
  private enforceFollow() {
656
774
  if (this._enforceFollowInterval) return;
657
775
  this._enforceFollowInterval = setInterval(() => {
src/engine-components/SpriteRenderer.ts CHANGED
@@ -155,7 +155,11 @@
155
155
  export class SpriteSheet {
156
156
 
157
157
  @serializable(Sprite)
158
- sprites!: Sprite[];
158
+ sprites: Sprite[];
159
+
160
+ constructor() {
161
+ this.sprites = [];
162
+ }
159
163
  }
160
164
 
161
165
  export class SpriteData {
@@ -171,6 +175,13 @@
171
175
  // hence the spriteSheet field is undefined by default
172
176
  constructor() { }
173
177
 
178
+ clone() {
179
+ const i = new SpriteData();
180
+ i.index = this.index;
181
+ i.spriteSheet = this.spriteSheet;
182
+ return i;
183
+ }
184
+
174
185
  /**
175
186
  * Set the sprite to be rendered in the currently assigned sprite sheet at the currently active index {@link index}
176
187
  */
@@ -320,7 +331,6 @@
320
331
  if (typeof value === "number") {
321
332
  const index = Math.floor(value);
322
333
  this.spriteIndex = index;
323
- return;
324
334
  }
325
335
  else if (value instanceof Sprite) {
326
336
  if (!this._spriteSheet) {
@@ -328,8 +338,8 @@
328
338
  }
329
339
  if (this._spriteSheet.sprite != value) {
330
340
  this._spriteSheet.sprite = value;
331
- this.updateSprite();
332
341
  }
342
+ this.updateSprite();
333
343
  }
334
344
  else if (value != this._spriteSheet) {
335
345
  this._spriteSheet = value;
@@ -342,7 +352,6 @@
342
352
  */
343
353
  set spriteIndex(value: number) {
344
354
  if (!this._spriteSheet) return;
345
- if (value === this.spriteIndex) return;
346
355
  this._spriteSheet.index = value;
347
356
  this.updateSprite();
348
357
  }
@@ -359,13 +368,19 @@
359
368
  private _spriteSheet?: SpriteData;
360
369
  private _currentSprite?: Mesh;
361
370
 
371
+
362
372
  /** @internal */
363
373
  awake(): void {
364
374
  this._currentSprite = undefined;
375
+
365
376
  if (!this._spriteSheet) {
366
- this._spriteSheet = new SpriteData();
367
- this._spriteSheet.spriteSheet = new SpriteSheet();
377
+ this._spriteSheet = SpriteData.create();
368
378
  }
379
+ else {
380
+ // Ensure each SpriteRenderer has a unique spritesheet instance for cases where sprite renderer are cloned at runtime and then different sprites are assigned to each instance
381
+ this._spriteSheet = this._spriteSheet.clone();
382
+ }
383
+
369
384
  if (debug) {
370
385
  console.log("Awake", this.name, this, this.sprite);
371
386
  }
@@ -408,6 +423,7 @@
408
423
  }
409
424
  mat.transparent = true;
410
425
  mat.toneMapped = this.toneMapped;
426
+ mat.depthWrite = false;
411
427
 
412
428
  if (sprite.texture && !mat.wireframe) {
413
429
  let tex = sprite.texture;
src/engine-components/SyncedTransform.ts CHANGED
@@ -19,7 +19,12 @@
19
19
 
20
20
  const builder = new flatbuffers.Builder();
21
21
 
22
- /** Creates a flatbuffer model containing the transform data of a game object. Used by {@link SyncedTransform}
22
+ /**
23
+ * Creates a flatbuffer model containing the transform data of a game object. Used by {@link SyncedTransform}
24
+ * @param guid The unique identifier of the object to sync
25
+ * @param b The behavior component containing transform data
26
+ * @param fast Whether to use fast mode synchronization (syncs more frequently)
27
+ * @returns A Uint8Array containing the serialized transform data
23
28
  */
24
29
  export function createTransformModel(guid: string, b: Behaviour, fast: boolean = true): Uint8Array {
25
30
  builder.clear();
@@ -50,18 +55,28 @@
50
55
  })
51
56
 
52
57
  /**
53
- * SyncedTransform is a behaviour that syncs the transform of a game object over the network.
58
+ * SyncedTransform synchronizes the position and rotation of a game object over the network.
59
+ * It handles ownership transfer, interpolation, and network state updates automatically.
54
60
  * @category Networking
55
61
  * @group Components
56
62
  */
57
63
  export class SyncedTransform extends Behaviour {
58
64
 
59
-
65
+
60
66
  // public autoOwnership: boolean = true;
67
+ /** When true, overrides physics behavior when this object is owned by the local user */
61
68
  public overridePhysics: boolean = true
69
+
70
+ /** Whether to smoothly interpolate position changes when receiving updates */
62
71
  public interpolatePosition: boolean = true;
72
+
73
+ /** Whether to smoothly interpolate rotation changes when receiving updates */
63
74
  public interpolateRotation: boolean = true;
75
+
76
+ /** When true, sends updates at a higher frequency, useful for fast-moving objects */
64
77
  public fastMode: boolean = false;
78
+
79
+ /** When true, notifies other clients when this object is destroyed */
65
80
  public syncDestroy: boolean = false;
66
81
 
67
82
  // private _state!: SyncedTransformModel;
@@ -77,7 +92,10 @@
77
92
  private _receivedFastUpdate: boolean = false;
78
93
  private _shouldRequestOwnership: boolean = false;
79
94
 
80
- /** Request ownership of an object - you need to be connected to a room */
95
+ /**
96
+ * Requests ownership of this object on the network.
97
+ * You need to be connected to a room for this to work.
98
+ */
81
99
  public requestOwnership() {
82
100
  if (debug)
83
101
  console.log("Request ownership");
@@ -89,10 +107,18 @@
89
107
  this._model.requestOwnership();
90
108
  }
91
109
 
110
+ /**
111
+ * Checks if this client has ownership of the object
112
+ * @returns true if this client has ownership, false if not, undefined if ownership state is unknown
113
+ */
92
114
  public hasOwnership(): boolean | undefined {
93
115
  return this._model?.hasOwnership ?? undefined;
94
116
  }
95
117
 
118
+ /**
119
+ * Checks if the object is owned by any client
120
+ * @returns true if the object is owned, false if not, undefined if ownership state is unknown
121
+ */
96
122
  public isOwned(): boolean | undefined {
97
123
  return this._model?.isOwned;
98
124
  }
@@ -140,6 +166,9 @@
140
166
  this.context.connection.stopListenBinary(SyncedTransformIdentifier, this.receivedDataCallback);
141
167
  }
142
168
 
169
+ /**
170
+ * Attempts to retrieve and apply the last known network state for this transform
171
+ */
143
172
  private tryGetLastState() {
144
173
  const model = this.context.connection.tryGetState(this.guid) as unknown as SyncedTransformModel;
145
174
  if (model) this.onReceivedData(model);
@@ -147,6 +176,10 @@
147
176
 
148
177
  private tempEuler: Euler = new Euler();
149
178
 
179
+ /**
180
+ * Handles incoming network data for this transform
181
+ * @param data The model containing transform information
182
+ */
150
183
  private onReceivedData(data: SyncedTransformModel) {
151
184
  if (this.destroyed) return;
152
185
  if (typeof data.guid === "function" && data.guid() === this.guid) {
@@ -183,7 +216,10 @@
183
216
  }
184
217
  }
185
218
 
186
- /** @internal */
219
+ /**
220
+ * @internal
221
+ * Initializes tracking of position and rotation when component is enabled
222
+ */
187
223
  onEnable(): void {
188
224
  this.lastWorldPos.copy(this.worldPosition);
189
225
  this.lastWorldRotation.copy(this.worldQuaternion);
@@ -194,7 +230,10 @@
194
230
  }
195
231
  }
196
232
 
197
- /** @internal */
233
+ /**
234
+ * @internal
235
+ * Releases ownership when component is disabled
236
+ */
198
237
  onDisable(): void {
199
238
  if (this._model)
200
239
  this._model.freeOwnership();
@@ -205,7 +244,11 @@
205
244
  private lastWorldPos!: Vector3;
206
245
  private lastWorldRotation!: Quaternion;
207
246
 
208
- /** @internal */
247
+ /**
248
+ * @internal
249
+ * Handles transform synchronization before each render frame
250
+ * Sends updates when owner, receives and applies updates when not owner
251
+ */
209
252
  onBeforeRender() {
210
253
  if (!this.activeAndEnabled || !this.context.connection.isConnected) return;
211
254
  // console.log("BEFORE RENDER", this.destroyed, this.guid, this._model?.isOwned, this.name, this.gameObject);
src/engine-components/TransformGizmo.ts CHANGED
@@ -8,27 +8,43 @@
8
8
  import { SyncedTransform } from "./SyncedTransform.js";
9
9
 
10
10
  /**
11
- * TransformGizmo is a component that displays a gizmo for transforming the object in the scene.
11
+ * TransformGizmo displays manipulation controls for translating, rotating, and scaling objects in the scene.
12
+ * It wraps three.js {@link TransformControls} and provides keyboard shortcuts for changing modes and settings.
12
13
  * @category Helpers
13
14
  * @group Components
14
15
  */
15
16
  export class TransformGizmo extends Behaviour {
16
17
 
18
+ /**
19
+ * When true, this is considered a helper gizmo and will only be shown if showGizmos is enabled in engine parameters.
20
+ */
17
21
  @serializable()
18
22
  public isGizmo: boolean = false;
19
23
 
24
+ /**
25
+ * Specifies the translation grid snap value in world units.
26
+ * Applied when holding Shift while translating an object.
27
+ */
20
28
  @serializable()
21
29
  public translationSnap: number = 1;
22
30
 
31
+ /**
32
+ * Specifies the rotation snap angle in degrees.
33
+ * Applied when holding Shift while rotating an object.
34
+ */
23
35
  @serializable()
24
36
  public rotationSnapAngle: number = 15;
25
37
 
38
+ /**
39
+ * Specifies the scale snapping value.
40
+ * Applied when holding Shift while scaling an object.
41
+ */
26
42
  @serializable()
27
43
  public scaleSnap: number = .25;
28
44
 
29
45
  /**
30
- * Get the underlying three.js TransformControls instance.
31
- * @returns The TransformControls instance.
46
+ * Gets the underlying three.js {@link TransformControls} instance.
47
+ * @returns The TransformControls instance or undefined if not initialized.
32
48
  */
33
49
  get control() {
34
50
  return this._control;
@@ -81,6 +97,10 @@
81
97
  window.removeEventListener('keyup', this.windowKeyUpListener);
82
98
  }
83
99
 
100
+ /**
101
+ * Enables grid snapping for transform operations according to set snap values.
102
+ * This applies the translationSnap, rotationSnapAngle, and scaleSnap properties to the controls.
103
+ */
84
104
  enableSnapping() {
85
105
  if (this._control) {
86
106
  this._control.setTranslationSnap(this.translationSnap);
@@ -89,6 +109,10 @@
89
109
  }
90
110
  }
91
111
 
112
+ /**
113
+ * Disables grid snapping for transform operations.
114
+ * Removes all snapping constraints from the transform controls.
115
+ */
92
116
  disableSnapping() {
93
117
  if (this._control) {
94
118
  this._control.setTranslationSnap(null);
@@ -97,6 +121,11 @@
97
121
  }
98
122
  }
99
123
 
124
+ /**
125
+ * Event handler for when dragging state changes.
126
+ * Disables orbit controls during dragging and requests ownership of the transform if it's synchronized.
127
+ * @param event The drag change event
128
+ */
100
129
  private onControlChangedEvent = (event) => {
101
130
  const orbit = this.orbit;
102
131
  if (orbit) orbit.enabled = !event.value;
@@ -109,7 +138,18 @@
109
138
  }
110
139
  }
111
140
 
112
-
141
+ /**
142
+ * Handles keyboard shortcuts for transform operations:
143
+ * - Q: Toggle local/world space
144
+ * - W: Translation mode
145
+ * - E: Rotation mode
146
+ * - R: Scale mode
147
+ * - Shift: Enable snapping (while held)
148
+ * - +/-: Adjust gizmo size
149
+ * - X/Y/Z: Toggle visibility of respective axis
150
+ * - Spacebar: Toggle controls enabled state
151
+ * @param event The keyboard event
152
+ */
113
153
  private windowKeyDownListener = (event) => {
114
154
  if (!this.enabled) return;
115
155
  if (!this._control) return;
@@ -162,6 +202,11 @@
162
202
  }
163
203
  }
164
204
 
205
+ /**
206
+ * Handles keyboard key release events.
207
+ * Currently only handles releasing Shift key to disable snapping.
208
+ * @param event The keyboard event
209
+ */
165
210
  private windowKeyUpListener = (event) => {
166
211
  if (!this.enabled) return;
167
212
  switch (event.keyCode) {
src/engine-components/postprocessing/Volume.ts CHANGED
@@ -62,11 +62,11 @@
62
62
  * Add a post processing effect to the stack and schedules the effect stack to be re-created.
63
63
  */
64
64
  addEffect<T extends PostProcessingEffect | Effect>(effect: T): T {
65
-
66
65
  let entry = effect as PostProcessingEffect;
67
66
  if (!(entry instanceof PostProcessingEffect)) {
68
67
  entry = new EffectWrapper(entry);
69
68
  }
69
+ if(entry.gameObject === undefined) this.gameObject.addComponent(entry);
70
70
  if (this._effects.includes(entry)) return effect;
71
71
  this._effects.push(entry);
72
72
  this._isDirty = true;
@@ -125,7 +125,7 @@
125
125
  }
126
126
 
127
127
  // ensure the profile is initialized
128
- this.sharedProfile?.init();
128
+ this.sharedProfile?.__init(this);
129
129
  }
130
130
 
131
131
  onEnable(): void {
@@ -190,7 +190,7 @@
190
190
  private _isDirty: boolean = false;
191
191
 
192
192
  private apply() {
193
- if (debug) console.log("Apply PostProcessing " + this.name);
193
+ if (debug) console.log(`Apply PostProcessing "${this.name}"`);
194
194
 
195
195
  if (isDevEnvironment()) {
196
196
  if (this._lastApplyTime !== undefined && Date.now() - this._lastApplyTime < 100) {
@@ -209,18 +209,16 @@
209
209
  if (this.sharedProfile?.components) {
210
210
  const comps = this.sharedProfile.components;
211
211
  for (const effect of comps) {
212
- if (effect.active && !this._activeEffects.includes(effect))
212
+ if (effect.active && effect.enabled && !this._activeEffects.includes(effect))
213
213
  this._activeEffects.push(effect);
214
214
  }
215
215
  }
216
216
  // add effects registered via code
217
217
  for (const effect of this._effects) {
218
- if (effect.active && !this._activeEffects.includes(effect))
218
+ if (effect.active && effect.enabled && !this._activeEffects.includes(effect))
219
219
  this._activeEffects.push(effect);
220
220
  }
221
221
 
222
- if (debug) console.log("Apply PostProcessing", this._activeEffects);
223
-
224
222
  if (this._activeEffects.length > 0) {
225
223
  if (!this._postprocessing)
226
224
  this._postprocessing = new PostProcessingHandler(this.context);
src/engine-components/postprocessing/VolumeProfile.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { serializeable } from "../../engine/engine_serialization_decorator.js";
2
2
  import { getParam } from "../../engine/engine_utils.js";
3
+ import { Component } from "../Component.js";
3
4
  import { PostProcessingEffect } from "./PostProcessingEffect.js";
5
+ import type { Volume } from "./Volume.js";
4
6
 
5
7
  const debug = getParam("debugpost");
6
8
 
@@ -38,8 +40,14 @@
38
40
  * call init on all components
39
41
  * @hidden
40
42
  **/
41
- init() {
42
- this.components?.forEach(c => c.init());
43
+ __init(owner: Component) {
44
+ this.components?.forEach(c => {
45
+ // Make sure all components are added to the gameobject (this is not the case when e.g. effects are serialized from Unity)
46
+ if (c.gameObject === undefined) {
47
+ owner.gameObject.addComponent(c);
48
+ }
49
+ c.init()
50
+ });
43
51
  }
44
52
 
45
53
  addEffect(effect: PostProcessingEffect) {
src/engine-components/webxr/WebARSessionRoot.ts CHANGED
@@ -49,18 +49,25 @@
49
49
  }
50
50
  }
51
51
 
52
+ private static _hasPlaced: boolean = false;
53
+ /**
54
+ * @returns true if the scene has been placed in AR by the user or automatic placement
55
+ */
56
+ static get hasPlaced(): boolean {
57
+ return this._hasPlaced;
58
+ }
52
59
 
53
- /** The scale of a user in AR:
54
- * Note: a large value makes the scene appear smaller
60
+
61
+ /** The scale of the user in AR.
62
+ * **NOTE**: a large value makes the scene appear smaller
55
63
  * @default 1
56
64
  */
57
65
  get arScale(): number {
58
66
  return this._arScale;
59
67
  }
60
68
  set arScale(val: number) {
61
- if (val === this._arScale) return;
62
- this._arScale = val;
63
- this.onScaleChanged();
69
+ this._arScale = Math.max(0.000001, val);
70
+ this.onSetScale();
64
71
  }
65
72
  private _arScale: number = 1;
66
73
 
@@ -134,6 +141,7 @@
134
141
  if (debug) console.log("ENTER WEBXR: SessionRoot start...");
135
142
 
136
143
  this._anchor = null;
144
+ WebARSessionRoot._hasPlaced = false;
137
145
 
138
146
  // if (_args.xr.session.enabledFeatures?.includes("image-tracking")) {
139
147
  // console.warn("Image tracking is enabled - will not place scene");
@@ -193,6 +201,7 @@
193
201
  this.onRevertSceneChanges();
194
202
  // this._anchor?.delete();
195
203
  this._anchor = null;
204
+ WebARSessionRoot._hasPlaced = false;
196
205
  this._rigPlacementMatrix = undefined;
197
206
  }
198
207
  onUpdateXR(args: NeedleXREventArgs): void {
@@ -405,6 +414,7 @@
405
414
  reticle.quaternion.copy(reticle["lastQuat"]);
406
415
 
407
416
  this.onApplyPose(reticle);
417
+ WebARSessionRoot._hasPlaced = true;
408
418
 
409
419
  if (this.useXRAnchor) {
410
420
  this.onCreateAnchor(NeedleXRSession.active!, hit);
@@ -417,8 +427,16 @@
417
427
  }
418
428
  }
419
429
 
420
- private onScaleChanged() {
421
- // TODO: implement
430
+ private onSetScale() {
431
+ if (!WebARSessionRoot._hasPlaced) return;
432
+ const rig = NeedleXRSession.active?.rig?.gameObject;
433
+ if (rig) {
434
+ const currentScale = NeedleXRSession.active?.rigScale || 1;
435
+ const newScale = (1 / this._arScale) * currentScale;
436
+ const scaleMatrix = new Matrix4().makeScale(newScale, newScale, newScale).invert();
437
+ rig.matrix.premultiply(scaleMatrix);
438
+ rig.matrix.decompose(rig.position, rig.quaternion, rig.scale);
439
+ }
422
440
  }
423
441
 
424
442
  private onRevertSceneChanges() {
@@ -516,7 +534,7 @@
516
534
  console.warn("No rig object to place");
517
535
  return;
518
536
  }
519
- const rigScale = NeedleXRSession.active?.rigScale || 1;
537
+ // const rigScale = NeedleXRSession.active?.rigScale || 1;
520
538
 
521
539
  // save the previous rig parent
522
540
  const previousParent = rigObject.parent || this.context.scene;
@@ -578,6 +596,10 @@
578
596
  private _scale: number = 1;
579
597
  private _hasChanged: boolean = false;
580
598
 
599
+ get scale() {
600
+ return this._scale;
601
+ }
602
+
581
603
  // readonly translate: Vector3 = new Vector3();
582
604
  // readonly rotation: Quaternion = new Quaternion();
583
605
  // readonly scale: Vector3 = new Vector3(1, 1, 1);
@@ -594,6 +616,7 @@
594
616
  reset() {
595
617
  this._scale = 1;
596
618
  this.offset.identity();
619
+ this._hasChanged = true;
597
620
  }
598
621
  get hasChanged() { return this._hasChanged; }
599
622
 
src/engine-components/webxr/WebXR.ts CHANGED
@@ -31,74 +31,127 @@
31
31
  export class WebXR extends Behaviour {
32
32
 
33
33
  // UI
34
- /** When enabled a button will be added to the UI to enter VR */
34
+ /**
35
+ * When enabled, a button will be automatically added to {@link NeedleMenu} that allows users to enter VR mode.
36
+ */
35
37
  @serializable()
36
38
  createVRButton: boolean = true;
37
- /** When enabled a button will be added to the UI to enter AR */
39
+
40
+ /**
41
+ * When enabled, a button will be automatically added to {@link NeedleMenu} that allows users to enter AR mode.
42
+ */
38
43
  @serializable()
39
44
  createARButton: boolean = true;
40
- /** When enabled a send to quest button will be shown if the device does not support VR */
45
+
46
+ /**
47
+ * When enabled, a button to send the experience to an Oculus Quest will be shown if the current device does not support VR.
48
+ * This helps direct users to compatible devices for optimal VR experiences.
49
+ */
41
50
  @serializable()
42
51
  createSendToQuestButton: boolean = true;
43
- /** When enabled a QRCode will be created to open the website on a mobile device */
52
+
53
+ /**
54
+ * When enabled, a QR code will be generated and displayed on desktop devices to allow easy opening of the experience on mobile devices.
55
+ */
44
56
  @serializable()
45
57
  createQRCode: boolean = true;
46
58
 
47
59
  // VR Settings
48
- /** When enabled default movement behaviour will be added */
60
+ /**
61
+ * When enabled, default movement controls will be automatically added to the scene when entering VR.
62
+ * This includes teleportation and smooth locomotion options for VR controllers.
63
+ */
49
64
  @serializable()
50
65
  useDefaultControls: boolean = true;
51
- /** When enabled controller models will automatically be created and updated when you are using controllers in WebXR */
66
+
67
+ /**
68
+ * When enabled, 3D models representing the user's VR controllers will be automatically created and rendered in the scene.
69
+ */
52
70
  @serializable()
53
71
  showControllerModels: boolean = true;
54
- /** When enabled hand models will automatically be created and updated when you are using hands in WebXR */
72
+
73
+ /**
74
+ * When enabled, 3D models representing the user's hands will be automatically created and rendered when hand tracking is available.
75
+ */
55
76
  @serializable()
56
77
  showHandModels: boolean = true;
57
78
 
58
79
  // AR Settings
59
- /** When enabled the scene must be placed in AR */
80
+ /**
81
+ * When enabled, a reticle will be displayed to help place the scene in AR. The user must tap on a detected surface to position the scene.
82
+ */
60
83
  @serializable()
61
84
  usePlacementReticle: boolean = true;
62
- /** When assigned this object will be used as the AR placement reticle */
85
+
86
+ /**
87
+ * Optional custom 3D object to use as the AR placement reticle instead of the default one.
88
+ */
63
89
  @serializable(AssetReference)
64
90
  customARPlacementReticle?: AssetReference;
65
- /** When enabled you can position, rotate or scale your AR scene with one or two fingers */
91
+
92
+ /**
93
+ * When enabled, users can adjust the position, rotation, and scale of the AR scene with one or two fingers after initial placement.
94
+ */
66
95
  @serializable()
67
96
  usePlacementAdjustment: boolean = true;
68
- /** Used when `usePlacementReticle` is enabled. This is the scale of the user in the scene in AR. Larger values make the 3D content appear smaller */
97
+
98
+ /**
99
+ * Determines the scale of the user relative to the scene in AR. Larger values make the 3D content appear smaller.
100
+ * Only applies when `usePlacementReticle` is enabled.
101
+ */
69
102
  @serializable()
70
103
  arScale: number = 1;
71
- /** Experimental: When enabled an XRAnchor will be created for the AR scene and the position will be updated to the anchor position every few frames */
104
+
105
+ /**
106
+ * When enabled, an XRAnchor will be created for the AR scene and its position will be regularly updated to match the anchor.
107
+ * This can help with spatial persistence in AR experiences.
108
+ * @experimental
109
+ */
72
110
  @serializable()
73
111
  useXRAnchor: boolean = false;
112
+
74
113
  /**
75
- * When enabled the scene will be placed automatically when a point in the real world is found
114
+ * When enabled, the scene will be automatically placed as soon as a suitable surface is detected in AR,
115
+ * without requiring the user to tap to confirm placement.
76
116
  */
77
117
  @serializable()
78
- autoPlace: boolean = true;
79
- /** When enabled the AR session root center will be automatically adjusted to place the center of the scene */
118
+ autoPlace: boolean = false;
119
+
120
+ /**
121
+ * When enabled, the AR session root center will be automatically adjusted to place the center of the scene.
122
+ * This helps ensure the scene is properly aligned with detected surfaces.
123
+ */
80
124
  @serializable()
81
125
  autoCenter: boolean = false;
82
126
 
83
- /** When enabled a USDZExporter component will be added to the scene (if none is found) */
127
+ /**
128
+ * When enabled, a USDZExporter component will be automatically added to the scene if none is found.
129
+ * This allows iOS and visionOS devices to view 3D content using Apple's AR QuickLook.
130
+ */
84
131
  @serializable()
85
132
  useQuicklookExport: boolean = false;
86
133
 
87
-
88
- /** Preview feature enabling occlusion (when available: https://github.com/cabanier/three.js/commit/b6ee92bcd8f20718c186120b7f19a3b68a1d4e47)
89
- * Enables the 'depth-sensing' WebXR feature to provide realtime depth occlusion. Only supported on Oculus Quest right now.
134
+ /**
135
+ * When enabled, the 'depth-sensing' WebXR feature will be requested to provide real-time depth occlusion.
136
+ * Currently only supported on Oculus Quest devices.
137
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/XRDepthInformation
138
+ * @experimental
90
139
  */
91
140
  @serializable()
92
141
  useDepthSensing: boolean = false;
93
142
 
94
143
  /**
95
- * When enabled the spatial grab raycaster will be added or enabled in the scene
144
+ * When enabled, a {@link SpatialGrabRaycaster} will be added or enabled in the scene,
145
+ * allowing users to interact with objects at a distance in VR/AR.
96
146
  * @default true
97
147
  */
98
148
  @serializable()
99
149
  useSpatialGrab: boolean = true;
100
150
 
101
- /** This avatar representation will be spawned when you enter a webxr session */
151
+ /**
152
+ * Specifies the avatar representation that will be created when entering a WebXR session.
153
+ * Can be a reference to a 3D model or a boolean to use the default avatar.
154
+ */
102
155
  @serializable(AssetReference)
103
156
  defaultAvatar?: AssetReference | boolean;
104
157
 
@@ -111,10 +164,19 @@
111
164
 
112
165
  static activeWebXRComponent: WebXR | null = null;
113
166
 
167
+ /**
168
+ * Initializes the WebXR component by obtaining the XR sync object for this context.
169
+ * @internal
170
+ */
114
171
  awake() {
115
172
  NeedleXRSession.getXRSync(this.context);
116
173
  }
117
174
 
175
+ /**
176
+ * Sets up the WebXR component when it's enabled. Checks for HTTPS connection,
177
+ * sets up USDZ export if enabled, creates UI buttons, and configures avatar settings.
178
+ * @internal
179
+ */
118
180
  onEnable(): void {
119
181
  // check if we're on a secure connection:
120
182
  if (window.location.protocol !== "https:") {
@@ -153,11 +215,21 @@
153
215
  }
154
216
  }
155
217
 
218
+ /**
219
+ * Cleans up resources when the component is disabled.
220
+ * Destroys the USDZ exporter if one was created and removes UI buttons.
221
+ * @internal
222
+ */
156
223
  onDisable(): void {
157
224
  this._usdzExporter?.destroy();
158
225
  this.removeButtons();
159
226
  }
160
227
 
228
+ /**
229
+ * Checks if WebXR is supported and offers an appropriate session.
230
+ * This is used to show the WebXR session joining prompt in browsers that support it.
231
+ * @returns A Promise that resolves to true if a session was offered, false otherwise
232
+ */
161
233
  private async handleOfferSession() {
162
234
  if (this.createVRButton) {
163
235
  const hasVRSupport = await NeedleXRSession.isVRSupported();
@@ -182,16 +254,32 @@
182
254
  get sessionMode(): XRSessionMode | null {
183
255
  return NeedleXRSession.activeMode ?? null;;
184
256
  }
257
+ /** While AR: this will return the currently active WebARSessionRoot component.
258
+ * You can also query this component in your scene with `findObjectOfType(WebARSessionRoot)`
259
+ */
260
+ get arSessionRoot() {
261
+ return this._activeWebARSessionRoot;
262
+ }
185
263
 
186
- /** Call to start an WebVR session */
264
+ /** Call to start an WebVR session.
265
+ *
266
+ * This is a shorthand for `NeedleXRSession.start("immersive-vr", init, this.context)`
267
+ */
187
268
  async enterVR(init?: XRSessionInit): Promise<NeedleXRSession | null> {
188
269
  return NeedleXRSession.start("immersive-vr", init, this.context);
189
270
  }
190
- /** Call to start an WebAR session */
271
+ /** Call to start an WebAR session
272
+ *
273
+ * This is a shorthand for `NeedleXRSession.start("immersive-ar", init, this.context)`
274
+ */
191
275
  async enterAR(init?: XRSessionInit): Promise<NeedleXRSession | null> {
192
276
  return NeedleXRSession.start("immersive-ar", init, this.context);
193
277
  }
194
- /** Call to end a WebXR (AR or VR) session */
278
+
279
+ /** Call to end a WebXR (AR or VR) session.
280
+ *
281
+ * This is a shorthand for `NeedleXRSession.stop()`
282
+ */
195
283
  exitXR() {
196
284
  NeedleXRSession.stop();
197
285
  }
@@ -199,11 +287,18 @@
199
287
  private _exitXRMenuButton?: HTMLElement;
200
288
  private _previousXRState: number = 0;
201
289
  private _spatialGrabRaycaster?: SpatialGrabRaycaster;
290
+ private _activeWebARSessionRoot: WebARSessionRoot | null = null;
202
291
 
203
292
  private get isActiveWebXR() {
204
293
  return !WebXR.activeWebXRComponent || WebXR.activeWebXRComponent === this;
205
294
  }
206
295
 
296
+ /**
297
+ * Called before entering a WebXR session. Sets up optional features like depth sensing, if needed.
298
+ * @param _mode The XR session mode being requested (immersive-ar or immersive-vr)
299
+ * @param args The XRSessionInit object that will be passed to the WebXR API
300
+ * @internal
301
+ */
207
302
  onBeforeXR(_mode: XRSessionMode, args: XRSessionInit): void {
208
303
  if (!this.isActiveWebXR) {
209
304
  console.warn(`WebXR: another WebXR component is already active (${WebXR.activeWebXRComponent?.name}). This is ignored: ${this.name}`);
@@ -217,6 +312,12 @@
217
312
  }
218
313
  }
219
314
 
315
+ /**
316
+ * Called when a WebXR session begins. Sets up the scene for XR by configuring controllers,
317
+ * AR placement, and other features based on component settings.
318
+ * @param args Event arguments containing information about the started XR session
319
+ * @internal
320
+ */
220
321
  async onEnterXR(args: NeedleXREventArgs) {
221
322
  if (!this.isActiveWebXR) return;
222
323
 
@@ -244,6 +345,7 @@
244
345
  }
245
346
  }
246
347
 
348
+ this._activeWebARSessionRoot = sessionroot;
247
349
  if (sessionroot) {
248
350
  // sessionroot.enabled = this.usePlacementReticle; // < not sure if we want to disable the session root when placement reticle if OFF...
249
351
  sessionroot.customReticle = this.customARPlacementReticle;
@@ -288,6 +390,12 @@
288
390
  }
289
391
  }
290
392
 
393
+ /**
394
+ * Called every frame during an active WebXR session.
395
+ * Updates components that depend on the current XR state.
396
+ * @param _args Event arguments containing information about the current XR session frame
397
+ * @internal
398
+ */
291
399
  onUpdateXR(_args: NeedleXREventArgs): void {
292
400
  if (!this.isActiveWebXR) return;
293
401
  if (this._spatialGrabRaycaster) {
@@ -295,6 +403,12 @@
295
403
  }
296
404
  }
297
405
 
406
+ /**
407
+ * Called when a WebXR session ends. Restores pre-session state,
408
+ * removes temporary components, and cleans up resources.
409
+ * @param _ Event arguments containing information about the ended XR session
410
+ * @internal
411
+ */
298
412
  onLeaveXR(_: NeedleXREventArgs): void {
299
413
  this._exitXRMenuButton?.remove();
300
414
 
@@ -310,6 +424,8 @@
310
424
  }
311
425
  this._createdComponentsInSession.length = 0;
312
426
 
427
+ this._activeWebARSessionRoot = null;
428
+
313
429
  this.handleOfferSession();
314
430
 
315
431
  delayForFrames(1).then(() => WebXR.activeWebXRComponent = null);
@@ -339,8 +455,10 @@
339
455
  return models;
340
456
  }
341
457
 
342
-
343
-
458
+ /**
459
+ * Creates and instantiates the user's avatar representation in the WebXR session.
460
+ * @param xr The active session
461
+ */
344
462
  protected async createLocalAvatar(xr: NeedleXRSession) {
345
463
  if (this._playerSync && xr.running && typeof this.defaultAvatar != "boolean") {
346
464
  this._playerSync.asset = this.defaultAvatar;
@@ -348,6 +466,11 @@
348
466
  }
349
467
  }
350
468
 
469
+ /**
470
+ * Event handler called when a player avatar is spawned.
471
+ * Ensures the avatar has the necessary Avatar component.
472
+ * @param instance The spawned avatar 3D object
473
+ */
351
474
  private onAvatarSpawned = (instance: Object3D) => {
352
475
  // spawned webxr avatars must have a avatar component
353
476
  if (debug) console.log("WebXR.onAvatarSpawned", instance);
@@ -360,13 +483,16 @@
360
483
 
361
484
  // HTML UI
362
485
 
363
- /** @deprecated use `getButtonsFactory()` or access `WebXRButtonFactory.getOrCreate()` directory */
486
+ /** @deprecated use {@link getButtonsFactory} or directly access {@link WebXRButtonFactory.getOrCreate} */
364
487
  getButtonsContainer(): WebXRButtonFactory {
365
488
  return this.getButtonsFactory();
366
489
  }
367
490
 
368
- /** Calling this function will get the Needle WebXR button factory (it will be created if it doesnt exist yet)
369
- * @returns the Needle WebXR button factory */
491
+ /**
492
+ * Returns the WebXR button factory, creating one if it doesn't exist.
493
+ * Use this to access and modify WebXR UI buttons.
494
+ * @returns The WebXRButtonFactory instance
495
+ */
370
496
  getButtonsFactory(): WebXRButtonFactory {
371
497
  if (!this._buttonFactory) {
372
498
  this._buttonFactory = WebXRButtonFactory.getOrCreate();
@@ -374,8 +500,15 @@
374
500
  return this._buttonFactory;
375
501
  }
376
502
 
503
+ /**
504
+ * Reference to the WebXR button factory used by this component.
505
+ */
377
506
  private _buttonFactory?: WebXRButtonFactory;
378
507
 
508
+ /**
509
+ * Creates and sets up UI elements for WebXR interaction based on component settings
510
+ * and device capabilities. Handles creating AR, VR, QuickLook buttons and utility buttons like QR codes.
511
+ */
379
512
  private handleCreatingHTML() {
380
513
  const xrButtonsPriority = 50;
381
514
 
@@ -423,14 +556,25 @@
423
556
  }
424
557
  }
425
558
 
559
+ /**
560
+ * Storage for UI buttons created by this component.
561
+ */
426
562
  private readonly _buttons: HTMLElement[] = [];
427
563
 
564
+ /**
565
+ * Adds a button to the UI with the specified priority.
566
+ * @param button The HTML element to add
567
+ * @param priority The button's priority value (lower numbers appear first)
568
+ */
428
569
  private addButton(button: HTMLElement, priority: number) {
429
570
  this._buttons.push(button);
430
571
  button.setAttribute("priority", priority.toString());
431
572
  this.context.menu.appendChild(button);
432
573
  }
433
574
 
575
+ /**
576
+ * Removes all buttons created by this component from the UI.
577
+ */
434
578
  private removeButtons() {
435
579
  for (const button of this._buttons) {
436
580
  button.remove();