Needle Engine

Changes between version 3.50.0-beta and 3.51.0
Files changed (18) hide show
  1. src/engine-components/export/usdz/extensions/Animation.ts +183 -45
  2. src/engine-components/export/usdz/extensions/behavior/Behaviour.ts +132 -110
  3. src/engine-components/export/usdz/extensions/behavior/BehaviourComponents.ts +64 -8
  4. src/engine-components/export/usdz/extensions/behavior/BehavioursBuilder.ts +27 -8
  5. src/engine/debug/debug_console.ts +17 -4
  6. src/engine/engine_element.ts +10 -4
  7. src/engine/engine_gameobject.ts +3 -0
  8. src/engine/engine_gltf_builtin_components.ts +1 -1
  9. src/engine/engine_networking_instantiate.ts +21 -6
  10. src/engine/engine_networking.ts +3 -2
  11. src/engine/engine_time_utils.ts +1 -1
  12. src/engine-components/export/usdz/index.ts +1 -1
  13. src/engine-components/export/usdz/extensions/behavior/PhysicsExtension.ts +1 -3
  14. src/engine-components-experimental/networking/PlayerSync.ts +5 -0
  15. src/engine-components/RigidBody.ts +4 -1
  16. src/engine-components/ShadowCatcher.ts +3 -0
  17. src/engine-components/export/usdz/ThreeUSDZExporter.ts +53 -4
  18. src/engine-components/export/usdz/USDZExporter.ts +49 -10
src/engine-components/export/usdz/extensions/Animation.ts CHANGED
@@ -21,14 +21,17 @@
21
21
  private _start?: number;
22
22
  get start(): number {
23
23
  if (this._start === undefined) {
24
- this._start = this.ext.getStartTime01(this.root, this.clip);
24
+ this._start = this.ext.getStartTimeByClip(this.clip);
25
25
  }
26
26
  return this._start;
27
27
  }
28
28
  get duration(): number { return this.clip?.duration ?? TransformData.restPoseClipDuration; }
29
+ get nearestAnimatedRoot(): Object3D | undefined { return this._nearestAnimatedRoot; }
30
+ get clipName(): string { return this.clip?.name ?? "rest"; }
29
31
 
30
32
  private ext: AnimationExtension;
31
33
  private root: Object3D;
34
+ private _nearestAnimatedRoot?: Object3D = undefined;
32
35
  private clip: AnimationClip | null;
33
36
 
34
37
  // Playback speed. Does not affect how the animation is written, just how fast actions play it back.
@@ -38,7 +41,52 @@
38
41
  this.ext = ext;
39
42
  this.root = root;
40
43
  this.clip = clip;
44
+
45
+ this._nearestAnimatedRoot = this.getNearestAnimatedRoot();
41
46
  }
47
+
48
+ private static isDescendantOf(parent: Object3D, child: Object3D) {
49
+ let current: Object3D | null = child;
50
+ if (!current || !parent) return false;
51
+ while (current) {
52
+ if (!current) return false;
53
+ if (current === parent) return true;
54
+ current = current.parent;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ /** Finds the nearest actually animated object under root based on the tracks in the AnimationClip. */
60
+ getNearestAnimatedRoot() {
61
+ let highestRoot: Object3D | undefined = undefined;
62
+ try {
63
+ for (const track of this.clip?.tracks ?? []) {
64
+ const parsedPath = PropertyBinding.parseTrackName(track.name);
65
+ let animationTarget = PropertyBinding.findNode(this.root, parsedPath.nodeName);
66
+ if (animationTarget) {
67
+ if (!highestRoot) highestRoot = animationTarget;
68
+ else {
69
+ if (animationTarget === highestRoot) continue;
70
+ if (RegisteredAnimationInfo.isDescendantOf(highestRoot, animationTarget)) continue;
71
+ if (!RegisteredAnimationInfo.isDescendantOf(animationTarget, highestRoot)) {
72
+ // TODO test this, should find the nearest common ancestor
73
+ while (!RegisteredAnimationInfo.isDescendantOf(animationTarget, highestRoot) && animationTarget.parent) {
74
+ animationTarget = animationTarget.parent;
75
+ }
76
+ if (!RegisteredAnimationInfo.isDescendantOf(animationTarget, highestRoot)) {
77
+ console.error("USDZExporter: Animation clip targets multiple roots that are not parent/child. Please report a bug", this.root, this.clip, highestRoot, animationTarget);
78
+ }
79
+ }
80
+ highestRoot = animationTarget;
81
+ }
82
+ }
83
+ }
84
+ } catch (e) {
85
+ console.error("USDZExporter: Exception when trying to find nearest animated root. Please report a bug", e);
86
+ highestRoot = undefined;
87
+ }
88
+ return highestRoot;
89
+ }
42
90
  }
43
91
 
44
92
  export class TransformData {
@@ -46,9 +94,8 @@
46
94
  pos?: KeyframeTrack;
47
95
  rot?: KeyframeTrack;
48
96
  scale?: KeyframeTrack;
49
- get frameRate(): number { return 60; }
50
97
 
51
- private root: Object3D | null;
98
+ private root: Object3D | null;
52
99
  private target: Object3D;
53
100
  private duration = 0;
54
101
  private useRootMotion = false;
@@ -56,8 +103,9 @@
56
103
  /** This value can theoretically be anything – a value of 1 is good to clearly see animation gaps.
57
104
  * For production, a value of 1/60 is enough, since the files can then still properly play back at 60fps.
58
105
  */
59
- static animationDurationPadding = 1 / 60;
60
- static restPoseClipDuration = 1 / 60;
106
+ static frameRate = 60;
107
+ static animationDurationPadding = 6 / 60;
108
+ static restPoseClipDuration = 6 / 60;
61
109
 
62
110
  constructor(root: Object3D | null, target: Object3D, clip: AnimationClip | null) {
63
111
  this.root = root;
@@ -73,6 +121,17 @@
73
121
  this.duration = clip.duration;
74
122
  }
75
123
 
124
+ // warn if the duration does not equal the maximum time value in the tracks
125
+ if (clip && clip.tracks) {
126
+ const maxTime = Math.max(...clip.tracks.map((t) => t.times[t.times.length - 1]));
127
+ if (maxTime !== this.duration) {
128
+ console.warn("USDZExporter: Animation clip duration does not match the maximum time value in the tracks.", clip, maxTime, this.duration);
129
+ // We need to override the duration, otherwise we end up with gaps in the exported animation
130
+ // where there are actually no written animation values
131
+ this.duration = maxTime;
132
+ }
133
+ }
134
+
76
135
  const animator = GameObject.getComponent(root, Animator);
77
136
  if (animator) this.useRootMotion = animator.applyRootMotion;
78
137
  }
@@ -132,8 +191,9 @@
132
191
  if (generateRot && rotTimesArray) for (const t of rotTimesArray) timesArray.push(t);
133
192
  if (generateScale && scaleTimesArray) for (const t of scaleTimesArray) timesArray.push(t);
134
193
 
135
- // we also need to make sure we have start and end times for these tracks
136
- // TODO seems we can't get track duration from the KeyframeTracks
194
+ // We also need to make sure we have start and end times for these tracks
195
+ // We already ensure this is the case for duration it's always the maximum time value in the tracks
196
+ // (see constructor)
137
197
  if (!timesArray.includes(0)) timesArray.push(0);
138
198
 
139
199
  // sort times so it's increasing
@@ -166,9 +226,34 @@
166
226
  if (!rotationInterpolant) rotation.set(object.quaternion.x, object.quaternion.y, object.quaternion.z, object.quaternion.w);
167
227
  if (!scaleInterpolant) scale.set(object.scale.x, object.scale.y, object.scale.z);
168
228
 
169
- for (let index = 0; index < timesArray.length; index++) {
170
- const time = timesArray[index];
229
+ // WORKAROUND because it seems that sometimes we get a quaternion interpolant with a valueSize
230
+ // of its own length... which then goes into an endless loop
231
+ if (positionInterpolant && positionInterpolant.valueSize !== 3)
232
+ positionInterpolant.valueSize = 3;
233
+ if (rotationInterpolant && rotationInterpolant.valueSize !== 4)
234
+ rotationInterpolant.valueSize = 4;
235
+ if (scaleInterpolant && scaleInterpolant.valueSize !== 3)
236
+ scaleInterpolant.valueSize = 3;
171
237
 
238
+ // We're optionally padding with one extra time step at the beginning and the end
239
+ // So that we don't get unwanted interpolations between clips (during the padding time)
240
+ const extraFrame = 0; // TransformData.animationDurationPadding > 1 / 60 ? 1 : 0;
241
+ for (let index = 0 - extraFrame; index < timesArray.length + extraFrame; index++) {
242
+ let time = 0;
243
+ let returnTime = 0;
244
+ if (index < 0) {
245
+ time = timesArray[0];
246
+ returnTime = time - (TransformData.animationDurationPadding / 2) + 1 / 60;
247
+ }
248
+ else if (index >= timesArray.length) {
249
+ time = timesArray[timesArray.length - 1];
250
+ returnTime = time + (TransformData.animationDurationPadding / 2) - 1 / 60;
251
+ }
252
+ else {
253
+ time = timesArray[index];
254
+ returnTime = time;
255
+ }
256
+
172
257
  if (positionInterpolant) {
173
258
  const pos = positionInterpolant.evaluate(time);
174
259
  translation.set(pos[0], pos[1], pos[2]);
@@ -192,21 +277,23 @@
192
277
  rootMatrix.decompose(translation, rotation, scale);
193
278
  }
194
279
 
195
- yield { time, translation, rotation, scale, index };
280
+ yield { time: returnTime, translation, rotation, scale, index };
196
281
  }
197
282
  }
198
283
  }
199
284
 
