动画运行时讲解 · 写给程序、技术动画师和要做技术选型的人
从 AnimBP
到 UAF
角色每一帧的样子,是几十到几百根骨骼的变换,由动画片段采样、混合、修正之后算出来。Anim Blueprint 把这件事做了十年,Unreal Animation Framework(UAF)要换一种做法:把逻辑拆成能叠起来的 Trait,把求值写成一段程序交给虚拟机跑,并把整件事搬离游戏线程。这份讲解从骨骼和四元数讲起,沿着动画运行时的演进走到 UAF 的内部,最后落到 UE 5.8 里哪些模块现在可以试、怎么试、会踩什么坑。
0.1
问题从哪来:一个两百人的集市
先放下骨骼和四元数,看一个具体的场景。后面每一章都会回到它。
假设你在做一个开放世界,有一座集市。集市里有两类角色:
两百个 NPC。摊贩、路人、巡逻的卫兵。他们要走、跑、转身、停下来交谈,用 Motion Matching 让移动自然。镜头里同时能看到几十个,远处还有上百个在动。他们几乎不播蒙太奇,也不需要网络同步。
一个主角"猎人"。玩家操作。攻击、闪避、处决、攀爬,大量用蒙太奇的分段和分支点;近战位移靠根运动;要支持联机。
| 集市 NPC × 200 | 主角"猎人" × 1 | |
|---|---|---|
| 压力在哪 | 数量:每个角色一份动画实例,游戏线程开销随人数增长 | 复杂度:一张越长越大的 Anim Blueprint,多人同时修改 |
| 选片方式 | Motion Matching + Chooser | 状态机 + 蒙太奇 |
| 位移 | 胶囊体驱动 | 大量根运动 |
| 网络 | 不需要或很少 | 必须 |
| 远处 | 需要更便宜的表示 | 永远在镜头前 |
用 Anim Blueprint 做这两类角色,问题分别出在两个地方。NPC 的问题是量:每个 NPC 都是一个完整的 UAnimInstance,事件图在游戏线程上跑,两百个加起来,游戏线程被动画吃掉一大块。主角的问题是结构:一个蓝图里塞着状态机、几十个蒙太奇插槽、IK、修正和一堆数据读取,谁都不敢动。
UAF 想同时回答这两个问题:把动画搬离游戏线程、让实例变轻,解决量;把逻辑拆成可叠加的小单元,解决结构。但读完全篇你会看到,它目前对 NPC 的帮助远大于对主角的帮助,因为主角依赖的蒙太奇、根运动、网络,恰好是 UAF 最不成熟的部分。第 8 章会把这个结论落到具体的试点方案上。
整份讲解都围绕三个问题:谁描述一帧动画要做的事、谁执行、在哪个线程执行。
场景与角色为本文虚构;ABP 与 UAF 的对比依据见第 2–7 章
集市 NPC × 200
压力在数量:游戏线程开销随人数增长。
主角"猎人"
压力在结构:一张巨大的 ABP,蒙太奇、根运动、联机。
结论预告
UAF 目前帮 NPC 远多于帮主角。
要点
三个问题:谁描述、谁执行、在哪个线程执行。
0.2
一个姿态是怎么混出来的
开场的小人只有十几根骨骼,但它已经包含了动画运行时要做的全部基本动作。每根骨骼存的不是一个位置,而是相对父骨骼的旋转(再加上平移和缩放)。从骨盆出发,沿着骨骼链把这些相对旋转一层层乘下去,才得到每根骨骼在模型里的位置。这一步叫前向运动学(FK)。
把权重拖到 0.5。在"局部空间混旋转"模式下,每根骨骼的角度各取一半,再做 FK,四肢长度始终不变。切到"组件空间直接插位置",同样是各取一半,但取的是关节的最终位置:手腕会从两套姿态的连线中间穿过去,前臂明显变短。这就是动画系统几乎总在局部空间里混合旋转的原因。
再打开上半身遮罩。下半身照常在两套关键帧之间混,上半身换成第三套姿态。每根骨骼有自己的混合权重,这就是分层混合(Layered Blend)。UAF 用 Hierarchy Table(一种按骨骼层级存数据的通用资产)来存这样一份按骨骼的权重表。
一帧动画就是把很多这样的操作按依赖关系串起来:采样几段动画、按权重混合、叠加修正、做 IK,最后写给网格。集市里的每个 NPC,每一帧都要把这串操作做一遍;两百个 NPC 就是两百遍。
示意骨架与关键帧为本文自制,不是引擎数据
局部空间
每根骨骼存相对父骨骼的旋转;FK 一层层乘出最终位置。
在局部空间混旋转
四肢长度不变。直接插最终位置,关节会缩短。
分层混合
每根骨骼一个权重。UAF 用 Hierarchy Table 存这份表。
要点
一帧动画 = 采样 → 混合 → 修正 → 写出。区别只在谁描述、谁执行、在哪个线程执行。
0.3
六个比喻,后面会一直用到
后面的内容会越来越接近源码。先准备六个比喻,读到对应的地方时会再提一次。
乐高积木,对应 Trait。ABP 的一个节点是一整块预制件,Sequence Player 就是 Sequence Player。UAF 把它拆成更小的积木:一块负责播放,一块负责镜像,一块负责平滑,按需要叠在一起,组成一个节点。
点菜单和后厨,对应两次遍历。服务员先走一遍桌子,记下每桌要什么(Update),再把菜单交给后厨按顺序做(Evaluate 之后的 VM)。记菜单的人不碰锅,做菜的人不用跑桌子。
计算器的栈,对应求值 VM。老式逆波兰计算器先压两个数,再按"+"把它们弹出来、压回结果。UAF 的求值 VM 压的不是数,而是整套骨骼姿态(关键帧),"+"换成了"按权重混合"。
快照照片,对应变量绑定。动画线程不直接翻游戏世界的抽屉,而是在开工前拿到一张照片:速度、加速度、是否在空中。照片拍完,世界怎么变都不影响这一帧的计算。
插播广告,对应 Injection。电视台不用重排节目表,到了插播点就把一段广告接进来,播完再切回。UAF 没有蒙太奇,临时插一段动画靠的是图里预留的"插播点"。
预制菜,对应图工厂。同一道菜每次都从头备料太慢,就提前做成半成品。UAF 能从一个动画资产直接"做"出一张小图,下次同样的需求直接复用。
比喻只为建立直觉,机制在对应小节展开
乐高积木
对应 Trait:节点拆成可叠加的小块。
点菜单和后厨
对应 Update 与 Evaluate 两次遍历。
计算器的栈
对应求值 VM:栈里放的是整套姿态。
快照照片
对应变量绑定:动画线程只看照片。
插播广告
对应 Injection:没有蒙太奇后的插入方式。
预制菜
对应图工厂:从资产直接生成小图。
要点
现在不用全懂,读到对应小节时会再提一次。
0.4
十六个会反复出现的词
这张表是查阅用的。最容易混的一组是Trait和节点:在 UAF 里,节点只是一个容器,里面叠着若干 Trait;真正干活的是 Trait。
- 姿态 Pose
- 一组骨骼变换。UAF 里常叫关键帧(Keyframe)
- 局部 / 组件空间
- 相对父骨骼 / 相对整个网格的根
- Anim Blueprint(ABP)
- UE4 起的动画蓝图:事件图 + 动画图
- UAF / AnimNext
- 新动画框架。AnimNext 是它在源码里的旧名
- Workspace
- 把多个 UAF 资产放在一个编辑器里编辑的容器
- System
- 每帧要做的事的总脚本,挂在组件上运行。旧版叫 Module
- Anim Graph
- 描述姿态怎么算的图,由 Trait Stack 节点组成
- Trait
- 最小的动画逻辑单元:播放、混合、镜像、平滑……
- Trait Stack
- 一个节点里叠在一起的若干 Trait
- Trait 接口
- Trait 对外提供的能力,例如 IUpdate、IEvaluate
- 求值程序
- Evaluate 遍历产出的一串任务,交给 VM 执行
- 求值 VM
- 执行任务的栈机,栈里放的是姿态
- RigVM
- Control Rig 底下的执行虚拟机,UAF 的 System 和图都跑在上面
- 变量绑定
- 把游戏数据按值送进 UAF 的机制
- Injection
- 往运行中的图里临时插入一段动画或一个修饰
- 惯性化
- 切换动画时不同时算两段,只把差值衰减掉
类名以 UE 5.8 的 API 参考为准;Module 改名为 System 见 UUAFSystem 的显示名
姿态 / 关键帧
一组骨骼变换
System · Anim Graph
每帧脚本 · 姿态图
Trait · Trait Stack
最小单元 · 叠在一个节点里
求值程序 · 求值 VM
任务清单 · 以姿态为元素的栈机
变量绑定
按值送进动画线程
Injection · 惯性化
临时插入 · 只衰减差值
要点
节点只是容器,干活的是 Trait。
0.5
怎么读这份讲解
全篇九章加两个附录,分成 8 个页签。每个页签是一次可以坐下来读完的量。
先打动画数学的底,再看 ABP 走到了哪一步,然后进 UAF 的内部。后面的取舍都靠前面解释。
四元数与惯性化,UAF 的资产、Trait 与两次遍历,求值 VM 与调度,最后是代码地图和动手实现。
混合、同步与过渡,Workspace 和 System 怎么搭,各功能模块与第 7 章的控制面,以及准入表和排错。
开场与混合基础,然后直接读第 7 章:遮罩怎么画、人群的距离和数量档位怎么设、哪些参数归你调。
先读两次遍历和求值 VM,再跟着附录 B 的六个里程碑写一个迷你框架。
ABP 的结构性问题、官方的现状说法、按模块准入表和版本路线。
开场的"两百人集市"是贯穿全篇的例子:第 3 章给 NPC 搭 System,第 5 章跟踪它的一帧,第 8 章分别给 NPC 和主角做准入决定。
页签会记住你读到哪里。数字键 1–8 切换页签,地址后缀 #t3 可以直接分享某个页签。右上角的演示把同样内容切成一页一屏的幻灯片,← → 翻页,N 显示讲稿。按 T 打开目录。
UAF 还是实验性功能,资料以 API 参考和社区文章为主,所以每节末尾都标了出处。证据分三档:官方(API 参考、版本说明、Epic 员工的公开回复)可以直接引用;社区(源码阅读笔记、教程)结构可靠,细节可能随版本变;推断是本文按结构整理或用背景知识补的,对外讲之前请自己验证。附录 A 的最后一节列了完整的参考资料。
路线划分是建议,按听众调整
完整课程
8 个页签,按顺序。
程序向
页签 1、3、4、7、8
技术动画师
页签 1、3、5、6
想自己实现
页签 1、4、8
决策向
页签 2、6
证据分三档
官方 · 社区 · 推断。UAF 仍在变,对外讲之前先核对版本。
01
动画数学基础
动画运行时做的事,归结起来是在正确的空间里,用正确的方式,把几套姿态按权重合成一套。
开场的小人已经暴露了三个问题:混合要在哪个空间做,旋转怎么插值,切换动画时怎么不跳。这一章把它们讲清楚,再加上两个运行时绕不开的话题:多段动画的时间怎么对齐,以及角色是被动画推着走,还是带着动画走。
这些都不是 UAF 特有的。但 UAF 的求值任务清单里,每一种任务都对应这里的一个操作,读完这一章,第 5 章的任务名会一看就懂。
1.1
姿态:一组骨骼变换,三种坐标空间
一个骨骼网格有一棵骨骼树。每根骨骼存一个变换:旋转、平移、缩放。动画片段在每个关键帧上给每根骨骼一组这样的变换,运行时按时间采样,得到这一帧的姿态。
同一个变换可以放在三种空间里表达:
局部空间:相对父骨骼。动画数据就是这么存的,混合也在这里做。组件空间:相对网格的根,也叫模型空间。IK、瞄准、脚步贴地这类"要知道手在哪儿"的操作需要它。世界空间:再乘上组件在世界里的变换,物理和碰撞用它。
从局部到组件空间要沿骨骼链一路相乘,代价和骨骼数成正比,反过来也一样。所以运行时会尽量在一个空间里把能做的事做完,再统一转换一次。UAF 的求值任务里有专门的 ConvertRotationsToMeshSpace 和 ConvertRotationsToLocalSpace,就是这一步。
相对父骨骼。动画数据、混合、加法都在这里
相对网格根。IK、瞄准、贴地
物理、碰撞、挂点
还有一个容易忽略的细节:不是每一帧都要算所有骨骼。远处的角色可以只算几十根主干骨骼,手指和面部全部跳过。这份"这一帧要算哪些骨骼"的清单,在 ABP 里叫 Required Bones,在 UAF 里体现为 LOD 姿态和骨骼映射(第 5.5 节)。
空间划分是通用动画知识;任务名见 UAFAnimGraph API 参考中的 FAnimNextConvertRotationsTo*Task
局部空间
动画数据和混合都在这里。
组件空间
IK、瞄准、贴地需要它;转换要沿骨骼链走一遍。
世界空间
物理、碰撞、挂点。
要点
运行时尽量在一个空间里把事做完,再统一转换。UAF 有专门的转换任务。
1.2
旋转怎么混:四元数、Slerp 与 NLerp
欧拉角(俯仰、偏航、滚转三个角度)直观,但不能直接按分量插值:同一个朝向有多种写法,中途还可能遇到万向锁,两个相近的朝向插出来可能绕一大圈。
动画系统用四元数表示旋转。单位四元数是四维单位球面上的一个点,两个旋转之间的"最短路径"就是球面上的一段大圆弧。
Slerp(q₀, q₁, t) = q₀ · sin((1−t)θ) / sin θ + q₁ · sin(tθ) / sin θθ 是两个四元数的夹角。沿大圆弧匀速走,结果天然是单位长度
NLerp(q₀, q₁, t) = normalize((1−t)·q₀ + t·q₁)先按分量线性插值,再归一化。路径相同,只是速度不完全匀速
实时动画几乎都用 NLerp:它便宜,可以对任意多个姿态做加权和后统一归一化,而且在两个姿态夹角不大时,和 Slerp 的差别看不出来。代价是一条约束:加权求和之前,要先保证所有四元数在同一个半球。q 和 −q 表示同一个旋转,如果一个取了 q、另一个取了 −q,加起来会互相抵消,骨骼会突然翻转。实现里会先看点积,小于零就把其中一个取反。
这也解释了为什么 UAF 的任务表里有一个单独的 NormalizeKeyframeRotations:多次"加权累加"之后,统一做一次归一化,而不是每一步都做。
四元数与 NLerp 为通用知识;任务名见 FAnimNextNormalizeKeyframeRotationsTask
欧拉角不能直接插
多种写法、万向锁、可能绕远路。
Slerp
沿大圆弧匀速,贵。
NLerp
加权求和再归一化,便宜,可以一次混多个。
要点
先把四元数翻到同一半球,再累加,最后统一归一化。
1.3
加法动画与骨骼遮罩
加法动画存的不是姿态,而是相对某个参考姿态的差。比如"呼吸":用呼吸动画的每一帧减去它的第一帧,得到一组小小的偏移,可以叠到任意站姿、跑姿上。
Additive = Pose_anim ⊖ Pose_refResult = Base ⊕ α · Additive旋转的"减"是乘以逆四元数,"加"是相乘;α 缩放这份偏移
加法有局部空间和网格空间两种。局部空间加法把偏移加在每根骨骼自己的旋转上;网格空间加法把偏移加在组件空间的朝向上,适合"整个上身往左倾"这种不受下身朝向影响的修正。UAF 的 Apply Additive Trait 两种都支持,还有一个按骨骼遮罩的版本。
骨骼遮罩就是开场的"上半身遮罩":每根骨骼一个 0 到 1 的权重。ABP 里它叫 Blend Profile 或 Layered Blend per Bone 的设置;UAF 用 Hierarchy Table 存它,这是一个通用的"按层级存数据"的资产,Blend Mask 只是它的一种用法。
还有一种特殊的加法叫动态加法:不预先存差值,而是运行时拿两个当前姿态相减。UAF 里有 Make Dynamic Additive Trait 和对应的任务,可以把一段普通动画当成加法用。
UAFAnimGraph API:FAnimNextApplyAdditiveTraitSharedData、FApplyAdditivePerBoneTraitSharedData、FMakeDynamicAdditiveTraitSharedData;Hierarchy Table 用作 BlendMask 见 RemRemRemRe 的 5.6 笔记
加法 = 差值
相对参考姿态的偏移,可叠到任意姿态上。
局部 / 网格空间
网格空间适合不随下身朝向变的修正。
Hierarchy Table
UAF 用它存按骨骼的遮罩权重。
要点
动态加法:运行时用两个当前姿态相减。
1.4
时间:同步组与同步标记
把走路和跑步按 0.5 混合,如果两段动画各自按自己的时间播放,会出现左脚还在走路的第 0.3 秒、右脚已经是跑步的第 0.1 秒,画面上就是一顿乱踩。
同步组的做法是选一个领头(通常是当前权重最大的那段),其他成员不按自己的时间走,而是按"归一化进度"跟随:领头走到 40%,大家都在 40%。走路一个周期 1.2 秒、跑步 0.8 秒也没关系,它们会被同时拉伸或压缩。
只按百分比对齐还不够,因为两段动画的左脚落地点不一定都在 0%。同步标记在动画上打"左脚落地""右脚落地"这样的标签,同步时按标记区间对齐,领头处在"左脚落地到右脚落地的 30%",跟随者也被拉到它自己那一段的 30%。
UAF 把这件事拆成了一个独立的 Trait(Trait 是 UAF 里最小的逻辑单元,第 4 章细讲):Synchronize Using Groups,按组名把时间线同步起来。时间线本身由 ITimeline 接口(Trait 之间协作用的接口之一,第 4.5 节)暴露,每个能报告进度的 Trait(比如 Sequence Player)都实现它,同步 Trait 通过接口读写,不需要知道下面挂的是什么。这是"组合"思路的一个典型例子。
同步组与同步标记为 UE 通用机制;UAF 对应 FAnimNextSynchronizeUsingGroupsTraitSharedData、ITimeline、IGroupSynchronization、FTimelineSyncMarker
问题
两段不同周期的循环按权重混,脚步会乱。
同步组
跟随领头的归一化进度。
同步标记
按"左脚落地"这类标签分段对齐。
要点
UAF 把同步做成独立 Trait,通过 ITimeline 接口读写时间线。
1.5
过渡:交叉淡化、惯性化与 Dead Blending
从一段动画切到另一段,最朴素的做法是交叉淡化:在过渡时间里两段都算,权重从 1:0 渐变到 0:1。它的问题有两个。第一,过渡期间要同时采样两段动画,层层嵌套时代价成倍增长。第二,如果切换很频繁(比如 Motion Matching 每几帧就换一段),会同时挂着好几段在淡出。
惯性化(Inertialization)换了一个思路:切换的瞬间只记下旧姿态和新姿态之差,以及差值的变化速度,之后只算新动画,再把这份差值按一条平滑曲线在过渡时间内衰减到零。每帧只采样一段动画。
先看"走 → 跑",速度调到 0.25×。硬切在切换那一帧,手脚的轨迹直接折断,这就是"跳帧"。交叉淡化的轨迹最圆滑,但过渡期间"采样"变成 2。惯性化和 Dead Blending 的轨迹同样没有折断,采样却一直是 1。
再切到"频繁切换":交叉淡化的采样数会涨到 3 甚至更多,因为上一段还没淡完,下一段又来了。Motion Matching 每隔几帧就可能换一段,这就是它几乎都配惯性化的原因。最后看"挥臂反向",打开"目标姿态虚影":惯性化的前臂带着旧速度冲过虚影再拉回来,Dead Blending 冲得更少。
下面这张曲线图把同一件事压成一根骨骼的角度,能读出具体的过冲量。
切到"惯性化",过渡期间底部的条只剩一格,因为只算 B;实线从 A 的值出发,带着 A 的速度,平滑地落到 B 上。切到"交叉淡化",过渡期间是两格。
惯性化有一个已知的弱点:差值曲线只看切换瞬间,如果 B 的运动方向和 A 相反,衰减曲线会冲过头。Dead Blending 是近几年的改进:不再记录"差值",而是把旧动画 A 按切换时刻的速度向前外推,速度逐渐衰减,再和 B 做普通的交叉淡化。它仍然只采样一段真实动画,外推几乎不要钱,结果对方向相反的情况更稳。
UAF 三种都有:Blend Two Way 这类 Trait 做交叉淡化,Blend Inertializer 做惯性化,Dead Blending 有专门的过渡任务和应用任务(DeadBlendingTransitionTask / DeadBlendingApplyTask)。
惯性化见 David Bollo, GDC 2018(Gears of War 4);Dead Blending 见 Daniel Holden 2023 年的文章;UAF 任务名见 UAFAnimGraph API 参考 · 交互图曲线为示意
交叉淡化
过渡期间两段都算,频繁切换时代价叠加。
惯性化
只记差值和速度,之后只算新动画。
Dead Blending
把旧动画按速度外推再淡化,方向相反时更稳。
要点
UAF 三种都有:Blend Two Way · Blend Inertializer · Dead Blending 任务。
1.6
根运动:动画推着角色走,还是角色带着动画走
角色往前走,可以有两种因果关系。
胶囊体驱动:移动组件按输入算出速度和位置,动画系统读这个速度,挑一段合适的走路动画来播。脚和地面可能对不上(滑步),需要靠步幅调整、Stride Warping 之类的手段修。
根运动驱动:动画里根骨骼的位移被提取出来,交给移动组件,角色按动画走。脚步自然贴地,但响应慢,网络同步也更麻烦,因为移动结果取决于动画状态。
实际项目多是混用:日常移动用胶囊体驱动,翻越、攀爬、处决这类精确动作用根运动。Motion Matching 让这个界线更模糊:它用轨迹(角色过去和未来几秒的位置朝向)去搜动画,动画天然贴合预测的移动。
这件事对 UAF 有直接影响。根运动意味着动画结果要回流给移动组件,而 UAF 想在游戏线程之外算动画;两者之间的时序必须被显式安排。这就是为什么 UAF 组件上有一个 bSetupCharacterMovementDependency,以及为什么 5.8 的 Mover 和 UAF 有一个专门的桥接插件(第 6.11 节)。
把胶囊体速度拖到和动画自带速度差不多,左边的点就重合了;拖快或拖慢,点就拉成一串。这就是"胶囊体驱动会滑步"的直观样子。右边永远不滑,但它的速度是动画决定的,玩家推摇杆的力度改变不了它,除非换一段动画。
通用知识;bSetupCharacterMovementDependency 见 UUAFComponent API;Mover UAF 插件见 UAF 插件索引
胶囊体驱动
移动决定速度,动画跟着选;会滑步。
根运动驱动
动画决定位移;贴地但响应慢、难同步。
轨迹
Motion Matching 用预测轨迹搜动画,模糊了两者的界线。
要点
根运动要回流给移动组件,所以 UAF 必须显式安排它与移动的时序。
页签 2 / 8 · 约 1.2 小时
动画系统谱系:ABP 走到了哪一步
UE 的动画运行时从 AnimTree 走到 Anim Blueprint,再走到今天的 UAF。这一页签先拆开 ABP 的两段式和线程模型,看清它的五个结构性问题,再看业界和 UE5 自己是怎么一步步过渡的。
02
动画系统谱系
UAF 不是凭空出现的。它要解决的问题,大部分在 ABP 里已经有人用补丁的方式解决过一次。
上一章讲的是"算什么"。这一章讲"谁来算"。UE 的动画运行时换过两代:UE3 的 AnimTree 是一棵可视化的节点树,UE4 的 Anim Blueprint 在节点树旁边加了一个蓝图事件图,让动画逻辑能读写游戏数据。ABP 用了十多年,期间为了性能和复用打了很多补丁:Fast Path、线程安全更新、Property Access、Linked Anim Layers。每一个补丁都对应一个结构性问题。
看清这些补丁在补什么,才能看懂 UAF 为什么要重写,而不是再打一个补丁。
2.1
从 AnimTree 到 UAF,一张图
下面按时间排了几条线索。UE 自己的一条在上面,业界的做法在下面。它们最后汇到同一个方向:逻辑和求值分开、逻辑可组合、求值离开主线程。
| 时间 | 系统 | 描述方式 | 留给后来的东西 |
|---|---|---|---|
| UE3 时代 | AnimTree | 一棵可视化节点树,逻辑写在 C++ 或 UnrealScript | "节点 = 一种操作"的图 |
| UE4(2014)起 | Anim Blueprint | 事件图(蓝图)+ 动画图(节点)+ 状态机 + 蒙太奇 | 状态机、蒙太奇与插槽、Linked Anim Layers |
| 2016 | Motion Matching(For Honor) | 不画状态机,按轨迹在动画库里搜 | 数据驱动的选片;惯性化过渡 |
| 2017 | Unity Playables | 用代码搭一张可混合的节点图 | 运行时程序化建图 |
| UE 5.0 – 5.4 | Pose Search · Chooser · Blend Stack · GASP | 在 ABP 里塞进数据驱动的选片 | "选片"和"混合"拆成独立部件 |
| UE 5.4 起 | AnimNext(实验) | RigVM 上的模块 + Trait 图 | 后来的 UAF |
| UE 5.6 – 5.8 | UAF(实验) | System + Anim Graph + Trait Stack + 求值 VM | 巫师 4 技术演示;人群;Anim Mixer |
本表的年份与归类为本文整理。Playables、For Honor 等业界条目来自公开演讲与文档的背景知识,不是本次检索的结论。
表里最值得注意的一行是 UE 5.0 到 5.4。Pose Search、Chooser、Blend Stack 都是塞进 ABP 里的独立部件:它们已经按"数据驱动、可组合"的方式设计,只是宿主还是 ABP。UAF 做的事,某种程度上是把宿主也换掉。
UE 版本信息见各版本说明与 GASP 博客;AnimNext 出现在 5.4 的 API 参考中;业界条目为背景知识
UE3 AnimTree
节点 = 一种操作
UE4 Anim Blueprint
事件图 + 动画图 + 状态机 + 蒙太奇
UE5 过渡件
Pose Search · Chooser · Blend Stack,宿主还是 ABP
UAF
连宿主也换掉:System + Trait 图 + 求值 VM
要点
方向一致:逻辑和求值分开、逻辑可组合、求值离开主线程。
2.2
Anim Blueprint 的两段式:Update 与 Evaluate
ABP 每帧分两步走。理解这两步,是理解 UAF 一切设计的前提,因为 UAF 保留了这个划分,只是把每一步的做法换掉了。
Update:推进时间、读取游戏数据、决定权重。事件图里的 Blueprint Update Animation 先跑,把角色速度之类的值写进动画蓝图的成员变量;然后动画图从根节点往下走一遍,每个节点调用自己的 Update_AnyThread,播放器推进时间,混合节点算权重,状态机判断要不要转换。
Evaluate:真正算姿态。动画图再从根往下走一遍,每个节点调用 Evaluate_AnyThread,播放器采样关键帧,混合节点把子节点的姿态混起来,结果一层层返回到根。
BlueprintUpdateAnimation:读角色,写成员变量
每个节点 Update_AnyThread:时间、权重、状态机
每个节点 Evaluate_AnyThread:采样、混合,递归返回姿态
拷贝结果、触发通知、更新蒙皮
注意第 3 步的形状:Evaluate 是递归的,每个节点调用子节点的 Evaluate,拿到一个姿态对象,处理完再返回。这意味着姿态在调用栈上层层传递,每个节点都持有自己的临时姿态,算什么和怎么算混在同一个函数里。UAF 把这一步拆成了"先写程序、再跑程序"(第 5 章)。
ABP 的 Update / Evaluate 两段式与 _AnyThread 命名为 UE 公开源码中的通用约定(背景知识)
Update
推进时间、读数据、算权重、状态机。
Evaluate
采样、混合;递归返回姿态。
UAF 保留了两段式
但把每一段的做法都换了。
要点
ABP 的 Evaluate 把"算什么"和"怎么算"写在同一个递归里。
2.3
ABP 与线程:Fast Path、线程安全更新与游戏线程
动画是很多项目里游戏线程最大的开销之一。ABP 这些年为了把它搬离游戏线程,加了三层补丁:
多线程更新:项目设置里打开后,动画图的 Update 可以放到工作线程。但前提是事件图已经跑完,所以事件图本身还在游戏线程。
Fast Path:如果节点引脚直接连到成员变量,编译器会生成一份"直接拷内存"的绑定,绕过蓝图虚拟机。一旦引脚上接了任何蓝图运算(哪怕一个取反),就退回慢路径。很多团队为此立了"动画图里不准写逻辑"的规矩。
线程安全更新 + Property Access(UE5):新增 BlueprintThreadSafeUpdateAnimation,在工作线程上运行,用 Property Access 从角色身上"安全地"读数据。这是向 UAF 迈出的一大步:数据读取被显式声明,而不是在事件图里随手调用函数。
但三层补丁都绕不开一件事:ABP 是一个 UObject 实例,它的事件图可以调用任意蓝图函数、访问任意对象。引擎无法证明一段事件图是线程安全的,所以只能把"可能不安全"的部分留在游戏线程,或者靠程序员自觉。UAF 的变量绑定(第 3.4 节)就是从结构上回答这个问题:动画线程只能看到被显式送进来的值。
Fast Path、多线程更新、线程安全更新与 Property Access 为 UE4/UE5 公开功能(背景知识)
多线程更新
图的 Update 可上工作线程;事件图仍在游戏线程。
Fast Path
引脚直连成员变量才生效;接一个运算就退回。
线程安全更新
UE5 的 ThreadSafeUpdate + Property Access:显式声明要读的数据。
要点
根问题:ABP 是 UObject,事件图能做任何事,引擎无法证明它线程安全。
2.4
ABP 的五个结构性问题
把上面几节合起来,ABP 的问题可以归成五条。它们都不是 bug,而是结构决定的。
| 问题 | 表现 | ABP 的补丁 | UAF 的回答 |
|---|---|---|---|
| 一个资产承担一切 | 状态机、IK、修正、数据读取挤在一个蓝图里,几十人改同一个文件 | Linked Anim Layers、子图 | System / Graph / State Tree 分资产;Trait 组合 |
| 节点是整块的 | 想给播放器加一个镜像,只能再包一层节点或改 C++ | 节点上越来越多的选项 | Trait Stack:在同一节点上叠加 Trait |
| 数据读取不受控 | 事件图里随手调函数,线程安全靠自觉 | Fast Path、Property Access | 变量绑定:只看显式送进来的值 |
| 求值是递归 | 姿态在调用栈上层层传,难以批量、难以裁剪 | — | 求值程序 + 栈式 VM |
| 每个实例都很重 | 每个角色一个 UAnimInstance,人群很贵 | Animation Sharing、LOD、顶点动画 | 共享只读数据 + 轻量实例;可写到实例化网格 |
最后一行值得多说一句。ABP 的每个实例都是一个完整的 UObject,带着整个类的成员变量和节点结构。UAF 把一张图分成共享的只读部分(节点描述、Trait 的配置)和每个实例的可写部分(时间、权重之类),几百个角色共用前者(第 4.4 节)。这是巫师 4 演示里"几百个角色"的结构基础之一。
ABP 的问题在于它是一个什么都能做的对象。UAF 的思路是把能做的事收窄,换取可组合、可并行、可共享。
归纳为本文整理;UAF 一栏的依据见第 3–5 章各节的出处
一个资产承担一切
→ 分资产 + Trait 组合
节点是整块的
→ Trait Stack
数据读取不受控
→ 变量绑定
求值是递归
→ 求值程序 + VM
每个实例都很重
→ 共享只读数据 + 轻量实例
要点
把能做的事收窄,换取可组合、可并行、可共享。
2.5
同一个方向的两个先例
UAF 的核心选择是把"决定做什么"和"实际做"分开。这个思路在业界有两个容易查证的先例。以下为背景知识,用来说明方向,不作性能对比。
Unity 的 Playables 与 Animation C# Jobs。Unity 2017 引入 Playables API:程序在运行时用代码搭一张 PlayableGraph,混合节点只管理输入和权重,姿态的实际计算在引擎底层完成。2018 年又加入 Animation C# Jobs,允许把自定义的姿态处理写成作业,在工作线程上并行执行。这和 UAF 的"图工厂按需建图"(第 5.8 节)、"遍历只产出任务,VM 负责执行"(第 5 章)是同一类分工。
Motion Matching 的数据管线。2016 年 Ubisoft 介绍 For Honor 的做法时,运行时的主体不是状态机,而是"构造查询 → 在数据库里搜 → 用惯性化接上"三步。前两步是纯数据处理,天然适合批量和并行;第三步决定了过渡要能廉价、频繁地发生。UAF 的 Motion Matching Trait、Chooser 和惯性化 Trait 就是这三步的直接对应(第 6.3 节)。
两者共同说明:一旦"做什么"可以被表示成数据或命令,"怎么做"就能被批量化、并行化、换实现。ABP 做不到这一点,因为它的 Evaluate 是一个递归调用,"做什么"和"怎么做"写在同一个函数里(第 2.2 节)。
Unity Playables(2017)与 Animation C# Jobs(2018)、For Honor 的 Motion Matching(GDC 2016)为背景知识,未在本次检索中逐条核对原始出处
Unity Playables
代码建图;节点只管权重,计算在底层。
Animation C# Jobs
自定义姿态处理写成作业,并行执行。
Motion Matching 管线
查询 → 搜索 → 惯性化,前两步是纯数据处理。
要点
"做什么"能表示成数据,"怎么做"就能批量、并行、换实现。
2.6
Motion Matching:从状态机到数据库搜索
传统做法是画状态机:待机、起步、走、跑、急停、转身,每两个状态之间画转换条件。动作越多,转换越多,状态机就越难维护。
Motion Matching 是 2016 年 Ubisoft 在 GDC 上系统介绍的另一种思路:不画状态机,把所有动作捕捉素材放进一个数据库,每隔几帧,用"角色当前姿态 + 未来几秒的预测轨迹"作为查询条件,搜出最匹配的一帧,从那里开始播。
它对运行时提了三个新要求,这三点后来都成了 UAF 的设计输入:
第一,频繁切换:每隔几帧就可能换一段,交叉淡化会堆出一串淡出中的动画,所以需要惯性化或 Dead Blending。第二,需要轨迹:动画系统要能拿到移动组件预测的未来,所以动画和移动的时序必须对齐。第三,选片和混合是两件事:"选哪段"由搜索决定,"怎么接上"由混合栈决定,两者应该是独立的部件。
UE 5 的 Pose Search 插件就是 Motion Matching 的实现。在 UAF 里,它对应 Motion Matching Trait 和一组生成轨迹的节点,后者可以从 Character Movement Component 或 Mover 生成轨迹(第 6.3 节)。
Motion Matching 见 Simon Clavet 在 GDC 2016 的演讲(背景知识);UAF 中的轨迹节点见 Eyad 的 UAF 教程
频繁切换
→ 需要惯性化 / Dead Blending
需要轨迹
→ 动画与移动时序对齐
选片和混合分离
→ 搜索与混合栈是独立部件
要点
Motion Matching 的三个要求,后来都成了 UAF 的设计输入。
2.7
UE5 的过渡件:Chooser、Pose Search、Blend Stack
在 UAF 成形之前,Epic 先在 ABP 里放进了几个按新思路设计的部件。它们今天在 UAF 里都有对应的插件,因此也是最自然的"先试"对象。
Chooser:一张表,列是条件(速度区间、姿态、标签……),行是候选资产。运行时按条件筛出一个或几个资产。它把"选哪段动画"从状态机里拿了出来,变成可以由策划和动画师直接编辑的数据。5.8 的 GASP 用一个新 Chooser,配合改进后的 Pose Match 列,在多个 Pose Search 数据库里做匹配。
Pose Search:Motion Matching 的实现,包括数据库、Schema(拿哪些骨骼和轨迹点比)和搜索节点。
Blend Stack:一个"后进先出"的混合栈。新动画压到栈顶,旧的在下面按时间淡出,配合惯性化,栈很浅。它替代了"每两个状态之间画一个转换"的做法。
Game Animation Sample Project(GASP):从 5.4 开始发布的官方样例,用 Chooser + Pose Search + Blend Stack 搭了一套完整的移动系统,5.8 又加了多角色交互、布娃娃起身和实验性的 Look-At 解算器。需要更正的一点:有预览期文章预计 5.8 的 GASP 会附带一套完全用 UAF 搭的角色,但 Epic 的 5.8 更新说明里没有提到 UAF。截至本文,GASP 的角色仍是 ABP 驱动。
GASP 5.8 更新说明(Epic 博客);预期见 Lunar Lizard 的 5.8 预览文章;Chooser / Pose Search / Blend Stack 的 UAF 对应插件见 UAF 插件索引
Chooser
条件表选资产,把"选哪段"变成数据。
Pose Search
Motion Matching:数据库 + Schema + 搜索。
Blend Stack
后进先出的混合栈,替代成对的转换。
更正
GASP 5.8 没有附带 UAF 角色,仍是 ABP。
要点
这几件在 UAF 里都有对应插件,是最自然的先试对象。
2.8
UAF 是一次重写,也是一次组合
到这里可以给 UAF 下一个定义了。按 UAF 插件自己的描述,它是"为动画系统定义功能性数据流的框架"。拆开来看,它由四件东西组合而成:
RigVM:Control Rig 已经在用的执行虚拟机。UAF 的 System 和图都编译成 RigVM 字节码,在工作线程上跑。
Trait:替代 ABP 节点的最小单元,用 C++ 写,一个节点里可以叠多个。
求值 VM:Evaluate 遍历不直接算姿态,而是产出一段任务程序,交给一台以姿态为栈元素的虚拟机执行。
前面那些过渡件:Chooser、Pose Search、State Tree、Control Rig、Warping,各自以 "UAF xxx" 插件的形式接进来。
所以 UAF 是对 ABP 宿主的一次重写,同时也把 UE5 已有的部件重新组合了一遍。
下一页签从资产和一帧的流程讲起,再进到 Trait 的内部。
插件描述见 Unreal Directive 的 UAF 插件页;RigVM 承载见 Eyad 与 RemRemRemRe 的文章
RigVM
System 和图都跑在上面。
Trait
替代 ABP 节点的最小单元。
求值 VM
Evaluate 产出程序,VM 执行。
已有部件
Chooser、Pose Search、State Tree……以插件接入。
要点
对 ABP 宿主的重写,也是对 UE5 已有部件的重新组合。
页签 3 / 8 · 约 2 小时
骨架:资产、一帧与 Trait
先认识 UAF 的几种资产和它们每帧的分工,再看数据怎么从游戏线程进到动画线程。然后拆开 Trait:它怎么叠、状态放在哪、对外提供哪些接口。
03
思想与资产
UAF 的核心只有一句话:每帧要做的事写成脚本,姿态怎么算写成图,两者都在游戏线程之外运行。
ABP 把事件图和动画图放在同一个资产里。UAF 把它们拆开:每帧的流程是一个 System,姿态怎么算是一张 Anim Graph,状态逻辑可以是一个 State Tree,它们在一个 Workspace 里一起编辑。这一章先认资产,再看一帧的流程,最后看数据怎么进来、结果怎么出去。
3.1
一句话:数据流、组合、离开游戏线程
把 UAF 的设计压成三个词:
数据流。输入是显式绑定进来的值,输出是写给网格的姿态。中间每一步读什么、写什么都在图上看得见。插件描述里那句"功能性数据流"指的就是这个。
组合。最小单元是 Trait,Trait 叠成节点,节点连成图,图可以嵌套成子图,也可以由代码按需生成。复用靠组合,不靠继承。
离开游戏线程。System 的事件可以被安排在指定的 Tick Group 里,在工作线程上运行。这是 UAF 的设计目标,但证据目前偏少:最直接的一条来自社区教程,作者报告配好之后角色做 Motion Matching 时不再占用游戏线程的时间;Epic 员工只说"性能是首要目标,会尽量并行"。所以本文把它当作"结构上可信、有待你自己测"的结论。
速度、加速度、是否在空中……按值送进来
按事件运行:初始化、物理前、物理后
Trait Stack 节点组成的图
执行任务程序,得到姿态
骨骼网格,或实例化蒙皮网格
按 Eyad 的整理,这条链可以写成:输入 → 绑定 → System → 图 → Trait Stack → 最终姿态。后面两章就沿着这条链一段段拆。
插件描述见 Unreal Directive;数据流链见 Eyad 的 UAF 教程;"不占游戏线程"见 farravid 的 5.7 设置教程(社区)
数据流
输入显式绑定,输出写给网格。
组合
Trait → 节点 → 图 → 子图。
离开游戏线程
System 事件在工作线程上按 Tick Group 运行。
要点
输入 → 绑定 → System → 图 → Trait Stack → 最终姿态。
3.2
资产地图:Workspace、System、Anim Graph 与共享变量
在内容浏览器的 Animation 分类下,启用插件后会多出一个 Animation Framework 子菜单。5.8 里常用的资产有这几种:
| 资产 | 对应 ABP 的什么 | 做什么 |
|---|---|---|
| UAF Workspace | — | 编辑器容器。把 System、Graph、共享变量放在一个窗口里编辑,本身不参与运行 |
| UAF System | 事件图 | 每帧的流程脚本:在哪个事件里跑图、写姿态、算变量。旧版叫 Module |
| UAF Animation Graph | 动画图 | 描述姿态怎么算。由 Trait Stack 节点组成,可以有多个入口 |
| UAF State Tree | 状态机 | 把状态逻辑交给引擎通用的 State Tree,在图里通过 State Tree Trait 驱动 |
| 共享变量 | 成员变量 | 多个资产共用的一组变量。5.6 的菜单里叫 Data Interface |
几点说明。
System 是根资产。API 里 UUAFSystem 的说明是"组件实例化时所代表的根资产",它的显示名是 "UAF System",类声明却仍在 Module/AnimNextModule.h 里。看到旧教程里的 Module,在 5.8 里对应 System。
共享变量的名字变过。5.6 的资产菜单里有 "UAF Data Interface";5.8 的 UUAFSystem 继承自 UUAFSharedVariables,图工厂的参数也用 "Public Variables Asset" 的说法。本文推断 Data Interface 演变成了共享变量,具体名称以你用的引擎版本为准。
可以用向导一次生成。社区教程提到 5.8 有一个 UAF Asset Wizard:选模板、角色蓝图和骨骼网格,自动生成 Workspace、System、Graph 和角色设置,命名形如 UAF_W_Character、UAF_S_Character、UAF_AG_Character。
UUAFSystem API(官方);5.6 资产菜单见 UAF FAQ 帖子中的用户笔记;向导见 Eyad 的教程(社区);Data Interface → 共享变量为推断
Workspace
编辑器容器,不参与运行。
System
根资产,每帧的流程脚本。旧称 Module。
Animation Graph
Trait Stack 节点组成的姿态图。
State Tree
状态逻辑交给通用 State Tree。
要点
看到旧教程里的 Module,5.8 里对应 System。
3.3
一帧里 UAF 做了什么
一个最小的 System 只有两个事件:
Initialize:调用 Make Reference Pose,生成参考姿态,作为图的初始状态。PrePhysics:调用 Run Graph 跑一遍动画图(内部就是 Update 和 Evaluate 两次遍历),再调用 Write Pose 把结果写给网格。如果要做 Motion Matching,还要在 Run Graph 之前先生成轨迹。
事件不是固定的几个。System 里的每个事件都是一个"用户事件",带着三样东西:名字、它要在哪个 Tick Group 结束(end_tick_group),以及同一 Tick Group 内的先后顺序(sort_order)。PrePhysics 和 PostPhysics 是两个预设好的事件,分别在世界物理更新之前和之后运行。
切到"ABP · 事件图",游戏线程上有一段明显的事件图开销,动画图的 Update 要等它跑完。切到"ABP · 线程安全更新",事件图挪到了工作线程,游戏线程只剩准备和收尾。切到"UAF",游戏线程上只剩一段很短的"拍快照":绑定的变量在这里被读出,之后 System、两次遍历、求值 VM 和写姿态都在工作线程上。
放回集市的例子:两百个 NPC,在 ABP 下是两百段游戏线程上的事件图;在 UAF 下是两百段很短的拍快照,加上两百个可以在工作线程上并行的 System。主角"猎人"如果还留在 ABP,他的那一段照旧。
要注意两点。第一,这张图的长度是示意,不是测量值;真实开销取决于图的复杂度和角色数。第二,"在工作线程上"不等于"不要钱",它只是把开销从最容易成为瓶颈的游戏线程挪开,并让多个角色可以并行。
System 事件流程见 Eyad 的教程与 farravid 的 5.7 设置教程;事件字段见 RigUnit_AnimNextUserEvent / PrePhysicsEvent 的 Python API(官方);时间轴为示意
Initialize
Make Reference Pose
PrePhysics
(生成轨迹)→ Run Graph → Write Pose
用户事件
名字 · end_tick_group · sort_order
要点
游戏线程上只剩拍快照;其余都在工作线程。长度为示意。
3.4
变量与绑定:数据怎么从游戏线程进到动画线程
ABP 的事件图可以随手调用 GetVelocity()。UAF 不让动画线程直接碰游戏对象,数据必须通过三条路之一送进来:
变量绑定:在 System 里声明一个变量,比如 Velocity,把它绑定到"当前 Actor → Get Velocity"。编译器会为这些绑定自动生成两个执行单元,一个在游戏线程跑(ExecuteBindings_GT),一个在工作线程跑(ExecuteBindings_WT)。需要碰 UObject 的读取放在前者,纯计算放在后者。
组件上的 Set Variable:游戏代码通过 UUAFComponent::SetVariable 或蓝图节点写值。写入不会立刻被动画线程看到,而是进入一个代理,每帧把"变脏"的变量拷过去。5.6 的源码笔记里这个代理叫 PublicVariablesProxy,作者提到计划改成类似骨骼网格组件那种双缓冲。
输入绑定到组件:BlueprintSetInputBinding 可以把一个类型为 FValueBundle 的输入变量绑定到某个组件,由那个组件按值提供一包数据。5.8 的 UAF Anim Node 里有一个 Input Value 节点,就是在 PreUpdate 时从图变量里读出这样一包值,压到求值栈上(这是 5.8 新出现的另一套节点系统,第 6.13 节)。
读 Actor、组件:速度、加速度、朝向
SetVariable 的写入在这里生效
纯计算:长度、方向、地面速度
只看这一帧的快照
这就是开场比喻里的"快照照片":动画线程开工前拿到一张照片,之后世界怎么变都不影响这一帧。代价是动画总是基于这一帧开头的数据,如果移动组件在同一帧稍后又改了速度,动画看不到。需要严格先后关系的地方,要靠下一节的 Tick 依赖来保证。
Epic 员工在 2026 年的论坛回复里也提到,游戏数据会在图运行的线程上可访问,但这部分设计还在变化。所以这一节的细节请以你手上的引擎版本为准。
ExecuteBindings_GT/_WT 与 PublicVariablesProxy 见 RemRemRemRe 的 5.6 笔记(社区);SetVariable、BlueprintSetInputBinding 见 UUAFComponent API(官方);FUAFInputValueAnimNode API(官方);"设计在变化"见 Epic 员工论坛回复
变量绑定
编译器生成 _GT 与 _WT 两段。
Set Variable
写入进代理,每帧拷贝变脏的变量。
输入绑定
FValueBundle:由组件按值提供一包数据。
要点
动画线程只看快照。需要严格先后,靠 Tick 依赖。
3.5
UUAFComponent:挂在 Actor 上的那一端
让一个角色用上 UAF,需要两步:在 Actor 上加一个 UAF 组件(5.8 里类名是 UUAFComponent,头文件仍叫 Component/AnimNextComponent.h),并关掉骨骼网格组件自己的动画。社区教程特别提醒:骨骼网格上的动画不完全关掉,UAF 不会运行。
组件上值得认识的字段和函数:
| 成员 | 作用 |
|---|---|
AssetData | 要实例化的 System(一个系统工厂资产)。注册后不能再换 |
OutputComponent | 要驱动的骨骼网格组件 |
SkeletalMeshComponentOutputMode | 向骨骼网格输出时执行哪种操作 |
Inputs | System 的输入描述 |
InitMethod | System 的初始化方式 |
bSetupCharacterMovementDependency | 自动加一个 Tick 依赖,保证角色的移动组件先于这个系统 Tick |
AddPrerequisite / AddSubsequent | 让某个 Tick 函数在 System 的某个事件之前 / 之后运行 |
AddModuleEventPrerequisite | 让另一个 UAF 组件的事件先于本组件的某个事件 |
Get / Set / WriteVariable | 按变量引用读写 System 的变量 |
QueueInputTraitEvent | 排一个 Trait 事件,下一次图更新时处理(第 4.6 节) |
QueueTask | 排一个任务,在指定事件、指定位置运行 |
ShowDebugDrawing | 在视口里画出 System 的调试指令(需要开启调试绘制的构建) |
这张表里最能说明设计取向的是那一组 Prerequisite / Subsequent。ABP 的时序大多是隐式的:动画在移动之后、物理之前,引擎替你安排好了。UAF 把每个事件都暴露成可以挂依赖的 Tick 函数,时序由你声明。好处是可以把多个角色、多个系统的先后关系精确地排出来(比如骑手的动画要在马之后);代价是你要自己想清楚。
还有一个细节:组件实现了 ISystemOutputAdapter 接口。输出不一定是骨骼网格,人群可以写到实例化蒙皮网格(第 6.10 节)。
UUAFComponent API 参考(官方);"必须关掉网格动画"见 farravid 与 Eyad 的教程(社区)
挂载
加 UAF 组件,关掉骨骼网格自己的动画。
时序要声明
Prerequisite / Subsequent / 移动依赖。
输出可替换
ISystemOutputAdapter:网格或实例化网格。
要点
ABP 的时序是隐式的;UAF 把每个事件暴露成可挂依赖的 Tick 函数。
04
Trait 与图
在 UAF 里,节点只是一个容器。真正干活的是叠在里面的 Trait。
ABP 的每个节点是一个 C++ 结构体,比如 FAnimNode_SequencePlayer,它自己决定怎么更新、怎么求值、有哪些引脚。想给它加一点行为,要么改它,要么在外面再包一个节点。
UAF 把节点拆开了。一个节点里是一个 Trait Stack:底部一个基础 Trait 提供主要行为,上面可以叠若干加法 Trait 去修改它。这一章讲 Trait 怎么叠、状态放在哪、它们之间通过什么接口说话。
4.1
Trait:比 ABP 节点更小的积木
按 RemRemRemRe 的源码笔记,Trait 是一个可复用的动画逻辑单元,大致相当于 ABP 的一个节点,但粒度更小。在类层次上:
FTrait 是基类。具体的 Trait 继承 FBaseTrait 或 FAdditiveTrait 之一,再加上若干个 ITraitInterface 接口。前者是"基础 Trait",能独立构成一个节点;后者是"加法 Trait",只能叠在基础 Trait 上面,去覆盖或修改它的行为。
和 ABP 节点最大的区别有三个:
无状态。Trait 对象本身不存任何每实例的数据,因为同一个 Trait 要在很多线程上被很多角色同时调用。状态放在别处(第 4.3 节)。
按接口协作。Trait 之间不直接调用对方的函数,而是通过接口:一个 Trait 说"我实现了 ITimeline",需要时间线的 Trait 就向它所在的栈查询这个接口。
可以叠。同一个节点上,基础 Trait 负责主要行为,加法 Trait 负责修改。开场比喻里的乐高积木就是指这个。
| ABP 节点 | UAF Trait | |
|---|---|---|
| 粒度 | 一整块功能 | 一个关注点 |
| 状态 | 节点自己的成员变量 | Trait 无状态;数据放在共享数据和实例数据里 |
| 扩展方式 | 改节点或外包一层 | 在同一节点上叠加法 Trait |
| 协作方式 | 父节点直接调子节点 | 通过 Trait 接口查询 |
| 编辑器里 | 一个节点一种类型 | 统一的 Trait Stack 节点,里面列出 Trait |
最后一行有个细节:编辑器里的 Trait Stack 节点只用于显示。编译时它被序列化进图的数据里,运行时不会执行这个节点本身。
RemRemRemRe《My Understanding of the Unreal Animation Framework in 5.6》(社区);FBaseTrait / FAdditiveTrait 见 UAFAnimGraph API(官方)
基础 Trait
能独立构成节点,提供主要行为。
加法 Trait
叠在上面,修改或覆盖行为。
无状态
同一 Trait 被多线程多角色共用。
要点
Trait 之间通过接口协作,不直接调用。
4.2
Trait Stack:基础 Trait 与加法 Trait 怎么叠
一个节点里的 Trait 从下往上排:最底下是基础 Trait,上面是加法 Trait。当有人对这个节点调用某个接口时,调用从栈顶开始:栈顶的 Trait 如果实现了这个接口,就由它处理,它可以选择再把调用转给下面的 Trait(类似调用 Super);如果没实现,就直接落到下一个。
试试选 IEvaluate,再把"镜像"和"曲线修改"都勾上。调用先到镜像:它在子节点求值之后要加一个"镜像"任务,所以它把调用往下转,最后由 Sequence Player 压入采样任务,回来时镜像再追加自己的任务。再选 ITimeline,调用会直接穿过两个加法 Trait 落到 Sequence Player,因为只有它知道播放进度。
官方 API 对 IEvaluate 的说明正是这个顺序:节点被访问时,先调用栈顶 Trait 的 PreEvaluate,它再转给栈里下一个实现了 IEvaluate 的 Trait;然后通过 IHierarchy 查询子节点并求值;最后在原来那个 Trait 上调用 PostEvaluate。
所以加法 Trait 能在不改动基础 Trait 的前提下,在它前后插入自己的行为。这就是 UAF 的扩展方式。
IEvaluate 的调用顺序见 IEvaluate API 参考(官方);交互图里各 Trait 的接口实现为示意
从栈顶开始
实现了接口的 Trait 处理,可再转给下一个。
没实现就穿过
调用直接落到下面的 Trait。
前后插入
加法 Trait 可以在基础 Trait 前后加自己的行为。
要点
不改基础 Trait,就能扩展它。
4.3
无状态的 Trait,状态放在哪
Trait 无状态,可一个 Sequence Player 总得记住播到第几秒。UAF 把数据分成三类,各放各的地方:
共享数据(Shared Data):只读、可序列化,通常是在编辑器里配好的参数,比如播放哪段动画、播放速率、是否循环。同一张图被一百个角色使用,共享数据只有一份。源码笔记把它比作 ABP 里的 FoldProperty。
实例数据(Instance Data):每个图实例一份,原生 C++ 数据,比如当前播放时间。类似 State Tree 的实例数据。
潜在属性(Latent Properties):那些既属于配置、又需要每实例单独求值的成员,比如"混合权重"这个参数连到了一个变量,每个角色的值不同。它们定义在共享数据里,但运行时的值存在实例数据后面。
8 字节头 + 各 Trait 的共享数据
存在图的 SharedDataBuffer 里
16 字节头,带引用计数
+ 各 Trait 的实例数据
+ 潜在属性的运行时值
Trait 的组合与顺序
所有模板在内存里连续存放
这个划分的意义在角色数多的时候才显现。一百个角色用同一张图,只读部分只占一份内存,每个角色只多出一组紧凑的实例数据。这和 ABP"每个角色一个完整 UObject 实例"形成了鲜明对比。
写自定义 Trait 时,这三类数据各有对应的宏和结构(附录 A.4 有一个代码骨架)。最容易犯的错,是把应该放在实例数据里的东西写成了 Trait 的成员变量:它在单线程测试里看起来正常,一上多角色就会互相覆盖。
共享数据 / 实例数据 / 潜在属性及节点头部大小见 RemRemRemRe 的 5.6 笔记(社区);FTraitInstanceData、FAnimNextTraitSharedData 见 API(官方)
共享数据
只读配置;每张图一份。
实例数据
每实例一份:时间、状态。
潜在属性
配置里连了变量的成员,运行时值放在实例数据后面。
要点
最常见的错:把实例状态写成 Trait 的成员变量。
4.4
节点模板、节点描述、节点实例
上一节的三类数据,落到运行时是三个对象。它们的关系是理解 UAF 内存布局的关键:
节点模板(FNodeTemplate):"一个节点由哪几个 Trait、按什么顺序组成"。它是共享的、只读的,同样组合的节点只存一份模板,模板后面紧跟着每个 Trait 的 FTraitTemplate。全局的模板注册表把所有模板放在一块连续内存里。
节点描述(FNodeDescription):图里一个具体节点的只读数据。对象本身 8 字节,后面跟着各个 Trait 的共享数据。多个节点描述可以指向同一个模板(比如图里有十个 Sequence Player)。它们都存在图资产的 SharedDataBuffer 里。
节点实例(FNodeInstance):运行时为每个图实例分配的数据。对象本身 16 字节,带引用计数,后面跟着实例数据和潜在属性。
运行时查询 Trait 的入口是两个"绑定"对象:FTraitStackBinding 指向一个节点的整个 Trait Stack,FTraitBinding 指向其中一个 Trait,带类型的版本是 TTraitBinding<接口>。前一节交互图里"调用沿栈往下走",在代码里就是从一个 Trait Binding 拿到"栈里下一个实现了这个接口的 Trait"的 Binding。
| 对象 | 数量 | 大小(5.6 笔记) | 内容 |
|---|---|---|---|
| FNodeTemplate | 每种 Trait 组合一份,全局 | 头 + N 个 FTraitTemplate | Trait 列表与顺序 |
| FNodeDescription | 每个图节点一份 | 8 字节 + 共享数据 | 模板句柄、各 Trait 的配置 |
| FNodeInstance | 每个图实例的每个活跃节点 | 16 字节 + 实例数据 + 潜在属性 | 引用计数、运行时状态 |
注意节点实例是引用计数的。一个节点在混合中淡出、权重变成零之后,它的实例可以被释放;重新变得"相关"时再分配,并收到 OnBecomeRelevant 回调。这和 ABP"整个实例常驻"的做法不同,也是 UAF 能做轻量实例的原因之一。
RemRemRemRe 的 5.6 笔记(社区);类名见 UAFAnimGraph API(官方);大小可能随版本变化
节点模板
Trait 的组合与顺序;全局共享。
节点描述
8 字节 + 共享数据;每个图节点一份。
节点实例
16 字节 + 实例数据;引用计数,按需分配。
要点
不再相关的节点实例可以释放,重新相关时再分配。
4.5
Trait 接口:IUpdate、IEvaluate、IHierarchy……
接口是 Trait 之间唯一的协作方式。每个接口类 ITraitInterface 只有一个方法:返回自己的唯一 ID。ID 由类名的 FNV1a 哈希在编译期算出,用 DECLARE_ANIM_TRAIT_INTERFACE 声明,用 AUTO_REGISTER_ANIM_TRAIT_INTERFACE 注册到全局表里。
5.8 的 API 参考里列出了这些接口,按用途分组如下:
| 用途 | 接口 | 回答什么问题 |
|---|---|---|
| 遍历 | IUpdate · IUpdateTraversal · IEvaluate · IHierarchy | 怎么推进时间?怎么产出求值任务?我有哪些子节点? |
| 时间 | ITimeline · ITimelinePlayer · IGroupSynchronization | 播到哪了?多长?怎么同步? |
| 混合 | IDiscreteBlend · IContinuousBlend · IBlendStack · ISmoothBlend · ISmoothBlendPerBone · IInertializerBlend | 当前混合到哪一个?权重多少?怎么平滑? |
| 输入 | IAlphaInputArgs · IAttributeProvider | alpha 从哪来?有哪些属性? |
| 事件与通知 | INotifySource | 这一段时间里触发了哪些通知? |
| 修改求值 | IEvaluationModifier | 让插入点接管求值、插入自己的任务 |
| 作用域 | IScopedTraitInterface · IScopedTagInterface | 在子树范围内生效的接口或标签 |
| 其他 | IGarbageCollection · IGraphFactory · IAnimGraphBuilder | 引用哪些 UObject?怎么从资产建图? |
两点值得注意。
混合被拆成了好几个接口。"离散混合"(在几个子节点之间选一个,比如按布尔值切换)和"连续混合"(按权重混合)是不同的接口,平滑和惯性化又是另外的接口。一个 Blend Smoother 加法 Trait 只要找到下面那个实现了 IDiscreteBlend 的 Trait,就能给它加上平滑,不管它是按布尔、按枚举还是按整数在选。
IUpdate 携带一个状态对象往下传。FTraitUpdateState 里有本次推进的时间、累计权重、累计轨迹权重,以及"正在淡出""刚变得相关"两个标志。它的修改方法都返回新副本,比如 WithTimeScale、WithWeight,所以父节点可以给每个子节点派生一份不同的状态,而不影响自己。第 5.1 节会看到它怎么在遍历里流动。
接口列表见 UAFAnimGraph API(官方);接口注册宏与 FNV1a 见 RemRemRemRe 的笔记(社区);FTraitUpdateState API(官方)
遍历
IUpdate · IEvaluate · IHierarchy
时间
ITimeline · IGroupSynchronization
混合拆成多个接口
离散 / 连续 / 平滑 / 惯性化 各自独立
状态往下传
FTraitUpdateState:时间、权重、标志,按副本派生
要点
加法 Trait 只认接口,不认具体是哪个 Trait。
4.6
Trait 事件:像 UI 点击一样往图里发消息
有些事情不适合用变量表达,比如"现在开始翻滚""这一段动画结束了"。UAF 用 Trait 事件处理这类一次性的消息。
事件的基类是 FAnimNextTraitEvent,用 DECLARE_ANIM_TRAIT_EVENT 声明,每种事件有自己的 ID 和 IsA 检查。按源码笔记的描述,它的行为很像 UI 的点击事件:在图里传播,某个 Trait 处理后可以标记为"已处理",事件可以有有限或无限的持续时间。
从外部发事件的入口在组件上:QueueInputTraitEvent 把一个事件排进队列,在下一次图更新时处理。注意"下一次":从游戏线程排进去的事件,最早在下一帧的动画线程上被看到。如果你的玩法要求同一帧响应,需要在 System 的事件安排上下功夫,或者接受一帧延迟。
反方向(图通知游戏)走的是 Notify:Notify Dispatcher Trait 在时间线推进时分发动画通知,Notify Filter Trait 做过滤。第 6.8 节再讲。
FAnimNextTraitEvent 与事件行为见 RemRemRemRe 的笔记(社区);QueueInputTraitEvent 见 UUAFComponent API(官方)
一次性消息
"开始翻滚"这类事用事件,不用变量。
像 UI 点击
传播、可标记已处理、可有持续时间。
下一次更新才处理
从游戏线程排入,最早下一帧被看到。
要点
反方向:Notify Dispatcher / Notify Filter。
4.7
现有 Trait 目录
下面是社区教程按用途整理的 Trait 列表,和 5.8 API 里的共享数据结构基本对得上。这张表也是第 8 章"哪些可以先试"的原材料。
| 类别 | Trait |
|---|---|
| 生成姿态 | Reference Pose · Sequence Player · Blend Space Player · Motion Matching · Pose Search Result Emulator |
| 混合 | Blend Two Way · Blend by Bool · Blend Layer · Blend Stack 系列 · Passthrough · Dead Blending |
| 叠加与调整 | Apply Additive · Modify Curve · Mirroring |
| 平滑 | Blend Inertializer · Blend Smoother 系列 |
| Alpha 输入 | 多种 Alpha Input Args |
| Warping | Strafe Warping · Steering · WarpTest |
| 逻辑 | State Tree · Call Function · Injection Site · Synchronize Using Groups |
| 事件 | Notify Dispatcher · Notify Filter · Pose History |
| 结构 | Input Pose · Sub Graph |
| 程序化与绑定 | Control Rig · RigLogic |
从这张表能读出几件事。没有"状态机"Trait:状态逻辑交给了 State Tree。没有"蒙太奇"Trait:临时插入动画靠 Injection Site(第 6.7 节)。没有"计算方向":社区教程提到 UAF 里没有现成的 Calculate Direction 节点,要自己用速度和朝向算。这些"没有"是迁移时最先要面对的。
分类表见 Eyad 的 UAF 教程(社区);与 UAFAnimGraph API 中的 *SharedData 结构对照(官方)
生成 · 混合 · 叠加
Sequence / Blend Space / Motion Matching;Blend Two Way / Blend Stack;Apply Additive / Mirroring
逻辑 · 事件
State Tree · Injection Site · Notify Dispatcher
缺的东西
没有状态机 Trait、没有蒙太奇、没有 Calculate Direction
要点
"没有"的部分是迁移时最先要面对的。
页签 4 / 8 · 约 1.8 小时
执行:两次遍历、求值 VM 与调度
Run Graph 一个节点里发生的全部事情:Update 遍历把时间和权重往下传,Evaluate 遍历把要做的事写成一段程序,求值 VM 执行它。再看这些工作怎么被排进一帧、跑在 RigVM 上,以及图怎样用代码生成。
05
两次遍历与求值 VM
Evaluate 遍历不算姿态,只写程序。真正的计算交给一台以姿态为栈元素的虚拟机。
ABP 的 Evaluate 是递归:每个节点调用子节点,拿回一个姿态,处理完再返回。UAF 保留了 Update 和 Evaluate 两次遍历,但两次都不用递归,而且第二次遍历只负责把要做的事写下来。
这一章用同一张小图贯穿:一个混合节点,下面挂着走路和跑步两个播放器,混合节点上还叠了一个镜像 Trait。先看 Update 怎么走,再看 Evaluate 写出什么程序,最后看 VM 怎么执行它。
5.1
Update 遍历:时间和权重往下传
Update 遍历的任务是让每个相关的 Trait 推进时间、更新权重、决定子节点里谁还相关。按源码笔记,FUpdateTraversalContext 用一个在 MemStack 上分配的后进先出栈做深度优先遍历,不用递归。
每访问一个节点,先调用它的 IUpdate::PreUpdate,然后把它的子节点压栈;子节点都处理完之后,再回到这个节点调用 PostUpdate。一个节点刚变得相关时,还会收到 OnBecomeRelevant。
看右边每个节点收到的更新状态。根节点收到 dt = 1/60 秒、权重 1。混合节点的 alpha 是 0.3,它给走路派生一份权重 0.7 的状态,给跑步派生一份权重 0.3 的状态。这就是第 4.5 节说的"按副本派生"。Blend Two Way 自己不管同步;如果要让走跑的脚步对齐,节点上还要叠一个 Synchronize Using Groups Trait(第 1.4 节),由它通过时间线接口调整跟随者的进度。
为什么不用递归?递归的调用栈深度由图的深度决定,栈帧里还藏着各个节点的临时变量;显式的栈只存"接下来要访问谁",大小可控、分配在线程局部的内存栈上,遍历过程也更容易在中途检查和调试。
FUpdateTraversalContext 的实现方式见 RemRemRemRe 的笔记(社区);FTraitUpdateState API(官方);交互图为示意
不用递归
MemStack 上的后进先出栈,深度优先。
Pre / Post
进节点调 PreUpdate,子节点做完调 PostUpdate。
按副本派生状态
父节点给每个子节点派生不同的 dt 与权重。
要点
刚变得相关的节点会收到 OnBecomeRelevant。
5.2
Evaluate 遍历:不算姿态,只写程序
Evaluate 遍历的顺序和 Update 相同:进节点调用 IEvaluate::PreEvaluate,处理子节点,回到节点调用 PostEvaluate。区别在于这两个函数不返回姿态,而是往一个 FEvaluationProgram 里追加任务。
对我们的小图,遍历会这样写程序:走路播放器追加"采样走路第 t 秒",跑步播放器追加"采样跑步第 t 秒",混合节点在 PostEvaluate 里追加"按 0.3 混合栈顶两个姿态",镜像 Trait 最后追加"镜像栈顶姿态"。(5.8 的公开任务清单里没有镜像任务,它来自 UAF Mirroring 插件,演示里的 Mirror* 是示意名。)
注意任务的顺序正好是后序遍历:先子节点、后父节点。这不是巧合。父节点的操作要用到子节点的结果,而 VM 是栈式的,子节点的结果按顺序压在栈上,父节点的任务执行时正好从栈顶取。这和逆波兰表达式 3 4 + 是一回事。
权重为零的子节点在 Update 时就已经被判为不相关,Evaluate 不会走进去,程序里自然没有它的采样任务。ABP 里同样有"权重为零就跳过"的优化,但它是每个混合节点自己判断;在 UAF 里,它是遍历结构的一部分。
IEvaluate 调用顺序见 API(官方);"收集任务到 FEvaluationProgram"见 RemRemRemRe 的笔记(社区);任务的具体顺序为示意
不返回姿态
PreEvaluate / PostEvaluate 只往程序里追加任务。
后序遍历
先子节点、后父节点,正好匹配栈式 VM。
零权重不进程序
Update 时判为不相关,Evaluate 不会走进去。
要点
程序 = 一串任务,就像逆波兰表达式。
5.3
求值 VM:一台以姿态为元素的栈机
程序写好之后,交给 FEvaluationVM 执行。VM 里有若干个按名字和类型区分的栈(FEvaluationVMStack),最主要的一个放的是关键帧,也就是整套骨骼的姿态。每个任务从栈上取输入,把结果压回去。
逐步执行一遍:两个采样任务各压入一个姿态;混合任务弹出两个、压回一个;镜像任务弹出一个、压回一个。程序结束时,栈上正好剩一个姿态,就是这一帧的结果。
5.8 的任务清单(第 8 章的准入表里也会用到)大致分这几类:
| 类别 | 任务(去掉 FAnimNext / FUAF 前缀与 Task 后缀) | 栈操作 |
|---|---|---|
| 压入 | PushReferenceKeyframe · AnimSequenceKeyframe · PushPose · DuplicateTopKeyframe | 0 → 1 |
| 混合 | BlendTwoKeyframes · BlendOverwriteKeyframeWithScale · BlendAddKeyframeWithScale · 以及各自的 PerBone 版本 | 2 → 1 |
| 加法 | ApplyAdditiveKeyframe · ApplyAdditiveKeyframePerBone · ApplyAdditiveKeyframeWithBlendMask · MakeDynamicAdditive | 2 → 1 |
| 空间与归一化 | ConvertRotationsToMeshSpace · ConvertRotationsToLocalSpace · NormalizeKeyframeRotations | 1 → 1 |
| 暂存 | StoreKeyframeTransforms · SwapTransforms | 把栈顶存到一个变换数组里,或交换 |
| 过渡 | DeadBlendingTransition · DeadBlendingApply | 记录 / 应用外推 |
| 嵌套 | ExecuteProgram | 执行另一段程序 |
注意"混合"有两种:Overwrite 是"栈顶 × 权重 覆盖进累加器",Add 是"栈顶 × 权重 加进累加器"。多路混合(比如 Blend Stack 里五段动画)不是两两混合四次,而是先用 Overwrite 放第一段,再用 Add 依次加上其余各段,最后统一归一化旋转。这正是第 1.2 节说的"加权求和后统一归一化"。
还有一个枚举 EEvaluationFlags,API 说明是"控制求值一个节点时要计算哪些东西"。可以理解为:如果这一帧不需要曲线或属性,任务可以跳过它们。
FEvaluationVM / FEvaluationVMStack / EEvaluationFlags 见 EvaluationVM API(官方);任务列表见 UAFAnimGraph API(官方);Overwrite + Add 的多路混合方式为按名称推断
栈元素是整套姿态
关键帧栈;还有其他按名字区分的栈。
任务 = 栈操作
压入 0→1,混合 2→1,转换 1→1。
多路混合
Overwrite 放第一段,Add 加其余,最后统一归一化。
要点
程序结束时栈上剩一个姿态,就是这一帧的结果。
5.4
为什么把"算什么"和"怎么算"分开
多了一层"先写程序再执行",看起来是多绕了一步。它换来的东西有五样:
一,图的逻辑和数值计算分属两种代码。遍历是分支多、数据少的逻辑代码;任务是分支少、数据多的数值代码,每个任务都是对几百根骨骼做同一件事,适合写成紧凑的循环甚至 SIMD。两种代码分开,各自都能写得更好。
二,程序是数据,可以检查。出了问题,可以把这一帧的程序打印出来,看到"采样了哪几段、按什么权重混合",而不用在递归调用栈里单步。
三,程序可以序列化。API 里有一个 FSerializableEvaluationProgram。本文推断,这为"把一段固定的求值程序预先存下来"或"在别处重放"留了余地。
四,插入点可以接管求值。IEvaluationModifier 允许插入点接管求值、塞进自己的任务。因为求值是一串任务而不是一棵调用树,往中间插一段是自然的操作(第 6.7 节)。
五,为跨角色批处理留了空间。如果一百个角色的程序里都有"采样走路第 t 秒",理论上可以合并执行。目前公开资料没有说 UAF 已经这么做,但这是任务化求值在业界常见的收益,结构上已经具备条件。
这和开场比喻里的"点菜单和后厨"是同一件事:记菜单的人不碰锅,做菜的人不跑桌子。
FSerializableEvaluationProgram、IEvaluationModifier 见 API(官方);第三、五条的收益为推断
两种代码分开
遍历是逻辑,任务是数值循环。
程序可检查
打印一帧的程序就知道采了什么、怎么混。
程序可序列化
FSerializableEvaluationProgram(用途为推断)。
插入点可接管
IEvaluationModifier 往程序里塞任务。
要点
跨角色批处理:结构上具备条件,公开资料未证实。
5.5
LOD 姿态与骨骼映射:数据长什么样
栈上的"关键帧"在代码里是什么?公开资料能确定的有这几点:
姿态带着 LOD。System 和图之间传递的姿态类型叫 AnimNextGraphLODPose(在 Python API 里可见,比如"写姿态到实例化蒙皮网格"的节点就以它为输入)。也就是说,姿态从一开始就知道自己处在哪个 LOD、包含哪些骨骼,而不是一套完整骨架再加一个"需要哪些骨骼"的掩码。
有专门的骨骼映射。API 里有 FAnimNextBoneMapping,以及一个"重映射姿态"的 RigVM 单元 RigUnit_AnimNextRemapPose。这和跨骨架使用动画有关:动画资产的骨骼顺序和目标网格不同,需要一张映射表。
变换数组有 AoS 和 SoA 两种写法。源码笔记提到,TransformArrayOperations.h 里每个工具函数都有"结构体数组"和"数组结构体"两个版本。SoA 把所有骨骼的旋转放在一起、平移放在一起,更适合批量数值运算;笔记作者观察到当时的代码仍偏向 AoS。
每根骨骼一个结构体。按骨骼访问方便
同一分量连续存放。按分量批量运算方便,适合 SIMD
附录 B 的迷你实现会直接用 SoA,并在里程碑 2 解释为什么。
AnimNextGraphLODPose 见 RigUnit_UAFWriteInstancedSkinnedMeshPose 的 Python API(官方);FAnimNextBoneMapping 见 API(官方);AoS/SoA 观察见 RemRemRemRe 的笔记(社区)
LOD 姿态
姿态从一开始就带着 LOD 和骨骼集合。
骨骼映射
FAnimNextBoneMapping · RemapPose:跨骨架用动画。
AoS 与 SoA
批量数值运算更适合 SoA。
要点
附录 B 的迷你实现直接用 SoA。
5.6
调度:事件、Tick Group 与依赖
UAF 不再依赖骨骼网格组件的 Tick。System 里的每个事件都会变成一个可调度的单元:
Tick Group 与顺序。每个用户事件指定它要在哪个 Tick Group 结束,以及同组内的排序值。源码笔记提到,事件的基类还控制"是否给模块单独建 Tick 函数""放在哪个 Tick Group""是否在游戏线程上运行"。
显式依赖。组件上的 AddPrerequisite / AddSubsequent 把任意 Tick 函数挂到某个事件之前或之后;AddComponentPrerequisite 针对组件的主 Tick;AddModuleEventPrerequisite 针对另一个 UAF 组件的事件。FindTickFunction 可以按事件名找到对应的 Tick 函数。
插队的任务。QueueTask 把一段 lambda 排到指定事件上,并用 ETaskRunLocation 指定它在事件之前还是之后之类的位置运行。这是游戏代码在动画线程上"顺手做点事"的正规入口。
| 需求 | 用什么 |
|---|---|
| 动画要用到本帧的移动结果 | bSetupCharacterMovementDependency,或对移动组件 AddComponentPrerequisite |
| 骑手的动画要在坐骑之后 | 骑手组件 AddModuleEventPrerequisite(坐骑组件, 坐骑的 PrePhysics) |
| 布娃娃 / 物理要读本帧姿态 | 动画在 PrePhysics 事件里写姿态;物理组件挂 Subsequent |
| 武器挂点在动画之后更新 | 武器组件对动画事件 AddComponentSubsequent |
| 在动画线程上读一次结果再做点事 | QueueTask 到对应事件 |
ABP 时代,这类依赖大多由引擎在幕后安排(移动 → 动画 → 物理),少数特殊情况才需要手动处理。UAF 把它们全部摆到台面上。对小项目这是额外的工作;对角色互相依赖的大项目,这是一个早该有的控制面。
事件字段见 RigUnit_AnimNextUserEvent 的 Python API(官方);依赖与任务函数见 UUAFComponent API(官方);事件基类的职责见 RemRemRemRe 的笔记(社区)
Tick Group + 排序
每个事件指定在哪组结束、组内先后。
显式依赖
Prerequisite / Subsequent / 跨组件事件依赖。
QueueTask
在动画线程上插一段代码。
要点
引擎不再替你安排时序,你来声明。
5.7
RigVM 在这里扮演什么角色
UAF 有两台"虚拟机",容易混:
RigVM 执行的是逻辑:System 的事件图、变量绑定、图里那些算 alpha、算方向的小运算,以及 Run Graph、Write Pose 这些"大节点"。Control Rig 用的就是这台虚拟机。UAF 的资产(System、图)都编译成 RigVM 字节码;UUAFSystem 的父类链上就有 URigVMHost。
求值 VM 执行的是姿态计算:上一节那串任务。它是 UAF 自己的,和 RigVM 无关。
为什么逻辑要跑在 RigVM 上,而不是蓝图虚拟机?社区文章给出的理由是性能更接近原生代码、并且是沙盒化的执行环境。本文补充一点背景:RigVM 的节点(Rig Unit)是带明确输入输出的纯函数式结构,编译器能知道一段图读了什么、写了什么,这正是"在工作线程上安全运行"的前提。蓝图虚拟机做不到这一点,因为蓝图节点可以调用任意函数。
对使用者来说,最直接的影响是:System 里能用的节点,是 RigVM 节点,不是蓝图节点。习惯 ABP 事件图的人,会发现很多熟悉的蓝图函数在这里找不到,需要换成绑定、换成 RigVM 运算,或者在游戏线程上算好再送进来。
RigVM 承载见 Eyad 与 RemRemRemRe 的文章(社区);URigVMHost 见 UUAFSystem API(官方);"纯函数式便于证明线程安全"为本文推断
RigVM
跑逻辑:System、绑定、小运算、Run Graph。
求值 VM
跑姿态计算:任务程序。
节点不是蓝图节点
很多熟悉的蓝图函数在 System 里找不到。
要点
明确的输入输出,让编译器知道一段图读写了什么。
5.8
不用编辑器也能建图:图工厂与代码构建
UAF 的图不一定来自编辑器。API 里有一整套代码建图的机制:
FAnimNextSimpleAnimGraphBuilder:在代码里加 Trait Stack、往栈里压 Trait(AddTraitStructView)、把一个栈的输出连到另一个栈的某个子输入(AddLink),再把公共变量映射到 Trait 的潜在属性(AddVariableMapping)。
FAnimNextFactoryParams:描述"如何生成并初始化一张图"的参数包,里面有一个 Builder 和一组初始化任务。它的注释里写着,可以在初始化时访问 Trait 的结构,比如往里面塞一段 AnimSequence。
FAnimGraphFactory:按参数创建或复用程序化生成的图。同一组参数只生成一次。
图工厂资产:FUAFGraphFactoryAsset_Graph、_Animation、_BlendSpace。也就是说,给它一段动画或一个 Blend Space,它能直接"做"出一张只包含播放器的小图。Injection 播放一段动画时(第 6.7 节)走的就是这条路。
这就是开场比喻里的"预制菜"。它的实际意义在于:很多临时需求(播一段动画、套一个修饰)不需要预先在大图里留好节点,运行时按需生成一张小图挂进去即可,而且相同需求共享同一张图。引擎自带的 UAFTestSuite 和 UAFAnimGraphTestSuite 测试里有代码建图的写法,可以当作参考。
FAnimNextSimpleAnimGraphBuilder、FAnimNextFactoryParams、FAnimGraphFactory、FUAFGraphFactoryAsset_* 见 UAFAnimGraph API(官方);测试套件见 RemRemRemRe 的笔记(社区)
Builder
代码里加栈、压 Trait、连线、映射变量。
Factory Params
生成 + 初始化一张图的参数包。
图工厂
按参数创建或复用;动画 / Blend Space 直接生成小图。
要点
临时需求不用在大图里预留节点,运行时按需生成。
页签 5 / 8 · 约 2.6 小时
功能模块与控制面
UAF 插件索引里列了二十个相关插件。这一页签先逐个过一遍主要的:混合、Motion Matching、State Tree、Warping、Control Rig、Injection、通知、Anim Mixer、人群、Mover,以及和现有项目共存的方式。然后换成使用者的视角:程序、技术动画师、动画师各握哪些旋钮,骨骼、图内、角色、空间、时间五种范围分别在哪设。
06
功能模块
UAF 本身只提供骨架。大部分能力来自以 "UAF xxx" 命名的插件,它们的成熟度各不相同。
按 5.8 的插件索引,依赖 UAF 的插件有二十个,从 UAF Anim Graph、UAF Chooser、UAF Pose Search,到 Mover UAF、MetaHuman Creator 的 UAF 支持、Sequencer Anim Mixer。它们全部标为实验性。
这一章每节讲一块:它在 UAF 里以什么形式出现、和 ABP 时代的做法有什么不同、现在已知的限制是什么。第 8 章的准入表就是从这里来的。
6.1
先看地图:按问题找插件
二十个插件按名字排没有意义。按"一帧动画要回答的问题"来排,它们会自然落进六组。读这一章时,可以随时回到这张表。
| 问题 | 插件与 Trait | 集市 NPC 用吗 | 主角用吗 | 小节 |
|---|---|---|---|---|
| 播什么 | Chooser · Pose Search(Motion Matching)· State Tree | 用:Chooser + Motion Matching | 用:State Tree 替代状态机(待验证) | 6.3 · 6.4 |
| 怎么接上 | Blend Two Way · Blend Stack · Blend Inertializer · Dead Blending | 用:混合栈 + 惯性化 | 用 | 6.2 |
| 怎么修正 | Warping · Mirroring · Layering · Control Rig · RigLogic | 少量:Steering | 大量:IK、上半身层 | 6.5 · 6.6 |
| 怎么临时插一段 | Injection · Sequencer Anim Mixer | 几乎不用 | 大量(替代蒙太奇) | 6.7 · 6.9 |
| 结果给谁 | 通知 · UAF Mass · 实例化蒙皮网格 | 远处写到 ISKM | 骨骼网格 + 大量通知 | 6.8 · 6.10 |
| 怎么和现有系统共存 | Mover UAF · 在 ABP 里托管 · UAF Anim Node | Mover 可选 | 根运动 + 托管 | 6.11 – 6.13 |
对照最后两列就能看出第 8 章结论的来源:集市 NPC 用到的几行(播什么、怎么接上、结果给谁)恰好是社区验证最多、官方已经在用的部分;主角用到的几行(临时插入、根运动、大量通知与 IK)恰好是缺口最大的部分。
分组为本文整理;插件来自 UAF 插件索引(官方)
播什么
Chooser · Motion Matching · State Tree
怎么接上
混合栈 · 惯性化 · Dead Blending
怎么修正
Warping · Mirroring · Layering · Control Rig
怎么临时插一段
Injection · Anim Mixer
结果给谁
通知 · Mass · ISKM
怎么共存
Mover · ABP 托管 · Anim Node
要点
NPC 用到的行最成熟,主角用到的行缺口最大。
6.2
混合与平滑:Blend Two Way、Blend Stack、惯性化
混合在 UAF 里被拆成了几层,对应第 4.5 节的几个接口:
选谁:Blend by Bool 这类 Trait 实现离散混合,按条件在几个子节点间选一个。按权重混:Blend Two Way、Blend Layer 实现连续混合。怎么过渡:Blend Smoother 系列给离散混合加上平滑过渡,Blend Inertializer 改用惯性化,Dead Blending 有专门的任务。栈式混合:Blend Stack 系列实现第 2.7 节的后进先出混合栈。
这种拆法的好处在组合时体现。比如"按布尔切换、用惯性化过渡、只作用于上半身",在 ABP 里要么找一个恰好有这些选项的节点,要么嵌套三层;在 UAF 里是一个 Blend by Bool 基础 Trait,上面叠一个惯性化 Trait,再叠一个按骨骼的平滑 Trait。
有一个容易踩的点:惯性化需要知道"切换发生了",以及切换前的姿态和速度。它通过接口从下面的离散混合 Trait 得到切换事件,再用 Pose History(保存最近几帧姿态的 Trait)拿到速度。如果你的图里没有这些部件,惯性化就无从下手。这和 ABP 里"必须在上游放一个 Inertialization 节点"是同一类约束,只是表现形式不同。
Trait 名称见 Eyad 的分类与 UAFAnimGraph API(官方/社区);惯性化依赖 Pose History 的说法为按结构推断
选谁
Blend by Bool:离散混合。
按权重混
Blend Two Way · Blend Layer。
怎么过渡
Blend Smoother · Blend Inertializer · Dead Blending。
组合
一个基础 Trait + 若干加法 Trait = ABP 里三层嵌套。
要点
惯性化需要切换事件和最近几帧的姿态。
6.3
Chooser 与 Pose Search:在 UAF 里做 Motion Matching
Motion Matching 是目前社区里用 UAF 做得最多的事,也有比较完整的教程。按 5.7 的一篇设置教程,图里的做法是:先求值一张 Chooser 表,选出要用的 Pose Search 数据库;再把数据库和预先算好的轨迹喂给 Motion Matching 节点。
需要三样东西:Pose Search 数据库(动画素材)、Pose Search Schema(比较哪些骨骼和哪些轨迹点)、Motion Matching Trait。轨迹由 System 在跑图之前生成,有两个现成节点:Generate Trajectory from Character Movement Component 用于标准角色移动组件,Generate Trajectory from Mover 用于 Mover。社区教程里的一组参数是:历史采样 30 个、预测采样 15 个、预测采样间隔 0.1 秒。
from Character Movement 或 from Mover
按状态选数据库(一个或几个)
当前姿态 + 轨迹,在数据库里搜
把新选中的片段接上
5.8 有两条相关的更新。GASP 的 Mover 角色换用了一个新的 Chooser,带改进后的 Pose Match 列,可以在多个内部 Pose Search 数据库里做匹配,Blend Space 也能放进 Chooser 和 Blend Stack。ChaosMover 增加了供 Motion Matching 用的轨迹预测。这两条都是在 ABP 上做的,但它们用到的 Chooser 和 Pose Search 正是 UAF 也在用的同一套。
社区教程报告了一个让人振奋的结果:这样配好之后,角色做 Motion Matching 时不再占用游戏线程。但同一篇教程也提到,图在编辑器里显示为半透明的"非激活"状态,作者认为是功能还在开发中。
链路与参数见 farravid 的 5.7 教程与 Eyad 的教程(社区);GASP 5.8 的 Chooser 改进见 Epic 博客(官方);ChaosMover 轨迹预测见 5.8 版本说明(官方)
三样东西
数据库 · Schema · Motion Matching Trait
轨迹
from Character Movement / from Mover
Chooser 选库
5.8 的 Pose Match 列可在多个数据库里匹配
要点
社区报告:配好后 Motion Matching 不占游戏线程。
6.4
State Tree:状态逻辑搬出动画图
ABP 的状态机长在动画图里:状态、转换条件、每个状态播什么,全画在一张图上。UAF 没有状态机 Trait,状态逻辑交给引擎通用的 State Tree,通过 UAF State Tree 插件接入(API 里仍叫 AnimNextStateTree)。
5.5、5.6 的 API 参考把它的结构说得比较清楚:
| 类型 | 作用 |
|---|---|
UStateTreeAnimNextSchema | State Tree 的 Schema:规定这种 State Tree 能用哪些任务、条件和外部数据 |
FAnimNextStateTreeGraphInstanceTask | 显示名"AnimNext Graph"。说明是:把一张动画图压到混合栈上的基础任务 |
FAnimNextStateTreeRigVMTaskBase · RigVMConditionBase | 用 RigVM 写的任务和条件 |
FAnimNextStateTreeEvaluatorBase | 评估器基类:每帧给状态树提供数据 |
FAnimNextStateTreeTraitContext | 任务通过它拿到所在 Trait 的上下文(作为外部数据句柄) |
把这几条拼起来,就能看出"状态"在 UAF 里是怎么变成姿态的:
图里有一个 State Tree Trait,它持有一棵状态树,并在 Update 时让它 Tick。状态树进入某个状态时,状态上的 "AnimNext Graph" 任务把一张动画图压到混合栈顶;混合栈负责把新压上来的图和下面正在淡出的图接起来(惯性化或交叉淡化)。转换条件和任务可以用 RigVM 写,所以它们和 System 一样能在工作线程上跑。
这个设计的含义是:ABP 里"状态 + 转换"两件事被拆开了。状态树只决定"现在该在哪个状态、该播哪张图";"怎么过渡"统一交给混合栈。ABP 状态机里每条转换线上配的混合时长、混合曲线,在 UAF 里不再挂在转换上,而是由混合栈或惯性化 Trait 的设置决定。迁移状态机时,这是最需要重新想的一点。
另外两个好处:状态可以嵌套,有进入条件、任务、转换和评估器,比扁平的状态机更适合表达"战斗中 → 持枪 → 瞄准"这样的层级;动画和 AI、玩法共用同一种状态工具。
仍然缺的信息:5.8 版本的类名是否已改为 UAF 前缀、任务实例数据里有哪些字段(图资产、混合设置)、能否从状态树向图传参数。这些 API 页没有写,需要在引擎里确认。
AnimNextStateTree API 参考(5.5 / 5.6,官方):FAnimNextStateTreeGraphInstanceTask 的说明为"Basic task pushing AnimationGraph onto blend stack";拼接后的运行方式为本文推断
没有状态机 Trait
状态交给通用 State Tree。
进入状态 = 压一张图
"AnimNext Graph" 任务把动画图压到混合栈顶。
过渡归混合栈
ABP 转换线上的混合设置,在 UAF 里归混合栈或惯性化。
要点
迁移状态机时,最需要重新想的是"过渡挂在哪"。
6.5
Warping、Mirroring 与 Layering
这三个插件都是"在已有姿态上做修正",天然适合做成加法 Trait,也是 UAF 组合思路最直观的展示。
UAF Warping:Strafe Warping 让同一段前进动画适配斜向移动,Steering 让角色朝向平滑地转向目标方向,WarpTest 是测试用。它们对应 ABP 时代 Animation Warping 插件里的 Orientation Warping、Stride Warping 一类节点。
UAF Mirroring:把姿态左右镜像。镜像需要一张"哪根骨骼对应哪根"的映射表。作为加法 Trait 叠在播放器上,就能让同一段动画左右两用。
UAF Layering:分层混合,对应开场的上半身遮罩。遮罩用 Hierarchy Table 表达(第 1.3 节),Blend Layer Trait 按遮罩把一层姿态混进输入。
这三类修正有一个共同点:它们都是对姿态的局部、可开关的调整。在 ABP 里,你得在动画图里串一个节点;在 UAF 里,可以把它叠在需要它的那个节点上,而不改变图的结构。
插件与 Trait 名见插件索引与 Eyad 的分类(官方/社区);与 Animation Warping 的对应为背景知识
Warping
Strafe Warping · Steering
Mirroring
需要左右骨骼映射表。
Layering
Hierarchy Table 遮罩 + Blend Layer Trait。
要点
局部、可开关的修正,叠在需要它的节点上。
6.6
Control Rig 与 RigLogic
Control Rig 和 UAF 跑在同一台 RigVM 上,接入是最顺的。UAF Control Rig 插件提供 Control Rig Trait,在图里执行一个 Control Rig,常用于脚步 IK、手部 IK、程序化次级运动。
已知的一个限制来自社区教程:作者用 Control Rig 节点做脚部 IK 时,无法把 Control Rig 的变量暴露到 UAF 里,不确定是有意设计还是缺陷。如果你的 IK 依赖从外部传参数进 Control Rig(比如地面法线、IK 开关),需要先验证这一点。
RigLogic for UAF 把 MetaHuman 面部的 RigLogic 求值接进 UAF。另有 MetaHuman Creator - UAF support 插件。从插件名看,MetaHuman 的面部和身体都在往 UAF 上接,这和 5.8 MetaHuman 人群使用 UAF 一致(第 6.10 节)。
5.8 在 Control Rig 侧还有几项更新(Control Rig Physics 作为分层 Rig 叠在已有动画上、Control Rig Dynamics 粒子解算器),它们是 Control Rig 本身的功能,版本说明没有说它们和 UAF 的关系,本文不展开。
插件见 UAF 插件索引(官方);变量无法暴露见 Eyad 的教程(社区观察);Control Rig 5.8 更新见版本说明(官方)
同一台 RigVM
Control Rig 接入最顺。
已知限制
社区报告:Control Rig 变量暴露不到 UAF。
RigLogic for UAF
MetaHuman 面部求值接进 UAF。
要点
依赖外部参数的 IK,先验证变量暴露。
6.7
Injection:没有蒙太奇之后,怎么插一段动画
蒙太奇是 ABP 里"临时播一段动画"的主力:攻击、受击、交互,都是往某个插槽里插一段蒙太奇。UAF 目前不支持蒙太奇,Epic 员工在 2026 年 5 月的论坛回复里说,以后是否支持也还不确定,有讨论和实验,但没有定论。
UAF 的替代机制叫 Injection,对应开场比喻里的"插播广告"。图里预先放一个 Injection Site(插入点)Trait,运行时可以往这个点注入东西,用完再撤掉。
| 函数(FInjectionUtils) | 注入什么 |
|---|---|
PlayAnim | 一段 AnimSequence,带播放参数、淡入淡出设置和生命周期事件 |
InjectAsset | 任意资产,通过图工厂参数生成一张小图再注入(第 5.8 节) |
InjectEvaluationModifier | 一个求值修饰器,直接控制底层求值(第 5.4 节的 IEvaluationModifier) |
Inject | 原始的自定义参数 |
Uninject | 撤掉之前的注入请求 |
插入点用 FAnimNextInjectionSite 指定:一个指向图变量的引用(期望的插入点),加一个"找不到时是否退回模块默认插入点"的开关。也就是说,一个 System 可以有一个默认插入点,调用方不指定时就插到那里。蓝图侧有 UUAFInjectionLibrary 和对应的异步节点。
和蒙太奇比,Injection 缺了不少东西:没有分段、没有分支点、没有"跳到某段"、没有内置的插槽组互斥,也没有和网络复制的现成整合。它更接近一个底层原语:"在这个点把这段东西混进来,结束时告诉我"。你项目里用到的蒙太奇特性越多,迁移成本越高,这是评估 UAF 时最该先盘点的一项。
FInjectionUtils、FAnimNextInjectionSite、UUAFInjectionLibrary 见 UAFAnimGraph API(官方);"不支持蒙太奇"见 Epic 员工论坛回复(官方);与蒙太奇的差异为本文分析
不支持蒙太奇
Epic:以后是否支持未定。
Injection Site
图里预留插入点,可有模块默认点。
底层原语
PlayAnim · InjectAsset · InjectEvaluationModifier · Uninject
要点
没有分段、分支点、插槽组。先盘点你用了多少蒙太奇特性。
6.8
通知与同步
动画通知(脚步声、特效、伤害判定窗口)在 UAF 里由两个 Trait 负责:Notify Dispatcher 在时间线推进时分发通知,Notify Filter 做过滤,比如权重太低的动画不触发通知。能产出通知的 Trait 实现 INotifySource 接口。
另外还有一个依赖 UAF 的 Evaluation Notifies 插件。从名字推断,它处理的是在求值阶段产生的通知,但公开资料没有细节。
需要特别注意的是线程。动画在工作线程上推进,通知也是在那里产生的;而大部分通知的消费者(播声音、生成特效、伤害判定)在游戏线程上。所以通知必须被收集起来,再交回游戏线程处理。在 ABP 里,引擎在收尾阶段替你做了这件事。在 UAF 里,这个交接点在哪里、延迟多少,取决于你的 System 怎么安排,建议先用一个简单的脚步声测一下延迟。
同步组在第 1.4 节已经讲过:它是 Synchronize Using Groups Trait,加上时间线接口 ITimeline 和同步标记 FTimelineSyncMarker。
Trait 名称见 Eyad 的分类与 FAnimNextNotifyDispatcherTraitSharedData API(社区/官方);Evaluation Notifies 见插件索引(官方);线程交接为本文分析
Notify Dispatcher
随时间线推进分发通知。
Notify Filter
按权重等条件过滤。
线程交接
工作线程产生,游戏线程消费;先测延迟。
要点
同步:Synchronize Using Groups + ITimeline + 同步标记。
6.9
Sequencer 里的 Anim Mixer
5.8 版本说明里有一条明确提到 UAF 的条目:Sequencer 的 Animation Mixer(实验性)"也支持新的 Unreal Animation Framework 的工作流"。
Anim Mixer 让过场动画师在 Sequencer 里直接混合、分层、遮罩角色动画,不用单独做一个 Anim Blueprint。它可以为 ABP 的插槽或自定义动画实例创建混合器。API 里有一个 FMovieSceneAnimNextInjectionTarget,从名字看,它对 UAF 的接入方式是注入到 UAF 的插入点,也就是上一节的 Injection 机制。
Epic 社区有一篇入门教程,覆盖启用插件、选择动画目标模式、搭建带循环、混合和根运动支持的混合层。
对评估 UAF 的团队来说,这是一个风险很低的切入点:过场动画和游戏内角色可以是分开的设置,用 Anim Mixer + UAF 做一两个过场镜头,不会影响游戏内的 ABP。
5.8 版本说明(官方);FMovieSceneAnimNextInjectionTarget 见 MovieSceneAnimMixer API(官方,用途按名称推断);Anim Mixer 入门教程(Epic 社区)
版本说明明确支持
Anim Mixer 支持 UAF 工作流。
接入方式
注入到 UAF 的插入点(按 API 名推断)。
低风险切入点
过场和游戏内角色可以分开设置。
要点
用一两个过场镜头先试。
6.10
人群:UAF Mass 与实例化蒙皮网格
人群是 UAF 最有说服力的场景。巫师 4 技术演示(UE 5.6,基础版 PS5 上 60 帧)里有大量 NPC,CD PROJEKT RED 的博客把 UAF 和 Mass 都列为演示展示的工具,Unreal Fest 2025 还有一场专讲 UAF 在演示中应用的演讲。
5.8 里和人群相关的几块:
UAF Mass 插件:"UAF 的 Mass 集成",依赖 MassGameplay、UAF 和 UAF Anim Graph。
写到实例化蒙皮网格:有一个 RigVM 单元 UAFWriteInstancedSkinnedMeshPose,把姿态写到实例化蒙皮网格组件(ISKM)。ISKM 继承自蒙皮网格组件而不是骨骼网格组件,不支持 Anim Blueprint,Epic 员工在论坛里说得很明确。所以在 ISKM 上用"图驱动"的动画,UAF 是现在唯一的官方选项。
MetaHuman Crowd:5.8 版本说明写到,MetaHuman 人群"按距离使用 Unreal Animation Framework 或简单序列来应用动画"。远处的角色用 ISKM,并且不运行后处理 Anim Blueprint。
骨骼网格 + ABP 或 UAF
姿态写到 ISKM
ISKM + 动画序列直接采样
Epic 员工对"Hitman 式人群"的建议是 5.8 起用 Mass + ISKM + AnimSequenceTransformProvider(一种生成 GPU 格式动画数据的方式,比 AnimBank 更灵活)。那条建议没有提 UAF,说明"简单序列"这一层完全可以不用 UAF;UAF 的位置是在中间那一层,需要混合和逻辑、又不值得一个完整 Actor 的角色。另外论坛上有人报告 5.8 中 Mass + ISKM 反复生成销毁时动画轨道池持续增长,评估时要盯一下显存。
UAF Mass 插件索引、RigUnit_UAFWriteInstancedSkinnedMeshPose、5.8 版本说明 MetaHuman Crowd 条目、MetaHuman Crowds 文档(官方);ISKM 不支持 ABP 与人群建议见 Epic 员工论坛回复(官方);巫师 4 见 CDPR 博客
UAF Mass
Mass 集成插件。
写到 ISKM
ISKM 不支持 ABP;图驱动只能用 UAF。
MetaHuman Crowd
按距离用 UAF 或简单序列。
要点
UAF 的位置在中间层:要混合和逻辑,又不值得完整 Actor。
6.11
Mover 与根运动
Mover 是 Epic 的新一代移动组件,用来替代 Character Movement Component。Mover UAF 插件把两者接起来。5.8 版本说明里,Mover 有一次覆盖核心移动、网络、动画和 ChaosMover 的大更新,其中包括改进的根运动和蒙太奇处理(那是 Mover 与 ABP 侧的蒙太奇,不是 UAF)。
社区的 5.7 教程给出的最小链路是:继承 BaseAnimatedMannyPawn 建一个 Mover 角色;在 System 里通过变量绑定拿到 Mover 组件(作者说这是获取组件或所有者 Actor 的新方式),生成轨迹,再跑图写姿态。
根运动在这里是最需要验证的一环。第 1.6 节说过,根运动意味着动画结果要回流给移动。UAF 在工作线程上算动画,Mover 有自己的模拟和网络预测,两者之间的数据方向和时序,公开资料还没有完整的说明。Epic 员工在论坛被问到"大量根运动角色"时,只说性能是首要目标、会尽量并行,没有给出根运动的具体细节。
建议:如果你的项目重度依赖根运动(近战、攀爬),在 UAF 上先做一个单独的验证原型,重点测网络下的表现,而不是先迁移移动系统。
Mover UAF 见插件索引(官方);Mover 5.8 更新见版本说明(官方);最小链路见 farravid 的 5.7 教程(社区);根运动现状见 Epic 员工论坛回复(官方)
Mover UAF
Mover 与 UAF 的桥接插件。
最小链路
绑定拿 Mover → 生成轨迹 → 跑图写姿态。
根运动未明
时序与网络细节公开资料不足,先做原型。
要点
重度根运动的项目,先验证,再迁移。
6.12
把 UAF 图挂进 Anim Blueprint
这是"部分模块先用起来"最重要的一条路:UAF 的图可以作为一个节点,在 Anim Blueprint 里运行。
RemRemRemRe 的 5.6 笔记里有一个演示:同一张 UAF 图,左边直接由 UAF 模块驱动,右边通过一个特殊节点托管在 Anim Blueprint 里,两边结果一致。API 里能找到对应的类:编辑器节点 AnimGraphNode_AnimNextGraph 和运行时节点 AnimNode_AnimNextGraph,蓝图里还有 Make / Break UAF Graph 结构。
这条路的意义在于宿主不变:角色仍然由 ABP 驱动,蒙太奇、插槽、通知、事件图都照旧;只有图里的某一块(比如移动部分的 Motion Matching、或者一套 Trait 组合出的上半身层)换成 UAF 图。出了问题,把这个节点换回原来的 ABP 节点即可。
代价也很清楚:UAF 图在 ABP 里运行时,线程和调度跟着 ABP 走,第 3 章说的"离开游戏线程""显式时序""轻量实例"这些收益基本拿不到。它适合用来积累 Trait 和图的使用经验、验证某个 UAF 插件的功能,而不是用来拿性能。
演示见 RemRemRemRe 的 5.6 笔记(社区);AnimGraphNode_AnimNextGraph、AnimNode_AnimNextGraph、Make/Break UAF Graph 见 API(官方);收益分析为本文推断
宿主不变
ABP 照旧,图里的一块换成 UAF。
可回退
换回原来的 ABP 节点即可。
拿不到性能收益
线程与调度跟着 ABP 走;用来积累经验。
要点
部分落地最重要的一条路。
6.13
5.8 新出现的 UAF Anim Node
5.8 的插件列表里有一个新插件:UAF Anim Node,描述只有一句"UAF 的节点系统"。它依赖 UAF、UAF Anim Graph、Control Rig 和 RigVM,而 UAF Chooser、Pose Search、State Tree、Warping、Shared Assets 都依赖它。
从 API 看,它定义了另一种运行时节点:
FUAFAnimNode 是基类,有父节点指针和子节点数组,有 PreAnimOp / PostAnimOp 两个"动画操作",有 PreUpdate / PostUpdate 两个更新入口(注释建议尽量用 PreUpdate),有总权重、正在淡出、刚变得相关等标志,生命周期由 FUAFAnimNodePtr 管理。派生类有 Sequence Player、Blend Stack、Timed Transition、Apply Additive、Make Dynamic Additive、Input Value、Modifier、Null Pose。
FUAFAnimNodeFactory 维护一张"资产类 → 节点构造函数"的注册表,可以从任意 UObject 创建对应的节点。Input Value 节点的说明是:在 PreUpdate 时从图变量读出一包值,通过它的 AnimOp 压到求值栈上。
本文的读法(推断,未经官方确认):这是一种以 C++ 对象为节点、在运行时按资产动态搭建的节点模型,节点通过 AnimOp 向同一套求值栈贡献任务。它和 Trait Stack 的关系,是替代、并存还是作为某些插件的底层,公开资料没有说。Chooser、Pose Search、State Tree 这些"运行时决定播什么"的插件都依赖它,说明它很可能服务于"动态选出一个资产、马上把它变成可播放节点"的场景。
这一节的结论只能是:5.8 的 UAF 内部还在演化,连节点模型都出现了第二套。这也是 Epic 说"核心部分仍在变化"的一个直接证据。
UAF Anim Node 插件索引、FUAFAnimNode、FUAFAnimNodeFactory、FUAFInputValueAnimNode API(官方);用途与关系为本文推断
新插件
UAF Anim Node:"UAF 的节点系统"。
C++ 对象节点
子节点、AnimOp、Pre/PostUpdate、工厂注册表。
关系未明
与 Trait Stack 是替代还是并存,资料没说。
要点
节点模型都出现了第二套:核心仍在变化。
07
控制面:谁调什么,范围怎么设
一个动画系统好不好用,取决于每个旋钮归谁、作用在多大范围、能不能在运行时改。
前面几章讲的是 UAF 怎么运行。这一章换成使用者的视角:程序、技术动画师、动画师和美术各自握着哪些旋钮,这些旋钮存在哪个资产里,作用范围有多大。
"范围"在动画里有五种意思:作用于哪些骨骼、图里的哪一段子树、哪些角色、离相机多远的空间、多久更新一次的时间。ABP 时代这五种范围散落在骨架资产、动画蓝图、项目设置和插件里;UAF 把其中几种换了地方存。这一章逐个讲清楚它们在哪设、谁来设,最后把集市案例的全部旋钮落成一张表。
7.1
三类人,三层旋钮
先按人分。下面的分工是本文的建议,不是 Epic 的规定;它的依据是每个旋钮存在哪种资产、改它需要什么技能、改错了影响多大。
程序握着"系统怎么跑":自定义 Trait 和任务、System 事件的线程与时序、变量绑定读哪些游戏数据、组件上的依赖、Injection 的调用、规模档位怎么触发。改这些需要写 C++ 或理解线程,改错了会崩溃或出现数据竞争。
技术动画师握着"图怎么搭":Trait 怎么组合、插入点放在哪、Chooser 表的列、Pose Search 比较哪些骨骼、State Tree 的状态和条件、混合时长,以及决定把哪些参数开放给动画师。改错了画面会不对,但不会崩溃。
动画师和美术握着"内容和取值":动画素材与同步标记、数据库里收哪些动画、遮罩里每根骨骼的权重、人群的距离和数量档位、过场里的混合,以及技术动画师开放出来的那些变量的值。
和 ABP 比,最大的变化在中间那一层。ABP 里,技术动画师和程序常常在同一个蓝图里工作:事件图归程序,动画图归技术动画师,但它们是同一个文件。UAF 把 System(程序的地盘)和 Anim Graph(技术动画师的地盘)拆成了两个资产,中间用变量连接。变量清单就成了两类人之间的接口约定,这一点在第 7.7 节展开。
分工为本文建议;资产归属依据第 3–6 章各节
程序
系统怎么跑:Trait、线程时序、绑定、依赖、注入、规模档位
技术动画师
图怎么搭:组合、插入点、Chooser、Schema、State Tree、混合
动画师 · 美术
内容和取值:素材、数据库、遮罩、人群档位、过场、变量取值
接口约定
System 与 Graph 分成两个资产,变量清单是两类人之间的合同
7.2
范围的五个维度
"这个效果作用在哪"可以沿五个方向问。每个方向在 UAF 里都有对应的设置位置:
| 维度 | 问的是 | UAF 里在哪设 | ABP 里在哪设 | 谁设 |
|---|---|---|---|---|
| 骨骼 | 只影响上半身?只影响手臂? | Blend Mask 资产(包着一个 Hierarchy Table);按骨骼的混合 / 加法 / 平滑 Trait | 骨架资产里的 Blend Mask 与 Blend Profile | 美术填权重,技术动画师决定用在哪 |
| 图内 | 只修正跑步,不修正待机? | Trait 叠在哪个节点上;Sub Graph;Injection Site 放在哪一层 | 节点串在哪条线上;插槽放在哪 | 技术动画师 |
| 角色 | 只有卫兵用这套逻辑? | 组件选哪个 System;共享变量资产;每实例的变量值 | 每个角色选哪个动画蓝图 / 子类 | 程序 + 技术动画师 |
| 空间 | 离相机多远换便宜的做法? | 网格 LOD → LOD 姿态;人群的距离档位;RigLogic 的 LOD 阈值 | LOD 设置、URO、Animation Sharing | 美术 + 程序 |
| 时间 | 多久算一次?谁先算? | System 事件的 Tick Group 与依赖;更新频率(暂无公开的 UAF 方案) | URO、Animation Budget Allocator | 程序 |
两个观察。第一,骨骼范围从骨架资产里搬了出来:ABP 的遮罩存在 Skeleton 里,改一次遮罩就是改一次骨架资产,多人协作时很容易冲突;UAF 的遮罩是独立资产。第二,时间范围是目前最大的空白:ABP 有成熟的降频和预算工具,但它们挂在骨骼网格组件的 Tick 上,公开资料没有说明它们对 UAF 组件是否有效(第 7.5 节)。
UAF 一列依据第 3–6 章与 UUAFBlendMask、RigLogic Trait API(官方);ABP 一列见 Blend Masks and Blend Profiles 与 Animation Budget Allocator 文档(官方)
骨骼
Blend Mask 资产 · 按骨骼 Trait
图内
Trait 叠在哪 · Sub Graph · Injection Site
角色
System · 共享变量 · 实例取值
空间
LOD 姿态 · 人群档位 · LOD 阈值
时间
Tick Group 与依赖;降频方案暂无公开资料
要点
遮罩从骨架资产里搬了出来;时间范围是最大的空白。
7.3
骨骼范围:Blend Mask 与 Hierarchy Table
开场的"上半身遮罩"在 UAF 里落成一个具体资产:UUAFBlendMask。API 的说明是"定义分层时混合包含哪些部分的资产"。它有三个字段:一个 Skeleton(只读,标明它属于哪套骨架)、一个 Table(一张 Hierarchy Table),以及一份运行时用的扁平缓存,避免每次都遍历树。它对外提供三组权重:按骨骼的、按曲线的、按属性的。
也就是说,一份遮罩不只决定"哪些骨骼参与",还决定"哪些曲线参与"。比如上半身换成举弓姿态时,面部曲线要不要一起换,在同一份遮罩里设。
试试"渐变"预设:spine 0.3、chest 0.7、手臂 1。上半身是逐渐"拧"过去的,而不是从腰部突然断开。这是分层混合里最常用的技巧:权重在脊柱上分几段过渡,接缝就看不出来。
美术怎么编辑。ABP 时代是在骨架树的 Options 里加 Blend Mask,然后在 Blend 列里拖滑块,可以右键"递归设置"给子骨骼;遮罩和 Blend Profile 共用一列,都存在 Skeleton 资产里。UAF 的遮罩存在 Hierarchy Table 里,按层级编辑,思路相同,但它是一个独立资产,可以按用途建多份("举弓上半身""受击左臂"),不会互相覆盖,也不用改骨架。
谁在用遮罩。Blend Layer Trait 按遮罩把一层姿态混进输入;Apply Additive 有按骨骼权重的版本;混合平滑有按骨骼的版本(ISmoothBlendPerBone),对应 ABP 的 Blend Profile:同一次过渡里,腿可以比上身切得快。
设计建议。遮罩按"身体区域 × 用途"命名,比如 BM_UpperBody_Aim、BM_LeftArm_Hit;脊柱至少分三段过渡;手指、面部这类细骨骼用递归统一设置;每份遮罩只给一种用途,宁可多建几份,也不要一份遮罩被三个地方共用、谁都不敢改。
UUAFBlendMask API(官方);ABP 遮罩与 Blend Profile 的编辑方式见 Blend Masks and Blend Profiles 文档(官方);ISmoothBlendPerBone 见 UAFAnimGraph API;脊柱分段与命名为本文建议
UUAFBlendMask
Skeleton + Hierarchy Table + 扁平缓存
三组权重
骨骼 · 曲线 · 属性
独立资产
不再存进 Skeleton,按用途建多份
建议
脊柱至少三段过渡;一份遮罩一种用途
要点
权重在脊柱上分几段过渡,接缝就看不出来。
7.4
空间范围:按距离分层
离相机越远,角色占的像素越少,花在他身上的动画计算就该越少。"按距离分层"有三种粒度,从细到粗:
骨骼数随网格 LOD 减少。UAF 在 System 和图之间传的是 LOD 姿态(第 5.5 节),写姿态的单元 WriteSkeletalMeshComponentPose 也以 LOD 姿态为输入。网格降到低 LOD、骨骼变少时,姿态里需要算的骨骼也随之变少。这一层由美术在网格的 LOD 设置里决定。
部分功能按 LOD 关闭。RigLogic 的 UAF Trait 有一个 LODThreshold:"在哪一级 LOD 以内才求值"。面部 RigLogic 很贵,远处直接关掉。可以照这个思路给自己的 Trait 加同样的阈值,比如 Control Rig 的脚步 IK 只在 LOD 0–1 运行。
整个表示方式按距离切换。这是人群的做法。以 MetaHuman 人群为例,它的 Mass 可视化 Trait 上有:完整 Actor 的距离、切到实例化表示的距离、停止渲染的截断距离、每一级的数量上限;动画上还有时间错开、同时混合的数量和混合时长。实例化 LOD 还可以按屏幕尺寸设置。
把"近"的上限调到 10、"中"调到 60,再把近界拉远:近档很快满了,多出来的人被挤进中档,最近的那十个才是完整 Actor。数量上限比距离更能保住帧率,因为人群会聚集,单靠距离,一群人走近时成本会突然翻倍。
这张图里的三档是本文建议的 UAF 用法:近处完整 Actor(ABP 或 UAF 驱动骨骼网格),中间 Mass + UAF 写到实例化蒙皮网格,远处实例化网格直接播简单序列。MetaHuman 人群的官方配置和它对应,但官方文档描述的实例化角色用的是预烘焙的 GPU 动画,并没有说中间那一档用 UAF;5.8 版本说明里"按距离使用 UAF 或简单序列"那句话,是中间档存在的依据。
谁设。距离和数量由美术或关卡设计在 Mass 实体配置里设,因为它们和场景密度、镜头构图有关。不同平台用不同档位,由程序用可扩展性设置去缩放,比如在低配平台上整体乘一个系数。
WriteSkeletalMeshComponentPose 与 LOD 姿态见 Python API(官方);RigLogic Trait 的 LODThreshold 见 FUAFRigLogicTraitSharedData API(官方);人群参数见 Create MetaHuman Crowds 文档与 FMetaHumanCrowdInstancedLODSettings API(官方);三档用法为本文建议
骨骼数
网格 LOD 越低,LOD 姿态越小
功能开关
RigLogic Trait 的 LODThreshold;自定义 Trait 可照做
表示方式
完整 Actor → Mass + UAF → 简单序列 → 截断
数量上限
比距离更能保住帧率:人群会聚集
要点
距离和数量归美术;平台缩放归程序。
7.5
时间范围:更新频率与预算
时间范围有两个问题:谁先算,以及多久算一次。
"谁先算"在 UAF 里讲得很清楚:System 事件的 Tick Group 和组内排序、组件上的前后依赖、移动依赖(第 5.6 节)。这一层归程序。
"多久算一次"目前是空白。ABP 时代有两套成熟工具:一是 URO(Update Rate Optimization),按距离和屏幕尺寸让远处角色隔几帧更新一次、中间插值;二是 Animation Budget Allocator,给骨骼网格动画设一个每帧的毫秒预算(a.Budget.BudgetMs,默认 1.0),超预算时按显著性降低不重要角色的更新频率或改成插值,相关上限如 a.Budget.MaxTickRate。它要求把网格组件换成 USkeletalMeshComponentBudgeted。
这两套工具都挂在骨骼网格组件自己的 Tick 上。而 UAF 要求把骨骼网格的动画关掉,由 UAF 组件的 System 事件来驱动。本文推断它们对 UAF 组件不起作用,文档也没有提到 UAF。
在官方给出方案之前,可以用这几种办法自己控制更新频率:
- 换档代替降频。远处的角色不降频,直接按第 7.4 节换到更便宜的表示方式。这是人群的主路线。
- 用变量传"重要程度"。程序按显著性写一个变量,图里据此关掉 IK、减少混合层数。频率不变,每次的工作量变少。
- 自己调度事件(风险最高)。在游戏代码里按显著性决定某一帧要不要让某个组件的事件运行。这等于自己实现 URO,要处理插值和根运动,只建议在有明确数据支持时做。
Animation Budget Allocator 文档(官方);URO 为背景知识;"对 UAF 不起作用"为本文推断,需要实测确认
谁先算
Tick Group · 排序 · 依赖:已有,归程序
多久算一次
URO 与 Budget Allocator 挂在网格 Tick 上,对 UAF 未知
替代办法
换档 · 变量传重要程度 · 自己调度(风险高)
要点
官方方案出来前,用换档代替降频。
7.6
图内范围:一个修饰只作用在哪里
"这个修正只作用于跑步,不作用于待机""受击动画只盖上半身",这类范围由东西放在图的哪个位置决定。UAF 里有四种工具:
Trait 叠在哪个节点上。加法 Trait 只影响它所在节点及其子树。把侧倾 Trait 叠在"跑步"那个 Blend Space Player 上,它就只在跑步时生效;叠在根节点上,就对全身全状态生效。这是 UAF 最细的范围控制,也是它和 ABP"在线上串一个节点"最大的不同:范围跟着节点走,不跟着连线走。
Sub Graph。把一段逻辑封装成子图,范围就是这个子图的输出。适合"上半身层"这样自成一体的部分。
Injection Site 放在哪一层。这决定了插进来的动画能盖住什么。插入点放在最终输出前,注入的动画覆盖全身;放在上半身层的输入处,再经过上半身遮罩,注入的受击就只影响上半身,腿照常跑。一个 System 可以有多个插入点,加上一个默认插入点(调用方不指定时用它)。
作用域接口。API 里有 IScopedTraitInterface 和 IScopedTagInterface,含义是"在一棵子树范围内生效的接口或标签"。按名字推断,它们允许一个节点给它下面的整棵子树提供某种上下文,比如"这个子树处于瞄准状态"。公开资料没有用法说明。
插入点 IS_FullBody:翻滚、处决
插入点 IS_UpperBody:受击、换弹
叠在根节点:对全部生效
LOD 阈值以外跳过
设计建议。插入点按"能盖住多大范围"命名并成对设计(全身 / 上半身),由技术动画师维护一张"插入点清单",程序调用 PlayAnim 时只认清单里的名字;修正类 Trait 尽量叠在它真正要影响的那个节点上,而不是一律叠在根上,否则以后想排除某个状态就得重构。
Trait 作用于所在节点的子树、Injection Site 与默认插入点见第 4.2、6.7 节(官方 API);IScopedTraitInterface / IScopedTagInterface 见 UAFAnimGraph API(官方,用途为推断);分层布局为本文建议
Trait 叠在哪
只影响该节点子树:范围跟着节点走
Sub Graph
自成一体的一层
插入点放在哪层
决定注入的动画能盖住什么
作用域接口
IScopedTraitInterface · IScopedTagInterface(用法未公开)
要点
插入点成对设计:全身 / 上半身。
7.7
参数暴露:程序给动画师开哪些口子
System 和 Anim Graph 拆开之后,两者之间只剩变量。变量清单就是程序、技术动画师和动画师之间的合同。UAF 提供的暴露手段有这几种:
System 变量与绑定。程序在 System 里定义变量,绑定到游戏数据。这一层只读游戏状态,不该放"手感"参数。
共享变量资产。多个 System 或图共用的一组变量,比如"NPC 通用参数"。按角色类型建一份,美术改值不用碰 System。
Trait 参数连变量。Trait 的共享数据里标成潜在属性的成员(第 4.3 节)可以连到变量,代码建图时用 AddVariableMapping,也可以用 AddVariableMappingToAll 把一个变量映射到所有同名属性。连了变量的参数就能按实例、在运行时变化。
组件上的读写。游戏代码和蓝图通过 SetVariable 改值,适合关卡或玩法临时调整(比如"这个卫兵受伤了,侧倾加大")。
暴露得好不好,看下面这张清单有没有写全:
| 变量 | 谁写 | 谁读 | 范围 / 单位 | 默认 | 运行时改 |
|---|---|---|---|---|---|
| Velocity | 绑定(程序) | 图 | cm/s,世界空间 | — | 每帧 |
| GroundSpeed | 绑定 WT(程序) | 图、Chooser | 0–800 cm/s | — | 每帧 |
| LeanAmount | 共享变量(动画师) | 侧倾 Trait | 0–1,无单位 | 0.6 | 否 |
| LeanMaxAngle | 共享变量(动画师) | 侧倾 Trait | 0–20° | 8° | 否 |
| Detail | 游戏代码(程序) | 图:开关 IK、层数 | 0 / 1 / 2 | 2 | 按显著性 |
| InjuredBlend | 游戏代码(玩法) | 上半身层 | 0–1 | 0 | 是 |
三条经验:按意图命名,不按实现命名(叫 LeanAmount,不叫 AdditiveAlpha_03);每个开放给动画师的变量都写范围和单位,否则就会有人把角度当弧度填;绑定变量和手感变量分开放,前者在 System 里由程序管,后者在共享变量资产里由动画师管,两边改动互不冲突。
变量机制见第 3.4、4.3、5.8 节(官方 API);清单写法与命名为本文建议,示例值为虚构
System 变量
绑定游戏数据,程序管
共享变量资产
手感参数,动画师管
Trait 参数连变量
潜在属性 · AddVariableMapping(ToAll)
变量清单是合同
谁写、谁读、范围单位、默认、能否运行时改
要点
按意图命名;写范围和单位;绑定与手感分开放。
7.8
落到集市:两张控制表
最后把这一章的东西落到贯穿案例上。下面两张表回答同一个问题:这个角色的每个旋钮,在哪设、谁设、作用多大范围。
| 集市 NPC × 200 | |||
|---|---|---|---|
| 旋钮 | 在哪设 | 谁 | 范围 |
| 近 / 中 / 远档的距离与上限 | Mass 实体配置 | 关卡美术 | 空间:每类人群 |
| 平台缩放系数 | 可扩展性设置 | 程序 | 空间:全局 |
| 选哪个数据库 | Chooser 表 | 技术动画师 | 角色:每类 NPC |
| Motion Matching 比哪些骨骼与轨迹点 | Pose Search Schema | 技术动画师 | 角色:每个数据库 |
| 收哪些动画 | Pose Search 数据库 | 动画师 | 角色:每个数据库 |
| 惯性化时长 | 混合 Trait 参数 | 技术动画师 | 图内:混合栈 |
| Steering 强度 | 共享变量 | 动画师 | 角色:每类 NPC |
| 脚步 IK 只在 LOD 0–1 | Trait 的 LOD 阈值 | 技术动画师 | 空间 + 图内 |
| 移动依赖、事件 Tick Group | 组件 / System | 程序 | 时间:每类 NPC |
| 主角"猎人"(试点期间保持 ABP,只托管 UAF 图) | |||
|---|---|---|---|
| 旋钮 | 在哪设 | 谁 | 范围 |
| 托管哪一块(如上半身层) | ABP 里的 UAF 图节点 | 技术动画师 | 图内:一层 |
| 上半身遮罩 | UAF Blend Mask 资产 | 动画师 | 骨骼 |
| 上半身层的侧倾、注视 | Trait + 共享变量 | 技术动画师 + 动画师 | 图内 + 骨骼 |
| 蒙太奇、插槽、根运动 | ABP(不动) | 原负责人 | — |
对照两张表能看到试点的边界:NPC 用到了全部五个维度,正好能检验 UAF 的控制面是否顺手;主角只用到骨骼和图内两个维度,而且都在 ABP 托管的那一块里,出了问题不影响主角的其余部分。
控制面设计的目标:每个旋钮只有一个主人,每个主人只改自己的资产。
本文建议;参数位置依据本章各节
NPC
五个维度全用:空间档位、Chooser、Schema、遮罩外的修正、时序
主角
只用骨骼与图内两个维度,在 ABP 托管的那一块里
目标
每个旋钮一个主人,每个主人只改自己的资产
页签 6 / 8 · 约 1.4 小时
落地:现状、准入与排错
官方怎么说、各版本走到了哪、哪些模块现在能试以及怎么试。然后是搭最小角色的步骤、从 ABP 迁移的对照表、调试与性能的现状,最后是坑的速查表和排错路线。
08
现状与准入
UAF 整体还不能上生产,但有几条路可以在不押上项目的前提下开始积累。
前面六章讲的是 UAF 的设计。这一章回到现实:Epic 自己怎么评价它、每个模块的成熟度如何、一个团队现在应该怎么做。你们"整体不可用、部分模块可试"的判断,和公开资料是一致的,这一章把"部分"具体到模块。
8.1
官方怎么说:实验性,且不建议直接上
先把官方口径列清楚,这是所有决策的基础。
插件状态:UAF 插件版本 0.1,在 5.7 和 5.8 中都是实验性,默认不启用,两个版本之间状态没有变化。所有 "UAF xxx" 插件的索引页都带着"发布时请谨慎"的提示。
Epic 员工在 2026 年 5 月的论坛回复("General questions on UAF")里说了这几件事:
| 话题 | 官方回复(意译) |
|---|---|
| 是否推荐使用 | 除非有非常好、非常具体的理由,否则不建议团队使用;核心部分仍在变化,项目会有风险。已更新 UAF FAQ |
| 性能与并行 | 性能是首要目标之一,会尽可能并行化 |
| 蒙太奇 | 目前不支持;以后是否支持也不确定,有讨论和实验,没有结论 |
| 文档与样例 | 还没有写文档,也没有计划做样例内容 |
| 时间表 | 5.8 中没有排期;没有日期;离"一般可用"还要很长时间(约一年);建议关注版本更新和源码仓库 |
| 游戏数据接入 | 游戏数据会在图运行的线程上可访问,但这部分设计还在变化 |
有两点要放在一起看。一方面,官方态度明显保守:没有文档、没有样例、没有日期。另一方面,5.8 里 UAF 已经被用在了 MetaHuman 人群和 Sequencer Anim Mixer 这些官方功能里,插件数量也在增加。合理的解读是:UAF 已经在 Epic 内部的特定场景里使用,但对外的 API 和工作流还不稳定,Epic 不愿意为外部项目背书。
这也解释了一个现象:很多文章说"UAF 是 Anim Blueprint 的继任者",这个方向没有疑问,但"继任"的时间点在 5.x 的范围内看不到。Epic 在 State of Unreal 2026 上说,5.8 是 UE5 最后一个计划中的大版本(必要时保留 5.9 的可能),UE6 的目标是 2027 年底推出抢先体验版。本文推断,UAF 的稳定化更可能与 UE6 这个节点相关,而不是某个 5.x 版本;Epic 的那篇新闻稿本身没有提到 UAF。
插件状态见 Unreal Directive;官方回复见 Epic 论坛 "General questions on UAF"(2026-05);UE6 时间点与"5.8 是最后一个计划中的大版本"见 Epic 的 State of Unreal 2026 新闻稿(官方);与 UAF 的关联为本文推断
插件状态
v0.1 · 实验性 · 默认不启用
官方建议
无充分具体理由不建议使用;核心仍在变。
缺什么
没有蒙太奇、没有文档、没有样例、没有日期。
但已在官方功能里用
MetaHuman 人群 · Anim Mixer
要点
方向确定,时间点在 5.x 里看不到。
8.2
版本路线:5.4 到 5.8
UAF 的名字、资产和 API 在几个版本里变化很大。读老教程时,先对一下它是哪个版本写的。
| 版本 | 状态与变化 | 读老资料时注意 |
|---|---|---|
| 5.4 | API 参考中出现 AnimNext 插件,求值 VM(FEvaluationProgram、FEvaluationVM)已经存在 | 全部叫 AnimNext |
| 5.5 | AnimNextAnimGraph 中有 IEvaluate 等 Trait 接口 | 同上 |
| 5.6 | 巫师 4 技术演示(2025 年 6 月)使用 UAF;ue5-main 上开始从 AnimNext 改名为 UAF;资产菜单:Anim Graph、Data Interface、Module、State Tree、Workspace | Module = 后来的 System;Data Interface 后来演变(推断) |
| 5.7 | UAF 插件以 v0.1 实验性出现在插件列表;社区出现 Mover 2.0 + UAF + Motion Matching 的完整教程 | 组件可能仍叫 AnimNext Component |
| 5.8 | UUAFComponent / UUAFSystem;新增 UAF Anim Node、UAF Mass;MetaHuman 人群按距离用 UAF;Anim Mixer 支持 UAF;资产向导 | 头文件名仍带 AnimNext;不少类仍以 FAnimNext 开头 |
两条读资料的经验:
类名前缀混用是正常的。5.8 里 UUAFComponent 住在 AnimNextComponent.h,UUAFSystem 住在 AnimNextModule.h,大量 Trait 共享数据仍叫 FAnimNext...,新加的则叫 FUAF...。改名是渐进的,搜源码时两个前缀都要搜。
先看是不是改过名的同一个东西。旧教程里"把 Module 拖进 Workspace""创建 Data Interface",在 5.8 里对应 System 和共享变量。有些字段会留下 _DEPRECATED 后缀,比如 UUAFSystem 上的 DefaultState_DEPRECATED、PropertyBag_DEPRECATED,这是读懂迁移路径的好线索。
5.4/5.5 见对应版本 API 参考;5.6 见 RemRemRemRe 笔记与 UAF FAQ 帖子;5.7 见 Unreal Directive 与 farravid 教程;5.8 见 API 参考与版本说明
5.4 – 5.5
AnimNext;求值 VM 与 Trait 接口已在
5.6
巫师 4 演示;开始改名;Module / Data Interface
5.7
UAF 插件 v0.1 实验性
5.8
UUAFComponent / System;Anim Node、Mass;人群与 Anim Mixer
要点
两个前缀都要搜;_DEPRECATED 字段是迁移线索。
8.3
按模块准入:哪些现在能试
下面是本文对各模块的分档建议。分档标准只有一条:出了问题能不能在一天之内退回去。能退回去的先试,退不回去的等。
几条总体建议:
不要迁移主角。主角用到的蒙太奇、根运动、网络同步最多,恰好是 UAF 最不成熟的部分。
先在 ABP 里托管 UAF 图。第 6.12 节的方式,让团队在不改变宿主的情况下熟悉 Trait、Workspace 和 System 的工作流。
给 UAF 的部分单独建插件或模块。所有自定义 Trait、System 资产放在一个独立的插件里,引擎升级时 API 变了,影响面可控。
别在 UAF 上堆厚封装。API 在 UE6 之前大概率还会变,薄薄一层适配比一套"自研 UAF 框架"好维护得多。
回到集市。两百个 NPC 落在"可试"的三行里:Motion Matching、ABP 托管、人群中间层,而且它们不用蒙太奇、不需要联机,出问题可以按角色退回 ABP。主角"猎人"同时踩中"暂不"的两行:主角、重度蒙太奇,外加根运动和联机两项未知。所以试点从 NPC 开始,主角保持 ABP,只用托管 UAF 图的方式让动画程序熟悉 Trait。下一节把这个决定写成一个四周的计划。
分档为本文建议;各模块现状的依据见第 6 章各节与本节交互图中的出处
可试
ABP 内托管 UAF 图 · Anim Mixer 过场 · 人群中间层 · 非关键 NPC 的 Motion Matching
观察
Mover 根运动 · State Tree · Control Rig 参数 · 通知时序
暂不
主角 · 重度蒙太奇 · 网络同步角色
要点
标准只有一条:出了问题能不能在一天之内退回去。
8.4
一个四周试点计划
准入表回答了"试什么"。这一节回答"怎么试、试多久、什么算成功"。计划以集市 NPC 为对象,每周都有可以测量的退出条件;任何一周不达标,就停下来记录原因,而不是硬推。
| 周 | 做什么 | 交付 | 通过条件 |
|---|---|---|---|
| 1 | 独立分支装 5.8;按 8.5 节搭最小角色;在一个 NPC 的 ABP 里托管 UAF 图,替换移动层 | 托管版 NPC;问题清单 | 托管版与原 ABP 并排播放,目视无差异;编辑器无崩溃 |
| 2 | 改由 UAF 组件驱动这个 NPC:Chooser + Motion Matching + 惯性化;变量绑定;移动依赖 | UAF 版 NPC;绑定与依赖说明 | 没有一帧延迟导致的脚滑;Insights 里该 NPC 的游戏线程动画开销明显低于 ABP 版 |
| 3 | 规模测试:1 / 50 / 200 个 NPC,ABP(线程安全更新)对 UAF;远景层试 UAF Mass + ISKM | 三档对比表(游戏线程、动画工作线程、总帧时间、显存) | 200 档游戏线程下降有统计意义;反复生成销毁 10 分钟显存不持续增长 |
| 4 | 工程评估:统计自定义代码量与 API 依赖面;在 5.8 小版本或主干上试编一次;记录调试耗时 | 一页决策书:按场景给出 采用 / 继续观察 / 不采用,以及回退方案 | 每个结论都有第 1–3 周的数据或问题记录支撑 |
三条执行纪律:
主角不动。四周里主角只允许以托管方式使用 UAF 图,组件、System 一律不碰。
每个 NPC 保留 ABP 版本。同一个角色蓝图上两套组件,用开关切换,所有对比都是同一角色、同一动画、同一场景。
提前写好停止条件。第 2 周如果一帧延迟或崩溃问题解决不了,第 3 周的规模测试就没有意义,直接进入第 4 周写结论。负面结论同样有价值:它告诉你要等哪一个版本。
计划为本文建议;测量方法见 8.9 节
第 1 周
托管 UAF 图,与原 ABP 并排一致
第 2 周
UAF 组件驱动一个 NPC,无一帧延迟
第 3 周
1 / 50 / 200 规模对比;人群显存稳定
第 4 周
一页决策书:按场景给结论和回退方案
要点
主角不动;每个 NPC 保留 ABP 版本;提前写好停止条件。
8.5
搭一个最小可跑的角色
下面是按 5.7–5.8 的社区教程整理的最小步骤。版本之间菜单名可能有出入,按意思找即可。
- 启用插件并重启编辑器。至少要 Unreal Animation Framework 和 UAF Anim Graph 两个;只启用前者不够。做 Motion Matching 再加 UAF Chooser、UAF Pose Search;需要 IK 加 UAF Control Rig;需要状态加 UAF State Tree;需要 Warping 加 UAF Warping。
- 创建资产。内容浏览器 → Animation → Animation Framework,建 Workspace、System、Animation Graph。或用资产向导一次生成。
- 组装 Workspace。把 System 和 Graph 加进 Workspace,在 System 的 Graph 字段里选中这张图。
- 写 System。Initialize 里调用 Make Reference Pose;PrePhysics 里调用 Run Graph,再调用 Write Pose。
- 写最小的图。输出接一个 Sequence Player,选一段待机动画。
- 挂到角色上。角色蓝图里加 UAF 组件(旧版叫 AnimNext Component),选择 System;把骨骼网格组件上的动画完全关掉。
- 运行验证。角色应该播放待机。播不了,先查第 6 步。
- 接上数据。在 System 里加一个 Vector 变量 Velocity,绑定到当前 Actor 的 Get Velocity;图里取长度得到速度,把 Sequence Player 换成接速度的 1D Blend Space Player(0 = 待机,1 = 走)。
Graph = AG_Character
到骨骼网格
第 8 步有一个已知的小坑:UAF 里没有 ABP 那个现成的 Calculate Direction 节点。要做八方向移动,得自己用速度和角色朝向算方向角,社区教程就是这么做的。复刻第三人称模板时,教程绑定了 ActorRotation、Velocity、CurrentAcceleration、OrientCharacterToMovement、IsFalling 五个变量,再在图里算 GroundSpeed、ShouldMove、Direction。
步骤见 Eyad 的教程、farravid 的 5.7 教程与 UAF FAQ 帖子中的用户笔记(社区);双插件要求见 Lunar Lizard 与 FAQ 帖子
插件
UAF + UAF Anim Graph 两个都要。
资产
Workspace · System · Animation Graph
System
Initialize: Make Ref Pose;PrePhysics: Run Graph → Write Pose
必须
关掉骨骼网格自己的动画。
要点
没有 Calculate Direction,自己用速度和朝向算。
8.6
从 ABP 迁移:概念对照表
迁移不是逐节点翻译。下面这张表告诉你 ABP 里的每样东西在 UAF 里"去哪了",以及有没有现成的对应。
| ABP | UAF | 现状 |
|---|---|---|
| 事件图 · BlueprintUpdateAnimation | System 的事件 + 变量绑定 | 可用;节点是 RigVM 节点,不是蓝图 |
| ThreadSafeUpdate · Property Access | 变量绑定(_GT / _WT) | 可用;设计仍在变 |
| 成员变量 | System 变量 · 共享变量 | 可用 |
| 动画图 | Animation Graph(Trait Stack 节点) | 可用 |
| Sequence Player · Blend Space Player | 同名 Trait | 可用 |
| Blend Poses by Bool / Two Way / Layered | Blend by Bool · Blend Two Way · Blend Layer | 可用 |
| Inertialization 节点 | Blend Inertializer · Dead Blending | 可用 |
| 状态机 | State Tree(UAF State Tree) | 资料少,需验证 |
| Motion Matching · Blend Stack · Chooser | 同名 Trait / 插件 | 社区验证最多 |
| Linked Anim Graph / Layer | Sub Graph Trait · 图工厂 | 可用;工作流不同 |
| 蒙太奇 · 插槽 | Injection Site + PlayAnim / InjectAsset | 不支持蒙太奇;Injection 是底层原语 |
| Anim Notify | Notify Dispatcher · Notify Filter | 可用;注意线程交接 |
| Control Rig 节点 | Control Rig Trait | 社区报告变量无法暴露 |
| Calculate Direction 等工具函数 | 无 | 自己算 |
| Post Process Anim BP | 无直接对应 | MetaHuman 远景不运行它 |
| Animation Sharing · Leader Pose | UAF Mass + ISKM | 用于人群 |
迁移的顺序建议反过来做:先盘点项目里没有对应和不支持的那几行用了多少(尤其是蒙太奇),再决定迁不迁。如果一个角色用了大量蒙太奇分段和分支点,现阶段的答案基本是"不迁"。
对照为本文整理;各条依据见第 3–6 章对应小节
直接对应
事件图 → System;动画图 → Graph;播放器、混合、惯性化同名。
换了工具
状态机 → State Tree;Linked Layer → Sub Graph。
没有对应
蒙太奇、Calculate Direction、Post Process ABP。
要点
先盘点"没有对应"的行用了多少,再决定迁不迁。
8.7
网络同步:公开资料的空白
对做联机游戏的团队,这是除蒙太奇之外最大的未知数。先说结论:截至本文,没有找到任何关于 UAF 与网络复制的官方或社区资料。Epic 员工在论坛回答问题时也没有涉及网络。下面能做的,是把问题拆清楚,告诉你要测什么。
ABP 时代的网络是怎么做的(背景知识)。动画本身一般不复制,每个客户端根据复制下来的游戏状态(速度、是否蹲下、武器)各自算动画。例外有两个:蒙太奇的播放状态通过角色的复制数据同步给其他客户端;根运动蒙太奇的位移被并入角色移动组件的网络预测和校正。
放到 UAF 上会怎样。第一条"各自根据复制状态算动画"和 UAF 的快照模型完全契合:只要快照里的值来自复制状态,各客户端算出的动画自然一致。麻烦在第二条:UAF 没有蒙太奇,于是"蒙太奇播放状态的复制"这条现成通路不存在;Injection 也没有公开的复制支持。一次性动作(攻击、受击)需要你自己把"开始了什么动作"做成复制状态或 RPC,再在每个客户端上各自注入。根运动则取决于 Mover 与 UAF 怎么交换位移,而 Mover 5.8 有自己的网络更新,二者的组合没有资料。
| 要测的场景 | 看什么 |
|---|---|
| 模拟代理(别的玩家)上的移动动画 | 与本地玩家看到的是否一致;绑定是否只读了复制状态 |
| 一次性动作:本地触发、远端注入 | 远端开始时间的偏差;中途加入的客户端看到什么 |
| 服务器校正(位置回拉)时 | 动画是否跳变;惯性化能否盖住 |
| 根运动动作(若用 Mover) | 预测与校正后位移是否一致 |
| Motion Matching 在不同客户端 | 同样输入是否选中同一段;不一致是否可接受 |
这一节也解释了为什么主角"猎人"被放在"暂不":他同时依赖蒙太奇复制和根运动预测,而这两条在 UAF 上都没有现成通路。集市 NPC 不受影响,它们不需要联机,或者只需要"按复制状态各自算"这一条。
UAF 网络资料为空白(本次检索未找到);ABP 网络做法为背景知识;Mover 5.8 网络更新见版本说明(官方)
公开资料
没有 UAF 网络复制的资料。
契合的部分
按复制状态各自算动画 = 快照模型。
缺的通路
蒙太奇复制、根运动预测都没有现成对应。
要点
一次性动作要自己复制,再在各客户端注入。
8.8
调试:现在有什么,缺什么
ABP 有一套成熟的调试工具:动画蓝图调试器能看到节点的实时权重,Rewind Debugger 能回放每一帧的姿态和节点状态,Animation Insights 能看时间线。UAF 的调试工具还在建设中,公开资料能确认的不多:
组件上的调试绘制:ShowDebugDrawing 在视口里画出 System 的调试指令,需要构建开启调试绘制。
Animation Insights:它是 UAF 插件的依赖之一。本文推断 UAF 有向 Insights 写追踪数据,但具体能看到什么,公开资料没有说明。
Rewind Debugger:公开资料没有找到 UAF 支持它的说明。
在工具补齐之前,下面这些手段最实用:
- 打印求值程序。第 5.4 节说过,程序是数据。在 Run Graph 之后把这一帧的任务列表打出来,"采样了哪几段、按什么权重混合"一目了然。
- 把变量快照打出来。动画只看快照,出问题时先确认快照里的值是不是你以为的值,很多"动画不对"其实是绑定读错了。
- 用 ABP 托管做对照。同一张图在 ABP 里托管跑一遍,在 UAF 组件上跑一遍,结果不同说明问题在 System、绑定或时序,而不在图。
- 看测试套件。引擎自带的 UAFTestSuite 和 UAFAnimGraphTestSuite 是"正确用法"的最可靠参考。
ShowDebugDrawing 见 UUAFComponent API(官方);Animation Insights 依赖见插件索引(官方);Insights 内容与 Rewind Debugger 支持情况未在公开资料中找到;调试手段为本文建议
有的
ShowDebugDrawing;Insights 是依赖。
没找到
Rewind Debugger 的 UAF 支持。
实用手段
打印求值程序 · 打印变量快照 · ABP 托管对照 · 看测试套件
要点
很多"动画不对"其实是绑定读错了。
8.9
性能:哪些说法有证据
"UAF 更快"是最常听到的说法。按证据强弱排一下:
| 说法 | 来源 | 证据强度 |
|---|---|---|
| 性能是首要目标,会尽量并行化 | Epic 员工论坛回复 | 官方表态,不是测量 |
| 巫师 4 演示在基础版 PS5 上 60 帧、有大量 NPC | CDPR / Epic 演示;社区文章称几百个骨骼网格角色 | 演示结果,非对照实验;PC Gamer 的分析认为该演示主要受 GPU 限制,CPU 后期主要花在 NPC 和动画上 |
| 配好后 Motion Matching 不占游戏线程 | 社区 5.7 教程 | 单人观察,结构上可信 |
| UAF vs ABP 的逐帧对比(500 个人体模型) | 社区视频(UE 5.6,i9-13900K + RTX 4090) | 存在对比测试;本文未能取得具体数值 |
| "数据导向,更快、更易组合" | 多篇预览文章 | 没有给出测量 |
结论是:没有官方基准,也没有可复现的公开对照数据。结构上的收益(离开游戏线程、轻量实例、共享只读数据)是确定的,但具体快多少取决于你的图、角色数和平台。
自己测时建议这样做:同一个角色、同一组动画,分别用 ABP(开启线程安全更新)、ABP 托管 UAF 图、UAF 组件三种方式驱动;角色数取 1、50、200 三档;用 Unreal Insights 分别看游戏线程时间、动画相关工作线程时间和总帧时间。只看总帧时间会错过最重要的信息:UAF 的收益主要体现在游戏线程变轻,而不一定是总 CPU 时间变少。
见各行来源;PC Gamer 对巫师 4 演示的分析;对比视频见 B 站转载《巫师4技术演示到底保不保真?UE5.6实测 UAF 动画管线》(原作 Mister Chedda);测试方法为本文建议
官方
性能是首要目标;没有基准。
演示
巫师 4:60 帧、大量 NPC;非对照实验。
社区
MM 不占游戏线程;有对比视频,数值待取。
要点
收益主要在游戏线程变轻,不一定是总 CPU 时间变少。
8.10
症状、原因、处理:速查表
下面是从社区教程、论坛和 API 结构里整理出的常见问题。第一列是你看到的现象。
| 症状 | 原因 | 处理 |
|---|---|---|
| 角色 T-pose / 不动 | 骨骼网格自己的动画没关;或只启用了 UAF 没启用 UAF Anim Graph | 关掉网格动画;补插件并重启 |
| 图在编辑器里显示为半透明 | 功能未完成或图未被 System 引用 | 确认 System 的 Graph 字段;社区认为是开发中的表现 |
| 动画总比移动慢一帧 | 动画读的是本帧开头的快照,移动在之后才更新 | bSetupCharacterMovementDependency,或给移动组件加 Prerequisite |
| 多角色时状态互相覆盖 | 自定义 Trait 把实例状态写成了成员变量 | 挪到 FInstanceData |
| 按 Set Variable 后不生效 | 写入进代理,下一次拷贝才可见;或变量引用不对 | 确认时序;用 GetVariable 回读 |
| Trait 事件晚一帧响应 | QueueInputTraitEvent 在下一次图更新时处理 | 接受一帧延迟,或调整事件安排 |
| 八方向移动方向错 | 没有 Calculate Direction,自己算时坐标系弄反 | 在 Actor 局部空间算速度方向角 |
| Control Rig 拿不到参数 | 社区报告 Control Rig 变量无法暴露到 UAF | 改用 Rig 内部读骨骼 / 曲线,或等版本修复 |
| 脚步声、特效延迟 | 通知在工作线程产生,交回游戏线程有延迟 | 测量延迟;调整 System 事件的 Tick Group |
| 骨骼翻转、抖动 | 多路混合前四元数不在同一半球,或漏了归一化 | 检查自定义任务;累加后调用 NormalizeKeyframeRotations |
| 老教程的资产找不到 | Module → System、Data Interface → 共享变量等改名 | 按第 8.2 节对照 |
| 升级引擎后自定义 Trait 编不过 | API 仍在变化 | 把自定义部分集中在一个插件里,薄封装 |
| 人群反复生成后显存上涨 | 社区报告 5.8 中 Mass + ISKM 的动画轨道池持续增长 | 监控显存;关注后续修复 |
这张表里的问题大致分三类:没接上(插件、网格动画、引用)、时序(快照、事件、通知)、还没做(缺节点、缺暴露、API 变化)。下一节的交互图按这三类倒推。
依据见第 3–7 章各节;人群显存问题见 Epic 论坛 AnimBank / ISKM 讨论中提到的帖子
没接上
网格动画没关 · 插件缺 · 图没被引用
时序
快照晚一帧 · 事件下一帧 · 通知交接
还没做
缺 Calculate Direction · Control Rig 参数 · API 在变
要点
先查接没接上,再查时序,最后才怀疑是功能缺失。
8.11
排错路线:从症状倒推
选一个症状,按顺序检查。每一步都给出"看什么"和"说明什么"。
排错时的一个原则:先确认快照,再怀疑图。UAF 把数据输入收窄成了快照,这让很多问题变得容易定位:快照对、结果错,问题在图;快照错,问题在绑定或时序。
排错顺序为本文建议
先确认快照
快照对、结果错 → 图
快照错
→ 绑定或时序
没反应
→ 先查接没接上
要点
UAF 把输入收窄成快照,问题更容易定位。
09
收尾
纠正几个流传很广的说法,再把全篇压成几句话。
9.1
六个常见误解
"UAF 已经可以替代 Anim Blueprint 了。"不能。插件 v0.1 实验性,官方不建议在无充分理由时使用,不支持蒙太奇,没有文档。
"GASP 5.8 里有一套完整的 UAF 角色。"没有。这个说法来自预览期文章的预期,Epic 的 GASP 5.8 更新说明里没有 UAF。
"UAF 就是 Control Rig 做动画。"不准确。两者共用 RigVM 这台执行虚拟机,但 UAF 的姿态计算靠的是自己的求值 VM 和 Trait,Control Rig 只是其中一个可以接入的 Trait。
"UAF 更快。"结构上更容易并行、实例更轻,但没有官方基准。它的收益主要在游戏线程变轻,总 CPU 时间不一定少。
"Trait 就是换了名字的节点。"不是。节点是容器,Trait 是可以叠的单元;Trait 无状态,通过接口协作。这是 UAF 可组合的根本。
"现在学 UAF 是浪费时间,反正还会变。"API 会变,但概念不太会变:两次遍历、求值程序、显式时序、按值输入、Trait 组合。这些概念在业界已经有先例,Epic 也已经在官方功能里用上了。现在学的是概念,薄薄地写代码。
依据见前文各节
已可替代 ABP
✗ v0.1 实验性,无蒙太奇
GASP 5.8 有 UAF 角色
✗ 更新说明没提
UAF = Control Rig
✗ 共用 RigVM,求值另有 VM
UAF 更快
△ 游戏线程更轻,无基准
Trait = 节点
✗ 节点是容器
现在学是浪费
✗ 概念稳定,代码写薄
9.2
数据流、组合、离开游戏线程
把全篇压成几句话:
一帧动画是在局部空间里把几套姿态按权重合成一套。ABP 用一个什么都能做的 UObject 来描述这件事,代价是难组合、难并行、实例很重,十年里的补丁都在绕这三点。
UAF 换了做法:每帧的流程是 System,姿态怎么算是由 Trait Stack 组成的图;Update 遍历把时间和权重往下传,Evaluate 遍历只写一段任务程序,求值 VM 在工作线程上执行它;数据按值快照进来,时序由你显式声明。
回到开场的集市:两百个 NPC 是 UAF 现在就能开始帮忙的地方,主角"猎人"还要再等等,等蒙太奇、根运动和网络这三块有了答案。
它在 5.8 里还是实验性的。现在能做的是:在 ABP 里托管 UAF 图积累经验,用 Anim Mixer 做过场,在人群的中间层试 UAF Mass,在非关键 NPC 上试 Motion Matching。主角、重度蒙太奇、网络同步的角色,再等等。
先学概念,代码写薄,等 API 稳定。
后面两个附录:附录 A 把各部件拼回一张系统流图和代码地图,附录 B 带你写一个迷你版的 UAF,把两次遍历和求值 VM 亲手实现一遍。
本文总结
问题
ABP 难组合、难并行、实例重。
做法
System + Trait 图;两次遍历;求值 VM;快照输入;显式时序。
现在
托管、过场、人群中间层、非关键 NPC。
一句话
先学概念,代码写薄,等 API 稳定。
页签 7 / 8 · 约 1 小时
工程:系统流图、代码地图与参考资料
把前面拆开讲过的部件重新拼回一张图:每块读什么、写什么、在哪个线程、在哪个文件。再给一个自定义 Trait 的代码骨架,最后是全部参考资料和证据等级。
A
工程全景
把前面八章拆开讲过的部件重新拼回一张图:每块读什么、写什么、在哪个线程、在哪个文件。
A.1
UAF 系统流图
下面是 UAF 一帧的系统流图,角色和人群各一张。点任意模块看它读什么、写什么、在哪个线程;点"按一帧的顺序播放"看数据怎么流动。
粗看只记三列:输入在游戏线程上被拍成快照;System 和图在工作线程上把快照变成一段求值程序;求值 VM 执行程序,结果写到网格。切到"人群",输入换成 Mass 实体,输出换成实例化蒙皮网格,中间部分不变。
依据见各模块链接的小节;线程归属为按公开资料整理,部分为推断
输入
游戏线程上拍成快照
System + 图
工作线程上产出求值程序
求值 VM
执行程序,写到网格或 ISKM
要点
角色和人群只在两端不同。
A.2
插件与模块地图
UAF 插件本身有四个模块:UAF(运行时)、UAFEditor、UAFUncookedOnly(编辑器下的编译与资产处理)、UAFTestData。它依赖十个插件,被二十个插件依赖。
| 层 | 插件 | 作用 |
|---|---|---|
| UAF 依赖的基础 | RigVM · Workspace · Hierarchy Table · Hierarchy Table Animation · Animation Insights · PropertyBindingUtils · Python Editor Script Plugin · Editor Scripting Utilities · Content Browser 数据源 ×2 | 执行虚拟机、编辑器容器、按骨骼表、追踪、属性绑定 |
| 核心 | UAF · UAF Anim Graph · UAF Anim Node | System 与组件 · Trait 图与求值 VM · 5.8 的节点系统 |
| 功能 | UAF Chooser · UAF Pose Search · UAF State Tree · UAF Warping · UAF Mirroring · UAF Layering · UAF Control Rig · RigLogic for UAF | 第 6 章各节 |
| 集成 | Mover UAF · UAF Mass · Sequencer Anim Mixer · Sequencer Mixed Control Rig · MetaHuman Creator - UAF support · Evaluation Notifies · IK Rig · AnimGen | 移动、人群、过场、MetaHuman、通知 |
| 其他 | UAF Shared Assets · UAF Tests | 共享资产、测试 |
两个观察。第一,IK Rig 和 AnimGen 也依赖 UAF,说明 UAF 的基础设施已经开始被其他动画系统复用。第二,UAF Anim Node 是很多功能插件的依赖(Chooser、Pose Search、State Tree、Warping、Shared Assets),所以它不是一个边缘实验,而是 5.8 里功能层的底座之一。
UAF 与 UAF Anim Node 插件索引(官方)
核心
UAF · UAF Anim Graph · UAF Anim Node
功能
Chooser · Pose Search · State Tree · Warping · Mirroring · Layering · Control Rig
集成
Mover · Mass · Anim Mixer · MetaHuman
要点
IK Rig、AnimGen 也依赖 UAF;Anim Node 是功能层的底座。
A.3
代码地图:一帧的调用顺序
按一帧的执行顺序列出关键的类和函数。点开每一层看它在哪个文件、做什么、讲原理的是哪一节。路径以 5.8 的 API 参考为准,前缀 AnimNext 与 UAF 混用是正常的。
读源码时建议的顺序:先读 IUpdate 和 IEvaluate 两个接口的头文件,再读一个最简单的 Trait(Sequence Player),然后读 FEvaluationVM,最后才读组件和调度。和执行顺序相反,但这样每读一层都知道它在为谁服务。
类与文件路径见 UAF / UAFAnimGraph API 参考(官方);调用关系部分来自 RemRemRemRe 的笔记(社区)
组件与事件
UUAFComponent → 用户事件 → 绑定
Run Graph
Update 遍历 → Evaluate 遍历 → 求值 VM
读源码顺序
接口 → 一个简单 Trait → VM → 组件与调度
A.4
在 UE 里写一个自定义 Trait
下面是一个加法 Trait 的代码骨架:它叠在任意节点上,在子节点求值完之后给上身加一个平滑的侧倾。它覆盖了第 4 章的全部要素:共享数据、实例数据、潜在属性、两个接口、注册宏,以及"先转给下一个、再做自己的事"的调用约定。
这是结构示意,不能直接编译。宏的参数和部分函数名在 5.6 到 5.8 之间有变化,命名空间也从 UE::AnimNext 逐步改成 UE::UAF。写之前请打开引擎里的 FBlendTwoWayTrait 或 Apply Additive 的实现对照。
// MyLeanTrait.h —— 结构示意:一个加法 Trait,在子节点求值之后给上身加一个侧倾。 // 宏的具体参数以引擎源码为准;可对照引擎里的 FBlendTwoWayTrait、FApplyAdditiveTrait。 // ① 共享数据:编辑器里配的参数,只读,每张图一份 USTRUCT(meta = (DisplayName = "Lean")) struct FMyLeanTraitSharedData : public FAnimNextTraitSharedData { GENERATED_BODY() UPROPERTY(EditAnywhere, meta = (Inline)) // 可连变量 → 潜在属性 float LeanAngle = 0.f; UPROPERTY(EditAnywhere) FName SpineBone = "spine_03"; // 潜在属性需要显式登记(GENERATE_TRAIT_LATENT_PROPERTIES) }; namespace UE::UAF // 旧版为 UE::AnimNext { struct FMyLeanTrait : FAdditiveTrait, IEvaluate, IUpdate { DECLARE_ANIM_TRAIT(FMyLeanTrait, FAdditiveTrait) using FSharedData = FMyLeanTraitSharedData; // ② 实例数据:每个图实例一份。状态只能放这里,不能放成 Trait 的成员 struct FInstanceData : FTrait::FInstanceData { float SmoothedAngle = 0.f; }; // IUpdate:推进自己的状态,再把调用转给下面的 Trait virtual void PreUpdate(FUpdateTraversalContext& Ctx, const TTraitBinding<IUpdate>& Binding, const FTraitUpdateState& State) const override; // IEvaluate:子节点求值完之后,往程序里追加自己的任务 virtual void PostEvaluate(FEvaluateTraversalContext& Ctx, const TTraitBinding<IEvaluate>& Binding) const override; }; } // MyLeanTrait.cpp namespace UE::UAF { AUTO_REGISTER_ANIM_TRAIT(FMyLeanTrait) #define TRAIT_INTERFACE_ENUMERATOR(GeneratorMacro) \ GeneratorMacro(IEvaluate) GeneratorMacro(IUpdate) GENERATE_ANIM_TRAIT_IMPLEMENTATION(FMyLeanTrait, TRAIT_INTERFACE_ENUMERATOR, ...) #undef TRAIT_INTERFACE_ENUMERATOR void FMyLeanTrait::PreUpdate(FUpdateTraversalContext& Ctx, const TTraitBinding<IUpdate>& Binding, const FTraitUpdateState& State) const { const FSharedData* Shared = Binding.GetSharedData<FSharedData>(); FInstanceData* Inst = Binding.GetInstanceData<FInstanceData>(); const float Target = Shared->GetLeanAngle(Binding); // 潜在属性按实例取值 Inst->SmoothedAngle = FMath::FInterpTo(Inst->SmoothedAngle, Target, State.GetDeltaTime(), 8.f); IUpdate::PreUpdate(Ctx, Binding, State); // 转给栈里下一个 } void FMyLeanTrait::PostEvaluate(FEvaluateTraversalContext& Ctx, const TTraitBinding<IEvaluate>& Binding) const { IEvaluate::PostEvaluate(Ctx, Binding); // 先让下面的 Trait 追加任务 const FInstanceData* Inst = Binding.GetInstanceData<FInstanceData>(); Ctx.AppendTask(FMyLeanTask::Make(Inst->SmoothedAngle)); // 栈顶姿态 1 → 1 } }
写自定义 Trait 时最容易出错的三件事:
把状态写成成员变量。Trait 对象被所有实例共用,成员变量会被多线程互相覆盖。状态一律放 FInstanceData。
忘了转给下一个。加法 Trait 不调用 IUpdate::PreUpdate / IEvaluate::PostEvaluate 往下转,下面的基础 Trait 就不会被更新或求值,表现为节点"冻住"。
在求值里碰 UObject。求值任务在工作线程上执行,任务里只能用值类型的数据。需要资产(比如曲线资产)时,在共享数据里引用,并通过 IGarbageCollection 接口报告引用,防止被回收。
宏与类名见 RemRemRemRe 的 5.6 笔记(社区)与 UAFAnimGraph API(官方);代码为本文写的结构示意
共享数据
配置;可连变量的成员成为潜在属性
实例数据
每实例状态,只能放这里
调用约定
先转给下一个,再做自己的事
注册
AUTO_REGISTER_ANIM_TRAIT + GENERATE_ANIM_TRAIT_IMPLEMENTATION
要点
求值任务里只用值类型数据。
A.5
参考资料与证据等级
按证据等级分三档。对外讲 UAF 时,一档可以直接引用,二档注明版本,三档先自己验证。
一档 · 官方
- UAF 插件索引(模块、依赖、相关插件)
- UUAFComponent API · UUAFSystem API
- UAFAnimGraph API(Trait、接口、求值任务、图工厂、Injection)
- UAF Anim Node 插件 · FUAFAnimNode · UAF Mass
- FInjectionUtils · FTraitUpdateState · IEvaluate(5.5)
- RigUnit_AnimNextUserEvent · RigUnit_UAFWriteInstancedSkinnedMeshPose
- UE 5.8 版本说明 · GASP 5.8 更新 · MetaHuman Crowds
- General questions on UAF(Epic 员工回复,2026-05) · AnimBank 与 ISKM(Epic 员工回复)
- UAF FAQ(Epic 社区知识库) · Animation Mixer in Sequencer 入门
二档 · 社区(结构可靠,细节随版本变)
- RemRemRemRe:My Understanding of the Unreal Animation Framework in 5.6(Trait、节点模型、遍历、宏)
- Eyad:Unreal Animation Framework (UAF)(Trait 分类、搭建步骤、Motion Matching)
- farravid:Mover 2.0 + UAF + Motion Matching in 5.7
- Unreal Directive:UAF 插件成熟度
- CD PROJEKT RED 博客:巫师 4 技术演示 · Unreal Fest 2025 UAF 演讲
三档 · 需要自己验证
- Lunar Lizard:UE 5.8 预览期文章(其中"GASP 附带 UAF 角色"的预期未兑现)
- PC Gamer:巫师 4 演示性能分析
- B 站:UE5.6 实测 UAF 动画管线(转载)(本文未取得具体数值)
- 本文中标注"推断""背景知识""示意"的内容
检索时间 2026 年 10 月
一档 · 官方
API 参考 · 插件索引 · 版本说明 · Epic 员工回复
二档 · 社区
RemRemRemRe · Eyad · farravid · Unreal Directive
三档 · 自己验证
预览期文章 · 媒体分析 · 视频 · 本文推断
页签 8 / 8 · 约 2 小时
动手:写一个自己的迷你 UAF
按六个里程碑,从采样一个关键帧写到两次遍历、混合同步和多线程。每一步给出数据布局、伪代码、做完应该看到什么,以及会踩的坑。读完再回头看 UAF 的源码,每个类都能对上号。
B
动手实现:写一个自己的迷你 UAF
按六个里程碑,从采样一个关键帧写到两次遍历和多线程。每一步都对应 UAF 里一个具体的类。
B.1
先定范围:最小可用版本长什么样
目标不是复刻 UAF,而是用最少的代码把它的四个核心想法各实现一遍:SoA 姿态与 LOD、栈式求值 VM、可叠加的 Trait 与接口、非递归的两次遍历。做完之后,再加上混合同步和多线程。
语言不限。下面的伪代码是 C++ 风格,但没有用任何 UE 的类型。一个能跑的版本大约一两千行,可以用任何能读 glTF 或 FBX 骨骼动画的库来加载素材,用最简单的线框画骨骼。
先不做:压缩动画、根运动、IK、物理、编辑器、网络。它们都很重要,但和这四个核心想法无关,会把注意力带偏。
每个里程碑都写了"做完应该看到什么"。看不到就别往下走,后面的每一步都建立在前面正确的基础上。
范围划分为本文建议
四个核心想法
SoA 姿态 · 栈式 VM · Trait 与接口 · 两次遍历
再加
混合同步 · 多线程
先不做
压缩、根运动、IK、物理、编辑器、网络
要点
看不到完成标准,就别往下走。
B.2
数据结构:骨架、姿态与 LOD
先把内存布局定下来。三个决定会影响后面所有代码:
骨骼按拓扑序排列:保证每根骨骼的父骨骼下标比它小。FK 就变成一次顺序循环,不需要递归或栈。
姿态用 SoA:旋转一个数组,平移一个数组,缩放一个数组。混合、归一化这类"对每根骨骼做同一件事"的循环,访问连续内存,编译器容易向量化。
LOD 是前缀:把重要的骨骼排在前面,LOD n 只算前 N 根。一个姿态只存它需要的那么多。对应 UAF 的 LOD 姿态(第 5.5 节)。
// 骨架:只读,所有实例共享 struct Skeleton { int numBones; vector<int> parent; // parent[i] < i:按拓扑序排列,FK 一次顺序循环即可 vector<Xform> refPose; // 参考姿态(局部空间) vector<int> lodBoneCount; // lodBoneCount[lod]:该 LOD 只算前 N 根骨骼 }; // 姿态:SoA。只存当前 LOD 需要的前 n 根骨骼 struct Pose { int n; quat* rot; // [n] vec3* trans; // [n] vec3* scale; // [n] float* curves; // [numCurves],曲线和骨骼一起混合 }; // 骨骼映射:动画轨道 → 骨架骨骼。离线建好,每段动画一张 struct BoneMap { vector<int16> trackToBone; };
"LOD 是前缀"和"拓扑序"有时会冲突:一根重要的骨骼的父骨骼必须也在前缀里。离线排序时先按 LOD 分组,组内再按拓扑序,最后检查每根骨骼的父骨骼所在的 LOD 不高于它自己。
数据结构为本文设计,思路对应 UAF 的 LOD 姿态与骨骼映射
拓扑序
父下标 < 子下标:FK 是一次顺序循环。
SoA
混合与归一化访问连续内存。
LOD 是前缀
LOD n 只算前 N 根骨骼。
要点
离线排序:先按 LOD 分组,组内按拓扑序。
B.3
里程碑 1:采样一个关键帧
第一个里程碑只做一件事:给定一段动画和时间,产出一个姿态,画出来。
void SampleClip(const Clip& c, float t, const BoneMap& m, Pose& out) {
float f = t * c.fps; int k0 = (int)f; float a = f - k0;
int k1 = c.loop ? (k0 + 1) % c.numKeys : min(k0 + 1, c.numKeys - 1);
for (int tr = 0; tr < c.numTracks; ++tr) {
int b = m.trackToBone[tr];
if (b < 0 || b >= out.n) continue; // 不在这个 LOD 里:跳过
quat q0 = c.rot(tr, k0), q1 = c.rot(tr, k1);
if (dot(q0, q1) < 0) q1 = -q1; // 同一半球
out.rot[b] = normalize(lerp(q0, q1, a)); // NLerp
out.trans[b] = lerp(c.trans(tr, k0), c.trans(tr, k1), a);
}
// 没有轨道的骨骼保持参考姿态:调用前先拷一份 refPose
}做完应该看到:骨骼线框播放待机动画,循环处没有跳变。切换 LOD 时远端的骨骼(手指)停在参考姿态,而不是飞走。
常见坑:一,忘了同一半球检查,在某些帧上骨骼会转一大圈。二,循环动画的最后一帧和第一帧相同(很多导出器会这样),直接取模会在循环点停顿一帧,要用 numKeys - 1 作为周期。三,没有轨道的骨骼没有初始化成参考姿态,表现为随机朝向。
通用实现;对应 UAF 的 FAnimNextAnimSequenceKeyframeTask 与 FDecompressionTools
目标
动画 + 时间 → 姿态
完成标准
循环无跳变;切 LOD 时远端骨骼停在参考姿态。
三个坑
同一半球 · 首尾重复帧 · 无轨道骨骼未初始化
B.4
里程碑 2:求值 VM 与任务
第二个里程碑把"怎么算"抽成一台栈机。先手写一段程序,不要图,验证 VM 本身。
struct VM {
PosePool pool; // 预分配的姿态内存,按 LOD 大小切块
vector<Pose*> stack; // 关键帧栈
};
struct Task { virtual void Run(VM&) const = 0; };
struct PushRef : Task { void Run(VM& vm) const override {
Pose* p = vm.pool.Alloc(); CopyRef(*p); vm.stack.push_back(p); } };
struct Sample : Task { ClipId clip; float time;
void Run(VM& vm) const override {
Pose* p = vm.pool.Alloc(); CopyRef(*p); SampleClip(Get(clip), time, Map(clip), *p);
vm.stack.push_back(p); } };
struct Blend2 : Task { float alpha; // 2 → 1
void Run(VM& vm) const override {
Pose* b = Pop(vm); Pose* a = Top(vm);
for (int i = 0; i < a->n; ++i) {
quat qb = b->rot[i]; if (dot(a->rot[i], qb) < 0) qb = -qb;
a->rot[i] = a->rot[i] * (1 - alpha) + qb * alpha; // 先不归一化
a->trans[i] = lerp(a->trans[i], b->trans[i], alpha);
}
vm.pool.Free(b); } };
struct Normalize : Task { void Run(VM& vm) const override { // 1 → 1
Pose* p = Top(vm); for (int i = 0; i < p->n; ++i) p->rot[i] = normalize(p->rot[i]); } };
void Execute(const Program& prog, VM& vm) {
for (const Task* t : prog.tasks) t->Run(vm);
assert(vm.stack.size() == 1); // 程序结束时必须只剩一个
}做完应该看到:手写程序 Sample(Walk) Sample(Run) Blend2(0.3) Normalize 的结果,和直接调两次 SampleClip 再手动混合的结果逐骨骼一致(误差在 1e-5 以内)。
为什么姿态内存要从池里拿:每帧会压入、弹出几十个姿态,用通用分配器会成为瓶颈,而且碎片化。池按 LOD 大小切好块,Alloc / Free 都是常数时间。UAF 在遍历里用 MemStack 也是同样的考虑。
常见坑:一,Blend2 里取栈顶两个的顺序弄反,alpha 的含义就反了;约定"先压的是 A",写进注释。二,多路混合时每一步都归一化,结果和"加权求和再归一化"不同,而且更贵;只在程序末尾归一化一次。三,程序结束时栈上不是一个元素,说明某个任务的输入输出数量写错了;加断言。
对应 UAF 的 FEvaluationVM、FEvaluationVMStack、FAnimNextBlendTwoKeyframesTask、FAnimNextNormalizeKeyframeRotationsTask
先不要图
手写程序验证 VM 本身。
完成标准
与直接混合逐骨骼一致,误差 < 1e-5。
三个坑
A/B 顺序 · 每步归一化 · 结束时栈不为 1
B.5
里程碑 3:Trait、接口与注册
第三个里程碑实现"节点 = Trait 栈"。关键是让 Trait 无状态,并且通过接口找到彼此。
// 接口 ID:编译期从名字算 FNV1a 哈希 constexpr uint32 IID(const char* s) { uint32 h = 2166136261u; while (*s) { h ^= (uint8)*s++; h *= 16777619u; } return h; } struct IUpdate { static constexpr uint32 ID = IID("IUpdate"); }; struct IEvaluate { static constexpr uint32 ID = IID("IEvaluate"); }; struct ITimeline { static constexpr uint32 ID = IID("ITimeline"); }; // Trait 本身无状态;所有数据通过 binding 取 struct Trait { virtual bool Implements(uint32 iid) const = 0; virtual size_t InstanceSize() const = 0; virtual void Construct(void* inst, const void* shared) const = 0; // 每个接口方法都带 binding:它知道自己在哪个栈、第几层、数据在哪 virtual void PreUpdate (UpdateCtx&, Binding b, const UpdateState&) const { b.Next<IUpdate>().PreUpdate(...); } virtual void PostEvaluate(EvalCtx&, Binding b) const { b.Next<IEvaluate>().PostEvaluate(...); } virtual float TimeLeft(Binding b) const { return b.Next<ITimeline>().TimeLeft(); } }; // Binding::Next<I>():从当前层往下找第一个 Implements(I::ID) 的 Trait // 节点模板 = Trait 指针数组;节点描述 = 模板 + 共享数据;节点实例 = 实例数据块
实现三个 Trait 就够验证:SequencePlayer(基础,实现 IUpdate、IEvaluate、ITimeline)、Blend2(基础,有两个子节点)、Mirror(加法,只实现 IEvaluate,在 PostEvaluate 里先让下面追加任务,再追加一个镜像任务)。
做完应该看到:同一个 SequencePlayer 节点,叠上 Mirror 后动画左右翻转;去掉 Mirror 恢复。问 Mirror 所在节点的 TimeLeft,得到的是下面 SequencePlayer 的值。
常见坑:一,在 Trait 里加了成员变量存播放时间,单角色正常,两个角色用同一张图时一起抽搐;所有状态进实例数据。二,加法 Trait 忘了调 Next,下面的节点不更新。三,实例数据的对齐没处理,SIMD 读 quat 时崩溃;每个 Trait 的实例数据按 16 字节对齐。
对应 UAF 的 FTrait / FBaseTrait / FAdditiveTrait、ITraitInterface、FTraitBinding、FNodeTemplate
三个 Trait
SequencePlayer · Blend2 · Mirror(加法)
完成标准
叠上 Mirror 翻转;TimeLeft 穿过 Mirror 拿到播放器的值。
三个坑
成员变量存状态 · 忘了 Next · 实例数据对齐
B.6
里程碑 4:两次遍历
第四个里程碑用显式栈替代递归,把 Update 和 Evaluate 串起来。
void UpdateGraph(GraphInstance& g, float dt) {
struct Item { NodeId n; UpdateState s; bool post; };
FixedStack<Item, 256> st; // 线程局部,不分配
st.push({ g.root, UpdateState{dt, 1.f}, false });
while (!st.empty()) {
Item it = st.pop();
Binding top = g.TopTrait(it.n);
if (it.post) { top.PostUpdate(it.s); continue; }
if (g.BecameRelevant(it.n)) top.OnBecomeRelevant(it.s);
top.PreUpdate(it.s); // 在这里决定子节点的状态
st.push({ it.n, it.s, true }); // 回来时调 Post
for (auto [child, cs] : g.RelevantChildren(it.n, it.s)) // 权重为 0 的不入栈
st.push({ child, cs, false });
}
}
// Evaluate 同样的骨架,只是 Pre/PostEvaluate 往 program 里追加任务,
// 而且只走 Update 时判为相关的那些节点做完应该看到:图 Mirror+Blend2(α){Walk, Run} 产出的程序打印出来是 Sample(Walk) Sample(Run) Blend2(α) Mirror;α 设为 0 时程序里没有 Sample(Run);α 从 0 拖到 1 时,Run 节点在离开 0 的那一帧收到 OnBecomeRelevant,它的播放时间从同步位置开始,而不是从 0 开始。
常见坑:一,子节点按顺序压栈,弹出时顺序反了,导致程序里 Run 在 Walk 前面;压栈时倒序压。二,Update 和 Evaluate 对"哪些子节点相关"判断不一致(比如 Evaluate 又读了一次 α,而 α 在两次遍历之间变了),程序里出现没被更新过的节点;Evaluate 只走 Update 记下的相关集合。三,"刚变得相关"没处理,新淡入的动画从第 0 帧开始,脚步对不上。
对应 UAF 的 FUpdateTraversalContext、FEvaluateTraversalContext、IUpdate::OnBecomeRelevant
显式栈
线程局部、不分配;Pre 时压子节点,Post 时回来。
完成标准
程序顺序正确;α=0 时无 Sample(Run);刚相关时同步起点。
三个坑
压栈顺序 · 两次遍历相关集不一致 · 忘了刚相关
B.7
里程碑 5:混合、同步与惯性化
第五个里程碑让切换看起来对:同步组让脚步对齐,惯性化让切换不跳。
同步组:在 Update 遍历里,Blend2 的 PreUpdate 找出权重最大的子节点作为领头,先推进它,拿到它的归一化进度,再用 WithTimeScale 或直接设置进度的方式让其他成员跟随。需要先实现 ITimeline 的"设置进度"方法。
// 惯性化:切换瞬间记下差值和速度,之后只算新动画 struct InertState { vector<quat> dq; vector<vec3> dv; float t, T; }; void OnSwitch(InertState& s, const Pose& prev, const Pose& prevPrev, const Pose& next, float dt, float T) { for (int i = 0; i < next.n; ++i) { s.dq[i] = prev.rot[i] * inverse(next.rot[i]); // 旧 ⊖ 新 s.dv[i] = AngularVel(prevPrev.rot[i], prev.rot[i], dt); // 需要前两帧:Pose History } s.t = 0; s.T = T; } void Apply(InertState& s, Pose& out, float dt) { s.t += dt; float k = Decay(s.t, s.T); // 五次多项式,t = T 时为 0,导数也为 0 for (int i = 0; i < out.n; ++i) out.rot[i] = slerp(identity, s.dq[i], k) * out.rot[i]; }
做完应该看到:走跑按 0.5 混合时脚步不乱;按布尔切换待机和跑步,惯性化下没有跳变,而且过渡期间程序里只有一个 Sample 任务。
常见坑:一,领头在混合过程中换人时进度跳变;换领头时把新领头的进度设成旧领头的进度。二,惯性化需要前两帧的姿态算速度,第一次切换时没有历史;没有历史就把速度当 0。三,衰减曲线用了线性函数,结束时有"顿"一下的感觉;要用起点和终点导数都可控的五次多项式,或者直接实现 Dead Blending。
对应 UAF 的 Synchronize Using Groups、Blend Inertializer、Pose History、Dead Blending 任务;惯性化公式见 Bollo GDC 2018(背景知识)
同步组
领头先走,其他按归一化进度跟随。
惯性化
切换时记差值和速度,之后只算新动画。
三个坑
领头换人 · 没有历史 · 线性衰减
B.8
里程碑 6:变量快照与线程
最后一个里程碑把动画搬离主线程。前五步的设计(无状态 Trait、实例数据、按值的程序)已经为此铺好了路,这一步主要是划清边界。
// 游戏线程:每帧给每个角色拍一张快照(值类型,无指针) struct Snapshot { vec3 velocity, accel; quat facing; bool falling; float dt; }; void GameThread_PreAnim(World& w) { for (Character& c : w.chars) c.snap[w.frame & 1] = MakeSnapshot(c); // 双缓冲 } // 工作线程:每个角色一个作业,互不依赖 void Job_Animate(Character& c, int frame) { const Snapshot& s = c.snap[frame & 1]; UpdateGraph(c.graph, s.dt); // 读快照,写实例数据 Program prog; EvaluateGraph(c.graph, prog); Execute(prog, c.vm); c.outPose[frame & 1] = c.vm.stack.back(); c.notifies.PushAll(c.graph.firedNotifies); // 收集,不在这里执行 } // 游戏线程:所有作业完成后,写蒙皮、分发通知
做完应该看到:200 个角色时主线程上只剩拍快照和收尾两段;用 ThreadSanitizer 跑一遍没有数据竞争报告;关掉多线程(所有作业在主线程顺序跑)结果逐位相同。
常见坑:一,在作业里读了角色对象上的字段而不是快照,偶发抖动,而且只在多核机器上出现。二,通知在作业里直接调用了播放声音的函数;通知只收集,回到主线程再分发。三,快照不是双缓冲,主线程在作业还在读的时候写了下一帧的快照。四,作业之间其实有依赖(骑手和坐骑),被当成独立作业并行了;用显式依赖表达,对应 UAF 的 AddModuleEventPrerequisite。
设计为本文建议;对应 UAF 的变量绑定、PublicVariablesProxy、事件调度与 Prerequisite
边界
主线程:拍快照、收尾。工作线程:遍历、VM。
完成标准
无数据竞争;关掉多线程结果逐位相同。
四个坑
读对象不读快照 · 作业里执行通知 · 快照单缓冲 · 隐含依赖
B.9
坑的总表:症状 → 实现层原因 → 修法
把六个里程碑里的坑按症状合起来。这张表也适用于读 UAF 源码时理解"为什么它这么写"。
| 症状 | 实现层原因 | 修法 | UAF 里对应的设计 |
|---|---|---|---|
| 某些帧骨骼转一大圈 | 四元数不在同一半球 | 点积 < 0 时取反 | 混合任务内部处理 |
| 多角色一起抽搐 | 状态写在共享对象里 | 状态进实例数据 | Trait 无状态;FInstanceData |
| 加法节点下面的动画冻住 | 没有把调用转给下一个 Trait | 每个接口方法默认转发 | Binding 的"下一个实现者" |
| 新淡入的动画脚步错 | 没处理刚变得相关 | OnBecomeRelevant 里同步起点 | IUpdate::OnBecomeRelevant |
| 程序里出现未更新的节点 | 两次遍历的相关集不一致 | Evaluate 只走 Update 记下的 | 两次遍历共享图实例状态 |
| 结束时栈不为 1 | 某任务输入输出数量写错 | 断言;每个任务注明 n → m | 任务是规整的栈操作 |
| 混合结果偏暗、骨骼缩短 | 每步归一化或在组件空间插位置 | 局部空间加权和,末尾归一化 | NormalizeKeyframeRotations |
| 偶发抖动,只在多核上 | 作业读了对象而不是快照 | 快照双缓冲,只读值类型 | 变量绑定 + 代理拷贝 |
| 坐骑上的骑手滞后 | 隐含依赖被当成独立作业 | 显式依赖 | AddModuleEventPrerequisite |
| 每帧分配很慢 | 姿态用通用分配器 | 按 LOD 大小的池;线程局部栈 | MemStack;预分配 |
UAF 的很多看起来繁琐的设计,都是这张表里某一行的答案。
本文整理
数值
同一半球 · 末尾归一化 · 局部空间混合
结构
Trait 无状态 · 默认转发 · 刚变得相关 · 相关集一致
线程
只读快照 · 双缓冲 · 显式依赖 · 池化内存
要点
UAF 繁琐的设计,都是这张表里某一行的答案。
B.10
怎么验证你写对了
动画的 bug 常常"看起来差不多对"。下面四件事值得在写对应功能的同一天就做出来。
一,参考实现对照。保留一个最笨的递归版本:每个节点直接返回姿态,混合就地算。每次改 VM 或遍历,跑一遍逐骨骼对比,误差超过 1e-5 就报警。
二,程序打印。每帧可以把求值程序打出来,带上每个任务的参数。90% 的"动画不对"看程序就能定位。
三,单线程与多线程逐位对比。同一段输入录下来,单线程跑一遍、多线程跑一遍,输出姿态逐位相同。不同就是有数据竞争或顺序依赖。
四,压力场景。
| 场景 | 测什么 | 判据 |
|---|---|---|
| 走跑 0.5 混合,慢放 | 同步 | 脚掌在落地帧的高度差 |
| 每 3 帧随机切换一次 | 惯性化稳定性 | 根骨骼角速度的峰值 |
| 同一张图 500 个实例 | 实例数据隔离、内存 | 每实例字节数;结果互不影响 |
| LOD 在 0 和最高之间来回切 | 前缀与映射 | 远端骨骼不飞、不跳 |
| 骑手与坐骑 | 显式依赖 | 骑手骨盆与鞍座的相对位移 |
先有尺子,再写系统。UAF 自己也是这么做的:引擎里的 UAFTestSuite 就是它的尺子。
本文建议
参考实现
保留最笨的递归版本做对照。
程序打印
90% 的问题看程序就能定位。
单 / 多线程逐位对比
不同就是有竞争或隐含依赖。
要点
先有尺子,再写系统。