参考:UE5中MotionMatching(一) MotionTrajectory
参考:游戏开发/虚幻引擎(UE5) 运动匹配
插件C++工程,点击Edit->Plugins->Aniamtion->Motion Trajectory即可安装插件,此时的插件是Experimental的,点击完重启Editor即可。
安装插件后看了下相关代码,我发现UE提供的官方插件都是包括在源码里面的,安装对应版本的UE Editor后,对应的Plugins的源代码都会随着被安装到电脑里(跟源码一样),如下图所示:
实际的项目里,只需要负责启用相关插件即可
参考的文章里已经提到了,修改默认的ThirdPersonCharacter蓝图,添加CharacterMovementTrajectory组件,勾选DebugDrawTrajectory,再连出来下面这个蓝图即可,注意这里需要创建一个临时变量给它赋值
看了下它绘制的预期路径,应该是没有考虑障碍物的影响
这个插件是为了得到运动轨迹,主要分为两个部分:
UMotionTrajectoryComponent
的TickComponent
函数里UCharacterMovementTrajectoryComponent
提供的GetTrajectory
节点函数实现的相关代码如下:
void UMotionTrajectoryComponent::TickComponent(float DeltaTime, enum ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
// 1. 从UCharacterMovementComponent中获取当前的速度、加速度, 以及为当前位置创建对应的FTrajectorySample对象
// Compute the instantaneous/present trajectory sample and guarantee that is zeroed on all accumulated domains
// GetPresentTrajectory为虚函数, 内部实际调用的是子类UCharacterMovementTrajectoryComponent的函数
PresentTrajectory = GetPresentTrajectory();
PresentTrajectory.AccumulatedDistance = 0.f;
PresentTrajectory.AccumulatedSeconds = 0.f;
// 2. 更新History里的Sample点, Evict过时的点
// Tick the historical sample retention/decay algorithm by one iteration, Eviction: 驱逐
TickHistoryEvictionPolicy();
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
}
更新History里的Sample点,这一部分算是比较简单的,无非是记录一个数组,基于每帧的角色的WorldTransform对应的DeltaTransform,不断记录每帧的累积位移、累积时间和累积移动距离就行了,然后根据设置删除轨迹里太早的点,核心代码如下所示:
// 每次调用此函数时, PresentTrajectory是一个初始对象
void UMotionTrajectoryComponent::TickHistoryEvictionPolicy()
{
// World space transform and time are used for determining local-relative historical samples
const UWorld* World = GetWorld();// Actor Component可以方便的利用此接口获取所在的World
const FTransform WorldTransform = GetPresentWorldTransform();// WorldTransform一直为单位矩阵
const float WorldGameTime = UKismetSystemLibrary::GetGameTimeInSeconds(World);
// Skip on the very first sample
if (PreviousWorldGameTime != 0.f)// 第一帧不做预测, 因为无法预测
{
// 1. 算DeltaTransform
// Compute world space/time deltas for historical sample decay
const float DeltaSeconds = WorldGameTime - PreviousWorldGameTime;
const float DeltaDistance = FVector::Distance(WorldTransform.GetLocation(), PreviousWorldTransform.GetLocation());
const FTransform DeltaTransform = PreviousWorldTransform.GetRelativeTransform(WorldTransform);
// 2. 遍历过去的每个轨迹点, 更新数据
for (auto& Sample : SampleHistory)
{
// 当Sample里表示过去的点的轨迹时, 其Seconds和Distance值为负数
Sample.AccumulatedSeconds -= DeltaSeconds;
Sample.AccumulatedDistance -= DeltaDistance;
Sample.Position = DeltaTransform.TransformPosition(Sample.Position);
}
// FirstSample是存在的过去轨迹点里, 离当前位置距离最远的
const FTrajectorySample FirstSample = SampleHistory.First();
const float FirstSampleTime = FirstSample.AccumulatedSeconds;
constexpr int32 DistanceDomainMask = static_cast<int32>(ETrajectorySampleDomain::Distance);
constexpr int32 TimeDomainMask = static_cast<int32>(ETrajectorySampleDomain::Time);
const bool bDistanceDomainEnabled = (HistorySettings.Domain & DistanceDomainMask) == DistanceDomainMask;
const bool bTimeDomainEnabled = (HistorySettings.Domain & TimeDomainMask) == TimeDomainMask;
// For both time and distance history domains, we compute the concept of an "effective" time horizon:
// This value is present to guarantee uniform history decay against a fixed anchor point in the past when there is lack of trajectory motion
// The anchor point is defined by the AccumulatedTime of the furthest history sample at the exact moment of zero motion
// 当角色停止移动时, 且历史轨迹点还存在时
if (FMath::IsNearlyZero(DeltaDistance) && !FirstSample.IsZeroSample())
{
// 当角色突然停止移动时, 会调用下面的函数(只在第一次停止移动时调用此函数, 给EffectiveTimeDomain赋值)
if (EffectiveTimeDomain == 0.f)
{
// One time initialization of an effective time domain at the exact moment of zero motion
EffectiveTimeDomain = FirstSampleTime;//
}
}
else
{
// When we are in motion, an effective time domain is not required
EffectiveTimeDomain = 0.f;
}
// Remove all trajectory samples which are outside the boundaries of the enabled domain horizons
SampleHistory.RemoveAll([&](const FTrajectorySample& Sample)
{
// 根据lambda函数返回的bool决定要不要删除元素
// 如果当前轨迹点与之前轨迹点的位置、速度、加速度都为0, 那么角色没有动过, 此时删除所有历史轨迹点
// Remove superfluous zero motion samples
if (Sample.IsZeroSample() && PresentTrajectory.IsZeroSample())
return true;
// History的Sample点的取消有两种模式, 一种是按时间来, 一种是按Distance来
// Distance horizon
// 如果历史点离当前位置的累计长度超过了设定值, 那么删除该点
if (bDistanceDomainEnabled && (Sample.AccumulatedDistance < -HistorySettings.Distance))
return true;
// 如果历史点离当前位置的累计时间超过了设定值, 那么删除该点
// Time horizon
if (bTimeDomainEnabled && (Sample.AccumulatedSeconds < -HistorySettings.Seconds))
return true;
// Effective time horizon
if (EffectiveTimeDomain != 0.f&& (Sample.AccumulatedSeconds < EffectiveTimeDomain))
return true;
return false;
});
}
// Disallow trajectory samples beyond the maximum count
// An opportunity to insert more samples will arise as history decays
if (SampleHistory.Num() < MaxSamples)
{
SampleHistory.Emplace(PresentTrajectory);
}
// Cache the present world space sample as the next frame's reference point for delta computation
PreviousWorldTransform = WorldTransform;
PreviousWorldGameTime = WorldGameTime;
}
角色蓝图的Tick过程里,调用UCharacterMovementTrajectoryComponent
提供的GetTrajectory
节点函数实现的,具体每帧就俩函数:
// 借助MovementComponent计算未来轨迹
FTrajectorySampleRange Prediction(SampleRate);
PredictTrajectory(MovementComponent, SampleRate, MaxSamples, Settings, PresentTrajectory, Prediction);
// 将预测的未来轨迹点与过去的轨迹点合并成一整个Trajectory
return CombineHistoryPresentPrediction(bIncludeHistory, Prediction);
所以这里的核心还是PredictTrajectory
函数,里面的思路为:
代码也不难,无非就是这个阻力减速的公式稍微难理解一点:
else// 当人物存在加速度时, 会走以下逻辑
{
// 1. 计算角色加速度方向和速度大小
const FVector AccelDir = Sample.LocalLinearAcceleration.GetSafeNormal();
const float VelSize = Sample.LocalLinearVelocity.Size();
// 2. 根据加速度方向和阻力衰减角色速度
// 这里的衰减公式我不太理解, 为啥只有角色加速度的方向考虑了进来, 而不考虑角色加速度的大小
// 不过这个公式好歹满足一点: 摩擦系数为0时, LocalLinearVelocity值不变
Sample.LocalLinearVelocity = Sample.LocalLinearVelocity - (Sample.LocalLinearVelocity - AccelDir * VelSize) * FMath::Min(IntegrationDelta * MovementComponent->GroundFriction, 1.f);
// 3. 把加速度带来的速度影响加在速度上
const float MaxInputSpeed = FMath::Max(MovementComponent->GetMaxSpeed() * MovementComponent->GetAnalogInputModifier(), MovementComponent->GetMinAnalogSpeed());
Sample.LocalLinearVelocity += Sample.LocalLinearAcceleration * IntegrationDelta;
// 4. 根据CharacterMovement限制角色速度
Sample.LocalLinearVelocity = Sample.LocalLinearVelocity.GetClampedToMaxSize(MaxInputSpeed);
}
Sample.Position += Sample.LocalLinearVelocity * IntegrationDelta;
差不多就是这样,MotionTrajectoryLibrary.cpp
也提供了一些helper函数,就不多分析了,前面参考的文章里也都介绍过了
计算运动轨迹的核心思路其实很简单:
对于预测路径而言,应该都是这么个算法,我看Daniel Holden的文章spring-roll-cal里也是这么逻辑,