@@ -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
|
|
@@ -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
|
-
|
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
|
-
}
|
@@ -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()
|
@@ -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
|
}
|
@@ -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";
|
@@ -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(
|
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);
|
@@ -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();
|
@@ -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"
|
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
|
;
|
@@ -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
|
}
|
@@ -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
|
-
|
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
|
37
|
+
allMethods.set(evt, new Array());
|
18
38
|
}
|
19
|
-
allMethods.get(evt)!.push(
|
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
|
-
|
28
|
-
|
29
|
-
|
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<
|
54
|
-
function invoke(ctx: Context, methods: Array<
|
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
|
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
|
}
|
@@ -1,4 +1,4 @@
|
|
1
|
-
import { Camera,
|
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
|
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,
|
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
|
-
|
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
|
+
|
@@ -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
|
+
|
@@ -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;
|
@@ -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
|
|
@@ -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
|
}
|
@@ -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)
|
@@ -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)
|
@@ -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
|
+
}
|
@@ -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
|
+
}
|
@@ -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
|
+
}
|
@@ -0,0 +1,2 @@
|
|
1
|
+
export * from "./gltf/index.js";
|
2
|
+
export { isExporting } from "./state.js";
|
@@ -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
|
+
}
|