[UE5]完全程序化的角色基础移动控制器
程序化Locomoition角色控制器
git仓库:GitHub - EanoJiang/ProceduralAnim: 完全程序化Locomotion · GitHub
视频演示:【UE5】完全程序化的角色基础移动控制器_哔哩哔哩_bilibili
Debug调试显示:
第一个程序动画
创建一个control rig

control rig蓝图中,获取骨骼的transform,暂时先控制pelvis来验证该功能,选择Space为全局空间,让过渡Translation的Z轴附加一个sin函数,模拟身体上下浮动

选择Use Specific Animation

效果:

BasicLegIK
基础的腿部IK
设置好腿部IK链:thigh-calf-foot,Efector设置为ik_foot

这里还需要设置主次朝向,也就是Primary和Secondaru Axis
Primary Axis为Item A(也就是thigh),Secondaru Axis为Item B(也就是calf)
thigh_l的坐标系如图(红绿蓝分别对应xyz):

calf_l的坐标系如图:

大腿需要朝向下,膝盖需要朝向正前
因此Primary Axis的x=-1,Secondaru Axis的y=1

Pole Vector(极向量) :控制 IK 的关节弯曲方向
因此设置朝向正前

效果:

把控制脚部偏移的节点加进来

用循环来为左右脚都设置IK
先建立一个脚部Transform的列表

然后给左右脚都设置好ik,因为右脚的骨骼朝向与左脚相反,所以当index!=0时主次轴朝向都需要乘-1

效果:

暂时先删去脚部偏移

FootRotation
脚的旋转
RotateAroundPoint
脚绕着目标点旋转
也就是“要旋转的物体” 相对于 “旋转中心点” 的旋转偏移,如图所示也就是向量A-B


四元数乘的顺序问题:
A*B 先乘B后乘A 也就是Quat_B要乘以Quat_A的量,因此要旋转的Transform放在B
RotateVector 节点:
Transform:旋转信息变换,也就是旋转量
Vector:要被旋转的方向向量Vector
SetFootTransforms
根据脚的位置计算脚的旋转方式
如果脚在身体前面,脚绕着脚踝旋转
如果脚在身体后面,脚绕着脚前掌旋转
Foot以及Ball骨骼的引用


脚踩平台追踪
从每个Foot的z轴(-50,50)追踪脚踩平台的接触点


脚部放置
设置Foot的Transform属性,Translation(位置)取自脚踩平台高度,Rotation和Scale保持Foot本身的不变

效果:

脚踩平台旋转偏移
最终脚部的Rotation = 脚踩平台的Rotation * 脚部的Rotation

再次强调四元数乘的顺序问题:
A * B 先 B,后 A 子对象(A)在父对象(B)的空间内变换 B * A 先 A,后 B 父对象(B)在子对象(A)的空间内变换 A是子对象,B是父对象
我们需要的是先让脚部按照自身的
Foot.Rotation旋转,然后再将整个脚部(包括它自己的旋转)作为一个整体,跟随平台的TargetFootPlatform.Rotation进行旋转也就是平台先转,脚再跟着平台转,因此是脚踩平台的
TargetFootPlatform.Rotation*Foot.Rotation,而不是反过来乘
先绕Z轴旋转45度看下效果:


计算脚踩平台的前后偏移量
向前方向是y = 1

如果脚在后面,那么点积结果<0,因此就可以根据这两个向量的点积结果来判断脚的位置(相对于脚踩处的位置)

因此,(Vector_脚踩平台位置 - Vector_大腿位置) ·(0,1,0)得到的值即为脚部向前偏移量FootForwardOffset

绕着脚踩平台旋转脚部
删去之前写的Rotation逻辑,直接取用FootRig.Rotation即可
然后再单独处理脚部旋转逻辑:调用之前写的RotateAroundPoint函数,待旋转的是脚部,旋转点和旋转量是脚踩处

计算脚前掌ball的旋转偏移点
Make Relative :将一个变换从全局空间转换为相对于另一个变换的局部空间
全局坐标中,脚前掌旋转点的水平方向XY取自脚前掌,高度Z取自脚踩处
需要变换到脚部Foot的局部坐标

在脚部旋转的逻辑处理之后,可视化脚前掌旋转点
Transform相乘:坐标系转换,通常是子Transform乘以父Transform转换为世界坐标

如图中绿色框

如果脚部的z轴旋转量增大,会发现脚前掌旋转点位置不精确,这是因为最终的LegIK会限制脚部的旋转
计算脚尖tip的旋转偏移点
ball沿着脚底向前移动一段距离即为tip
脚底向前方向向量 = Vector_脚前掌(ball)- Vector_脚踝(foot)

脚底向前方向向量 + 脚前掌Ball的世界坐标 = 脚尖旋转偏移点的世界坐标,然后相关于脚前掌的局部坐标

绘制脚尖旋转偏移点(需要转换为世界坐标)

最终得到

计算脚后跟heel的旋转偏移点
脚踩点位置 + 脚底向前方向向量*(-0.8) = 脚后跟旋转偏移点的世界坐标,然后相关于脚部的局部坐标

绘制脚后跟旋转偏移点

最终得到

所有点位

封装绘制这些点位的函数


旋转脚部
腿在后面:先绕着脚前掌Ball旋转脚部Foot,再绕着脚尖Tip旋转脚部Foot
因为旋转量是取自脚部向前偏移量,因此把这个偏移量分为两个阶段:(-10 ~ -40),(-40 ~ -70)
绕着脚前掌Ball旋转脚部Foot
旋转量取自脚部向前偏移量,从(-40,-10)Remap为(-25,0)

暂时关闭SetFinalLegIK

设置脚部前后偏移(沿y轴)


修改一下前面计算点位时的Z轴逻辑
脚前掌

脚后跟

取消旋转脚前掌ball

绕着脚尖Tip旋转脚部Foot
旋转量取自脚部向前偏移量,从(-70,-40)Remap为(-30,0)

腿在前面:绕着脚后跟heel旋转脚部Foot

开启SetFinalLegIK

设置Z轴偏移,防止脚部浮空

最终效果:

Velocity cycles and leg movement
计算速度
CalculateVelocity
世界空间速度 = (根骨骼世界坐标 - 上一帧的根骨骼世界坐标) / DeltaTime

