Needle Engine

Changes between version 3.40.0-alpha.5 and 3.41.0-alpha
Files changed (22) hide show
  1. src/engine-components/Animation.ts +20 -1
  2. src/engine-components/AnimationUtils.ts +11 -53
  3. src/engine-components/Animator.ts +6 -1
  4. src/engine-components/AnimatorController.ts +20 -0
  5. src/engine/api.ts +3 -0
  6. src/engine-components/ContactShadows.ts +10 -2
  7. src/engine/engine_context.ts +5 -0
  8. src/engine/engine_element_attributes.ts +6 -1
  9. src/engine/engine_lifecycle_api.ts +15 -15
  10. src/engine/engine_lifecycle_functions_internal.ts +54 -12
  11. src/engine/engine_scenetools.ts +40 -16
  12. src/engine/engine_three_utils.ts +62 -4
  13. src/engine/engine_types.ts +6 -1
  14. src/engine/engine_utils_format.ts +8 -2
  15. src/engine/webcomponents/needle menu/needle-menu.ts +15 -2
  16. src/engine-components/timeline/PlayableDirector.ts +3 -1
  17. src/engine-components/timeline/TimelineTracks.ts +4 -0
  18. src/engine/engine_animation.ts +138 -0
  19. src/engine/engine_test_utils.ts +108 -0
  20. src/engine/export/gltf/index.ts +177 -0
  21. src/engine/export/index.ts +2 -0
  22. src/engine/export/state.ts +20 -0
src/engine-components/Animation.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import { getParam } from "../engine/engine_utils.js";
6
6
  import { MixerEvent } from "./Animator.js";
7
7
  import { Behaviour } from "./Component.js";
8
+ import { IAnimationComponent } from "../needle-engine.js";
8
9
 
9
10
  const debug = getParam("debuganimation");
10
11
 
@@ -24,8 +25,14 @@
24
25
  /**
25
26
  * Animation component to play animations on a GameObject
26
27
  */
27
- export class Animation extends Behaviour {
28
+ export class Animation extends Behaviour implements IAnimationComponent {
28
29
 
30
+ get isAnimationComponent(): boolean { return true; }
31
+ addClip(clip: AnimationClip) {
32
+ if (!this.animations) this.animations = [];
33
+ this.animations.push(clip);
34
+ }
35
+
29
36
  @serializable()
30
37
  playAutomatically: boolean = true;
31
38
  @serializable()
@@ -131,6 +138,17 @@
131
138
  }
132
139
  }
133
140
 
141
+ onDisable(): void {
142
+ if (this.mixer) {
143
+ this.mixer.stopAllAction();
144
+ this.mixer = undefined;
145
+ }
146
+ }
147
+
148
+ onDestroy(): void {
149
+ this.context.animations.unregisterAnimationMixer(this.mixer);
150
+ }
151
+
134
152
  start() {
135
153
  if (this.randomStartTime && this.currentAction)
136
154
  this.currentAction.time = Math.random() * this.currentAction.getClip().duration;
@@ -258,6 +276,7 @@
258
276
  if (!this.gameObject) return;
259
277
  this.actions = [];
260
278
  this.mixer = new AnimationMixer(this.gameObject);
279
+ this.context.animations.registerAnimationMixer(this.mixer);
261
280
  }
262
281
  }
263
282
 
src/engine-components/AnimationUtils.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { Object3D, PropertyBinding } from "three";
2
2
  import type { GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
3
3
 
4
- import { addNewComponent } from "../engine/engine_components.js";
5
4
  import { ContextEvent, ContextRegistry } from "../engine/engine_context_registry.js";
6
5
  import { Animation } from "./Animation.js";
7
6
  import { Animator } from "./Animator.js";
8
7
  import { GameObject } from "./Component.js";
9
8
  import { PlayableDirector } from "./timeline/PlayableDirector.js";
9
+ import { AnimationUtils } from "../engine/engine_animation.js";
10
+ import { addComponent, addNewComponent } from "../engine/engine_components.js";
10
11
 
11
12
 
12
13
  const $objectAnimationKey = Symbol("objectIsAnimatedData");
@@ -37,6 +38,8 @@
37
38
  return set !== undefined && set.size > 0;
38
39
  }
39
40
 
41
+
42
+
40
43
  ContextRegistry.registerCallback(ContextEvent.ContextCreated, args => {
41
44
  const autoplay = args.context.domElement.getAttribute("autoplay");
42
45
  if (autoplay !== undefined && (autoplay === "" || autoplay === "true" || autoplay === "1")) {
@@ -57,61 +60,16 @@
57
60
  }
58
61
  return undefined;
59
62
  }, true);
60
- if (hasAnimation !== true)
61
- findAnimations(file.file as GLTF);
63
+ if (hasAnimation !== true) {
64
+ AnimationUtils.assignAnimationsFromFile(file.file as GLTF, {
65
+ createAnimationComponent: (obj, _clip) => {
66
+ return addComponent(obj, Animation);
67
+ },
68
+ });
69
+ }
62
70
  }
63
71
  }
64
72
  }
65
73
  });
66
74
 
67
75
 
68
- function findAnimations(gltf: GLTF) {
69
- if (!gltf || !gltf.animations) return;
70
-
71
-
72
- for (let i = 0; i < gltf.animations.length; i++) {
73
- const animation = gltf.animations[i];
74
- if (!animation.tracks || animation.tracks.length <= 0) continue;
75
- for (const t in animation.tracks) {
76
- const track = animation.tracks[t];
77
- // TODO use PropertyBinding API directly, needs testing
78
- const parsedPath = PropertyBinding.parseTrackName(track.name);
79
- let obj = PropertyBinding.findNode(gltf.scene, parsedPath.nodeName);
80
-
81
- if (!obj) {
82
- const objectName = track["__objectName"] ?? track.name.substring(0, track.name.indexOf("."));
83
- // let obj = gltf.scene.getObjectByName(objectName);
84
- // this finds unnamed objects that still have tracks targeting them
85
- obj = gltf.scene.getObjectByProperty('uuid', objectName);
86
-
87
- if (!obj) {
88
- // console.warn("could not find " + objectName, animation, gltf.scene);
89
- continue;
90
- }
91
- }
92
- let animationComponent = findAnimationGameObjectInParent(obj);
93
- if (!animationComponent) {
94
- animationComponent = addNewComponent(gltf.scene, new Animation());
95
- }
96
- const animations = animationComponent.animations = animationComponent.animations || [];
97
- animation["name_animator"] = animationComponent.name;
98
- if (animations.indexOf(animation) < 0) {
99
- animations.push(animation);
100
- }
101
- }
102
- }
103
- function findAnimationGameObjectInParent(obj) {
104
- if (!obj) return;
105
- const components = obj.userData?.components;
106
- if (components && components.length > 0) {
107
- for (let i = 0; i < components.length; i++) {
108
- const component = components[i];
109
- // console.log(component);
110
- if (component instanceof Animator || component instanceof Animation) {
111
- return obj;;
112
- }
113
- }
114
- }
115
- return findAnimationGameObjectInParent(obj.parent);
116
- }
117
- }
src/engine-components/Animator.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  import { getObjectAnimated } from "./AnimationUtils.js";
8
8
  import { AnimatorController } from "./AnimatorController.js";
