From 3d66316716307e8a8d9acb069b71e6a7c65297c1 Mon Sep 17 00:00:00 2001 From: Wojtek Figat Date: Mon, 9 Feb 2026 15:03:42 +0100 Subject: [PATCH] Optimize animations retargeting between skeletons Cuts down `RetargetSkeletonPose` time down by over 80%. #3827 --- .../Animations/Graph/AnimGroup.Animation.cpp | 135 +++++++++--------- Source/Engine/Content/Assets/SkinnedModel.cpp | 2 + Source/Engine/Graphics/Models/SkeletonData.h | 9 ++ Source/Engine/Graphics/Models/SkinnedMesh.cpp | 36 +++++ 4 files changed, 116 insertions(+), 66 deletions(-) diff --git a/Source/Engine/Animations/Graph/AnimGroup.Animation.cpp b/Source/Engine/Animations/Graph/AnimGroup.Animation.cpp index 08767728a..7eb8d32d6 100644 --- a/Source/Engine/Animations/Graph/AnimGroup.Animation.cpp +++ b/Source/Engine/Animations/Graph/AnimGroup.Animation.cpp @@ -109,86 +109,84 @@ namespace nodes->RootMotion.Orientation.Normalize(); } } - - Matrix ComputeWorldMatrixRecursive(const SkeletonData& skeleton, int32 index, Matrix localMatrix) - { - const auto& node = skeleton.Nodes[index]; - index = node.ParentIndex; - while (index != -1) - { - const auto& parent = skeleton.Nodes[index]; - localMatrix *= parent.LocalTransform.GetWorld(); - index = parent.ParentIndex; - } - return localMatrix; - } - - Matrix ComputeInverseParentMatrixRecursive(const SkeletonData& skeleton, int32 index) - { - Matrix inverseParentMatrix = Matrix::Identity; - const auto& node = skeleton.Nodes[index]; - if (node.ParentIndex != -1) - { - inverseParentMatrix = ComputeWorldMatrixRecursive(skeleton, index, inverseParentMatrix); - inverseParentMatrix = Matrix::Invert(inverseParentMatrix); - } - return inverseParentMatrix; - } } -void RetargetSkeletonNode(const SkeletonData& sourceSkeleton, const SkeletonData& targetSkeleton, const SkinnedModel::SkeletonMapping& sourceMapping, Transform& node, int32 targetIndex) +// Utility for retargeting animation poses between skeletons. +struct Retargeting { - // sourceSkeleton - skeleton of Anim Graph (Base Locomotion pack) - // targetSkeleton - visual mesh skeleton (City Characters pack) - // target - anim graph input/output transformation of that node - const auto& targetNode = targetSkeleton.Nodes[targetIndex]; - const int32 sourceIndex = sourceMapping.NodesMapping[targetIndex]; - if (sourceIndex == -1) +private: + const Matrix* _sourcePosePtr, * _targetPosePtr; + const SkeletonData* _sourceSkeleton, *_targetSkeleton; + const SkinnedModel::SkeletonMapping* _sourceMapping; + +public: + void Init(const SkeletonData& sourceSkeleton, const SkeletonData& targetSkeleton, const SkinnedModel::SkeletonMapping& sourceMapping) { - // Use T-pose - node = targetNode.LocalTransform; - return; + ASSERT_LOW_LAYER(targetSkeleton.Nodes.Count() == sourceMapping.NodesMapping.Length()); + + // Cache world-space poses for source and target skeletons to avoid redundant calculations during retargeting + _sourcePosePtr = sourceSkeleton.GetNodesPose().Get(); + _targetPosePtr = targetSkeleton.GetNodesPose().Get(); + + _sourceSkeleton = &sourceSkeleton; + _targetSkeleton = &targetSkeleton; + _sourceMapping = &sourceMapping; } - const auto& sourceNode = sourceSkeleton.Nodes[sourceIndex]; - // [Reference: https://wickedengine.net/2022/09/animation-retargeting/comment-page-1/] - - // Calculate T-Pose of source node, target node and target parent node - Matrix bindMatrix = ComputeWorldMatrixRecursive(sourceSkeleton, sourceIndex, sourceNode.LocalTransform.GetWorld()); - Matrix inverseBindMatrix = Matrix::Invert(bindMatrix); - Matrix targetMatrix = ComputeWorldMatrixRecursive(targetSkeleton, targetIndex, targetNode.LocalTransform.GetWorld()); - Matrix inverseParentMatrix = ComputeInverseParentMatrixRecursive(targetSkeleton, targetIndex); - - // Target node animation is world-space difference of the animated source node inside the target's parent node world-space - Matrix localMatrix = inverseBindMatrix * ComputeWorldMatrixRecursive(sourceSkeleton, sourceIndex, node.GetWorld()); - localMatrix = targetMatrix * localMatrix * inverseParentMatrix; - - // Extract local node transformation - localMatrix.Decompose(node); -} - -void RetargetSkeletonPose(const SkeletonData& sourceSkeleton, const SkeletonData& targetSkeleton, const SkinnedModel::SkeletonMapping& mapping, const Transform* sourceNodes, Transform* targetNodes) -{ - // TODO: cache source and target skeletons world-space poses for faster retargeting (use some pooled memory) - ASSERT_LOW_LAYER(targetSkeleton.Nodes.Count() == mapping.NodesMapping.Length()); - for (int32 targetIndex = 0; targetIndex < targetSkeleton.Nodes.Count(); targetIndex++) + void RetargetNode(const Transform& source, Transform& target, int32 sourceIndex, int32 targetIndex) { - auto& targetNode = targetSkeleton.Nodes.Get()[targetIndex]; - const int32 sourceIndex = mapping.NodesMapping.Get()[targetIndex]; - Transform node; + // sourceSkeleton - skeleton of Anim Graph + // targetSkeleton - visual mesh skeleton + // target - anim graph input/output transformation of that node + const SkeletonNode& targetNode = _targetSkeleton->Nodes.Get()[targetIndex]; if (sourceIndex == -1) { // Use T-pose - node = targetNode.LocalTransform; + target = targetNode.LocalTransform; } else { - // Retarget - node = sourceNodes[sourceIndex]; - RetargetSkeletonNode(sourceSkeleton, targetSkeleton, mapping, node, targetIndex); + // [Reference: https://wickedengine.net/2022/09/animation-retargeting/comment-page-1/] + + // Calculate T-Pose of source node, target node and target parent node + const Matrix* sourcePosePtr = _sourcePosePtr; + const Matrix* targetPosePtr = _targetPosePtr; + const Matrix& bindMatrix = sourcePosePtr[sourceIndex]; + const Matrix& targetMatrix = targetPosePtr[targetIndex]; + Matrix inverseParentMatrix; + if (targetNode.ParentIndex != -1) + Matrix::Invert(targetPosePtr[targetNode.ParentIndex], inverseParentMatrix); + else + inverseParentMatrix = Matrix::Identity; + + // Target node animation is world-space difference of the animated source node inside the target's parent node world-space + const SkeletonNode& sourceNode = _sourceSkeleton->Nodes.Get()[sourceIndex]; + Matrix localMatrix = source.GetWorld(); + if (sourceNode.ParentIndex != -1) + localMatrix = localMatrix * sourcePosePtr[sourceNode.ParentIndex]; + localMatrix = Matrix::Invert(bindMatrix) * localMatrix; + localMatrix = targetMatrix * localMatrix * inverseParentMatrix; + + // Extract local node transformation + localMatrix.Decompose(target); } - targetNodes[targetIndex] = node; } + + FORCE_INLINE void RetargetPose(const Transform* sourceNodes, Transform* targetNodes) + { + for (int32 targetIndex = 0; targetIndex < _targetSkeleton->Nodes.Count(); targetIndex++) + { + const int32 sourceIndex = _sourceMapping->NodesMapping.Get()[targetIndex]; + RetargetNode(sourceNodes[sourceIndex], targetNodes[targetIndex], sourceIndex, targetIndex); + } + } +}; + +void RetargetSkeletonPose(const SkeletonData& sourceSkeleton, const SkeletonData& targetSkeleton, const SkinnedModel::SkeletonMapping& mapping, const Transform* sourceNodes, Transform* targetNodes) +{ + Retargeting retargeting; + retargeting.Init(sourceSkeleton, targetSkeleton, mapping); + retargeting.RetargetPose(sourceNodes, targetNodes); } AnimGraphTraceEvent& AnimGraphContext::AddTraceEvent(const AnimGraphNode* node) @@ -431,9 +429,13 @@ void AnimGraphExecutor::ProcessAnimation(AnimGraphImpulse* nodes, AnimGraphNode* const bool weighted = weight < 1.0f; const bool retarget = mapping.SourceSkeleton && mapping.SourceSkeleton != mapping.TargetSkeleton; const auto emptyNodes = GetEmptyNodes(); + Retargeting retargeting; SkinnedModel::SkeletonMapping sourceMapping; if (retarget) + { sourceMapping = _graph.BaseModel->GetSkeletonMapping(mapping.SourceSkeleton); + retargeting.Init(mapping.SourceSkeleton->Skeleton, mapping.TargetSkeleton->Skeleton, mapping); + } for (int32 nodeIndex = 0; nodeIndex < nodes->Nodes.Count(); nodeIndex++) { const int32 nodeToChannel = mapping.NodesMapping[nodeIndex]; @@ -447,7 +449,8 @@ void AnimGraphExecutor::ProcessAnimation(AnimGraphImpulse* nodes, AnimGraphNode* // Optionally retarget animation into the skeleton used by the Anim Graph if (retarget) { - RetargetSkeletonNode(mapping.SourceSkeleton->Skeleton, mapping.TargetSkeleton->Skeleton, sourceMapping, srcNode, nodeIndex); + const int32 sourceIndex = sourceMapping.NodesMapping[nodeIndex]; + retargeting.RetargetNode(srcNode, srcNode, sourceIndex, nodeIndex); } // Mark node as used diff --git a/Source/Engine/Content/Assets/SkinnedModel.cpp b/Source/Engine/Content/Assets/SkinnedModel.cpp index ce6300b17..c0355ea0e 100644 --- a/Source/Engine/Content/Assets/SkinnedModel.cpp +++ b/Source/Engine/Content/Assets/SkinnedModel.cpp @@ -378,6 +378,7 @@ bool SkinnedModel::SetupSkeleton(const Array& nodes) model->Skeleton.Bones[i].LocalTransform = node.LocalTransform; model->Skeleton.Bones[i].NodeIndex = i; } + model->Skeleton.Dirty(); ClearSkeletonMapping(); // Calculate offset matrix (inverse bind pose transform) for every bone manually @@ -435,6 +436,7 @@ bool SkinnedModel::SetupSkeleton(const Array& nodes, const ArraySkeleton.Nodes = nodes; model->Skeleton.Bones = bones; + model->Skeleton.Dirty(); ClearSkeletonMapping(); // Calculate offset matrix (inverse bind pose transform) for every bone manually diff --git a/Source/Engine/Graphics/Models/SkeletonData.h b/Source/Engine/Graphics/Models/SkeletonData.h index 0b6c7d4d7..79e0be512 100644 --- a/Source/Engine/Graphics/Models/SkeletonData.h +++ b/Source/Engine/Graphics/Models/SkeletonData.h @@ -73,6 +73,10 @@ struct TIsPODType /// class FLAXENGINE_API SkeletonData { +private: + mutable volatile int64 _dirty = 1; + mutable Array _cachedPose; + public: /// /// The nodes in this hierarchy. The root node is always at the index 0. @@ -114,6 +118,11 @@ public: int32 FindNode(const StringView& name) const; int32 FindBone(int32 nodeIndex) const; + // Gets the skeleton nodes transforms in mesh space (pose). Calculated from the local node transforms and hierarchy. Cached internally and updated when data is dirty. + const Array& GetNodesPose() const; + + // Marks data as dirty (modified) to update internal state and recalculate cached data if needed (eg. skeleton pose). + void Dirty(); uint64 GetMemoryUsage() const; /// diff --git a/Source/Engine/Graphics/Models/SkinnedMesh.cpp b/Source/Engine/Graphics/Models/SkinnedMesh.cpp index 66b3e5701..369e5d825 100644 --- a/Source/Engine/Graphics/Models/SkinnedMesh.cpp +++ b/Source/Engine/Graphics/Models/SkinnedMesh.cpp @@ -154,6 +154,8 @@ void SkeletonData::Swap(SkeletonData& other) { Nodes.Swap(other.Nodes); Bones.Swap(other.Bones); + Dirty(); + other.Dirty(); } Transform SkeletonData::GetNodeTransform(int32 nodeIndex) const @@ -171,6 +173,7 @@ Transform SkeletonData::GetNodeTransform(int32 nodeIndex) const void SkeletonData::SetNodeTransform(int32 nodeIndex, const Transform& value) { CHECK(Nodes.IsValidIndex(nodeIndex)); + Dirty(); const int32 parentIndex = Nodes[nodeIndex].ParentIndex; if (parentIndex == -1) { @@ -201,6 +204,39 @@ int32 SkeletonData::FindBone(int32 nodeIndex) const return -1; } +const Array& SkeletonData::GetNodesPose() const +{ + // Guard with a simple atomic flag to avoid locking if the pose is up to date + if (Platform::AtomicRead(&_dirty)) + { + ScopeLock lock(RenderContext::GPULocker); + if (Platform::AtomicRead(&_dirty)) + { + Platform::AtomicStore(&_dirty, 0); + const SkeletonNode* nodes = Nodes.Get(); + const int32 nodesCount = Nodes.Count(); + _cachedPose.Resize(nodesCount); + Matrix* posePtr = _cachedPose.Get(); + for (int32 nodeIndex = 0; nodeIndex < nodesCount; nodeIndex++) + { + const SkeletonNode& node = nodes[nodeIndex]; + Matrix local; + Matrix::Transformation(node.LocalTransform.Scale, node.LocalTransform.Orientation, node.LocalTransform.Translation, local); + if (node.ParentIndex != -1) + Matrix::Multiply(local, posePtr[node.ParentIndex], posePtr[nodeIndex]); + else + posePtr[nodeIndex] = local; + } + } + } + return _cachedPose; +} + +void SkeletonData::Dirty() +{ + Platform::AtomicStore(&_dirty, 1); +} + uint64 SkeletonData::GetMemoryUsage() const { uint64 result = Nodes.Capacity() * sizeof(SkeletonNode) + Bones.Capacity() * sizeof(SkeletonBone);