骨骼空间速度 = (世界空间速度 + 骨骼位置).转换到骨骼空间 - 骨骼位置
绘制相对于根骨骼的角色速度 = 从根骨骼位置出发 -> (根骨骼位置+骨骼空间速度)


锁定脚部位置的数组
构造一个LockedFootLocationArray


根骨骼前后帧的相对变换:T_now * T_last⁻¹:相当于 “先撤销上一帧的所有变换(逆操作),再执行当前帧的变换”,最终得到的就是 “从上一帧到当前帧,物体相对动了多少”

计算Cycle


计算脚踩目标平台的位置
CalculateFootTargetPlatform


UnLocked状态
对脚踩目标平台位置进行Lerp线性插值计算
如果处于UnLocked状态,让局部变量脚踩目标平台位置(TempFootPlatform)从 原锁定位置-Lerp->新的脚部位置,Lerp的阻尼取自Cycle进度,也就是Cycle从0 ~ 0.5映射到Lerp的阻尼就是0 ~ 1
为什么是0.5?因为下面会设置规则Cycle百分比大于0.5就锁定脚部

更新数组
然后更新脚部锁定数组Cycle百分比大于0.5就锁定,再更新脚部锁定位置数组

Locked状态

UnLocked状态,对未来脚部位置进行预测,然后Lerp
之前只是默认Lerp到角色正下方的脚部位置,现在需要预测角色即将到达的脚部位置,再Lerp
新建预测脚部位置数组

预测脚部落点

适配步幅的骨骼空间移动速度 = 骨骼空间速度的单位向量 × (步幅/2)

得到的PredictFeetLocationArray就可以传入CalculateFootTargerTransform函数,将脚踩目标平台Lerp到角色未来即将到达的脚部位置


效果:

交替移动两条腿
脚部Cycle以异步模式进行
新建每只脚的Cycle百分比数组

每只脚的Cycle百分比 = 主Cycle百分比 + (FootIndex × 0.5) ,最后保证这个百分比超过1自动重置
这样当开始主Cycle时,左脚index = 0,右脚index = 1,右脚就比左脚慢了半个周期

替换计算目标脚踩平台中的MasterCyclePercent为每只脚的Cycle百分比


确保脚底在地面上

预测角色的移动以实现脚部落点的追踪
未落地前的的骨骼空间移动速度应该是即将前进的距离,加上移动方向上的步幅,这样在脚部遇到障碍物之前,会提前抬高一些防止脚部穿模
其中,即将前进的距离 = RigSpaceVelocity × 慢慢变小的预测时间,由于预测时间在慢慢减小,这个前进的距离也在由大变小,因此脚部抬高与前进会产生一个曲线,自然过渡到可能要遇到的障碍物的平面落点

计算脚部的插值平滑曲线
位置Translation从StartingTransform平滑插值到EndTransform,平滑模式设置为BSpline(全局平滑),中间可自定义2个插值的z轴高度

旋转Rotation直接简单Lerp插值即可

回到计算脚踩目标平台的函数:将原来的Interpolate替换为写好的平滑曲线过渡

效果:

动态Cycle时间
可以发现,如果移动速度过快,会出现往后迈的腿的锁定位置远远落后,导致该腿会绷直一段时间,很不自然
这是因为:主Cycle百分比是固定值,当速度很快的时候脚的锁定时间过长
因此需要改为动态的Cycle时间,当移动速度变得更快的时候这个Cycle时间要相应的更短

得到的摆动时间占Cycle周期长度的百分比SwingTimeAsAPercent替换掉原来的固定值0.5



修复脚部锁定过于向前:抵消掉预测即将前进距离

修复:静止状态时原地踏步的问题

不同速度下,应该对应的平滑曲线示意图:
高速——黄色关键帧和红色曲线所示,低速——蓝色关键帧和紫色曲线所示

改造CalculateFootSpline

从起始点沿着移动速度方向,向前/后偏移一定距离处,-1后退,1前进
因此点位1就是开始位置0向后偏移8,点位2是结束位置6向前偏移20——(偏移量随时更改)

点位2是点位1沿z轴向上偏移30,点位4是点位5沿z轴向上偏移30,点位3是2和4的中点——(偏移量随时更改)

最后按序传入BSpline平滑曲线的点位,得到的Position传给返回值OutputTransform的Translation属性,Rotation属性这里先直接用StartingTransform和EndTransform的混合值即可

效果:

适当减小地面停留时间,让脚部重置的响应快一些

修复:脚部落地前出现一段时间的大小腿绷直
这是因为脚部的目标IK位置距离太远,脚部要想到达该位置会尽量伸直大小腿来实现,膝盖就会绷得很紧
解决方法:限制IK距离

Foot到Calf + Calf到Thigh,距离之和是大小腿的总长,那么脚部IK的目标位置不能超过腿长的1.02——(这个值可以调整)
要保证获取的Transform是初始的值,不要被其他地方修改所影响,因此要勾选Initial

效果:

Pelvis and spine control
身体控制
初始化设置——盆骨偏移OffsetPelvis放到SetFinalIK之前

先保存OffsetPelvis之前的脚部Transform,然后恢复


Pelvis Cycle
基于速度的身体上下循环偏移

身体左右旋转循环偏移
人体运动学:左脚迈出时,上半身体顺时针旋转;右脚迈出时,上半身体逆时针旋转

因此,需要比较哪个脚在前,来决定上半身体的旋转方向以及旋转角度


效果:

肩膀左右旋转循环偏移
用来补偿身体的左右旋转带来的手臂顺拐现象,因此肩膀的左右旋转方向与身体应当是相反的
SpineRotation = PelvisRotation × -2



效果:

如果把Scale设置的更大,可以看到更夸张的效果,因此这个Scale可以根据想要的效果更改值大小:

脖子左右旋转循环偏移


效果:

斜向移动时骨骼朝向偏移
将脚踩平台的Transform存为数组以供随时访问
在SetupFootArray中初始化后,再存储该数组

用左右脚踩平台用来替换左右脚的引用

斜向移动时左右脚的朝向偏移
对于靶向移动来说,在同一条对角线上的两个方向移动时的脚朝向是相同的,因此只需要考虑角色面前的180度半圆内的脚朝向
需要设置一定死区,防止越过临界值的时候移动方向突变,这里设置为10度死区

