UE自带重定向方法验证
核心源码在VS的解决方案中的位置:
UE4\Source\Developer\AssetTools\Private\AssetTypeActions\AnimSequence.cpp
中第3237行RemapTracksToNewSkeleton
函数
跳转方法
AssetTypeActions_AnimationAsset.cpp
的RetargetNonSkeletonAnimationHandler
函数调用了RetargetAnimationHandler
- 跳转到
EditorAnimUtils.cpp
的RetargetAnimations
函数 - 跳转到
AnimationAsset.cpp
的ReplaceSkeleton
函数 - 最后跳转到
AnimSequence.cpp
的RemapTracksToNewSkeleton
函数
核心源码理论
-
获取全局(
Component Space
)齐次矩阵的方法
源码第3277行,有一个FillUpTransformBasedOnRig
函数,各种跳转以后,可以找到AnimationRuntime.cpp
中的FillUpComponentSpaceTransforms
函数,其中一行代码:ComponentSpaceTransforms[Index] = BoneSpaceTransforms[Index] * ComponentSpaceTransforms[ParentIndex];
普通但是特别,因为以前写代码时候的正常操作是
当前关节的全局旋转=父关节全局旋转∗子关节局部旋转当前关节的全局旋转 = 父关节全局旋转*子关节局部旋转 当前关节的全局旋转=父关节全局旋转∗子关节局部旋转
但是UE
是反过来的,为:
当前关节的全局旋转=子关节局部旋转∗父关节全局旋转当前关节的全局旋转 = 子关节局部旋转 * 父关节全局旋转 当前关节的全局旋转=子关节局部旋转∗父关节全局旋转
这一点要注意,写代码时候区分好 -
新旧骨骼依据世界坐标系迁移位移
源码第3311行计算了比率:float OldTranslationSize = OldTranslation.Size(); float NewTranslationSize = NewTranslation.Size();OldToNewTranslationRatio[NodeIndex] = (FMath::IsNearlyZero(OldTranslationSize)) ? 1.f/*do not touch new translation size*/ : NewTranslationSize / OldTranslationSize;
在第3372行把比率应用到新的动画轨迹上:
AnimatedLocalKey.ScaleTranslation(OldToNewTranslationRatio[NodeIndex]);
-
新旧骨骼依据
refPose
(Tpose
/Apose
)计算旋转数据的迁移矩阵源码第3271行的注释内容:
first calculate component space ref pose to get the relative transform betweentwo ref poses. It is very important update ref pose before getting here
源码第3299行的注释和3414行的实现内容:
// theta (RelativeToNewTransform) = (P1*R1)^(-1) * P2*R2 where theta => P1*R1*theta = P2*R2 RelativeToNewSpaceBases[NodeIndex] = NewSpaceBases[NodeIndex].GetRelativeTransform(OldSpaceBases[NodeIndex]);
结合
UE
的重定向操作,可以大概推测出新旧骨骼都有一个refpose
(一般为Tpose
或者Apose
),然后由于建模流程中绑骨操作可能导致每个模型的局部坐标系不一样,所以需要依据refPose
计算旧骨骼到新骨骼的迁移矩阵。 -
动画数据迁移
源码第3413的注释:now convert to the new space and save to local spaces
就是将旧的动画数据迁移到新的动画数据的全局空间(component space)中,然后再转化为局部旋转
所以在第3414行代码:
ConvertedSpaceAnimations[SrcTrackIndex][Key] = RelativeToNewSpaceBases[NodeIndex] * ComponentSpaceAnimations[SrcTrackIndex][Key];
将迁移矩阵施加到旧的骨骼全局矩阵上,就得到了新骨骼的全局矩阵。
最后计算局部旋转即可:
-
对于非根关节:
ConvertedSpaceAnimations[RotParentTrackIndex][Key].GetRotation().Inverse() * ConvertedSpaceAnimations[SrcTrackIndex][Key].GetRotation()
-
对于根关节
ConvertedSpaceAnimations[SrcTrackIndex][Key].GetRotation()
-
数值验证
先预备几个函数:
-
UE4的欧拉角到旋转矩阵的转换:
# UE的欧拉角转旋转矩阵 def euler_to_rotMat(yaw, pitch, roll):yaw = np.deg2rad(yaw)pitch = np.deg2rad(pitch)roll = np.deg2rad(roll)Rz_yaw = np.array([[np.cos(yaw), -np.sin(yaw), 0],[np.sin(yaw), np.cos(yaw), 0],[ 0, 0, 1]])Ry_pitch = np.array([[ np.cos(pitch), 0, np.sin(pitch)],[ 0, 1, 0],[-np.sin(pitch), 0, np.cos(pitch)]])Rx_roll = np.array([[1, 0, 0],[0, np.cos(roll), -np.sin(roll)],[0, np.sin(roll), np.cos(roll)]])rotMat = np.dot(Rz_yaw, np.dot(Ry_pitch, Rx_roll))return rotMat
-
两个向量的夹角
def anglebetween(v1,v2):v1 = v1/np.linalg.norm(v1)v2 = v2/np.linalg.norm(v2)dot_product = np.dot(v1, v2)angle = np.arccos(dot_product)return np.rad2deg(angle)
假设我们有两个关节,分别称为父关节和子关节:
-
源模型的Tpose下,子关节在父关节下的局部坐标和父关节全局旋转量为:
oriOffset = np.array([-0.000000,35.000000,-0.000000]) #子关节局部坐标 oriSkelT = euler_to_rotMat(0.000000, 0.000000, -89.996216)#父关节全局旋转
-
目标模型的Tpose下,子关节在父关节下的局部坐标和父关节全局旋转量为:
tarOffset = np.array([45.206524,-0.000000,-0.000002]) #子关节局部坐标 tarSkelT = euler_to_rotMat(-90.887756, 89.786018, 89.115906)#父关节全局旋转
-
由于两个模型的Tpose可能有细微差距,所以先看看两个Tpose在世界坐标系下的夹角:
print(anglebetween(np.dot(oriSkelT, oriOffset), np.dot(tarSkelT, tarOffset))) #输出:0.21776551527114438
接下来看骨骼动画的重定向部分,重定向过程就不截图了,按照官网说的,先全部递归选择skeleton
,然后把root
和pelvis
关节调整成Animation Scaled
然后我们先提取出某一帧的源模型和目标模型的关节数值:
-
源模型的父关节全局旋转量为:
oriSkelAnim = euler_to_rotMat(-63.208164, 9.146088, -74.355972)
-
目标模型的父关节全局旋转量为:
tarSkelAnim = euler_to_rotMat(56.725945, 72.118958, -58.820042)
-
看看他俩在同一个世界坐标系下的角度差:
print(anglebetween(np.dot(oriSkelAnim, oriOffset), np.dot(tarSkelAnim, tarOffset))) #0.21275403661014525
发现重定向完毕以后,和重定向之前的角度差距不大
旋转验证
看看如何通过Tpose
数据(oriSkelT
、tarSkelT
)和源模型的某帧数据(oriSkelAnim
)计算得到新的模型帧数据(tarSkelAnim
),经过源码分析发现,UE的计算方法如下:
tarSkelAnim=oriSkelAnim∗oriskelT−1∗tarskelTtarSkelAnim = oriSkelAnim*oriskelT^{-1}*tarskelT tarSkelAnim=oriSkelAnim∗oriskelT−1∗tarskelT
所以可以利用一个缓存矩阵把oriskelT−1∗tarskelToriskelT^{-1}*tarskelToriskelT−1∗tarskelT存下来,这样就不用每帧都算这一项了。
式子知道了,直接验证:
tmpMat = np.dot(oriSkelAnim,np.matmul(np.linalg.inv(oriSkelT),tarSkelT))
打印出我们算的,和UE里面提取的看看:
print(tmpMat)
print(tarSkelAnim)
'''
[[ 0.16835118 -0.87957756 -0.44497325][ 0.25672688 -0.39671296 0.8813116 ][-0.95170856 -0.26260644 0.15902411]]
[[ 0.1684567 -0.87956606 -0.44495605][ 0.25670405 -0.39668436 0.88133112][-0.95169605 -0.26268815 0.15896403]]
'''
基本一模一样,说明算法没错。
位移验证
通过源码分析,其实就是:
目标模型动画帧全局坐标=目标模型Tpose全局位移长度源模型Tpose全局位移长度∗源模型动画帧全局坐标目标模型动画帧全局坐标 = \frac{目标模型Tpose全局位移长度}{源模型Tpose全局位移长度}*源模型动画帧全局坐标 目标模型动画帧全局坐标=源模型Tpose全局位移长度目标模型Tpose全局位移长度∗源模型动画帧全局坐标
提取了3帧数据的全局位移量:
# Tpose下源模型和目标模型(world positon)
tran1 = np.array([0.000000,-0.005569,83.999969])
tran2 = np.array([0.002693,0.000016,106.468102])# 第10帧源模型和目标模型(world positon)
tran3 = np.array([-162.444809,-66.448837,83.561867])
tran4 = np.array([(-205.898224,-84.200836,105.923523)]) # 第1000帧源模型和目标模型(world positon)
tran5 = np.array([-135.590073,-85.603470,81.906609])
tran6 = np.array([-171.862610,-108.480934,103.825958])
打印看看根关节位移量的长度比率:
print(np.linalg.norm(tran1)/np.linalg.norm(tran2))
print(np.linalg.norm(tran3)/np.linalg.norm(tran4))
print(np.linalg.norm(tran5)/np.linalg.norm(tran6))
'''
0.7889684100664619
0.7889692163816936
0.7889695268366821
'''
几乎一毛一样,那再看看,x,y,zx,y,zx,y,z分别的比率
print(tran3/tran4)
print(tran5/tran6)
```
[[0.78895682 0.78917075 0.78888867]]
[0.78894457 0.78911074 0.78888373]
```
可以发现坐标各自的比率和长度比率其实是一致的,可能由于计算精度有少量偏差。
后记
本文主要针对UE4的动画数据重定向原理做了计算方法的探索,当然后续还会继续对源码进行深究,包括IK、实时重定向之类的。
完整的python实现放在微信公众号的简介中描述的github中,有兴趣可以去找找。同时文章也同步到微信公众号中,有疑问或者兴趣欢迎公众号私信。