动画通知深入分析

前言

本文通过源码对动画通知进行深入分析

Notify

动画通知先从Notify开始看, 这里先看默认的Queue即队列类型的通知, BranchPoint的通知最后再看

image-20220418160141678

先看调用栈, 一般的通知都是到TriggerSingleAnimNotify()触发, 当然还有蒙太奇通知的特殊处理, 这个后面看

image-20220418160509060

这部分没什么难度和内容, 关键是这个队列数据是怎么维护的?

查找引用后发现, 这个队列数组在每一次PreUpdateAnimation()时会被Reset, 在多线程Proxy中, 会在PostUpdate()中对队列进行Append

image-20220418163341561

这个NotifyQueue成员变量是来自Proxy::UpdateAnimation() 中对动画资源的更新

image-20220418200254914

image-20220418200357670

关注一下关键的判定条件, 通知触发的条件就是

(NotifyStartTime <= CurrentPosition) && (NotifyEndTime > PreviousPosition)

翻译一下通知触发条件就是 :通知的时间位于当前帧时间与前一帧时间中间

总结一下

  1. AnimInstance执行PreUpdate重置通知队列, 同时也会执行Proxy的PreUpdate方法来重置Proxy内的队列
  2. Proxy执行Update刷新动画资源, 刷新Proxy的通知队列
  3. Proxy执行PostUpdate操作把本地的队列数据Append到AnimInstance
  4. MeshComponent执行PostAnimEvaluation一路执行到TriggerSingleAnimNotify

补充:以上流程全是多线程

另外, 不需要资源直接右键创建的动画通知也是在TriggerSingleAnimNotify中执行, 看下面代码, 通过ProcessEvent执行蓝图事件

image-20220418203343912

NotifyState

NotifyState即动画通知窗口中可以拖动起点和重点的带时长的动画通知, 可以接受Begin/Tick/End事件

NotifyState的调用栈与前者类似, 在TriggerAnimNotifies中分别处理的Notify和NotifyState的调用

image-20220418204155763

用伪代码描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

{
遍历旧的激活中的通知(上一帧还在,这一帧没有了的)
{
执行End
}
遍历Begin类型的通知(这一帧新出现的)
{
执行Begin
}
遍历正在激活的通知
{
执行Tick
}
}

因为他是先检测End并触发End, 所以说通知的End一定至少落后Begin一帧, 这在绝大多数情况下是没有问题的, 但是如果是下面这样

image-20220418205813769

第一个通知打印1, 第二个打印2,得到的结果如下图

image-20220418205749772

按照动画通知是配置按道理应该是第一个通知的Begin早于第二个的End, 但是由于前面说的机制, 导致了End会落后Begin一帧,这个在某些时候会导致不必要的问题

当然, 有几个解决办法, 要么尽量把前面通知的End与后面通知的Begin至少间隔一帧执行

或者还可以用BranchPoint的方式来解决一部分问题, 这个在下面会讲

BranchPoint

BranchPoint类型的动画通知是比较特殊的通知, 他与Queue不同的是不会排队处理, 而是统一到一个地方集中处理.

而且不同与Queue的多线程, BranchPoint的通知是统一在游戏线程同步执行的

官方说比较适合蒙太奇不同section之间有跳转的场合使用

先看一下一个普通的蓝图类Notify调用栈

image-20220418144400820

从Montage的Advance开始看, 这个函数不断的推进蒙太奇动画

image-20220418144531240

image-20220418145044104

image-20220418145129007

上面的图中获取一个BranchingPointMarkerPtr, 这个指针记录了这个BranchPoint的触发时机和类型

补充: 如果是State通知, 那么 将保存Begin和End两份数据

image-20220418155211999

然后调用 HandleEvents()

在HandleEvents中会先处理队列通知, 把所有通知里的BranchPoint过滤掉

然后是 UpdateActiveStateBranchingPoints()

这里会调用NotifyState的Begin和End事件, 用的方法是记录一个Active的事件数组ActiveStateBranchingPoints, 通过时间判断触发Begin或者End

最后是调用BranchingPointEventHandler()

这一步先处理一写被标记为转成Queue的BranchPoint通知, 通过宏可以关闭BranchPoint的通知,强制转成Queue, 看下图

image-20220419101840632

然后再处理一遍State类型的Begin和End触发, 最后是才是处理Notify的触发

那么问题来了, 为什么UpdateActiveStateBranchingPoints()和BranchingPointEventHandler()都有对State通知的触发

首先机制不一样, 前者是通过当前时间来触发, 后者是通过序列化到资源的BranchingPointMarkerPtr数据来判定触发与否,

这是因为蒙太奇动画经常会跳转section导致动画的position会瞬切, 这当中涉及到的问题很多, 前后这两者加起来保证了正常的触发

最后还有一个函数Termnate()会确保所有State通知如果在动画结束了还没有被End, 来保证触发通知的End事件

补充: Sequence的BranchPoint也是会当作Queue通过TriggerSingleAnimNotify()等形式触发

补充: 引擎自带的蒙太奇通知都是BranchPoint执行的

回到上面2个State通知的情况, 如果前者使用的是BranchPoint, 那么看一下结果

image-20220419095456222

image-20220419095523247

结果就是前面的通知的End可以在正确的时机触发而不用等到下一帧了

但是BranchPoint有个问题, 在同一帧中的不能有太多的BranchPoint, 否则可能会导致部分通知丢失, 这个在添加通知的时候就会有警告

image-20220419095915331

这是由BranchPoint的触发逻辑决定的, 因为在触发逻辑中, BranchPoint会去查找当前时间点第一个找到的BranchPoint通知, 同一时间的其他会被丢弃掉

image-20220419100312295

image-20220419100126891

总结

  • 关于Queue与BranchPoint

Queue与BranchPoint 主要区别在于Queue方法是异步的,而BranchPoint 是同步的。

Queue方法:当动画的 Evalutate 阶段结束时触发通知。不适合改变部分或跳跃蒙太奇位置。

BranchPoint 方法:到达分支点时,在正确的时间触发通知,精度较高。适合更改部分或跳转到蒙太奇位置。

如果一个特定的动画需要跳转到一个非常精确的点,最好使用BranchPoint 方法以获得更高的精度。

如果由于Queue方法的异步特性而在另一个帧中触发了事件,或者如果出现挂起,请使用BranchPoint方法。

BranchPoint方法以高开销为代价达到高精度,有好处也有缺点。如果允许若干帧的精度差距, 不会跳帧, 那么使用队列是足够了

补充: 在大幅度的跳帧的情况下, 两者都可能不能触发

  • Notify和NotifyState

Notify是一次性事件, 如果跳帧了可能会被跳过

NotifyState是持续性的一个状态, 有Begin/End/Tick事件, 除非整个被跳过, 否则有头必有尾

另外,两者的蓝图Notify事件都是const的, 如果需要进行非const操作还是建议到cpp中去执行(老版本UE4中的Notify的蓝图事件不是const)