从图中可以看出:
向斜后方移动的角度 > 100,实际的脚部朝向角 = 移动方向角 - 180
向斜后方移动的角度 <-100,实际的脚部朝向角 = 移动方向角 + 180

得到的FootTargetZAngle转换为Quat,得到移动角度偏移MovementAngleOffset

逻辑代码迁移到C++自定义ControlRig节点:
需要.Build中添加编译要用到的模块AnimationCore

RigUnit_ProceduralCharacter.h
#pragma region 计算移动角度偏移
//计算移动角度偏移
USTRUCT(meta = (DisplayName = "GetMovementAngleOffset"))
struct PROCEDURALANIM_API FRigUnit_GetMovementAngleOffset : public FRigUnit
{
GENERATED_BODY()
RIGVM_METHOD()
virtual void Execute() override;
UPROPERTY(meta = (Input))
FVector RigSpaceVelocity;
UPROPERTY(meta = (Output))
float FootTargetZAngle;
UPROPERTY(meta = (Output))
FQuat MovementAngleOffset;
};
FVector EulerFromQuat(const FQuat& Rotation, EEulerRotationOrder RotationOrder = EEulerRotationOrder::ZYX, bool bUseUEHandyness = false);
FQuat FromTwoVectors(const FVector& A, const FVector& B);
#pragma endregion
RigUnit_ProceduralCharacter.cpp
#pragma region 计算移动角度偏移
FRigUnit_GetMovementAngleOffset_Execute()
{
const float OriginalZAngle = AnimationCore::EulerFromQuat(
FromTwoVectors(FVector(0,1,0),RigSpaceVelocity)
).Z;
if (OriginalZAngle > 100)
{
FootTargetZAngle = OriginalZAngle - 180;
}
else if (OriginalZAngle < -100)
{
FootTargetZAngle = OriginalZAngle + 180;
}
else
{
FootTargetZAngle = OriginalZAngle;
}
MovementAngleOffset = AnimationCore::QuatFromEuler(FVector(0,0,FootTargetZAngle));
}
FQuat FromTwoVectors(const FVector& A, const FVector& B)
{
if (A.IsNearlyZero() || B.IsNearlyZero())
{
return FQuat::Identity;
}
return FRigVMMathLibrary::FindQuatBetweenVectors(A, B);
}
#pragma endregion

在PredictFootLandingSpot中传入移动角度偏移

效果:

在FinalLegIK中传递移动角度偏移到膝盖

效果:

身体跟随脚部的旋转而自旋转
上面解决了脚部旋转以及膝盖的跟随旋转,但是Pelvis还没有跟着旋转,因此需要找到双脚间的平均旋转偏移来附加到Pelvis上


预测脚步落点位置需要绕着身体旋转

效果:
八向移动

斜向移动

Smoothing and rotation limits
转向限制,尽可能避免出现腿部交叉的情况
减少方向切换时的移动角度偏移

让更新前后的向量平滑过渡(不受帧速率影响)
之前的向量更新是瞬时的,因此会出现腿部位置突变的情况,极其破坏动作的说服力
新建用于向量的Lerp函数(消除帧率差异)
LerpedVector = InVector + 差值 * 基于DeltaTime的变化量

迁移到C++
#pragma region 消除帧率差异的用于Vector的Lerp函数
USTRUCT(meta = (DisplayName = "VectorLerp"), Category = "Lerp")
struct PROCEDURALANIM_API FRigUnit_VectorLerpIndependentOnFrameRate : public FRigUnit
{
GENERATED_BODY()
FRigUnit_VectorLerpIndependentOnFrameRate()
{
LerpedVector = TargetVector = InVector = FVector(1.f, 0.f, 0.f);
MaxDelVectorPerSecond = 0.f;
}
RIGVM_METHOD()
virtual void Execute() override;
UPROPERTY(meta = (Input))
FVector InVector;
UPROPERTY(meta = (Input))
FVector TargetVector;
UPROPERTY(meta = (Input))
float MaxDelVectorPerSecond = 0;
UPROPERTY(meta = (Output))
FVector LerpedVector;
};
FVector MathVectorClampLength(FVector Value = FVector(1.f, 0.f, 0.f), float MinimumLength = 0, float MaximumLength = 1);
#pragma endregion
#pragma region 消除帧率差异的用于Vector的Lerp函数
FRigUnit_VectorLerpIndependentOnFrameRate_Execute()
{
FVector DeltaVector = MathVectorClampLength(TargetVector - InVector, 0,MaxDelVectorPerSecond * ExecuteContext.GetDeltaTime<float>());
LerpedVector = InVector + DeltaVector;
}
FVector MathVectorClampLength(FVector Value, float MinimumLength, float MaximumLength)
{
if (Value.IsNearlyZero())
{
return FVector::ZeroVector;
}
float Length = static_cast<float>(Value.Size());
return Value * FMath::Clamp<float>(Length, MinimumLength, MaximumLength) / Length;
}
#pragma endregion
RigSpaceVelocity加入自定义的VectorLerp节点

修复:预测脚部落点位置更新不及时的Bug
当阻尼值设置较小时会出现,停下后脚部落点位置更新不及时
这是因为停下的瞬间RigSpaceVelocity已经为0,这时候的移动方向是未知方向,因此移动方向上的步幅会出问题
因此还需要修改预测脚部落点位置的函数逻辑
移动方向上的步幅中,步幅原来的逻辑是RigSpaceVelocity的单位向量 × 步长的一半,因为归一化节点unit对于零向量会出问题,因此改为RigSpaceVelocity×在地面停留时间的一半

