ts
function linearSample(kfs: Kf[], at: number): number {
if (kfs.length === 0) return 0
if (at <= kfs[0].t) return kfs[0].v
for (let i = 0; i < kfs.length - 1; i++) {
const a = kfs[i]
const b = kfs[i + 1]
if (at >= a.t && at <= b.t) {
const span = b.t - a.t
const p = span === 0 ? 0 : (at - a.t) / span
return a.v + (b.v - a.v) * p
}
}
return kfs[kfs.length - 1].v
}
这是我给关键帧动画编辑器写的第一版采样函数。场景对象树里挂着一个角色 Hero,底下是身体 Body、左手 Arm.L、右手 Arm.R,各自拥有一条位移轨道。角色做的是 JUMP_02 跳跃动作,时间轴上打了两个标记:起跳 00:18、最高点 00:36。编辑器右侧那排变换属性显示位置 X 124 px、位置 Y 86 px、缩放 1.18×、旋转 -12°。
这套界面的时间轴刻度是 24 FPS,横轴从 00 排到 60,每条轨道(Position、Scale、Rotation)上都有几颗菱形的关键帧标记。第一版我图省事,采样函数直接做了线性插值,几十行就搞定,心想"关键帧之间不就是平均走嘛"。
结果拖到 00:18 和 00:36 之间,角色的右手猛地抽一下,动作生硬得像掉帧。跑起来才发现问题出在哪:线性插值在关键帧处一阶导数不连续,速度在每一帧交接的地方凭空跳变。00:18 那一帧,右手上一段还在往上走,下一段突然往下掉,方向瞬间反转。人眼对速度突变特别敏感,这个断点一眼就抓住了。视觉上就像角色抽了一下筋,所以题目里说"拖动关键帧角色就抽动"。
想当然的做法在哪翻车,我在纸上画了条折线就明白了:线性插值给出的是折线段,折点处斜率跳变,等价于角色在这一帧突然获得了一个反向加速度。要消掉这个跳变,得让曲线在关键帧处一阶连续,甚至二阶连续,贝塞尔曲线正好能干这个。
先把关键帧的数据模型立起来
先别急着上贝塞尔,把数据模型立起来。一条轨道是一堆带时间戳的采样点,每个关键帧除了数值,还得带两个控制点,缓入缓出才有地方描述。
ts
enum EaseMode { LINEAR = 0, BEZIER = 1, SPRING = 2 }
interface Keyframe {
time: number
value: number
inHandle: number
outHandle: number
ease: EaseMode
}
interface Track {
name: string
unit: string
keyframes: Keyframe[]
}
class KeyframeStore {
tracks: Track[] = [
{
name: 'Position',
unit: 'px',
keyframes: [
{ time: 0, value: 86, inHandle: 0, outHandle: 12, ease: EaseMode.BEZIER },
{ time: 18, value: 124, inHandle: -14, outHandle: 0, ease: EaseMode.BEZIER },
{ time: 36, value: 86, inHandle: 0, outHandle: -8, ease: EaseMode.BEZIER }
]
},
{
name: 'Rotation',
unit: 'deg',
keyframes: [
{ time: 0, value: -12, inHandle: 0, outHandle: 3, ease: EaseMode.BEZIER },
{ time: 36, value: 6, inHandle: -4, outHandle: 0, ease: EaseMode.BEZIER }
]
}
]
}
EaseMode 里留了个 SPRING,对应界面右侧"插值模式"那三个 tab------线性、缓入、弹性------里的弹性档。弹簧是一条独立的积分逻辑,阻尼和刚度调出来的曲线没法用贝塞尔精确拟合,所以单独占一个枚举值,这篇文章只聊前两档。
这里有个约定:inHandle 和 outHandle 存的是相对当前 value 的偏移量,不是绝对坐标。我一开始存绝对坐标,拖动控制点的时候要同时改两个相邻关键帧,一对就错位。改成相对偏移之后,挪 value 控制点跟着走,挪控制点只动自己那一侧,互不干扰。这个决定省掉了很多边界判断,也少写了一半的同步逻辑。
还有个单位问题:控制点偏移量按帧算,不按像素算。outHandle 12 表示影响接下来 12 帧的走向,跟屏幕密度无关。我第一版按像素存,换了一台屏幕密度不同的设备,控制点拖动的物理距离一样,换算成帧数差了 1.5 倍,同一份工程在两台设备上采样结果不一致。统一成帧之后,工程文件跟分辨率脱钩,导出给播放端也不会变形。
采样从折线换成贝塞尔
有了带控制点的数据,采样要做的就是把"线性走 t"换成"沿三次贝塞尔走 t"。三次贝塞尔有个麻烦:它的横轴不均匀,输入时间归一化成 x 之后,不能直接拿 x 当参数,得先反解出曲线上的参数 u,再算纵轴值。
ts
function bezier1d(p0: number, p1: number, p2: number, p3: number, t: number): number {
const u = 1 - t
return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3
}
function solveBezierT(x: number, p1x: number, p2x: number): number {
let t = x
for (let i = 0; i < 12; i++) {
const guess = bezier1d(0, p1x, p2x, 1, t) - x
const slope = 3 * (1 - t) * (1 - t) * p1x + 6 * (1 - t) * t * (p2x - p1x) + 3 * t * t * (1 - p2x)
if (Math.abs(slope) < 1e-6) break
t -= guess / slope
if (t < 0) t = 0
if (t > 1) t = 1
}
return t
}
solveBezierT 用牛顿迭代反解参数,12 次迭代对 24 FPS 采样足够了,误差能压到 1e-4 以下。斜率接近 0 就 break,防止除以极小量把 t 弹飞,再钳制回 0, 1。默认控制点取 (0.25, 0.75) 时,这条曲线接近标准的缓出手感,跟 CSS 的 ease 差不多。
为什么要反解 t?因为贝塞尔的横轴(时间)和参数 t 不是线性关系,直接拿归一化时间当 t 会让动效在中段突然加速,缓入缓出就白做了。反解这一步是整个函数里最容易踩坑的地方,t 的初值我用 x 起步,比从 0.5 起步收敛快,省两次迭代。
牛顿迭代也不是万能的。控制点收得极紧时,曲线在某一小段几乎竖直,斜率接近 0,t 的更新会左右横跳。我碰到过一次:把 outHandle 拉到 0.1 帧以内,曲线在起点附近抖成 S 形,迭代 12 次都没收敛到 1e-4。后来在 UI 上给控制点拖拽加了最小步长 0.5 帧,从源头绕开这个病态区间,比在求解器里加特判省事。
控制点怎么拼进采样
接下来把关键帧的两个控制点拼进采样,出控制点用本帧 outHandle,入控制点用下一帧 inHandle,一段段首尾相接。
ts
function sampleTrack(track: Track, at: number): number {
const kfs = track.keyframes
if (kfs.length === 0) {
return 0
}
if (at <= kfs[0].time) {
return kfs[0].value
}
for (let i = 0; i < kfs.length - 1; i++) {
const a = kfs[i]
const b = kfs[i + 1]
if (at >= a.time && at <= b.time) {
if (a.ease === EaseMode.LINEAR || b.ease === EaseMode.LINEAR) {
const span = b.time - a.time
const p = span === 0 ? 0 : (at - a.time) / span
return a.value + (b.value - a.value) * p
}
const span = b.time - a.time
if (span === 0) {
return a.value
}
const x = (at - a.time) / span
const t = solveBezierT(x, 0.25, 0.75)
const c0 = a.value
const c1 = a.value + a.outHandle
const c2 = b.value + b.inHandle
const c3 = b.value
return bezier1d(c0, c1, c2, c3, t)
}
}
return kfs[kfs.length - 1].value
}


