模块:PoseSearch。目前对于MotionMatchingInteraction,无任何官方文档,无任何官方示例(即便是写这篇文章时Epic发布的UE5.7的GASP项目),故仅作参考。

关键类分析

Availabilities

Availability可以理解为我当前角色在本帧声明的交互意图和约束。

类声明在 PoseSearchInteractionAvailability.h 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// input for MotionMatchInteraction_Pure: it declares that the associated character ("AnimContext" that could be an AnimInstance or an AnimNextCharacterComponent)
// is willing to partecipate in an interaction described by a UMultiAnimAsset (derived by UPoseSearchInteractionAsset) contained in the UPoseSearchDatabase Database
// with one of the roles in RolesFilter (if empty ANY of the Database roles can be taken) the MotionMatchInteraction_Pure will ultimately setup a motion matching query
// using looking for the pose history "PoseHistoryName" to gather bone and trajectory positions for this character for an interaction to be valid,
// the query needs to find all the other interacting characters within BroadPhaseRadius, and reach a maximum cost of MaxCost
// Experimental, this feature might be removed without warning, not for production use
USTRUCT(Experimental, BlueprintType, Category = "Animation|Pose Search")
struct FPoseSearchInteractionAvailability
{
GENERATED_BODY()

// Database describing the interaction. It'll contains multi character UMultiAnimAsset and a schema with multiple skeletons with associated roles
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=Settings)
TObjectPtr<UPoseSearchDatabase> Database;

// in case this availability Database is valid (not null), Tag (if IsTagValid()) is used to flag the Database with a specific name. Different availabilities can share the same Tag.
// in case this availability Database is NOT valid, we use the valid Tag to figure out all the possible databases that can be assigned to this availability from all the published availabilities.
// The reason behind Tag is, for example, to be able to have NPCs been able to interact with a main character (MC), without the MC having a direct dependency to the database used
// for the interaction allowing those NPCs to be contextually loaded/unloaded, streamed in/out, with the obvious advantages for the the memory managment of the "payload" database.
// Another reason for Tag, is to facilitate the setup of interactions, where the MC have to publish only one availability with its own assigned Role (in RolesFilter)
// automatically contextually resolved in multiple different types of possible databases: it could be MC-NPC, MC-Vehicle, MC-Whatever
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=Settings)
FName Tag;

// roles the character is willing to take to partecipate in this interaction. If empty ANY of the Database roles can be taken
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=Settings)
TArray<FName> RolesFilter;

// the associated character to this FPoseSearchInteractionAvailability will partecipate in an interaction only if all the necessary roles gest assigned to character within BroadPhaseRadius centimeters
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=Settings)
float BroadPhaseRadius = 500.f;

// during interaction the BroadPhaseRadius will be incremented by BroadPhaseRadiusIncrementOnInteraction to create
// geometrical histeresys, where it's harder for actors to get into interaction rather than staying in interaction
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=Settings)
float BroadPhaseRadiusIncrementOnInteraction = 10.f;

// if true, the system will disable collsions between interacting characters
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=Settings)
bool bDisableCollisions = false;

// the Actor with the higher TickPriority of any Availability request will be elected as the MainActor of the interaction island (containing all the actors that could interact with each other)
// the main Actor will tick first and all the other interacting actors will tick after in a concurrently from each other. TickPriority is useful if your setup is already enforcing tick dependency
// between actors, and the motion matching interaction system needs to play nicely with them.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=Settings)
int32 TickPriority = UE::PoseSearch::DefaultTickPriority;

bool IsTagValid() const { return !Tag.IsNone(); }

bool operator==(const FPoseSearchInteractionAvailability& Other) const = default;
};

在蓝图中可以设置:

各字段含义: - Database:类型为 UPoseSearchDatabase,里面存多角色交互资产。该参数直接指定要参与哪个动画数据库交互。 - Tag:如果Database有效,则用Tag标记数据库的特定名称。不同的Availabilities可以共享同一个Tag。如果Database无效,则使用有效的Tag从所有发布的Availabilities中找出所有可能分配给这个Availability的数据库。Tag的作用是允许NPC与主角进行交互,而主角不需要直接依赖于用于交互的数据库,从而允许这些NPC被上下文加载/卸载,流式加载/卸载,这对于交互数据库的内存管理有明显优势。另一个Tag的作用是简化交互设置,主角只需发布一个具有自己分配角色(在RolesFilter中)的Availability,就可以自动在多种可能类型的数据库中进行上下文解析:可能是主角-NPC,主角-车辆,主角-其他。