移动角度偏移MovementAngleOffset加入自定义的VectorLerp节点
考虑到之前把移动角度偏移MovementAngleOffset的逻辑也封装到了C++中,那么要想在C++中也调用这个自定义的VecotrLerp节点,需要把该节点的功能再封装一层为独立的函数
#pragma region 消除帧率差异的用于Vector的Lerp函数
USTRUCT(meta = (DisplayName = "VectorLerp"), Category = "Lerp")
struct PROCEDURALANIM_API FRigUnit_VectorLerpIndependentOnFrameRate : public FRigUnit
{
GENERATED_BODY()
FRigUnit_VectorLerpIndependentOnFrameRate()
{
LerpedVector = TargetVector = InVector = FVector(1.f, 0.f, 0.f);
MaxDelVectorPerSecond = 0.f;
}
RIGVM_METHOD()
virtual void Execute() override;
UPROPERTY(meta = (Input))
FVector InVector;
UPROPERTY(meta = (Input))
FVector TargetVector;
UPROPERTY(meta = (Input))
float MaxDelVectorPerSecond = 0;
UPROPERTY(meta = (Output))
FVector LerpedVector;
};
FVector VectorLerpIndependentOnFrameRate(FVector InVector, FVector TargetVector, float MaxDelVectorPerSecond = 0, float DeltaTime = 0);
FVector MathVectorClampLength(FVector Value = FVector(1.f, 0.f, 0.f), float MinimumLength = 0, float MaximumLength = 1);
#pragma endregion
#pragma region 消除帧率差异的用于Vector的Lerp函数
FRigUnit_VectorLerpIndependentOnFrameRate_Execute()
{
float DeltaTime = ExecuteContext.GetDeltaTime<float>();
LerpedVector = VectorLerpIndependentOnFrameRate(InVector, TargetVector, MaxDelVectorPerSecond, DeltaTime);
}
FVector VectorLerpIndependentOnFrameRate(FVector InVector, FVector TargetVector, float MaxDelVectorPerSecond, float DeltaTime)
{
FVector DeltaVector = MathVectorClampLength(TargetVector - InVector, 0,MaxDelVectorPerSecond * DeltaTime);
return InVector + DeltaVector;
}
FVector MathVectorClampLength(FVector Value, float MinimumLength, float MaximumLength)
{
if (Value.IsNearlyZero())
{
return FVector::ZeroVector;
}
float Length = static_cast<float>(Value.Size());
return Value * FMath::Clamp<float>(Length, MinimumLength, MaximumLength) / Length;
}
#pragma endregion
加入自定义的VectorLerp节点

迁移到C++:
UPROPERTY(meta = (Input))
float MaxDelVectorPerSecond = 360.0f;

FVector LerpedVector = VectorLerpIndependentOnFrameRate(
AnimationCore::EulerFromQuat(MovementAngleOffset),
FVector(0,0,FootTargetZAngle),
MaxDelVectorPerSecond,
ExecuteContext.GetDeltaTime<float>()
);
MovementAngleOffset = AnimationCore::QuatFromEuler(FVector(0,0,LerpedVector.Z));


暂时把每秒的最大变换量设置为360,在这里也就是1圈
效果:

突然改变移动方向不会出现盆骨Rotation突变的情况
修复:侧向移动时脚部是平移过去的,没有正确旋转
正确次序是:绕脚掌ball旋转->绕脚尖tip旋转->绕脚后跟旋转->绕脚踩处旋转
原来的逻辑:


修改后:


限制脚部的旋转偏移
脚部的Z轴旋转(左右旋转)受移动角度偏移限制,限制权重为0.5

限制在(-25,25)度

将之前预测落点函数中的移动角度偏移权重也更换为这个变量

修复:当脚部经过身体正下方会处于很平的浮空
让脚部在完成Swing阶段后保持最初始的旋转信息
怎么判断已完成Swing阶段?
每只脚的Cycle实时百分比 / Swing阶段占比 < 1,说明当前处于Swing阶段
并且用曲线来平滑处理,中点就是Swing阶段时脚部经过身体正下方的时刻,权重为1,完全是最初始的旋转信息

效果:

修复:出现腿部交叉的情况
对比现实中,如果腿部即将交叉,我们会让一条腿绕着另一条腿向前或向后旋转
怎么实现"一条腿绕着另一条腿旋转"?
这个自定义脚部曲线的中间点3的水平面位置xy绕着盆骨旋转,旋转量为移动角度偏移,z轴位置还是原先的InsertPoint3

让步幅与移动速度相关
跑的时候步幅小,走的时候步幅大

Arm motion
手部运动
由于身体偏移是在z轴进行的,如果要让手部运动sin型,只需在身体偏移之前让手部运动沿着移动速度的方向做前后运动即可
建立HandArray

迁移到C++
#pragma region 初始化Array
USTRUCT(meta = (DisplayName = "SetupArray"))
struct PROCEDURALANIM_API FRigUnit_SetupArray : public FRigUnit_DynamicHierarchyBaseMutable
{
GENERATED_BODY()
RIGVM_METHOD()
virtual void Execute() override;
UPROPERTY(meta=(Output))
TArray<FRigElementKey> FootArray;
UPROPERTY(meta=(Output))
TArray<FTransform> LockedFootLocationArray;
UPROPERTY(meta=(Output))
TArray<bool> IsFootLockedArray;
UPROPERTY(meta=(Output))
TArray<FTransform> PredictFeetLocationArray;
UPROPERTY(meta=(Output))
TArray<float> PerFootCyclePercentArray;
UPROPERTY(meta=(Output))
TArray<FTransform> SavedFootPlatformArray;
UPROPERTY(meta=(Output))
TArray<FRigElementKey> HandArray;
};
#pragma endregion
#pragma region SetupArray
FRigUnit_SetupArray_Execute()
{
DECLARE_SCOPE_HIERARCHICAL_COUNTER_RIGUNIT()
URigHierarchy* Hierarchy = ExecuteContext.Hierarchy;
if(!Hierarchy)
{
return;
}
FootArray.Reset();
LockedFootLocationArray.Reset();
IsFootLockedArray.Reset();
PredictFeetLocationArray.Reset();
PerFootCyclePercentArray.Reset();
SavedFootPlatformArray.Reset();
HandArray.Reset();
const FRigElementKey RootBoneKey(TEXT("root"), ERigElementType::Bone);
if (!Hierarchy->Contains(RootBoneKey))
{
return;
}
for (const FRigElementKey& ChildKey : Hierarchy->GetChildren(RootBoneKey, true))
{
if (ChildKey.Type != ERigElementType::Bone)
{
continue;
}
const FString BoneNameStr = ChildKey.Name.ToString();
if (BoneNameStr.Contains(TEXT("foot"), ESearchCase::IgnoreCase) && !BoneNameStr.Contains(TEXT("ik"), ESearchCase::IgnoreCase))
{
FootArray.Add(ChildKey);
FVector LockedFootLocationElementTranslation = Hierarchy->GetGlobalTransform(ChildKey).GetTranslation() + FVector(0.0f, 0.0f, -13.5f);
FTransform LockedFootLocationElement;
LockedFootLocationElement.SetTranslation(LockedFootLocationElementTranslation);
LockedFootLocationArray.Add(LockedFootLocationElement);
IsFootLockedArray.Add(false);
PredictFeetLocationArray.Add(FTransform());
PerFootCyclePercentArray.Add(0);
SavedFootPlatformArray.Add(FTransform());
}
else if (BoneNameStr.Contains(TEXT("hand"), ESearchCase::IgnoreCase) && !BoneNameStr.Contains(TEXT("ik"), ESearchCase::IgnoreCase))
{
HandArray.Add(ChildKey);
}
}
}
#pragma endregion
ArmMotion