失败分支与异步预览
失败分支藏在这几个判断里。span === 0 表示两个关键帧时间戳重复,多半是复制关键帧忘了改时间,直接返回前值,避免除法造出 NaN 传染整条轨道。轨道为空、时间落在首帧之前,也各自兜住。这些分支线上真会踩到------我第一版没写 span === 0 那行,复制关键帧之后整个曲线编辑器直接白屏,查了半天才定位到是一个 NaN 进了 Polyline 的 points。
NaN 传染这件事值得单独说。曲线编辑器里 Polyline 的 points 是一串 'x,y' 拼起来的字符串,一个 NaN 进去,整条 path 变成 'NaN,86 NaN,124',渲染层解析不出来,画布直接白屏,连报错都不给。从那以后我养成了一个习惯:凡是数值要流进 UI 组件,先过一道 Number.isFinite 检查,代价是一行代码,省的是半天排查。
预览不能一帧帧同步算。24 FPS 下 60 秒动画有 1440 帧,主线程逐帧算会卡死拖动。编辑器里改用异步采样循环,把结果塞进缓存,UI 只读缓存。
ts
async function buildPreview(store: KeyframeStore, fps: number, onProgress: (p: number) => void): Promise<Map<string, Float32Array>> {
const out = new Map<string, Float32Array>()
const first = store.tracks[0].keyframes[0].time
const last = store.tracks[0].keyframes[store.tracks[0].keyframes.length - 1].time
const frameCount = Math.floor((last - first) * fps / 24) + 1
const limit = 4096
if (frameCount > limit) {
throw new Error(`预览帧数 ${frameCount} 超过上限 ${limit}`)
}
for (const track of store.tracks) {
const buf = new Float32Array(frameCount)
for (let f = 0; f < frameCount; f++) {
buf[f] = sampleTrack(track, first + f * 24 / fps)
if (f % 120 === 0) {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
onProgress(f / frameCount)
}
}
out.set(track.name, buf)
}
return out
}
await setTimeout(resolve, 0) 是关键,每算 120 帧让出一次事件循环,界面才有机会响应拖动。frameCount 上限 4096 挡住用户把时间轴拉到几千帧导致内存暴涨的场景。Float32Array 比 number\[\] 省一半内存,一条 400 帧的轨道不过 1.6 KB。缓存建好后,拖动时间轴只是按索引取值,单帧耗时从 8ms 掉到 1ms 以内,抽动也跟着消失了。

