Needle Engine

Changes between version 3.26.2-beta and 3.27.0-beta
Files changed (14) hide show
  1. plugins/vite/facebook-instant-games.js +1 -1
  2. src/engine-components/export/usdz/extensions/Animation.ts +573 -141
  3. src/engine-components/export/usdz/utils/animationutils.ts +57 -91
  4. src/engine-components/AnimatorController.ts +2 -2
  5. src/engine-components/export/usdz/extensions/behavior/Behaviour.ts +8 -11
  6. src/engine-components/export/usdz/extensions/behavior/BehaviourComponents.ts +163 -45
  7. src/engine-components/export/usdz/extensions/behavior/BehavioursBuilder.ts +6 -4
  8. src/engine-components/Component.ts +3 -0
  9. src/engine/engine_gizmos.ts +17 -21
  10. src/engine/codegen/register_types.ts +2 -2
  11. src/engine-components/SceneSwitcher.ts +2 -1
  12. src/engine-components/export/usdz/ThreeUSDZExporter.ts +296 -53
  13. src/engine-components/export/usdz/USDZExporter.ts +92 -40
  14. src/engine-components/export/usdz/extensions/USDZText.ts +4 -3
plugins/vite/facebook-instant-games.js CHANGED
@@ -39,7 +39,7 @@
39
39
  if (userSettings.noFacebookInstantGames === true) return;
40
40
 
41
41
  // If the config is not present it means that we don't want to use fb instant games
42
- if (!config.facebookInstantGames) return;
42
+ if (!config || !config.facebookInstantGames) return;
43
43
 
44
44
  log("Setup Facebook Instant Games", config.facebookInstantGames);
45
45
 
src/engine-components/export/usdz/extensions/Animation.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  import { GameObject } from "../../../Component.js";
2
2
  import { getParam } from "../../../../engine/engine_utils.js";
3
-
4
- import { USDObject, buildMatrix } from "../ThreeUSDZExporter.js";
3
+ import { USDObject, buildMatrix, findStructuralNodesInBoneHierarchy, usdNumberFormatting as fn, getPathToSkeleton } from "../ThreeUSDZExporter.js";
5
4
  import type { IUSDExporterExtension } from "../Extension.js";
5
+ import { Object3D, Matrix4, Vector3, Quaternion, Interpolant, AnimationClip, KeyframeTrack, PropertyBinding, Bone } from "three";
6
+ import { Animator } from "../../../Animator.js";
6
7
 
7
- import { Object3D, Matrix4, Vector3, Quaternion, Interpolant, AnimationClip, KeyframeTrack, PropertyBinding } from "three";
8
-
9
8
  const debug = getParam("debugusdzanimation");
10
9
  const debugSerialization = getParam("debugusdzanimationserialization");
11
10
 
@@ -17,7 +16,13 @@
17
16
 
18
17
  export class RegisteredAnimationInfo {
19
18
 
20
- get start(): number { return this.ext.getStartTime01(this.root, this.clip); }
19
+ private _start?: number;
20
+ get start(): number {
21
+ if (this._start === undefined) {
22
+ this._start = this.ext.getStartTime01(this.root, this.clip);
23
+ }
24
+ return this._start;
25
+ }
21
26
  get duration(): number { return this.clip.duration; }
22
27
 
23
28
  private ext: AnimationExtension;
@@ -32,34 +37,50 @@
32
37
  }
33
38
 
