一个 PoseSearch Database 里塞了 200 段动画、15 万个采样帧。每帧游戏循环,引擎要从这 15 万个候选中挑出"最像当前运动状态"的那一帧——而且在 PCAKDTree 模式下必须压到 0.3ms 量级。
这不是魔法。这是一套精密的分层搜索管线:Feature Channel 构建特征向量 → PCA 降维 → KDTree/VPTree 空间索引 → Continuing Pose 成本函数保证连续性。Epic 在 PoseSearch 插件里塞了 53,925 行 C++(Runtime 38,955 + Editor 14,970,UE 5.8 实测),我们今天把它一层层拆开。
一、PoseSearch 不是什么
先说清楚 PoseSearch 在整个动画系统里的位置。
它不是 Animation Blueprint 的替代品。它不是状态机。它甚至不直接驱动骨骼——PoseSearch 只做一件事:给定当前角色的运动状态(位置、速度、朝向、骨骼姿态),从动画数据库中找出最匹配的那一帧,返回一个 Pose Index 和 Asset Time。
拿到这个结果之后,AnimNode_MotionMatching 负责把它喂给动画蓝图,AnimBlueprint 里的 Blend Stack 负责做混合过渡。PoseSearch 是"搜索引擎",不是"播放器"。
这个分工在源码里非常清晰。UPoseSearchDatabase::Search()(PoseSearchDatabase.h:690):
UE_API void Search(UE::PoseSearch::FSearchContext& SearchContext,
UE::PoseSearch::FSearchResults& SearchResults) const;
// 紧接着还有一个(标为 Experimental):
UE_API void SearchContinuingPose(UE::PoseSearch::FSearchContext&,
UE::PoseSearch::FSearchResults&) const;
输入是一个 FSearchContext(包含当前查询特征向量、Continuing Pose 信息、事件等),输出是一个 FSearchResults。没有骨骼操作,没有动画播放——纯粹的搜索。注意 SearchContinuingPose 在源码注释里被 Epic 标为 Experimental,与主 Search() 一起构成完整搜索但语义仍在演进。
二、特征向量:把"运动状态"翻译成数字
PoseSearch 的核心问题可以简化成一句话:怎么把"角色正在向前跑、速度 5m/s、右脚即将落地"这个状态,表示成一个可比较的浮点数向量?
答案在 UPoseSearchSchema 和它的 Feature Channels 里。
Schema:特征向量的蓝图
// PoseSearchSchema.h
class UPoseSearchSchema : public UDataAsset
{
// ...
int32 SchemaCardinality = 0; // 特征向量的总维度
// ...
};
SchemaCardinality 是特征向量的总维度。一个典型的 locomotion 数据库可能有 30–80 维——每个维度对应一个 Feature Channel 贡献的浮点值。
Schema 通过 IterateChannels 递归遍历所有 Channel(包括嵌套的 Group Channel):
void IterateChannels(IteratePredicateType IteratePredicate) const
{
IterateChannelsRecursive(GetChannels(), IteratePredicate);
}
Trajectory Channel:运动匹配的灵魂
最重要的 Feature Channel 是 UPoseSearchFeatureChannel_Trajectory——轨迹通道。它定义了"我们要匹配运动的哪些方面"(PoseSearchFeatureChannel_Trajectory.h:28):
USTRUCT()
struct FPoseSearchTrajectorySample
{
GENERATED_BODY()
// Offset 秒后的轨迹数据会被用来匹配
// 例如 Offset = 0.5s, Flags = Position → 匹配未来 0.5 秒的位置
UPROPERTY(EditAnywhere, Category = Config)
float Offset = 0.f;
// 匹配什么:Velocity / Position / VelocityDirection / FacingDirection
// 还有对应的 XY 变体(去掉 Z 分量)
UPROPERTY(EditAnywhere, meta = (Bitmask, ...))
int32 Flags = int32(EPoseSearchTrajectoryFlags::Position);
#if WITH_EDITORONLY_DATA
UPROPERTY(EditAnywhere, Category = Config)
float Weight = 1.f;
// 同 class、同 cardinality、同 NormalizationGroup 的会一起做归一化
UPROPERTY(EditAnywhere, Category = Config)
FName NormalizationGroup;
UPROPERTY(EditAnywhere, Category = Config, meta = (ExcludeFromHash, DisplayPriority = 0))
FLinearColor DebugColor = FLinearColor::Blue;
#endif // WITH_EDITORONLY_DATA
};
注意 Weight / NormalizationGroup / DebugColor 都在 WITH_EDITORONLY_DATA 里——归一化和权重信息在编辑器里配置,构建阶段"烘"进 SearchIndex 之后,运行时数据结构里不再有这几个字段。
EPoseSearchTrajectoryFlags 的完整定义值得注意:
enum class EPoseSearchTrajectoryFlags : uint32
{
Velocity = 1 << 0,
Position = 1 << 1,
VelocityDirection = 1 << 2,
FacingDirection = 1 << 3,
VelocityXY = 1 << 4,
PositionXY = 1 << 5,
VelocityDirectionXY = 1 << 6,
FacingDirectionXY = 1 << 7,
};
每个 "非 XY" 版本是 3D(贡献 3 维 X/Y/Z),每个 XY 变体是 2D(贡献 2 维,源码里通过 EComponentStrippingVector::StripZ 剥掉 Z)。
关键但容易漏的细节:UE 5.8 里 UPoseSearchFeatureChannel_Trajectory 的默认构造函数(PoseSearchFeatureChannel_Trajectory.cpp 里的 AddDefaultChannels)默认加的四组样本,全部使用 XY 变体:
Samples.Add({ -0.4f, PositionXY, Weight=0.4 });
Samples.Add({ 0.0f, VelocityXY | FacingDirectionXY, Weight=2.0 });
Samples.Add({ 0.35f, PositionXY | FacingDirectionXY, Weight=0.7 });
Samples.Add({ 0.7f, VelocityXY | PositionXY | FacingDirectionXY, Weight=0.5 });
也就是说:默认的 Locomotion 匹配是平面 (XY) 的,垂直分量不参与。如果你的角色有跳跃、蹲下、斜坡爬升等需要匹配 Z 的动作,得在 Schema 里手动改用 Position / Velocity 3D flag。
除了 Trajectory,Runtime 里还有独立的 UPoseSearchFeatureChannel_Position / _Velocity / _Heading / _Phase / _Distance / _Curve / _PermutationTime / _TimeToEvent / _Padding / _FilterCrashingLegs / _Pose / _Group 等 Channel(对应文件都在 Source/Runtime/Public/PoseSearch/PoseSearchFeatureChannel_*.h)。所有 Channel 的输出拼接起来,就是完整的特征向量。
三、Search Index:数据库的"编译产物"
特征向量建好后,下一步是把数据库中所有动画的所有采样帧都"编译"成可搜索的索引。这就是 FSearchIndex。
基础层:FSearchIndexBase
// PoseSearchIndex.h:504
struct FSearchIndexBase
{
TAlignedArray<float> Values; // 所有帧的特征向量(扁平存储)
FSparsePoseMultiMap<int32> ValuesVectorToPoseIndexes; // 向量→帧索引映射(去重后一对多)
TAlignedArray<FPoseMetadata> PoseMetadata; // 每帧的元数据
bool bAnyBlockTransition = false; // 快速判断:这个索引里有没有过渡帧
TAlignedArray<FSearchIndexAsset> Assets; // 动画资产列表
// Experimental, this feature might be removed without warning
FEventData EventData; // 事件搜索所需数据
float MinCostAddend = -MAX_FLT; // 最小成本附加值,短路搜索用
#if WITH_EDITORONLY_DATA
FSearchStats Stats; // 编辑器统计信息
#endif
};
Values 是一个巨大的扁平数组。如果数据库有 15 万个采样帧、每帧 40 维特征向量(一个中等规模的 locomotion database 典型值),那 Values 就是 600 万个 float——约 24MB。扁平数组不是可选项:搜索时要按帧顺序连续访问相邻帧的数据,Eigen 也需要连续内存才能发挥 SIMD。数组用 TAlignedArray 保证 16/32 字节对齐,直接对齐到 SSE/AVX 加载边界。
FPoseMetadata(PoseSearchIndex.h:20)记录每帧的关键信息:
struct FPoseMetadata
{
private:
enum { ValueOffsetNumBits = 27 };
enum { AssetIndexNumBits = 20 };
enum { BlockTransitionNumBits = 1 };
uint32 ValueOffset : ValueOffsetNumBits = 0; // 在 Values 数组里的偏移
uint32 AssetIndex : AssetIndexNumBits = 0; // 所属动画资产索引
bool bBlockTransition : BlockTransitionNumBits = 0; // 是否为段落过渡帧
FFloat16 CostAddend = 0.f; // 半精度存储的固定成本附加
// ...
};
三个位宽细节值得注意:ValueOffset 只有 27 bits(1.3 亿上限)、AssetIndex 只有 20 bits(1M asset 上限)、CostAddend 用的是 FFloat16 半精度。三个数字加起来正好 48 bits 装进 uint32 + uint16,Metadata 单条 6 字节起(加上 padding 是 8 字节),15 万帧就是 1.2MB,压得极紧。
bBlockTransition 标记很有意思——动画段落边界的帧不应该被 KDTree 搜到(因为跳到一个段落边界会导致动画衔接不自然),它们只参与 Continuing Pose 搜索。
搜索增强层:FSearchIndex
// PoseSearchIndex.h:575
struct FSearchIndex : public FSearchIndexBase
{
// 权重平方根(数值稳定性优化,见下面注释)
TAlignedArray<float> WeightsSqrt;
// PCA 空间的编码;不是所有帧都有 —— Block Transition 帧被剔除,只参与 Continuing Pose 搜索
TAlignedArray<float> PCAValues;
FSparsePoseMultiMap<int32> PCAValuesVectorToPoseIndexes; // PCA 空间的去重映射
TAlignedArray<float> PCAProjectionMatrix;
TAlignedArray<float> Mean;
FKDTree KDTree;
FVPTree VPTree;
#if WITH_EDITORONLY_DATA
TAlignedArray<float> DeviationEditorOnly;
float PCAExplainedVarianceEditorOnly = 0.f; // PCA 保留率诊断
#endif
// ...
};
PCAValuesVectorToPoseIndexes 是 PCA 空间独立的去重映射——和基类的 ValuesVectorToPoseIndexes 是两套并存的去重系统。原始空间和 PCA 空间的"哪些帧特征相同"结果不完全一样(PCA 会把细微差异抹掉,导致 PCA 空间的去重集合更大),所以分别存两份。
PCAExplainedVarianceEditorOnly 是编辑器诊断字段,代表当前 NumberOfPrincipalComponents 保留了多少方差。数据库编辑器 preview 会显示这个数——低于 90% 就该增加主成分数或重新审视 Schema。
注意 WeightsSqrt 的设计细节。源码注释里写得很清楚:
"we store weights square roots to reduce numerical errors when CompareFeatureVectors
((VA - VB) * VW).square().sum()instead of((VA - VB).square() * VW).sum()since(VA - VB).square()could lead to big numbers, and VW being multiplied by the variance of the dataset..."
这是数值稳定性优化。先 (VA - VB) * VW(元素乘)再 square,各项数量级更均匀;反过来先 square 再乘 VW 会放大误差。存 WeightsSqrt 意味着运行时不需要再开根号——这个开根号如果放在热路径里,对搜索延迟是个真实成本。
四、PCA 降维:从高维压缩到低维
15 万个 40 维向量做最近邻搜索,暴力遍历是 15 万 × 40 = 600 万次维度比较,每次比较约 4 FLOP,合计 2400 万 FLOP。桌面 CPU 单核一帧 0.3ms 的预算——BruteForce 打满不够用。
PCA(主成分分析)降维是 PoseSearch 的第一道加速。Epic 把原始特征维度压缩到少数几个主成分,然后用 KDTree 在低维空间做近似搜索。
// PCAValues 存的是 Values 在 PCA 空间的编码
// 不是所有帧都有 PCA 值 —— Block Transition 帧只参与 Continuing Pose 搜索
TAlignedArray<float> PCAValues;
TAlignedArray<float> PCAProjectionMatrix; // 投影矩阵
TAlignedArray<float> Mean; // 均值向量
FKDTree KDTree; // 在 PCA 空间构建的 KD 树
PCA 投影的核心 API:
TConstArrayView<float> PCAProject(
TConstArrayView<float> PoseValues,
TArrayView<float> BufferUsedForProjection) const;
查询时,当前帧的特征向量先减去 Mean、乘以 PCAProjectionMatrix 投影到低维空间,然后在 KDTree 中找 K 个最近邻,最后对这 K 个候选回到原始高维空间做精确成本计算。
关于默认值(很多资料写错的地方):
EPoseSearchMode PoseSearchMode = EPoseSearchMode::PCAKDTree——UE 5.8 默认 PCAKDTreeint32 NumberOfPrincipalComponents = 4——默认只有 4 个主成分,范围 [1, 64]int32 KDTreeMaxLeafSize = 16int32 KDTreeQueryNumNeighbors = 200——默认取 200 个近邻,不是网上流传的 20–40
看到 NumberOfPrincipalComponents = 4 很多人第一反应是"太少了吧?",但结合上面提到默认 Schema Cardinality 常在 30–80,4 个主成分保留 90%+ 方差在 locomotion 类数据上完全可能——Locomotion 帧的方差本来就集中在少数几个方向(前后速度、左右速度、朝向)。要用 PCAExplainedVarianceEditorOnly 实测再决定是不是要加。
另外一个隐藏彩蛋:如果 KDTreeQueryNumNeighbors == 1,注释里说 SearchIndex 会把原始 Values 完全 strip 掉只保留 PCAValues——这是极致内存优化模式,代价是只能得到 PCA 空间的近似最优,永远不做原始空间的精确成本回评。
五、搜索模式:四种策略的分工
UPoseSearchDatabase 支持四种搜索模式,EPoseSearchMode(PoseSearchDatabase.h:30):
enum class EPoseSearchMode : int32
{
// Database searches will be evaluated extensively.
BruteForce,
// Optimized: PCA space kdtree
PCAKDTree,
// Experimental: vantage point tree
VPTree UMETA(DisplayName = "VPTree (Experimental)"),
// Experimental: search will only be performed on events.
EventOnly UMETA(DisplayName = "EventOnly (Experimental)")
};
注意:VPTree 和 EventOnly 都是 Experimental;EventOnly 意味着"这个 Database 只服务事件驱动搜索,不做特征向量搜索"。
模式 1:BruteForce
遍历所有帧,计算完整的特征向量距离。精度最高但最慢。在编辑器里作为 Ground Truth 用于验证其他模式的结果——源码里有一个 a.MotionMatch.Test.KDTreeConstruct 之类的开关,开启后会跑一遍 BruteForce 做对比验证。
模式 2:PCAKDTree(默认)
先 PCA 降维 → KDTree 找 K 近邻 → 对候选集做精确成本计算。核心逻辑在 SearchPCAKDTree()(PoseSearchDatabase.cpp):
用 PCA 投影查询向量 KDTree.FindNeighbors找 K 个最近邻(默认 K=200)通过 PCAValuesVectorToPoseIndexes把去重后的 PCA 向量展开到原始帧对每个候选,用 CompareFeatureVectors在原始空间计算精确成本
模式 3:VPTree(实验性)
Vantage-Point Tree 在高维空间的性能理论上优于 KDTree(KDTree 维度超过 20 后剪枝效率崩溃)。Epic 在注释里明确写 "not for production use",说明还在调优。
VPTree 的实现在 VPTree.h/cpp 中,基于经典 VP Tree 算法。
模式 4:EventOnly(实验性)
当 Database 只服务事件命中的动画(比如"开门"、"起跳"这种特化 Database),可以设为 EventOnly——彻底跳过 KDTree/BruteForce 分支,只走 EventData 命中。
六、成本函数:怎么定义"最像"
所有搜索模式最终都调用同一个成本计算核心——CompareFeatureVectors(PoseSearchIndex.cpp:8):
void CompareFeatureVectors(TConstArrayView<float> A, // 查询向量
TConstArrayView<float> B, // 候选向量
TConstArrayView<float> WeightsSqrt, // 权重平方根
TArrayView<float> Result) // 逐维度的成本
{
check(A.Num() == B.Num() && A.Num() == WeightsSqrt.Num() && A.Num() == Result.Num());
Eigen::Map<const Eigen::ArrayXf> VA(A.GetData(), A.Num());
Eigen::Map<const Eigen::ArrayXf> VB(B.GetData(), B.Num());
Eigen::Map<const Eigen::ArrayXf> VW(WeightsSqrt.GetData(), WeightsSqrt.Num());
Eigen::Map<Eigen::ArrayXf> VR(Result.GetData(), Result.Num());
VR = ((VA - VB) * VW).square();
}
成本 = Σ( (特征差 × sqrt权重)² )。Epic 用 Eigen 而没有手写 SIMD——Eigen 根据编译目标自动选 SSE2 / SSE4 / AVX / AVX2 / NEON,比手写 intrinsics 更好维护。这也解释了为什么 Values 用 TAlignedArray:SIMD 加载需要对齐。
PoseSearchCost.h:20 上 FPoseSearchCost 的完整构造函数是:
FPoseSearchCost(float DissimilarityCost,
float InNotifyCostAddend = 0.f,
float InContinuingPoseCostAddend = 0.f,
float InContinuingInteractionCostAddend = 0.f,
float InContinuingContextInteractionCostAddend = 0.f)
: TotalCost(DissimilarityCost + InNotifyCostAddend
+ InContinuingPoseCostAddend
+ InContinuingInteractionCostAddend
+ InContinuingContextInteractionCostAddend)
#if WITH_EDITORONLY_DATA
, NotifyCostAddend(InNotifyCostAddend)
, ContinuingPoseCostAddend(InContinuingPoseCostAddend)
, ContinuingInteractionCostAddend(InContinuingInteractionCostAddend)
, ContinuingContextInteractionCostAddend(InContinuingContextInteractionCostAddend)
#endif
{ }
关键实现细节:四个 CostAddend 分解字段只有编辑器构建里存,runtime 打包只保留 TotalCost 一个 float。所以 shipping 版本没法在游戏内读到"这个匹配是被哪个 CostAddend 加分了"——想调试要走 PIE / Editor / Rewind Debugger。
DynamicWeightsSqrt 是运行时可变的——UPoseSearchDatabase::CalculateDynamicWeightsSqrt() 从 SearchIndex.WeightsSqrt 初始化,但 Schema 的 Channel 可以通过 IPoseSearchFilter 接口动态修改权重。同一个数据库可以在不同 Gameplay 上下文下产生不同的搜索结果——战斗时更看重脚步,潜行时更看重身体朝向。
七、Continuing Pose:动画连续性的秘密
Motion Matching 最容易被忽视但最关键的设计是 Continuing Pose——当前正在播放的动画帧。
为什么需要它?假设你搜到了"跑动第 37 帧"作为最佳匹配,下一帧你又搜到了"跑动第 82 帧"。如果直接跳过去,动画会"瞬移"。Continuing Pose 的作用是:给当前正在播放的帧一个独立参赛资格,让它以"完整成本 + 一个额外的鼓励项"参与最终排序——鼓励继续沿当前动画播下去,除非有更好的候选把它比下去。
// PoseSearchDatabase.h:691 — 注意方法本身被标为 Experimental
UE_API void SearchContinuingPose(FSearchContext&, FSearchResults&) const;
大致逻辑(PoseSearchDatabase.cpp 中的实现):
从 FSearchContext::GetContinuingPoseSearchResult()拿到"上一次搜索选中的 PoseIdx"用 SearchIndex.GetPoseValues(ContinuingPoseIdx)取该帧特征向量与当前查询向量做 CompareFeatureVectors加上 ContinuingPoseCostAddend(通常是负值,等于鼓励)结果参与 SearchResults排序,标bIsContinuingPoseSearch = true
Block Transition 帧被排除在 KDTree 之外正是配合这个机制——它们只能被 Continuing Pose 搜索选中,避免搜到段落边界导致衔接断裂。
Continuing Pose 管"选谁的连续性"。搜索频率本身有一个独立参数控制:FAnimNode_MotionMatching::SearchThrottleTime(AnimNode_MotionMatching.h:135):
// Minimum amount of time to wait between searching for a new pose segment.
// default for locomotion is searching every update, but you may only want
// to search once for other situations, like jump.
UPROPERTY(EditAnywhere, Category = Settings, meta = (PinHiddenByDefault, ClampMin="0"))
float SearchThrottleTime = 0.f;
默认是 0——每帧都搜。这和"Motion Matching 靠节流控制性能"的普遍印象不符:Epic 的默认策略是每帧都搜,靠 Continuing Pose 的成本鼓励保证不乱跳;只有 jump 之类特殊 case 才把 Throttle 调大到 0.2s 左右锁定一段动画不允许中途换。
八、性能管线全景
把上面的所有组件串起来,一次完整的 PoseSearch 查询流程是:
构建查询向量: FSearchContext::GetOrBuildQuery(Schema)从当前角色的运动状态构建特征向量事件搜索前置:如果 EventData 有效,先尝试事件命中 Continuing Pose 评估:单独计算当前播放帧的成本,加入结果集 PCA 投影: (查询向量 - Mean) * PCAProjectionMatrix→ 低维向量KDTree 搜索:在 PCA 空间找 KDTreeQueryNumNeighbors(默认 200)个最近邻去重展开:通过 PCAValuesVectorToPoseIndexes把去重后的向量映射回原始帧索引精确成本计算:对每个候选帧,在原始空间跑 CompareFeatureVectors结果排序:按 TotalCost排序,选出最佳匹配
性能参考(默认参数、PCAKDTree 模式、Lyra 数据规模):
数据库规模:100–300 段动画,5–20 万帧 Schema Cardinality:30–80 维 PCA 主成分数( NumberOfPrincipalComponents):默认 4,可调KNN 数( KDTreeQueryNumNeighbors):默认 200单次搜索耗时(桌面 i7/Ryzen):0.1–0.5ms
九、三个你可能没注意到的设计细节
1. 去重是两层的
PoseSearch 有两套独立的去重机制:
PruneDuplicateValues→ 在原始特征空间去重,映射存FSearchIndexBase::ValuesVectorToPoseIndexesPruneDuplicatePCAValues→ 在 PCA 空间独立去重,映射存FSearchIndex::PCAValuesVectorToPoseIndexes
同一段"跑步循环"里第 12 帧和第 13 帧在原始空间不同(骨骼相位差一点),但 PCA 投影到 4 维后可能被压成同一个点。所以 PCA 空间的去重集合往往比原始空间更大,两套映射不能合并共用。
2. Block Transition 帧不参与空间搜索
动画段落边界的帧被标记为 bBlockTransition = true。这些帧的 PCA 值会被 PrunePCAValuesFromBlockTransitionPoses 移除——它们只参与 Continuing Pose 搜索。这避免了搜索跳到段落边界导致动画衔接断裂。FSearchIndexBase::bAnyBlockTransition 是快速判断标志。
3. 权重是动态的
DynamicWeightsSqrt 不是编译时固定的。UPoseSearchDatabase::CalculateDynamicWeightsSqrt() 从 SearchIndex.WeightsSqrt 初始化,但 Schema 的 Channel 可以在运行时通过 IPoseSearchFilter 接口动态修改权重。这意味着同一个数据库可以在不同 Gameplay 上下文中产生不同的搜索结果——战斗时更看重脚步,潜行时更看重身体朝向。
十、怎么在自己的项目里调优 PoseSearch
1. 认清默认值
新建 Database 的默认参数在 UE 5.8 是:
PoseSearchMode = PCAKDTree
NumberOfPrincipalComponents = 4 // 别下意识调到 20+
KDTreeMaxLeafSize = 16
KDTreeQueryNumNeighbors = 200 // 默认已经很大
先跑默认,看 Cost Breakdown 有没有问题,再调。
2. 调 Trajectory Sample 的 Offset 和 Weight
默认配置全是 XY 变体。如果你的角色需要匹配跳跃、下蹲、Z 轴速度,必须手动把 XY flags 改成 3D 版本(去掉 XY 后缀)。这是从默认 Locomotion 走向"完整 3D matching"的最重要一步。
根据角色移动特性调 Weight:
快速角色(赛车/飞行):增大未来 Offset(0.5–1.0s),减小过去 Offset 慢速角色(潜行):减小所有 Offset,增大当前位置 Weight 频繁转向:增大 FacingDirection 通道的 Weight
3. 用 Console Variable 调试
在 UE 5.8 里实测存在的 CVar(可以在 PoseSearchLibrary.cpp:37、AnimNode_MotionMatching.cpp:40 附近搜到 FAutoConsoleVariableRef 声明):
a.MotionMatch.DrawQuery.Enable 1 # 可视化查询向量
a.MotionMatch.DrawMatch.Enable 1 # 可视化匹配结果
a.AnimNode.MotionMatching.DebugDrawQuery 1 # AnimNode 层的查询绘制
a.AnimNode.MotionMatching.DebugDrawCurResult 1
a.MotionMatch.Test.ForceIndexing 1 # 强制重建 SearchIndex,验证 DDC
a.MotionMatch.Test.KDTreeConstruct 1 # 验证 KDTree 构建
4. 控制数据库规模
不要无脑把所有动画都扔进一个 Database。按运动类型分库(Locomotion / Combat / Interaction),每个库 50–200 段动画。多库搜索时,PoseSearch 会对每个库独立搜索然后合并结果。
十一、PoseSearch 的边界
PoseSearch 不是银弹。它有几个明确的局限:
不处理动画混合——那是 AnimBlueprint 和 Blend Stack 的事 不生成新动画——它只从已有数据库中选,不会像 Motion Matching 的学术论文那样做 Motion Synthesis 数据库质量决定一切——垃圾进垃圾出。动画采样密度不够、Trajectory 配置不合理,搜出来的结果就是"抽搐" 对动画数据格式敏感——镜像动画(Mirrored)、Blend Space 采样、多资产数据库的配置复杂度随规模指数增长
理解这些边界,比理解它能做什么更重要。
以上是原本的核心内容。接下来这几节把水面下的东西挖出来——PCA 到底怎么训练的、Cost 分解怎么读、Interaction Asset 是什么、决策边界在哪。
十二、PCA 是怎么训练出来的:SearchIndex 的"编译期"
前面章节把 PCAProjectionMatrix 和 Mean 当成"给定的数据"用了。但这两个东西不是天上掉下来的——它们是在数据库 Build 阶段计算出来并序列化到 uasset / DDC 里的。
数学骨架
PCA 的标准流程一句话:给一个 N×D 的样本矩阵 X(N 个样本、D 个特征),求协方差矩阵 C = (X-μ)ᵀ(X-μ) / N 的前 k 大特征值对应的特征向量,把它们按列拼起来,就是 D×k 的投影矩阵 P。任意样本 x 的低维编码就是 Pᵀ(x - μ)。
放到 PoseSearch 里,N 是有效帧数(去掉 Block Transition),D 是 SchemaCardinality(例如 40),k 是 NumberOfPrincipalComponents(默认 4)。
源码怎么做
Epic 用 Eigen 的 SelfAdjointEigenSolver 对协方差矩阵做特征分解(PoseSearchDerivedData.cpp:781):
const Eigen::SelfAdjointEigenSolver<ColMajorMatrix> EigenSolver(CovariantMatrix);
ComputationInfo = EigenSolver.info();
if (ComputationInfo == Eigen::Success)
{
// 特征值升序返回,特征向量按列
const ColMajorMatrix EigenVectors = EigenSolver.eigenvectors().real();
// ... 取最大的 k 个
const RowMajorVector EigenValues = EigenSolver.eigenvalues().real();
// ...
const float TotalVariance = EigenValues.sum();
// 保留率 = 累计方差 / 总方差
SearchIndex.PCAExplainedVarianceEditorOnly =
TotalVariance > UE_KINDA_SMALL_NUMBER
? AccumulatedVariance / TotalVariance
: 0.f;
}
三个实现细节:
对称矩阵专用求解器: SelfAdjointEigenSolver而不是通用EigenSolver。协方差矩阵天然对称正半定,对称专用求解器数值更稳、更快。保留率是可诊断的: PCAExplainedVarianceEditorOnly字段直接告诉你"当前 k 保留了多少方差"。Editor 里的 Database Preview 会显示这个数。默认 k=4 在很多 Locomotion 数据上能达到 90%+,这就是 4 这个看似不合理的默认值敢用的底气。信息不够就得加 k:如果保留率 < 90%,要么增加 NumberOfPrincipalComponents,要么重新审视 Schema——是不是有大量高方差 channel 在贡献噪声而不是信号。
什么时候会失效
PCA 假设"重要的方向是方差大的方向"。当你的数据库里 95% 的帧都是"匀速直线跑",而你真正想区分的"急停"、"急转"只占 5% 时,PCA 会把主成分对齐到直线跑的方向,急停和急转就会被压缩到低方差主成分里丢失。解决方案要么给少数派样本手动加权,要么按运动类型分库让每个库的方差分布更均匀。
十三、Cost 分解:Editor Only 的调试通道
PoseSearchCost 的四个 addend 分解字段(NotifyCostAddend / ContinuingPoseCostAddend / ContinuingInteractionCostAddend / ContinuingContextInteractionCostAddend)都在 #if WITH_EDITORONLY_DATA 保护内。这有一个直接后果:Shipping 打包里没有 Cost 分解信息,你在 game 端读 FPoseSearchCost 只有 TotalCost 一个 float。
要读 Cost 分解只有两条路:
PIE / Editor:Database Editor 有 Preview + Cost Breakdown 面板,实时显示每个 channel 的贡献 Rewind Debugger:PoseSearch 在 PoseSearchCost.h:45定义了operator<<序列化,用于 Rewind Debugger 抓取回放数据。回放里能看到分解——但源码注释里 Epic 自己也承认这块设计有点尴尬("when captures come from the game the cost addend breakdowns will be invalid")。
这也说明一个问题:如果你做的是 online 项目、需要跨机器复盘 Motion Matching 决策,PoseSearch 自身不提供这个能力,你得自己在 game 端记录额外元数据。别在生产代码里假设能读到 CostAddend 分解。
读 Cost 分解怎么调 Schema:
如果 Trajectory Position 占总成本 60%+:位置权重太高,会追未来预测过头,脚步匹配质量降。降 Position 采样的 Weight,或者拆一些采样出来 如果所有 Bone Position 加起来占 50%+:脚步/骨骼权重过高,角色会"拧着走"(脚步好但轨迹跟不上) 如果 CostAddend 占大头:说明帧固定成本设得太狠了,等于人为屏蔽了某些帧
十四、Interaction Asset:多角色动作的独立通路
UE 5.4/5.5 引入、5.8 完善的 Interaction 系统——很多资料错误地说它是"新增的 Feature Channel",实际结构不是这样。
组织形式
看 PoseSearchInteractionAsset.h:49:
class UPoseSearchInteractionAsset : public UMultiAnimAsset
它是一个 UMultiAnimAsset(多动画资产),不是 UPoseSearchFeatureChannel_*。这个区别很重要:Interaction 不是塞进单角色特征向量的一个 channel,而是一个可以同时驱动多个 Actor 的复合动画资产——两个人抱在一起打招呼,一个 asset 里就有 Actor A 和 Actor B 的两条动画数据。
配套的 Runtime 支撑:
PoseSearchInteractionSubsystem.h— WorldSubsystem,全局跟踪当前场景里哪些角色正在参与 interactionPoseSearchInteractionIsland.h— 一组正在协作的角色构成 IslandPoseSearchInteractionAvailability.h— 描述"这个角色现在能参加哪些 interaction"PoseSearchInteractionValidator.h— 验证 interaction 组合是否合法
为什么这么设计
传统 Motion Matching 只匹配角色自身状态。但两人交互(拥抱、格挡、抓取)的关键不是"两个角色分别的姿态",而是"两个角色相对位姿是否匹配"。放到独立的 Multi-Anim Asset 里,PoseSearch 可以对整个组合做匹配——搜索出来的结果是"Actor A 该播哪个帧、Actor B 同时该播哪个帧",两条动画在时空上被强绑定。
与 FPoseSearchCost 的关联
第六节 FPoseSearchCost 里那两个"看起来重复"的字段:
ContinuingInteractionCostAddend
ContinuingContextInteractionCostAddend
现在能看懂了:一旦角色进入某个 interaction,就应该倾向于继续与同一个目标交互(ContinuingInteractionCostAddend),也应该倾向于保持当前上下文(ContinuingContextInteractionCostAddend)——比如两个人抱在一起,不能因为 KDTree 找到了一个"更像单人跑步的"帧就把互动打断。
SearchContinuingPose 处理单角色帧连续性,Continuing Interaction 处理多角色 Island 的连续性。两层机制,一个针对帧、一个针对组合。
十五、Chooser 集成与 DDC 缓存
到这里为止都假设"有一个数据库"。实际项目里往往有几十个数据库:徒手 locomotion、持刀 locomotion、受伤 locomotion、隐身 locomotion……
Chooser Table
PoseSearch Runtime 里有独立的 Source/Runtime/Private/Chooser/ 子目录,专门做数据库选择的 Chooser 集成。运行时给一组输入(GameplayTag、bool、数值),Chooser 从上到下匹配第一条命中的行,返回 UPoseSearchDatabase 引用给 AnimNode。
不写 Chooser、直接在 AnimBlueprint 里手动 switch database,是很多团队第一次上 Motion Matching 后放弃的常见原因——上下文条件排列组合很快就会爆。
DDC 与增量构建
PoseSearchDerivedData.cpp 里 PoseSearch 的 SearchIndex 构建走的是标准 UE DDC(Derived Data Cache):
数据库配置发生变化 → 计算新的 DDC Key DDC 命中 → 从缓存拉回序列化的 SearchIndex(PCA 矩阵、KDTree、Values 全在里面) 未命中 → 走 FPoseSearchDatabaseAsyncBuildTask,包括采样动画、特征提取、PCA 训练、KDTree/VPTree 构建
Editor 里保存/编辑 Database 时的漫长等待就是在跑这套。源码里的 a.MotionMatch.Test.Determinism.* 一大堆 CVar 都是保证构建流程跨机器、跨执行确定性的测试开关——你在编辑器里改了权重、alt-tab 一下再切回来,Cook 出来的 SearchIndex 必须字节一致,否则会污染团队共享 DDC。
十六、什么时候不该用 Motion Matching
Motion Matching 是好东西,但不适合所有项目。写在最后作为决策清单。
该用:
3A 品质角色(主角、精英 NPC),需要平滑的方向切换、精确的脚步匹配、可对接 Interaction 系统 有专门动画师能产出高密度、覆盖各种运动状态的素材(100+ 段起步) CPU 有预算容纳 0.1–0.5ms/帧的搜索开销 交付平台是 PC / 主机(PS5、Xbox Series)
不该用(或该谨慎用):
移动端项目——即使 PCA + KDTree 加速,PoseSearch 的内存开销(几十 MB SearchIndex + PCA Values)在中低端安卓上是硬伤 数据库规模上不去(< 50 段动画)——PCA 起不到降维意义,KDTree 优势有限,不如 Blend Space + State Machine 大量小怪 / 群体角色 —— 每个 pawn 都在搜索,成本相加会爆 项目美术资源以少量高质量循环动画为主 —— 传统方案更简单 需要严格的确定性(回放系统、rollback 网络同步)—— PoseSearch 结果依赖浮点计算和多个 CVar,跨机器一致性需要额外工程;何况 CostAddend 分解只在 Editor 里能读
Motion Matching 是把复杂度从"动画蓝图状态机"迁到"数据库设计 + Schema 调参 + Chooser 组织"。这不是"变简单了",是"复杂度换了个地方"。你的团队真正擅长哪一头,决定你该选哪一头。
PoseSearch 是 UE5 动画系统里最"计算机科学"的模块——特征工程(Schema + Channel)、降维(PCA)、空间索引(KDTree/VPTree)、成本函数(CompareFeatureVectors + FPoseSearchCost)、连续性约束(Continuing Pose + Continuing Interaction)、上下文过滤(Chooser + IPoseSearchFilter)、DDC 缓存。每一步都是经典的搜索与推荐系统问题,只是应用场景变成了游戏角色的每一帧运动。
理解它的关键不是记住 API,而是把它放回搜索问题的语境:查询、索引、加速结构、连续性、上下文、缓存。去掉任何一层系统立刻退化——BruteForce 慢、无 PCA 内存爆、无 Continuing Pose 抽搐、无 Chooser 状态机爆炸、无 Interaction 多角色对不齐、无 DDC 编辑器卡死。
我每周拆一个 UE5 底层系统,从源码级讲到可运行的配置。下一篇我们来拆 PoseSearch 的 Interaction Subsystem 内部——UPoseSearchInteractionSubsystem 是怎么把多个角色组织成 Island 并驱动一次匹配的,为什么 UE 5.4 之后把这块提出来做成一等公民。
夜雨聆风