举个例子吧。假设: 1. 主角 Availability: Database=null, Tag=“MC_INTERACT”, RolesFilter=[“Leader”] 2. NPC_A Availability: Database=DB_MC_NPC_Greet, Tag=“MC_INTERACT”, RolesFilter=[“Follower”] 3. NPC_B Availability: Database=DB_MC_NPC_Trade, Tag=“MC_INTERACT”, RolesFilter=[“Follower”] 那么得到的结果就是,主角不引用任何具体 DB,只声明“我在 MC_INTERACT 频道,想当 Leader”。子系统会用同 Tag 的发布方提供的 DB(Greet/Trade)去组装候选搜索。

  • RolesFilter:角色过滤器,角色是由数据库定义的。角色过滤器指定了角色的名称列表,表示该角色愿意参与交互。如果为空,则数据库中的任何角色都可以被接受。
  • BroadPhaseRadius:粗筛半径,决定哪些角色在交互中被考虑。只有当所有必要的角色都在这个半径范围内时,交互才有效。
  • BroadPhaseRadiusIncrementOnInteraction:交互期间粗筛半径的增量,让“进入难,保持易”
  • bDisableCollisions:如果为true,系统将禁用交互角色之间的碰撞。
  • TickPriority:具有更高TickPriority的Actor将被选为交互岛(Island)的主Actor,主Actor先Tick,其他交互角色后Tick。TickPriority在你已经强制执行Actor之间的Tick依赖关系时很有用,Motion Matching Interaction系统需要与它们友好地配合。

TickPriority很重要,后续会经常提到。

FInteractionSearchContext

FInteractionSearchContext 可以理解为一次“多角色交互搜索任务”的完整描述,是拿去跑MMI之前的“任务包”。它继承基类 FInteractionSearchContextBase,该基类内部有以下字段: - AnimContexts:参与这次交互搜索的角色上下文列表(谁参与) - PoseHistories:每个参与者对应的姿态历史来源(给 MM 取特征) - Roles:每个参与者在这次搜索中扮演的 role - Database:这次搜索使用的 UPoseSearchDatabase - bDisableCollisions:这次交互若成立,是否要关参与者间碰撞。 然后是子类的字段: - PlayingAsset:上轮/当前在播的资产(弱引用),主要是为了连续性参考? - PlayingAssetAccumulatedTime:该资产当前累计播放时间 - bIsPlayingAssetMirrored:连续性参考资产是否镜像 - PlayingAssetBlendParameters:若是 blendspace,连续性参考参数 - InterruptMode:这次搜索允许/不允许打断的策略 - bIsContinuingInteraction:这次是否被判定为“延续中的同一交互” - TickPriorities:参与者优先级,用于后续选主执行角色/注入 tick 顺序。

Island

Island是MMI的“调度与执行单元”,可以理解为:一组可能互相交互的角色 + 这组角色的搜索上下文与结果缓存。

它解决的核心问题: 1. 把全场搜索划分成按岛屿为单位进行搜索。假设同一帧有100个角色发布交互,两两配对check会十分耗性能。Island 的作用是先按“可能互相交互”分组,再在组内搜索。 2. 用 Tick 栅栏保证跨角色读写 PoseHistory 的线程安全。交互搜索要读取多角色的轨迹/姿态历史(PoseHistory)。如果各角色动画并发跑且没有执行顺序约束,就可能读到未更新或并发写入的数据。Island则保证“先更新需要的运动信息,再搜索,再分发结果”。 3. 统一“谁执行搜索、谁取结果”,避免重复算。Island规定:只有 MainAnimContext(最高TickPriority) 执行全量搜索,其它角色直接读缓存。

然后来看看 FInteractionIsland 主体字段: 1. PreTickFunction / PostTickFunction:含义:插入 tick 链路前后,形成并发栅栏。Pre 里先准备轨迹,Post 用于结束该阶段并约束后续并发。 2. bHasTickDependencies:当前 island 是否已成功注入 tick 依赖,用于避免重复注入或在未注入时误以为线程安全成立。 3. TickActorComponents :参与这个 island 的关键 tick 组件(通常是能代表该角色动画/更新时序的组件)。建立 Pre/PostTickFunction 与角色组件之间的前后依赖关系。 4. IslandAnimContexts:这个岛里全部角色上下文(AnimInstance/AnimNextComponent 等)。用于定义“岛成员全集”,每个 SearchContext 只会用它的子集。 5. SearchContexts:本帧要尝试的候选交互组合集合。 6. SearchResults:候选搜索后的有效结果集合。 7. bSearchPerfomed:这个岛本轮是否已经做过搜索。 8. InteractionSubsystem:反向引用所属子系统。