9
9
  import { Behaviour } from "./Component.js";
10
+ import { IAnimationComponent } from "../needle-engine.js";
10
11
 
11
12
  const debug = getParam("debuganimator");
12
13
 
@@ -26,8 +27,12 @@
26
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)
27
28
  * A new AnimatorController can be created from code via `AnimatorController.createFromClips`
28
29
  */
29
- export class Animator extends Behaviour {
30
+ export class Animator extends Behaviour implements IAnimationComponent {
30
31
 
32
+ get isAnimationComponent() {
33
+ return true;
34
+ }
35
+
31
36
  @serializable()
32
37
  applyRootMotion: boolean = false;
33
38
  @serializable()
src/engine-components/AnimatorController.ts CHANGED
@@ -235,6 +235,21 @@
235
235
  return this._mixer;
236
236
  }
237
237
 
238
+ /**
239
+ * Clears the animation mixer and unregisters it from the context.
240
+ */
241
+ dispose() {
242
+ this._mixer.stopAllAction();
243
+ if (this.animator) {
244
+ this._mixer.uncacheRoot(this.animator.gameObject);
245
+ for (const action of this._activeStates) {
246
+ if (action.motion.clip)
247
+ this.mixer.uncacheAction(action.motion.clip, this.animator.gameObject);
248
+ }
249
+ }
250
+ this.context?.animations.unregisterAnimationMixer(this._mixer);
251
+ }
252
+
238
253
  // applyRootMotion(obj: Object3D) {
239
254
  // // this.internalApplyRootMotion(obj);
240
255
  // }
@@ -243,8 +258,13 @@
243
258
  bind(animator: Animator) {
244
259
  if (!animator) console.error("AnimatorController.bind: animator is null");
245
260
  else if (this.animator !== animator) {
261
+ if (this._mixer) {
262
+ this._mixer.stopAllAction();
263
+ this.context?.animations.unregisterAnimationMixer(this._mixer);
264
+ }
246
265
  this.animator = animator;
247
266
  this._mixer = new AnimationMixer(this.animator.gameObject);
267
+ this.context?.animations.registerAnimationMixer(this._mixer);
248
268
  this.createActions(this.animator);
249
269
  }
250
270
  }
src/engine/api.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  export * from "./engine_element.js";
16
16
  export * from "./engine_element_attributes.js";
17
17
  export * from "./engine_element_loading.js";
18
+ export * from "./export/index.js";
18
19
  export * from "./engine_gameobject.js";
19
20
  export { Gizmos } from "./engine_gizmos.js"
20
21
  export * from "./engine_gltf.js";
@@ -42,9 +43,11 @@
42
43
  export * from "./engine_serialization.js";
43
44
  export { type ISerializable } from "./engine_serialization_core.js";
44
45
  export * from "./engine_texture.js";
46
+ export * from "./engine_test_utils.js";
45
47
  export * from "./engine_three_utils.js";
46
48
  export * from "./engine_time.js";
47
49
  export * from "./engine_time_utils.js";
50
+ export * from "./engine_utils_format.js";
48
51
  export * from "./engine_types.js";
49
52
  export { registerType, TypeStore } from "./engine_typestore.js";
50
53
  export { prefix, validate } from "./engine_util_decorator.js";
src/engine-components/ContactShadows.ts CHANGED
@@ -11,9 +11,17 @@
11
11
  import { getParam } from "../engine/engine_utils.js"
12
12
  import { setCustomVisibility } from "../engine/js-extensions/Layers.js";
13
13
  import { Behaviour, GameObject } from "./Component.js";
14
+ import { onStart } from "../engine/engine_lifecycle_api.js";
14
15
 
15
16
  const debug = getParam("debugcontactshadows");
16
17
 
18
+ onStart(ctx => {
19
+ const val = ctx.domElement.getAttribute("contactshadows");
20
+ if (val != undefined && val != "0" && val != "false") {
21
+ ContactShadows.auto(ctx);
22
+ }
23
+ })
24
+
17
25
  // Adapted from https://github.com/mrdoob/three.js/blob/master/examples/webgl_shadow_contact.html.
18
26
 
19
27
  // Improved with
@@ -114,7 +122,7 @@
114
122
  // expand box in all directions (except below ground)
115
123
  // 0.75 expands by 75% in each direction
116
124
  // The "32" is pretty much heuristically determined – adjusting the value until we don't get a visible border anymore.
117
- const expandFactor = Math.max(0.5, this.blur / 32);
125
+ const expandFactor = Math.max(1, this.blur / 32);
118
126
  const sizeX = box.max.x - box.min.x;
119
127
  const sizeZ = box.max.z - box.min.z;
120
128
  box.expandByVector(new Vector3(expandFactor * sizeX, 0, expandFactor * sizeZ));
@@ -330,7 +338,7 @@
330
338
  scene.overrideMaterial = null;
331
339
 
332
340
  const blurAmount = Math.max(this.blur, 0.05);
333
-
341
+
334
342
  // two-pass blur to reduce the artifacts
335
343
  this.blurShadow(blurAmount * 2);
336
344
  this.blurShadow(blurAmount * 0.5);
src/engine/engine_context.ts CHANGED
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { isDevEnvironment, LogType, showBalloonError, showBalloonMessage, showBalloonWarning } from './debug/index.js';
11
11
  import { Addressables } from './engine_addressables.js';
12
+ import { AnimationsRegistry } from './engine_animation.js';
12
13
  import { Application } from './engine_application.js';
13
14
  import { AssetDatabase } from './engine_assetdatabase.js';
14
15
  import { VERSION } from './engine_constants.js';
@@ -363,6 +364,8 @@
363
364
  private _camera: Camera | null = null;
364
365
 
365
366
  application: Application;
367
+ /** access animation mixer used by components in the scene */
368
+ animations: AnimationsRegistry;
366
369
  /** access timings (current frame number, deltaTime, timeScale, ...) */
367
370
  time: Time;
368
371
  input: Input;
@@ -420,6 +423,7 @@
420
423
  this.players = new PlayerViewManager(this);
421
424
  this.menu = new NeedleMenu(this);
422
425
  this.lodsManager = new LODsManager(this);
426
+ this.animations = new AnimationsRegistry(this);
423
427
 
424
428
 
425
429
  const resizeCallback = () => this._sizeChanged = true;
@@ -616,6 +620,7 @@
616
620
  this.renderer = null!;
617
621
  this.input.dispose();
618
622
  this.menu.onDestroy();
623
+ this.animations.onDestroy();
619
624
  for (const cb of this._disposeCallbacks) {
620
625
  try {
621
626
  cb();
src/engine/engine_element_attributes.ts CHANGED
@@ -24,7 +24,7 @@
24
24
  "ktx2DecoderPath"?: string;
25
25
 
26
26
  /** Add to prevent Needle Engine context from being disposed when the element is removed from the DOM */
27
- "keep-alive"? : boolean;
27
+ "keep-alive"?: boolean;
28
28
 
29
29
  addEventListener?(event: "ready", callback: (event: CustomEvent) => void): void;
30
30
  addEventListener?(event: "error", callback: (event: CustomEvent) => void): void;
@@ -53,6 +53,10 @@
53
53
  "environment-image"?: string,
54
54
  }
55
55
 
56
+ type ContactShadowAttributes = {
57
+ "contactshadows"?: boolean,
58
+ }
59
+
56
60
  /**
57
61
  * Available attributes for the `<needle-engine>` web component
58
62
  * @inheritdoc
@@ -62,4 +66,5 @@
62
66
  & Partial<Omit<HTMLElement, "style">>
63
67
  & LoadingAttributes
64
68
  & SkyboxAttributes
69
+ & ContactShadowAttributes
65
70
  ;
src/engine/engine_lifecycle_api.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { FrameEvent } from "./engine_context.js";
2
2
  import { ContextEvent } from "./engine_context_registry.js";
3
- import { type LifecycleMethod, registerFrameEventCallback, unregisterFrameEventCallback } from "./engine_lifecycle_functions_internal.js";
3
+ import { type LifecycleMethod, registerFrameEventCallback, unregisterFrameEventCallback, LifecycleMethodOptions } from "./engine_lifecycle_functions_internal.js";
4
4
 
5
5
 
6
6
  /**
@@ -15,16 +15,16 @@
15
15
  * }
16
16
  * ```
17
17
  * */
18
- export function onInitialized(cb: LifecycleMethod): () => void {
19
- registerFrameEventCallback(cb, ContextEvent.ContextCreated);
18
+ export function onInitialized(cb: LifecycleMethod, opts?: LifecycleMethodOptions): () => void {
19
+ registerFrameEventCallback(cb, ContextEvent.ContextCreated, opts);
20
20
  return () => unregisterFrameEventCallback(cb, ContextEvent.ContextCreated);
21
21
  }
22
22
  /**
23
23
  * Register a callback before the engine context is cleared.
24
24
  * This happens if e.g. `<needle-engine src>` changes
25
25
  */
26
- export function onClear(cb: LifecycleMethod): () => void {
27
- registerFrameEventCallback(cb, ContextEvent.ContextClearing);
26
+ export function onClear(cb: LifecycleMethod, opts?: LifecycleMethodOptions): () => void {
27
+ registerFrameEventCallback(cb, ContextEvent.ContextClearing, opts);
28
28
  return () => unregisterFrameEventCallback(cb, ContextEvent.ContextClearing);
29
29
  }
30
30
 
@@ -32,8 +32,8 @@
32
32
  * Register a callback in the engine before the context is destroyed
33
33
  * This happens once per context (before the context is destroyed)
34
34
  */
35
- export function onDestroy(cb: LifecycleMethod): () => void {
36
- registerFrameEventCallback(cb, ContextEvent.ContextDestroying);
35
+ export function onDestroy(cb: LifecycleMethod, opts?: LifecycleMethodOptions): () => void {
36
+ registerFrameEventCallback(cb, ContextEvent.ContextDestroying, opts);
37
37
  return () => unregisterFrameEventCallback(cb, ContextEvent.ContextDestroying);
38
38
  }
39
39
 
@@ -48,8 +48,8 @@
48
48
  * }
49
49
  * ```
50
50
  * */
51
- export function onStart(cb: LifecycleMethod): () => void {
52
- registerFrameEventCallback(cb, FrameEvent.Start);
51
+ export function onStart(cb: LifecycleMethod, opts?: LifecycleMethodOptions): () => void {
52
+ registerFrameEventCallback(cb, FrameEvent.Start, opts);
53
53
  return () => unregisterFrameEventCallback(cb, FrameEvent.Start);
54
54
  }
55
55
 
@@ -65,8 +65,8 @@
65
65
  * }
66
66
  * ```
67
67
  * */
68
- export function onUpdate(cb: LifecycleMethod): () => void {
69
- registerFrameEventCallback(cb, FrameEvent.Update);
68
+ export function onUpdate(cb: LifecycleMethod, opts?: LifecycleMethodOptions): () => void {
69
+ registerFrameEventCallback(cb, FrameEvent.Update, opts);
70
70
  return () => unregisterFrameEventCallback(cb, FrameEvent.Update);
71
71
  }
72
72
 
@@ -81,8 +81,8 @@
81
81
  * }
82
82
  * ```
83
83
  * */
84
- export function onBeforeRender(cb: LifecycleMethod): () => void {
85
- registerFrameEventCallback(cb, FrameEvent.OnBeforeRender);
84
+ export function onBeforeRender(cb: LifecycleMethod, opts?: LifecycleMethodOptions): () => void {
85
+ registerFrameEventCallback(cb, FrameEvent.OnBeforeRender, opts);
86
86
  return () => unregisterFrameEventCallback(cb, FrameEvent.OnBeforeRender);
87
87
  }
88
88
 
@@ -101,7 +101,7 @@
101
101
  * });
102
102
  * ```
103
103
  */
104
- export function onAfterRender(cb: LifecycleMethod): () => void {
105
- registerFrameEventCallback(cb, FrameEvent.OnAfterRender);
104
+ export function onAfterRender(cb: LifecycleMethod, opts?: LifecycleMethodOptions): () => void {
105
+ registerFrameEventCallback(cb, FrameEvent.OnAfterRender, opts);
106
106
  return () => unregisterFrameEventCallback(cb, FrameEvent.OnAfterRender);
107
107
  }
src/engine/engine_lifecycle_functions_internal.ts CHANGED
@@ -2,21 +2,44 @@
2
2
  import { ContextEvent } from "./engine_context_registry.js";
3
3
  import { safeInvoke } from "./engine_generic_utils.js";
4
4
 
5
- export declare type LifecycleMethod = (ctx: Context) => void;
6
5
  export declare type Event = ContextEvent | FrameEvent;
7
6
 
8
- const allMethods = new Map<Event, Array<LifecycleMethod>>();
7
+ /**
8
+ * A function that can be called during the Needle Engine frame event at a specific point
9
+ * @link https://engine.needle.tools/docs/scripting.html#special-lifecycle-hooks
10
+ */
11
+ export declare type LifecycleMethod = (ctx: Context) => void;
12
+ /**
13
+ * Options for `onStart(()=>{})` etc event hooks
14
+ * @link https://engine.needle.tools/docs/scripting.html#special-lifecycle-hooks
15
+ */
16
+ export declare type LifecycleMethodOptions = {
17
+ /**
18
+ * If true, the callback will only be called once
19
+ */
20
+ once?: boolean
21
+ };
22
+
23
+
24
+ declare type RegisteredLifecycleMethod = { method: LifecycleMethod, options: LifecycleMethodOptions };
25
+
26
+
27
+ const allMethods = new Map<Event, Array<RegisteredLifecycleMethod>>();
9
28
  const _started = new WeakSet<Context>();
10
29
 
30
+
11
31
  /** register a function to be called during the Needle Engine frame event at a specific point
12
32
  * @param cb the function to call
13
33
  * @param evt the event to call the function at
14
34
  */
15
- export function registerFrameEventCallback(cb: LifecycleMethod, evt: Event) {
35
+ export function registerFrameEventCallback(cb: LifecycleMethod, evt: Event, opts?: LifecycleMethodOptions) {
16
36
  if (!allMethods.has(evt)) {
17
- allMethods.set(evt, new Array<LifecycleMethod>());
37
+ allMethods.set(evt, new Array());
18
38
  }
19
- allMethods.get(evt)!.push(cb);
39
+ allMethods.get(evt)!.push({
40
+ method: cb,
41
+ options: { once: false, ...opts }
42
+ });
20
43
  }
21
44
  /**
22
45
  * unregister a function to be called during the Needle Engine frame event at a specific point
@@ -24,15 +47,24 @@
24
47
  export function unregisterFrameEventCallback(cb: LifecycleMethod, evt: Event) {
25
48
  const methods = allMethods.get(evt);
26
49
  if (methods) {
27
- const index = methods.indexOf(cb);
28
- if (index >= 0) {
29
- methods.splice(index, 1);
50
+ for (let i = 0; i < methods.length; i++) {
51
+ if (methods[i].method === cb) {
52
+ methods.splice(i, 1);
53
+ return;
54
+ }
30
55
  }
31
56
  }
32
57
 
33
58
  }
34
59
 
35
60
  export function invokeLifecycleFunctions(ctx: Context, evt: Event) {
61
+
62
+ // When a context is created, we need to reset the started state
63
+ // Because we want to invoke `onStart` again (even if it's the same context)
64
+ if (evt === ContextEvent.ContextCreated) {
65
+ _started.delete(ctx);
66
+ }
67
+
36
68
  const methods = allMethods.get(evt);
37
69
  if (methods) {
38
70
  if (methods.length > 0) {
@@ -50,14 +82,24 @@
50
82
  }
51
83
  }
52
84
 
53
- const bufferArray = new Array<LifecycleMethod>();
54
- function invoke(ctx: Context, methods: Array<LifecycleMethod>) {
85
+ const bufferArray = new Array<RegisteredLifecycleMethod>();
86
+ function invoke(ctx: Context, methods: Array<RegisteredLifecycleMethod>) {
55
87
  bufferArray.length = 0;
56
88
  for (let i = 0; i < methods.length; i++) {
57
89
  bufferArray.push(methods[i]);
58
90
  }
59
91
  for (let i = 0; i < bufferArray.length; i++) {
60
- const method = bufferArray[i];
61
- safeInvoke(method, ctx);
92
+ const entry = bufferArray[i];
93
+ safeInvoke(entry.method, ctx);
94
+
95
+ // Remove the method if it's a one time call
96
+ if (entry.options?.once) {
97
+ for (let j = 0; j < methods.length; j++) {
98
+ if (methods[j] === entry) {
99
+ methods.splice(j, 1);
100
+ break;
101
+ }
102
+ }
103
+ }
62
104
  }
63
105
  }
src/engine/engine_scenetools.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Camera, Mesh, MeshPhongMaterial, MeshStandardMaterial, Object3D, Scene } from "three";
1
+ import { Camera, Loader, Material, Mesh, Object3D } from "three";
2
2
  import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js'
3
3
  import { type GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
4
4
  import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
@@ -11,11 +11,12 @@
11
11
  import { registerPrewarmObject } from "./engine_mainloop_utils.js";
12
12
  import { SerializationContext } from "./engine_serialization_core.js";
13
13
  import { Context } from "./engine_setup.js"
14
- import { type SourceIdentifier, type UIDProvider } from "./engine_types.js";
14
+ import { type UIDProvider } from "./engine_types.js";
15
15
  import * as utils from "./engine_utils.js";
16
16
  import { invokeAfterImportPluginHooks, registerComponentExtension, registerExtensions } from "./extensions/extensions.js";
17
17
  import { NEEDLE_components } from "./extensions/NEEDLE_components.js";
18
- import { FileType, tryDetermineFileTypeFromBinary, tryDetermineFileTypeFromURL } from "./engine_utils_format.js"
18
+ import { FileType, tryDetermineFileTypeFromURL } from "./engine_utils_format.js"
19
+ import { postprocessFBXMaterials } from "./engine_three_utils.js";
19
20
 
20
21
  /** @internal */
21
22
  export class NeedleGltfLoader implements INeedleGltfLoader {
@@ -167,6 +168,7 @@
167
168
  // Handle any other loader that is not a GLTFLoader
168
169
  if (!(loader instanceof GLTFLoader)) {
169
170
  const res = loader.parse(data, path);
171
+ postprocessLoadedFile(loader, res);
170
172
  return {
171
173
  animations: res.animations,
172
174
  scene: res,
@@ -242,19 +244,7 @@
242
244
  // Handle any loader that is not a GLTFLoader
243
245
  if (!(loader instanceof GLTFLoader)) {
244
246
  const res = await loader.loadAsync(url, prog);
245
- res.traverseVisible(obj => {
246
- // HACK: for FBXLoader loading all materials as MeshPhongMaterial
247
- if (obj instanceof Mesh && obj.material instanceof MeshPhongMaterial) {
248
- const prev = obj.material;
249
- obj["material:source"] = prev;
250
- obj.material = new MeshStandardMaterial();
251
- for (const key of Object.keys(prev)) {
252
- if (key === "type") continue;
253
- if (obj.material[key] !== undefined)
254
- obj.material[key] = prev[key];
255
- }
256
- }
257
- });
247
+ postprocessLoadedFile(loader, res);
258
248
  return {
259
249
  animations: res.animations,
260
250
  scene: res,
@@ -325,3 +315,37 @@
325
315
  a.click();
326
316
  }
327
317
  }
318
+
319
+
320
+
321
+
322
+ function postprocessLoadedFile(loader: Loader, result: Object3D | GLTF) {
323
+
324
+ if (result instanceof Object3D) {
325
+
326
+ if (loader instanceof FBXLoader) {
327
+ result.traverse(child => {
328
+
329
+ // See https://github.com/needle-tools/three.js/blob/b8df3843ff123ac9dc0ed0d3ccc5b568f840c804/examples/webgl_loader_multiple.html#L377
330
+ if (child instanceof Mesh) {
331
+ postprocessFBXMaterials(child, child.material as Material);
332
+ }
333
+ });
334
+ }
335
+ else if (loader instanceof OBJLoader) {
336
+
337
+ result.traverse(_child => {
338
+
339
+ // TODO: Needs testing
340
+
341
+ // if (!(child instanceof Mesh)) return;
342
+
343
+ // child.material = new MeshStandardMaterial();
344
+
345
+ });
346
+ }
347
+
348
+
349
+ }
350
+ }
351
+
src/engine/engine_three_utils.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { AnimationAction, Box3, Box3Helper, Euler, GridHelper, Mesh, Object3D, PerspectiveCamera, PlaneGeometry, Quaternion, Scene, ShadowMaterial, Texture, Uniform, Vector3 } from "three";
1
+ import { AnimationAction, Box3, Box3Helper, Euler, GridHelper, Material, Mesh, MeshStandardMaterial, Object3D, PerspectiveCamera, PlaneGeometry, Quaternion, Scene, ShadowMaterial, Texture, Uniform, Vector3 } from "three";
2
2
  import { ShaderMaterial, WebGLRenderer } from "three";
3
3
  import { GroundedSkybox } from "three/examples/jsm/objects/GroundedSkybox.js";
4
4
 
@@ -34,7 +34,7 @@
34
34
  export function lookAtObject(object: Object3D, target: Object3D, keepUpDirection: boolean = true, copyTargetRotation: boolean = false) {
35
35
  if (object === target) return;
36
36
  _tempQuat.copy(object.quaternion);
37
-
37
+
38
38
  const lookTarget = getWorldPosition(target);
39
39
  const lookFrom = getWorldPosition(object);
40
40
 
@@ -47,7 +47,7 @@
47
47
  forwardPoint.y = ypos;
48
48
  object.lookAt(forwardPoint);
49
49
  }
50
-
50
+
51
51
  // sanitize – three.js sometimes returns NaN values when parents are non-uniformly scaled
52
52
  if (Number.isNaN(object.quaternion.x)) {
53
53
  object.quaternion.copy(_tempQuat);
@@ -541,4 +541,62 @@
541
541
  }
542
542
 
543
543
  return box;
544
- }
544
+ }
545
+
546
+
547
+ /**
548
+ * Postprocesses the material of an object loaded by THREE.FBXLoader. It will apply some conversions to the material and will assign a MeshStandardMaterial to the object.
549
+ */
550
+ export function postprocessFBXMaterials(obj: Mesh, material: Material): boolean {
551
+
552
+ // ignore if the material is already a MeshStandardMaterial
553
+ if (material.type === "MeshStandardMaterial") {
554
+ return false;
555
+ }
556
+ // check if the material was already processed
557
+ else if (material["material:fbx"] != undefined) {
558
+ return true;
559
+ }
560
+
561
+ const newMaterial = new MeshStandardMaterial();
562
+ newMaterial["material:fbx"] = material;
563
+
564
+ const oldMaterial = material as any;
565
+
566
+ if (oldMaterial) {
567
+ // If a map is present then the FBX color should be ignored
568
+ // Tested e.g. in Unity and Substance Stager
569
+ // https://docs.unity3d.com/2020.1/Documentation/Manual/FBXImporter-Materials.html#:~:text=If%20a%20diffuse%20Texture%20is%20set%2C%20it%20ignores%20the%20diffuse%20color%20(this%20matches%20how%20it%20works%20in%20Autodesk®%20Maya®%20and%20Autodesk®%203ds%20Max®)
570
+ if (!oldMaterial.map)
571
+ newMaterial.color.copyLinearToSRGB(oldMaterial.color);
572
+ else newMaterial.color.set(1, 1, 1);
573
+
574
+ newMaterial.emissive.copyLinearToSRGB(oldMaterial.emissive);
575
+
576
+ newMaterial.emissiveIntensity = oldMaterial.emissiveIntensity;
577
+ newMaterial.opacity = oldMaterial.opacity;
578
+ newMaterial.displacementScale = oldMaterial.displacementScale;
579
+ newMaterial.transparent = oldMaterial.transparent;
580
+ newMaterial.bumpMap = oldMaterial.bumpMap;
581
+ newMaterial.aoMap = oldMaterial.aoMap;
582
+ newMaterial.map = oldMaterial.map;
583
+ newMaterial.displacementMap = oldMaterial.displacementMap;
584
+ newMaterial.emissiveMap = oldMaterial.emissiveMap;
585
+ newMaterial.normalMap = oldMaterial.normalMap;
586
+ newMaterial.envMap = oldMaterial.envMap;
587
+ newMaterial.alphaMap = oldMaterial.alphaMap;
588
+ newMaterial.metalness = oldMaterial.reflectivity;
589
+ if (oldMaterial.shininess) {
590
+ // from blender source code
591
+ // https://github.com/blender/blender-addons/blob/5e66092bcbe0df6855b3fa814b4826add8b01360/io_scene_fbx/import_fbx.py#L1442
592
+ // https://github.com/blender/blender-addons/blob/main/io_scene_fbx/import_fbx.py#L2060
593
+ newMaterial.roughness = 1.0 - (Math.sqrt(oldMaterial.shininess) / 10.0);
594
+ }
595
+ newMaterial.needsUpdate = true;
596
+ }
597
+
598
+ obj.material = newMaterial;
599
+ return true;
600
+ }
601
+
602
+
src/engine/engine_types.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { QueryFilterFlags } from "@dimforge/rapier3d-compat";
2
- import { Camera, Color, Material, Mesh, Object3D, Quaternion, Ray, Scene, WebGLRenderer } from "three";
2
+ import { AnimationClip, Camera, Color, Material, Mesh, Object3D, Quaternion, Ray, Scene, WebGLRenderer } from "three";
3
3
  import { Vector3 } from "three";
4
4
  import { type GLTF as GLTF3 } from "three/examples/jsm/loaders/GLTFLoader.js";
5
5
 
@@ -211,6 +211,11 @@
211
211
 
212
212
  export type ICamera = CameraComponent;
213
213
 
214
+ export type IAnimationComponent = Pick<IComponent, "gameObject"> & {
215
+ isAnimationComponent: boolean;
216
+ addClip?(clip: AnimationClip);
217
+ }
218
+
214
219
  /** Interface for a camera controller component that can be attached to a camera to control it */
215
220
  export declare interface ICameraController {
216
221
  get isCameraController(): boolean;
src/engine/engine_utils_format.ts CHANGED
@@ -2,9 +2,15 @@
2
2
 
3
3
  const debug = getParam("debugfileformat");
4
4
 
5
+ /**
6
+ * The supported file types that can be determined by the engine. Used in {@link tryDetermineFileTypeFromURL} and {@link tryDetermineFileTypeFromBinary}
7
+ */
5
8
  export declare type FileType = "gltf" | "glb" | "fbx" | "obj" | "usdz" | "unknown";
6
9
 
7
-
10
+ /**
11
+ * Tries to determine the file type of a file from its URL
12
+ * This method does perform a range request to the server to get the first few bytes of the file
13
+ */
8
14
  export async function tryDetermineFileTypeFromURL(url: string): Promise<FileType> {
9
15
 
10
16
  // If the URL doesnt contain a filetype we need to check the header
@@ -21,7 +27,7 @@
21
27
  if (header?.ok) {
22
28
  const data = await header.arrayBuffer();
23
29
  const res = tryDetermineFileTypeFromBinary(data);
24
- if(debug) console.log("Determined file type from header", res);
30
+ if (debug) console.log("Determined file type from header", res);
25
31
  return res;
26
32
  }
27
33
 
src/engine/webcomponents/needle menu/needle-menu.ts CHANGED
@@ -29,6 +29,11 @@
29
29
  }
30
30
  }
31
31
 
32
+ declare type ButtonInfo = {
33
+ label: string,
34
+ onClick: (evt: Event) => void,
35
+ }
36
+
32
37
  export class NeedleMenu {
33
38
  private readonly _context: Context;
34
39
  private readonly _menu: NeedleMenuElement;
@@ -176,7 +181,7 @@
176
181
 
177
182
 
178
183
 
179
- appendChild(child: HTMLElement) {
184
+ appendChild(child: HTMLElement | ButtonInfo) {
180
185
  this._menu.appendChild(child);
181
186
  }
182
187
 
@@ -583,7 +588,15 @@
583
588
  }
584
589
  }
585
590
  }
586
- appendChild<T extends Node>(node: T): T {
591
+ appendChild<T extends Node>(node: T | ButtonInfo): T {
592
+
593
+ if (!(node instanceof Node)) {
594
+ const button = document.createElement("button");
595
+ button.textContent = node.label;
596
+ button.onclick = node.onClick;
597
+ node = button as unknown as T;
598
+ }
599
+
587
600
  const res = this.options.appendChild(node);
588
601
  return res;
589
602
  }
src/engine-components/timeline/PlayableDirector.ts CHANGED
@@ -569,8 +569,10 @@
569
569
  handler.mixer = binding.runtimeAnimatorController.mixer;
570
570
  }
571
571
  // If we can not get the mixer from the animator then create a new one
572
- if (!handler.mixer)
572
+ if (!handler.mixer) {
573
573
  handler.mixer = new AnimationMixer(binding.gameObject);
574
+ this.context.animations.registerAnimationMixer(handler.mixer);
575
+ }
574
576
  handler.clips.push(clip);
575
577
  // uncache because we want to create a new action
576
578
  // this is needed because if a clip is used multiple times in a track (or even multiple tracks)
src/engine-components/timeline/TimelineTracks.ts CHANGED
@@ -156,6 +156,10 @@
156
156
  this.mixer?.stopAllAction();
157
157
  }
158
158
 
159
+ onDestroy() {
160
+ this.director.context.animations.unregisterAnimationMixer(this.mixer);
161
+ }
162
+
159
163
  // Using this callback instead of onEnable etc
160
164
  // because we want to re-enable the animator when the director is at the end and wrap mode is set to none
161
165
  // in which case the director is stopped (but not disabled)
src/engine/engine_animation.ts ADDED
@@ -0,0 +1,138 @@
1
+ import { AnimationAction, AnimationClip, AnimationMixer, Object3D, PropertyBinding } from "three";
2
+ import type { Context } from "./engine_context";
3
+ import { GLTF, IAnimationComponent, IComponent } from "./engine_types";
4
+ import { foreachComponentEnumerator } from "./engine_gameobject";
5
+
6
+ /**
7
+ * Registry for animation related data. Use {@link registerAnimationMixer} to register an animation mixer instance.
8
+ * Can be accessed from {@link Context.animations} and is used internally e.g. when exporting GLTF files.
9
+ * @category Animation
10
+ */
11
+ export class AnimationsRegistry {
12
+
13
+ readonly context: Context
14
+ readonly mixers: AnimationMixer[] = []
15
+
16
+ constructor(context: Context) {
17
+ this.context = context;
18
+ }
19
+
20
+ /** @hidden @internal */
21
+ onDestroy() {
22
+ this.mixers.forEach(mixer => mixer.stopAllAction());
23
+ this.mixers.length = 0;
24
+ }
25
+
26
+ /**
27
+ * Register an animation mixer instance.
28
+ */
29
+ registerAnimationMixer(mixer: AnimationMixer): void {
30
+ if (!mixer) {
31
+ console.warn("AnimationsRegistry.registerAnimationMixer called with null or undefined mixer")
32
+ return;
33
+ }
34
+ if (this.mixers.includes(mixer)) return;
35
+ this.mixers.push(mixer);
36
+ }
37
+ /**
38
+ * Unregister an animation mixer instance.
39
+ */
40
+ unregisterAnimationMixer(mixer: AnimationMixer | null | undefined): void {
41
+ if (!mixer) return;
42
+ const index = this.mixers.indexOf(mixer);
43
+ if (index === -1) return;
44
+ this.mixers.splice(index, 1);
45
+ }
46
+
47
+ }
48
+
49
+
50
+ /**
51
+ * Utility class for working with animations.
52
+ * @category Animation
53
+ */
54
+ export class AnimationUtils {
55
+
56
+ /**
57
+ * Tries to get the animation actions from an animation mixer.
58
+ * @param mixer The animation mixer to get the actions from
59
+ * @returns The actions or null if the mixer is invalid
60
+ */
61
+ static tryGetActionsFromMixer(mixer: AnimationMixer): Array<AnimationAction> | null {
62
+ const actions = mixer["_actions"] as Array<AnimationAction>;
63
+ if (!actions) return null;
64
+ return actions;
65
+ }
66
+
67
+ static tryGetAnimationClipsFromObjectHierarchy(obj: Object3D, target?: Array<AnimationClip>): Array<AnimationClip> {
68
+ if (!target) target = new Array<AnimationClip>();
69
+
70
+ if (!obj) {
71
+ return target;
72
+ }
73
+ else if (obj.animations) {
74
+ target.push(...obj.animations);
75
+ }
76
+ if (obj.children) {
77
+ for (const child of obj.children) {
78
+ this.tryGetAnimationClipsFromObjectHierarchy(child, target);
79
+ }
80
+ }
81
+ return target;
82
+ }
83
+
84
+ /**
85
+ * Assigns animations from a GLTF file to the objects in the scene.
86
+ * This method will look for objects in the scene that have animations and assign them to the correct objects.
87
+ * @param file The GLTF file to assign the animations from
88
+ */
89
+ static assignAnimationsFromFile(file: Pick<GLTF, "animations" | "scene">, opts?: { createAnimationComponent(obj: Object3D, animation: AnimationClip): IAnimationComponent }) {
90
+ if (!file || !file.animations) return;
91
+
92
+ for (let i = 0; i < file.animations.length; i++) {
93
+ const animation = file.animations[i];
94
+ if (!animation.tracks || animation.tracks.length <= 0) continue;
95
+ for (const t in animation.tracks) {
96
+ const track = animation.tracks[t];
97
+ const parsedPath = PropertyBinding.parseTrackName(track.name);
98
+ let obj = PropertyBinding.findNode(file.scene, parsedPath.nodeName);
99
+ if (!obj) {
100
+ const objectName = track["__objectName"] ?? track.name.substring(0, track.name.indexOf("."));
101
+ // let obj = gltf.scene.getObjectByName(objectName);
102
+ // this finds unnamed objects that still have tracks targeting them
103
+ obj = file.scene.getObjectByProperty('uuid', objectName);
104
+
105
+ if (!obj) {
106
+ // console.warn("could not find " + objectName, animation, gltf.scene);
107
+ continue;
108
+ }
109
+ }
110
+
111
+ let animationComponent = findAnimationGameObjectInParent(obj);
112
+ if (!animationComponent) {
113
+ if (!opts?.createAnimationComponent) {
114
+ console.warn("No AnimationComponent found in parent hierarchy of object and no 'createAnimationComponent' callback was provided in options.")
115
+ continue;
116
+ }
117
+ animationComponent = opts.createAnimationComponent(file.scene, animation)
118
+ }
119
+ if (animationComponent.addClip) {
120
+ animationComponent.addClip(animation);
121
+ }
122
+ }
123
+ }
124
+ function findAnimationGameObjectInParent(obj): IAnimationComponent | null {
125
+ if (!obj) return null;
126
+ const components = obj.userData?.components;
127
+ if (components && components.length > 0) {
128
+ for (let i = 0; i < components.length; i++) {
129
+ const component = components[i] as IAnimationComponent;
130
+ if (component.isAnimationComponent === true) {
131
+ return obj;
132
+ }
133
+ }
134
+ }
135
+ return findAnimationGameObjectInParent(obj.parent);
136
+ }
137
+ }
138
+ }
src/engine/engine_test_utils.ts ADDED
@@ -0,0 +1,108 @@
1
+ import { PerspectiveCamera, Scene, Vector3, Object3D, EquirectangularReflectionMapping, DataTextureLoader } from "three";
2
+ import { AssetReference } from "./engine_addressables.js";
3
+ import { getBoundingBox } from "./engine_three_utils.js";
4
+ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
5
+
6
+ declare type ComparisonSceneOptions = {
7
+ /**
8
+ * An array of model urls to load
9
+ */
10
+ files: string[];
11
+ /**
12
+ * Optional dom element to attach the orbit controls to. By default this should be the WebGLRenderer.domElement
13
+ */
14
+ domElement?: HTMLElement;
15
+ /**
16
+ * Can be a .hdr or .exr file url
17
+ */
18
+ environment?: string;
19
+ }
20
+
21
+ /**
22
+ * A collection of utility methods for quickly spinning up test environments
23
+ */
24
+ export class TestSceneUtils {
25
+
26
+ /**
27
+ * Use this method to quickly setup a scene to compare multiple models.
28
+ * @example
29
+ * ```ts
30
+ * const files = [
31
+ * "https://threejs.org/examples/models/gltf/RobotExpressive/RobotExpressive.glb",
32
+ * "https://threejs.org/examples/models/gltf/Lantern/glTF-Binary/Lantern.glb",
33
+ * ];
34
+ * const { scene, camera } = await TestUtils.createComparisonScene({ files });
35
+ * // this could now be assigned to the Needle Engine Context
36
+ * context.scene = scene;
37
+ * context.mainCamera = camera;
38
+ * ```
39
+ */
40
+ static async createComparisonScene(opts: ComparisonSceneOptions) {
41
+
42
+ const { files } = opts;
43
+
44
+ const promises = Promise.all(files.map(file => new AssetReference(file).loadAssetAsync()));
45
+ const results = await promises;
46
+ const scene = new Scene();
47
+
48
+ let offset = 0;
49
+ for (const result of results) {
50
+ if (result instanceof Object3D) {
51
+ result.position.y = offset;
52
+ scene.add(result);
53
+ const box = getBoundingBox([result]);
54
+ offset += box.getSize(new Vector3()).y;
55
+ offset += .1;
56
+ }
57
+ }
58
+ const camera = new PerspectiveCamera(20);
59
+ scene.add(camera);
60
+
61
+ // Load an environment map
62
+ let environmentUrl = opts.environment || "https://dl.polyhaven.org/file/ph-assets/HDRIs/exr/1k/studio_small_09_1k.exr";
63
+ if (environmentUrl) {
64
+ let loader: DataTextureLoader | null = null;
65
+ if (environmentUrl.endsWith(".hdr")) {
66
+ const RGBELoader = (await import("three/examples/jsm/loaders/RGBELoader.js")).RGBELoader;
67
+ loader = new RGBELoader();
68
+ }
69
+ else if (environmentUrl.endsWith(".exr")) {
70
+ const EXRLoader = (await import("three/examples/jsm/loaders/EXRLoader.js")).EXRLoader;
71
+ loader = new EXRLoader();
72
+ }
73
+ if (loader) {
74
+ const envmap = await loader.loadAsync(environmentUrl).catch((e) => { console.error(e); return null; });
75
+ if (envmap) {
76
+ envmap.mapping = EquirectangularReflectionMapping;
77
+ envmap.needsUpdate = true;
78
+ scene.background = envmap;
79
+ scene.environment = envmap;
80
+ scene.backgroundBlurriness = .75;
81
+ }
82
+ }
83
+ else console.warn("Unsupported environment map format", environmentUrl);
84
+ }
85
+
86
+ const box = getBoundingBox(scene.children);
87
+ const center = box.getCenter(new Vector3());
88
+ const size = box.getSize(new Vector3());
89
+ const max = Math.max(size.x, size.y, size.z);
90
+ const distance = max / (2 * Math.tan(Math.PI * camera.fov / 360));
91
+ camera.position.set(center.x, center.y, distance);
92
+ camera.lookAt(center);
93
+
94
+ const orbit = new OrbitControls(camera, opts.domElement || document.body);
95
+ orbit.target = center;
96
+ orbit.update();
97
+
98
+
99
+ const element = (opts.domElement || document.body).getBoundingClientRect();
100
+ camera.aspect = element.width / element.height;
101
+ camera.updateProjectionMatrix();
102
+
103
+ return {
104
+ scene,
105
+ camera
106
+ }
107
+ }
108
+ }
src/engine/export/gltf/index.ts ADDED
@@ -0,0 +1,177 @@
1
+ import { AnimationAction, AnimationClip, Material, Mesh, Object3D, Texture } from "three";
2
+ import type { Context } from "../../engine_setup";
3
+ import { GLTFExporter, GLTFExporterOptions } from "three/examples/jsm/exporters/GLTFExporter";
4
+ import GLTFMeshGPUInstancingExtension from "../../../include/three/EXT_mesh_gpu_instancing_exporter";
5
+ import { registerExportExtensions } from "../../extensions";
6
+ import { AnimationUtils } from "../../engine_animation";
7
+ import { __isExporting } from "../state";
8
+
9
+ declare type ExportOptions = {
10
+ context: Context,
11
+ scene?: Object3D | Array<Object3D>,
12
+ binary?: boolean,
13
+ animations?: boolean,
14
+ downloadAs?: string,
15
+ }
16
+
17
+ const DEFAULT_OPTIONS: Omit<ExportOptions, "context" | "scene"> = {
18
+ binary: true,
19
+ animations: true,
20
+ }
21
+
22
+ export async function exportAsGLTF(_opts: ExportOptions): Promise<ArrayBuffer | Record<string, any>> {
23
+
24
+ if (!_opts.context) {
25
+ throw new Error("No context provided to exportAsGLTF");
26
+ }
27
+
28
+ if (!_opts.scene) {
29
+ _opts.scene = _opts.context.scene;
30
+ }
31
+
32
+ const opts = {
33
+ ...DEFAULT_OPTIONS,
34
+ ..._opts
35
+ } as Required<ExportOptions>;
36
+
37
+ const { context } = opts;
38
+
39
+ const exporter = new GLTFExporter();
40
+ exporter.register(writer => new GLTFMeshGPUInstancingExtension(writer));
41
+ registerExportExtensions(exporter, opts.context);
42
+
43
+ const exporterOptions: GLTFExporterOptions = {
44
+ binary: opts.binary,
45
+ animations: collectAnimations(context, opts.scene, []),
46
+ }
47
+ const state = new ExporterState();
48
+
49
+ console.log("Exporting GLTF", exporterOptions);
50
+ state.onBeforeExport(opts);
51
+ __isExporting(true);
52
+ const res = await exporter.parseAsync(opts.scene, exporterOptions).catch((e) => {
53
+ console.error(e);
54
+ return null;
55
+ });
56
+ __isExporting(false);
57
+ state.onAfterExport(opts);
58
+
59
+ if (!res) {
60
+ throw new Error("Failed to export GLTF");
61
+ }
62
+
63
+ if (opts.downloadAs != undefined) {
64
+ let blob: Blob | null = null;
65
+ if (res instanceof ArrayBuffer) {
66
+ blob = new Blob([res], { type: "application/octet-stream" });
67
+ }
68
+ else {
69
+ console.error("Can not download GLTF as a blob", res);
70
+ }
71
+
72
+ if (blob) {
73
+ const url = URL.createObjectURL(blob);
74
+ const a = document.createElement("a");
75
+ a.href = url;
76
+ let name = opts.downloadAs;
77
+ if (!name.endsWith(".glb") && !name.endsWith(".gltf")) {
78
+ name += opts.binary ? ".glb" : ".gltf";
79
+ }
80
+ a.download = name;
81
+ a.click();
82
+ }
83
+ }
84
+
85
+
86
+
87
+ return res;
88
+ }
89
+
90
+
91
+ const ACTIONS_WEIGHT_KEY = Symbol("needle:weight");
92
+
93
+ class ExporterState {
94
+
95
+ private readonly _undo: Array<() => void> = [];
96
+
97
+ onBeforeExport(opts: Required<ExportOptions>) {
98
+ opts.context.animations.mixers.forEach(mixer => {
99
+ const actions = AnimationUtils.tryGetActionsFromMixer(mixer);
100
+ if (actions) {
101
+ for (let i = 0; i < actions.length; i++) {
102
+ const action = actions[i];
103
+ action[ACTIONS_WEIGHT_KEY] = action.weight;
104
+ action.weight = 0;
105
+ this._undo.push(() => { action.weight = action[ACTIONS_WEIGHT_KEY]; });
106
+ }
107
+ }
108
+ mixer.update(0);
109
+ })
110
+
111
+ const objects = Array.isArray(opts.scene) ? opts.scene : [opts.scene];
112
+ objects.forEach(obj => {
113
+ if (!obj) return;
114
+ obj.traverse(o => {
115
+ if ((o as any).isMesh) {
116
+ const material = (o as Mesh).material;
117
+ if (Array.isArray(material)) {
118
+ for (const mat of material) {
119
+ this.fixMaterial(mat);
120
+ }
121
+ }
122
+ else {
123
+ this.fixMaterial(material);
124
+ }
125
+ }
126
+ });
127
+ });
128
+ }
129
+
130
+ private fixMaterial(mat: Material) {
131
+ // enumerate textures and make sure we don't export a WebGLRenderTarget texture
132
+ for (const prop in mat) {
133
+ const tex = mat[prop] as Texture | null | undefined;
134
+ if (!tex) continue;
135
+ if (tex.isRenderTargetTexture) {
136
+ mat[prop] = null;
137
+ this._undo.push(() => { mat[prop] = tex; });
138
+ }
139
+ }
140
+ }
141
+
142
+ onAfterExport(_opts: Required<ExportOptions>) {
143
+ this._undo.forEach(fn => fn());
144
+ this._undo.length = 0;
145
+ }
146
+
147
+ }
148
+
149
+
150
+ function collectAnimations(context: Context, scene: Object3D | Array<Object3D>, clips: Array<AnimationClip>): Array<AnimationClip> {
151
+
152
+ // Get all animations that are used by any mixer in the scene
153
+ // technically we might also collect animations here that aren't used by any object in the scene because they're part of another scene
154
+ // But that's a problem for later...
155
+ context.animations.mixers.forEach(mixer => {
156
+ const actions = AnimationUtils.tryGetActionsFromMixer(mixer);
157
+ if (actions) {
158
+ for (let i = 0; i < actions.length; i++) {
159
+ const action = actions[i];
160
+ const clip = action.getClip();
161
+ // TODO: might need to check if the clip is part of the scene that we want to export
162
+ clips.push(clip);
163
+ }
164
+ }
165
+ });
166
+
167
+ // Get all animations that are directly assigned to objects in the scene
168
+ if (!Array.isArray(scene)) scene = [scene];
169
+ for (const obj of scene) {
170
+ AnimationUtils.tryGetAnimationClipsFromObjectHierarchy(obj, clips);
171
+ }
172
+
173
+ // ensure we only have unique clips
174
+ const uniqueClips = new Set(clips);
175
+ return Array.from(uniqueClips);
176
+
177
+ }
src/engine/export/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./gltf/index.js";
2
+ export { isExporting } from "./state.js";
src/engine/export/state.ts ADDED
@@ -0,0 +1,20 @@
1
+
2
+
3
+ let exportingStack: number = 0;
4
+
5
+ /** @internal */
6
+ export function __isExporting(state: boolean) {
7
+ if (state) {
8
+ exportingStack++;
9
+ } else {
10
+ exportingStack--;
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Returns whether an export process is currently running.
16
+ * @returns True if an export process is currently running, false otherwise.
17
+ */
18
+ export function isExporting() {
19
+ return exportingStack > 0;
20
+ }