找回密码
 立即注册
查看: 293|回复: 0

游戏引擎应用-Unreal Engine内容示例之Control Rig分析(一)

[复制链接]
发表于 2022-9-20 08:04 | 显示全部楼层 |阅读模式
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("Invalid primary target."));
                }
        }
        // 如果要执行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("Invalid secondary target."));
                }
        }
这样,我们就可以尝试利用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'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="Transform Direction", PrototypeName="Rotate", Keywords="Transform,Direction"))
FRigUnit_MathTransformRotateVector_Execute()
{
        DECLARE_SCOPE_HIERARCHICAL_COUNTER_RIGUNIT()
        Result = Transform.TransformVector(Direction);
}
//USTRUCT(meta=(DisplayName="Transform Location", PrototypeName="Multiply"))
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="From Two Vectors", PrototypeName="FromTwoVectors", Keywords="Make,Construct"))
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="Spring Interpolate", Keywords="Alpha,SpringInterpolate,Verlet", Category = "Simulation|Springs", PrototypeName = "SpringInterp", MenuDescSuffix = "(Vector)"))
//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

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2025-2-22 16:40 , Processed in 0.388337 second(s), 26 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表