UE MotionTrajectory插件研究

参考:UE5中MotionMatching(一) MotionTrajectory
参考:游戏开发/虚幻引擎(UE5) 运动匹配


安装插件

插件C++工程,点击Edit->Plugins->Aniamtion->Motion Trajectory即可安装插件,此时的插件是Experimental的,点击完重启Editor即可。

安装插件后看了下相关代码,我发现UE提供的官方插件都是包括在源码里面的,安装对应版本的UE Editor后,对应的Plugins的源代码都会随着被安装到电脑里(跟源码一样),如下图所示:
UE MotionTrajectory插件研究_第1张图片
实际的项目里,只需要负责启用相关插件即可


运行插件

参考的文章里已经提到了,修改默认的ThirdPersonCharacter蓝图,添加CharacterMovementTrajectory组件,勾选DebugDrawTrajectory,再连出来下面这个蓝图即可,注意这里需要创建一个临时变量给它赋值UE MotionTrajectory插件研究_第2张图片
看了下它绘制的预期路径,应该是没有考虑障碍物的影响


代码分析

这个插件是为了得到运动轨迹,主要分为两个部分:

  • 记录过去的轨迹采样点,具体过程在UMotionTrajectoryComponentTickComponent函数里
  • 预测未来的轨迹采样点,具体过程是在角色蓝图的Tick过程里,调用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函数,里面的思路为:

  • 基于当前的deltaTime和Update位移函数,反复多次调用该位移函数,算出未来的预测点,加入到返回的FTrajectorySampleRange的Sample数组里
  • 当未来的预测点超出了规定的范围时, 停止预测

代码也不难,无非就是这个阻力减速的公式稍微难理解一点:

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函数,就不多分析了,前面参考的文章里也都介绍过了


总结

计算运动轨迹的核心思路其实很简单:

  • 对于过去的点,一直记录当前计算的模型位置即可,就是每帧存一下,没什么特殊的
  • 对于未来的点,按照上一个Sample点的速度,加上当前的加速度,不断调用速度和位移的Update函数即可,注意这里假设的用户的Input是不变的,所以这里的加速度也是不变的(注意,角色的加速度仅仅是根据玩家的Input得到的)

对于预测路径而言,应该都是这么个算法,我看Daniel Holden的文章spring-roll-cal里也是这么逻辑,

你可能感兴趣的:(动画,unreal,engine,5)