@needle-tools/engine
Version:
Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.
936 lines (932 loc) • 58 kB
JavaScript
import { Matrix4, PropertyBinding, Quaternion, Vector3 } from "three";
import { isDevEnvironment, showBalloonWarning } from "../../../../engine/debug/debug.js";
import { getParam } from "../../../../engine/engine_utils.js";
import { Animator } from "../../../Animator.js";
import { GameObject } from "../../../Component.js";
import { buildMatrix, findStructuralNodesInBoneHierarchy, getPathToSkeleton, usdNumberFormatting as fn } from "../ThreeUSDZExporter.js";
const debug = getParam("debugusdzanimation");
const debugSerialization = getParam("debugusdzanimationserialization");
export class RegisteredAnimationInfo {
_start;
get start() {
if (this._start === undefined) {
this._start = this.ext.getStartTimeByClip(this.clip);
}
return this._start;
}
get duration() { return this.clip?.duration ?? TransformData.restPoseClipDuration; }
get nearestAnimatedRoot() { return this._nearestAnimatedRoot; }
get clipName() { return this.clip?.name ?? "rest"; }
ext;
root;
_nearestAnimatedRoot = undefined;
clip;
// Playback speed. Does not affect how the animation is written, just how fast actions play it back.
speed;
constructor(ext, root, clip) {
this.ext = ext;
this.root = root;
this.clip = clip;
this._nearestAnimatedRoot = this.getNearestAnimatedRoot();
}
static isDescendantOf(parent, child) {
let current = child;
if (!current || !parent)
return false;
while (current) {
if (!current)
return false;
if (current === parent)
return true;
current = current.parent;
}
return false;
}
/** Finds the nearest actually animated object under root based on the tracks in the AnimationClip. */
getNearestAnimatedRoot() {
let highestRoot = undefined;
try {
for (const track of this.clip?.tracks ?? []) {
const parsedPath = PropertyBinding.parseTrackName(track.name);
let animationTarget = PropertyBinding.findNode(this.root, parsedPath.nodeName);
if (animationTarget) {
if (!highestRoot)
highestRoot = animationTarget;
else {
if (animationTarget === highestRoot)
continue;
if (RegisteredAnimationInfo.isDescendantOf(highestRoot, animationTarget))
continue;
if (!RegisteredAnimationInfo.isDescendantOf(animationTarget, highestRoot)) {
// TODO test this, should find the nearest common ancestor
while (!RegisteredAnimationInfo.isDescendantOf(animationTarget, highestRoot) && animationTarget.parent) {
animationTarget = animationTarget.parent;
}
if (!RegisteredAnimationInfo.isDescendantOf(animationTarget, highestRoot)) {
console.error("USDZExporter: Animation clip targets multiple roots that are not parent/child. Please report a bug", this.root, this.clip, highestRoot, animationTarget);
}
}
highestRoot = animationTarget;
}
}
}
}
catch (e) {
console.error("USDZExporter: Exception when trying to find nearest animated root. Please report a bug", e);
highestRoot = undefined;
}
return highestRoot;
}
}
export class TransformData {
clip;
pos;
rot;
scale;
root;
target;
duration = 0;
useRootMotion = false;
/** This value can theoretically be anything – a value of 1 is good to clearly see animation gaps.
* For production, a value of 1/60 is enough, since the files can then still properly play back at 60fps.
*/
static frameRate = 60;
static animationDurationPadding = 6 / 60;
static restPoseClipDuration = 6 / 60;
constructor(root, target, clip) {
this.root = root;
this.target = target;
this.clip = clip;
// this is a rest pose clip.
// we assume duration 1/60 and no tracks, and when queried for times we just return [0, duration]
if (!clip) {
this.duration = TransformData.restPoseClipDuration;
}
else {
this.duration = clip.duration;
}
// warn if the duration does not equal the maximum time value in the tracks
if (clip && clip.tracks) {
const maxTime = Math.max(...clip.tracks.map((t) => t.times[t.times.length - 1]));
if (maxTime !== this.duration) {
console.warn("USDZExporter: Animation clip duration does not match the maximum time value in the tracks.", clip, maxTime, this.duration);
// We need to override the duration, otherwise we end up with gaps in the exported animation
// where there are actually no written animation values
this.duration = maxTime;
}
}
const animator = GameObject.getComponent(root, Animator);
if (animator)
this.useRootMotion = animator.applyRootMotion;
}
addTrack(track) {
if (!this.clip) {
console.error("This is a rest clip but you're trying to add tracks to it – this is likely a bug");
return;
}
if (track.name.endsWith("position"))
this.pos = track;
else if (track.name.endsWith("quaternion"))
this.rot = track;
else if (track.name.endsWith("scale"))
this.scale = track;
else {
if (track.name.endsWith("activeSelf")) {
/*
// Construct a scale track, then apply to the existing scale track.
// Not supported right now, because it would also require properly tracking that these objects need to be enabled
// at animation start because they're animating the enabled state... otherwise they just stay disabled from scene start
const newValues = [...track.values].map((v) => v ? [1,1,1] : [0,0,0]).flat();
const scaleTrack = new KeyframeTrack(track.name.replace(".activeSelf", ".scale"), track.times, newValues, InterpolateDiscrete);
if (!this.scale)
{
this.scale = scaleTrack;
console.log("Mock scale track", this.scale);
}
*/
console.warn("[USDZ] Animation of enabled/disabled state is not supported for USDZ export and will NOT be exported: " + track.name + " on " + (this.root?.name ?? this.target.name) + ". Animate scale 0/1 instead.");
}
else {
console.warn("[USDZ] Animation track type not supported for USDZ export and will NOT be exported: " + track.name + " on " + (this.root?.name ?? this.target.name) + ". Only .position, .rotation, .scale are supported.");
}
if (isDevEnvironment())
showBalloonWarning("[USDZ] Some animations can't be exported. See console for details.");
}
}
getFrames() {
if (!this.clip)
return 2;
return Math.max(this.pos?.times?.length ?? 0, this.rot?.times?.length ?? 0, this.scale?.times?.length ?? 0);
}
getDuration() {
return this.duration;
}
getSortedTimesArray(generatePos = true, generateRot = true, generateScale = true) {
if (!this.clip)
return [0, this.duration];
const posTimesArray = this.pos?.times;
const rotTimesArray = this.rot?.times;
const scaleTimesArray = this.scale?.times;
// timesArray is the sorted union of all time values
const timesArray = [];
if (generatePos && posTimesArray)
for (const t of posTimesArray)
timesArray.push(t);
if (generateRot && rotTimesArray)
for (const t of rotTimesArray)
timesArray.push(t);
if (generateScale && scaleTimesArray)
for (const t of scaleTimesArray)
timesArray.push(t);
// We also need to make sure we have start and end times for these tracks
// We already ensure this is the case for duration – it's always the maximum time value in the tracks
// (see constructor)
if (!timesArray.includes(0))
timesArray.push(0);
// sort times so it's increasing
timesArray.sort((a, b) => a - b);
// make sure time values are unique
return [...new Set(timesArray)];
}
/**
* Returns an iterator that yields the values for each time sample.
* Values are reused objects - if you want to append them to some array
* instead of processing them right away, clone() them.
* @param timesArray
* @param generatePos
* @param generateRot
* @param generateScale
*/
*getValues(timesArray, generatePos = true, generateRot = true, generateScale = true) {
const translation = new Vector3();
const rotation = new Quaternion();
const scale = new Vector3(1, 1, 1);
const object = this.target;
const positionInterpolant = generatePos ? this.pos?.createInterpolant() : undefined;
const rotationInterpolant = generateRot ? this.rot?.createInterpolant() : undefined;
const scaleInterpolant = generateScale ? this.scale?.createInterpolant() : undefined;
if (!positionInterpolant)
translation.set(object.position.x, object.position.y, object.position.z);
if (!rotationInterpolant)
rotation.set(object.quaternion.x, object.quaternion.y, object.quaternion.z, object.quaternion.w);
if (!scaleInterpolant)
scale.set(object.scale.x, object.scale.y, object.scale.z);
// WORKAROUND because it seems that sometimes we get a quaternion interpolant with a valueSize
// of its own length... which then goes into an endless loop
if (positionInterpolant && positionInterpolant.valueSize !== 3)
positionInterpolant.valueSize = 3;
if (rotationInterpolant && rotationInterpolant.valueSize !== 4)
rotationInterpolant.valueSize = 4;
if (scaleInterpolant && scaleInterpolant.valueSize !== 3)
scaleInterpolant.valueSize = 3;
// We're optionally padding with one extra time step at the beginning and the end
// So that we don't get unwanted interpolations between clips (during the padding time)
const extraFrame = 0; // TransformData.animationDurationPadding > 1 / 60 ? 1 : 0;
for (let index = 0 - extraFrame; index < timesArray.length + extraFrame; index++) {
let time = 0;
let returnTime = 0;
if (index < 0) {
time = timesArray[0];
returnTime = time - (TransformData.animationDurationPadding / 2) + 1 / 60;
}
else if (index >= timesArray.length) {
time = timesArray[timesArray.length - 1];
returnTime = time + (TransformData.animationDurationPadding / 2) - 1 / 60;
}
else {
time = timesArray[index];
returnTime = time;
}
if (positionInterpolant) {
const pos = positionInterpolant.evaluate(time);
translation.set(pos[0], pos[1], pos[2]);
}
if (rotationInterpolant) {
const quat = rotationInterpolant.evaluate(time);
rotation.set(quat[0], quat[1], quat[2], quat[3]);
}
if (scaleInterpolant) {
const scaleVal = scaleInterpolant.evaluate(time);
scale.set(scaleVal[0], scaleVal[1], scaleVal[2]);
}
// Apply basic root motion offset – non-animated transformation data is applied to the node again.
// We're doing this because clips that animate their own root are (typically) not in world space,
// but in local space and moved to a specific spot in the world.
if (this.useRootMotion && object === this.root) {
const rootMatrix = new Matrix4();
rootMatrix.compose(translation, rotation, scale);
rootMatrix.multiply(object.matrix);
rootMatrix.decompose(translation, rotation, scale);
}
yield { time: returnTime, translation, rotation, scale, index };
}
}
}
export class AnimationExtension {
get extensionName() { return "animation"; }
get animationData() { return this.dict; }
get registeredClips() { return this.clipToStartTime.keys(); }
get animatedRoots() { return this.rootTargetMap.keys(); }
get holdClipMap() { return this.clipToHoldClip; }
/** For each animated object, contains time/pos/rot/scale samples in the format that USD needs,
* ready to be written to the .usda file.
*/
dict = new Map();
/** Map of all roots (Animation/Animator or scene) and all targets that they animate.
* We need that info so that we can ensure that each target has the same number of TransformData entries
* so that switching between animations doesn't result in data "leaking" to another clip.
*/
rootTargetMap = new Map();
rootAndClipToRegisteredAnimationMap = new Map();
/** Clips registered for each root */
rootToRegisteredClip = new Map();
lastClipEndTime = 0;
clipToStartTime = new Map();
clipToHoldClip = new Map();
serializers = [];
/** Determines if we inject a rest pose clip for each root - only makes sense for QuickLook */
injectRestPoses = false;
/** Determines if we inject a PlayAnimationOnClick component with "scenestart" trigger - only makes sense for QuickLook */
injectImplicitBehaviours = false;
constructor(quickLookCompatible) {
this.injectRestPoses = quickLookCompatible;
this.injectImplicitBehaviours = quickLookCompatible;
}
getStartTimeCode() {
// return the end time + padding of the rest clip, if any exists
if (!this.injectRestPoses)
return 0;
if (this.rootAndClipToRegisteredAnimationMap.size === 0)
return 0;
// return 0;
return (TransformData.restPoseClipDuration + TransformData.animationDurationPadding) * 60;
}
/** Returns the end time code, based on 60 frames per second, for all registered animations.
* This matches the highest time value in the USDZ file. */
getEndTimeCode() {
let max = 0;
for (const [_, info] of this.rootAndClipToRegisteredAnimationMap) {
const end = info.start + info.duration;
if (end > max)
max = end;
}
return max * 60;
}
getClipCount(root) {
const currentCount = this.rootToRegisteredClip.get(root)?.length ?? 0;
// The rest pose is not part of rootToRegisteredClip
// if (this.injectRestPoses) currentCount = currentCount ? currentCount - 1 : 0;
return currentCount ?? 0;
}
/*
// TODO why do we have this here and on TransformData? Can RegisteredAnimationInfo not cache this value?
// TODO we probably want to assert here that this is the same value on all nodes
getStartTime01(root: Object3D, clip: AnimationClip | null) {
// This is a rest pose clip, it always starts at 0
if (!clip) return 0;
const targets = this.rootTargetMap.get(root);
if (!targets) return 0;
const transformDatas = this.dict.get(targets[0]);
if (!transformDatas) {
console.error("Trying to get start time for root that has no animation data", root, clip, ...this.dict);
return 0;
}
let currentStartTime = 0;
for (let i = 0; i < transformDatas.length; i++) {
if (transformDatas[i].clip === clip) break;
currentStartTime += transformDatas[i].getDuration() + TransformData.animationDurationPadding;
}
return currentStartTime;
}
*/
getStartTimeByClip(clip) {
if (!clip)
return 0;
if (!this.clipToStartTime.has(clip)) {
console.error("USDZExporter: Missing start time for clip – please report a bug.", clip);
return 0;
}
const time = this.clipToStartTime.get(clip);
return time;
}
// The same clip could be registered for different roots. All of them need written animation data then.
// The same root could have multiple clips registered to it. If it does, the clips need to write
// independent time data, so that playing back an animation on that root doesn't result in data "leaking"/"overlapping".
// The structure we need is:
// - MyRoot
// Animator
// - Clip1: CubeScale (only animates MyCube), duration: 3s
// - Clip2: SphereRotation (only animates MySphere), duration: 2s
// - MyCube
// - MySphere
// Results in:
// - MyRoot
// - MyCube
// - # rest clip (0..0.1)
// - # CubeScale (0.2..3.2)
// - # rest clip for SphereRotation (3.3..5.3)
// - MySphere
// - # rest clip (0..0.1)
// - # rest clip for CubeScale (0.2..3.2)
// - # SphereRotation (3.3..5.3)
/** Register an AnimationClip for a specific root object.
* @param root The root object that the animation clip is targeting.
* @param clip The animation clip to register. If null, a rest pose is registered.
* @returns The registered animation info, which contains the start time and duration of the clip.
*/
registerAnimation(root, clip) {
if (!root)
return null;
if (!this.rootTargetMap.has(root))
this.rootTargetMap.set(root, []);
// if we registered that exact pair already, just return the info
// no proper tuples in JavaScript, but we can use the uuids for a unique key here
const hash = root.uuid + (clip?.uuid ?? "-rest");
if (this.rootAndClipToRegisteredAnimationMap.has(hash)) {
return this.rootAndClipToRegisteredAnimationMap.get(hash);
}
if (debug)
console.log("registerAnimation", root, clip);
// When injecting a rest clip, the rest clip has ALL animated nodes as targets.
// So all other nodes will already have at least one animated clip registered, and their own
// animations need to start at index 1. Otherwise we're getting overlap where everything is
// in animation 0 and some data overrides each other incorrectly.
// When we don't inject a rest clip, we start at 0.
const startIndex = this.injectRestPoses ? 1 : 0;
const currentCount = (this.rootToRegisteredClip.get(root)?.length ?? 0) + startIndex;
const targets = this.rootTargetMap.get(root);
const unregisteredNodesForThisClip = new Set(targets);
if (clip && clip.tracks) {
// We could sort so that supported tracks come first, this allows us to support some additional tracks by
// modifying what has already been written for the supported ones (e.g. enabled -> modify scale).
// Only needed if we're actually emulating some unsupported track types in addTrack(...).
// const sortedTracks = clip.tracks.filter(x => !!x).sort((a, _b) => a.name.endsWith("position") || a.name.endsWith("quaternion") || a.name.endsWith("scale") ? -1 : 1);
for (const track of clip.tracks) {
const parsedPath = PropertyBinding.parseTrackName(track.name);
const animationTarget = PropertyBinding.findNode(root, parsedPath.nodeName);
if (!animationTarget) {
console.warn("no object found for track", track.name, "using " + root.name + " instead");
continue;
// // if no object was found it might be that we have a component that references an animation clip but wants to target another object
// // in that case UnityGLTF writes the name of the component as track targets because it doesnt know of the intented target
// animationTarget = root;
}
if (!this.dict.has(animationTarget)) {
this.dict.set(animationTarget, []);
}
const transformDataForTarget = this.dict.get(animationTarget);
if (!transformDataForTarget) {
console.warn("no transform data found for target ", animationTarget, "at slot " + currentCount + ", this is likely a bug");
continue;
}
// this node has animation data for this clip – no need for additional padding
unregisteredNodesForThisClip.delete(animationTarget);
// Since we're interleaving animations, we need to ensure that for the same root,
// all clips that "touch" it are written in the same order for all animated nodes.
// this means we need to pad the TransformData array with empty entries when a particular
// node inside that root is not animated by a particular clip.
// It also means that when we encounter a clip that contains animation data for a new node,
// We need to pad that one's array as well so it starts at the same point.
// TODO most likely doesn't work for overlapping clips (clips where a root is a child of another root)
// TODO in that case we may need to pad globally, not per root
// Inject a rest pose if we don't have it already
if (this.injectRestPoses && !transformDataForTarget[0]) {
console.log("Injecting rest pose", animationTarget, clip, "at slot", currentCount);
transformDataForTarget[0] = new TransformData(null, animationTarget, null);
}
// These all need to be at the same index, otherwise our padding went wrong
let model = transformDataForTarget[currentCount];
if (!model) {
model = new TransformData(root, animationTarget, clip);
transformDataForTarget[currentCount] = model;
}
model.addTrack(track);
// We're keeping track of all animated nodes per root, needed for proper padding
if (!targets?.includes(animationTarget))
targets?.push(animationTarget);
}
}
if (debug)
console.log("Unregistered nodes for this clip", unregisteredNodesForThisClip, "clip", clip, "at slot", currentCount, "for root", root, "targets", targets);
// add padding for nodes that are not animated by this clip
for (const target of unregisteredNodesForThisClip) {
const transformDataForTarget = this.dict.get(target);
if (!transformDataForTarget)
continue;
// Inject rest pose if these nodes don't have it yet for some reason – this is likely a bug
if (this.injectRestPoses && !transformDataForTarget[0]) {
console.warn("Adding rest pose for ", target, clip, "at slot", currentCount, "This is likely a bug, should have been added earlier.");
const model = new TransformData(null, target, null);
transformDataForTarget[0] = model;
}
let model = transformDataForTarget[currentCount];
if (!model) {
if (debug)
console.log("Adding padding clip for ", target, clip, "at slot", currentCount);
model = new TransformData(root, target, clip);
transformDataForTarget[currentCount] = model;
}
}
// get the entry for this object.
// This doesnt work if we have clips animating multiple objects
const info = new RegisteredAnimationInfo(this, root, clip);
this.rootAndClipToRegisteredAnimationMap.set(hash, info);
if (debug)
console.log({ root, clip, info });
if (clip) {
const registered = this.rootToRegisteredClip.get(root);
if (!registered)
this.rootToRegisteredClip.set(root, [clip]);
else
registered.push(clip);
const lastClip = this.clipToStartTime.get(clip);
if (!lastClip) {
if (this.lastClipEndTime == null)
this.lastClipEndTime = TransformData.restPoseClipDuration;
let newStartTime = this.lastClipEndTime + TransformData.animationDurationPadding;
let newEndTime = newStartTime + clip.duration;
// Round these times, makes it easier to understand what happens in the file
const roundedStartTime = Math.round(newStartTime * 60) / 60;
const roundedEndTime = Math.round(newEndTime * 60) / 60;
if (Math.abs(roundedStartTime - newStartTime) < 0.01)
newStartTime = roundedStartTime;
if (Math.abs(roundedEndTime - newEndTime) < 0.01)
newEndTime = roundedEndTime;
// Round newStartTime up to the next frame
newStartTime = Math.ceil(newStartTime);
newEndTime = newStartTime + clip.duration;
this.clipToStartTime.set(clip, newStartTime);
this.lastClipEndTime = newEndTime;
}
}
return info;
}
onAfterHierarchy(_context) {
if (debug)
console.log("Animation clips per animation target node", this.dict);
}
onAfterBuildDocument(_context) {
// Validation: go through all roots, check if their data and lengths are consistent.
// There are some cases that we can patch up here, especially if non-overlapping animations have resulted in "holes"
// in TransformData where only now we know what the matching animation clip actually is.
if (debug)
console.log("Animation data", { dict: this.dict, rootTargetMap: this.rootTargetMap, rootToRegisteredClip: this.rootToRegisteredClip });
for (const root of this.rootTargetMap.keys()) {
const targets = this.rootTargetMap.get(root);
if (!targets)
continue;
// The TransformData[] arrays here should have the same length, and the same durations
let arrayLength = undefined;
const durations = [];
for (const target of targets) {
const datas = this.dict.get(target);
if (!datas) {
console.error("No data found for target on USDZ export – please report a bug!", target);
continue;
}
if (arrayLength === undefined)
arrayLength = datas?.length;
if (arrayLength !== datas?.length)
console.error("Different array lengths for targets – please report a bug!", datas);
for (let i = 0; i < datas.length; i++) {
let data = datas[i];
if (!data) {
// If we don't have TransformData for this object yet, we're emitting a rest pose with the duration
// of the matching clip that was registered for this root.
const index = i - (this.injectRestPoses ? 1 : 0);
datas[i] = new TransformData(null, target, this.rootToRegisteredClip.get(root)[index]);
data = datas[i];
}
const duration = data.getDuration();
if (durations[i] === undefined)
durations[i] = duration;
else if (durations[i] !== duration) {
console.error("Error during UDSZ export: Encountered different animation durations for animated targets. Please report a bug!", { datas, target });
durations[i] = duration;
continue;
}
}
}
}
for (const ser of this.serializers) {
const parent = ser.model?.parent;
const isEmptyParent = parent?.isDynamic === true;
if (debugSerialization)
console.log(isEmptyParent, ser.model?.parent);
if (isEmptyParent) {
ser.registerCallback(parent);
}
}
}
onExportObject(object, model, _context) {
GameObject.foreachComponent(object, (comp) => {
const c = comp;
if (typeof c.createAnimation === "function") {
c.createAnimation(this, model, _context);
}
}, false);
// we need to be able to retarget serialization to empty parents before actually serializing (we do that in another callback)
const ser = new SerializeAnimation(object, this);
this.serializers.push(ser);
ser.registerCallback(model);
}
}
class SerializeAnimation {
model = undefined;
object;
animationData;
ext;
callback;
constructor(object, ext) {
this.object = object;
this.animationData = ext.animationData;
this.ext = ext;
}
registerCallback(model) {
if (this.model && this.callback) {
this.model.removeEventListener("serialize", this.callback);
}
if (!this.callback)
this.callback = this.onSerialize.bind(this);
if (debugSerialization)
console.log("REPARENT", model);
this.model = model;
if (this.callback)
this.model.addEventListener("serialize", this.callback);
}
skinnedMeshExport(writer, _context, ext) {
const model = this.model;
const dict = this.animationData;
if (!model)
return;
if (model.skinnedMesh) {
const skeleton = model.skinnedMesh.skeleton;
const boneAndInverse = new Array();
const sortedBones = [];
const uuidsFound = [];
for (const bone of skeleton.bones) {
// if (bone.parent!.type !== 'Bone')
{
sortedBones.push(bone);
uuidsFound.push(bone.uuid);
const inverse = skeleton.boneInverses[skeleton.bones.indexOf(bone)];
boneAndInverse.push({ bone, inverse });
}
}
let maxSteps = 10_000;
while (uuidsFound.length < skeleton.bones.length && maxSteps-- > 0) {
for (const sortedBone of sortedBones) {
const children = sortedBone.children;
for (const childBone of children) {
if (uuidsFound.indexOf(childBone.uuid) === -1 && skeleton.bones.indexOf(childBone) !== -1) {
sortedBones.push(childBone);
uuidsFound.push(childBone.uuid);
const childInverse = skeleton.boneInverses[skeleton.bones.indexOf(childBone)];
boneAndInverse.push({ bone: childBone, inverse: childInverse });
}
}
}
}
if (maxSteps <= 0)
console.error("Failed to sort bones in skinned mesh", model.skinnedMesh, skeleton.bones, uuidsFound);
for (const structuralNode of findStructuralNodesInBoneHierarchy(skeleton.bones)) {
boneAndInverse.push({ bone: structuralNode, inverse: structuralNode.matrixWorld.clone().invert() });
}
// sort bones by path – need to be sorted in the same order as during mesh export
const assumedRoot = boneAndInverse[0].bone.parent;
if (!assumedRoot)
console.error("No bone parent found for skinned mesh during USDZ export", model.skinnedMesh);
boneAndInverse.sort((a, b) => getPathToSkeleton(a.bone, assumedRoot) > getPathToSkeleton(b.bone, assumedRoot) ? 1 : -1);
function createVector3TimeSampleLines_(values) {
const lines = [];
for (const [frame, frameValues] of values) {
let line = `${frame} : [`;
const boneRotations = [];
for (const v of frameValues) {
boneRotations.push(`(${fn(v.x)}, ${fn(v.y)}, ${fn(v.z)})`);
}
line = line.concat(boneRotations.join(', '));
line = line.concat('],');
lines.push(line);
}
return lines;
}
function createVector4TimeSampleLines_(rotations) {
const lines = [];
for (const [frame, frameRotations] of rotations) {
let line = `${frame} : [`;
const boneRotations = [];
for (const v of frameRotations) {
boneRotations.push(`(${fn(v.w)}, ${fn(v.x)}, ${fn(v.y)}, ${fn(v.z)})`);
}
line = line.concat(boneRotations.join(', '));
line = line.concat('],');
lines.push(line);
}
return lines;
}
function getSortedFrameTimes(boneToTransformData) {
// We should have a proper rectangular array,
// Where for each bone we have the same number of TransformData entries.
let numberOfEntries = undefined;
let allBonesHaveSameNumberOfTransformDataEntries = true;
const undefinedBoneEntries = new Map();
for (const [bone, transformDatas] of boneToTransformData) {
if (numberOfEntries === undefined)
numberOfEntries = transformDatas.length;
if (numberOfEntries !== transformDatas.length) {
allBonesHaveSameNumberOfTransformDataEntries = false;
}
let index = 0;
for (const transformData of transformDatas) {
index++;
if (!transformData) {
if (!undefinedBoneEntries.has(bone))
undefinedBoneEntries.set(bone, []);
undefinedBoneEntries.get(bone).push(index);
}
}
}
// TODO not working yet for multiple skinned characters at the same time
if (debug) {
console.log("Bone count: ", boneToTransformData.size, "TransformData entries per bone: ", numberOfEntries, "Undefined bone entries: ", undefinedBoneEntries);
}
console.assert(allBonesHaveSameNumberOfTransformDataEntries, "All bones should have the same number of TransformData entries", boneToTransformData);
console.assert(undefinedBoneEntries.size === 0, "All TransformData entries should be set", undefinedBoneEntries);
const times = [];
for (const [bone, transformDatas] of boneToTransformData) {
/*
// calculate start times from the transformDatas
const startTimes = new Array<number>();
let currentStartTime = 0;
for (let i = 0; i < transformDatas.length; i++) {
startTimes.push(currentStartTime);
currentStartTime += transformDatas[i].getDuration() + TransformData.animationDurationPadding;
}
*/
for (let i = 0; i < transformDatas.length; i++) {
const transformData = transformDatas[i];
// const timeOffset = transformData.getStartTime(dict);
const timeOffset = ext.getStartTimeByClip(transformData.clip);
if (times.length <= i) {
times.push({ pos: [], rot: [], scale: [], timeOffset });
}
const perTransfromDataTimes = times[i];
perTransfromDataTimes.pos.push(...transformData.getSortedTimesArray(true, false, false));
perTransfromDataTimes.rot.push(...transformData.getSortedTimesArray(false, true, false));
perTransfromDataTimes.scale.push(...transformData.getSortedTimesArray(false, false, true));
}
}
for (const perTransfromDataTimes of times) {
/*
// TODO we're doing that in animation export as well
if (!times.pos.includes(0)) times.pos.push(0);
if (!times.rot.includes(0)) times.rot.push(0);
if (!times.scale.includes(0)) times.scale.push(0);
*/
// sort times so it's increasing
perTransfromDataTimes.pos.sort((a, b) => a - b);
perTransfromDataTimes.rot.sort((a, b) => a - b);
perTransfromDataTimes.scale.sort((a, b) => a - b);
// make sure time values are unique
perTransfromDataTimes.pos = [...new Set(perTransfromDataTimes.pos)];
perTransfromDataTimes.rot = [...new Set(perTransfromDataTimes.rot)];
perTransfromDataTimes.scale = [...new Set(perTransfromDataTimes.scale)];
}
return times;
}
function createTimeSamplesObject_(data, sortedComponentFrameNumbers, bones) {
const positionTimeSamples = new Map();
const quaternionTimeSamples = new Map();
const scaleTimeSamples = new Map();
const count = sortedComponentFrameNumbers.length;
// return sampled data for each bone
for (const bone of bones) {
const boneEntryInData = data.get(bone);
let emptyTransformData = undefined;
// if we have animation data for this bone, check that it's the right amount.
if (boneEntryInData)
console.assert(boneEntryInData.length === count, "We should have the same number of TransformData entries for each bone", boneEntryInData, sortedComponentFrameNumbers);
// if we don't have animation data, create an empty one –
// it will automatically map to the rest pose, albeit inefficiently
else
emptyTransformData = new TransformData(null, bone, null);
for (let i = 0; i < count; i++) {
const transformData = boneEntryInData ? boneEntryInData[i] : emptyTransformData;
const timeData = sortedComponentFrameNumbers[i];
for (const { time, translation } of transformData.getValues(timeData.pos, true, false, false)) {
const shiftedTime = time + timeData.timeOffset;
const t = shiftedTime * 60;
if (!positionTimeSamples.has(t))
positionTimeSamples.set(t, new Array());
positionTimeSamples.get(t).push(translation.clone());
}
for (const { time, rotation } of transformData.getValues(timeData.rot, false, true, false)) {
const shiftedTime = time + timeData.timeOffset;
const t = shiftedTime * 60;
if (!quaternionTimeSamples.has(t))
quaternionTimeSamples.set(t, new Array());
quaternionTimeSamples.get(t).push(rotation.clone());
}
for (const { time, scale } of transformData.getValues(timeData.scale, false, false, true)) {
const shiftedTime = time + timeData.timeOffset;
const t = shiftedTime * 60;
if (!scaleTimeSamples.has(t))
scaleTimeSamples.set(t, new Array());
scaleTimeSamples.get(t).push(scale.clone());
}
}
}
return {
position: positionTimeSamples.size == 0 ? undefined : positionTimeSamples,
quaternion: quaternionTimeSamples.size == 0 ? undefined : quaternionTimeSamples,
scale: scaleTimeSamples.size == 0 ? undefined : scaleTimeSamples,
};
}
function buildVector3Array_(array) {
const lines = [];
for (const v of array) {
lines.push(`(${fn(v.x)}, ${fn(v.y)}, ${fn(v.z)})`);
}
return lines.join(', ');
}
function buildVector4Array_(array) {
const lines = [];
for (const v of array) {
lines.push(`(${fn(v.w)}, ${fn(v.x)}, ${fn(v.y)}, ${fn(v.z)})`);
}
return lines.join(', ');
}
function getPerBoneTransformData(bones) {
const boneToTransformData = new Map();
if (debug) {
const logData = new Array();
for (const [key, val] of dict) {
logData.push(key.uuid + ": " + val.length + " " + val.map(x => x.clip?.uuid.substring(0, 6)).join(" "));
}
console.log("getPerBoneTransformData\n" + logData.join("\n"));
}
for (const bone of bones) {
const data = dict.get(bone);
if (!data)
continue;
boneToTransformData.set(bone, data);
}
return boneToTransformData;
}
function createAllTimeSampleObjects(bones) {
const perBoneTransformData = getPerBoneTransformData(bones);
const sortedFrameNumbers = getSortedFrameTimes(perBoneTransformData);
return createTimeSamplesObject_(perBoneTransformData, sortedFrameNumbers, bones);
}
const sanitizeRestPose = _context.quickLookCompatible;
const rest = [];
const translations = [];
const rotations = [];
const scales = [];
for (const { bone } of boneAndInverse) {
// Workaround for FB13808839: Rest poses must be decomposable in QuickLook
if (sanitizeRestPose) {
const scale = bone.scale;
if (scale.x == 0)
scale.x = 0.00001;
if (scale.y == 0)
scale.y = 0.00001;
if (scale.z == 0)
scale.z = 0.00001;
rest.push(new Matrix4().compose(bone.position, bone.quaternion, bone.scale));
}
else {
rest.push(bone.matrix.clone());
}
translations.push(bone.position);
rotations.push(bone.quaternion);
scales.push(bone.scale);
}
const bonesArray = boneAndInverse.map(x => "\"" + getPathToSkeleton(x.bone, assumedRoot) + "\"").join(', ');
const bindTransforms = boneAndInverse.map(x => buildMatrix(x.inverse.clone().invert())).join(', ');
writer.beginBlock(`def Skeleton "Rig"`);
writer.appendLine(`uniform matrix4d[] bindTransforms = [${bindTransforms}]`);
writer.appendLine(`uniform token[] joints = [${bonesArray}]`);
writer.appendLine(`uniform token purpose = "guide"`);
writer.appendLine(`uniform matrix4d[] restTransforms = [${rest.map(m => buildMatrix(m)).join(', ')}]`);
// In glTF, transformations on the Skeleton are ignored (NODE_SKINNED_MESH_LOCAL_TRANSFORMS validator warning)
// So here we don't write anything to get an identity transform.
// writer.appendLine( `matrix4d xformOp:transform = ${buildMatrix( new Matrix4() )}` );
// writer.appendLine( `uniform token[] xformOpOrder = ["xformOp:transform"]` );
const timeSampleObjects = createAllTimeSampleObjects(boneAndInverse.map(x => x.bone));
if (debug) {
// find the first..last value in the time samples
let min = 10000000;
let max = 0;
for (const key of timeSampleObjects.position?.keys() ?? []) {
min = Math.min(min, key);
max = Math.max(max, key);
}
console.log("Time samples", min, max, timeSampleObjects);
}
writer.beginBlock(`def SkelAnimation "_anim"`);
// TODO if we include blendshapes we likely need subdivision?
// writer.appendLine( `uniform token[] blendShapes` )
// writer.appendLine( `float[] blendShapeWeights` )
writer.appendLine(`uniform token[] joints = [${bonesArray}]`);
writer.appendLine(`quatf[] rotations = [${buildVector4Array_(rotations)}]`);
if (timeSampleObjects && timeSampleObjects.quaternion) {
writer.beginBlock(`quatf[] rotations.timeSamples = {`, '');
const rotationTimeSampleLines = createVector4TimeSampleLines_(timeSampleObjects['quaternion']);
for (const line of rotationTimeSampleLines) {
writer.appendLine(line);
}
writer.closeBlock();
}
writer.appendLine(`half3[] scales = [${buildVector3Array_(scales)}]`);
if (timeSampleObjects && timeSampleObjects.scale) {
writer.beginBlock(`half3[] scales.timeSamples = {`, '');
const scaleTimeSampleLines = createVector3TimeSampleLines_(timeSampleObjects['scale']);
for (const line of scaleTimeSampleLines) {
writer.appendLine(line);
}
writer.closeBlock();
}
writer.appendLine(`float3[] translations = [${buildVector3Array_(translations)}]`);
if (timeSampleObjects && timeSampleObjects.position) {
writer.beginBlock(`float3[] translations.timeSamples = {`, '');
const positionTimeSampleLines = createVector3TimeSampleLines_(timeSampleObjects['position']);
for (const line of positionTimeSampleLines) {
writer.appendLine(line);
}
writer.closeBlock();
}
writer.closeBlock();
writer.closeBlock();
}
}
onSerialize(writer, _context) {
if (!this.model)
return;
// Workaround: Sanitize TransformData for this object.
// This works around an issue with wrongly detected animation roots, where some of the indices
// in the TransformData array are not property set. Reproduces with golem_yokai.glb
const arr0 = this.animationData.get(this.object);
if (arr0) {
for (let i = 0; i < arr0.length; i++) {
if (arr0[i] !== undefined)
continue;
arr0[i] = new TransformData(null, this.object, null);
}
}
const ext = this.ext;
this.skinnedMeshExport(writer, _context, ext);
const object = this.object;
const model = this.model;
// do we have animation data for this node? if not, return
const animationData = this.animationData.get(object);
if (!animationData)
return;
// Skinned meshes are handled separately by the method above.
// They need to be handled first (before checking for animation data) because animation needs to be exported
// as part of the skinned mesh and that may not be animated at all – if any bone is animated we need to export.
//@ts-ignore
if (object.isSkinnedMesh)
return;
// Excluding the concrete bone xform hierarchy animation his is mostly useful for debugging,