界面左侧是场景对象树,舞台、角色、身体、左右手、阴影、相机一路展开,角色那行高亮成选中态,左右手用菱形图标挂在角色下面表示父子层级;中间是 24 FPS 的预览画布,角色头顶那圈黄色圆点就是当前关键帧,底下 VALUE CURVE 把 Position 的曲线画了出来,青色折线就是实时采样结果;右侧是变换属性列表和插值模式切换,线性、缓入、弹性三档,弹性那档目前高亮着。
边界用例跑一遍才敢用
采样函数这种纯逻辑,写完不跑用例就是给自己埋雷。我攒了一个断言小框架,把边界情况逐条钉死,谁改坏了采样立刻报错。
ts
function assertNear(actual: number, expected: number, label: string): void {
if (Math.abs(actual - expected) > 1e-4) {
throw new Error(`${label} 失败:期望 ${expected},实际 ${actual}`)
}
}
const posTrack: Track = {
name: 'Position',
unit: 'px',
keyframes: [
{ time: 0, value: 86, inHandle: 0, outHandle: 12, ease: EaseMode.BEZIER },
{ time: 18, value: 124, inHandle: -14, outHandle: 0, ease: EaseMode.BEZIER },
{ time: 36, value: 86, inHandle: 0, outHandle: -8, ease: EaseMode.BEZIER }
]
}
assertNear(sampleTrack(posTrack, -5), 86, '首帧之前返回首帧值')
assertNear(sampleTrack(posTrack, 18), 124, '命中关键帧返回关键帧值')
assertNear(sampleTrack(posTrack, 40), 86, '末帧之后返回末帧值')
const dupTrack: Track = {
name: 'Dup',
unit: 'px',
keyframes: [
{ time: 10, value: 50, inHandle: 0, outHandle: 8, ease: EaseMode.BEZIER },
{ time: 10, value: 50, inHandle: 0, outHandle: 0, ease: EaseMode.BEZIER }
]
}
assertNear(sampleTrack(dupTrack, 10), 50, '重复时间戳不产生 NaN')
const emptyTrack: Track = { name: 'Empty', unit: 'px', keyframes: [] }
assertNear(sampleTrack(emptyTrack, 12), 0, '空轨道返回 0')
容差给到 1e-4,跟牛顿迭代的收敛精度对齐,再严就会被浮点尾数坑到,误报一堆。重复时间戳 那条是重点,它守的就是"复制关键帧忘了改时间"那个线上事故,当年白屏的曲线编辑器就是缺这条用例。
速度跳变的回归用例
光守边界还不够,还得验"曲线真的顺了"。第一版抽动的根因是关键帧处速度跳变,写个检查函数把相邻两段在关键帧处的速度差算出来,当回归用例用。
ts
function velocityAt(track: Track, at: number, h: number = 0.5): number {
return (sampleTrack(track, at + h) - sampleTrack(track, at - h)) / (2 * h)
}
function checkJerk(track: Track): number[] {
const jumps: number[] = []
const kfs = track.keyframes
for (let i = 1; i < kfs.length - 1; i++) {
const t = kfs[i].time
const before = velocityAt(track, t - 1)
const after = velocityAt(track, t + 1)
jumps.push(Math.abs(after - before))
}
return jumps
}
checkJerk 用中心差分估关键帧前后一小步的速度,差值就是速度跳变量。线性插值版本在 00:18 那帧的跳变有 20 多 px/s,换成贝塞尔之后压到 2 px/s 以内,人眼基本觉不出顿感。h 取 0.5 帧是试出来的,取太小差分被浮点噪声放大,取太大跨越了相邻段,量出来的是平均速度不是瞬时速度。
这套用例现在挂在提交前跑,二十来条,一两秒跑完。改采样逻辑的人不止我一个,动画组那边也提过 PR,没有用例盯着,谁都不敢动 solveBezierT 里那个 12 次迭代的循环。
工程结构与边界条件
工程目录长这样。
text
KeyframeStudio/
├─ entry/src/main/ets/
│ ├─ pages/Index.ets # 编辑器界面与时间轴布局
│ ├─ core/KeyframeStore.ets # 轨道数据模型与关键帧
│ ├─ core/BezierSampler.ets # 贝塞尔插值与参数反解
│ └─ core/PreviewBuilder.ets # 异步预览采样与缓存
├─ entry/src/main/resources/ # 色板与文字资源
└─ build-profile.json5
| 边界条件 | 处理 | 触发场景 |
|---|---|---|
| span === 0 | 返回前值,不产生 NaN | 复制关键帧忘了改时间 |
| 轨道为空 | 返回 0 | 新建轨道还没打点 |
| at 在首帧之前 | 返回首帧值 | 时间轴拖到 0 之前 |
| 帧数超 4096 | 抛错中断 | 时间轴拉到几千帧 |
边界这块再补两句。贝塞尔插值只在数值轨道上成立,Position、Scale、Rotation、Opacity 都适用,但 Rotation 那个 -12° 到 6° 的跨零问题要单独处理------旋转量超过 180° 时走短边还是长边,取决于关键帧上有没有"顺时针"标记,这篇没展开。spring 弹性模式另有一套管弹簧刚度与阻尼的积分逻辑,也不在这里。24 FPS 是按项目时间轴刻度定的采样率,真要导出给 60 FPS 播放端,得重新按目标帧率采样,不能简单两帧拼一帧,否则速度会整体变快。另外贝塞尔只是让曲线变顺,它不保证二阶连续,角色做高速转向时如果还想要完全无抖动,得换成 Catmull-Rom 之类的样条,那又是另一个话题了。