|
0.前言
打工人干打工事,后续会陆续有流体的东西推出,还敬请期待。
看了Unreal官方在B站上很多关于Control Rig的视频,还是挺有启发的,主要的一点就是其正向解算、逆向解算、程序化动画、FBIK等设计与实现,Control Rig可以很迅速的实现程序化动画,这样会减少很多美术工作,并且这些实现完全只需要在Unreal中操作即可。
我自己本地如果想用C++模式打开项目,和普通项目一样需要先创建一个C++文件,关键的是需要将.uproject中的Platform删掉,并且在TargetPlatform中只保留了Windows,才能Generate Sln,想要检索蓝图运算节点的C++实现,可以将鼠标移动到节点上查看Comment,然后在Visual Studio里面搜索Comment,或者也可以直接搜索节点名,但是要注意需要添加空格。
因为编辑越来越卡,所以分个章节吧。
1. Aim例子
这是我第一个看也是第一个分析的例子,所以会较为详细。首先点击Example1.2的人角色SKM_Quinn,在其Animation属性中,AnimationMode选取的是Use Animation Blueprint并且指定了动画蓝图为ABP_Aim,打开这个动画蓝图。
ABP_Aim
可以发现动画蓝图主要是两个部分,一个是Update,在Update中,会不断的更新Animation的参数,也就是图中所示的Anim Target Ctrl Location,并且在AnimGraph中,将这个值传递进Control Rig节点,并且传递进去的Vector 2D参数被Anim Target Ctrl吸收。双击Control Rig节点可以看见里面的逻辑,可以得知,这个Control Rig节点的名字为CR_Aim。
CR_Aim
可以看见,对于这个Control Rig来说,我们只用两个控制量,其中一个是aim_target_ctrl,点击这个控制量再点开Details查看细节可以知道,这个是一个Vector 2D量,并且保持Y轴不变,同时是Animtable,也就是说可以变成动画蓝图中的节点,被Animation更新的。(可以尝试把这个关掉之后Compile,回去看动画蓝图Control Rig节点的Input就没有这个量了)
这个Control Rig蓝图中做的事情也很简单,首先是Forwards Solve进来,所谓Forwards Solve就是Control Rig引起的变化解算到骨骼上的过程,对于Control Rig还有与其对应的Backwords Solve过程,这个可以将骨骼动画逆解算为Control Rig动画,然后生成Control Rig序列易于调整(感觉是非常好的设计逻辑),不过逆解算功能在我们这个简单的例子中是没有的。
我们现在了解了Forwards Solve想将Control Rig的变化作用到Rig上(所以这个系统的命名也是很直接啊),那么就仔细看下这个蓝图。
①Pre Aim:将head骨骼目前的Global Space的Transform给画出来,如果调整成Local Space可以自行查看一下,这个就和三维空间变换的知识有关了。
②Branching:作者通过两种方式实现了这个功能,所以之前说的两个控制量的另一个use_aim_node就用在了这里。
③Aim Math:这部分是将我们输入的Control作用到骨骼上的操作,整体逻辑被封装起来了,我们只能取查阅源码中的描述,Aim Math在RigUnit_AimBone.h中定义,RigUnit_AimBone.cpp的FRigUnit_AimBoneMath_Execute执行计算,这个函数其实逻辑很简单,具体的分析如下,设计上将Control作为一个空间量,虽然只接收Vector 2D,但仍然是一个空间量,所以可以取Transform。
/**
* Outputs an aligned transform of a primary and secondary axis of an input transform to a world target.
* Note: This node operates in world space!
*/
USTRUCT(meta = (DisplayName = "Aim Math", Category = "Hierarchy", Keywords = "Lookat"))
struct CONTROLRIG_API FRigUnit_AimBoneMath : public FRigUnit_HighlevelBase
{
GENERATED_BODY()
FRigUnit_AimBoneMath() // 这部分是初始化,基本上Input都在蓝图节点中显示出来了。
{
InputTransform = FTransform::Identity;
Primary = FRigUnit_AimItem_Target();
Secondary = FRigUnit_AimItem_Target();
Primary.Axis = FVector(1.f, 0.f, 0.f);
Secondary.Axis = FVector(0.f, 0.f, 1.f);
Weight = 1.f;
DebugSettings = FRigUnit_AimBone_DebugSettings();
PrimaryCachedSpace = FCachedRigElement();
SecondaryCachedSpace = FCachedRigElement();
}
...
UPROPERTY(meta = (Output))
FTransform Result; // Result是输出,后面也会解释为什么需要将Bone的Transform作为输入
...
}
// -------------------------------只展示部分重要的逻辑---------------------------------------
FRigUnit_AimBoneMath_Execute()
{
// 直接将Input的Transform付给Result,因为部分情况下,是不会执行计算的,就需要一个default output
// 为了不影响系统,那么就使用需要修改的Bone目前的Transform
Result = InputTransform;
// Check 是否能够或者需要进行计算 SMALL_NUMBER以下的影响忽略
// 如果要执行Primary
if (Primary.Weight > SMALL_NUMBER)
{
//空间变换
//Debug绘制
//如果是Location模式,那么就与之前的Result的Location求差(等于说转化成Direction)
if (!Target.IsNearlyZero() && !Primary.Axis.IsNearlyZero())
{
Target = Target.GetSafeNormal();
//将Primary.Axis施加上一个Result的Transform,这样就是现在旋转开始的位置
FVector Axis = Result.TransformVectorNoScale(Primary.Axis).GetSafeNormal();
float T = Primary.Weight * Weight;
if (T < 1.f - SMALL_NUMBER)
{
Target = FMath::Lerp<FVector>(Axis, Target, T).GetSafeNormal();
}
//可以发现其实Axis表示的是原本的指向,而Target则转化为了目的指向,所以正确的Axis很重要
FQuat Rotation = FControlRigMathLibrary_FindQuatBetweenNormals(Axis, Target);
Result.SetRotation((Rotation * Result.GetRotation()).GetNormalized());
}
else
{
UE_CONTROLRIG_RIGUNIT_REPORT_WARNING(TEXT(&#34;Invalid primary target.&#34;));
}
}
// 如果要执行Secondary
if (Secondary.Weight > SMALL_NUMBER)
{
//空间变换
//Debug绘制
//如果是Location模式,那么就与之前的Result的Location求差(等于说转化成Direction)
FVector PrimaryAxis = Primary.Axis;
if (!PrimaryAxis.IsNearlyZero())
{
//这里和上面还不一样,上面取的是作用Primary之前的,现在是作用之后的轴向
//等于说将Secondary的Target投影到Primary作用后的轴向上,然后去除这一部分
//那么Target就应该在Primary的正交平面上的投影发挥作用
PrimaryAxis = Result.TransformVectorNoScale(Primary.Axis).GetSafeNormal();
Target = Target - FVector::DotProduct(Target, PrimaryAxis) * PrimaryAxis;
}
//投影到平面上看下还剩下什么,什么都不剩就会报Warning
if (!Target.IsNearlyZero() && !Secondary.Axis.IsNearlyZero())
{
Target = Target.GetSafeNormal();
//Secondary轴也利用上一个Result进行旋转
FVector Axis = Result.TransformVectorNoScale(Secondary.Axis).GetSafeNormal();
float T = Secondary.Weight * Weight;
if (T < 1.f - SMALL_NUMBER)
{
Target = FMath::Lerp<FVector>(Axis, Target, T).GetSafeNormal();
}
//将Axis旋转到Target
FQuat Rotation;
if (FVector::DotProduct(Axis,Target) + 1.f < SMALL_NUMBER && !PrimaryAxis.IsNearlyZero())
{
// special case, when the axis and target and 180 degrees apart and there is a primary axis
Rotation = FQuat(PrimaryAxis, PI);
}
else
{
Rotation = FControlRigMathLibrary_FindQuatBetweenNormals(Axis, Target);
}
Result.SetRotation((Rotation * Result.GetRotation()).GetNormalized());
}
else
{
UE_CONTROLRIG_RIGUNIT_REPORT_WARNING(TEXT(&#34;Invalid secondary target.&#34;));
}
}
这样,我们就可以尝试利用Secondary添加在Primary注视之后侧头的效果,如果熟悉了上面的流程,这个操作是非常简单的。结合上面的代码来说,我们Parimary Axis选取了正Y轴,是人物在目前的局部坐标面向的方向,然后在Primary阶段将人物的朝向轴旋转到了指向绿色球的位置。然后,我们创建一个红色球,并且设置其相对的位置空间为绿色球,然后将红色球的Translation给到Secondary Target,此时,红色球的Location也是相对于绿色球的,根据上面的说法,这个时候红球投影在正交平面上的量有效,这样,红球其实可以在Animation端传入一个参数,这样就可以保证修改也在垂直平面上了。(不过要在Control Rig中添加常量计算似乎不是很方便?)
添加侧头Secondary
关闭侧头Secondary
当然,我们还可以将上面这个东西做的更加细致,红色的控制点我们也可以设置为Vector 2D,并且将他的Max、Min设置到一个合理的范围,再调整其Initial所在的位置(红色小框左下角为0,0),红色球的可移动范围就被我们限制住了,此时无论怎么移动绿色控制点,其X轴始终保持着相对正确的旋转,这个一做出来就有Metahuman控制器那味了。
有点像Metahuman控制点
④Aim:这里就是把下面的东西用一个函数做了,其实本质没变。
⑤Post Aim:把变换后Bone的Transform显示出来。
虽然除了Aim计算以外,其他的逻辑似乎很简单,但是有几个地方是不容忽视的,第一个就是Get Transform函数,这个函数可以Get的类型就特别多,None、Bone、Null、Control、Curve、Reference,目前我们用到的就是Bone和Control,也就是主要的Input和Output,还有一个就是Initial,似乎是专门传递初值的开关,这个感觉在很多时候能用到可以简化很多事情。这些应该在Unreal Documentation里面都有介绍。
最后一个比较细致的是怎么在Level中显示控制点,可以自行建立一个Control Display Actor去和初始建立的属性对比一下,很简单。
2. FK
FK是1.1的例子,这个例子表现起来就是一个很简单的扭曲,其做法是将同一个Transform施加到所有的Ctrl上,Example中给的是左右Rotate的Transform,这样每个ControlRig都将这个Transform作用到指定的骨骼上,整体表现就发生了弯曲。但是在Rig Graph中可以发现,他并不是使用Set Transform调整骨骼的,而是使用了一个叫做Parent Constrain的函数,可以发现这上面有一个Maintain Offset的参数。尝试调整一下,首先将tent_1_fk_ctrl的Offset清零
然后关闭第二个Maintain Offset
可以发现,中间一部分骨骼移动到了Control Rig组件所在的位置,查阅源码可以知道,这里Maintain的Offset是现在的Child与其各个Parent在Initial时刻Global Transform的Offset,也就是说,只有Parent对其Initial状态的相对变化会传递到Child中。并且这个还能实现Filter的功能,可以调整Input中的Filter,其中True是使用Parent的结果,False是不变。
FTransform ChildInitialGlobalTransform = Hierarchy->GetInitialGlobalTransform(Child);
FTransform ChildCurrentGlobalTransform = Hierarchy->GetGlobalTransform(Child, false);
...
for (const FConstraintParent& Parent : Parents)
{
...
if (bMaintainOffset)
{
FTransform ParentInitialGlobalTransform = Hierarchy->GetInitialGlobalTransform(Parent.Item);
// offset transform is a transform that transforms parent to child
OffsetTransform = ChildInitialGlobalTransform.GetRelativeTransform(ParentInitialGlobalTransform);
OffsetTransform.NormalizeRotation();
}
这样可以实现更加好的控制,但是代价就是需要更多的计算,这种传递影响的方式,一是可以利用Weight传递多个影响,二是可以维持初始状态Transform的Offset,三是可以使用Filter过滤需要的影响。很多情况下,我们如果直接使用SetTransform,那么就必须要求Control Rig与骨骼完全对齐,不过这个应该也不是什么困难的事,感觉这个特性要推广的话可能还需要一个比较良好的应用环境,仔细思考一下,这个示例中的设计方式真的好么?值得斟酌。
3. Looping Rig Elements
到这里应该对Control Rig的结构很熟悉了,这里直接看Rig Graph,这个示例主要是提供了一个单链结构的Loop,感觉可能与有一个教学视频中蝎子尾巴的实现类似。这里还展示了一个非常便捷的功能,多选Rig拉成Array,逻辑也很简单。
首先将几条手臂的分支开始的地方拉成Array,然后对这个Array中的每一项进行遍历,遍历的时候使用Get Children获取这一条链上面的Rig,其中如果不选择Include Parent就会导致根节点不转,如果不选择Recursive就会导致只取到第一级的Child Rig。然后将取到的一串Rig Array执行Offset Transform,而如果在Offset Transform部分不勾选Propagate to Children,那么父骨骼的影响就没法传递到儿子身上去,就会变成如下形状,可以发现每个节点都做了旋转,但是都是Offset形式的旋转,没有被父亲影响。
稍微看下Propagate to Child的逻辑,首先Offset Transform的执行在RigUnit_OffsetTransform.cpp中,这里的代码也是异常简单,首先获取GlobalSpace空间下之前的Transform,然后将OffsetTransform作用到之前的变换,再将变化好的GlobalTransform设置到Item上。bPropagateToChildren就用在最后一步。
FRigUnit_OffsetTransformForItem_Execute()
{
DECLARE_SCOPE_HIERARCHICAL_COUNTER_RIGUNIT()
if (Weight < SMALL_NUMBER)
{
return;
}
FTransform PreviousTransform = FTransform::Identity;
FTransform GlobalTransform = FTransform::Identity;
FRigUnit_GetTransform::StaticExecute(RigVMExecuteContext, Item, EBoneGetterSetterMode::GlobalSpace, false, PreviousTransform, CachedIndex, Context);
FRigUnit_MathTransformMakeAbsolute::StaticExecute(RigVMExecuteContext, OffsetTransform, PreviousTransform, GlobalTransform, Context);
FRigUnit_SetTransform::StaticExecute(RigVMExecuteContext, Item, EBoneGetterSetterMode::GlobalSpace, false, GlobalTransform, Weight, bPropagateToChildren, CachedIndex, ExecuteContext, Context);
}
/**
* Returns the absolute global transform within a parent&#39;s transform
*/
FRigUnit_MathTransformMakeAbsolute_Execute()
{
DECLARE_SCOPE_HIERARCHICAL_COUNTER_RIGUNIT()
Global = Local * Parent;
Global.NormalizeRotation();
}
最后一步其实是和之前FK例子一样,不过这里是静态调用的SetTransform方法,其逻辑在RigUnit_SetTransform中,这里可以看见进入PropagateDirtyFlags之前,现在是limb_A_01执行,可以看见其ElementsToDirty可以将整个limb_A的链给拉出来,这也是我们后面要将影响下放的基础,注意这个ElementsToDirty不是开了Propagate才会存在,而是一直会存在。
limb_A_01进来之后函数参数如下
不过这些东西还是得分析一下源码,虽然有点长,但是其中表示逻辑的部分我已经提取出来并且加上注释,还是好看的。
#if URIGHIERARCHY_RECURSIVE_DIRTY_PROPAGATION
void URigHierarchy::PropagateDirtyFlags(FRigTransformElement* InTransformElement, bool bInitial, bool bAffectChildren, bool bComputeOpposed, bool bMarkDirty) const
#else
void URigHierarchy::PropagateDirtyFlags(FRigTransformElement* InTransformElement, bool bInitial, bool bAffectChildren) const
#endif
{
...
//在这里选择,如果要Dirty Local,那么就要将Global作为计算项
//如果要Dirty Global,那么就要将Local作为计算项
const ERigTransformType::Type LocalType = bInitial ? ERigTransformType::InitialLocal : ERigTransformType::CurrentLocal;
const ERigTransformType::Type GlobalType = bInitial ? ERigTransformType::InitialGlobal : ERigTransformType::CurrentGlobal;
const ERigTransformType::Type TypeToCompute = bAffectChildren ? LocalType : GlobalType;
const ERigTransformType::Type TypeToDirty = SwapLocalAndGlobal(TypeToCompute);
//---------------------------------------------bComputeOpposed Begin
//在bComputeOpposed循环中,其实并没有做任何的更改,只是循环迭代了一次骨骼
#if URIGHIERARCHY_RECURSIVE_DIRTY_PROPAGATION
if(bComputeOpposed)
#endif
{
for(const FRigTransformElement::FElementToDirty& ElementToDirty : InTransformElement->ElementsToDirty)
{
#if URIGHIERARCHY_RECURSIVE_DIRTY_PROPAGATION
...
if(FRigControlElement* ControlElement = Cast<FRigControlElement>(ElementToDirty.Element))
{
...
}
else if(FRigMultiParentElement* MultiParentElement = Cast<FRigMultiParentElement>(ElementToDirty.Element))
{
...
}
else
{//如果我们需要Dirty的Space已经打上了Dirty标记,那么就保持
if(ElementToDirty.Element->Pose.IsDirty(TypeToDirty))
{
continue;
}
}
#else
...
#endif
... //将非Dirty的部分计算出来,这个GetTransform函数在下面
GetTransform(ElementToDirty.Element, TypeToCompute); // make sure the local / global transform is up 2 date
#if URIGHIERARCHY_RECURSIVE_DIRTY_PROPAGATION
//并且这个地方的Dirty标记已经消失,子孙中也可能存在未计算的Dirty标记,所以需要向下迭代
PropagateDirtyFlags(ElementToDirty.Element, bInitial, bAffectChildren, true, false);
#endif
}
}
#if URIGHIERARCHY_RECURSIVE_DIRTY_PROPAGATION
//---------------------------------------------bComputeOpposed End
//---------------------------------------------bMarkDirty Begin
//上面一步就像一个清除,而这一步则是加上Dirty标记
if(bMarkDirty)
#endif
{
for(const FRigTransformElement::FElementToDirty& ElementToDirty : InTransformElement->ElementsToDirty)
{
#if URIGHIERARCHY_RECURSIVE_DIRTY_PROPAGATION
...
#else
...
#endif
//给第一级别的儿子打上Dirty标记
ElementToDirty.Element->Pose.MarkDirty(TypeToDirty);
if(FRigMultiParentElement* MultiParentElement = Cast<FRigMultiParentElement>(ElementToDirty.Element))
{
MultiParentElement->Parent.MarkDirty(GlobalType);
}
if(FRigControlElement* ControlElement = Cast<FRigControlElement>(ElementToDirty.Element))
{
ControlElement->Offset.MarkDirty(GlobalType);
ControlElement->Shape.MarkDirty(GlobalType);
}
#if URIGHIERARCHY_RECURSIVE_DIRTY_PROPAGATION
//如果开启了Propagate to Children,那么就进行迭代,这次迭代关闭了Compute,开启了MarkDirty
if(bAffectChildren)
{
#if URIGHIERARCHY_RECURSIVE_DIRTY_PROPAGATION
PropagateDirtyFlags(ElementToDirty.Element, bInitial, bAffectChildren, false, true);
#endif
}
#endif
}
}
//---------------------------------------------bMarkDirty End
}
//--------------------------------------------------------------------URigHierarchy::GetTransform
FTransform URigHierarchy::GetTransform(FRigTransformElement* InTransformElement,
const ERigTransformType::Type InTransformType) const
{
...
#if WITH_EDITOR
if(bRecordTransformsPerInstruction && ExecuteContext)
{
TArray<TArray<int32>>& ReadTransformsPerSlice = ReadTransformsPerInstructionPerSlice[ExecuteContext->InstructionIndex];
while(ReadTransformsPerSlice.Num() < ExecuteContext->GetSlice().TotalNum())
{
ReadTransformsPerSlice.Add(TArray<int32>());
}
ReadTransformsPerSlice[ExecuteContext->GetSlice().GetIndex()].Add(InTransformElement->GetIndex());
}
TGuardValue<bool> RecordTransformsPerInstructionGuard(bRecordTransformsPerInstruction, false);
#endif
//如果现在要取的这个空间是Dirty的
if(InTransformElement->Pose.IsDirty(InTransformType))
{
const ERigTransformType::Type OpposedType = SwapLocalAndGlobal(InTransformType);
const ERigTransformType::Type GlobalType = MakeGlobal(InTransformType);
//这里个ensure也证明了我们之前推导的逻辑是正确的,只要一个Space被标记为了Dirty,那么另一个Space就需要
//将Dirty消除
ensure(!InTransformElement->Pose.IsDirty(OpposedType));
FTransform ParentTransform;
if(IsLocal(InTransformType))
{
if(FRigControlElement* ControlElement = Cast<FRigControlElement>(InTransformElement))
{
...
}
else
{
//这就是Example中的情况,将Parent的变换转到自己身上
ParentTransform = GetParentTransform(InTransformElement, GlobalType);
//这样做就获得了Local,拿到Parent的Global和自己的Global,求Local也就是相对的Transform
FTransform NewTransform = InTransformElement->Pose.Get(OpposedType).GetRelativeTransform(ParentTransform);
NewTransform.NormalizeRotation();
InTransformElement->Pose.Set(InTransformType, NewTransform); //Set在下面!!!
}
}
else
{
if(FRigControlElement* ControlElement = Cast<FRigControlElement>(InTransformElement))
{
...
}
else
{
ParentTransform = GetParentTransform(InTransformElement, GlobalType);
//这样做就获得了Global,拿到Parent的Global和自己的Local,求自己的Local的Global表达
FTransform NewTransform = InTransformElement->Pose.Get(OpposedType) * ParentTransform;
NewTransform.NormalizeRotation();
InTransformElement->Pose.Set(InTransformType, NewTransform); //Set在下面!!!
}
}
EnsureCacheValidity();
}
return InTransformElement->Pose.Get(InTransformType);
}
//---------------------------------------------------FRigComputedTransform
FORCEINLINE_DEBUGGABLE void Set(const FTransform& InTransform)
{
#if WITH_EDITOR
ensure(InTransform.GetRotation().IsNormalized());
#endif
// ensure(!FMath::IsNearlyZero(InTransform.GetScale3D().X));
// ensure(!FMath::IsNearlyZero(InTransform.GetScale3D().Y));
// ensure(!FMath::IsNearlyZero(InTransform.GetScale3D().Z));
Transform = InTransform;
bDirty = false; //只要执行了Set,那么Dirty就清除。
}
当然,在Example中我们只对其中一个Space进行Dirty操作,按照上面的代码逻辑应该没法触发GetTransform才对?其实这个触发逻辑已经写在最开始的FRigUnit_OffsetTransformForItem_Execute里面了,调用Offset的时候自动先Get一次,这个Get会一直调用到URigHierarchy::GetTransform,这样每次使用之前都会将Dirty标记带来的影响计算进去。
其次即便我们没有打上PropagateToChild标记,也会给自己的第一级别的儿子打上Dirty,但是由于Offset的设定所有都是在Global空间下,并且bAffectChildren为False,TypeToDirty会一直为Local。其实这样,整个系统看起来还是挺复杂的,如果要Global和Local混合计算,甚至还加上Control,Debug难度可想而知。
Dirty作为一个System其实就是控制Local和Global每个时刻,修改并且只修改一个,在这个基础上,当我需要调用这个Space仍然存在Dirty时,我就需要利用Parent重新计算一下他的Transform。 至此,只有Rig参与的Propagate逻辑我们就完整梳理了一遍。
至于Evaluate Curve就很简单了,输入的Value会先经过一个Curve映射,其中Curve上面显示的值是Source和Target MinMax的归一化结果,也就是横轴的0就是Source的最小值,1就是Source的最大值,Target同理,按照这个逻辑,将Target Min改成-45就可以发现,这个动画变成了前后翻转的动画了!同时还可以给旋转轴增加点压力,最后就可以获得一个旋转收缩的结果。不过按理来说,左边的MinMax应该要连到Curve上,这样在修改Control的MinMax时,虽然在空间上我们可以调节的范围变小了,但是仍然可以在达到MinMax时表现出期望的效果。
4. Animating Morphs and Materials
这个部分相对比较简单,主要是展示了Control Rig不仅可以直接控制Rig,还可以控制Curve。这个逻辑以及控制的Curve都挺简单的,这里就不多描述实现了。
5. Secondary Animation
这个示例很有意思啊,程序化实现了一个晃动的效果,看下是怎么实现的。
首先是用Parent Constraint把唯一的pelvis_ctrl作用到pelvis骨骼上,这一步和上面的例子很相似,并且Maintain Offset选项上面也分析过了。然后把两个天线的根骨骼做一个Array做遍历,其实就是每组骨骼做相同的操作,那么他们的根骨骼就放在一起做遍历省事。后面也是循环把子骨骼做成一个Array,然后分别执行Set Rotation操作。
然后看下Rotation的计算,先获取head骨骼目前的Transform,然后放到两个函数里面去,其定义在Control Rig的RigUnit_MathTransform中,可以发现Transform Direction不带Translation,Transform Location带Translation。
//USTRUCT(meta=(DisplayName=&#34;Transform Direction&#34;, PrototypeName=&#34;Rotate&#34;, Keywords=&#34;Transform,Direction&#34;))
FRigUnit_MathTransformRotateVector_Execute()
{
DECLARE_SCOPE_HIERARCHICAL_COUNTER_RIGUNIT()
Result = Transform.TransformVector(Direction);
}
//USTRUCT(meta=(DisplayName=&#34;Transform Location&#34;, PrototypeName=&#34;Multiply&#34;))
FRigUnit_MathTransformTransformVector_Execute()
{
DECLARE_SCOPE_HIERARCHICAL_COUNTER_RIGUNIT()
Result = Transform.TransformPosition(Location);
}
template<typename T>
FORCEINLINE TVector<T> TTransform<T>::TransformVector(const TVector<T>& V) const
{
DiagnosticCheckNaN_All();
const TransformVectorRegister InputVectorW0 = VectorLoadFloat3_W0(&V);
//RotatedVec = Q.Rotate(Scale*V.X, Scale*V.Y, Scale*V.Z, 0.f)
const TransformVectorRegister ScaledVec = VectorMultiply(Scale3D, InputVectorW0);
const TransformVectorRegister RotatedVec = VectorQuaternionRotateVector(Rotation, ScaledVec);
TVector<T> Result;
VectorStoreFloat3(RotatedVec, &Result);
return Result;
}
template<typename T>
FORCEINLINE TVector<T> TTransform<T>::TransformPosition(const TVector<T>& V) const
{
DiagnosticCheckNaN_All();
const TransformVectorRegister InputVectorW0 = VectorLoadFloat3_W0(&V);
//Transform using QST is following
//QST(P) = Q.Rotate(S*P) + T where Q = quaternion, S = scale, T = translation
//RotatedVec = Q.Rotate(Scale*V.X, Scale*V.Y, Scale*V.Z, 0.f)
const TransformVectorRegister ScaledVec = VectorMultiply(Scale3D, InputVectorW0);
const TransformVectorRegister RotatedVec = VectorQuaternionRotateVector(Rotation, ScaledVec);
const TransformVectorRegister TranslatedVec = VectorAdd(RotatedVec, Translation);
TVector<T> Result;
VectorStoreFloat3(TranslatedVec, &Result);
return Result;
}
后面的逻辑,首先是将不带Translation的作为From Two Vectors的第一个变量,将经过一个Spring Interpolate处理的相对Translation作为From Two Vectors的第二个变量,等于说一个是初始向量,一个是目的向量。
//USTRUCT(meta=(DisplayName=&#34;From Two Vectors&#34;, PrototypeName=&#34;FromTwoVectors&#34;, Keywords=&#34;Make,Construct&#34;))
FRigUnit_MathQuaternionFromTwoVectors_Execute()
{
DECLARE_SCOPE_HIERARCHICAL_COUNTER_RIGUNIT()
if (A.IsNearlyZero() || B.IsNearlyZero())
{
Result = FQuat::Identity;
return;
}
Result = FQuat::FindBetweenVectors(A, B).GetNormalized();
}
说实话,研究到这里的时候我还是很困惑为什么要选取X轴,突然我想到了,在上面Aim例子中,我们成功用X轴实现的左右晃头,那么说明X轴的选取应该还是与坐标系有关系,所以我就加了很多东西让这个机器人运行时的各个信息都给Display出来了。可以发现所有的分支骨骼,他们的局部坐标系内X轴都指向其末端,并且我显示了A、B两个向量在两个天线根的相对空间坐标系(显示的时候做了Transform,不然就会在脚旁边不容易看出来),发现的确是X轴指向末端也就是X轴可以控制链条的弯曲。这里显示的都是Transform,也就是说从长度可以看出Scale,从相对位置可以看出Translate,从XYZ轴向可以看出Rotation。
理解了坐标系之后,我还需要多嘴一句,可以看见这个时候两个天线上都有显示的Transform,但是在实现的时候这个Transform是从head取到的,所以按理来说这个Rotation计算的基准是head,那么应该也会显示应该也是head,所以我将这个Local Transform的Display分别作用到了antenna_01_l/r上才显示的,这样符合我们的直观感受。现在我们能够知道,整个实现是基于一个不经过head Translation的A向量(此时A如果Display应该处于root的正上方),一个根据Spring Interpolate变换的、并且与head Transform作差的B向量(这个B向量也应该处于root的正上方,因为其Translation的效果因为作差抵消了),这时候我们再来看下Spring Interpolate做了什么。
Example使用的Spring Interpolate是Vector的,代码如下,首先我在已经理解了的基础上先简要概括一下逻辑,其实这个系统的实现,是依托于弹簧模型,而弹簧模型在无阻尼的情况下是一个正弦函数,在有阻尼的情况下振幅会越变越小,Spring Interpolate的输入就是经过Transform之后(12,0,0)的世界坐标,这也就是弹簧无伸缩的状态,所以也解释了为什么我们给Spring Interpolate输入的名字是Target。而真正在Spring Interpolate里面还保留着目前弹簧的位置,所以我们一旦进行移动,Target变了而Spring Interpolate里面保存的相对位置还没变,所有的计算也就是围绕这个展开,所以也会存在很多频率周期相关的概念,大部分 T=\frac{2\pi}{\omega} 可以解决,具体更多数学原理可以自行查阅。[1][2]
//USTRUCT(meta=(DisplayName=&#34;Spring Interpolate&#34;, Keywords=&#34;Alpha,SpringInterpolate,Verlet&#34;, Category = &#34;Simulation|Springs&#34;, PrototypeName = &#34;SpringInterp&#34;, MenuDescSuffix = &#34;(Vector)&#34;))
//SimulatedResult为临时结果存储的地方,所以才可以实现插值
FRigUnit_SpringInterpVectorV2_Execute()
{
DECLARE_SCOPE_HIERARCHICAL_COUNTER_RIGUNIT()
if (Context.State == EControlRigState::Init)
{
SpringState.Reset(); //这里会执行bPrevTargetValid = false;
Result = Target;
}
else
{
// Treat the input as a frequency in Hz
//根据后面的计算可以看出来这个其实就是把弹簧的劲度系数k->Stiffness用Strength作为参数表达
float AngularFrequency = Strength * 2.0f * PI;
float Stiffness = AngularFrequency * AngularFrequency;
FVector AdjustedTarget = Target;
if (!FMath::IsNearlyZero(Stiffness))
{ //Target也可以是虚拟的,我们可以给Target进行Adjust,就修改了静态时的表现
AdjustedTarget += Force / (Stiffness * RigUnitSpringInterpConstants::Mass);
}
else
{
SpringState.Velocity += Force * (Context.DeltaTime / RigUnitSpringInterpConstants::Mass);
}
SimulatedResult = UKismetMathLibrary::VectorSpringInterp(
bUseCurrentInput ? Current : SimulatedResult, AdjustedTarget, SpringState, Stiffness, CriticalDamping,
Context.DeltaTime, RigUnitSpringInterpConstants::Mass, TargetVelocityAmount,
false, FVector(), FVector(), !bUseCurrentInput || bInitializeFromTarget);
Result = SimulatedResult;
}
Velocity = SpringState.Velocity;
}
//注意SpringState传的是引用,所以里面是可以修改外面的
FVector UKismetMathLibrary::VectorSpringInterp(FVector Current, FVector Target, FVectorSpringState& SpringState,
float Stiffness, float CriticalDamping, float DeltaTime,
float Mass, float TargetVelocityAmount,
bool bClamp, FVector MinValue, FVector MaxValue,
bool bInitializeFromTarget)
{
GenericSpringInterp(Current, Target, SpringState.PrevTarget, SpringState.bPrevTargetValid, SpringState.Velocity, Stiffness,
CriticalDamping, DeltaTime, TargetVelocityAmount, Mass, bInitializeFromTarget);
if (bClamp)
{
... //Example中恒为False
}
return Current;
}
//注意bPrevTargetValid、Current、PrevTarget、Velocity传的是引用
template <typename T>
void GenericSpringInterp(T& Current, const T Target, T& PrevTarget, bool& bPrevTargetValid, T& Velocity,
float Stiffness, float CriticalDamping, float DeltaTime,
float TargetVelocityAmount, float Mass, bool bInitializeFromTarget)
{
if (bInitializeFromTarget && !bPrevTargetValid)
{
Current = Target; //一些不能计算的情况
}
if (DeltaTime > SMALL_NUMBER)
{
if (!FMath::IsNearlyZero(Mass))
{
// Note that old target was stored in PrevTarget
//TargetVelocityAmount 为0就不解相对速率带来的影响
T TargetVel = bPrevTargetValid ? (Target - PrevTarget) * (TargetVelocityAmount / DeltaTime) : T(0.f);
const float Omega = FMath::Sqrt(Stiffness / Mass); // angular frequency就是弹簧频率与周期计算
const float Frequency = Omega / (2.0f * PI);
T NewValue = Current; //这句话应该是UE程序员忘记删了?
FMath::SpringDamper(Current, Velocity, Target, TargetVel, DeltaTime, Frequency, CriticalDamping);
PrevTarget = Target;
bPrevTargetValid = true; //这里就置True,可以调整外面很多if
}
}
}
//我们以前接触的系统Target都是定点,这个系统可以解Target在运动时的相对解,其实差不多都是相对计算罢了
template< class T >
static void SpringDamper(
T& InOutValue,
T& InOutValueRate,
const T& InTargetValue,
const T& InTargetValueRate,
const float InDeltaTime,
const float InUndampedFrequency,
const float InDampingRatio)
{
if (InDeltaTime <= 0.0f)
{
return;
}
float W = InUndampedFrequency * TWO_PI;
// Handle special cases
if (W < SMALL_NUMBER) // no strength which means no damping either
{
InOutValue += InOutValueRate * InDeltaTime;
return;
}
else if (InDampingRatio < SMALL_NUMBER) // No damping at all
{
T Err = InOutValue - InTargetValue;
const T B = InOutValueRate / W;
float S, C;
FMath::SinCos(&S, &C, W * InDeltaTime);
InOutValue = InTargetValue + Err * C + B * S;
InOutValueRate = InOutValueRate * C - Err * (W * S);
return;
}
// Target velocity turns into an offset to the position 前面都是特殊情况判断,都是很清晰的
// 这里是,默认DampingRatio为1的时候,整个工作在一个周期内做完
float SmoothingTime = 2.0f / W;
T AdjustedTarget = InTargetValue + InTargetValueRate * (InDampingRatio * SmoothingTime);
T Err = InOutValue - AdjustedTarget; //现在到Target的差值
// Handle the cases separately
// 下面各种情况都在阻尼振动公式中有,其中InOutValue 是位移,也就是X
// InOutValueRate 是速度,所以其解就是对方程对时间求导数得到的
if (InDampingRatio > 1.0f) // Overdamped 过阻尼
{
const float WD = W * FMath::Sqrt(FMath::Square(InDampingRatio) - 1.0f);
const T C2 = -(InOutValueRate + (W * InDampingRatio - WD) * Err) / (2.0f * WD);
const T C1 = Err - C2;
const float A1 = (WD - InDampingRatio * W);
const float A2 = -(WD + InDampingRatio * W);
// Note that A1 and A2 will always be negative. We will use an approximation for 1/Exp(-A * DeltaTime).
const float A1_DT = -A1 * InDeltaTime;
const float A2_DT = -A2 * InDeltaTime;
// This approximation in practice will be good for all DampingRatios
const float E1 = InvExpApprox(A1_DT);
// As DampingRatio gets big, this approximation gets worse, but mere inaccuracy for overdamped motion is
// not likely to be important, since we end up with 1 / BigNumber
const float E2 = InvExpApprox(A2_DT);
InOutValue = AdjustedTarget + E1 * C1 + E2 * C2;
InOutValueRate = E1 * C1 * A1 + E2 * C2 * A2;
}
else if (InDampingRatio < 1.0f) // Underdamped 欠阻尼
{
const float WD = W * FMath::Sqrt(1.0f - FMath::Square(InDampingRatio));
const T A = Err;
const T B = (InOutValueRate + Err * (InDampingRatio * W)) / WD;
float S, C;
FMath::SinCos(&S, &C, WD * InDeltaTime);
const float E0 = InDampingRatio * W * InDeltaTime;
// Needs E0 < 1 so DeltaTime < SmoothingTime / (2 * DampingRatio * Sqrt(1 - DampingRatio^2))
const float E = InvExpApprox(E0);
InOutValue = E * (A * C + B * S);
InOutValueRate = -InOutValue * InDampingRatio * W;
InOutValueRate += E * (B * (WD * C) - A * (WD * S));
InOutValue += AdjustedTarget;
}
else // Critical damping 临界阻尼状态,因为阻尼系数为1很多地方就没有写出来
{
const T& C1 = Err;
T C2 = InOutValueRate + Err * W;
const float E0 = W * InDeltaTime;
// Needs E0 < 1 so InDeltaTime < SmoothingTime / 2
float E = InvExpApprox(E0);
InOutValue = AdjustedTarget + (C1 + C2 * InDeltaTime) * E; //阻尼振动方程
InOutValueRate = (C2 - C1 * W - C2 * (W * InDeltaTime)) * E;
}
}
这样,整个Spring的逻辑以及空间坐标系我们就梳理完毕了,因为有速度参与运算,所以不是简单的回归,而是带速度的回归,给人物绕圈圈可以看见恢复的时候也是绕圈圈的。
当然还有最后一个部分,就是Evaluate Curve和For Each的Ratio,其实在表现上也能看出来,每个天线是在第二个骨骼开始旋转的,并且每级的骨骼旋转力度也不一样,实现这个方案就是将Rotation的Weight与For Each的Ratio通过Curve映射成为需要的值。这个Ratio的设计确实有点没想到,有点搞笑还有点实用,再怎么说这个部分也是非常简单的了,不多赘述。附赠Propagate to Children关闭的图,里面的Curve调了一下。
head附近就是弹簧的Transform,注意此时的Z轴是朝上的,上面因为没有Propagate所以天线都断开了,将Curve调整了一下所以最上面的Rotation反而小了。
还有一个值得注意的地方就是为什么要选取12和10这两个数,其实第一个向量只是标注方向,第二个向量也是标注方向,但是求角度是算的偏移,如果第二个向量的X变小,那么每个单位的移动影响的角度会变大,故而在第二个向量X变小的时候,同等的移动幅度,角度变化却变大了。
参考
- ^阻尼振动https://en.wikipedia.org/wiki/Vibration#Free_vibration_with_damping
- ^阻尼振动方程https://baike.baidu.com/item/%E9%98%BB%E5%B0%BC%E6%8C%AF%E5%8A%A8/1351943?fr=aladdin
|
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
×
|