关键帧动画系统:插值、缓动与时间轴模型

0 阅读20分钟

一、三个看起来毫不相干的 bug

做视频编辑器的关键帧功能,你会陆续收到这样三条反馈:

  1. "我把片段开头裁掉了一点,动画全乱了。" 本来匀速飘进画面的贴纸,裁完之后从一半开始,最后还甩了一下。
  2. "时间轴放大之后,之前那个关键帧点不中了。" 缩小回去又能点中。
  3. "我只想让它移动,结果之前调好的透明度被锁死了。" 用户在第 3 秒打了个点改位置,回头发现第 1 秒调的淡入没了。

三条反馈来自三个不同的功能入口,看起来是三个 bug。它们的根因是同一个:关键帧的数据模型。

这件事上最容易产生的误解是"关键帧系统的核心是插值算法"。插值是全场最简单的部分——线性插值五行代码,写完就不用再碰。真正决定这个系统能做多复杂的动画、能不能和裁剪/变速/分割/撤销共存的,是三个建模决定:

  • 一个关键帧持有什么(整帧快照,还是单属性通道)
  • 一个关键帧的时间锚在哪条时间轴上
  • 哪些状态是真相,哪些是派生

这篇按我踩过的顺序讲这三件事。示例代码是等价重写的示意(真实工程是 Objective-C,我用 Swift 写清楚结构),字段名按通用叫法改过。


二、一个关键帧持有什么:整帧快照 vs 属性通道

两种模型,选哪个决定了后面所有事。

模型 A:整帧快照。 一个关键帧是"这一时刻,这个片段的所有可动画属性的值":

final class KeyFrame: NSObject, NSCoding, NSCopying {
    var time: CGFloat           // 关键帧在片段内的时间
    var originTime: CGFloat     // 对应的源素材时间(第三节细说)

    // 几何
    var scale: CGFloat
    var position: CGPoint
    var rotate: CGFloat
    var opacity: CGFloat

    // 调色(一整排)
    var filterValue: CGFloat
    var contrast: CGFloat
    var luminance: CGFloat
    var saturation: CGFloat
    var sharpness: CGFloat
    var hue: CGFloat
    var temperature: CGFloat
    var shadowHighlight: CGFloat
    var vignette: CGFloat

    // 音量
    var volume: CGFloat

    // 蒙版
    var maskCenter: CGPoint
    var maskScale: CGFloat
    var maskGradient: CGFloat
    var maskRotation: CGFloat
    var maskRect: CGSize
}

二十来个字段,一个扁平的属性包。

模型 B:属性通道。 每个属性一条独立的轨,各自有各自的关键帧列表:

struct AnimatedClip {
    var channels: [PropertyKey: [Keyframe<Value>]]   // "position" → [...], "opacity" → [...]
}

我当时选了 A。理由很实际:

  • UI 直接对应:时间轴上就是一排点,用户点一下加一个点,所见即所得。模型 B 的 UI 要么给每个属性一条轨(界面爆炸),要么把多条通道折叠成一排点(那就回到了 A 的交互,却要承担 B 的复杂度)。
  • 存档简单:这个对象要 NSCoding 进工程文件,一个扁平结构比一张嵌套字典好写、好兼容、好调试。
  • 撤销重做便宜:整个数组拷一份就是一次快照,不用按通道 diff。

代价就是开头那条反馈 3。看清楚机制:

// 用户在某个时刻点了"加关键帧"
let kf = KeyFrame()
kf.time = timelineTime - clip.trackIn
// ↓ 把片段"此刻的全部状态"抄进去
kf.scale = clip.scale
kf.position = clip.position
kf.rotate = clip.rotate
kf.opacity = clip.alpha
kf.volume = clip.volume
kf.filterValue = clip.filterPower
// ... 二十来个字段全抄

用户以为自己打的是"位置的点",实际打的是"所有属性的点"。 于是第 3 秒这一帧上,透明度也有了一个确定值(= 当时的值)。用户之前在第 1 秒设的淡入,插值到第 3 秒时会被这一帧的值接管——从用户视角看,就是"我的淡入没了"。

