我曾经认为将动画的关键帧硬塞到目标位置是无害的。但是,事实并非如此。

我正在为 Unity 开发一个动画工具,近一个年来,我一直在测试一个demo,demo中有535块小块物体围绕一个圆形轨道旋转,随着高度的增加,圆形轨道会变大,形成一个空气中的涡轮。每个块物体都执行一个小弧线运动动画,然后将控制权交给下一个动画,如此循环往复。理论上讲,这应该是非常平滑的动画。

然而,事实并非如此。

每当一个动画结束,交给下一个动画时,块物体会产生一个微小的抖动。关闭平滑效果后,它会看起来像一个突然的后退。开启弹性平滑效果后,它会变成一个奇怪的软飘动。同一个问题,完全不同的症状。

还有另一个细节我一直以为是无害的:动画的结束值必须准确地达到目标。如果没有达到目标,物体可能会在目标附近徘徊,并且该距离会随着帧率的变化而变化。

所以,如果你告诉一个按钮移动到 x=5 和 y=25 的位置,你会很失望如果它最终移动到 x=5.03 和 y=25.001 的位置。动画通常不会在百分比完成为 1.0 时准确地达到目标,尤其是在有变动帧率的情况下(就像我们所有人一样)。这就是为什么最明显的解决方案是通过设置动画的目标值来结束动画:

bool done = percentDone >= 1f;

if (done)
{
    inst.CurrentValue = inst.Target;
}
else
{
    float t = effect.Easing.Evaluate(percentDone);
    inst.CurrentValue = inst.StartValue + (t * (inst.Target - inst.StartValue));
}

基本上,如果动画已经完成,就将其强制到目标位置。

我以为这是一个安全的解决方案。然而,它也是一个掩盖问题的短路。

实际的bug并不是一个bug,而是三个小的时间错误,仅在动画链、圆形路径和平滑效果都涉及时才会显现出来。

第一:硬塞到目标的动画实际上消除了最后一帧的一部分,但是这意味着最后一帧的真实运动几乎没有贡献。动画已经到达了目标,并且已经超出了目标,然后我强制它准确到达。所以每个动画都以死帧的最后一部分结束。

修复方案很简单:停止将完成视觉处理作为一个单独的视觉案例。用相同的插值路径完成运动:

bool done = percentDone >= 1f;

float p = Mathf.Clamp01(percentDone);
float t = effect.Easing.Evaluate(p);

inst.CurrentValue = inst.StartValue + (t * (inst.Target - inst.StartValue));

如果 p 为 1,lerp就会自然到达目标。没有特殊的硬塞。

第二:手动传递的动画丢弃了时间。

当效果 A 在一个帧中完成一半时,我驱动循环会完成 A,yield,开始效果 B 在下一个帧中。

这意味着剩余的帧片段就被删除了。

效果 A 在一个帧中完成了 60%,剩余的 40% 应该交给效果 B。然而,效果 B 在下一个帧中从零开始。

这会在动画链中产生一个不可避免的缝隙。

修复方案是立即在同一帧中将剩余时间传递给下一个效果:

float residual = frameCurrentTime - crossTime;

if (residual > Epsilon)
{
    time.PreviousCurrentTime = crossTime;
    time.DeltaTime = residual;
    carrying = true;
    continue;
}

不等待下一帧,不丢弃时间。

第三:当两个效果可以共享一个帧时,你不能让两个效果都获得整个帧。

这听起来现在很明显,但是在旧的流程中并不是这样。

效果 A 应该只获得到达其结束时刻的帧片段。效果 B 应该获得剩余的帧片段。如果两个效果都获得整个帧,时间就会被重复计算。这意味着处理两个帧,并且操纵每个效果对时间的感知。

这重复计算的时间会产生一个速度突发。然后弹性平滑效果会做它应该做的事情:将这段假的速度集成到一个可见的飘动中。

所以,A 的最后一步必须被限制到准确的交叉时刻:

crossTime = startTime + duration;

time.CurrentTime = crossTime;
time.DeltaTime = crossTime - time.PreviousCurrentTime;

然后 B 只会获得剩余的残余时间。

A 消耗了帧的第一部分。B 消耗了帧的第二部分。一起他们会占据整个帧。

想象一下。

最有趣的部分是原始的硬塞看起来在独立情况下是正确的。

一个单独的动画?没问题。它会准确到达目标。没有可见的问题。正确的,甚至是。

但是,一旦我将动画链起来,添加了平滑效果,并且有数百个对象在同时执行它,我的“安全”短路就开始毒害整个动画流程。

对我来说,最重要的教训是:硬塞到目标位置的动画并不一定是错误的,但是它可以掩盖时间错误。如果动画系统支持动画链、平滑效果、速度或手动传递,最后一帧仍然很重要。不能简单地将其丢弃,因为它是“足够接近”的。

这个bug现在已经修复了。没有卡顿,没有突发,没有硬塞,没有飘动。

为了背景,JuiceBox 是我正在为 Unity 开发的 Unity 动画工具。有一个 免费版本 可用,最近我发布了 Pro版本,价格为 50% 的折扣。

我不想把这篇文章变成一个功能列表,所以我会在这里结束。主要是因为我觉得这个调试故事值得分享,因为这个bug正是因为我确信它不会造成问题而写的代码。