本文简单记录了使用 动画通知(Anim Notify) 过程中遇到的一些问题和可能的解决方法
基础
动画通知 是 UE 提供的一种逻辑触发机制,可以在播放动画过程中触发指定的逻辑,在类型上大概分为两类:
- UAnimNotify
UAnimNotify 是 单点触发 类的动画通知,相关逻辑是一次性触发完成的,从代码(C++)上来讲,扩展 UAnimNotify 主要是要重载实现 Notify 函数:
void Notify(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation);
- UAnimNotifyState
UAnimNotifyState 是 时间段触发 类的动画通知,相关逻辑是在一段时间内触发完成的(分为 Begin, Tick, End 三个阶段),从代码(C++)上来讲,扩展 UAnimNotifyState 主要是要重载实现以下三个函数:
void NotifyBegin(class USkeletalMeshComponent * MeshComp, class UAnimSequenceBase * Animation, float TotalDuration);
void NotifyTick(class USkeletalMeshComponent * MeshComp, class UAnimSequenceBase * Animation, float FrameDeltaTime);
void NotifyEnd(class USkeletalMeshComponent * MeshComp, class UAnimSequenceBase * Animation);
基础内容叙述的比较简略,有兴趣的朋友可以参考文档继续了解.
问题
动画通知一般都会提供一些可配置的参数,譬如:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="AnimNotify", meta=(DisplayName="Particle System"))
UParticleSystem* PSTemplate;
并且,动画通知一般也应该仅提供可配置的(静态)参数设置(而不要记录其他(动态)数据)
对于 UAnimNotify 而言,由于是 单点触发 的,所以一般也不会有记录(动态)数据的需求,但是对于 UAnimNotifyState,往往都涉及清理操作(时间段结束时),所以记录(动态)数据很多时候都是刚需:
考虑你要开发一个 UAnimNotifyState,用于在一段时间内播放某个循环粒子(时间结束后销毁),一个普遍的实现方法就是在 NotifyBegin 时创建粒子,并记录下粒子引用(数据),之后在 NotifyEnd 时检查引用并释放粒子,按照以上思路,我们很容易在该 UAnimNotifyState 的实现中添加以下数据成员:
TWeakObjectPtr<UParticleSystemComponent> ParticleSystemComponent;
但是,这里存在一个很隐晦的问题: 动画通知其实是共享的(因为动画实例播放动画时引用的是同一份动画资源)
说的有些抽象,这里举个例子: 假设我们有某一动画(名为 ‘Animation’),该动画中设置了两个动画通知(名为 ‘Notify1’ 和 ‘Notify2’(这里注意一下,动画中不同动画通知之间彼此是数据独立的)),现在有 A 和 B 两个动画实例同时播放 ‘Animation’,那么 A 和 B 访问的其实是同一份 ‘Animation’ 资源,自然的,相关的 ‘Notify1’ 和 ‘Notify2’ 也便是共享的.
那么回到上面的那个问题,如果我们直接在 UAnimNotifyState 的实现中添加(动态)数据,那么不同动画实例播放同一动画序列时便会产生’数据竞争’,进而导致逻辑出错.
有一些方法可以处理动画通知共享数据的问题,比较简单的一种就是在动画通知中将不同动画实例之间的数据进行隔离:
// data container for animation notify
template<typename T>
class TANDataContainer
{
public:
// do not buffer result
T* Get(USkeletalMeshComponent* MeshComponent)
{
auto Key = GenKey(MeshComponent);
if (Key >= 0)
{
if (DataContainer.Contains(Key))
{
return &DataContainer[Key];
}
}
return nullptr;
}
// do not buffer result
T* GetOrAdd(USkeletalMeshComponent* MeshComponent)
{
auto Key = GenKey(MeshComponent);
if (Key >= 0)
{
return &DataContainer.FindOrAdd(Key);
}
return nullptr;
}
bool Remove(USkeletalMeshComponent* MeshComponent)
{
auto Key = GenKey(MeshComponent);
if (Key >= 0)
{
return DataContainer.Remove(Key) > 0;
}
return false;
}
protected:
int GenKey(USkeletalMeshComponent* MeshComponent)
{
if (MeshComponent)
{
return MeshComponent->GetUniqueID();
}
// return -1 as invalid key
// NOTE key is valid when >= 0
return -1;
}
protected:
TMap<int, T> DataContainer;
};
代码使用大概是这个样子(动画通知中使用 TANDataContainer 来存储(动态)数据):
struct FANData
{
TWeakObjectPtr<UParticleSystemComponent> ParticleSystemComponent;
};
TANDataContainer<FANData> DataContainer;
经过以上步骤,不同动画实例间的数据共享问题就解决了,但是仍然存在一个更隐晦的问题: 同一动画实例间的数据共享问题.
你可能会觉得奇怪,同一动画实例怎么会有数据共享问题呢 ? 这种情况其实是很普遍的,这往往发生在动画播放过程中,又重新触发播放了相同动画(动画重入问题).
在上面这种情况下, 对于 UAnimNotifyState 而言,其一般的触发顺序是(略去了 NotifyTick, 另外的,也可能出现多一次 NotifyBegin/NotifyEnd 调用的情况,此处也略去不谈):
NotifyBegin(old) -> NotifyEnd(old) -> NotifyBegin(new) -> NotifyEnd(new)
所以实际上也并不会出现数据竞争的问题,但是如果你将动画的触发方式改为了 Branching Point (对触发方式有兴趣的朋友还是请参考文档,这里不做解释),那么对于 UAnimNotifyState 而言,其触发顺序往往是这样的(略去了 NotifyTick):
NotifyBegin(old) -> NotifyBegin(new) -> NotifyEnd(old) -> NotifyEnd(new)
于是数据共享问题又出现了(同一动画实例) …
为了解决这个问题,我们需要重载实现 UAnimNotifyState 中的以下函数:
void BranchingPointNotifyBegin(FBranchingPointNotifyPayload& BranchingPointPayload);
void BranchingPointNotifyTick(FBranchingPointNotifyPayload& BranchingPointPayload);
void BranchingPointNotifyEnd(FBranchingPointNotifyPayload& BranchingPointPayload);
其中 BranchingPointPayload 参数可以为我们提供 MontageInstanceID 数据,基于此我们就可以区分出相同动画的不同实例(当然,这种实现方式也导致对应的 UAnimNotifyState 只能用于 蒙太奇(Montage) 中),官方有个相关的实现 UAnimNotifyState_DisableRootMotion,有兴趣的朋友可以看看:
UCLASS(editinlinenew, const, hidecategories = Object, collapsecategories, MinimalAPI, meta = (DisplayName = "Disable Root Motion"))
class UAnimNotifyState_DisableRootMotion : public UAnimNotifyState
{
GENERATED_UCLASS_BODY()
public:
virtual void BranchingPointNotifyBegin(FBranchingPointNotifyPayload& BranchingPointPayload) override;
virtual void BranchingPointNotifyEnd(FBranchingPointNotifyPayload& BranchingPointPayload) override;
#if WITH_EDITOR
virtual bool CanBePlaced(UAnimSequenceBase* Animation) const override;
#endif
};
UAnimNotifyState_DisableRootMotion::UAnimNotifyState_DisableRootMotion(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
bIsNativeBranchingPoint = true;
}
void UAnimNotifyState_DisableRootMotion::BranchingPointNotifyBegin(FBranchingPointNotifyPayload& BranchingPointPayload)
{
Super::BranchingPointNotifyBegin(BranchingPointPayload);
if (USkeletalMeshComponent* MeshComp = BranchingPointPayload.SkelMeshComponent)
{
if (UAnimInstance* AnimInstance = MeshComp->GetAnimInstance())
{
if (FAnimMontageInstance* MontageInstance = AnimInstance->GetMontageInstanceForID(BranchingPointPayload.MontageInstanceID))
{
MontageInstance->PushDisableRootMotion();
}
}
}
}
void UAnimNotifyState_DisableRootMotion::BranchingPointNotifyEnd(FBranchingPointNotifyPayload& BranchingPointPayload)
{
Super::BranchingPointNotifyEnd(BranchingPointPayload);
if (USkeletalMeshComponent* MeshComp = BranchingPointPayload.SkelMeshComponent)
{
if (UAnimInstance* AnimInstance = MeshComp->GetAnimInstance())
{
if (FAnimMontageInstance* MontageInstance = AnimInstance->GetMontageInstanceForID(BranchingPointPayload.MontageInstanceID))
{
MontageInstance->PopDisableRootMotion();
}
}
}
}
#if WITH_EDITOR
bool UAnimNotifyState_DisableRootMotion::CanBePlaced(UAnimSequenceBase* Animation) const
{
return (Animation && Animation->IsA(UAnimMontage::StaticClass()));
}
#endif // WITH_EDITOR
值得注意的是,使用 Branching Point 时,同一触发时间只能存在一个 Branching Point 类动画通知,如果在同一触发时间配置了多个 Branching Point 类动画通知,会有程序告警,并且不能正确触发动画通知:
Branching Point ‘XXX’ overlaps with ‘XXX’ at time: XXX. One of them will not get triggered!
(实际上,上述说法仅对 Branching Point 类 UAnimNotify 成立, 同一触发时间如果存在多个 Branching Point 类 UAnimNotifyState 的话,虽然也会有程序告警,但实际上还是能正确触发的,有兴趣的朋友可以搜索查看 FAnimMontageInstance::UpdateActiveStateBranchingPoints 函数来了解原由)