这不是 bug,是模型 A 的定义。能做的缓解有限,而且都很糙:

  • 加关键帧时只抄"当前面板正在编辑的那一类属性",其余字段沿用前一帧的插值结果。听起来聪明,实际上会产生幽灵状态:同一个关键帧对象,有的字段是"用户的意图",有的字段是"当时算出来的中间值",两者在数据上不可区分。半年后没人说得清某个字段到底是谁写的。
  • 老老实实走模型 B。

整帧快照 vs 属性通道

所以真正的结论是一条选型前必须问的问题:

产品会不会要"只给位置打关键帧、不影响透明度"?

会 → 从第一天就上属性通道。不会 → 整帧快照能省掉一半代码。

这个决定基本不可逆:模型 A 换 B,要同时改存档格式(老工程文件得能升级)、时间轴 UI、撤销重做的记录粒度、以及所有读写关键帧的调用点。它比"插值算法选哪种"重要一个数量级,但通常在项目第一周、由一个正在赶工期的人顺手定下来。


三、时间的身份:一个关键帧有三个时间

这是整个系统里最难、也最值钱的部分。

用户在屏幕上看到播放头停在某处,点了"加关键帧"。这一刻的"时间"是什么? 有三个候选:

时间定义谁会破坏它
全局时间轴时间播放头在整条时间轴上的位置拖动片段、前面插入/删除片段
片段局部时间timelineTime − clip.trackIn裁剪片段开头、改变速
源素材时间这一帧对应原始视频文件里的哪一刻几乎不被破坏

一个个排除:

存全局时间是错的。 用户把片段往后拖两秒,动画不应该跟着"错位"——它应该整体跟着片段走。存全局时间意味着每次移动片段都要遍历修正所有关键帧,而且漏一个地方就出错。

只存局部时间也是错的。 用户裁掉片段开头 1 秒,局部时间的原点就变了:原本在局部 2.0s 的关键帧,现在应该在 1.0s。同样要遍历修正——这就是开头那条反馈 1。更糟的是变速:速度从 1.0 改成 2.0,局部时间轴整体压缩一半,所有关键帧都得重算。

源素材时间最稳。 裁剪、变速、移动都不改变"这一帧钉在原始素材的第几秒"这件事。

真实工程里的做法是两个都存,分清主从:

// 加关键帧时,同时算出两个时间
kf.time = timelineTime - clip.trackIn                               // 派生:片段局部时间

if clip.mediaType == .photo {
    // 图片没有"源时间轴",也不受变速影响
    kf.originTime = (timelineTime - clip.trackIn) + clip.trimIn
} else {
    // 视频/音频:先回到原速,再叠上左裁剪量
    kf.originTime = (timelineTime - clip.trackIn) / clip.speed + clip.trimIn / clip.speed
}

originTime 是真相,time 是派生缓存(因为渲染和 UI 每帧都要用它,不能每次都算)。

于是任何改变片段几何的操作之后,都要重建派生态:

/// 裁剪、改时长、改速度之后统一调用
func rebuildKeyFrames(of clip: Clip) {
    let leftTrim = clip.trimIn
    let rightTrim = clip.isTimeScalable
        ? leftTrim + clip.trimDuration / clip.speed
        : leftTrim + clip.trimDuration

    var kept: [KeyFrame] = []
    for kf in clip.keyFrames {
        // 1. 落在裁剪区间外的关键帧,丢弃
        guard kf.originTime >= leftTrim, kf.originTime <= rightTrim else { continue }
        // 2. 区间内的,用源时间反推新的局部时间
        kf.time = clip.isTimeScalable
            ? (kf.originTime - leftTrim) * clip.speed
            : (kf.originTime - leftTrim)
        kept.append(kf)
    }
    clip.keyFrames = kept
}

一个关键帧有三个时间,真相只有一个

三个细节,每个都是踩出来的:

第一,图片是例外,而且例外必须写进模型。 图片没有源时间轴,变速对它无意义(它的"时长"是用户设的,不是素材固有的)。如果不把这个例外收进一个 isTimeScalable 之类的属性里,你会在十几个调用点各写一遍 if mediaType == .photo——然后在第十一个地方漏掉。

第二,被裁掉区间外的关键帧是真的被删了。 不是隐藏,是从数组里移除。这直接带来下一节的问题。

第三,这套映射只在"速度是一个标量"时成立。 第七节细说。


四、删除关键帧时,有一个所有人都会漏掉的步骤

删除一个关键帧,看起来就是 array.remove(at:)。

实际上还有一步。假设片段上有两个关键帧,播放头停在它们中间,画面此刻显示的是插值出来的中间状态(比如 scale = 1.35)。现在用户删掉其中一个关键帧——那个 1.35 是谁给的?没人。它是上一次插值留在片段属性上的残留。

结果就是:用户删掉关键帧后,画面定格在一个诡异的缩放上,而时间轴上看不出任何异常,属性面板显示的数字也"没人改过"。这种 bug 极难复现,因为它取决于删除时播放头的位置。

正确做法是:任何改变关键帧集合的操作,末尾都要重建片段的当前属性:

func deleteKeyFrame(at index: Int, of clip: Clip) {
    clip.keyFrames.remove(at: index)

    // 关键的一步:把片段当前属性回灌成某个仍然存在的关键帧的值
    if let anchor = clip.keyFrames.last {
        clip.apply(anchor)     // scale / position / rotate / opacity / volume / mask ...
    }
}

背后的原则比这段代码重要:

在动画系统里,"片段当前的属性值"是派生态,不是状态。

派生态的清单比想象中长:片段的当前属性、每个关键帧的局部时间、时间轴上那排按钮的横坐标、以及"这个片段有没有动画"的标记。它们都必须能从真相(关键帧数组 + 片段几何)重建出来,而且每条修改路径结束时都要重建一次。

判断自己有没有做对,有个很简单的检验:把片段的所有派生态清零,再从关键帧数组重算一遍,画面应该一模一样。 做不到,说明某个地方偷偷把派生态当成了真相。


五、裁剪和分割:被吃掉的关键帧,撤销要能吐回来

上一节说裁剪会真的删掉区间外的关键帧。那么撤销呢?

用户裁掉片段开头 → 三个关键帧被删 → 用户后悔了 → 撤销。如果撤销记录里只存了"裁剪前的 trimIn",那么恢复 trimIn 之后,那三个关键帧不会自己回来——它们已经不在数组里了。

所以撤销记录必须把被这次操作删掉的关键帧单独存一份。分割同理,而且更绕:分割会把一个片段变成两个,原来的关键帧要按分割点分给两段,落在分割点上的那个还要复制成两份(前段的尾 + 后段的头),否则分割处会出现跳变。

真实工程里,撤销模型除了"关键帧数组"之外,确实还带着一个"因分割而被删除的关键帧数组"字段。字段名本身就是这段历史的化石。

顺带一个分割时的判断——分割点落在哪里,决定了要不要复制:

/// 分割点是否夹在两个关键帧之间(前面有、后面也有)
func isSplitBetweenKeyFrames(at timelineTime: CGFloat, clip: Clip) -> Bool {
    let t = timelineTime - clip.trackIn
    let hasFront = clip.keyFrames.contains { $0.time < t }
    let hasBack  = clip.keyFrames.contains { $0.time > t }
    return hasFront && hasBack
}

这三行代码对应的产品决策是:夹在两帧之间的分割,两段都必须拿到一个"边界帧",否则前段的末尾和后段的开头会各自去 clamp 到自己剩下的那个关键帧,画面在分割处跳一下。用户不会说"分割处跳变",他会说"我剪了一刀,动画坏了"。

我在讲撤销重做那篇里写过一句:"撤销系统的 bug 九成不在撤销代码里。"关键帧是这句话最好的例子——撤销代码没错,错在它记录的东西不足以还原现场。