200
- declare type AnimationDict = Map<Object3D, Array<TransformData>>;
201
-
202
285
  export class AnimationExtension implements IUSDExporterExtension {
203
286
 
204
287
  get extensionName(): string { return "animation" }
288
+ get animationData() { return this.dict; }
289
+ get registeredClips() { return this.clipToStartTime.keys(); }
290
+ get animatedRoots() { return this.rootTargetMap.keys(); }
291
+ get holdClipMap() { return this.clipToHoldClip; }
205
292
 
206
293
  /** For each animated object, contains time/pos/rot/scale samples in the format that USD needs,
207
294
  * ready to be written to the .usda file.
208
295
  */
209
- private dict: AnimationDict = new Map();
296
+ private dict: Map<Object3D, Array<TransformData>> = new Map();
210
297
  /** Map of all roots (Animation/Animator or scene) and all targets that they animate.
211
298
  * We need that info so that we can ensure that each target has the same number of TransformData entries
212
299
  * so that switching between animations doesn't result in data "leaking" to another clip.
@@ -215,6 +302,9 @@
215
302
  private rootAndClipToRegisteredAnimationMap = new Map<string, RegisteredAnimationInfo>();
216
303
  /** Clips registered for each root */
217
304
  private rootToRegisteredClip = new Map<Object3D, Array<AnimationClip>>();
305
+ private lastClipEndTime = 0;
306
+ private clipToStartTime: Map<AnimationClip, number> = new Map();
307
+ private clipToHoldClip: Map<AnimationClip, AnimationClip> = new Map();
218
308
 
219
309
  private serializers: SerializeAnimation[] = [];
220
310
 
@@ -232,6 +322,7 @@
232
322
  // return the end time + padding of the rest clip, if any exists
233
323
  if (!this.injectRestPoses) return 0;
234
324
  if (this.rootAndClipToRegisteredAnimationMap.size === 0) return 0;
325
+ // return 0;
235
326
  return (TransformData.restPoseClipDuration + TransformData.animationDurationPadding) * 60;
236
327
  }
237
328
 
@@ -253,6 +344,7 @@
253
344
  return currentCount ?? 0;
254
345
  }
255
346
 
347
+ /*
256
348
  // TODO why do we have this here and on TransformData? Can RegisteredAnimationInfo not cache this value?
257
349
  // TODO we probably want to assert here that this is the same value on all nodes
258
350
  getStartTime01(root: Object3D, clip: AnimationClip | null) {
@@ -275,7 +367,18 @@
275
367
 
276
368
  return currentStartTime;
277
369
  }
370
+ */
278
371
 
372
+ getStartTimeByClip(clip: AnimationClip | null) {
373
+ if (!clip) return 0;
374
+ if (!this.clipToStartTime.has(clip)) {
375
+ console.error("USDZExporter: Missing start time for clip – please report a bug.", clip);
376
+ return 0;
377
+ }
378
+ const time = this.clipToStartTime.get(clip)!;
379
+ return time;
380
+ }
381
+
279
382
  // The same clip could be registered for different roots. All of them need written animation data then.
280
383
  // The same root could have multiple clips registered to it. If it does, the clips need to write
281
384
  // independent time data, so that playing back an animation on that root doesn't result in data "leaking"/"overlapping".
@@ -364,6 +467,7 @@
364
467
 
365
468
  // Inject a rest pose if we don't have it already
366
469
  if (this.injectRestPoses && !transformDataForTarget[0]) {
470
+ console.log("Injecting rest pose", animationTarget, clip, "at slot", currentCount);
367
471
  transformDataForTarget[0] = new TransformData(null, animationTarget, null);
368
472
  }
369
473
  // These all need to be at the same index, otherwise our padding went wrong
@@ -412,6 +516,26 @@
412
516
  const registered = this.rootToRegisteredClip.get(root);
413
517
  if (!registered) this.rootToRegisteredClip.set(root, [clip]);
414
518
  else registered.push(clip);
519
+
520
+ const lastClip = this.clipToStartTime.get(clip);
521
+ if (!lastClip) {
522
+ if (this.lastClipEndTime == null) this.lastClipEndTime = TransformData.restPoseClipDuration;
523
+
524
+ let newStartTime = this.lastClipEndTime + TransformData.animationDurationPadding;
525
+ let newEndTime = newStartTime + clip.duration;
526
+
527
+ // Round these times, makes it easier to understand what happens in the file
528
+ const roundedStartTime = Math.round(newStartTime * 60) / 60;
529
+ const roundedEndTime = Math.round(newEndTime * 60) / 60;
530
+ if (Math.abs(roundedStartTime - newStartTime) < 0.01) newStartTime = roundedStartTime;
531
+ if (Math.abs(roundedEndTime - newEndTime) < 0.01) newEndTime = roundedEndTime;
532
+ // Round newStartTime up to the next frame
533
+ newStartTime = Math.ceil(newStartTime);
534
+ newEndTime = newStartTime + clip.duration;
535
+
536
+ this.clipToStartTime.set(clip, newStartTime);
537
+ this.lastClipEndTime = newEndTime;
538
+ }
415
539
  }
416
540
  return info;
417
541
  }
@@ -486,7 +610,7 @@
486
610
  }, false);
487
611
 
488
612
  // we need to be able to retarget serialization to empty parents before actually serializing (we do that in another callback)
489
- const ser = new SerializeAnimation(object, this.dict);
613
+ const ser = new SerializeAnimation(object, this);
490
614
  this.serializers.push(ser);
491
615
  ser.registerCallback(model);
492
616
  }
@@ -498,15 +622,18 @@
498
622
 