34
39
  export class TransformData {
35
- clip: AnimationClip;
40
+ clip?: AnimationClip;
36
41
  pos?: KeyframeTrack;
37
42
  rot?: KeyframeTrack;
38
43
  scale?: KeyframeTrack;
39
44
  get frameRate(): number { return 60; }
40
45
 
41
- private ext: AnimationExtension;
42
- private root: Object3D;
46
+ private root: Object3D | null;
43
47
  private target: Object3D;
44
48
  private duration = 0;
49
+ private useRootMotion = false;
45
50
 
46
- constructor(ext: AnimationExtension, root: Object3D, target: Object3D, clip: AnimationClip) {
47
- this.ext = ext;
51
+ static animationDurationPadding = 1;
52
+
53
+ constructor(root: Object3D | null, target: Object3D, clip: AnimationClip | undefined) {
48
54
  this.root = root;
49
55
  this.target = target;
50
56
  this.clip = clip;
57
+
58
+ // this is a rest pose clip.
59
+ // we assume duration 1/60 and no tracks, and when queried for times we just return [0, duration]
60
+ if (!clip) {
61
+ this.duration = 1 / this.frameRate;
62
+ }
63
+ else {
64
+ this.duration = clip.duration;
65
+ }
66
+
67
+ const animator = GameObject.getComponent(root, Animator);
68
+ if (animator) this.useRootMotion = animator.applyRootMotion;
51
69
  }
52
70
 
53
71
  addTrack(track) {
72
+ if (!this.clip) {
73
+ console.error("This is a rest clip but you're trying to add tracks to it – this is likely a bug");
74
+ return;
75
+ }
76
+
54
77
  if (track.name.endsWith("position")) this.pos = track;
55
78
  if (track.name.endsWith("quaternion")) this.rot = track;
56
79
  if (track.name.endsWith("scale")) this.scale = track;
57
-
58
- // this.duration = Math.max(this.duration, track.times[track.times.length - 1]);
59
- this.duration = this.clip.duration;
60
80
  }
61
81
 
62
82
  getFrames(): number {
83
+ if (!this.clip) return 2;
63
84
  return Math.max(this.pos?.times?.length ?? 0, this.rot?.times?.length ?? 0, this.scale?.times?.length ?? 0);
64
85
  }
65
86
 
@@ -67,46 +88,81 @@
67
88
  return this.duration;
68
89
  }
69
90
 
70
- static animationDurationPadding = 1;
91
+ getSortedTimesArray(generatePos: boolean = true, generateRot: boolean = true, generateScale: boolean = true) {
92
+ if (!this.clip) return [0, this.duration];
71
93
 
72
- /*
73
- getStartTime(arr: TransformData[]): number {
74
- let sum = 0;
75
- for (let i = 0; i < arr.length; i++) {
76
- const entry = arr[i];
77
- if (entry === this) {
78
- return sum;
79
- }
80
- // we need some padding, otherwise we can end up with animations
81
- // written on the SAME time sample belonging to different clips
82
- else sum += entry.getDuration() + TransformData.animationDurationPadding;
83
- }
84
- return sum;
94
+ const posTimesArray = this.pos?.times;
95
+ const rotTimesArray = this.rot?.times;
96
+ const scaleTimesArray = this.scale?.times;
97
+
98
+ // timesArray is the sorted union of all time values
99
+ const timesArray: number[] = [];
100
+ if (generatePos && posTimesArray) for (const t of posTimesArray) timesArray.push(t);
101
+ if (generateRot && rotTimesArray) for (const t of rotTimesArray) timesArray.push(t);
102
+ if (generateScale && scaleTimesArray) for (const t of scaleTimesArray) timesArray.push(t);
103
+
104
+ // we also need to make sure we have start and end times for these tracks
105
+ // TODO seems we can't get track duration from the KeyframeTracks
106
+ if (!timesArray.includes(0)) timesArray.push(0);
107
+
108
+ // sort times so it's increasing
109
+ timesArray.sort((a, b) => a - b);
110
+ // make sure time values are unique
111
+ return [...new Set(timesArray)];
85
112
  }
86
- */
87
113
 
88
- getStartTime(dict: AnimationDict): number {
114
+ /**
115
+ * Returns an iterator that yields the values for each time sample.
116
+ * Values are reused objects - if you want to append them to some array
117
+ * instead of processing them right away, clone() them.
118
+ * @param timesArray
119
+ * @param generatePos
120
+ * @param generateRot
121
+ * @param generateScale
122
+ */
123
+ *getValues(timesArray: number[], generatePos: boolean = true, generateRot: boolean = true, generateScale: boolean = true) {
89
124
 
90
- // TODO this collection of clip slots should happen once and not for each node & track!
125
+ const translation = new Vector3();
126
+ const rotation = new Quaternion();
127
+ const scale = new Vector3(1, 1, 1);
128
+ const object = this.target;
91
129
 
92
- // collect all clips in dict
93
- // walk through clips until we find ours and sum up durations
94
- // to make sure each clip gets a unique slot
95
- const clips : Array<AnimationClip> = [];
96
- for (const [key, value] of dict) {
97
- for (const entry of value) {
98
- if (!clips.includes(entry.clip)) clips.push(entry.clip);
130
+ const positionInterpolant: Interpolant | undefined = generatePos ? this.pos?.createInterpolant() : undefined;
131
+ const rotationInterpolant: Interpolant | undefined = generateRot ? this.rot?.createInterpolant() : undefined;
132
+ const scaleInterpolant: Interpolant | undefined = generateScale ? this.scale?.createInterpolant() : undefined;
133
+
134
+ if (!positionInterpolant) translation.set(object.position.x, object.position.y, object.position.z);
135
+ if (!rotationInterpolant) rotation.set(object.quaternion.x, object.quaternion.y, object.quaternion.z, object.quaternion.w);
136
+ if (!scaleInterpolant) scale.set(object.scale.x, object.scale.y, object.scale.z);
137
+
138
+ for (let index = 0; index < timesArray.length; index++) {
139
+ const time = timesArray[index];
140
+
141
+ if (positionInterpolant) {
142
+ const pos = positionInterpolant.evaluate(time);
143
+ translation.set(pos[0], pos[1], pos[2]);
99
144
  }
100
- }
145
+ if (rotationInterpolant) {
146
+ const quat = rotationInterpolant.evaluate(time);
147
+ rotation.set(quat[0], quat[1], quat[2], quat[3]);
148
+ }
149
+ if (scaleInterpolant) {
150
+ const scaleVal = scaleInterpolant.evaluate(time);
151
+ scale.set(scaleVal[0], scaleVal[1], scaleVal[2]);
152
+ }
101
153
 
102
- if (debug) console.log(clips, this.clip);
154
+ // Apply basic root motion offset – non-animated transformation data is applied to the node again.
155
+ // We're doing this because clips that animate their own root are (typically) not in world space,
156
+ // but in local space and moved to a specific spot in the world.
157
+ if (this.useRootMotion && object === this.root) {
158
+ const rootMatrix = new Matrix4();
159
+ rootMatrix.compose(translation, rotation, scale);
160
+ rootMatrix.multiply(object.matrix);
161
+ rootMatrix.decompose(translation, rotation, scale);
162
+ }
103
163
 
104
- let sum = 0;
105
- for (const clip of clips) {
106
- if (clip === this.clip) break;
107
- sum += clip.duration + TransformData.animationDurationPadding;
164
+ yield { time, translation, rotation, scale, index };
108
165
  }
109
- return sum;
110
166
  }
111
167
  }
112
168
 
@@ -114,45 +170,79 @@
114
170
 
115
171
  export class AnimationExtension implements IUSDExporterExtension {
116
172
 
173
+
117
174
  get extensionName(): string { return "animation" }
175
+
118
176
  private dict: AnimationDict = new Map();
119
- // private rootTargetMap: Map<Object3D, Object3D[]> = new Map();
120
- private rootTargetMap: Map<Object3D, Object3D[]> = new Map();
177
+ private rootTargetMap = new Map<Object3D, Object3D[]>();
178
+ private rootAndClipToRegisteredAnimationMap = new Map<string, RegisteredAnimationInfo>();
179
+ private rootToRegisteredClipCount = new Map<Object3D, number>();
121
180
 
181
+ private serializers: SerializeAnimation[] = [];
182
+
183
+ // determines if we inject a rest pose clip for each root - only makes sense for QuickLook
184
+ injectRestPoses = false;
185
+ // determines if we inject a PlayAnimationOnClick component with "scenestart" trigger - only makes sense for QuickLook
186
+ injectImplicitBehaviours = false;
187
+
188
+ constructor(quickLookCompatible: boolean) {
189
+ this.injectRestPoses = quickLookCompatible;
190
+ this.injectImplicitBehaviours = quickLookCompatible;
191
+ }
192
+
193
+ getClipCount(root: Object3D): number {
194
+ // don't count the rest pose
195
+ let currentCount = this.rootToRegisteredClipCount.get(root);
196
+ if (this.injectRestPoses) currentCount = currentCount ? currentCount - 1 : 0;
197
+ return currentCount ?? 0;
198
+ }
199
+
200
+ // TODO why do we have this here and on TransformData? Can RegisteredAnimationInfo not cache this value?
201
+ // TODO we probably want to assert here that this is the same value on all nodes
122
202
  getStartTime01(root: Object3D, clip: AnimationClip) {
203
+
123
204
  const targets = this.rootTargetMap.get(root);
124
- if (!targets) return Infinity;
125
- let longestStartTime: number = -1;
126
- for (const target of targets) {
127
- if (debug) console.log(" " + target.name)
128
- const data = this.dict.get(target);
129
- let startTimeInSeconds = 0;
130
- if (data?.length) {
131
- let dataContainedClip = false;
132
- for (const entry of data) {
133
- if (debug) console.log(" ", entry.clip.name, entry.getDuration());
134
- if (entry.clip === clip) {
135
- dataContainedClip = true;
136
- break;
137
- }
138
- startTimeInSeconds += entry.getDuration() + TransformData.animationDurationPadding;
139
- }
140
- if (dataContainedClip)
141
- longestStartTime = Math.max(longestStartTime, startTimeInSeconds);
142
- }
143
- else {
144
- console.warn("No animation found on root", root, clip, data);
145
- }
205
+ if (!targets) return 0;
206
+ const transformDatas = this.dict.get(targets[0]);
207
+ if (!transformDatas) {
208
+ console.error("Trying to get start time for root that has no animation data", root, clip, ...this.dict);
209
+ return 0;
146
210
  }
147
- if (debug) console.log("getStartTime01 for " + clip.name + ": " + longestStartTime, root, clip, targets);
148
- return longestStartTime;
211
+
212
+ let currentStartTime = 0;
213
+ for (let i = 0; i < transformDatas.length; i++) {
214
+ if (transformDatas[i].clip === clip) break;
215
+ currentStartTime += transformDatas[i].getDuration() + TransformData.animationDurationPadding;
216
+ }
217
+
218
+ return currentStartTime;
149
219
  }
150
220
 
221
+ // The same clip could be registered for different roots. All of them need written animation data then.
222
+ // The same root could have multiple clips registered to it.
151
223
  registerAnimation(root: Object3D, clip: AnimationClip): RegisteredAnimationInfo | null {
152
224
  if (!clip || !root) return null;
153
225
  if (!this.rootTargetMap.has(root)) this.rootTargetMap.set(root, []);
154
- // this.rootTargetMap.get(root)?.push(clip);
155
226
 
227
+ // if we registered that exact pair already, just return the info
228
+ // no proper tuples in JavaScript, but we can use the uuids for a unique key here
229
+ const hash = root.uuid + clip.uuid;
230
+ if (this.rootAndClipToRegisteredAnimationMap.has(hash)) {
231
+ return this.rootAndClipToRegisteredAnimationMap.get(hash)!;
232
+ }
233
+
234
+ if (debug) console.log("registerAnimation", root, clip);
235
+
236
+ // When injecting a rest clip, the rest clip has ALL animated nodes as targets.
237
+ // So all other nodes will already have at least one animated clip registered, and their own
238
+ // animations need to start at index 1. Otherwise we're getting overlap where everything is
239
+ // in animation 0 and some data overrides each other incorrectly.
240
+ // When we don't inject a rest clip, we start at 0.
241
+ const startIndex = this.injectRestPoses ? 1 : 0;
242
+ const currentCount = this.rootToRegisteredClipCount.get(root) ?? startIndex;
243
+
244
+ const targets = this.rootTargetMap.get(root);
245
+ const unregisteredNodesForThisClip = new Set(targets);
156
246
  if (clip.tracks) {
157
247
  for (const track of clip.tracks) {
158
248
  const parsedPath = PropertyBinding.parseTrackName(track.name);
@@ -168,24 +258,69 @@
168
258
  if (!this.dict.has(animationTarget)) {
169
259
  this.dict.set(animationTarget, []);
170
260
  }
171
- const arr = this.dict.get(animationTarget);
172
- if (!arr) continue;
261
+ const transformDataForTarget = this.dict.get(animationTarget);
262
+ if (!transformDataForTarget) continue;
173
263
 
174
- let model = arr.find(x => x.clip === clip);
264
+ // this node has animation data for this clip – no need for additional padding
265
+ unregisteredNodesForThisClip.delete(animationTarget);
266
+
267
+ // Since we're interleaving animations, we need to ensure that for the same root,
268
+ // all clips that "touch" it are written in the same order for all animated nodes.
269
+ // this means we need to pad the TransformData array with empty entries when a particular
270
+ // node inside that root is not animated by a particular clip.
271
+ // It also means that when we encounter a clip that contains animation data for a new node,
272
+ // We need to pad that one's array as well so it starts at the same point.
273
+ // TODO most likely doesn't work for overlapping clips (clips where a root is a child of another root)
274
+ // TODO in that case we may need to pad globally, not per root
275
+
276
+ // check if we have a rest pose already
277
+ if (this.injectRestPoses && !transformDataForTarget[0]) {
278
+ const model = new TransformData(null, animationTarget, undefined);
279
+ transformDataForTarget[0] = model;
280
+ }
281
+ // These all need to be at the same index, otherwise our padding went wrong
282
+ let model = transformDataForTarget[currentCount];
283
+
284
+ // validation (for debugging)
285
+ // let modelFoundByClip = transformDataForTarget.find(x => x.clip === clip);
286
+ // let modelFoundByClipIndex = modelFoundByClip ? transformDataForTarget.indexOf(modelFoundByClip) : -1;
287
+ // console.assert(model === modelFoundByClip, "We should find the same model by index and by clip; " + currentCount + " !== " + modelFoundByClipIndex);
288
+
175
289
  if (!model) {
176
- model = new TransformData(this, root, animationTarget, clip);
177
- arr.push(model);
290
+ model = new TransformData(root, animationTarget, clip);
291
+ transformDataForTarget[currentCount] = model;
178
292
  }
179
293
  model.addTrack(track);
180
294
 
181
- const targets = this.rootTargetMap.get(root);
295
+ // We're keeping track of all animated nodes per root, needed for proper padding
182
296
  if (!targets?.includes(animationTarget)) targets?.push(animationTarget);
183
297
  }
184
298
  }
185
299
 
300
+ // add padding for nodes that are not animated by this clip
301
+ for (const target of unregisteredNodesForThisClip) {
302
+ const transformDataForTarget = this.dict.get(target);
303
+ if (!transformDataForTarget) continue;
304
+
305
+ // inject rest pose
306
+ if (this.injectRestPoses && !transformDataForTarget[0]) {
307
+ const model = new TransformData(null, target, undefined);
308
+ transformDataForTarget[0] = model;
309
+ }
310
+
311
+ let model = transformDataForTarget[currentCount];
312
+ if (!model) {
313
+ if (debug) console.log("Adding padding clip for ", target, clip);
314
+ model = new TransformData(root, target, clip);
315
+ transformDataForTarget[currentCount] = model;
316
+ }
317
+ }
318
+
186
319
  // get the entry for this object.
187
320
  // This doesnt work if we have clips animating multiple objects
188
321
  const info = new RegisteredAnimationInfo(this, root, clip);
322
+ this.rootAndClipToRegisteredAnimationMap.set(hash, info);
323
+ this.rootToRegisteredClipCount.set(root, currentCount + 1);
189
324
  return info;
190
325
  }
191
326
 
@@ -193,8 +328,6 @@
193
328
  if (debug) console.log("Animation clips per animation target node", this.dict);
194
329
  }
195
330
 
196
- private serializers: SerializeAnimation[] = [];
197
-
198
331
  onAfterBuildDocument(_context: any) {
199
332
  for (const ser of this.serializers) {
200
333
  const parent = ser.model?.parent;
@@ -223,6 +356,8 @@
223
356
 
224
357
  }
225
358
 
359
+ declare type TransformDataByObject = Map<Object3D, TransformData[]>;
360
+ declare type AnimationClipFrameTimes = { pos: number[], rot: number[], scale: number[], timeOffset: number };
226
361
 
227
362
  class SerializeAnimation {
228
363
 
@@ -248,20 +383,346 @@
248
383
  this.model.addEventListener("serialize", this.callback);
249
384
  }
250
385
 
386
+ skinnedMeshExport(writer, _context) {
387
+ const model = this.model;
388
+ const dict = this.dict;
389
+ if (!model) return;
390
+
391
+ if ( model.skinnedMesh ) {
392
+
393
+ const skeleton = model.skinnedMesh.skeleton;
394
+
395
+ const boneAndInverse = new Array<{bone: Object3D, inverse: Matrix4}>();
396
+ for (const index in skeleton.bones) {
397
+ const bone = skeleton.bones[index];
398
+ const inverse = skeleton.boneInverses[index];
399
+ boneAndInverse.push({bone, inverse});
400
+ }
401
+
402
+ for (const bone of findStructuralNodesInBoneHierarchy(skeleton.bones)) {
403
+ boneAndInverse.push({bone, inverse: bone.matrixWorld.clone().invert()});
404
+ }
405
+
406
+ // sort bones by path – need to be sorted in the same order as during mesh export
407
+ const assumedRoot = boneAndInverse[0].bone.parent!;
408
+ if (!assumedRoot) console.error("No bone parent found for skinned mesh during USDZ export", model.skinnedMesh);
409
+ boneAndInverse.sort((a, b) => getPathToSkeleton(a.bone, assumedRoot) > getPathToSkeleton(b.bone, assumedRoot) ? 1 : -1);
410
+
411
+ function createVector3TimeSampleLines_( values ) {
412
+
413
+ const lines:string[] = []
414
+ for (const [frame, frameValues] of values) {
415
+ let line = `${frame} : [`;
416
+ const boneRotations: Array<string> = [];
417
+ for ( const v of frameValues ) {
418
+ boneRotations.push( `(${fn( v.x )}, ${fn( v.y )}, ${fn( v.z )})` );
419
+ }
420
+ line = line.concat( boneRotations.join( ', ' ) );
421
+ line = line.concat( '],' );
422
+ lines.push( line );
423
+ }
424
+
425
+ return lines;
426
+
427
+ }
428
+
429
+ function createVector4TimeSampleLines_( rotations ) {
430
+
431
+ const lines:string[] = []
432
+
433
+ for (const [frame, frameRotations] of rotations) {
434
+ let line = `${frame} : [`;
435
+ const boneRotations: Array<string> = [];
436
+ for ( const v of frameRotations ) {
437
+ boneRotations.push( `(${fn( v.w )}, ${fn( v.x )}, ${fn( v.y )}, ${fn( v.z )})` );
438
+ }
439
+ line = line.concat( boneRotations.join( ', ' ) );
440
+ line = line.concat( '],' );
441
+ lines.push( line );
442
+ }
443
+
444
+ return lines;
445
+
446
+ }
447
+
448
+ function getSortedFrameTimes( boneToTransformData: TransformDataByObject ): AnimationClipFrameTimes[] {
449
+
450
+ // We should have a proper rectangular array,
451
+ // Where for each bone we have the same number of TransformData entries.
452
+ let allBonesHaveSameNumberOfTransformDataEntries = true;
453
+ let numberOfEntries: number | undefined = undefined;
454
+ for (const [bone, transformDatas] of boneToTransformData) {
455
+ if (numberOfEntries === undefined) numberOfEntries = transformDatas.length;
456
+ if (numberOfEntries !== transformDatas.length) {
457
+ allBonesHaveSameNumberOfTransformDataEntries = false;
458
+ break;
459
+ }
460
+ }
461
+
462
+ // TODO not working yet for multiple skinned characters at the same time
463
+ if (debug) console.log("Bone count: ", boneToTransformData.size, "TransformData entries per bone: ", numberOfEntries);//, ...dict);
464
+ console.assert(allBonesHaveSameNumberOfTransformDataEntries, "All bones should have the same number of TransformData entries", boneToTransformData);
465
+
466
+ const times: AnimationClipFrameTimes[] = [];
467
+
468
+ for (const [bone, transformDatas] of boneToTransformData) {
469
+
470
+ // calculate start times from the transformDatas
471
+ const startTimes = new Array<number>();
472
+ let currentStartTime = 0;
473
+ for (let i = 0; i < transformDatas.length; i++) {
474
+ startTimes.push(currentStartTime);
475
+ currentStartTime += transformDatas[i].getDuration() + TransformData.animationDurationPadding;
476
+ }
477
+
478
+ for (let i = 0; i < transformDatas.length; i++) {
479
+ const transformData = transformDatas[i];
480
+ // const timeOffset = transformData.getStartTime(dict);
481
+ const timeOffset = startTimes[i];
482
+ if (times.length <= i) {
483
+ times.push({pos: [], rot: [], scale: [], timeOffset});
484
+ }
485
+
486
+ const perTransfromDataTimes = times[i];
487
+
488
+ perTransfromDataTimes.pos.push( ...transformData.getSortedTimesArray(true, false, false));
489
+ perTransfromDataTimes.rot.push( ...transformData.getSortedTimesArray(false, true, false));
490
+ perTransfromDataTimes.scale.push( ...transformData.getSortedTimesArray(false, false, true));
491
+ }
492
+ }
493
+
494
+ for (const perTransfromDataTimes of times) {
495
+
496
+ /*
497
+ // TODO we're doing that in animation export as well
498
+ if (!times.pos.includes(0)) times.pos.push(0);
499
+ if (!times.rot.includes(0)) times.rot.push(0);
500
+ if (!times.scale.includes(0)) times.scale.push(0);
501
+ */
502
+
503
+ // sort times so it's increasing
504
+ perTransfromDataTimes.pos.sort((a, b) => a - b);
505
+ perTransfromDataTimes.rot.sort((a, b) => a - b);
506
+ perTransfromDataTimes.scale.sort((a, b) => a - b);
507
+
508
+ // make sure time values are unique
509
+ perTransfromDataTimes.pos = [...new Set(perTransfromDataTimes.pos)];
510
+ perTransfromDataTimes.rot = [...new Set(perTransfromDataTimes.rot)];
511
+ perTransfromDataTimes.scale = [...new Set(perTransfromDataTimes.scale)];
512
+ }
513
+
514
+ return times;
515
+ }
516
+
517
+ function createTimeSamplesObject_( data: TransformDataByObject, sortedComponentFrameNumbers: AnimationClipFrameTimes[], bones ) {
518
+ const positionTimeSamples = new Map<number, Array<Vector3>>();
519
+ const quaternionTimeSamples = new Map<number, Array<Quaternion>>();
520
+ const scaleTimeSamples = new Map<number, Array<Vector3>>();
521
+
522
+ const count = sortedComponentFrameNumbers.length;
523
+
524
+ // return sampled data for each bone
525
+ for ( const bone of bones ) {
526
+ const boneEntryInData = data.get( bone );
527
+
528
+ let emptyTransformData: TransformData | undefined = undefined;
529
+ // if we have animation data for this bone, check that it's the right amount.
530
+ if (boneEntryInData)
531
+ console.assert(boneEntryInData.length === count, "We should have the same number of TransformData entries for each bone", boneEntryInData, sortedComponentFrameNumbers);
532
+ // if we don't have animation data, create an empty one – 
533
+ // it will automatically map to the rest pose, albeit inefficiently
534
+ else
535
+ emptyTransformData = new TransformData(null, bone, undefined);
536
+
537
+ for (let i = 0; i < count; i++) {
538
+ const transformData = boneEntryInData ? boneEntryInData[i] : emptyTransformData!;
539
+ const timeData = sortedComponentFrameNumbers[i];
540
+
541
+ for (const { time, translation } of transformData.getValues(timeData.pos, true, false, false)) {
542
+ const shiftedTime = time + timeData.timeOffset;
543
+ const t = shiftedTime * 60;
544
+
545
+ if (!positionTimeSamples.has(t)) positionTimeSamples.set(t, new Array<Vector3>());
546
+ positionTimeSamples.get(t)!.push( translation.clone() );
547
+ }
548
+ for (const { time, rotation } of transformData.getValues(timeData.rot, false, true, false)) {
549
+ const shiftedTime = time + timeData.timeOffset;
550
+ const t = shiftedTime * 60;
551
+
552
+ if (!quaternionTimeSamples.has(t)) quaternionTimeSamples.set(t, new Array<Quaternion>());
553
+ quaternionTimeSamples.get(t)!.push( rotation.clone() );
554
+ }
555
+ for (const { time, scale } of transformData.getValues(timeData.scale, false, false, true)) {
556
+ const shiftedTime = time + timeData.timeOffset;
557
+ const t = shiftedTime * 60;
558
+
559
+ if (!scaleTimeSamples.has(t)) scaleTimeSamples.set(t, new Array<Vector3>());
560
+ scaleTimeSamples.get(t)!.push( scale.clone() );
561
+ }
562
+ }
563
+ }
564
+
565
+ return {
566
+ position: positionTimeSamples.size == 0 ? undefined : positionTimeSamples,
567
+ quaternion: quaternionTimeSamples.size == 0 ? undefined: quaternionTimeSamples,
568
+ scale: scaleTimeSamples.size == 0 ? undefined : scaleTimeSamples,
569
+ };
570
+ }
571
+
572
+ function buildVector3Array_( array: Array<Vector3> ) {
573
+ const lines: Array<string> = [];
574
+ for ( const v of array ) {
575
+ lines.push( `(${fn( v.x )}, ${fn( v.y )}, ${fn( v.z )})` );
576
+ }
577
+ return lines.join( ', ' );
578
+ }
579
+
580
+ function buildVector4Array_( array: Array<Quaternion> ) {
581
+ const lines: Array<string> = [];
582
+ for ( const v of array ) {
583
+ lines.push( `(${fn( v.w )}, ${fn( v.x )}, ${fn( v.y )}, ${fn( v.z )})` );
584
+ }
585
+ return lines.join( ', ' );
586
+ }
587
+
588
+ function getPerBoneTransformData( bones ): TransformDataByObject {
589
+
590
+ const boneToTransformData = new Map<Object3D, TransformData[]>();
591
+ if (debug) {
592
+ const logData = new Array<string>();
593
+ for (const [key, val] of dict) {
594
+ logData.push(key.uuid + ": " + val.length + " " + val.map(x => x.clip?.uuid.substring(0, 6)).join(" "));
595
+ }
596
+ console.log("getPerBoneTransformData\n" + logData.join("\n"));
597
+ }
598
+
599
+ for (const bone of bones) {
600
+ const data = dict.get(bone);
601
+ if (!data) continue;
602
+ boneToTransformData.set(bone, data);
603
+ }
604
+
605
+ return boneToTransformData;
606
+ }
607
+
608
+ function createAllTimeSampleObjects( bones: Array<Object3D> ) {
609
+ const perBoneTransformData = getPerBoneTransformData( bones );
610
+ const sortedFrameNumbers = getSortedFrameTimes( perBoneTransformData );
611
+ return createTimeSamplesObject_( perBoneTransformData, sortedFrameNumbers, bones );
612
+ }
613
+
614
+ const rest: Array<Matrix4> = [];
615
+ const translations: Array<Vector3> = [];
616
+ const rotations: Array<Quaternion> = [];
617
+ const scales: Array<Vector3> = [];
618
+ for ( const { bone } of boneAndInverse ){
619
+
620
+ rest.push( bone.matrix.clone() );
621
+ translations.push( bone.position ) ;
622
+ rotations.push( bone.quaternion );
623
+ scales.push( bone.scale );
624
+
625
+ }
626
+
627
+ const bonesArray = boneAndInverse.map( x => "\"" + getPathToSkeleton(x.bone, assumedRoot) + "\"" ).join( ', ' );
628
+ const bindTransforms = boneAndInverse.map( x => buildMatrix( x.inverse.clone().invert() ) ).join( ', ' );
629
+
630
+ writer.beginBlock( `def Skeleton "Rig"`);
631
+
632
+ writer.appendLine( `uniform matrix4d[] bindTransforms = [${bindTransforms}]` );
633
+ writer.appendLine( `uniform token[] joints = [${bonesArray}]` );
634
+ writer.appendLine( `uniform token purpose = "guide"` );
635
+ writer.appendLine( `uniform matrix4d[] restTransforms = [${rest.map( m => buildMatrix( m ) ).join( ', ' )}]` );
636
+ // In glTF, transformations on the Skeleton are ignored (NODE_SKINNED_MESH_LOCAL_TRANSFORMS validator warning)
637
+ // So here we also just write an identity transform.
638
+ writer.appendLine( `matrix4d xformOp:transform = ${buildMatrix( new Matrix4() )}` );
639
+ // writer.appendLine( `matrix4d xformOp:transform = ${buildMatrix( matrix )}` );
640
+ writer.appendLine( `uniform token[] xformOpOrder = ["xformOp:transform"]` );
641
+
642
+ const timeSampleObjects = createAllTimeSampleObjects ( boneAndInverse.map( x => x.bone ) );
643
+
644
+ writer.beginBlock( `def SkelAnimation "_anim"` );
645
+
646
+ // TODO if we include blendshapes we likely need subdivision?
647
+ // writer.appendLine( `uniform token[] blendShapes` )
648
+ // writer.appendLine( `float[] blendShapeWeights` )
649
+
650
+ writer.appendLine( `uniform token[] joints = [${bonesArray}]` );
651
+
652
+ writer.appendLine( `quatf[] rotations = [${buildVector4Array_( rotations )}]` );
653
+
654
+ if ( timeSampleObjects && timeSampleObjects.quaternion ) {
655
+ writer.beginBlock( `quatf[] rotations.timeSamples = {`, '');
656
+ const rotationTimeSampleLines = createVector4TimeSampleLines_( timeSampleObjects['quaternion'] );
657
+ for ( const line of rotationTimeSampleLines ) {
658
+ writer.appendLine( line );
659
+ }
660
+ writer.closeBlock();
661
+
662
+ }
663
+
664
+ writer.appendLine( `half3[] scales = [${buildVector3Array_( scales )}]` );
665
+
666
+ if ( timeSampleObjects && timeSampleObjects.scale ) {
667
+ writer.beginBlock( `half3[] scales.timeSamples = {`, '' );
668
+ const scaleTimeSampleLines = createVector3TimeSampleLines_( timeSampleObjects['scale'] );
669
+
670
+ for ( const line of scaleTimeSampleLines ) {
671
+ writer.appendLine( line );
672
+ }
673
+ writer.closeBlock();
674
+
675
+ }
676
+
677
+ writer.appendLine( `float3[] translations = [${buildVector3Array_( translations )}]` );
678
+
679
+ if ( timeSampleObjects && timeSampleObjects.position) {
680
+ writer.beginBlock( `float3[] translations.timeSamples = {`, '' );
681
+ const positionTimeSampleLines = createVector3TimeSampleLines_( timeSampleObjects['position'] );
682
+
683
+ for ( const line of positionTimeSampleLines ) {
684
+ writer.appendLine( line );
685
+ }
686
+ writer.closeBlock();
687
+
688
+ }
689
+
690
+ writer.closeBlock();
691
+ writer.closeBlock();
692
+
693
+ }
694
+ }
695
+
696
+
697
+
251
698
  onSerialize(writer, _context) {
252
699
  if (!this.model) return;
253
700
 
701
+ this.skinnedMeshExport(writer, _context);
702
+
254
703
  const object = this.object;
704
+
255
705
  // do we have animation data for this node? if not, return
256
706
  const arr = this.dict.get(object);
257
707
  if (!arr) return;
258
708
 
709
+ // Skinned meshes are handled separately by the method above.
710
+ // They need to be handled first (before checking for animation data) because animation needs to be exported
711
+ // as part of the skinned mesh and that may not be animated at all – if any bone is animated we need to export.
712
+ //@ts-ignore
713
+ if (object.isSkinnedMesh) return;
714
+
715
+ // Excluding the concrete bone xform hierarchy animation his is mostly useful for debugging,
716
+ // as otherwise we're getting a ton of extra animation data per-bone xform
717
+ // TODO if this turns out to be slow/large (lots of duplicated animation data)
718
+ // we can look into optimizing it, and only including both the xform hierarchy and the animation
719
+ // only when we need it – when anywhere below it in the hierarchy are animated visible meshes.
720
+ //@ts-ignore
721
+ // if (object.isBone) return;
722
+
259
723
  if (debugSerialization) console.log("SERIALIZE", this.model.name, this.object.type, arr);
260
724
 
261
725
  const composedTransform = new Matrix4();
262
- const translation = new Vector3();
263
- const rotation = new Quaternion();
264
- const scale = new Vector3(1, 1, 1);
265
726
 
266
727
  writer.appendLine("matrix4d xformOp:transform.timeSamples = {");
267
728
  writer.indent++;
@@ -270,71 +731,42 @@
270
731
  // We need to make sure that the same underlying animation clip ends up
271
732
  // at the same start time in the USD file, and that we're not getting overlaps to other clips.
272
733
  // That means that the same clip (transformData.clip) should end up at the same start time for all nodes.
273
- for (const transformData of arr) {
274
- const posTimesArray = transformData.pos?.times;
275
- const rotTimesArray = transformData.rot?.times;
276
- const scaleTimesArray = transformData.scale?.times;
277
-
278
- // timesArray is the sorted union of all time values
279
- let timesArray: number[] = [];
280
- if (posTimesArray) for (const t of posTimesArray) timesArray.push(t);
281
- if (rotTimesArray) for (const t of rotTimesArray) timesArray.push(t);
282
- if (scaleTimesArray) for (const t of scaleTimesArray) timesArray.push(t);
283
734
 
284
- // we also need to make sure we have start and end times for these tracks
285
- // TODO seems we can't get track duration from the KeyframeTracks
286
- if (!timesArray.includes(0)) timesArray.push(0);
735
+ // calculate start times
736
+ // calculate start times from the transformDatas
737
+ const startTimes = new Array<number>();
738
+ let currentStartTime = 0;
739
+ for (let i = 0; i < arr.length; i++) {
740
+ startTimes.push(currentStartTime);
741
+ currentStartTime += arr[i].getDuration() + TransformData.animationDurationPadding;
742
+ }
287
743
 
288
- // sort times so it's increasing
289
- timesArray.sort((a, b) => a - b);
290
- // make sure time values are unique
291
- timesArray = [...new Set(timesArray)];
292
-
293
- if (!timesArray || timesArray.length === 0) {
294
- console.error("got an animated object but no time values?", object, transformData);
295
- continue;
296
- }
297
- const startTime = transformData.getStartTime(this.dict);
298
-
299
- if (debug) {
300
- const clipName = transformData.clip.name;
301
- const duration = transformData.getDuration();
302
- console.log("Write .timeSamples:", clipName, startTime, duration, arr);
303
- writer.appendLine("# " + clipName + ": start=" + (startTime * transformData.frameRate).toFixed(3) + ", length=" + (duration * transformData.frameRate).toFixed(3) + ", frames=" + transformData.getFrames());
304
- }
305
-
306
- const positionInterpolant: Interpolant | undefined = transformData.pos?.createInterpolant();
307
- const rotationInterpolant: Interpolant | undefined = transformData.rot?.createInterpolant();
308
- const scaleInterpolant: Interpolant | undefined = transformData.scale?.createInterpolant();
309
-
310
- if (!positionInterpolant) translation.set(object.position.x, object.position.y, object.position.z);
311
- if (!rotationInterpolant) rotation.set(object.quaternion.x, object.quaternion.y, object.quaternion.z, object.quaternion.w);
312
- if (!scaleInterpolant) scale.set(object.scale.x, object.scale.y, object.scale.z);
313
-
314
- for (let index = 0; index < timesArray.length; index++) {
315
- const time = timesArray[index];
316
-
317
- if (positionInterpolant) {
318
- const pos = positionInterpolant.evaluate(time);
319
- translation.set(pos[0], pos[1], pos[2]);
744
+ for (let i = 0; i < arr.length; i++) {
745
+ const transformData = arr[i];
746
+ if (!transformData) continue;
747
+ const startTime = startTimes[i];
748
+ {
749
+ const timesArray = transformData.getSortedTimesArray();
750
+
751
+ if (!timesArray || timesArray.length === 0) {
752
+ console.error("got an animated object but no time values?", object, transformData);
753
+ continue;
320
754
  }
321
- if (rotationInterpolant) {
322
- const quat = rotationInterpolant.evaluate(time);
323
- rotation.set(quat[0], quat[1], quat[2], quat[3]);
755
+
756
+ if (debug) {
757
+ const clipName = transformData.clip?.name ?? "rest";
758
+ const duration = transformData.getDuration();
759
+ console.log("Write .timeSamples:", clipName, startTime, duration, arr);
760
+ writer.appendLine("# " + clipName + ": start=" + (startTime * transformData.frameRate).toFixed(3) + ", length=" + (duration * transformData.frameRate).toFixed(3) + ", frames=" + transformData.getFrames());
324
761
  }
325
- if (scaleInterpolant) {
326
- const scaleVal = scaleInterpolant.evaluate(time);
327
- scale.set(scaleVal[0], scaleVal[1], scaleVal[2]);
762
+
763
+ for (const { time, translation, rotation, scale } of transformData.getValues(timesArray)) {
764
+ composedTransform.compose(translation, rotation, scale);
765
+
766
+ const line = `${(startTime + time) * transformData.frameRate}: ${buildMatrix(composedTransform)},`;
767
+ writer.appendLine(line);
328
768
  }
329
-
330
- composedTransform.compose(translation, rotation, scale);
331
-
332
- const line = `${(startTime + time) * transformData.frameRate}: ${buildMatrix(composedTransform)},`;
333
- if (debug)
334
- writer.appendLine("# " + index);
335
- writer.appendLine(line);
336
769
  }
337
-
338
770
  }
339
771
  writer.indent--;
340
772
  writer.appendLine("}");
src/engine-components/export/usdz/utils/animationutils.ts CHANGED
@@ -1,112 +1,81 @@
1
1
  import { Animator } from "../../../Animator.js";
2
2
  import { Animation } from "../../../Animation.js";
3
- import { Object3D, AnimationClip, KeyframeTrack, PropertyBinding } from "three";
3
+ import { Object3D, AnimationClip } from "three";
4
4
  import { AnimationExtension } from "../extensions/Animation.js";
5
- import { GameObject } from "../../../Component.js";
5
+ import { Behaviour, GameObject } from "../../../Component.js";
6
6
  import { getParam } from "../../../../engine/engine_utils.js";
7
+ import { PlayAnimationOnClick } from "../extensions/behavior/BehaviourComponents.js";
7
8
 
8
9
  const debug = getParam("debugusdz");
9
10
 
10
- export function registerAnimatorsImplictly(root: Object3D, ext: AnimationExtension) {
11
+ export function registerAnimatorsImplictly(root: Object3D, ext: AnimationExtension): Array<Object3D> {
11
12
 
13
+ // TODO: currently we're simply appending all animations to each other.
14
+ // We are not currently emitting PlayAnimation actions that would make sure
15
+ // that only a specific clip or a set of clips is playing.
16
+
17
+ // Better would be to scratch the functionality here entirely, and instead
18
+ // inject PlayAnimationOnStart actions. This way, animations can loop independently, and
19
+ // Animator state chaining would be respected out-of-the-box.
20
+ // For non-QuickLook USDZ export it's still useful to have implicit registration per root.
21
+
12
22
  // collect animators and their clips
13
23
  const animationClips: { root: Object3D, clips: AnimationClip[] }[] = [];
14
24
  const animators = GameObject.getComponentsInChildren(root, Animator);
15
25
  const animationComponents = GameObject.getComponentsInChildren(root, Animation);
16
26
 
17
- // insert rest pose clip
18
- let injectedRestPose = false;
19
- const restPoseClip = new AnimationClip("rest", .01, []);
20
- const nodesWithRestPoseTracks: Map<Object3D, string[]> = new Map();
27
+ const constructedObjects = new Array<Object3D>();
21
28
 
22
- const checkInjectRestPose = (clips: AnimationClip[], clip: AnimationClip) => {
23
- // we need to inject a rest pose clip so that the animation position is correct
24
- // e.g. when the animation starts in the air and animates down we dont want the object to move under the ground
25
- // TODO rest pose needs to contain ALL animated objects of all clips
26
- if (!injectedRestPose && clip.tracks.length > 0) {
27
- injectedRestPose = true;
28
- /*
29
- const track = clip.tracks[0];
30
- const trackBaseName = track.name.substring(0, track.name.lastIndexOf("."));
31
- const currentPositionTrack = new KeyframeTrack(trackBaseName + ".position", [0, .01], [0, 0, 0, 0, 0, 0]);
32
- const currentRotationTrack = new KeyframeTrack(trackBaseName + ".quaternion", [0, .01], [0, 0, 0, 1, 0, 0, 0, 1]);
33
- restPoseClip.tracks = [currentPositionTrack, currentRotationTrack];
34
- */
35
- clips.push(restPoseClip);
36
- }
29
+ if (ext.injectImplicitBehaviours) {
30
+ // We're registering animators with implicit PlayAnimationOnClick (with hacked "start" trigger) here.
31
+ // TODO need to remove these extra components again after export.
32
+ for (const animator of animators) {
33
+ if (!animator || !animator.runtimeAnimatorController) continue;
34
+ const activeState = animator.runtimeAnimatorController.activeState;
37
35
 
38
- if (clip.tracks.length > 0) {
39
- for (const track of clip.tracks) {
40
- const parsedPath = PropertyBinding.parseTrackName(track.name);
41
- const targetNode = PropertyBinding.findNode(root, parsedPath.nodeName);
42
- if (targetNode) {
43
- // keep track of which nodes already have a rest pose track
44
- if (!nodesWithRestPoseTracks.has(targetNode))
45
- nodesWithRestPoseTracks.set(targetNode, []);
46
- const propertyNames = nodesWithRestPoseTracks.get(targetNode)!;
47
- // make a new KeyframeTrack with the same track name, but the current values of the object
48
- switch (parsedPath.propertyName) {
49
- case "position": {
50
- if (propertyNames.includes("position")) continue;
51
- const currentData = targetNode.position;
52
- const newTrack = new KeyframeTrack(track.name, [0, .01], [
53
- currentData.x, currentData.y, currentData.z,
54
- currentData.x, currentData.y, currentData.z
55
- ]);
56
- propertyNames.push("position");
57
- restPoseClip.tracks.push(newTrack);
58
- break;
59
- }
60
- case "quaternion": {
61
- if (propertyNames.includes("quaternion")) continue;
62
- const currentData = targetNode.quaternion;
63
- const newTrack = new KeyframeTrack(track.name, [0, .01], [
64
- currentData.x, currentData.y, currentData.z, currentData.w,
65
- currentData.x, currentData.y, currentData.z, currentData.w
66
- ]);
67
- propertyNames.push("quaternion");
68
- restPoseClip.tracks.push(newTrack);
69
- break;
70
- }
71
- case "scale": {
72
- if (propertyNames.includes("scale")) continue;
73
- const currentData = targetNode.scale;
74
- const newTrack = new KeyframeTrack(track.name, [0, .01], [
75
- currentData.x, currentData.y, currentData.z,
76
- currentData.x, currentData.y, currentData.z
77
- ]);
78
- propertyNames.push("scale");
79
- restPoseClip.tracks.push(newTrack);
80
- break;
81
- }
82
- }
83
- }
84
- }
36
+ // skip missing data, empty states or motions
37
+ if (!activeState) continue;
38
+ if (!activeState.motion || !activeState.motion.clip) continue;
39
+ if (activeState.motion.clip.tracks?.length < 1) continue;
40
+
41
+ // Create a PlayAnimationOnClick component that will play the animation on start
42
+ // This is a bit hacky right now (we're using the internal "start" trigger)
43
+ const newComponent = new PlayAnimationOnClick();
44
+ newComponent.animator = animator;
45
+ newComponent.stateName = activeState.name;
46
+ newComponent.trigger = "start";
47
+ newComponent.name = "PlayAnimationOnClick_implicitAtStart_" + newComponent.stateName;
48
+ const go = new Object3D();
49
+ GameObject.addComponent(go, newComponent);
50
+ constructedObjects.push(go);
51
+
52
+ // the behaviour can be anywhere in the hierarchy
53
+ root.add(go);
85
54
  }
86
55
  }
56
+ else {
57
+ for (const animator of animators) {
58
+ if (!animator || !animator.runtimeAnimatorController) continue;
87
59
 
88
- for (const animator of animators) {
89
- if (!animator || !animator.runtimeAnimatorController) continue;
60
+ if (debug) console.log(animator);
90
61
 
91
- if (debug)
92
- console.log(animator);
62
+ const clips: AnimationClip[] = [];
93
63
 
94
- const clips: AnimationClip[] = [];
64
+ for (const action of animator.runtimeAnimatorController.enumerateActions()) {
65
+ if (debug)
66
+ console.log(action);
67
+ const clip = action.getClip();
95
68
 
96
- for (const action of animator.runtimeAnimatorController.enumerateActions()) {
97
- if (debug)
98
- console.log(action);
99
- const clip = action.getClip();
69
+ if (!clips.includes(clip))
70
+ clips.push(clip);
71
+ }
100
72
 
101
- checkInjectRestPose(clips, clip);
102
-
103
- if (!clips.includes(clip))
104
- clips.push(clip);
73
+ animationClips.push({ root: animator.gameObject, clips: clips });
105
74
  }
106
-
107
- animationClips.push({ root: animator.gameObject, clips: clips });
108
75
  }
109
76
 
77
+ // TODO once PlayAnimationOnClick can use animation components as well,
78
+ // we can treat them the same as we treat Animators above.
110
79
  for (const animationComponent of animationComponents) {
111
80
  if (debug)
112
81
  console.log(animationComponent);
@@ -114,22 +83,19 @@
114
83
  const clips: AnimationClip[] = [];
115
84
 
116
85
  for (const clip of animationComponent.animations) {
117
- checkInjectRestPose(clips, clip);
118
-
119
86
  if (!clips.includes(clip))
120
87
  clips.push(clip);
121
88
  }
122
89
 
123
90
  animationClips.push({ root: animationComponent.gameObject, clips: clips });
124
91
  }
92
+
93
+ if (debug) console.log("USDZ Animation Clips", animationClips);
125
94
 
126
- if (debug) {
127
- console.log("Rest Pose Clip", restPoseClip);
128
- console.log("USDZ Animation Clips", animationClips);
129
- }
130
-
131
95
  for (const pair of animationClips) {
132
96
  for (const clip of pair.clips)
133
97
  ext.registerAnimation(pair.root, clip);
134
98
  }
99
+
100
+ return constructedObjects;
135
101
  }
src/engine-components/AnimatorController.ts CHANGED
@@ -355,13 +355,13 @@
355
355
  console.log("transition to " + transition.destinationState, transition, normalizedTime, transition.exitTime, transition.hasExitTime);
356
356
  // console.log(action.time, transition.exitTime);
357
357
  }
358
- this.transitionTo(transition.destinationState as State, transition.duration, transition.offset);
358
+ this.transitionTo(transition.destinationState, transition.duration, transition.offset);
359
359
  // use the first transition that matches all conditions and make the transition as soon as in range
360
360
  return;
361
361
  }
362
362
  }
363
363
  else {
364
- this.transitionTo(transition.destinationState as State, transition.duration, transition.offset);
364
+ this.transitionTo(transition.destinationState, transition.duration, transition.offset);
365
365
  return;
366
366
  }
367
367
  // if none of the transitions can be made continue searching for another transition meeting the conditions
src/engine-components/export/usdz/extensions/behavior/Behaviour.ts CHANGED
@@ -1,17 +1,16 @@
1
1
  import { GameObject } from "../../../../Component.js";
2
- import type { IContext } from "../../../../../engine/engine_types.js";
3
2
  import type { IUSDExporterExtension } from "../../Extension.js";
4
- import { USDObject, USDWriter } from "../../ThreeUSDZExporter.js";
3
+ import { USDObject, USDWriter, USDZExporterContext } from "../../ThreeUSDZExporter.js";
5
4
  import { BehaviorModel } from "./BehavioursBuilder.js";
6
5
  import { getParam } from "../../../../../engine/engine_utils.js";
7
6
 
8
7
  const debug = getParam("debugusdz");
9
8
 
10
9
  export interface UsdzBehaviour {
11
- createBehaviours?(ext: BehaviorExtension, model: USDObject, context: IContext): void;
12
- beforeCreateDocument?(ext: BehaviorExtension, context: IContext): void | Promise<void>;
13
- afterCreateDocument?(ext: BehaviorExtension, context: IContext): void | Promise<void>
14
- afterSerialize?(ext: BehaviorExtension, context: IContext): void;
10
+ createBehaviours?(ext: BehaviorExtension, model: USDObject, context: USDZExporterContext): void;
11
+ beforeCreateDocument?(ext: BehaviorExtension, context: USDZExporterContext): void | Promise<void>;
12
+ afterCreateDocument?(ext: BehaviorExtension, context: USDZExporterContext): void | Promise<void>
13
+ afterSerialize?(ext: BehaviorExtension, context: USDZExporterContext): void;
15
14
  }
16
15
 
17
16
  /** internal USDZ behaviours extension */
@@ -73,9 +72,6 @@
73
72
 
74
73
  onAfterHierarchy(context, writer: USDWriter) {
75
74
  if (this.behaviours?.length) {
76
-
77
- // this.combineBehavioursWithSameTapActions();
78
-
79
75
  writer.beginBlock('def Scope "Behaviors"');
80
76
 
81
77
  for (const beh of this.behaviours)
@@ -92,8 +88,6 @@
92
88
 
93
89
  if (typeof beh.afterSerialize === "function") {
94
90
 
95
- if (debug) console.log("behaviour has afterSerialize", beh)
96
-
97
91
  const isAsync = beh.afterSerialize.constructor.name === "AsyncFunction";
98
92
 
99
93
  if ( isAsync ) {
@@ -103,6 +97,9 @@
103
97
  }
104
98
  }
105
99
  }
100
+
101
+ // cleanup
102
+ this.behaviours.length = 0;
106
103
  }
107
104
 
108
105
  // combine behaviours that have tap triggers on the same object
src/engine-components/export/usdz/extensions/behavior/BehaviourComponents.ts CHANGED
@@ -3,18 +3,19 @@
3
3
  import { Renderer } from "../../../../Renderer.js";
4
4
  import { serializable } from "../../../../../engine/engine_serialization_decorator.js";
5
5
  import type { IPointerClickHandler, PointerEventData } from "../../../../ui/PointerEvents.js";
6
- import { RegisteredAnimationInfo, type UsdzAnimation } from "../Animation.js";
6
+ import { AnimationExtension, RegisteredAnimationInfo, type UsdzAnimation } from "../Animation.js";
7
7
  import { getWorldPosition, getWorldQuaternion, getWorldScale, setWorldPosition, setWorldQuaternion, setWorldScale } from "../../../../../engine/engine_three_utils.js";
8
8
 
9
9
  import { Object3D, Material, Vector3, Quaternion, Mesh, Group } from "three";
10
- import { USDDocument, USDObject } from "../../ThreeUSDZExporter.js";
10
+ import { USDDocument, USDObject, USDZExporterContext } from "../../ThreeUSDZExporter.js";
11
11
 
12
12
  import type { BehaviorExtension, UsdzBehaviour } from "./Behaviour.js";
13
13
  import { ActionBuilder, ActionModel, AuralMode, BehaviorModel, type IBehaviorElement, MotionType, PlayAction, Space, TriggerBuilder } from "./BehavioursBuilder.js";
14
14
  import { AudioSource } from "../../../../AudioSource.js";
15
15
  import { NEEDLE_progressive } from "../../../../../engine/extensions/NEEDLE_progressive.js";
16
- import { isDevEnvironment } from "../../../../../engine/debug/index.js";
16
+ import { isDevEnvironment, showBalloonWarning } from "../../../../../engine/debug/index.js";
17
17
  import { Raycaster, ObjectRaycaster } from "../../../../ui/Raycaster.js";
18
+ import type { State } from "../../../../../engine/extensions/NEEDLE_animator_controller_model.js";
18
19
 
19
20
  function ensureRaycaster(obj: GameObject) {
20
21
  if (!obj) return;
@@ -551,12 +552,11 @@
551
552
  @serializable()
552
553
  stateName?: string;
553
554
 
554
- @serializable()
555
- stateNameAfterPlaying?: string;
555
+ // Not editable from the outside yet, but from code
556
+ // we want to expose this once we have a nice drawer for "Triggers" (e.g. shows proximity distance)
557
+ // and once we rename the component to "PlayAnimation" or "PlayAnimationOnTrigger"
558
+ trigger: "tap" | "start" = "tap"; // "proximity"
556
559
 
557
- @serializable()
558
- loopAfterPlaying: boolean = false;
559
-
560
560
  private get target() { return this.animator?.gameObject }
561
561
 
562
562
  start(): void {
@@ -566,17 +566,20 @@
566
566
  onPointerClick(args: PointerEventData) {
567
567
  args.use();
568
568
  if (!this.target) return;
569
- if (this.stateName)
569
+ if (this.stateName) {
570
+ // TODO this is currently quite annoying to use,
571
+ // as for the web we use the Animator component and its states directly,
572
+ // while in QuickLook we use explicit animations / states.
570
573
  this.animator?.play(this.stateName, 0, 0, .1);
574
+ }
571
575
  }
572
576
 
573
577
  private selfModel: any;
574
578
 
575
579
  private stateAnimationModel: any;
576
- private stateAnimation?: RegisteredAnimationInfo;
577
580
 
578
- private stateAfterPlayingAnimationModel: any;
579
- private stateAfterPlayingAnimation?: RegisteredAnimationInfo;
581
+ private animationSequence? = new Array<RegisteredAnimationInfo>();
582
+ private animationLoopAfterSequence? = new Array<RegisteredAnimationInfo>();
580
583
 
581
584
  createBehaviours(_ext, model, _context) {
582
585
  if (model.uuid === this.gameObject.uuid)
@@ -584,61 +587,176 @@
584
587
  }
585
588
 
586
589
  private static animationActions: ActionModel[] = [];
590
+ private static rootsWithExclusivePlayback: Set<Object3D> = new Set();
587
591
 
588
- onAfterHierarchy() {
592
+ // Cleanup. TODO This is not the best way as it's called multiple times (once for each component).
593
+ afterSerialize() {
594
+ if (PlayAnimationOnClick.rootsWithExclusivePlayback.size > 1) {
595
+ const message = "Multiple root objects targeted by more than one animation. To work around QuickLook bug FB13410767, animations will be set as \"exclusive\" and activating them will stop other animations being marked as exclusive.";
596
+ if (isDevEnvironment()) showBalloonWarning(message);
597
+ console.warn(message, ...PlayAnimationOnClick.rootsWithExclusivePlayback);
598
+ }
589
599
  PlayAnimationOnClick.animationActions = [];
600
+ PlayAnimationOnClick.rootsWithExclusivePlayback = new Set();
590
601
  }
591
602
 
592
- afterCreateDocument(ext, context) {
593
- if (!this.stateAnimation || !this.stateAnimationModel) return;
603
+ afterCreateDocument(ext: BehaviorExtension, context: USDZExporterContext) {
604
+ if (!this.animationSequence || !this.stateAnimationModel) return;
605
+ if (!this.target) return;
606
+
594
607
  const document = context.document;
608
+
609
+ // check if the AnimationExtension has been attached and what data it has for the current object
610
+ const animationExt = context.extensions.find(ext => ext instanceof AnimationExtension) as AnimationExtension;
611
+ if (!animationExt) return;
612
+
613
+ // This is a workaround for FB13410767 - StartAnimationAction in USDZ preliminary behaviours does not stop when another StartAnimationAction is called on the same prim
614
+ // When we play multiple animations on the same root, QuickLook just overlaps them and glitches around instead of stopping an earlier one.
615
+ // Once this is fixed, we can relax this check and just always make it non-exclusive again.
616
+ // Setting exclusive playback has the side effect of unfortunately canceling all other playing actions that are exclusive too -
617
+ // seems there is no finer-grained control over which actions should stop which other actions...
618
+ const requiresExclusivePlayback = animationExt.getClipCount(this.target) > 1;
619
+ if (requiresExclusivePlayback) {
620
+ if (isDevEnvironment())
621
+ console.warn("Setting exclusive playback for " + this.target.name + "@" + this.stateName + " because it has " + animationExt.getClipCount(this.target) + " animations. This works around QuickLook bug FB13410767.");
622
+
623
+ PlayAnimationOnClick.rootsWithExclusivePlayback.add(this.target);
624
+ }
625
+
626
+ const getOrCacheAction = (model: USDObject, anim: RegisteredAnimationInfo) => {
627
+ let action = PlayAnimationOnClick.animationActions.find(a => a.affectedObjects == model && a.start == anim.start && a.duration == anim.duration);
628
+ if (!action) {
629
+ action = ActionBuilder.startAnimationAction(model, anim.start, anim.duration) as ActionModel;
630
+ PlayAnimationOnClick.animationActions.push(action);
631
+ }
632
+ return action;
633
+ }
634
+
595
635
  document.traverse(model => {
596
- // TODO we should probably check if a startAnimationAction already exists, and not have duplicates of identical ones;
597
- // looks like otherwise we're getting some animation overlap that doesn't look good.
598
- if (model.uuid === this.target?.uuid && this.stateAnimation) {
599
- const sequence: IBehaviorElement[] = [];
600
- let startAction = PlayAnimationOnClick.animationActions.find(a => a.affectedObjects == model && a.start == this.stateAnimation!.start && a.duration == this.stateAnimation!.duration);
601
- if (!startAction) {
602
- startAction = ActionBuilder.startAnimationAction(model, this.stateAnimation.start, this.stateAnimation.duration) as ActionModel;
603
- PlayAnimationOnClick.animationActions.push(startAction);
636
+ if (model.uuid === this.target?.uuid) {
637
+ const sequence = ActionBuilder.sequence();
638
+
639
+ if (this.animationSequence !== undefined)
640
+ for (const anim of this.animationSequence) {
641
+ sequence.addAction(getOrCacheAction(model, anim));
604
642
  }
605
- sequence.push(startAction);
606
643
 
607
- if (this.stateAfterPlayingAnimation && this.stateAfterPlayingAnimationModel) {
608
- let endAction = PlayAnimationOnClick.animationActions.find(a => a.affectedObjects == model && a.start == this.stateAfterPlayingAnimation!.start && a.duration == this.stateAfterPlayingAnimation!.duration);
609
- if (!endAction) {
610
- endAction = ActionBuilder.startAnimationAction(model, this.stateAfterPlayingAnimation.start, this.stateAfterPlayingAnimation.duration) as ActionModel;
611
- PlayAnimationOnClick.animationActions.push(endAction);
644
+ if (this.animationLoopAfterSequence !== undefined) {
645
+ // only make a new action group if there's already stuff in the existing one
646
+ const loopSequence = sequence.actions.length == 0 ? sequence : ActionBuilder.sequence();
647
+ for (const anim of this.animationLoopAfterSequence) {
648
+ loopSequence.addAction(getOrCacheAction(model, anim));
612
649
  }
613
- const idleAnim = ActionBuilder.sequence(endAction);
614
- if (this.loopAfterPlaying)
615
- idleAnim.makeLooping();
616
- sequence.push(idleAnim);
650
+ loopSequence.makeLooping();
651
+ if (sequence !== loopSequence)
652
+ sequence.addAction(loopSequence);
617
653
  }
654
+
618
655
  const playAnimationOnTap = new BehaviorModel("tap " + this.name + " for " + this.stateName + " on " + this.target?.name,
619
- TriggerBuilder.tapTrigger(this.selfModel),
620
- ActionBuilder.sequence(...sequence)
656
+ this.trigger == "tap" ? TriggerBuilder.tapTrigger(this.selfModel) : TriggerBuilder.sceneStartTrigger(),
657
+ sequence
621
658
  );
659
+
660
+ // See comment above for why exclusive playback is currently required when playing multiple animations on the same root.
661
+ if (requiresExclusivePlayback)
662
+ playAnimationOnTap.makeExclusive(true);
622
663
  ext.addBehavior(playAnimationOnTap);
623
664
  }
624
665
  });
625
666
  }
626
667
 
627
668
  createAnimation(ext, model, _context) {
628
- if (this.target && this.animator) {
669
+ if (!this.target || !this.animator) return;
629
670
 
630
- const state = this.animator?.runtimeAnimatorController?.findState(this.stateName);
631
- if (!state?.motion?.clip) {
632
- if (isDevEnvironment()) console.warn("No clip found for state " + this.stateName);
633
- return;
671
+ // If there's a separate state specified to play after this one, we
672
+ // play it automatically. Theoretically an animator state machine flow could be encoded here.
673
+
674
+ // We're parsing the Animator states here and follow the transition chain until we find a loop.
675
+ // There are some edge cases:
676
+ // - (0 > 1.looping) should keep looping (1).
677
+ // - (0 > 1 > 1) should keep looping (1).
678
+ // - (0 > 1 > 2 > 3 > 2) should keep looping (2,3).
679
+ // - (0 > 1 > 2 > 3 > 0) should keep looping (0,1,2,3).
680
+ const runtimeController = this.animator?.runtimeAnimatorController;
681
+ let currentState = runtimeController?.findState(this.stateName);
682
+ let statesUntilLoop: State[] = [];
683
+ let statesLooping: State[] = [];
684
+
685
+ if (runtimeController && currentState) {
686
+ // starting point – we have set this above already as startAction
687
+ const visitedStates = new Array<State>;
688
+ visitedStates.push(currentState);
689
+ let foundLoop = false;
690
+
691
+ while (true && visitedStates.length < 100) {
692
+ if (!currentState || currentState === null || !currentState.transitions || currentState.transitions.length === 0) {
693
+ if (currentState.motion?.isLooping)
694
+ foundLoop = true;
695
+ break;
696
+ }
697
+
698
+ // find the first transition without parameters
699
+ // TODO we could also find the first _valid_ transition here instead based on the current parameters.
700
+ const transition = currentState.transitions.find(t => t.conditions.length === 0);
701
+ const nextState = transition ? runtimeController["getState"](transition.destinationState, 0) : null;
702
+ // abort: we found a state loop
703
+ if (nextState && visitedStates.includes(nextState)) {
704
+ currentState = nextState;
705
+ foundLoop = true;
706
+ break;
707
+ }
708
+ // keep looking: transition to another state
709
+ else if (transition) {
710
+ currentState = nextState;
711
+ if (!currentState)
712
+ break;
713
+ visitedStates.push(currentState);
714
+ }
715
+ // abort: no transition found. check if last state is looping
716
+ else {
717
+ foundLoop = currentState.motion?.isLooping ?? false;
718
+ break;
719
+ }
634
720
  }
635
- this.stateAnimationModel = model;
636
- this.stateAnimation = ext.registerAnimation(this.target, state?.motion.clip);
637
721
 
638
- const stateAfter = this.animator?.runtimeAnimatorController?.findState(this.stateNameAfterPlaying);
639
- this.stateAfterPlayingAnimationModel = model;
640
- this.stateAfterPlayingAnimation = ext.registerAnimation(this.target, stateAfter?.motion.clip);
722
+ if (foundLoop && currentState) {
723
+ // check what the first state in the loop is – it must be matching the last one we added
724
+ const firstStateInLoop = visitedStates.indexOf(currentState);
725
+ statesUntilLoop = visitedStates.slice(0, firstStateInLoop); // can be empty, which means we're looping all
726
+ statesLooping = visitedStates.slice(firstStateInLoop); // can be empty, which means nothing is looping
727
+ console.log("found loop from " + this.stateName, "states until loop", statesUntilLoop, "states looping", statesLooping);
728
+ }
729
+ else {
730
+ statesUntilLoop = visitedStates;
731
+ statesLooping = [];
732
+ console.log("found no loop from " + this.stateName, "states", statesUntilLoop);
733
+ }
641
734
  }
735
+
736
+ // filter out any states that don't have motion data
737
+ statesUntilLoop = statesUntilLoop.filter(s => s.motion?.clip && s.motion?.clip.tracks?.length > 0);
738
+ statesLooping = statesLooping.filter(s => s.motion?.clip && s.motion?.clip.tracks?.length > 0);
739
+
740
+ // If none of the found states have motion, we need to warn
741
+ if (statesUntilLoop.length === 0 && statesLooping.length === 0) {
742
+ console.warn("No clips found for state " + this.stateName + " on " + this.animator?.name + ", can't export animation data");
743
+ return;
744
+ }
745
+ this.stateAnimationModel = model;
746
+
747
+ // Register all the animation states we found.
748
+ if (statesUntilLoop.length > 0) {
749
+ this.animationSequence = new Array<RegisteredAnimationInfo>();
750
+ for (const state of statesUntilLoop) {
751
+ this.animationSequence.push(ext.registerAnimation(this.target, state.motion.clip));
752
+ }
753
+ }
754
+ if (statesLooping.length > 0) {
755
+ this.animationLoopAfterSequence = new Array<RegisteredAnimationInfo>();
756
+ for (const state of statesLooping) {
757
+ this.animationLoopAfterSequence.push(ext.registerAnimation(this.target, state.motion.clip));
758
+ }
759
+ }
642
760
  }
643
761
 
644
762
  }
src/engine-components/export/usdz/extensions/behavior/BehavioursBuilder.ts CHANGED
@@ -210,7 +210,8 @@
210
210
  writer.beginArray("rel actions");
211
211
  for (const act of this.actions) {
212
212
  if (!act) continue;
213
- writer.appendLine("<" + act.id + ">,");
213
+ const isLast = act === this.actions[this.actions.length - 1];
214
+ writer.appendLine("<" + act.id + ">" + (isLast ? "" : ", "));
214
215
  }
215
216
  writer.closeArray();
216
217
  writer.appendLine();
@@ -433,13 +434,14 @@
433
434
  static startAnimationAction(targetObject: Target, start: number, duration: number = 0, animationSpeed: number = 1, reversed: boolean = false, pingPong: boolean = false): IBehaviorElement {
434
435
  const act = new ActionModel(targetObject);
435
436
  act.tokenId = "StartAnimation";
437
+ // start is time in seconds, the documentation is not right here
436
438
  act.start = start;
437
- // start is time in seconds, the documentation is not right here
439
+ // duration of 0 is play to end
438
440
  act.duration = duration;
439
- // duration of 0 is play to end
440
441
  act.animationSpeed = animationSpeed;
441
442
  act.reversed = reversed;
442
443
  act.pingPong = pingPong;
444
+ act.multiplePerformOperation = MultiplePerformOperation.Allow;
443
445
  if (reversed) {
444
446
  act.start -= duration;
445
447
  //console.warn("Reversed animation does currently not work. The resulting file will most likely not playback.", act.id, targetObject);
@@ -455,7 +457,7 @@
455
457
  const group = ActionBuilder.sequence(act, back);
456
458
  return group;
457
459
  }
458
- if (debug) console.log("Start Animation Action", act);
460
+ // if (debug) console.log("Start Animation Action", act);
459
461
  return act;
460
462
  }
461
463
 
src/engine-components/Component.ts CHANGED
@@ -656,14 +656,17 @@
656
656
  }
657
657
 
658
658
  private static _forward: Vector3 = new Vector3();
659
+ /** Forward (0,0,-1) vector in world space */
659
660
  public get forward(): Vector3 {
660
661
  return Component._forward.set(0, 0, -1).applyQuaternion(this.worldQuaternion);
661
662
  }
662
663
  private static _right: Vector3 = new Vector3();
664
+ /** Right (1,0,0) vector in world space */
663
665
  public get right(): Vector3 {
664
666
  return Component._right.set(1, 0, 0).applyQuaternion(this.worldQuaternion);
665
667
  }
666
668
  private static _up: Vector3 = new Vector3();
669
+ /** Up (0,1,0) vector in world space */
667
670
  public get up(): Vector3 {
668
671
  return Component._up.set(0, 1, 0).applyQuaternion(this.worldQuaternion);
669
672
  }
src/engine/engine_gizmos.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  import type { Vec3, Vec4 } from './engine_types.js';
5
5
  import ThreeMeshUI, { Inline, Text } from "three-mesh-ui"
6
6
  import { getParam } from './engine_utils.js';
7
+ import { type Options } from 'three-mesh-ui/build/types/core/elements/MeshUIBaseElement.js';
7
8
 
8
9
  const _tmp = new Vector3();
9
10
  const _tmp2 = new Vector3();
@@ -176,20 +177,22 @@
176
177
  opacity = backgroundColor["a"]
177
178
  }
178
179
 
180
+ const props: Options = {
181
+ boxSizing: 'border-box',
182
+ fontFamily: this.familyName,
183
+ width: "auto",
184
+ fontSize: size,
185
+ color: color,
186
+ lineHeight: .75,
187
+ backgroundColor: backgroundColor ?? undefined,
188
+ backgroundOpacity: opacity,
189
+ textContent: text,
190
+ borderRadius: 1 * size,
191
+ padding: 1 * size,
192
+ };
193
+
179
194
  if (!element) {
180
- element = new Text({
181
- boxSizing: 'border-box',
182
- fontFamily: this.familyName,
183
- width: "auto",
184
- fontSize: size,
185
- color: color,
186
- lineHeight: .75,
187
- backgroundColor: backgroundColor ?? undefined,
188
- backgroundOpacity: opacity,
189
- textContent: text,
190
- borderRadius: 1 * size,
191
- padding: 1 * size,
192
- });
195
+ element = new Text(props);
193
196
  const global = this;
194
197
  const labelHandle = element as LabelHandle & Text;
195
198
  labelHandle.setText = function (str: string) {
@@ -198,14 +201,7 @@
198
201
  };
199
202
  }
200
203
  else {
201
- element.set({
202
- color: color,
203
- fontSize: size,
204
- backgroundColor: backgroundColor ?? undefined,
205
- backgroundOpacity: opacity,
206
- textContent: text,
207
- whiteSpace: 'pre',
208
- });
204
+ element.set(props);
209
205
  // const handle = element as any as LabelHandle;
210
206
  // handle.setText(text);
211
207
  }
src/engine/codegen/register_types.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { TypeStore } from "./../engine_typestore.js"
2
-
2
+
3
3
  // Import types
4
4
  import { __Ignore } from "../../engine-components/codegen/components.js";
5
5
  import { ActionBuilder } from "../../engine-components/export/usdz/extensions/behavior/BehavioursBuilder.js";
@@ -219,7 +219,7 @@
219
219
  import { XRGrabRendering } from "../../engine-components/webxr/WebXRGrabRendering.js";
220
220
  import { XRRig } from "../../engine-components/webxr/WebXRRig.js";
221
221
  import { XRState } from "../../engine-components/XRFlag.js";
222
-
222
+
223
223
  // Register types
224
224
  TypeStore.add("__Ignore", __Ignore);
225
225
  TypeStore.add("ActionBuilder", ActionBuilder);
src/engine-components/SceneSwitcher.ts CHANGED
@@ -468,7 +468,7 @@
468
468
  }
469
469
 
470
470
  private tryGetSceneEventListener(obj: Object3D, level: number = 0): ISceneEventListener | null {
471
- const sceneListener = GameObject.foreachComponent(obj, c => (c as any as ISceneEventListener).sceneClosing ? c : null) as ISceneEventListener | null;
471
+ const sceneListener = GameObject.foreachComponent(obj, c => (c as any as ISceneEventListener).sceneClosing ? c : undefined) as ISceneEventListener | undefined;
472
472
  // if we didnt find any component with the listener on the root object
473
473
  // we also check the first level of its children because a scene might be a group
474
474
  if (level === 0 && !sceneListener && obj.children.length) {
@@ -477,6 +477,7 @@
477
477
  if (res) return res;
478
478
  }
479
479
  }
480
+ if (!sceneListener) return null;
480
481
  return sceneListener;
481
482
  }
482
483
  }
src/engine-components/export/usdz/ThreeUSDZExporter.ts CHANGED
@@ -11,19 +11,20 @@
11
11
  WebGLRenderer,
12
12
  MathUtils,
13
13
  Matrix4,
14
- RepeatWrapping,
15
- MirroredRepeatWrapping,
16
14
  DoubleSide,
17
15
  BufferGeometry,
18
16
  Material,
19
- Camera,
20
17
  Color,
21
18
  MeshStandardMaterial,
22
19
  MeshPhysicalMaterial,
23
20
  Object3D,
24
21
  MeshBasicMaterial,
22
+ Bone,
25
23
  SkinnedMesh,
26
24
  SRGBColorSpace,
25
+ AnimationClip,
26
+ OrthographicCamera,
27
+ BufferAttribute
27
28
  } from 'three';
28
29
  import * as fflate from 'three/examples/jsm/libs/fflate.module.js';
29
30
 
@@ -37,6 +38,45 @@
37
38
  return str;
38
39
  }
39
40
 
41
+ // TODO check if this works when bones in the skeleton are completely unordered
42
+ function findCommonAncestor(objects: Object3D[]): Object3D | null {
43
+ if (objects.length === 0) return null;
44
+
45
+ const ancestors = objects.map((obj) => {
46
+ const objAncestors = new Array<Object3D>();
47
+ while (obj.parent) {
48
+ objAncestors.unshift(obj.parent);
49
+ obj = obj.parent;
50
+ }
51
+ return objAncestors;
52
+ });
53
+
54
+ //@ts-ignore – findLast seems to be missing in TypeScript types pre-5.x
55
+ const commonAncestor = ancestors[0].findLast((ancestor) => {
56
+ return ancestors.every((a) => a.includes(ancestor));
57
+ });
58
+
59
+ return commonAncestor || null;
60
+ }
61
+
62
+ function findStructuralNodesInBoneHierarchy(bones: Array<Object3D>) {
63
+
64
+ const commonAncestor = findCommonAncestor(bones);
65
+ // find all structural nodes – parents of bones that are not bones themselves
66
+ const structuralNodes = new Set<Object3D>();
67
+ for ( const bone of bones ) {
68
+ let current = bone.parent;
69
+ while ( current && current !== commonAncestor ) {
70
+ if ( !bones.includes(current) ) {
71
+ structuralNodes.add(current);
72
+ }
73
+ current = current.parent;
74
+ }
75
+ }
76
+
77
+ return structuralNodes;
78
+ }
79
+
40
80
  // TODO: remove once we update to TypeScript 5 that has proper types for OffscreenCanvas
41
81
  declare type OffsetCanvasExt = OffscreenCanvas & {
42
82
  convertToBlob: (options?: any) => Promise<Blob>;
@@ -54,10 +94,12 @@
54
94
  get isDynamic() { return this._isDynamic; }
55
95
  private set isDynamic( value ) { this._isDynamic = value; }
56
96
  geometry: BufferGeometry | null;
57
- material: Material | null;
58
- camera: Camera | null;
97
+ material: MeshStandardMaterial | MeshBasicMaterial | Material | null;
98
+ camera: PerspectiveCamera | OrthographicCamera | null;
59
99
  parent: USDObject | null;
100
+ skinnedMesh: SkinnedMesh | null;
60
101
  children: Array<USDObject | null> = [];
102
+ animations: AnimationClip[] | null;
61
103
  _eventListeners: {};
62
104
 
63
105
  static createEmptyParent( object ) {
@@ -79,7 +121,7 @@
79
121
  return empty;
80
122
  }
81
123
 
82
- constructor( id, name, matrix, mesh: BufferGeometry | null = null, material: Material | null = null, camera: Camera | null = null ) {
124
+ constructor( id, name, matrix, mesh: BufferGeometry | null = null, material: MeshStandardMaterial | MeshBasicMaterial | Material | null = null, camera: PerspectiveCamera | OrthographicCamera | null = null, skinnedMesh: SkinnedMesh | null = null, animations: AnimationClip[] | null = null ) {
83
125
 
84
126
  this.uuid = id;
85
127
  this.name = makeNameSafe( name );
@@ -91,6 +133,8 @@
91
133
  this.children = [];
92
134
  this._eventListeners = {};
93
135
  this._isDynamic = false;
136
+ this.skinnedMesh = skinnedMesh;
137
+ this.animations = animations;
94
138
 
95
139
  }
96
140
 
@@ -230,7 +274,7 @@
230
274
 
231
275
  }
232
276
 
233
- traverse( callback, current: USDObject | null = null ) {
277
+ traverse( callback: ( object: USDObject ) => void, current: USDObject | null = null ) {
234
278
 
235
279
  if ( current !== null ) callback( current );
236
280
  else current = this;
@@ -246,7 +290,7 @@
246
290
 
247
291
  }
248
292
 
249
- findById( uuid ) {
293
+ findById( uuid: string ) {
250
294
 
251
295
  let found = false;
252
296
  function search( current ) {
@@ -277,7 +321,7 @@
277
321
  }
278
322
 
279
323
 
280
- buildHeader() {
324
+ buildHeader( endTimeCode ) {
281
325
 
282
326
  return `#usda 1.0
283
327
  (
@@ -288,7 +332,7 @@
288
332
  metersPerUnit = 1
289
333
  upAxis = "Y"
290
334
  startTimeCode = 0
291
- endTimeCode = ${this.stageLength}
335
+ endTimeCode = ${endTimeCode}
292
336
  timeCodesPerSecond = 60
293
337
  framesPerSecond = 60
294
338
  )
@@ -319,21 +363,32 @@
319
363
 
320
364
  }
321
365
 
322
- beginBlock( str ) {
366
+ beginBlock( str: string | undefined = undefined, char = '{', createNewLine = true ) {
323
367
 
324
- str = this.applyIndent( str );
325
- this.str += str;
368
+ if ( str !== undefined ) {
369
+ str = this.applyIndent( str );
370
+ this.str += str;
371
+ if ( createNewLine ) {
372
+ this.str += newLine;
373
+ this.str += this.applyIndent( char );
374
+ }
375
+ else {
376
+ this.str += " " + char;
377
+ }
378
+ }
379
+ else {
380
+ this.str += this.applyIndent( char );
381
+ }
382
+
326
383
  this.str += newLine;
327
- this.str += this.applyIndent( '{' );
328
- this.str += newLine;
329
384
  this.indent += 1;
330
385
 
331
386
  }
332
387
 
333
- closeBlock() {
388
+ closeBlock( char = '}' ) {
334
389
 
335
390
  this.indent -= 1;
336
- this.str += this.applyIndent( '}' ) + newLine;
391
+ this.str += this.applyIndent( char ) + newLine;
337
392
 
338
393
  }
339
394
 
@@ -386,6 +441,7 @@
386
441
  files: {};
387
442
  document: USDDocument;
388
443
  output: string;
444
+ animations: AnimationClip[];
389
445
 
390
446
  constructor( root, exporter, extensions ) {
391
447
 
@@ -400,6 +456,7 @@
400
456
  this.files = {};
401
457
  this.document = new USDDocument();
402
458
  this.output = '';
459
+ this.animations = [];
403
460
 
404
461
  }
405
462
 
@@ -420,6 +477,7 @@
420
477
  };
421
478
  quickLookCompatible: boolean = false;
422
479
  extensions: any[] = [];
480
+ maxTextureSize: number = 4096;
423
481
  }
424
482
 
425
483
  class USDZExporter {
@@ -433,6 +491,20 @@
433
491
 
434
492
  }
435
493
 
494
+ getEndTimeCode( animations ) {
495
+ let endTimeCode = 0;
496
+
497
+ for( const animation of animations ) {
498
+ const currentEndTimeCode = animation.duration * 60;
499
+
500
+ if ( endTimeCode < currentEndTimeCode ) {
501
+ endTimeCode = currentEndTimeCode;
502
+ }
503
+ }
504
+
505
+ return endTimeCode;
506
+ }
507
+
436
508
  async parse( scene, options: USDZExporterOptions = new USDZExporterOptions() ) {
437
509
 
438
510
  options = Object.assign( new USDZExporterOptions(), options );
@@ -453,6 +525,26 @@
453
525
 
454
526
  await invokeAll( context, 'onBeforeBuildDocument' );
455
527
 
528
+ // HACK let's find all skeletons and reparent them to their skelroot / armature / uppermost bone parent
529
+ const reparentings: Array<any> = [];
530
+ scene.traverseVisible(object => {
531
+
532
+ if (object.isSkinnedMesh) {
533
+ const bones = object.skeleton.bones as Bone[];
534
+
535
+ const commonAncestor = findCommonAncestor(bones);
536
+ if (commonAncestor) {
537
+ reparentings.push( { object, originalParent: object.parent, newParent: commonAncestor } );
538
+ }
539
+ }
540
+ });
541
+
542
+ for ( const reparenting of reparentings ) {
543
+ const { object, originalParent, newParent } = reparenting;
544
+ if (this.debug) console.log("REPARENTING", object, "from", originalParent, "to", newParent);
545
+ newParent.add( object );
546
+ }
547
+
456
548
  traverseVisible( scene, context.document, context );
457
549
 
458
550
  await invokeAll( context, 'onAfterBuildDocument' );
@@ -465,10 +557,18 @@
465
557
 
466
558
  await invokeAll( context, 'onAfterSerialize' );
467
559
 
560
+ // repair the parenting again
561
+ for ( const reparenting of reparentings ) {
562
+ const { object, originalParent, newParent } = reparenting;
563
+ originalParent.add( object );
564
+ }
565
+
468
566
  // Moved into parseDocument callback for proper defaultPrim encapsulation
469
567
  // context.output += buildMaterials( materials, textures, options.quickLookCompatible );
470
568
 
471
- const header = context.document.buildHeader();
569
+ const endTimeCode = this.getEndTimeCode( context.animations );
570
+
571
+ const header = context.document.buildHeader( endTimeCode );
472
572
  const final = header + '\n' + context.output;
473
573
 
474
574
  // full output file
@@ -486,14 +586,16 @@
486
586
 
487
587
  const isRGBA = formatsWithAlphaChannel.includes( texture.format );
488
588
 
489
- if ( texture.isCompressedTexture ) {
589
+ if ( texture.isCompressedTexture || texture.isRenderTargetTexture ) {
490
590
 
491
- texture = decompressGpuTexture( texture, 4096, decompressionRenderer );
591
+ texture = decompressGpuTexture( texture, options.maxTextureSize, decompressionRenderer );
492
592
 
493
593
  }
494
594
 
495
595
  // TODO add readback options for textures that don't have texture.image
496
- const canvas = await imageToCanvas( texture.image );
596
+ const canvas = await imageToCanvas( texture.image ).catch( err => {
597
+ console.error("Error converting texture to canvas", texture, err);
598
+ });
497
599
 
498
600
  if ( canvas ) {
499
601
 
@@ -551,7 +653,7 @@
551
653
  let geometry: BufferGeometry | undefined = undefined;
552
654
  let material: Material | Material[] | undefined = undefined;
553
655
 
554
- if (object instanceof Mesh) {
656
+ if (object instanceof Mesh || object instanceof SkinnedMesh) {
555
657
  geometry = object.geometry;
556
658
  material = object.material;
557
659
  }
@@ -564,12 +666,13 @@
564
666
  material = undefined;
565
667
  }
566
668
 
567
- if ( object instanceof Mesh && material && (material instanceof MeshStandardMaterial || material instanceof MeshBasicMaterial) && ! (object instanceof SkinnedMesh )) {
669
+ if ( (object instanceof Mesh || object instanceof SkinnedMesh) && material && (material instanceof MeshStandardMaterial || material instanceof MeshBasicMaterial)) {
568
670
 
569
671
  const name = getObjectId( object );
570
- model = new USDObject( object.uuid, name, object.matrix, geometry, material );
672
+ const skinnedMeshObject = object instanceof SkinnedMesh ? object : null;
673
+ model = new USDObject( object.uuid, name, object.matrix, geometry, material, undefined, skinnedMeshObject, object.animations );
571
674
 
572
- } else if ( object instanceof Camera ) {
675
+ } else if ( object instanceof PerspectiveCamera || object instanceof OrthographicCamera ) {
573
676
 
574
677
  const name = getObjectId( object );
575
678
  model = new USDObject( object.uuid, name, object.matrix, undefined, undefined, object );
@@ -577,7 +680,7 @@
577
680
  } else {
578
681
 
579
682
  const name = getObjectId( object );
580
- model = new USDObject( object.uuid, name, object.matrix );
683
+ model = new USDObject( object.uuid, name, object.matrix, undefined, undefined, undefined, undefined, object.animations );
581
684
 
582
685
  }
583
686
 
@@ -673,20 +776,23 @@
673
776
 
674
777
  }
675
778
 
676
- function addResources( object, context: USDZExporterContext ) {
779
+ function addResources( object: USDObject | null, context: USDZExporterContext ) {
677
780
 
781
+ if ( object == null ) {
782
+ return;
783
+ }
678
784
  const geometry = object.geometry;
679
785
  const material = object.material;
680
786
 
681
787
  if ( geometry ) {
682
788
 
683
- if ( material.isMeshStandardMaterial || material.isMeshBasicMaterial ) { // TODO convert unlit to lit+emissive
789
+ if ( material && ( 'isMeshStandardMaterial' in material && material.isMeshStandardMaterial || 'isMeshBasicMaterial' in material && material.isMeshBasicMaterial ) ) { // TODO convert unlit to lit+emissive
684
790
 
685
791
  const geometryFileName = 'geometries/Geometry_' + geometry.id + '.usd';
686
792
 
687
793
  if ( ! ( geometryFileName in context.files ) ) {
688
794
 
689
- const meshObject = buildMeshObject( geometry );
795
+ const meshObject = buildMeshObject( geometry, object.skinnedMesh?.skeleton?.bones );
690
796
  context.files[ geometryFileName ] = buildUSDFileAsString( meshObject, context );
691
797
 
692
798
  }
@@ -824,12 +930,12 @@
824
930
 
825
931
  }
826
932
 
827
- async function imageToCanvas( image, color: string | undefined = undefined, flipY = false ) {
933
+ async function imageToCanvas( image, color: string | undefined = undefined, flipY = false, maxTextureSize = 4096 ) {
828
934
 
829
935
  if ( isImageBitmap( image ) ) {
830
936
 
831
937
  // max. canvas size on Safari is still 4096x4096
832
- const scale = 4096 / Math.max( image.width, image.height );
938
+ const scale = maxTextureSize / Math.max( image.width, image.height );
833
939
 
834
940
  const canvas = new OffscreenCanvas( image.width * Math.min( 1, scale ), image.height * Math.min( 1, scale ) );
835
941
 
@@ -911,21 +1017,37 @@
911
1017
 
912
1018
  }
913
1019
 
1020
+ function getBoneName(bone) {
1021
+ return makeNameSafe(bone.name || 'bone_' + bone.uuid);
1022
+ }
1023
+
1024
+ function getPathToSkeleton(bone: Object3D, assumedRoot: Object3D) {
1025
+ let path = getBoneName(bone);
1026
+ let current = bone.parent;
1027
+ while ( current && current !== assumedRoot ) {
1028
+ path = getBoneName(current) + '/' + path;
1029
+ current = current.parent;
1030
+ }
1031
+ return path;
1032
+ }
1033
+
914
1034
  // Xform
915
1035
 
916
- export function buildXform( model, writer, context ) {
1036
+ export function buildXform( model: USDObject | null, writer, context ) {
917
1037
 
1038
+ if ( model == null)
1039
+ return;
1040
+
918
1041
  const matrix = model.matrix;
919
1042
  const geometry = model.geometry;
920
1043
  const material = model.material;
921
1044
  const camera = model.camera;
922
1045
  const name = model.name;
923
1046
 
924
- // postprocess node
925
- if ( model.onBeforeSerialize ) {
926
-
927
- model.onBeforeSerialize( writer, context );
928
-
1047
+ if ( model.animations ) {
1048
+ for ( const animation of model.animations ) {
1049
+ context.animations.push( animation )
1050
+ }
929
1051
  }
930
1052
 
931
1053
  const transform = buildMatrix( matrix );
@@ -936,11 +1058,16 @@
936
1058
 
937
1059
  }
938
1060
 
1061
+ const isSkinnedMesh = geometry && geometry.isBufferGeometry && geometry.attributes.skinIndex !== undefined && geometry.attributes.skinIndex.count > 0;
1062
+ const objType = isSkinnedMesh ? 'SkelRoot' : 'Xform';
1063
+ const apiSchemas = isSkinnedMesh ? '"MaterialBindingAPI", "SkelBindingAPI"' : '"MaterialBindingAPI"';
1064
+
939
1065
  if ( geometry ) {
940
- writer.beginBlock( `def Xform "${name}" (
941
- prepend references = @./geometries/Geometry_${geometry.id}.usd@</Geometry>
942
- ${material ? 'prepend apiSchemas = ["MaterialBindingAPI"]' : ''}
943
- )` );
1066
+ writer.beginBlock( `def ${objType} "${name}"`, "(", false );
1067
+ writer.appendLine(`prepend references = @./geometries/Geometry_${geometry.id}.usd@</Geometry>`);
1068
+ writer.appendLine(`prepend apiSchemas = [${apiSchemas}]`);
1069
+ writer.closeBlock( ")" );
1070
+ writer.beginBlock();
944
1071
  }
945
1072
  else if ( camera )
946
1073
  writer.beginBlock( `def Camera "${name}"` );
@@ -948,20 +1075,29 @@
948
1075
  writer.beginBlock( `def Xform "${name}"` );
949
1076
 
950
1077
  if ( geometry && material )
951
- writer.appendLine( `rel material:binding = ${materialRoot}/Material_${material.id}>` );
952
- writer.appendLine( `matrix4d xformOp:transform = ${transform}` );
1078
+ writer.appendLine( `rel material:binding = </StageRoot/Materials/Material_${material.id}>` );
1079
+
1080
+ if ( isSkinnedMesh ) {
1081
+ writer.appendLine( `rel skel:skeleton = <Rig>` );
1082
+ writer.appendLine( `rel skel:animationSource = <Rig/_anim>`);
1083
+ // writer.appendLine( `matrix4d xformOp:transform = ${transform}` );
1084
+ writer.appendLine( `matrix4d xformOp:transform = ${buildMatrix(new Matrix4())}` ); // seems to always be identity / in world space
1085
+ }
1086
+ else {
1087
+ writer.appendLine( `matrix4d xformOp:transform = ${transform}` );
1088
+ }
953
1089
  writer.appendLine( 'uniform token[] xformOpOrder = ["xformOp:transform"]' );
954
1090
 
955
1091
  if ( camera ) {
956
1092
 
957
- if ( camera.isOrthographicCamera ) {
1093
+ if ( 'isOrthographicCamera' in camera && camera.isOrthographicCamera ) {
958
1094
 
959
1095
  writer.appendLine( `float2 clippingRange = (${camera.near}, ${camera.far})` );
960
1096
  writer.appendLine( `float horizontalAperture = ${( ( Math.abs( camera.left ) + Math.abs( camera.right ) ) * 10 ).toPrecision( PRECISION )}` );
961
1097
  writer.appendLine( `float verticalAperture = ${( ( Math.abs( camera.top ) + Math.abs( camera.bottom ) ) * 10 ).toPrecision( PRECISION )}` );
962
1098
  writer.appendLine( 'token projection = "orthographic"' );
963
1099
 
964
- } else {
1100
+ } else if ( 'isPerspectiveCamera' in camera && camera.isPerspectiveCamera) {
965
1101
 
966
1102
  writer.appendLine( `float2 clippingRange = (${camera.near.toPrecision( PRECISION )}, ${camera.far.toPrecision( PRECISION )})` );
967
1103
  writer.appendLine( `float focalLength = ${camera.getFocalLength().toPrecision( PRECISION )}` );
@@ -969,7 +1105,6 @@
969
1105
  writer.appendLine( `float horizontalAperture = ${camera.getFilmWidth().toPrecision( PRECISION )}` );
970
1106
  writer.appendLine( 'token projection = "perspective"' );
971
1107
  writer.appendLine( `float verticalAperture = ${camera.getFilmHeight().toPrecision( PRECISION )}` );
972
-
973
1108
  }
974
1109
 
975
1110
  }
@@ -995,9 +1130,9 @@
995
1130
 
996
1131
  }
997
1132
 
998
- function fn( num ) {
1133
+ function fn( num:number ): string {
999
1134
 
1000
- return num.toFixed( 10 );
1135
+ return Number.isInteger(num) ? num.toString() : num.toFixed( 10 );
1001
1136
 
1002
1137
  }
1003
1138
 
@@ -1017,9 +1152,9 @@
1017
1152
 
1018
1153
  // Mesh
1019
1154
 
1020
- function buildMeshObject( geometry ) {
1155
+ function buildMeshObject( geometry, bonesArray: Bone[] = [] ) {
1021
1156
 
1022
- const mesh = buildMesh( geometry );
1157
+ const mesh = buildMesh( geometry, bonesArray );
1023
1158
  return `
1024
1159
  def "Geometry"
1025
1160
  {
@@ -1029,14 +1164,69 @@
1029
1164
 
1030
1165
  }
1031
1166
 
1032
- function buildMesh( geometry ) {
1167
+ function buildMesh( geometry, bones: Bone[] = [] ) {
1033
1168
 
1034
1169
  const name = 'Geometry';
1035
1170
  const attributes = geometry.attributes;
1036
1171
  const count = attributes.position.count;
1037
1172
 
1173
+ const hasBones = bones && bones.length > 0;
1174
+
1175
+ // We need to sort bones and all skinning data by path –
1176
+ // Neither glTF nor three.js care, but in USD they must be sorted
1177
+ // since the array defines the virtual hierarchy and is evaluated in that order
1178
+ const sortedBones: Array<{bone: Object3D, index: number}> = [];
1179
+ const indexMapping: number[] = [];
1180
+ let sortedSkinIndex = new Array<number>();
1181
+ let sortedSkinIndexAttribute: BufferAttribute | null = attributes.skinIndex;
1182
+ let bonesArray = "";
1183
+ if (hasBones) {
1184
+
1185
+ for ( const index in bones ) {
1186
+ sortedBones.push( { bone: bones[index], index: parseInt(index) } );
1187
+ }
1188
+
1189
+ // add structural nodes to the list of bones
1190
+ for ( const structuralNode of findStructuralNodesInBoneHierarchy(bones) ) {
1191
+ sortedBones.push( { bone: structuralNode, index: sortedBones.length } );
1192
+ }
1193
+
1194
+ // sort bones by path – need to be sorted in the same order as during mesh export
1195
+ const assumedRoot = bones[0].parent!;
1196
+ sortedBones.sort((a, b) => getPathToSkeleton(a.bone, assumedRoot) > getPathToSkeleton(b.bone, assumedRoot) ? 1 : -1);
1197
+ bonesArray = sortedBones.map( x => "\"" + getPathToSkeleton(x.bone, assumedRoot) + "\"" ).join( ', ' );
1198
+
1199
+ // TODO we can probably skip the expensive attribute re-ordering if the bones were already in a correct order.
1200
+ // That doesn't mean that they are strictly sorted by path – just that all parents strictly need to come first.
1201
+
1202
+ // build index mapping
1203
+ for (const i in sortedBones) {
1204
+ indexMapping[sortedBones[i].index] = parseInt(i);
1205
+ }
1206
+
1207
+ // remap skin index attributes
1208
+ const skinIndex = attributes.skinIndex;
1209
+ sortedSkinIndex = new Array<number>();
1210
+ for ( let i = 0; i < skinIndex.count; i ++ ) {
1211
+
1212
+ const x = skinIndex.getX( i );
1213
+ const y = skinIndex.getY( i );
1214
+ const z = skinIndex.getZ( i );
1215
+ const w = skinIndex.getW( i );
1216
+
1217
+ sortedSkinIndex.push( indexMapping[x], indexMapping[y], indexMapping[z], indexMapping[w] );
1218
+ }
1219
+
1220
+ // turn it back into an attribute so the rest of the code doesn't need to learn a new thing
1221
+ sortedSkinIndexAttribute = new BufferAttribute( new Uint16Array( sortedSkinIndex ), 4 );
1222
+ }
1223
+
1224
+ const isSkinnedMesh = attributes.skinWeight && attributes.skinIndex;
1225
+
1038
1226
  return `
1039
- def Mesh "${name}"
1227
+ def Mesh "${name}" ${isSkinnedMesh ? `(
1228
+ prepend apiSchemas = ["SkelBindingAPI"]
1229
+ )` : ''}
1040
1230
  {
1041
1231
  int[] faceVertexCounts = [${buildMeshVertexCount( geometry )}]
1042
1232
  int[] faceVertexIndices = [${buildMeshVertexIndices( geometry )}]
@@ -1052,6 +1242,24 @@
1052
1242
  `texCoord2f[] primvars:st2 = [${buildVector2Array( attributes.uv2, count )}] (
1053
1243
  interpolation = "vertex"
1054
1244
  )` : '' }
1245
+ ${isSkinnedMesh ?
1246
+ `matrix4d primvars:skel:geomBindTransform = ( (1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0), (0, 0, 0, 1) ) (
1247
+ elementSize = 1
1248
+ interpolation = "constant"
1249
+ )` : '' }
1250
+ ${attributes.skinIndex ?
1251
+ `int[] primvars:skel:jointIndices = [${buildVector4Array( sortedSkinIndexAttribute, true )}] (
1252
+ elementSize = 4
1253
+ interpolation = "vertex"
1254
+ )` : '' }
1255
+ ${attributes.skinWeight ?
1256
+ `float[] primvars:skel:jointWeights = [${buildVector4Array( attributes.skinWeight )}] (
1257
+ elementSize = 4
1258
+ interpolation = "vertex"
1259
+ )` : '' }
1260
+ ${hasBones ?
1261
+ //`uniform token[] skel:blendShapes
1262
+ `uniform token[] skel:joints = [${bonesArray}]` : '' }
1055
1263
  uniform token subdivisionScheme = "none"
1056
1264
  }
1057
1265
  `;
@@ -1120,6 +1328,27 @@
1120
1328
 
1121
1329
  }
1122
1330
 
1331
+ function buildVector4Array( attribute, ints = false ) {
1332
+ const array: Array<string> = [];
1333
+
1334
+ for ( let i = 0; i < attribute.count; i ++ ) {
1335
+
1336
+ const x = attribute.getX( i );
1337
+ const y = attribute.getY( i );
1338
+ const z = attribute.getZ( i );
1339
+ const w = attribute.getW( i );
1340
+
1341
+ array.push( `${ints ? x : x.toPrecision( PRECISION )}` );
1342
+ array.push( `${ints ? y : y.toPrecision( PRECISION )}` );
1343
+ array.push( `${ints ? z : z.toPrecision( PRECISION )}` );
1344
+ array.push( `${ints ? w : w.toPrecision( PRECISION )}` );
1345
+
1346
+ }
1347
+
1348
+ return array.join( ', ' );
1349
+
1350
+ }
1351
+
1123
1352
  function buildVector2Array( attribute, count ) {
1124
1353
 
1125
1354
  if ( attribute === undefined ) {
@@ -1480,4 +1709,18 @@
1480
1709
  36492, // RGBA_BPTC_Format
1481
1710
  ];
1482
1711
 
1483
- export { USDZExporter, USDZExporterContext, USDWriter, USDObject, buildMatrix, USDDocument, makeNameSafe as makeNameSafeForUSD, imageToCanvas, decompressGpuTexture };
1712
+ export {
1713
+ USDZExporter,
1714
+ USDZExporterContext,
1715
+ USDWriter,
1716
+ USDObject,
1717
+ buildMatrix,
1718
+ getBoneName,
1719
+ getPathToSkeleton,
1720
+ fn as usdNumberFormatting,
1721
+ USDDocument,
1722
+ makeNameSafe as makeNameSafeForUSD,
1723
+ imageToCanvas,
1724
+ decompressGpuTexture,
1725
+ findStructuralNodesInBoneHierarchy,
1726
+ };
src/engine-components/export/usdz/USDZExporter.ts CHANGED
@@ -63,11 +63,15 @@
63
63
  @serializable()
64
64
  interactive: boolean = true;
65
65
 
66
- extensions: IUSDExporterExtension[] = [];
67
-
68
66
  @serializable()
69
67
  allowCreateQuicklookButton: boolean = true;
70
68
 
69
+ @serializable()
70
+ quickLookCompatible: boolean = true;
71
+
72
+ // Registered extensions. Add your own extensions here
73
+ extensions: IUSDExporterExtension[] = [];
74
+
71
75
  private link!: HTMLAnchorElement;
72
76
  private webxr?: WebXR;
73
77
 
@@ -137,50 +141,55 @@
137
141
  this.exportAsync();
138
142
  }
139
143
 
144
+ /**
145
+ * Creates an USDZ file from the current scene or assigned objectToExport and opens it in QuickLook.
146
+ * Use the various public properties of USDZExporter to customize export behaviour.
147
+ */
140
148
  async exportAsync() {
141
149
 
142
150
  let name = this.exportFileName ?? this.objectToExport?.name ?? this.name;
143
-
151
+ name += "-" + getFormattedDate(); // seems iOS caches the file in some cases, this ensures we always have a fresh file
152
+
144
153
  if (!hasProLicense()) {
145
154
  if (name !== "") name += "-";
146
155
  name += "MadeWithNeedle";
147
156
  }
148
- name += "-" + getFormattedDate(); // seems iOS caches the file in some cases, this ensures we always have a fresh file
149
157
 
150
158
  if (!this.link) this.link = ensureQuicklookLinkIsCreated(this.context);
151
159
 
152
160
  // ability to specify a custom USDZ file to be used instead of a dynamic one
153
161
  if (this.customUsdzFile) {
154
- if(debug) console.log("Exporting custom usdz", this.customUsdzFile)
155
-
156
- // see https://developer.apple.com/documentation/arkit/adding_an_apple_pay_button_or_a_custom_action_in_ar_quick_look
157
- const overlay = this.buildQuicklookOverlay();
158
- if (debug) console.log("QuickLook Overlay", overlay);
159
- const callToAction = overlay.callToAction ? encodeURIComponent(overlay.callToAction) : "";
160
- const checkoutTitle = overlay.checkoutTitle ? encodeURIComponent(overlay.checkoutTitle) : "";
161
- const checkoutSubtitle = overlay.checkoutSubtitle ? encodeURIComponent(overlay.checkoutSubtitle) : "";
162
- this.link.href = this.customUsdzFile + `#callToAction=${callToAction}&checkoutTitle=${checkoutTitle}&checkoutSubtitle=${checkoutSubtitle}&callToActionURL=${overlay.callToActionURL}`;
163
-
164
- if(debug) console.log(this.link.href)
165
-
166
- if (!this.lastCallback) {
167
- this.lastCallback = this.quicklookCallback.bind(this)
168
- this.link.addEventListener('message', this.lastCallback);
169
- }
170
-
171
- // open quicklook
172
- this.link.download = name + ".usdz";
173
- this.link.click();
162
+ if (debug) console.log("Exporting custom usdz", this.customUsdzFile)
163
+ this.openInQuickLook(this.customUsdzFile, name);
174
164
  return;
175
165
  }
176
166
 
177
- if (!this.objectToExport) {
167
+ const blob = await this.export(this.objectToExport);
168
+ if (!blob) {
178
169
  console.warn("No object to export", this);
179
170
  return;
180
171
  }
181
172
 
173
+ if (debug) console.log("USDZ generation done. Downloading as " + this.link.download);
174
+
175
+ // TODO detect QuickLook availability:
176
+ // https://webkit.org/blog/8421/viewing-augmented-reality-assets-in-safari-for-ios/#:~:text=inside%20the%20anchor.-,Feature%20Detection,-To%20detect%20support
177
+
178
+
179
+ this.openInQuickLook(blob, name);
180
+ }
181
+
182
+ /**
183
+ * Creates an USDZ file from the current scene or assigned objectToExport and opens it in QuickLook.
184
+ * @returns a Promise<Blob> containing the USDZ file
185
+ */
186
+ async export(objectToExport: Object3D | undefined) : Promise<Blob | null> {
187
+
188
+ if (!objectToExport)
189
+ return null;
190
+
182
191
  // trigger progressive textures to be loaded:
183
- const renderers = GameObject.getComponentsInChildren(this.objectToExport, Renderer);
192
+ const renderers = GameObject.getComponentsInChildren(objectToExport, Renderer);
184
193
  const progressiveLoading = new Array<Promise<any>>();
185
194
  for (const rend of renderers) {
186
195
  for (const mat of rend.sharedMaterials) {
@@ -206,15 +215,19 @@
206
215
  const extensions: any = [...this.extensions]
207
216
 
208
217
  // collect animators and their clips
209
- const animExt = new AnimationExtension();
218
+ const animExt = new AnimationExtension(this.quickLookCompatible);
210
219
  extensions.push(animExt);
211
220
 
212
- if (this.autoExportAnimations)
213
- registerAnimatorsImplictly(this.objectToExport, animExt);
214
-
215
- const eventArgs = { self: this, exporter: exporter, extensions: extensions, object: this.objectToExport };
221
+ const eventArgs = { self: this, exporter: exporter, extensions: extensions, object: objectToExport };
216
222
  this.dispatchEvent(new CustomEvent("before-export", { detail: eventArgs }))
217
223
 
224
+ // Implicit registration and actions for Animators and Animation components
225
+ // Currently, Animators properly build PlayAnimation actions, but Animation components don't.
226
+ const implicitAnimationBehaviors = new Array<Object3D>();
227
+ if (this.autoExportAnimations) {
228
+ implicitAnimationBehaviors.push(...registerAnimatorsImplictly(objectToExport, animExt));
229
+ }
230
+
218
231
  //@ts-ignore
219
232
  exporter.debug = debug;
220
233
 
@@ -235,12 +248,20 @@
235
248
  },
236
249
  },
237
250
  extensions: extensions,
238
- quickLookCompatible: true,
251
+ quickLookCompatible: this.quickLookCompatible,
239
252
  });
240
253
  const blob = new Blob([arraybuffer], { type: 'application/octet-stream' });
241
254
 
255
+ // cleanup – implicit animation behaviors need to be removed again
256
+ for (const go of implicitAnimationBehaviors) {
257
+ GameObject.destroy(go);
258
+ }
259
+
242
260
  this.dispatchEvent(new CustomEvent("after-export", { detail: eventArgs }))
243
261
 
262
+ // restore XR flags
263
+ XRState.Global.Set(currentXRState);
264
+
244
265
  // second file: USDA (without assets)
245
266
  //@ts-ignore
246
267
  // const usda = exporter.lastUsda;
@@ -249,15 +270,26 @@
249
270
  // this.link.href = URL.createObjectURL(blob2);
250
271
  // this.link.click();
251
272
 
273
+ return blob;
274
+ }
275
+
276
+ /**
277
+ * Opens QuickLook on iOS/iPadOS/visionOS with the given content in AR mode.
278
+ * @param content The URL to the .usdz or .reality file or a blob containing an USDZ file.
279
+ * @param name Download filename
280
+ */
281
+ openInQuickLook(content: Blob | string, name: string) {
282
+
283
+ const url = content instanceof Blob ? URL.createObjectURL(content) : content;
284
+
252
285
  // see https://developer.apple.com/documentation/arkit/adding_an_apple_pay_button_or_a_custom_action_in_ar_quick_look
253
286
  const overlay = this.buildQuicklookOverlay();
254
287
  if (debug) console.log("QuickLook Overlay", overlay);
255
288
  const callToAction = overlay.callToAction ? encodeURIComponent(overlay.callToAction) : "";
256
289
  const checkoutTitle = overlay.checkoutTitle ? encodeURIComponent(overlay.checkoutTitle) : "";
257
290
  const checkoutSubtitle = overlay.checkoutSubtitle ? encodeURIComponent(overlay.checkoutSubtitle) : "";
258
- this.link.href = URL.createObjectURL(blob) + `#callToAction=${callToAction}&checkoutTitle=${checkoutTitle}&checkoutSubtitle=${checkoutSubtitle}&callToActionURL=${overlay.callToActionURL}`;
291
+ this.link.href = url + `#callToAction=${callToAction}&checkoutTitle=${checkoutTitle}&checkoutSubtitle=${checkoutSubtitle}&callToActionURL=${overlay.callToActionURL}`;
259
292
 
260
-
261
293
  if (!this.lastCallback) {
262
294
  this.lastCallback = this.quicklookCallback.bind(this)
263
295
  this.link.addEventListener('message', this.lastCallback);
@@ -267,15 +299,35 @@
267
299
  this.link.download = name + ".usdz";
268
300
  this.link.click();
269
301
 
270
- if (debug) console.log("USDZ generation done. Downloading as " + this.link.download);
302
+ // cleanup
303
+ // TODO check if we can do that immediately or need to wait until the user returns
304
+ // if (content instanceof Blob) URL.revokeObjectURL(url);
305
+ }
271
306
 
272
- // restore XR flags
273
- XRState.Global.Set(currentXRState);
274
-
275
- // TODO detect QuickLook availability:
276
- // https://webkit.org/blog/8421/viewing-augmented-reality-assets-in-safari-for-ios/#:~:text=inside%20the%20anchor.-,Feature%20Detection,-To%20detect%20support
307
+ /**
308
+ * Downloads the given blob as a file.
309
+ */
310
+ download(blob: Blob, name: string) {
311
+ USDZExporter.save(blob, name);
277
312
  }
278
313
 
314
+ // Matches GltfExport.save(blob, filename)
315
+ private static save(blob, filename) {
316
+ const link = document.createElement('a');
317
+ link.style.display = 'none';
318
+ document.body.appendChild(link); // Firefox workaround, see #6594
319
+ if (typeof blob === "string")
320
+ link.href = blob;
321
+ else
322
+ link.href = URL.createObjectURL(blob);
323
+ link.download = filename;
324
+ link.click();
325
+ link.remove();
326
+ // console.log(link.href);
327
+ // URL.revokeObjectURL( url ); breaks Firefox...
328
+ }
329
+
330
+
279
331
  private lastCallback?: any;
280
332
 
281
333
  private quicklookCallback(event: Event) {
src/engine-components/export/usdz/extensions/USDZText.ts CHANGED
@@ -75,9 +75,10 @@
75
75
 
76
76
  writeTo(_document: USDDocument | undefined, writer: USDWriter) {
77
77
 
78
- writer.beginBlock( `def Preliminary_Text "${this.id}" (
79
- prepend apiSchemas = ["MaterialBindingAPI"]
80
- )` );
78
+ writer.beginBlock( `def Preliminary_Text "${this.id}"`, "(", false);
79
+ writer.appendLine(`prepend apiSchemas = ["MaterialBindingAPI"]`);
80
+ writer.closeBlock( ")" );
81
+ writer.beginBlock();
81
82
 
82
83
  if (this.content)
83
84
  writer.appendLine(`string content = "${this.content}"`);