六、命中判定:容差是像素,不是时间

用户点击时间轴上的关键帧标记,怎么判断点中了哪一个?

直觉答案:找时间最接近播放头的那个,差值小于某个阈值(比如 0.1 秒)就算命中。

真实工程里的写法不是这样:

// 命中容差 = 标记按钮宽度的一半,换算成时间
let tolerance = markerWidth / 2.0 / pixelsPerSecond

for (i, kf) in clip.keyFrames.enumerated() {
    if kf.time - tolerance > currentTime { return nil }             // 已经越过,没命中
    if kf.time - tolerance <= currentTime,
       kf.time + tolerance >= currentTime { return i }              // 命中
}
return nil

注意 pixelsPerSecond:时间轴缩放会改变命中容差。放大时间轴,容差变小;缩小,容差变大。

命中容差的单位是像素,不是时间

第一次看到这段代码的人几乎都会想改掉它——容差怎么能随缩放漂移呢?

但它是对的,而且是我在这套系统里最喜欢的一个设计:

用户点的不是一个时刻,是屏幕上的一个按钮。 容差的正确单位就是像素,时间只是它的投影。

如果用固定的时间容差,会出现这种情况:把时间轴放到最大,两个相距 0.05 秒的关键帧在屏幕上相隔几百像素,用户精准地点在右边那个上——却因为 0.05 < 0.1 被判给了左边那个。用户会觉得这个编辑器"点不准"。反过来,缩到最小时,一个按钮宽度可能对应两秒钟,此时固定容差会让大量点击全部落空。

代价也要认下来,它们都是真实的:

  • 同一个操作在不同缩放下结果不同,所以自动化测试必须把缩放固定成一个已知值,否则用例会随机飘红。
  • 关键帧的最小间距有了下界:两个关键帧如果在屏幕上重叠,永远有一个点不中。所以"加关键帧"要挡一下——当前位置已经命中某个关键帧时,就是更新它而不是新增一个。这一步不做,用户会在同一个位置堆出好几个关键帧,然后发现删不干净。

七、缓动:这套系统里没有缓动函数,这不是偷懒

说个可能让人意外的事实:这套关键帧系统只有线性插值,没有任何缓动函数。没有贝塞尔手柄,没有 ease-in/ease-out 枚举,什么都没有。

func value(at t: CGFloat, from a: KeyFrame, to b: KeyFrame) -> CGFloat {
    guard b.time > a.time else { return a.value }
    let k = (t - a.time) / (b.time - a.time)      // 线性,仅此而已
    return a.value + (b.value - a.value) * k
}

这是个有意识的取舍,理由有三条:

  1. C 端剪辑用户不画曲线。 会用贝塞尔手柄的人本来就在用专业工具。给他们提供一个残缺版手柄,不如不提供。
  2. 缓动和"整帧快照"打架。 模型 A 里一个关键帧承载二十来个属性,缓动挂在哪?挂在关键帧上,意味着所有属性共享同一条缓动——位置在加速的同时透明度也必须加速,这经常不是用户要的。挂在属性上,就等于被迫走向模型 B。
  3. 密集关键帧能顶掉大部分需求。 用户想要"慢进快出",多打两个点就有了,而且所见即所得。

什么时候必须上缓动?当产品要的是线性拼不出来的东西——弹性回弹、过冲、抖动。这些用密集关键帧硬凑,用户要打十几个点,而且调不准。

真要加,我会这样加(注意这是个可加字段,老工程文件读不到它时按 .linear 处理,不破坏任何已有存档):

enum Easing: Int, Codable {
    case linear = 0, easeIn = 1, easeOut = 2, easeInOut = 3
}
// 缓动描述的是"从这一帧到下一帧"的过程,所以挂在起始帧上
kf.easingToNext = .easeOut

枚举,不是自由手柄。 四个选项覆盖九成场景,UI 是四个图标,存档多一个 int。自由手柄要配一个曲线编辑器、要处理手柄越界和非单调(同一时刻两个值)、要定义手柄在变速后怎么变换——那是另一个季度的工作量。