主次朝向逻辑判断迁移到C++中:
#pragma region 计算ArmMotion的主次轴朝向数据
USTRUCT(meta = (DisplayName = "GetArmMotionAxisData"), Category = "ArmMotion")
struct PROCEDURALANIM_API FRigUnit_GetArmMotionAxisData : public FRigUnit
{
GENERATED_BODY()
RIGVM_METHOD()
virtual void Execute() override;
UPROPERTY(meta = (Input))
int ArmIndex = 0;
UPROPERTY(meta = (Output))
FVector PrimaryAxis = FVector(1, 0, 0) ;
UPROPERTY(meta = (Output))
FVector SecondaryAxis = FVector(0, -1, 0);
};
#pragma endregion
#pragma region 计算ArmMotion的主次轴朝向数据
FRigUnit_GetArmMotionAxisData_Execute()
{
//右骨骼朝向是反的,因此Index不为0时需要反向
PrimaryAxis = (ArmIndex == 0) ? FVector(1, 0, 0) : FVector(-1, 0, 0);
SecondaryAxis = (ArmIndex == 0) ? FVector(0, -1, 0) : FVector(0, 1, 0);
}
#pragma endregion

给Effector加一点偏移,效果如下:

绕着肩膀旋转手臂

基于移动速度的Arm摆动旋转曲线:
RigSpaceVelocity.Length.Remap * sin( 2Π * (PerFootCyclePercent+0.25)%1 )
手臂摆动旋转曲线中,由于需要和腿部实际摆动周期同步,需要加上一定偏移量,这里暂时设为提前1/4周期

Arm摆动旋转曲线迁移到C++
#pragma region 计算ArmMotion的Effector的RotationAmount值
//计算ArmMotion的Effector的RotationAmount值
USTRUCT(meta = (DisplayName = "GetArmMotionEffectorRotationAmount"), Category = "ArmMotion")
struct PROCEDURALANIM_API FRigUnit_GetArmMotionEffectorRotationAmount : public FRigUnit
{
GENERATED_BODY()
RIGVM_METHOD()
virtual void Execute() override;
UPROPERTY(meta = (Input))
TArray<float> PerFootCyclePercentArray;
UPROPERTY(meta = (Input))
int ArmIndex;
UPROPERTY(meta = (Input))
FVector RigSpaceVelocity;
UPROPERTY(meta = (Output))
FQuat RotateAmount;
};
float MathFloatRemap(float Value, float SourceMinimum, float SourceMaximum, float TargetMinimum, float TargetMaximum, bool bClamp);
#pragma endregion
#pragma region 计算ArmMotion的Effector的RotationAmount值
FRigUnit_GetArmMotionEffectorRotationAmount_Execute()
{
const float PerFootCyclePercent = PerFootCyclePercentArray[ArmIndex];
//RigSpaceVelocity.Length.Remap * sin( 2Π * (PerFootCyclePercent+0.25)%1 )
const float ArmSwingCurve = sin(2 * UE_PI * FMath::Fmod(PerFootCyclePercent + 0.25f, 1.0f))
* MathFloatRemap(
RigSpaceVelocity.Length(),
0,
500,
0,
30,
true
);
RotateAmount = AnimationCore::QuatFromEuler(FVector(ArmSwingCurve, 0, 0));
}
float MathFloatRemap(float Value, float SourceMinimum, float SourceMaximum, float TargetMinimum, float TargetMaximum, bool bClamp)
{
float Result = 0.f;
float Ratio = 0.f;
if (FMath::IsNearlyEqual(SourceMinimum, SourceMaximum))
{
Ratio = 0.f;
}
else
{
Ratio = (Value - SourceMinimum) / (SourceMaximum - SourceMinimum);
}
if (bClamp)
{
Ratio = FMath::Clamp<float>(Ratio, 0.f, 1.f);
}
Result = FMath::Lerp<float>(TargetMinimum, TargetMaximum, Ratio);
return Result;
}
#pragma endregion
迁移后:

效果:

给Hand加一个基于移动速度的Z轴偏移量


手臂摆动的中轴向前偏移一定距离

因此C++改动为:
#pragma region 计算ArmMotion的Effector的RotationAmount值
FRigUnit_GetArmMotionEffectorRotationAmount_Execute()
{
const float PerFootCyclePercent = PerFootCyclePercentArray[ArmIndex];
//摆动的幅度
const float ArmSwingAmplitude = MathFloatRemap(
RigSpaceVelocity.Length(),
0,
500,
0,
45,
true
);
//摆动的中轴向前偏移量
const float ArmSwingAxisOffset = MathFloatRemap(
RigSpaceVelocity.Length(),
0,
500,
0,
20,
true
);
//ArmSwingAmplitude * sin( 2Π * (PerFootCyclePercent+0.25)%1 ) + ArmSwingAxisOffset
const float ArmSwingCurve = sin(2 * UE_PI * FMath::Fmod(PerFootCyclePercent + 0.25f, 1.0f))
* ArmSwingAmplitude
+ ArmSwingAxisOffset;
RotateAmount = AnimationCore::QuatFromEuler(FVector(ArmSwingCurve, 0, 0));
}
效果:

修复:后退时同手同脚

