拖动关键帧角色就抽动,动画编辑器怎样补齐中间状态

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 之类的样条,那又是另一个话题了。

相关推荐
行走的陀螺仪1 小时前
Claude Code 从入门到实战(二):Bash工具、Plan模式与后台任务
开发语言·bash
lpfasd1231 小时前
pywebview与Electron
前端·javascript·electron
努力努力再努力wz1 小时前
【Lua 入门系列】从执行模型到事件驱动:一文理解 Lua 有栈协程、挂起恢复与协作式调度
开发语言·lua
行走的陀螺仪1 小时前
Claude Code 从入门到实战(一):安装、登录与第一个Vue项目
前端·javascript·vue.js
梦曦i1 小时前
0.8.2 新增 backFallback 配置,无 tabbar 页返回无忧
前端·uni-app x
椒颜皮皮虾྅1 小时前
一套 C# 接口,解锁四类视觉任务:DeploySharp 分类、分割、关键点与旋转框实战
开发语言·分类·c#
vipxieliang1 小时前
解构赋值与展开运算符:现代 JS 提效技巧
javascript
Amos_Web2 小时前
Rspack 源码解析(三十四):rspack_hook 与宏系统如何用过程宏生成异步 Hook 与插件
前端
恋猫de小郭2 小时前
感人,Flutter 居然有了一个超实用提案,期待落地
android·前端·flutter