八、当 speed 从标量变成曲线

第三节那个时间映射:

kf.time = (kf.originTime - leftTrim) * clip.speed

它成立的前提是 speed 是一个常数。整段片段 2 倍速,源时间和局部时间之间是一个线性变换,乘一下就完事。

然后产品要曲线变速——速度在片段内部连续变化(比如前半段 0.5 倍慢放、中间加速到 3 倍、结尾回到 1 倍)。速度本身被描述成一串点:

struct SpeedPoint {
    var time: CGFloat        // 在片段内的位置
    var speedValue: CGFloat  // 该点的速度
    var percentTime: CGFloat // 归一化位置
}

这一刻,上面那行乘法就不成立了。源时间到局部时间的关系不再是乘一个系数,而是对速度曲线求积分:

localTime(t_origin) = ∫[trimIn → t_origin]  1 / v(τ)  dτ

即:从左裁剪点积到目标源时刻,被 1/速度 加权。
速度恒定时它退化成一次乘法 —— 那就是上面那行代码。

如果继续用标量 speed 去算,关键帧会在变速区间里逐渐漂移——慢放段漂得多,匀速段看不出来。用户的描述会是"变速之后动画对不上了,但只有中间那一段不对"。

正确的做法不是在每个调用点都写一遍积分,而是把时间映射收成一个对象:

/// 源时间 ↔ 片段局部时间的双向映射。标量变速是它的一个特例。
protocol TimeMap {
    func toLocal(_ originTime: CGFloat) -> CGFloat
    func toOrigin(_ localTime: CGFloat) -> CGFloat
}

struct ConstantSpeedMap: TimeMap { let speed: CGFloat; let trimIn: CGFloat /* 乘除 */ }
struct CurveSpeedMap: TimeMap  { let points: [SpeedPoint] /* 预积分成查找表,再二分 */ }

关键帧、波形图、贴纸出入点、字幕时间——所有需要在两条时间轴之间换算的地方,只调这个接口。加曲线变速时,改的是一个实现,不是二十个调用点。

而一旦把速度写成 [SpeedPoint],会发现一件很值得停下来想一想的事:

速度曲线本身就是一组关键帧。 (time, speedValue) 和关键帧数组是同构的:一串带时间的采样点,中间靠插值,两端要 clamp,改动要进撤销栈,存档要兼容。

我是在做完曲线变速之后才意识到这个同构的。如果一开始就看见,变速完全可以复用关键帧的"通道 + 插值 + 时间映射"那套代码,而不是独立实现一遍——包括它独立踩一遍的边界问题。

这条经验可以推广:当你在一个系统里第二次写出"带时间戳的点列 + 插值"时,停下来,把它抽象成一个东西。 第三次出现的时候,你已经没机会了——那时它们已经各自长出了不兼容的边界行为。


九、求值:唯一一段必须是纯函数的代码

渲染时按时间取值,逻辑很短:

func evaluate(_ clip: Clip, at localTime: CGFloat) -> ClipState {
    let frames = clip.keyFrames          // 已按 time 排序
    guard let first = frames.first else { return clip.staticState }
    guard frames.count > 1 else { return ClipState(first) }

    if localTime <= first.time { return ClipState(first) }        // 左端 clamp
    if localTime >= frames.last!.time { return ClipState(frames.last!) }  // 右端 clamp

    // 二分找到 localTime 落在哪两帧之间
    var lo = 0, hi = frames.count - 1
    while hi - lo > 1 {
        let mid = (lo + hi) / 2
        if frames[mid].time <= localTime { lo = mid } else { hi = mid }
    }
    let a = frames[lo], b = frames[hi]
    let k = (localTime - a.time) / (b.time - a.time)
    return ClipState.interpolate(a, b, k)
}

三条纪律:

第一,两端必须 clamp,不能外推。 第一个关键帧之前、最后一个之后,属性保持不变。外推会在用户裁剪出一段"没有关键帧覆盖"的区域时产生离谱的值(缩放变成负数、透明度超过 1)。

第二,这必须是纯函数。 同一个 localTime 必须永远给出同一个结果,不依赖"上一帧算到哪了"。

第二条看起来是废话,直到你发现预览和导出走的是两条不同的时间推进路径:预览由播放器驱动、可能跳帧、用户还会随机 seek;导出是按固定帧率一帧帧推进的。如果求值函数里藏了任何"上次的状态"——比如为了省事缓存了"当前在第几段区间"——那么预览正常、导出错位,而且错得很隐蔽(只在跳帧或 seek 之后才对不上)。

这类"预览对、导出不对"的问题,我的排查顺序永远是:先确认求值是不是纯的,再看时间映射,最后才怀疑渲染。九成停在第一步。

第三,属性子集要显式。 贴纸没有音量,音频没有蒙版。整帧快照模型里所有字段都存在,所以必须有一张"这个媒体类型参与哪些属性"的表,写在一处。否则给音频片段插值蒙版参数这种事,会悄无声息地发生,然后在某次重构后变成一个诡异的显示问题。


十、什么时候不该自己写

  • 只是界面动效 → Core Animation / SwiftUI 的动画,别自造。它们的缓动、打断、反向播放都比你写得好。
  • 只是播放一段固定动效 → Lottie 或序列帧(上一篇讲特效素材协议时提过这个边界)。设计师做好、端上播放,不需要"用户能编辑"。
  • 必须自己写的情况只有一种:用户要在你的时间轴上打点,而且这份动画数据要存档、要进撤销栈、要跟裁剪/变速/分割联动。

第三种情况下,工作量的分布大概是:插值 5%,UI 25%,时间映射与派生态重建 70%。如果你的排期是按"插值算法有多难"估的,那个排期从一开始就是错的。


十一、收尾:三条可以带走的

第一,关键帧系统的难点是时间的身份,不是插值。 一个"时刻"在不同坐标系下有不同的名字,而破坏坐标系的操作(裁剪、变速、移动、分割)每一个都是产品必备功能。先想清楚谁是真相、谁是派生,再写第一行插值代码。这条对所有"内容 + 时间轴"的系统都成立:字幕、波形、弹幕、埋点回放。

第二,派生态必须能被整体重建,而且要有一个检验它的办法。 把派生态清零、从真相重算一遍、结果应该一模一样——这个检验能写成测试,也能在开发期当断言跑。做不到这一点的系统,会在每次新增编辑操作时冒出一个新的"显示不对"。

第三,第二次写出"带时间戳的点列 + 插值"就该抽象。 关键帧和曲线变速是同一个东西的两次实现,我是做完第二次才看出来的。代价不是多写了一份代码,是两份代码各自长出了不一样的边界行为——之后再想合并,就得先做一轮"哪边的行为才是对的"的考古。

回到开头那三条反馈:裁剪导致动画乱掉,是因为时间锚错了坐标系;放大后点不中,是命中容差的单位问题(而那个"奇怪"的写法恰恰是对的);改位置锁死透明度,是整帧快照模型的定义。三个问题,三个不同的建模决定,没有一个能靠改插值算法解决。


如果你的关键帧动画"预览是对的、导出不对",先别查渲染——把求值函数拿出来,确认它对同一个时间输入永远给同一个输出。九成问题停在这一步。


关于本文的代码与数据

文中 Swift 示例是对真实实现(Objective-C)的等价重写,用于说明数据模型与时间映射的结构,未逐行编译验证;字段名、类型名按通用叫法改写,不对应任何产品的线上代码。裁剪后重建、删除后回灌、命中容差按像素换算、分割点夹在两帧之间的判断,均为真实实现中存在的机制,描述的是机制本身而非任何产品的业务数据。曲线变速的积分式是对"速度非常数时时间映射应有的形状"的推导,不是某个具体实现的还原。文中没有任何产品指标或内部数据。