因此C++改动为:
#pragma region 计算ArmMotion的Effector的RotationAmount值
//计算ArmMotion的Effector的RotationAmount值
USTRUCT(meta = (DisplayName = "GetArmMotionEffectorRotationAmount"), Category = "ArmMotion")
struct PROCEDURALANIM_API FRigUnit_GetArmMotionEffectorRotationAmount : public FRigUnit
{
GENERATED_BODY()
RIGVM_METHOD()
virtual void Execute() override;
UPROPERTY(meta = (Input))
TArray<float> PerFootCyclePercentArray;
UPROPERTY(meta = (Input))
int ArmIndex;
UPROPERTY(meta = (Input))
FVector RigSpaceVelocity;
UPROPERTY(meta = (Input))
FQuat MovementAngleOffset; //新增
UPROPERTY(meta = (Output))
FQuat RotateAmount;
};
#pragma region 计算ArmMotion的Effector的RotationAmount值
FRigUnit_GetArmMotionEffectorRotationAmount_Execute()
{
const float PerFootCyclePercent = PerFootCyclePercentArray[ArmIndex];
//摆动的正负号(向后摆动时需要乘-1)
const float ArmSwingSign = FVector::DotProduct(
RigSpaceVelocity.GetSafeNormal(),
MovementAngleOffset.RotateVector(FVector(0,1,0) )
);
//摆动的幅度
const float ArmSwingAmplitude = MathFloatRemap(
RigSpaceVelocity.Length(),
0,
500,
0,
30,
true
);
//摆动的中轴向前偏移量
const float ArmSwingAxisOffset = MathFloatRemap(
RigSpaceVelocity.Length(),
0,
500,
0,
20,
true
);
//Sign * ArmSwingAmplitude * sin( 2Π * (PerFootCyclePercent+0.25)%1 ) + ArmSwingAxisOffset
const float ArmSwingCurve = sin(2 * UE_PI * FMath::Fmod(PerFootCyclePercent + 0.25f, 1.0f))
* ArmSwingSign
* ArmSwingAmplitude
+ ArmSwingAxisOffset;
RotateAmount = AnimationCore::QuatFromEuler(FVector(ArmSwingCurve, 0, 0));
}

效果:

让向后移动时的手臂摆动幅度更小

因此C++改动为:
#pragma region 计算ArmMotion的Effector的RotationAmount值
FRigUnit_GetArmMotionEffectorRotationAmount_Execute()
{
const float PerFootCyclePercent = PerFootCyclePercentArray[ArmIndex];
//摆动的正负号(向后摆动时需要乘-1)
//向后摆动时的幅度小一些
const float ArmSwingSignClamp = FMath::Clamp(ArmSwingSign,-0.5,1);
//摆动的幅度
//摆动的中轴向前偏移量
//ArmSwingSignClamp * ArmSwingAmplitude * sin( 2Π * (PerFootCyclePercent+0.25)%1 ) + ArmSwingAxisOffset
const float ArmSwingCurve = sin(2 * UE_PI * FMath::Fmod(PerFootCyclePercent + 0.25f, 1.0f))
* ArmSwingSignClamp
* ArmSwingAmplitude
+ ArmSwingAxisOffset;
RotateAmount = AnimationCore::QuatFromEuler(FVector(ArmSwingCurve, 0, 0));
}
修复:手臂摆动小幅度向两侧打圈
把周期偏移0.25改为0.15
//Sign * ArmSwingAmplitude * sin( 2Π * (PerFootCyclePercent+0.15)%1 ) + ArmSwingAxisOffset
const float ArmSwingCurve = sin(2 * UE_PI * FMath::Fmod(PerFootCyclePercent + 0.15f, 1.0f))
* ArmSwingSignClamp
* ArmSwingAmplitude
+ ArmSwingAxisOffset;
基于速度的肩膀晃动

-0.25是为了让肩膀晃动与跑姿同步:
- 左脚落地 → 左肩往下
- 右脚落地 → 右肩往下

迁移到C++:
#pragma region 计算肩膀的晃动偏移
//计算肩膀的晃动偏移
USTRUCT(meta = (DisplayName = "GetClavicleOffset"), Category = "ArmMotion")
struct PROCEDURALANIM_API FRigUnit_GetClavicleOffset : public FRigUnit
{
GENERATED_BODY()
RIGVM_METHOD()
virtual void Execute() override;
UPROPERTY(meta = (Input))
FVector RigSpaceVelocity;
UPROPERTY(meta = (Input))
float MasterCyclePercent;
UPROPERTY(meta = (Output))
FVector ClavicleOffset;
};
#pragma endregion
#pragma region 计算肩膀的晃动偏移
FRigUnit_GetClavicleOffset_Execute()
{
const float ClavicleZOffset = sin(2 * PI * 2 * (MasterCyclePercent-0.25) )
* MathFloatRemap(
RigSpaceVelocity.Length(),
0,
500,
0,
2,
true
) ;
ClavicleOffset = FVector(0, 0, ClavicleZOffset);
}
#pragma endregion

效果:

Tweaks fixes and improvements
一些细节调整
移动时身体倾斜


迁移到C++
#pragma region 身体倾斜
//身体倾斜
USTRUCT(meta = (DisplayName = "PelvisLean"), Category = "OffsetPelvis")
struct PROCEDURALANIM_API FRigUnit_PelvisLean : public FRigUnit_DynamicHierarchyBaseMutable
{
GENERATED_BODY()
RIGVM_METHOD()
virtual void Execute() override;
UPROPERTY(meta = (Input))
FVector RigSpaceVelocity;
};
#pragma endregion
#pragma region 身体倾斜
FRigUnit_PelvisLean_Execute()
{
URigHierarchy* Hierarchy = ExecuteContext.Hierarchy;
if(!Hierarchy)
{
return;
}
FRigElementKey PelvisRig = FRigElementKey(TEXT("pelvis"), ERigElementType::Bone);
FTransform TransformToRotate = Hierarchy->GetGlobalTransform(PelvisRig);
FVector PointToRotateAround = TransformToRotate.GetTranslation();
float LeanRotateAmount = MathFloatRemap(
RigSpaceVelocity.Length(),
0,
500,
0,
-10,
true
);
float RigSpaceVelocityYProjection = RigSpaceVelocity.GetSafeNormal().Dot(FVector::UnitY());
float LeanRotateAmountAroundX = LeanRotateAmount * RigSpaceVelocityYProjection;
//基于速度的前后旋转量(绕x轴)
FQuat RotateAmount = AnimationCore::QuatFromEuler(FVector(LeanRotateAmountAroundX, 0, 0));
//Pelvis自旋转后的Transform
FTransform ModifiedTransform = RotateAroundPoint(TransformToRotate, PointToRotateAround, RotateAmount);
float LeanOffsetAmount = MathFloatRemap(
RigSpaceVelocity.Length(),
0,
500,
0,
10,
true
);
// 基于速度的前后位置偏移量(y轴)
float LeanOffsetAmountOnY = LeanOffsetAmount * RigSpaceVelocityYProjection;
ModifiedTransform.AddToTranslation(FVector(0, LeanOffsetAmountOnY, 0));
//最终倾斜后的Pelvis
FTransform FinalPelvis;
FinalPelvis.SetRotation(ModifiedTransform.GetRotation());
FinalPelvis.SetTranslation(ModifiedTransform.GetTranslation());
FinalPelvis.SetScale3D(ModifiedTransform.GetScale3D());
Hierarchy->SetGlobalTransform(PelvisRig, FinalPelvis);
}
#pragma endregion
效果:

身体随着不同脚部迈出而左右侧倾


效果:

修复:奔跑时由于脚部落点位置落后导致后腿绷直
FinalLegIK中的Clamp Sphere的圆心应该放在大腿Thigh处,而且这个Thigh的Transform不应该为初始值

修复:移动速度突变时脚部落点更新滞后A
脚部锁定位置加一个Clamp Sphere限制


增大Lerp速度

脚在地面停留的时间:速度越快,停留在地面的时间越长

修复:身体前后倾斜速度以及手臂运动速度过快
新建一个Lerp速度更慢的RigSpaceVelocity



效果:

修复:移动方向改变时腿部交叉
这个也是als的通病
FootMovementAngleOffsetLimit变量名字修改为FootRotationFactor:


可以发现当这个值为1时,脚部交叉现象几乎没了
因此,我们需要让FootRotationFactor的值是动态的


迁移到C++:
#pragma region 计算每个脚的RotationFactor
//计算每个脚的RotationFactor
USTRUCT(meta = (DisplayName = "CalculatePerFootRotationFactor"), Category = "FootRotation")
struct PROCEDURALANIM_API FRigUnit_CalculatePerFootRotationFactor : public FRigUnit
{
GENERATED_BODY()
RIGVM_METHOD()
virtual void Execute() override;
UPROPERTY(meta = (Input))
FQuat MovementAngleOffset;
UPROPERTY(meta = (Input))
int FootIndex;
UPROPERTY(meta = (Output))
float FootRotationFactor;
};
#pragma endregion
#pragma region 计算每个脚的RotationFactor
FRigUnit_CalculatePerFootRotationFactor_Execute()
{
const float ZAngle = AnimationCore::EulerFromQuat(MovementAngleOffset).Z;
if (FootIndex == 0)
{
//每当左脚向右转:说明这时候是左脚在前的右向移动,让此时的FootRotationFactor = 0,也就是前腿不旋转
FootRotationFactor = (ZAngle > 0) ? 0.5 : 0.9;
}
else if (FootIndex == 1)
{
//每当右脚向左转:说明这时候是右脚在前的左向移动,让此时的FootRotationFactor = 0
FootRotationFactor = (ZAngle > 0) ? 0.5 : 0.9;
}
else
{
FootRotationFactor = 0.9;
}
}
#pragma endregion
效果:


让膝盖的朝向略微受MovementAngleOffset影响

脚部适应斜坡角度

效果:

身体向下偏移量固定值改为向下射线的距离


效果:

修复:InputPose更换蹲姿时膝盖没有分开
初始的FootPole朝向Vector数组:


把原先SetFinalLegIK中的RotateVector固定值(0,10000,0)换为该数组

效果:

修改:ArmMotion中的肘部Pole朝向Vector也改为初始姿态的默认值


让脚部预测落点移动速度更加平滑
x
修改VectorLerp函数逻辑:
在即将到达目标值时减慢速度

C++的修改:
FVector VectorLerpIndependentOnFrameRate(FVector InVector, FVector TargetVector, float BlendSpeed, float DeltaTime)
{
const float LerpFactor = FMath::Clamp<float>(BlendSpeed * DeltaTime, 0, 1);
FVector DeltaVector = (TargetVector - InVector) * LerpFactor;
return InVector + DeltaVector;
}
所有用到该函数的地方需要更改BlendSpeed的值
侧向移动时的ArmMotion
手臂前后摆动受移动角度偏移的影响少一些

摆动幅度受速度沿正前方分量的影响,斜向移动时这个分量小,映射之后手臂摆动幅度会变小

因此,C++的改动:
//摆动的正负号(向后摆动时需要乘-1)
const float ArmSwingSign = FVector::DotProduct(
RigSpaceVelocity.GetSafeNormal(),
MathQuaternionScale(MovementAngleOffset, 0.4).RotateVector(FVector(0,1,0) )
);
//向后摆动时的幅度小一些
const float ArmSwingSignClamp = FMath::Clamp(ArmSwingSign,-0.5,1);
//摆动的幅度
const float ArmSwingAmplitude = MathFloatRemap(
RigSpaceVelocity.Length(),
0,
500,
0,
50,
true
)
* FMath::Clamp(FMath::Abs(RigSpaceVelocity.Dot(FVector(0,0.4,0))), 0.4, 1);
效果:

增加:上半身的左右摆动
修改前:上半身没有左右摆动

修改后:

Improved foot traces and foot avoidance
优化脚部轨迹追踪、避免脚部交叉
修复:从高处落下时,脚部位置延后导致轨迹异常
解决方法:
1.预测脚部落点的节点中,如果Sphere Trace没有击中,那么沿用以前的值

2.限制脚的高度

效果:

修复:当两只脚踩在不同高度的平面时,一只脚浮空
按照更低的脚做SphereTrace检测脚踩平台位置,作为z轴PelvisOffset


效果:

修复:斜面上走路,脚部会绕z轴向左偏移一定角度

预测脚部落点的节点中:
最终的Rotation的绕z轴分量应当取自移动角度偏移MovementAngleOffset

效果:

修复:脚部在高平台边缘会穿模
在预测落地点的周围做多点Trace检测
绘制矩阵Trace检测