499
623
  class SerializeAnimation {
500
624
 
501
- object: Object3D;
502
- dict: AnimationDict;
503
625
  model: USDObject | undefined = undefined;
626
+
627
+ private object: Object3D;
628
+ private animationData: Map<Object3D, Array<TransformData>>;
629
+ private ext: AnimationExtension;
504
630
 
505
631
  private callback?: (writer: USDWriter, context: USDZExporterContext) => void;
506
632
 
507
- constructor(object: Object3D, dict: AnimationDict) {
633
+ constructor(object: Object3D, ext: AnimationExtension) {
508
634
  this.object = object;
509
- this.dict = dict;
635
+ this.animationData = ext.animationData;
636
+ this.ext = ext;
510
637
  }
511
638
 
512
639
  registerCallback(model: USDObject) {
@@ -522,9 +649,9 @@
522
649
  this.model.addEventListener("serialize", this.callback);
523
650
  }
524
651
 
525
- skinnedMeshExport(writer: USDWriter, _context: USDZExporterContext) {
652
+ skinnedMeshExport(writer: USDWriter, _context: USDZExporterContext, ext: AnimationExtension) {
526
653
  const model = this.model;
527
- const dict = this.dict;
654
+ const dict = this.animationData;
528
655
  if (!model) return;
529
656
 
530
657
  if ( model.skinnedMesh ) {
@@ -636,6 +763,7 @@
636
763
 
637
764
  for (const [bone, transformDatas] of boneToTransformData) {
638
765
 
766
+ /*
639
767
  // calculate start times from the transformDatas
640
768
  const startTimes = new Array<number>();
641
769
  let currentStartTime = 0;
@@ -643,11 +771,12 @@
643
771
  startTimes.push(currentStartTime);
644
772
  currentStartTime += transformDatas[i].getDuration() + TransformData.animationDurationPadding;
645
773
  }
774
+ */
646
775
 
647
776
  for (let i = 0; i < transformDatas.length; i++) {
648
777
  const transformData = transformDatas[i];
649
778
  // const timeOffset = transformData.getStartTime(dict);
650
- const timeOffset = startTimes[i];
779
+ const timeOffset = ext.getStartTimeByClip(transformData.clip);
651
780
  if (times.length <= i) {
652
781
  times.push({pos: [], rot: [], scale: [], timeOffset});
653
782
  }
@@ -883,15 +1012,13 @@
883
1012
  }
884
1013
  }
885
1014
 
886
-
887
-
888
1015
  onSerialize(writer: USDWriter, _context: USDZExporterContext) {
889
1016
  if (!this.model) return;
890
1017
 
891
1018
  // Workaround: Sanitize TransformData for this object.
892
1019
  // This works around an issue with wrongly detected animation roots, where some of the indices
893
1020
  // in the TransformData array are not property set. Reproduces with golem_yokai.glb
894
- const arr0 = this.dict.get(this.object);
1021
+ const arr0 = this.animationData.get(this.object);
895
1022
  if (arr0) {
896
1023
  for (let i = 0; i < arr0.length; i++) {
897
1024
  if (arr0[i] !== undefined) continue;
@@ -899,15 +1026,17 @@
899
1026
  }
900
1027
  }
901
1028
 
902
- this.skinnedMeshExport(writer, _context);
1029
+ const ext = this.ext;
903
1030
 
1031
+ this.skinnedMeshExport(writer, _context, ext);
1032
+
904
1033
  const object = this.object;
905
1034
  const model = this.model;
906
1035
 
907
1036
  // do we have animation data for this node? if not, return
908
- const arr = this.dict.get(object);
909
- if (!arr) return;
910
-
1037
+ const animationData = this.animationData.get(object);
1038
+ if (!animationData) return;
1039
+
911
1040
  // Skinned meshes are handled separately by the method above.
912
1041
  // They need to be handled first (before checking for animation data) because animation needs to be exported
913
1042
  // as part of the skinned mesh and that may not be animated at all – if any bone is animated we need to export.
@@ -922,7 +1051,7 @@
922
1051
  //@ts-ignore
923
1052
  // if (object.isBone) return;
924
1053
 
925
- if (debugSerialization) console.log("SERIALIZE", this.model.name, this.object.type, arr);
1054
+ if (debugSerialization) console.log("SERIALIZE", this.model.name, this.object.type, animationData);
926
1055
 
927
1056
  // const composedTransform = new Matrix4();
928
1057
  // writer.appendLine("matrix4d xformOp:transform.timeSamples = {");
@@ -935,16 +1064,18 @@
935
1064
 
936
1065
  // calculate start times
937
1066
  // calculate start times from the transformDatas
1067
+ /*
938
1068
  const startTimes = new Array<number>();
939
1069
  let currentStartTime = 0;
940
- for (let i = 0; i < arr.length; i++) {
1070
+ for (let i = 0; i < animationData.length; i++) {
941
1071
  startTimes.push(currentStartTime);
942
- if (arr[i] === undefined) {
943
- console.error("Got an undefined transform data, this is likely a bug.", object, arr);
1072
+ if (animationData[i] === undefined) {
1073
+ console.error("Got an undefined transform data, this is likely a bug.", object, animationData);
944
1074
  continue;
945
1075
  }
946
- currentStartTime += arr[i].getDuration() + TransformData.animationDurationPadding;
1076
+ currentStartTime += animationData[i].getDuration() + TransformData.animationDurationPadding;
947
1077
  }
1078
+ */
948
1079
 
949
1080
  const formatter = Intl.NumberFormat("en-US", {
950
1081
  maximumFractionDigits: 3,
@@ -981,7 +1112,7 @@
981
1112
  for (let i = 0; i < arr.length; i++) {
982
1113
  const transformData = arr[i];
983
1114
  if (!transformData) continue;
984
- const startTime = startTimes[i];
1115
+ const startTime = ext.getStartTimeByClip(transformData.clip);
985
1116
  const timesArray = transformData.getSortedTimesArray(type === "position", type === "rotation", type === "scale");
986
1117
 
987
1118
  if (!timesArray || timesArray.length === 0) {
@@ -989,31 +1120,38 @@
989
1120
  continue;
990
1121
  }
991
1122
 
992
- // if (debug) // writing out the clip name and duration is useful even when not debugging
1123
+ const isRestPose = !transformData.clip;
1124
+ const hasPos = type === "position" && (transformData.pos || isRestPose);
1125
+ const hasRot = type === "rotation" && (transformData.rot || isRestPose);
1126
+ const hasScale = type === "scale" && (transformData.scale || isRestPose);
1127
+
1128
+ // Writing out the clip name and duration.
1129
+ // If this is a rest pose clip, we still want to write it out, as it's a valid clip.
1130
+ if (hasPos || hasRot || hasScale)
993
1131
  {
994
1132
  const clipName = transformData.clip?.name ?? "rest";
995
1133
  const duration = transformData.getDuration();
996
1134
  if (debug) console.log("Write .timeSamples:", clipName, startTime, duration, arr);
997
- writer.appendLine("# " + clipName + ": start=" + formatter.format(startTime * transformData.frameRate) + ", length=" + formatter.format(duration * transformData.frameRate) + ", frames=" + transformData.getFrames());
1135
+ writer.appendLine("# " + clipName + ": start=" + formatter.format(startTime * TransformData.frameRate) + ", length=" + formatter.format(duration * TransformData.frameRate) + ", frames=" + transformData.getFrames());
998
1136
  }
999
1137
 
1000
- if (type === "position" && transformData.pos) {
1138
+ if (hasPos) {
1001
1139
  for (const { time, translation } of transformData.getValues(timesArray, true, false, false)) {
1002
- const timeStr = formatter.format((startTime + time) * transformData.frameRate);
1140
+ const timeStr = formatter.format((startTime + time) * TransformData.frameRate);
1003
1141
  const line = `${timeStr}: (${fn(translation.x)}, ${fn(translation.y)}, ${fn(translation.z)}),`;
1004
1142
  writer.appendLine(line);
1005
1143
  }
1006
1144
  }
1007
- if (type === "rotation" && transformData.rot) {
1145
+ if (hasRot) {
1008
1146
  for (const { time, rotation } of transformData.getValues(timesArray, false, true, false)) {
1009
- const timeStr = formatter.format((startTime + time) * transformData.frameRate);
1147
+ const timeStr = formatter.format((startTime + time) * TransformData.frameRate);
1010
1148
  const line = `${timeStr}: (${fn(rotation.w)}, ${fn(rotation.x)}, ${fn(rotation.y)}, ${fn(rotation.z)}),`;
1011
1149
  writer.appendLine(line);
1012
1150
  }
1013
1151
  }
1014
- if (type === "scale" && transformData.scale) {
1152
+ if (hasScale) {
1015
1153
  for (const { time, scale } of transformData.getValues(timesArray, false, false, true)) {
1016
- const timeStr = formatter.format((startTime + time) * transformData.frameRate);
1154
+ const timeStr = formatter.format((startTime + time) * TransformData.frameRate);
1017
1155
  const line = `${timeStr}: (${fn(scale.x)}, ${fn(scale.y)}, ${fn(scale.z)}),`;
1018
1156
  writer.appendLine(line);
1019
1157
  }
@@ -1021,9 +1159,9 @@
1021
1159
  }
1022
1160
  writer.closeBlock();
1023
1161
  }
1024
- writeAnimationTimesamples(arr, "position");
1025
- writeAnimationTimesamples(arr, "rotation");
1026
- writeAnimationTimesamples(arr, "scale");
1162
+ writeAnimationTimesamples(animationData, "position");
1163
+ writeAnimationTimesamples(animationData, "rotation");
1164
+ writeAnimationTimesamples(animationData, "scale");
1027
1165
 
1028
1166
  // We must be careful here that we don't overwrite the xformOpOrder that the object already had.
1029
1167
  // it _might_ not even have any (if the position/quaternion/scale are all indentity values)
src/engine-components/export/usdz/extensions/behavior/Behaviour.ts CHANGED
@@ -79,7 +79,7 @@
79
79
 
80
80
  onExportObject(_object, model: USDObject, context) {
81
81
  for (const beh of this.behaviourComponents) {
82
- if (debug) console.log("onExportObject: createBehaviours", beh);
82
+ // if (debug) console.log("onExportObject: createBehaviours", beh);
83
83
  beh.createBehaviours?.call(beh, this, model, context);
84
84
  }
85
85
  }
@@ -100,39 +100,160 @@
100
100
  const triggerSources = new Set<Target>();
101
101
  const actionTargets = new Set<Target>();
102
102
  const targetUuids = new Set<string>();
103
+ const playAnimationActions = new Set<ActionModel>();
103
104
 
104
- function collect (actionModel: IBehaviorElement) {
105
+ // We're assembling a mermaid graph on the go, for easier debugging
106
+ const createMermaidGraphForDebugging = debug;
107
+ let mermaidGraph = "graph LR\n";
108
+ let mermaidGraphTopLevel = "";
109
+
110
+ function collectAction (actionModel: IBehaviorElement) {
105
111
  if (actionModel instanceof GroupActionModel) {
112
+ if (createMermaidGraphForDebugging) mermaidGraph += `subgraph Group_${actionModel.id}\n`;
106
113
  for (const action of actionModel.actions) {
107
- collect(action);
114
+ if (createMermaidGraphForDebugging) mermaidGraph += `${actionModel.id}[${actionModel.id}] -- ${actionModel.type} --> ${action.id}[${action.id}]\n`;
115
+ collectAction(action);
108
116
  }
117
+ if (createMermaidGraphForDebugging) mermaidGraph += `end\n`;
109
118
  }
110
119
  else if (actionModel instanceof ActionModel) {
120
+ if (actionModel.tokenId === "StartAnimation") {
121
+ playAnimationActions.add(actionModel);
122
+ }
111
123
  const affected = actionModel.affectedObjects;
112
124
  if (affected) {
113
- if (typeof affected === "object")
125
+ if (Array.isArray(affected)) {
126
+ for (const a of affected) {
127
+ actionTargets.add(a as Target);
128
+ //@ts-ignore
129
+ if (createMermaidGraphForDebugging) mermaidGraphTopLevel += `${actionModel.id}[${actionModel.id}] -- ${actionModel.tokenId} --> ${a.uuid}(("${a.displayName || a.name || a.uuid}"))\n`;
130
+ }
131
+ }
132
+ else if (typeof affected === "object") {
114
133
  actionTargets.add(affected as Target);
115
- else if (typeof affected === "string")
134
+ //@ts-ignore
135
+ if (createMermaidGraphForDebugging) mermaidGraphTopLevel += `${actionModel.id}[${actionModel.id}] -- ${actionModel.tokenId} --> ${affected.uuid}(("${affected.displayName || affected.name || affected.uuid}"))\n`;
136
+ }
137
+ else if (typeof affected === "string") {
116
138
  actionTargets.add({uuid: affected} as any as Target);
139
+ }
117
140
  }
118
141
 
119
142
  const xform = actionModel.xFormTarget;
120
143
  if (xform) {
121
- if (typeof xform === "object")
144
+ if (typeof xform === "object") {
122
145
  actionTargets.add(xform as Target);
123
- else if (typeof xform === "string")
146
+ //@ts-ignore
147
+ if (createMermaidGraphForDebugging) mermaidGraphTopLevel += `${actionModel.id}[${actionModel.id}] -- ${actionModel.tokenId} --> ${xform.uuid}(("${xform.displayName || xform.name || xform.uuid}"))\n`;
148
+ }
149
+ else if (typeof xform === "string") {
124
150
  actionTargets.add({uuid: xform} as any as Target);
151
+ }
125
152
  }
126
153
  }
127
154
  }
128
155
 
156
+ function collectTrigger(trigger: IBehaviorElement | IBehaviorElement[], action: IBehaviorElement) {
157
+ if (Array.isArray(trigger)) {
158
+ for (const t of trigger)
159
+ collectTrigger(t, action);
160
+ }
161
+ else if (trigger instanceof TriggerModel) {
162
+ if (typeof trigger.targetId === "object") {
163
+ triggerSources.add(trigger.targetId as Target);
164
+ //@ts-ignore
165
+ if (createMermaidGraphForDebugging) mermaidGraphTopLevel += `${trigger.targetId.uuid}(("${trigger.targetId.displayName}")) --> ${trigger.id}[${trigger.id}]\n`;
166
+ }
167
+ //@ts-ignore
168
+ if (createMermaidGraphForDebugging) mermaidGraph += `${trigger.id}((${trigger.id})) -- ${trigger.tokenId}${trigger.type ? ":" + trigger.type : ""} --> ${action.id}[${action.tokenId || action.id}]\n`;
169
+ }
170
+ }
171
+
129
172
  // collect all targets of all triggers and actions
130
173
  for (const beh of this.behaviours) {
131
- if (beh.trigger instanceof TriggerModel && typeof beh.trigger.targetId === "object" )
132
- triggerSources.add(beh.trigger.targetId as Target);
133
- collect(beh.action);
174
+ if (createMermaidGraphForDebugging) mermaidGraph += `subgraph Behavior_${beh.id}\n`;
175
+ collectAction(beh.action);
176
+ collectTrigger(beh.trigger, beh.action);
177
+ if (createMermaidGraphForDebugging) mermaidGraph += `end\n`;
134
178
  }
179
+ if (createMermaidGraphForDebugging) mermaidGraph += "\n" + mermaidGraphTopLevel;
180
+
181
+ if (createMermaidGraphForDebugging) {
182
+ console.log("All USDZ behaviours", this.behaviours);
183
+ console.warn("The Mermaid graph can be pasted into https://massive-mermaid.glitch.me/ or https://mermaid.live/edit. It should be in your clipboard already!");
184
+ console.log(mermaidGraph);
185
+ // copy to clipboard, can be pasted into https://massive-mermaid.glitch.me/ or https://mermaid.live/edit
186
+ navigator.clipboard.writeText(mermaidGraph);
187
+ }
135
188
 
189
+ {
190
+ // Validation: Check if any PlayAnimation actions are overlapping.
191
+ // That means: the target of one of the actions is a child of any other target of another action.
192
+ // This leads to undefined behaviour in the runtime.
193
+ // See FB15122057 for more information.
194
+ let animationsGraph = "gantt\ntitle Animations\ndateFormat X\naxisFormat %s\n";
195
+ const arr = Array.from(playAnimationActions);
196
+ const animationTargetObjects = new Set<USDObject>();
197
+ for (const a of arr) {
198
+ if (!a.affectedObjects) continue;
199
+ if (typeof a.affectedObjects === "string") continue;
200
+ if (Array.isArray(a.affectedObjects)) {
201
+ for (const o of a.affectedObjects) {
202
+ animationTargetObjects.add(o as USDObject);
203
+ }
204
+ }
205
+ else {
206
+ animationTargetObjects.add(a.affectedObjects as USDObject);
207
+ }
208
+
209
+ if (createMermaidGraphForDebugging) {
210
+ animationsGraph += `section ${a.animationName} (${a.id})\n`;
211
+ animationsGraph += `${a.id} : ${a.start}, ${a.duration}s\n`;
212
+ }
213
+ }
214
+
215
+ if (createMermaidGraphForDebugging) {
216
+ console.log(animationsGraph);
217
+ }
218
+
219
+ const animationTargetPaths = new Set<{path: string, obj: USDObject}>();
220
+ for (const o of animationTargetObjects) {
221
+ if (!o.getPath) {
222
+ console.error("USDZExporter: Animation target object has no getPath method. This is likely a bug", o);
223
+ }
224
+ let path = o.getPath();
225
+ // remove < and >, these are part of USD paths
226
+ if (path.startsWith("<")) path = path.substring(1);
227
+ if (path.endsWith(">")) path = path.substring(0, path.length - 1);
228
+ animationTargetPaths.add({path, obj: o});
229
+ }
230
+
231
+ // order by length
232
+ const sortedPaths = Array.from(animationTargetPaths).sort((a, b) => a.path.length - b.path.length);
233
+ const overlappingTargets = new Array<{child: string, parent: string}>();
234
+ for (let i = 0; i < sortedPaths.length; i++) {
235
+ for (let j = i + 1; j < sortedPaths.length; j++) {
236
+ if (sortedPaths[j].path.startsWith(sortedPaths[i].path)) {
237
+ const c = sortedPaths[j];
238
+ const p = sortedPaths[i];
239
+ overlappingTargets.push({child: c.obj.displayName + " (" + c.path + ")", parent: p.obj.displayName + " (" + p.path + ")"});
240
+ }
241
+ }
242
+ }
243
+
244
+ // There's some overlapping animation targets – we should warn here, so that this
245
+ // can be resolved in the scene.
246
+ if (overlappingTargets.length) {
247
+ console.warn("USDZExporter: There are overlapping PlayAnimation actions. This can lead to undefined runtime behaviour when playing multiple animations. Please restructure the hierarchy so that animations don't overlap.",
248
+ {
249
+ overlappingTargets,
250
+ playAnimationActions,
251
+ }
252
+ );
253
+ }
254
+ }
255
+
256
+
136
257
  for (const source of new Set([...triggerSources, ...actionTargets])) {
137
258
  // shouldn't happen but strictly speaking a trigger source could be set to an array
138
259
  if (Array.isArray(source)) {
@@ -191,103 +312,4 @@
191
312
  this.behaviourComponentsCopy.length = 0;
192
313
  this.audioClipsCopy.length = 0;
193
314
  }
194
-
195
- // combine behaviours that have tap triggers on the same object
196
- // private combineBehavioursWithSameTapActions() {
197
- // // TODO: if behaviours have different settings (e.g. one is exclusive and another one is not) this wont work - we need more logic for that
198
-
199
- // const combined: { [key: string]: { behaviorId: string, trigger: TriggerModel, actions: IBehaviorElement[] } } = {};
200
-
201
- // for (let i = this.behaviours.length - 1; i >= 0; i--) {
202
- // const beh = this.behaviours[i];
203
- // const trigger = beh.trigger as TriggerModel;
204
- // if (!Array.isArray(trigger) && TriggerBuilder.isTapTrigger(trigger)) {
205
- // const targetObject = trigger.targetId;
206
- // if (!targetObject) continue;
207
- // if (!combined[targetObject]) {
208
- // combined[targetObject] = { behaviorId: beh.id, trigger: trigger, actions: [] };
209
- // }
210
- // const action = beh.action;
211
- // combined[targetObject].actions.push(action);
212
- // this.behaviours.splice(i, 1);
213
- // }
214
- // }
215
- // for (const key in combined) {
216
- // const val = combined[key];
217
- // console.log("Combine " + val.actions.length + " actions on " + val.trigger.id, val.actions);
218
- // const beh = new BehaviorModel(val.behaviorId, val.trigger, ActionBuilder.sequence(...val.actions));
219
- // this.behaviours.push(beh);
220
- // }
221
- // }
222
- }
223
-
224
-
225
-
226
-
227
- // const playAnimationOnTap = new BehaviorModel("b_" + model.name + "_playanim", TriggerBuilder.tapTrigger(model),
228
- // ActionBuilder.parallel(
229
- // ActionBuilder.lookAtCameraAction(model),
230
- // ActionBuilder.sequence(
231
- // //ActionBuilder.startAnimationAction(model, 0, 0, 1, false, true),
232
- // ActionBuilder.emphasize(model, 1, MotionType.Float),
233
- // ActionBuilder.waitAction(1),
234
- // ActionBuilder.emphasize(model, 1, MotionType.Blink),
235
- // ActionBuilder.waitAction(1),
236
- // ActionBuilder.emphasize(model, 1, MotionType.Jiggle),
237
- // ActionBuilder.waitAction(1),
238
- // ActionBuilder.emphasize(model, 1, MotionType.Pulse),
239
- // ActionBuilder.waitAction(1),
240
- // ActionBuilder.emphasize(model, 1, MotionType.Spin),
241
- // ActionBuilder.waitAction(1),
242
- // ActionBuilder.emphasize(model, 1, MotionType.Bounce),
243
- // ActionBuilder.waitAction(1),
244
- // ActionBuilder.emphasize(model, 1, MotionType.Flip),
245
- // ActionBuilder.waitAction(1),
246
- // ).makeLooping()
247
- // ).makeLooping()
248
- // );
249
- // this.behaviours.push(playAnimationOnTap);
250
- // return;
251
-
252
- // const identityMatrix = new Matrix4().identity();
253
-
254
- // const emptyParent = new USDZObject(model.name + "_empty", model.matrix);
255
- // const parent = model.parent;
256
- // parent.add(emptyParent);
257
- // model.matrix = identityMatrix;
258
- // emptyParent.add(model);
259
-
260
-
261
- // const geometry = new SphereGeometry(.6, 32, 16);
262
- // const modelVariant = new USDZObject(model.name + "_variant", identityMatrix, geometry, new MeshStandardMaterial({ color: 0xff0000 }));
263
- // emptyParent.add(modelVariant);
264
-
265
- // const matrix2 = new Matrix4();
266
- // matrix2.makeTranslation(.5, 0, 0);
267
- // const modelVariant2 = new USDZObject(model.name + "_variant2", matrix2, geometry, new MeshStandardMaterial({ color: 0xffff00 }));
268
- // emptyParent.add(modelVariant2);
269
-
270
- // const hideVariantOnStart = new BehaviorModel("b_" + model.name + "_start", TriggerBuilder.sceneStartTrigger(), ActionBuilder.fadeAction(modelVariant, 0, false));
271
- // this.behaviours.push(hideVariantOnStart);
272
-
273
- // const showVariant = new BehaviorModel("b_" + model.name + "_show_variant", [TriggerBuilder.tapTrigger(model)], new GroupActionModel("group", [
274
- // ActionBuilder.fadeAction(model, 0, false),
275
- // ActionBuilder.fadeAction(modelVariant, 0, true),
276
- // ]));
277
- // this.behaviours.push(showVariant);
278
-
279
- // const showOriginal = new BehaviorModel("b_" + model.name + "_show_original", [
280
- // TriggerBuilder.tapTrigger(modelVariant),
281
- // TriggerBuilder.tapTrigger(modelVariant2)
282
- // ],
283
- // new GroupActionModel("group", [
284
- // ActionBuilder.fadeAction([modelVariant, modelVariant2], 0, false),
285
- // //ActionBuilder.waitAction(1),
286
- // ActionBuilder.fadeAction(model, 0, true),
287
- // //ActionBuilder.waitAction(.2),
288
- // ActionBuilder.startAnimationAction(model, 0, 1000, 1, false, true),
289
- // //ActionBuilder.lookAtCameraAction(model, 2, Vec3.forward, Vec3.up),
290
- // //ActionBuilder.waitAction(1),
291
- // //ActionBuilder.fadeAction(modelVariant2, 0, true),
292
- // ]).makeSequence());
293
- // this.behaviours.push(showOriginal);
315
+ }
src/engine-components/export/usdz/extensions/behavior/BehaviourComponents.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Material, Matrix4, Mesh, Object3D, Quaternion, Vector3 } from "three";
1
+ import { AnimationClip, Material, Matrix4, Mesh, Object3D, Quaternion, Vector3 } from "three";
2
2
 
3
3
  import { isDevEnvironment, showBalloonWarning } from "../../../../../engine/debug/index.js";
4
4
  import { serializable } from "../../../../../engine/engine_serialization_decorator.js";
@@ -13,18 +13,19 @@
13
13
  import { Behaviour, GameObject } from "../../../../Component.js";
14
14
  import type { IPointerClickHandler, PointerEventData } from "../../../../ui/PointerEvents.js";
15
15
  import { ObjectRaycaster,Raycaster } from "../../../../ui/Raycaster.js";
16
- import { makeNameSafeForUSD,USDDocument, USDObject, USDWriter, USDZExporterContext } from "../../ThreeUSDZExporter.js";
16
+ import { makeNameSafeForUSD,USDDocument, USDObject, USDZExporterContext } from "../../ThreeUSDZExporter.js";
17
17
  import { AnimationExtension, RegisteredAnimationInfo, type UsdzAnimation } from "../Animation.js";
18
18
  import { AudioExtension } from "./AudioExtension.js";
19
19
  import type { BehaviorExtension, UsdzBehaviour } from "./Behaviour.js";
20
- import { ActionBuilder, ActionModel, BehaviorModel, EmphasizeActionMotionType,type IBehaviorElement, TriggerBuilder } from "./BehavioursBuilder.js";
20
+ import { ActionBuilder, ActionModel, BehaviorModel, EmphasizeActionMotionType,type IBehaviorElement, Target, TriggerBuilder } from "./BehavioursBuilder.js";
21
21
 
22
22
  const debug = getParam("debugusdzbehaviours");
23
23
 
24
24
  function ensureRaycaster(obj: GameObject) {
25
25
  if (!obj) return;
26
26
  if (!obj.getComponentInParent(Raycaster)) {
27
- if (isDevEnvironment()) console.warn("Create Raycaster on " + obj.name + " because no raycaster was found in the hierarchy")
27
+ if (isDevEnvironment())
28
+ console.debug("Raycaster on \"" + obj.name + "\" was automatically added, because no raycaster was found in the parent hierarchy.")
28
29
  obj.addComponent(ObjectRaycaster);
29
30
  }
30
31
  }
@@ -851,6 +852,7 @@
851
852
  document.traverse(model => {
852
853
  if (model.uuid === this.target?.uuid) {
853
854
  const sequence = PlayAnimationOnClick.getActionForSequences(
855
+ document,
854
856
  model,
855
857
  this.animationSequence,
856
858
  this.animationLoopAfterSequence,
@@ -870,12 +872,12 @@
870
872
  });
871
873
  }
872
874
 
873
- static getActionForSequences(model: USDObject, animationSequence?: Array<RegisteredAnimationInfo>, animationLoopAfterSequence?: Array<RegisteredAnimationInfo>, randomOffsetNormalized?: number) {
875
+ static getActionForSequences(_document: USDDocument, model: Target, animationSequence?: Array<RegisteredAnimationInfo>, animationLoopAfterSequence?: Array<RegisteredAnimationInfo>, randomOffsetNormalized?: number) {
874
876
 
875
- const getOrCacheAction = (model: USDObject, anim: RegisteredAnimationInfo) => {
877
+ const getOrCacheAction = (model: Target, anim: RegisteredAnimationInfo) => {
876
878
  let action = PlayAnimationOnClick.animationActions.find(a => a.affectedObjects == model && a.start == anim.start && a.duration == anim.duration && a.animationSpeed == anim.speed);
877
879
  if (!action) {
878
- action = ActionBuilder.startAnimationAction(model, anim.start, anim.duration, anim.speed) as ActionModel;
880
+ action = ActionBuilder.startAnimationAction(model, anim) as ActionModel;
879
881
  PlayAnimationOnClick.animationActions.push(action);
880
882
  }
881
883
  return action;
@@ -982,7 +984,7 @@
982
984
  // find the first transition without parameters
983
985
  // TODO we could also find the first _valid_ transition here instead based on the current parameters.
984
986
  const transition = currentState.transitions.find(t => t.conditions.length === 0);
985
- const nextState = transition ? runtimeController["getState"](transition.destinationState, 0) : null;
987
+ const nextState: State | null = transition ? runtimeController["getState"](transition.destinationState, 0) : null;
986
988
  // abort: we found a state loop
987
989
  if (nextState && visitedStates.includes(nextState)) {
988
990
  currentState = nextState;
@@ -1008,6 +1010,14 @@
1008
1010
  const firstStateInLoop = visitedStates.indexOf(currentState);
1009
1011
  statesUntilLoop = visitedStates.slice(0, firstStateInLoop); // can be empty, which means we're looping all
1010
1012
  statesLooping = visitedStates.slice(firstStateInLoop); // can be empty, which means nothing is looping
1013
+
1014
+ // Potentially we need to prevent self-transitions into a non-looping state, these do not result in a loop in the runtime
1015
+ /*
1016
+ if (statesLooping.length === 1 && !statesLooping[0].motion?.isLooping) {
1017
+ statesUntilLoop.push(statesLooping[0]);
1018
+ statesLooping = [];
1019
+ }
1020
+ */
1011
1021
  if (debug) console.log("found loop from " + stateName, "states until loop", statesUntilLoop, "states looping", statesLooping);
1012
1022
  }
1013
1023
  else {
@@ -1015,6 +1025,52 @@
1015
1025
  statesLooping = [];
1016
1026
  if (debug) console.log("found no loop from " + stateName, "states", statesUntilLoop);
1017
1027
  }
1028
+ // If we do NOT loop, we need to explicitly add a single-keyframe clip with a "hold" pose
1029
+ // to prevent the animation from resetting to the start.
1030
+ if (!statesLooping.length) {
1031
+ const lastState = statesUntilLoop[statesUntilLoop.length - 1];
1032
+ const lastClip = lastState.motion?.clip;
1033
+ if (lastClip) {
1034
+ let clipCopy: AnimationClip | undefined;
1035
+ if (ext.holdClipMap.has(lastClip)) {
1036
+ clipCopy = ext.holdClipMap.get(lastClip);
1037
+ }
1038
+ else {
1039
+ // We're creating a "hold" clip here; exactly 1 second long, and inteprolates exactly on the duration of the clip
1040
+ const holdStateName = lastState.name + "_hold";
1041
+ clipCopy = lastClip.clone();
1042
+ clipCopy.duration = 1;
1043
+ clipCopy.name = holdStateName;
1044
+ const lastFrame = lastClip.duration;
1045
+ clipCopy.tracks = lastClip.tracks.map(t => {
1046
+ const trackCopy = t.clone();
1047
+ trackCopy.times = new Float32Array([0, lastFrame]);
1048
+ const len = t.values.length;
1049
+ const size = t.getValueSize();;
1050
+ const lastValue = t.values.slice(len - size, len);
1051
+ // we need this twice
1052
+ trackCopy.values = new Float32Array(2 * size);
1053
+ trackCopy.values.set(lastValue, 0);
1054
+ trackCopy.values.set(lastValue, size);
1055
+ return trackCopy;
1056
+ });
1057
+ clipCopy.name = holdStateName;
1058
+ ext.holdClipMap.set(lastClip, clipCopy);
1059
+ }
1060
+
1061
+ if (clipCopy) {
1062
+ const holdState = {
1063
+ name: clipCopy.name,
1064
+ motion: { clip: clipCopy, isLooping: false, name: clipCopy.name },
1065
+ speed: 1,
1066
+ transitions: [],
1067
+ behaviours: [],
1068
+ hash: lastState.hash + 1,
1069
+ }
1070
+ statesLooping.push(holdState);
1071
+ }
1072
+ }
1073
+ }
1018
1074
  }
1019
1075
 
1020
1076
  // Special case: someone's trying to play an empty clip without any motion data, no loops or anything.
src/engine-components/export/usdz/extensions/behavior/BehavioursBuilder.ts CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { getParam } from "../../../../../engine/engine_utils.js";
4
4
  import { makeNameSafeForUSD,USDDocument, USDObject, USDWriter } from "../../ThreeUSDZExporter.js";
5
+ import type { RegisteredAnimationInfo } from "./../Animation.js";
5
6
  import { BehaviorExtension } from "./Behaviour.js";
6
7
 
7
8
  const debug = getParam("debugusdz");
@@ -347,7 +348,7 @@
347
348
  private static global_id: number = 0;
348
349
 
349
350
  id: string;
350
- tokenId?: string;
351
+ tokenId?: "ChangeScene" | "Visibility" | "StartAnimation" | "Wait" | "LookAtCamera" | "Emphasize" | "Transform" | "Audio" | "Impulse";
351
352
  affectedObjects?: string | Target;
352
353
  easeType?: EaseType;;
353
354
  motionType: EmphasizeActionMotionType | VisibilityActionMotionType | undefined = undefined;
@@ -368,6 +369,10 @@
368
369
  multiplePerformOperation?: MultiplePerformOperation;
369
370
  velocity?: Vec3;
370
371
 
372
+ // extra info written as comment at the beginning of the action
373
+ comment?: string;
374
+ animationName?: string;
375
+
371
376
  clone(): ActionModel {
372
377
  const copy = new ActionModel();
373
378
  const id = copy.id;
@@ -379,17 +384,14 @@
379
384
  constructor(affectedObjects?: string | Target, id?: string) {
380
385
  if (affectedObjects) this.affectedObjects = affectedObjects;
381
386
  if (id) this.id = id;
382
- /*else if (affectedObjects) {
383
- this.id = "Action_" + sanitizeId(affectedObjects);
384
- }
385
- else */
386
- else
387
- this.id = "Action";
387
+ else this.id = "Action";
388
388
  this.id += "_" + ActionModel.global_id++;
389
389
  }
390
390
 
391
391
  writeTo(document: USDDocument, writer: USDWriter) {
392
392
  writer.beginBlock(`def Preliminary_Action "${this.id}"`);
393
+ if (this.comment)
394
+ writer.appendLine(`# ${this.comment}`);
393
395
  if (this.affectedObjects) {
394
396
  if (typeof this.affectedObjects !== "string") this.affectedObjects = resolve(this.affectedObjects, document);
395
397
  writer.appendLine('rel affectedObjects = ' + this.affectedObjects);
@@ -518,9 +520,26 @@
518
520
  * @param start offset in seconds!
519
521
  * @param duration in seconds! 0 means play to end
520
522
  */
521
- static startAnimationAction(targetObject: Target, start: number, duration: number = 0, animationSpeed: number = 1, reversed: boolean = false, pingPong: boolean = false): IBehaviorElement {
523
+ static startAnimationAction(targetObject: Target, anim: RegisteredAnimationInfo, reversed: boolean = false, pingPong: boolean = false): IBehaviorElement {
522
524
  const act = new ActionModel(targetObject);
523
525
  act.tokenId = "StartAnimation";
526
+
527
+ /*
528
+ if (targetObject instanceof USDObject) {
529
+ act.cachedTargetObject = targetObject;
530
+ // try to retarget the animation – this improves animation playback with overlapping roots.
531
+ act.affectedObjects = anim.nearestAnimatedRoot;
532
+ }
533
+ */
534
+
535
+ const start = anim.start;
536
+ const duration = anim.duration;
537
+ const animationSpeed = anim.speed;
538
+ const animationName = anim.clipName;
539
+
540
+ act.comment = `Animation: ${animationName}, start=${start * 60}, length=${duration * 60}, end=${(start + duration) * 60}`;
541
+ act.animationName = animationName;
542
+
524
543
  // start is time in seconds, the documentation is not right here
525
544
  act.start = start;
526
545
  // duration of 0 is play to end
src/engine/debug/debug_console.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import { isDevEnvironment } from "./debug.js";
4
4
  import { getErrorCount, makeErrorsVisibleForDevelopment } from "./debug_overlay.js";
5
5
 
6
- let consoleInstance: VConsole | null = null;
6
+ let consoleInstance: VConsole | null | undefined = undefined;
7
7
  let consoleHtmlElement: HTMLElement | null = null;
8
8
  let consoleSwitchButton: HTMLElement | null = null;
9
9
  let isLoading = false;
@@ -48,7 +48,7 @@
48
48
 
49
49
  export function showDebugConsole() {
50
50
 
51
- if (consoleInstance !== null) {
51
+ if (consoleInstance) {
52
52
  isVisible = true;
53
53
  consoleInstance.showSwitch();
54
54
  return;
@@ -57,7 +57,7 @@
57
57
  }
58
58
 
59
59
  export function hideDebugConsole() {
60
- if (consoleInstance === null) return;
60
+ if (!consoleInstance) return;
61
61
  isVisible = false;
62
62
  consoleInstance.hide();
63
63
  consoleInstance.hideSwitch();
@@ -121,12 +121,19 @@
121
121
  }
122
122
 
123
123
  function createConsole(startHidden: boolean = false) {
124
- if (consoleInstance) return;
124
+ if (consoleInstance !== undefined) return;
125
125
  if (isLoading) return;
126
126
  isLoading = true;
127
127
 
128
128
  const script = document.createElement("script");
129
129
  script.onload = () => {
130
+ // check if VConsole is now defined on globalThis
131
+ if (!globalThis.VConsole) {
132
+ console.warn("🌵 Debug console failed to load.");
133
+ isLoading = false;
134
+ consoleInstance = null;
135
+ return;
136
+ }
130
137
  isLoading = false;
131
138
  isVisible = true;
132
139
  beginWatchingLogs();
@@ -235,11 +242,17 @@
235
242
  }
236
243
 
237
244
  };
245
+ script.onerror = () => {
246
+ console.warn("🌵 Debug console failed to load.");
247
+ isLoading = false;
248
+ consoleInstance = null;
249
+ };
238
250
  script.src = "https://unpkg.com/vconsole@latest/dist/vconsole.min.js";
239
251
  document.body.appendChild(script);
240
252
  }
241
253
 
242
254
  function createInspectPlugin() {
255
+ if (!globalThis.VConsole) return;
243
256
  const plugin = new VConsole.VConsolePlugin("needle-console", "🌵 Inspect glTF");
244
257
  const getIframe = () => {
245
258
  return document.querySelector("#__vc_plug_" + plugin._id + " iframe") as HTMLIFrameElement;
src/engine/engine_element.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { AgXToneMapping, Color, LinearToneMapping, NeutralToneMapping, NoToneMapping } from "three";
2
2
 
3
- import { getLoader, registerLoader } from "../engine/engine_gltf.js";
4
- import { GameObject } from "../engine-components/Component.js";
3
+ import { registerLoader } from "../engine/engine_gltf.js";
5
4
  import { isDevEnvironment, showBalloonWarning } from "./debug/index.js";
6
5
  import { VERSION } from "./engine_constants.js";
7
6
  import { TonemappingAttributeOptions } from "./engine_element_attributes.js";
@@ -10,7 +9,7 @@
10
9
  import { hasCommercialLicense } from "./engine_license.js";
11
10
  import { setDracoDecoderPath, setDracoDecoderType, setKtx2TranscoderPath } from "./engine_loaders.js";
12
11
  import { NeedleGltfLoader } from "./engine_scenetools.js";
13
- import { Context, ContextCreateArgs, LoadingProgressArgs } from "./engine_setup.js";
12
+ import { Context, ContextCreateArgs } from "./engine_setup.js";
14
13
  import { type INeedleEngineComponent, type LoadedGLTF } from "./engine_types.js";
15
14
  import { getParam } from "./engine_utils.js";
16
15
  import { ensureFonts } from "./webcomponents/fonts.js";
@@ -156,6 +155,12 @@
156
155
  height: 100%;
157
156
  }
158
157
  }
158
+
159
+ :host > div.canvas-wrapper {
160
+ width: 100%;
161
+ height: 100%;
162
+ }
163
+
159
164
  :host canvas {
160
165
  position: absolute;
161
166
  user-select: none;
@@ -172,6 +177,7 @@
172
177
  }
173
178
  :host .content {
174
179
  position: absolute;
180
+ top: 0;
175
181
  width: 100%;
176
182
  height: 100%;
177
183
  visibility: visible;
@@ -194,7 +200,7 @@
194
200
  z-index: 9999;
195
201
  }
196
202
  </style>
197
- <div> <!-- this wrapper is necessary for WebXR https://github.com/meta-quest/immersive-web-emulator/issues/55 -->
203
+ <div class="canvas-wrapper"> <!-- this wrapper is necessary for WebXR https://github.com/meta-quest/immersive-web-emulator/issues/55 -->
198
204
  <canvas></canvas>
199
205
  </div>
200
206
  <div class="content">
src/engine/engine_gameobject.ts CHANGED
@@ -50,6 +50,9 @@
50
50
  components?: boolean;
51
51
  }
52
52
 
53
+ /**
54
+ * Instantiation options for {@link syncInstantiate}
55
+ */
53
56
  export class InstantiateOptions implements IInstantiateOptions {
54
57
  idProvider?: UIDProvider | undefined;
55
58
  parent?: string | undefined | Object3D;
src/engine/engine_gltf_builtin_components.ts CHANGED
@@ -89,7 +89,7 @@
89
89
  queue.length = 0;
90
90
  }
91
91
  // when dropping the same file multiple times we need to generate new guids
92
- // e.g. SyncTransform sends its own guid to the server to know about ownership
92
+ // e.g. SyncedTransform sends its own guid to the server to know about ownership
93
93
  // so it requires a unique guid for a new instance
94
94
  // doing it here at the end of resolving of references should ensure that
95
95
  // and this should run before awake and onenable of newly created components
src/engine/engine_networking_instantiate.ts CHANGED
@@ -111,9 +111,12 @@
111
111
  }
112
112
 
113
113
  /**
114
- * @param obj - The object or component to be destroyed
115
- * @param con - The network connection to send the destroy event to
116
- * @param recursive - If true, all children will be destroyed as well. Default is true
114
+ * Destroy an object across the network. See also {@link syncInstantiate}.
115
+ * @param obj The object or component to be destroyed
116
+ * @param con The network connection to send the destroy event to
117
+ * @param recursive If true, all children will be destroyed as well. Default is true
118
+ * @param opts Options for the destroy operation
119
+ * @category Networking
117
120
  */
118
121
  export function syncDestroy(obj: GameObject | Component, con: INetworkConnection, recursive: boolean = true, opts?: SyncDestroyOptions) {
119
122
  if (!obj) return;
@@ -162,13 +165,18 @@
162
165
  }
163
166
 
164
167
 
165
- // when a file is instantiated via some server (e.g. via file drop) we also want to send the info where the file can be downloaded
166
- // doing it this route will ensure we have
168
+ //
167
169
 
168
- /** @internal */
170
+ /**
171
+ * When a file is instantiated via some server (e.g. via file drop) we also want to send the info where the file can be downloaded.
172
+ * @internal
173
+ */
169
174
  export class HostData {
175
+ /** File to download */
170
176
  filename: string;
177
+ /** Checksum to verify its the correct file */
171
178
  hash: string;
179
+ /** Expected size of the referenced file and its dependencies */
172
180
  size: number;
173
181
 
174
182
  constructor(filename: string, hash: string, size: number) {
@@ -205,8 +213,15 @@
205
213
  }
206
214
  }
207
215
 
216
+ /**
217
+ * Instantiation options for {@link syncInstantiate}
218
+ */
208
219
  export type SyncInstantiateOptions = IInstantiateOptions & Pick<IModel, "deleteOnDisconnect">;
209
220
 
221
+ /**
222
+ * Instantiate an object across the network. See also {@link syncDestroy}.
223
+ * @category Networking
224
+ */
210
225
  export function syncInstantiate(object: GameObject | Object3D, opts: SyncInstantiateOptions, hostData?: HostData, save?: boolean): GameObject | null {
211
226
 
212
227
  const obj: GameObject = object as GameObject;
src/engine/engine_networking.ts CHANGED
@@ -40,9 +40,9 @@
40
40
  * @link https://engine.needle.tools/docs/networking.html#manual-networking
41
41
  * */
42
42
  export enum RoomEvents {
43
- /** Internal: sent to the server when attempting to join a room */
43
+ /** Internal: Sent to the server when attempting to join a room */
44
44
  Join = "join-room",
45
- /** Internal: sent to the server when attempting to leave a room */
45
+ /** Internal: Sent to the server when attempting to leave a room */
46
46
  Leave = "leave-room",
47
47
  /** Incoming: When the local user has joined a room */
48
48
  JoinedRoom = "joined-room",
@@ -52,6 +52,7 @@
52
52
  UserJoinedRoom = "user-joined-room",
53
53
  /** Incoming: When a other user has left the room */
54
54
  UserLeftRoom = "user-left-room",
55
+ /** When a user joins a room, the server sends the entire room state. Afterwards, the server sends the room-state-sent event. */
55
56
  RoomStateSent = "room-state-sent",
56
57
  }
57
58
 
src/engine/engine_time_utils.ts CHANGED
@@ -83,7 +83,7 @@
83
83
  * // Enable console logging:
84
84
  * Progress.start("export-usdz", { logTimings: true });
85
85
  */
86
- static start(scope: string, options?: ProgressStartOptions | string ) {
86
+ static start(scope: string, options?: ProgressStartOptions | string) {
87
87
  if (typeof options === "string") options = { parentScope: options };
88
88
  const p = new ProgressEntry(scope, options);
89
89
  progressCache.set(scope, p);
src/engine-components/export/usdz/index.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { type UsdzBehaviour } from "./extensions/behavior/Behaviour.js";
2
- export { imageToCanvas, makeNameSafeForUSD, USDZExporter as NeedleUSDZExporter, USDObject, USDWriter,type USDZExporterContext } from "./ThreeUSDZExporter.js";
2
+ export { imageToCanvas, makeNameSafeForUSD, USDZExporter as NeedleUSDZExporter, USDDocument, USDObject, USDWriter,type USDZExporterContext } from "./ThreeUSDZExporter.js";
3
3
  export { USDZExporter } from "./USDZExporter.js";
src/engine-components/export/usdz/extensions/behavior/PhysicsExtension.ts CHANGED
@@ -9,9 +9,7 @@
9
9
 
10
10
  export class PhysicsExtension implements IUSDExporterExtension {
11
11
 
12
- get extensionName(): string {
13
- return "Physics";
14
- }
12
+ get extensionName(): string { return "Physics"; }
15
13
 
16
14
  onExportObject?(object: Object3D, model : USDObject, _context: USDZExporterContext) {
17
15
 
src/engine-components-experimental/networking/PlayerSync.ts CHANGED
@@ -14,6 +14,11 @@
14
14
 
15
15
  declare type PlayerSyncWithAsset = PlayerSync & Required<Pick<PlayerSync, "asset">>;
16
16
 
17
+ /**
18
+ * This component instantiates an asset for each player that joins a networked room. The asset will be destroyed when the player leaves the room.
19
+ * The asset should have a PlayerState component, and can have other components like SyncedTransform, custom components, etc.
20
+ * @category Networking
21
+ */
17
22
  export class PlayerSync extends Behaviour {
18
23
 
19
24
  /**
src/engine-components/RigidBody.ts CHANGED
@@ -316,7 +316,10 @@
316
316
  this._watch.start(true, true);
317
317
  this.startCoroutine(this.beforePhysics(), FrameEvent.LateUpdate);
318
318
  if (isDevEnvironment() && !this.context.physics.engine?.getBody(this)) {
319
- console.warn(`Rigidbody was not created: Does your object (${this.name}) have a collider?`);
319
+ if (!globalThis["NEEDLE_USE_RAPIER"])
320
+ console.warn(`Rigidbody could not be created: Rapier physics are explicitly disabled.`);
321
+ else
322
+ console.warn(`Rigidbody could not be created. Ensure \"(${this.name}\" has a Collider component.`);
320
323
  }
321
324
  }
322
325
 
src/engine-components/ShadowCatcher.ts CHANGED
@@ -122,6 +122,7 @@
122
122
  return;
123
123
  `);
124
124
  }
125
+ material.userData.isLightBlendMaterial = true;
125
126
  }
126
127
 
127
128
  // ShadowMaterial: only does a mask; shadowed areas are fully black.
@@ -135,12 +136,14 @@
135
136
  material.opacity = this.shadowColor.alpha;
136
137
  this.applyMaterialOptions(material);
137
138
  this.targetMesh.material = material;
139
+ material.userData.isShadowCatcherMaterial = true;
138
140
  }
139
141
  else {
140
142
  const material = this.targetMesh.material as ShadowMaterial;
141
143
  material.color = this.shadowColor;
142
144
  material.opacity = this.shadowColor.alpha;
143
145
  this.applyMaterialOptions(material);
146
+ material.userData.isShadowCatcherMaterial = true;
144
147
  }
145
148
  }
146
149
  }
src/engine-components/export/usdz/ThreeUSDZExporter.ts CHANGED
@@ -41,6 +41,7 @@
41
41
  import { BehaviorExtension } from '../../api.js';
42
42
  import type { IUSDExporterExtension } from './Extension.js';
43
43
  import type { AnimationExtension } from './extensions/Animation.js';
44
+ import type { PhysicsExtension } from './extensions/behavior/PhysicsExtension.js';
44
45
 
45
46
  function makeNameSafe( str ) {
46
47
  // Remove characters that are not allowed in USD ASCII identifiers
@@ -395,12 +396,21 @@
395
396
 
396
397
  }
397
398
 
398
-
399
399
  buildHeader( _context: USDZExporterContext ) {
400
400
  const animationExtension = _context.extensions?.find( ext => ext?.extensionName === 'animation' ) as AnimationExtension | undefined;
401
401
  const behaviorExtension = _context.extensions?.find( ext => ext?.extensionName === 'Behaviour' ) as BehaviorExtension | undefined;
402
+ const physicsExtension = _context.extensions?.find( ext => ext?.extensionName === 'Physics' ) as PhysicsExtension | undefined;
402
403
  const startTimeCode = animationExtension?.getStartTimeCode() ?? 0;
403
404
  const endTimeCode = animationExtension?.getEndTimeCode() ?? 0;
405
+
406
+ let comment = "";
407
+ const registeredClips = animationExtension?.registeredClips;
408
+ if (registeredClips) {
409
+ for ( const clip of registeredClips ) {
410
+ comment += `\t# Animation: ${clip.name}, start=${animationExtension.getStartTimeByClip(clip) * 60}, length=${clip.duration * 60}\n`;
411
+ }
412
+ }
413
+ const comments = comment;
404
414
 
405
415
  return `#usda 1.0
406
416
  (
@@ -409,6 +419,7 @@
409
419
  dictionary Needle = {
410
420
  bool animations = ${animationExtension ? 1 : 0}
411
421
  bool interactive = ${behaviorExtension ? 1 : 0}
422
+ bool physics = ${physicsExtension ? 1 : 0}
412
423
  bool quickLookCompatible = ${_context.quickLookCompatible ? 1 : 0}
413
424
  }
414
425
  }
@@ -420,6 +431,7 @@
420
431
  timeCodesPerSecond = 60
421
432
  framesPerSecond = 60
422
433
  doc = """Generated by Needle Engine USDZ Exporter ${VERSION}"""
434
+ ${comments}
423
435
  )
424
436
  `;
425
437
 
@@ -582,6 +594,7 @@
582
594
  sceneAnchoringOptions: USDZExporterOptions = new USDZExporterOptions();
583
595
  extensions: Array<IUSDExporterExtension> = [];
584
596
  keepObject?: (object: Object3D) => boolean;
597
+ beforeWritingDocument?: () => void;
585
598
 
586
599
  constructor() {
587
600
 
@@ -697,6 +710,9 @@
697
710
  // Moved into parseDocument callback for proper defaultPrim encapsulation
698
711
  // context.output += buildMaterials( materials, textures, options.quickLookCompatible );
699
712
 
713
+ // callback for validating after all export has been done
714
+ context.exporter?.beforeWritingDocument?.();
715
+
700
716
  const header = context.document.buildHeader( context );
701
717
  const final = header + '\n' + context.output;
702
718
 
@@ -707,7 +723,7 @@
707
723
  context.output = '';
708
724
 
709
725
  Progress.report("export-usdz", { message: "Exporting textures", autoStep: 10 });
710
- Progress.start("export-usdz-textures", "export-usdz");
726
+ Progress.start("export-usdz-textures", { parentScope: "export-usdz", logTimings: false });
711
727
  const decompressionRenderer = new WebGLRenderer( {
712
728
  antialias: false,
713
729
  alpha: true,
@@ -1823,7 +1839,7 @@
1823
1839
 
1824
1840
  if ( attribute === undefined ) {
1825
1841
 
1826
- console.warn( 'USDZExporter: Attribute is missing. Results may be undefined.' );
1842
+ console.warn( 'USDZExporter: A mesh attribute is missing and will be set with placeholder data. The result may look incorrect.' );
1827
1843
  return Array( count ).fill( '(0, 0, 1)' ).join( ', ' );
1828
1844
 
1829
1845
  }
@@ -1943,12 +1959,45 @@
1943
1959
 
1944
1960
  function buildMaterial( material: MeshBasicMaterial, textures: TextureMap, quickLookCompatible = false ) {
1945
1961
 
1962
+ const materialName = getMaterialName(material);
1963
+
1964
+ console.log(material);
1965
+ // Special case: occluder material
1966
+ // Supported on iOS 18+ and visionOS 1+
1967
+ const isShadowCatcherMaterial =
1968
+ material.colorWrite === false ||
1969
+ (material.userData?.isShadowCatcherMaterial || material.userData?.isLightBlendMaterial);
1970
+ if (isShadowCatcherMaterial) {
1971
+
1972
+ // Two options here:
1973
+ // - ND_realitykit_occlusion_surfaceshader (non-shadow receiving)
1974
+ // - ND_realitykit_shadowreceiver_surfaceshader (shadow receiving)
1975
+
1976
+ const mode = (material.userData.isLightBlendMaterial || material.userData.isShadowCatcherMaterial)
1977
+ ? "ND_realitykit_shadowreceiver_surfaceshader"
1978
+ : "ND_realitykit_occlusion_surfaceshader";
1979
+
1980
+ return `
1981
+
1982
+ def Material "${materialName}" ${material.name ?`(
1983
+ displayName = "${material.name}"
1984
+ )` : ''}
1985
+ {
1986
+ token outputs:mtlx:surface.connect = ${materialRoot}/${materialName}/Occlusion.outputs:out>
1987
+
1988
+ def Shader "Occlusion"
1989
+ {
1990
+ uniform token info:id = "${mode}"
1991
+ token outputs:out
1992
+ }
1993
+ }`;
1994
+ }
1995
+
1946
1996
  // https://graphics.pixar.com/usd/docs/UsdPreviewSurface-Proposal.html
1947
1997
 
1948
1998
  const pad = ' ';
1949
1999
  const inputs: Array<string> = [];
1950
2000
  const samplers: Array<string> = [];
1951
- const materialName = getMaterialName(material);
1952
2001
  const usedUVChannels: Set<number> = new Set();
1953
2002
 
1954
2003
  function texName(tex: Texture) {
src/engine-components/export/usdz/USDZExporter.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { NEEDLE_progressive } from "@needle-tools/gltf-progressive";
2
2
  import { Euler, Matrix4, Mesh, Object3D, Quaternion, Vector3 } from "three";
3
3
 
4
- import { showBalloonMessage, showBalloonWarning } from "../../../engine/debug/index.js";
4
+ import { isDevEnvironment, showBalloonMessage, showBalloonWarning } from "../../../engine/debug/index.js";
5
5
  import { hasProLicense } from "../../../engine/engine_license.js";
6
6
  import { serializable } from "../../../engine/engine_serialization.js";
7
7
  import { getFormattedDate, Progress } from "../../../engine/engine_time_utils.js";
@@ -9,10 +9,12 @@
9
9
  import { WebXRButtonFactory } from "../../../engine/webcomponents/WebXRButtons.js";
10
10
  import { InternalUSDZRegistry } from "../../../engine/xr/usdz.js"
11
11
  import { InstancingHandler } from "../../../engine-components/RendererInstancing.js";
12
+ import { Collider } from "../../Collider.js";
12
13
  import { Behaviour, GameObject } from "../../Component.js";
13
14
  import { ContactShadows } from "../../ContactShadows.js";
14
15
  import { GroundProjectedEnv } from "../../GroundProjection.js";
15
16
  import { Renderer } from "../../Renderer.js"
17
+ import { Rigidbody } from "../../RigidBody.js";
16
18
  import { SpriteRenderer } from "../../SpriteRenderer.js";
17
19
  import { WebARSessionRoot } from "../../webxr/WebARSessionRoot.js";
18
20
  import { WebXR } from "../../webxr/WebXR.js";
@@ -115,7 +117,15 @@
115
117
  @serializable()
116
118
  interactive: boolean = true;
117
119
 
120
+ /** Enabling this option will export the USDZ file with RealityKit physics components.
121
+ * Rigidbody and Collider components will be converted to their RealityKit counterparts.
122
+ * Physics are supported on QuickLook in iOS 18+ and VisionOS 1+.
123
+ * Physics export is automatically turned off when there are no Rigidbody components anywhere on the exported object.
124
+ */
118
125
  @serializable()
126
+ physics: boolean = true;
127
+
128
+ @serializable()
119
129
  allowCreateQuicklookButton: boolean = true;
120
130
 
121
131
  @serializable()
@@ -149,14 +159,6 @@
149
159
  this.objectToExport = this.gameObject;
150
160
  if (!this.objectToExport?.children?.length && !(this.objectToExport as Mesh)?.isMesh)
151
161
  this.objectToExport = this.context.scene;
152
-
153
- if (this.interactive) {
154
- this.extensions.push(new BehaviorExtension());
155
- this.extensions.push(new AudioExtension());
156
- this.extensions.push(new PhysicsExtension());
157
- this.extensions.push(new TextExtension());
158
- this.extensions.push(new USDZUIExtension());
159
- }
160
162
  }
161
163
 
162
164
  /** @internal */
@@ -356,7 +358,30 @@
356
358
  // TODO we probably want to do that with all the extensions...
357
359
  // Ordering of extensions is important
358
360
  const animExt = new AnimationExtension(this.quickLookCompatible);
359
- const extensions: any = [animExt, ...this.extensions]
361
+ let physicsExt: PhysicsExtension | undefined = undefined;
362
+ const defaultExtensions: IUSDExporterExtension[] = [];
363
+ if (this.interactive) {
364
+ defaultExtensions.push(new BehaviorExtension());
365
+ defaultExtensions.push(new AudioExtension());
366
+
367
+ // If physics are enabled, and there are any Rigidbody components in the scene,
368
+ // add the PhysicsExtension to the default extensions.
369
+ if (globalThis["NEEDLE_USE_RAPIER"]) {
370
+ const rigidbodies = GameObject.getComponentsInChildren(objectToExport, Rigidbody);
371
+ if (rigidbodies.length > 0) {
372
+ if (this.physics) {
373
+ physicsExt = new PhysicsExtension();
374
+ defaultExtensions.push(physicsExt);
375
+ }
376
+ else if (isDevEnvironment()) {
377
+ console.warn("USDZExporter: Physics export is disabled, but there are active Rigidbody components in the scene. They will not be exported.");
378
+ }
379
+ }
380
+ }
381
+ defaultExtensions.push(new TextExtension());
382
+ defaultExtensions.push(new USDZUIExtension());
383
+ }
384
+ const extensions: any = [animExt, ...defaultExtensions, ...this.extensions];
360
385
 
361
386
  const eventArgs = { self: this, exporter: exporter, extensions: extensions, object: objectToExport };
362
387
  Progress.report("export-usdz", "Invoking before-export");
@@ -401,6 +426,20 @@
401
426
  return keep;
402
427
  }
403
428
 
429
+ exporter.beforeWritingDocument = () => {
430
+ // Warn if there are any physics components on animated objects or their children
431
+ if (isDevEnvironment() && animExt && physicsExt) {
432
+ const animatedObjects = animExt.animatedRoots;
433
+ for (const object of animatedObjects) {
434
+ const rigidBodySources = GameObject.getComponentsInChildren(object, Rigidbody).filter(c => c.enabled);
435
+ const colliderSources = GameObject.getComponents(object, Collider).filter(c => c.enabled && !c.isTrigger);
436
+ if (rigidBodySources.length > 0 || colliderSources.length > 0) {
437
+ console.error("An animated object has physics components in its child hierarchy. This can lead to undefined behaviour due to a bug in Apple's QuickLook (FB15925487). Remove the physics components from child objects or verify that you get the expected results.", object);
438
+ }
439
+ }
440
+ }
441
+ };
442
+
404
443
  // Collect invisible objects so that we can disable them if
405
444
  // - we're exporting for QuickLook
406
445
  // - and interactive behaviors are allowed.