Island的生命周期

首先每个角色在调用 UPoseSearchInteractionSubsystem::Query_AnyThread 的末尾会将自己的 Availabilities 入队:

1
2
// queuing the availabilities for the next frame Query_AnyThread
AddAvailabilities(Availabilities, AnimContext, PoseHistoryName, PoseHistory);

具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void UPoseSearchInteractionSubsystem::AddAvailabilities(const TArrayView<const FPoseSearchInteractionAvailability> Availabilities, const UObject* AnimContext, FName PoseHistoryName, const UE::PoseSearch::IPoseHistory* PoseHistory)
{
using namespace UE::PoseSearch;

check(AnimContext && AnimContext->GetWorld() && AnimContext->GetWorld() == GetWorld());

const int32 ReservedAnimContextsAvailabilitiesIndex = AnimContextsAvailabilitiesIndex.Add(1);

if (ReservedAnimContextsAvailabilitiesIndex < AnimContextsAvailabilitiesBuffer.Num())
{
FPoseSearchInteractionAnimContextAvailabilities& AnimContextAvailabilities = AnimContextsAvailabilitiesBuffer[ReservedAnimContextsAvailabilitiesIndex];
AnimContextAvailabilities.AnimContext = AnimContext;
AnimContextAvailabilities.Availabilities.Reset();
for (const FPoseSearchInteractionAvailability& Availability : Availabilities)
{
AnimContextAvailabilities.Availabilities.AddDefaulted_GetRef().Init(Availability, PoseHistoryName, PoseHistory);
}
}
}

考虑到 Query_AnyThread 可能在任意线程并行调用,AddAvailabilities 里通过 AnimContextsAvailabilitiesIndexAnimContextsAvailabilitiesBuffer 实现了线程安全的入队。注意这时候还没建立Island,只是将所有 Availability 请求收集起来,存在 AnimContextsAvailabilitiesBuffer 里。

真正建立岛屿的入口在 UPoseSearchInteractionSubsystem::Tick

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void UPoseSearchInteractionSubsystem::Tick(float DeltaSeconds)
{
using namespace UE::PoseSearch;

QUICK_SCOPE_CYCLE_COUNTER(STAT_UPoseSearchInteractionSubsystem_Tick);

check(IsInGameThread());

Super::Tick(DeltaSeconds);

FMemMark Mark(FMemStack::Get());
UpdateValidInteractionSearches();

if (ConsolidateAnimContextsAvailabilities())
{
RegenerateAllIslands(DeltaSeconds);

#if DO_CHECK
check(ValidateAllIslands());
#endif
}

AnimContextsAvailabilitiesIndex.Reset();
}

这里第一个函数是 UpdateValidInteractionSearches。它作用是维护“交互状态副作用”,主要是碰撞开关和“是否持续交互”的语义。如果不做生命周期,你会丢两件事:不知道何时该调用 OnInteractionStart 去关碰撞;不知道何时该调用 OnInteractionEnd 去恢复碰撞。

它做的则是根据上一帧的搜索结果以及上一帧得到的Island来对每个Search结果做这么一个状态生命周期判断。主要有三种情况: 1. 新交互出现(但是是针对上一帧的新交互?):调用 OnInteractionStart,这时候可能交互双方会关闭碰撞。 2. 交互持续:调用 OnInteractionContinuing, 不重复执行start,不误执行end。 3. 交互结束(即便是针对上一帧,但肯定结束了):调用 OnInteractionEnd,这时候可能交互双方会恢复碰撞。

然后调用第二个函数 ConsolidateAnimContextsAvailabilities,返回一个bool值。

首先函数需要记录该帧需要处理的Availabilities写入请求数量:

1
2
3
4
5
6
7
8
9
10
11
12
const int32 AnimContextsAvailabilitiesIndexValue = AnimContextsAvailabilitiesIndex.GetValue();
AnimContextsAvailabilitiesNum = AnimContextsAvailabilitiesBuffer.Num();
// @todo: add some setttings to initialize AnimContextsAvailabilitiesBuffer to a big enough value
if (AnimContextsAvailabilitiesIndexValue > AnimContextsAvailabilitiesNum)
{
UE_LOG(LogPoseSearch, Error, TEXT("UPoseSearchInteractionSubsystem::ConsolidateAnimContextsAvailabilities not enough space to add more availabilities locklessly. It'll be adjusted automatically, but some availability requests has been lost this frame [capacity %d / requests %d]"), AnimContextsAvailabilitiesNum, AnimContextsAvailabilitiesIndexValue);
AnimContextsAvailabilitiesBuffer.SetNum(AnimContextsAvailabilitiesIndexValue);
}
else
{
AnimContextsAvailabilitiesNum = AnimContextsAvailabilitiesIndexValue;
}

AnimContextsAvailabilitiesIndexValue 是本轮(上一帧) AddAvailabilities() 被调用的总次数。本轮有效长度为 AnimContextsAvailabilitiesNum,如果低于 AnimContextsAvailabilitiesIndexValue,说明有请求被丢了(因为 buffer 不够大?),会自动扩容。

然后对Buffer按照 AnimContext 指针地址排序:

1
2
3
4
5
6
// consolidating AnimContextsAvailabilities sharing the same AnimInstance
TArrayView<FPoseSearchInteractionAnimContextAvailabilities> AnimContextsAvailabilities = MakeArrayVie(AnimContextsAvailabilitiesBuffer.GetData(), AnimContextsAvailabilitiesNum);
AnimContextsAvailabilities.Sort([](const FPoseSearchInteractionAnimContextAvailabilities&AnimContextAvailabilitiesA, const FPoseSearchInteractionAnimContextAvailabilities& AnimContextAvailabilitiesB)
{
return AnimContextAvailabilitiesA.AnimContext < AnimContextAvailabilitiesB.AnimContext;
});
这个主要是为了归并。可以理解为我们在算法竞赛中常做的离散化的去重步骤,但是事实上有合并的地方(两个相同AnimContext有不同的Availability):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int32 WriteIndex = 0;
for (int32 ReadIndex = 1; ReadIndex < AnimContextsAvailabilitiesNum; ++ReadIndex)
{
// Avoiding adding trivial duplicates. FPoseSearchInteractionAvailabilityEx could not be fully specified to understand if it's an actual duplicate in case the pose
// history is passed by name or the Availability.Database is null and supposed to be resolved using other availabilities Database(s) with the same Availability.Tag.
// The duplicated availabilities are excluded when creating the combinations of possible interactions during FAnimContextInfoVisitor when
// FRoledAnimContextInfos.AddRoledAnimContextInfos calls FRoledAnimContextInfos.AddUnique
if (AnimContextsAvailabilities[WriteIndex].AnimContext == AnimContextsAvailabilities[ReadIndex].AnimContext)
{
for (FPoseSearchInteractionAvailabilityEx& AvailabilityEx : AnimContextsAvailabilities[ReadIndex].Availabilities)
{
AnimContextsAvailabilities[WriteIndex].Availabilities.AddUnique(AvailabilityEx);
}
}
else
{
++WriteIndex;
if (WriteIndex != ReadIndex)
{
AnimContextsAvailabilities[WriteIndex] = AnimContextsAvailabilities[ReadIndex];
}
}
}

AnimContextsAvailabilitiesNum = WriteIndex + 1;

所以整个函数做的就是将Buffer中的Availabilities再整理了一遍,保证只有独立的 AnimContext,以及包含了所有输入的 Availability(不丢失的情况下)。如果有至少一个元素则返回 true,然后调用 RegenerateAllIslands 来重建岛屿。

RegenerateAllIslands 第一步是调用 GenerateSearchContexts 生成所有候选的SearchContext。

GenerateSearchContexts 首先会调用 GenerateAnimContextInfosAndTagToDatabases 生成两个信息: - AnimContextInfos:本质是个无向邻接图,它单纯的维护不同 AnimContext 之间是否能够交互的联通关系,没有所谓的边信息(交互数据库,Role等) - TagToDatabases:形如 TagToDatabases["MC_INTERACT"] = [DB_Greet, DB_Trade] 的索引。

然后函数会遍历 AnimContextInfos,把角色分成连通块。这个过程首先通过 FAnimContextInfoVisitor 里的lambda回调 OnNewAnimContextFound 实现,然后第二个lambda回调 OnDoneGroupingAnimContexts 真正得出我们要的SearchContext。