效果:

沿原始落点朝向向前二次Trace
Count先设置为1,然后在Trace的起止点的基础上(沿原始落点朝向)向前移动到前脚掌的位置(图中绿色框是前脚掌位置),做为二次Trace的起止点


Count设回3

效果:

输出最佳落点
初始化LowestResult

如果两次Trace结果的高度差<LowestResult,取第一次Trace结果


检测效果:

比对两次Trace结果的Z轴高度,优先选取更高的

效果:

比对两次Trace结果与原始落点的位置偏移量,优先选取偏移更小的
OffsetFactor影响比重更小,也就是原始落点的影响权重更大

输出SelectedLandingPoint作为最终的落点位置
先把RectangleFootLandTraces节点的输入FootLandingSpot的Translation属性换为每只脚的预测脚部位置

输出SelectedLandingPoint作为Trace后的预测脚部位置

这里有个bug,是因为脚部自定义曲线的InsertPoint3(也就是曲线的中点)的xy值直接用了基于MovementAngle的旋转结果,没有区分移动和静止,导致停下时角色的脚还会来回Lerp
回到CalculateFootSpline节点,计算起止点之间的偏移,当偏移量小于一定值,认为已经停下,InsertPoint3保留最原始的Point2和4的平均值:

加入Trace的击中判断




效果:

修复:脚部最终落点重叠
修改前:
传入FootIndex区分左右脚,左脚Trace向左偏移,右脚Trace向右偏移

从图中可以看出,左右方向对应x方向上的正负,因此在x方向上,左脚偏移1,右脚偏移-1


效果:


修复腿部交叉
如果移动方向偏移MovementAngleOffset在一个不恰当的时机Lerp到目标值,会出现腿部交叉
这个不恰当的时机就是左右脚循环的起步阶段,在这个阶段不让移动方向偏移,也就是让MovementAngleOffset的Lerp速度 = 0即可解决
改动:



因此,c++的改动:
#pragma region 计算移动角度偏移
FRigUnit_GetMovementAngleOffset_Execute()
{
const float OriginalZAngle = AnimationCore::EulerFromQuat(
FromTwoVectors(FVector(0,1,0),RigSpaceVelocity)
).Z;
//TargetZAngle
float TargetZAngle = OriginalZAngle;
if (OriginalZAngle > 100)
{
TargetZAngle = OriginalZAngle - 180;
}
else if (OriginalZAngle < -100)
{
TargetZAngle = OriginalZAngle + 180;
}
//Lerp速度
float LerpSpeed = 6;
bool IsBigAngleOffset = abs(TargetZAngle - AnimationCore::EulerFromQuat(MovementAngleOffset).Z) > 90;
bool IsInStartMoment = FMath::Modulo(MasterCyclePercent * 2, 1) < 0.4;
if (IsBigAngleOffset && !IsInStartMoment)
{
LerpSpeed = 0;
}
FVector LerpedVector = VectorLerpIndependentOnFrameRate(
AnimationCore::EulerFromQuat(MovementAngleOffset),
FVector(0,0,TargetZAngle),
LerpSpeed,
ExecuteContext.GetDeltaTime<float>()
);
MovementAngleOffset = AnimationCore::QuatFromEuler(FVector(0,0,LerpedVector.Z));
}
让一条腿躲避另一条腿
让左脚始终在左侧,右脚始终在右侧




FootAvoidance节点迁移到C++:
#pragma region 避免脚部交叉
//避免脚部交叉
USTRUCT(meta = (DisplayName = "FootAvoidance"), Category = "CalculateFootTargetTransform")
struct PROCEDURALANIM_API FRigUnit_FootAvoidance : public FRigUnit
{
GENERATED_BODY()
RIGVM_METHOD()
virtual void Execute() override;
UPROPERTY(meta = (Input))
FVector IdeaLocation;
UPROPERTY(meta = (Input))
int FootIndex;
UPROPERTY(meta = (Input))
FQuat MovementAngleOffset;
UPROPERTY(meta = (Input))
TArray<FTransform> SavedFootPlatformArray;
UPROPERTY(meta = (Output))
FVector ModifiedLocation;
};
#pragma endregion
#pragma region 避免脚部交叉
FRigUnit_FootAvoidance_Execute()
{
//前后移动方向矢量
FVector MoveAngleVector = MovementAngleOffset.RotateVector(FVector::UnitY());
//指向身体两侧的矢量
FVector BodySideVector = AnimationCore::QuatFromEuler(FVector(0,0,90)).RotateVector(MoveAngleVector);
//对侧脚踩位置相距身体两侧偏移多少
float OppositeFootBodySideOffset = SavedFootPlatformArray[(FootIndex==0)? 1: 0].GetTranslation().Dot(BodySideVector);
//左脚需要偏移的量
float LeftFootOffset = FMath::Min(IdeaLocation.Dot(BodySideVector), OppositeFootBodySideOffset-15);
//右脚需要偏移的量
float RightFootOffset = FMath::Max(IdeaLocation.Dot(BodySideVector), OppositeFootBodySideOffset+15);
//身体两侧方向上需要避开多少
FVector BodySideAvoidOffset = BodySideVector*( (FootIndex==0)? LeftFootOffset: RightFootOffset );
//移动方向上需要避开多少
FVector MoveAngleAvoidOffset = MoveAngleVector * ( IdeaLocation.Dot(MoveAngleVector) );
//最终输出的位置
ModifiedLocation.X = (BodySideAvoidOffset+MoveAngleAvoidOffset).X;
ModifiedLocation.Y = (BodySideAvoidOffset+MoveAngleAvoidOffset).Y;
ModifiedLocation.Z = IdeaLocation.Z;
}
#pragma endregion
效果:

最终效果:
总结
优点:用程序的方式极其节省动画资产,节省工程存储空间,节省传统状态机运行时大量加载动画带来的繁重内存,相对足够写实,与环境交互性强,修改灵活,不需要维护一个庞大的状态机,对于小制作游戏来说是一个很好的选择
缺点:对于需要特殊张力表现的角色来说,适配性不好,因为对于这类角色来说为了表现张力,通常身体四肢的变换不是符合现实运作的,会有短暂的拉伸和形变,而该系统的ik部分极大限制了这种发挥。






浙公网安备 33010602011771号