Defold 补间动画与缓动曲线

从插值数学讲起,系统拆解 Defold 的补间动画:go.animate 的签名与播放模式、go.Easing 全部曲线类型与选型直觉、自定义 easing 函数的实现与曲线拟合、多属性协同与链式序列编排、时长/延迟/暂停与变速控制,以及 UI 动效与角色运动的实战配方。

引言

「动效」和「动画」是两件事:动画是逐帧画出来的位移,动效是属性随时间从 A 变到 B。Defold 把后者做成了一个内建引擎——go.animate。你不需要写 update 里的一行行 self.t = self.t + dt,只要声明「哪个属性、从哪到哪、多久、用什么节奏」,引擎替你插值。听起来简单,但真正决定手感的是缓动曲线(Easing Curve):同样 0.3 秒的按钮缩放,LINEAR 显得廉价,OUT_BACK 才有「弹一下」的爽感。

本文把补间动画拆成可复用的知识:先讲插值的数学本质,再讲 go.animate 的完整签名与播放模式,接着列出 go.Easing 的全部曲线并给出选型直觉,然后是自定义 easing 函数的实现与曲线拟合,再讲多属性协同与链式序列编排、时长/延迟/暂停/变速的控制手段,最后给 UI 动效与角色运动的实战配方。

前置:精灵与动画体系 、GUI 节点与动效 。动效的渲染开销见 前端动画与渲染性能 、动画状态机设计 。

1. 补间动画的本质:在时间轴上插值

任何补间都可以写成一行公式:

value(t) = from + (to - from) * ease(t / duration)
           ↑起点    ↑总变化量      ↑归一化进度经曲线映射

三个变量决定一切:起点 from、终点 to、时间归一化进度 p = t / duration ∈ [0, 1]。ease(p) 就是缓动函数,它把线性进度 p 映射成「非线性进度」。

-- 手写线性补间:理解公式的最好方式
local function lerp_tween(self, from, to, t, duration)
    local p = math.min(t / duration, 1)     -- 归一化并钳制
    return from + (to - from) * p
end

线性 vs 缓动的差别:

进度 pLINEAROUT_QUADOUT_BOUNCE
0.250.250.440.06
0.500.500.750.77
0.750.750.941.06
1.001.001.001.00

注意 OUT_BOUNCE 在 p=0.75 时值超过 1——这就是「弹过头再回来」的来源。缓动函数允许越界,这正是弹性与回拉效果的数学基础。

心智:补间 = 起点 + 变化量 × 缓动(归一化时间)——所有动效的差异,本质都是这条曲线长得不一样。

2. go.animate:内置补间引擎

完整签名:

go.animate(url, property, playback, to, easing, duration, [delay], [complete_function])
--        对象   属性      播放模式  目标值  曲线    时长     延迟     完成回调
-- 最小用法:0.3 秒内把节点缩放到 1.2 倍
go.animate("#btn", "scale", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
           vmath.vector3(1.2, 1.2, 1), go.Easing.OUT_BACK, 0.3)

-- 带完成回调
go.animate(".", "position", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
           vmath.vector3(0, 200, 0), go.Easing.OUT_QUAD, 0.5,
           0.1, function(self, complete)
               if complete then print("到位") end
           end)

播放模式(PlaybackMode):

模式行为典型用途
PLAYBACK_ONCE_FORWARD从当前值播到目标位移、缩放
PLAYBACK_ONCE_BACKWARD从目标反向播回收起
PLAYBACK_ONCE_PINGPONG正向再反向一次抖动
PLAYBACK_LOOP_FORWARD循环正向持续脉动
PLAYBACK_LOOP_PINGPONG来回循环呼吸效果

可动画的属性路径:

go.animate 的 property 支持「点路径」访问子分量:
  "position"        整体位置
  "position.y"      只动 y(上下浮动)
  "scale.x"         只动横向缩放
  "rotation.z"      2D 旋转(弧度)
  "tint.w"          透明度(alpha)
  "#sprite" 开头的组件属性同理:#sprite.tint.x
-- 只动透明度:淡入
go.animate("#sprite", "tint.w", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
           1.0, go.Easing.OUT_QUAD, 0.25)

心智:go.animate 七参数——对象、属性、模式、目标、曲线、时长、(延迟与回调);属性支持点路径,能只动一个分量。

3. 缓动曲线全表与选型

go.Easing 命名规律:[IN|OUT|IN_OUT]_[曲线名]。

IN_*     开头慢、结尾快(像加速)    —— 适合「进入」「下落」
OUT_*    开头快、结尾慢(像减速)    —— 适合「到达」「停止」
IN_OUT_* 两头慢、中间快             —— 适合「穿过」「过渡」

曲线名:
  LINEAR   匀速
  QUAD     二次(温和)
  CUBIC    三次(更明显)
  QUART    四次
  QUINT    五次
  SINE     正弦(最柔和,接近自然)
  EXPO     指数(极端的快慢对比)
  CIRC     圆弧(接近物理减速)
  BACK     回拉(越过再退回)
  BOUNCE   弹跳(落地反弹)
  ELASTIC  弹簧(高频震荡)

选型速查:

场景推荐曲线理由
UI 面板滑入OUT_QUAD / OUT_CUBIC快进慢停,干脆
UI 面板收起IN_QUAD慢起快走,不拖沓
按钮回弹OUT_BACK越过目标再回,Q 弹
掉落着地IN_QUAD + 落地 OUT_BOUNCE加速下落 + 反弹
弹性提示(新消息)OUT_ELASTIC高频弹簧,抓注意力
相机跟随SINE / OUT_SINE最柔和,不易晕
进度条填充LINEAR匀速才符合「进度」语义
数值滚动(金币计数)OUT_EXPO先快后慢,有「刹车感」
-- 三种常见配方的对照
-- 1) 面板滑入:干脆
go.animate("#panel", "position.x", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
           0, go.Easing.OUT_CUBIC, 0.28)

-- 2) 按钮回弹:Q 弹
go.animate("#btn", "scale", go.PlaybackMode.PLAYBACK_ONCE_PINGPONG,
           vmath.vector3(1.1, 1.1, 1), go.Easing.OUT_BACK, 0.18)

-- 3) 新消息提示:弹簧
go.animate("#toast", "position.y", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
           0, go.Easing.OUT_ELASTIC, 0.6)

心智:IN 是加速(进/落)、OUT 是减速(到/停)、IN_OUT 是过渡;BACK 会越界、BOUNCE 会弹、ELASTIC 会震——选曲线先想「这个动作在物理上该怎么停」。

4. 自定义缓动:函数与曲线拟合

go.animate 的 easing 参数也接受一个自定义函数,签名 f(t) -> value,t 为已过时长(秒):

-- 自定义:先冲过头再回到目标的「过冲」曲线
local function overshoot(t, duration)
    local p = t / duration
    local c = 1.70158 * 1.2
    p = p - 1
    return p * p * ((c + 1) * p + c) + 1
end

go.animate(".", "position.x", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
           400, overshoot, 0.5)

自定义函数的两种语义(Defold 都支持,按参数个数区分):

f(t)                  → 直接返回插值后的值(最灵活)
f(t, duration)        → 返回归一化进度 0~1,引擎再做 from + (to-from)*p

常用自定义配方:

-- 1) 分段曲线:前 70% 用 OUT_QUAD,后 30% 用 BACK 收尾
local function segmented(t, duration)
    local p = t / duration
    if p < 0.7 then
        return 1 - (1 - p / 0.7) ^ 2 * 0.9
    else
        local q = (p - 0.7) / 0.3
        return 0.9 + 0.1 * (q * q * (2.70158 * q - 1.70158) + 1)
    end
end

-- 2) 阻尼正弦:衰减震动(掉血抖动)
local function damped_shake(t, duration)
    local p = t / duration
    return math.sin(p * math.pi * 6) * (1 - p)     -- 振幅随时间衰减
end

曲线拟合的实用做法:拿美术给的曲线图(或参考其他引擎的 cubic-bezier(x1,y1,x2,y2)),用牛顿迭代反解贝塞尔:

-- 三次贝塞尔 y 值(x 已按时间归一)
local function cubic_bezier_y(t, x1, y1, x2, y2)
    local function bez(a, b, u) return 3*a*u*(1-u)^2 + 3*b*u^2*(1-u) + u^3 end
    return bez(y1, y2, t)
end
拟合流程:
  1. 取参考曲线控制点(如 CSS ease-in-out = cubic-bezier(0.42,0,0.58,1))
  2. 对每个采样 t 求 y
  3. 包成 f(t, duration) 交给 go.animate

心智:内置曲线不够用时,传一个函数进去——f(t, duration) 返回归一化进度最通用,贝塞尔反解让你能复刻任何参考曲线。

5. 多属性协同与链式编排

多个属性同时动:分别调用 go.animate,它们并行推进,互不阻塞:

-- 同时位移 + 缩放 + 淡出(三路并行)
local url = msg.url("#card")
go.animate(url, "position.y", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
           -300, go.Easing.OUT_CUBIC, 0.4)
go.animate(url, "scale", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
           vmath.vector3(0.8, 0.8, 1), go.Easing.OUT_QUAD, 0.4)
go.animate(url, "tint.w", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
           0, go.Easing.IN_QUAD, 0.4)

串行(链式)编排:用完成回调把动画串成序列:

-- 链式:放大 → 停顿 → 缩小
local function shrink(self)
    go.animate(".", "scale", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
               vmath.vector3(1, 1, 1), go.Easing.IN_OUT_BACK, 0.2)
end

local function grow(self)
    go.animate(".", "scale", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
               vmath.vector3(1.4, 1.4, 1), go.Easing.OUT_BACK, 0.2,
               0, function(self, complete)
                   if complete then shrink(self) end
               end)
end

用延迟参数做「错峰」(Stagger)——列表项逐个入场是常见需求:

-- 每个卡片延迟 0.05s 依次滑入
for i, card_url in ipairs(self.card_urls) do
    go.animate(card_url, "position.x", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
               0, go.Easing.OUT_CUBIC, 0.35, i * 0.05)   -- delay = i * 0.05
end

编排三原则:

1. 并行:分别 go.animate,天然同时进行
2. 串行:用 complete_function 里调下一个
3. 错峰:用 delay 参数,别写定时器轮询

心智:并行靠多次调用、串行靠完成回调、错峰靠 delay 参数——编排不需要额外的调度器,go.animate 自带这三件套。

6. 时间控制:暂停、变速与取消

取消正在进行的动画:

go.cancel_animations(".", "position")                 -- 取消某属性
go.cancel_animations(".", go.PROP_ANIM_POSITION)      -- 等价的枚举写法
go.cancel_animations(".")                             -- 取消该对象全部动画
注意:cancel 后属性停在「当前值」,不会回到起点
      ——这正是「打断动画」想要的效果(从当前位置继续)

暂停 / 恢复的工程做法:go.animate 没有内建暂停。要让整组动画随游戏暂停,用「时间缩放」思路:

-- 游戏暂停时,把动画时长按比例「冻结」是不可行的(go.animate 无法中途改速)
-- 正解:暂停时 cancel 并记录进度,恢复时从记录值续播
function pause_tween(self, url, prop)
    self.saved[prop] = go.get(url, prop)      -- 记住当前值
    go.cancel_animations(url, prop)
end

function resume_tween(self, url, prop, target, easing, dur)
    go.animate(url, prop, go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
               target, easing, dur)            -- 从当前值续播到原目标
end

变速(慢动作):go.animate 的时长是固定的,慢动作应作用在游戏时间上而非单条动画——把 dt 乘一个系数,只在 update 驱动的动画上生效:

function update(self, dt)
    local scaled = dt * self.time_scale        -- 0.3 = 慢动作
    self.t = self.t + scaled
    go.set(".", "position", self:curve_at(self.t))
end

用 go.get 读动画中的实时值:

-- 动画进行中读取当前缩放(用于同步其他表现)
local cur = go.get("#card", "scale")
local alpha = go.get("#card", "tint.w")

心智:cancel 停在当前值(适合打断)、没有内建暂停(靠记录值续播)、慢动作要作用在 dt 上而非单条动画——控制粒度决定了动效能不能「被打断」。

7. 实战:UI 动效与角色运动

UI 动效配方(弹窗出现):

local function show_dialog(self)
    local url = msg.url("#dialog")
    go.set(url, "scale", vmath.vector3(0.7, 0.7, 1))     -- 起始小
    go.set(url, "tint.w", 0)                              -- 起始透明
    go.animate(url, "scale", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
               vmath.vector3(1, 1, 1), go.Easing.OUT_BACK, 0.28)
    go.animate(url, "tint.w", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
               1, go.Easing.OUT_QUAD, 0.18)
end

数值滚动(金币计数)——用 go.animate 驱动一个「虚拟属性」再在回调里刷新文本:

-- 把 0~1 的进度映射到 0~目标值
go.animate("#counter", "position.x", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
           1, go.Easing.OUT_EXPO, 0.8,
           0, function(self, complete)
               local p = go.get("#counter", "position.x")   -- 借用一个不渲染的属性
               local shown = math.floor(self.target_gold * p)
               label.set_text("#gold_label", tostring(shown))
           end)

更干净的做法是用一个不可见对象承载「进度属性」,避免借用 position 造成视觉抖动。

角色运动配方(跳跃弧线):

-- 跳跃 = 水平匀速 + 垂直缓动(两个属性用不同曲线)
go.animate(".", "position.x", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
           self.start_x + 160, go.Easing.LINEAR, 0.6)
go.animate(".", "position.y", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
           self.start_y + 90, go.Easing.OUT_QUAD, 0.3,
           0, function(self, complete)
               if complete then
                   -- 上升结束 → 下落
                   go.animate(".", "position.y", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
                              self.start_y, go.Easing.IN_QUAD, 0.3)
               end
           end)

受击抖动(Shake):

-- 用 PINGPONG + 短时长做「来回抖」
for i = 1, 3 do
    go.animate(".", "position.x", go.PlaybackMode.PLAYBACK_ONCE_PINGPONG,
               self.base_x + 8, go.Easing.LINEAR, 0.05, (i - 1) * 0.1)
end

动效性能纪律:

- 只动「位置/缩放/旋转/透明度」——这几类不触发重建
- 不要用 go.animate 动「尺寸(size)」这类需要重算图集的属性
- 同屏同时进行的动画数量控制在几十条以内,超了就改程序动画
- 动效结束即 cancel 清理,避免残留动画继续写属性

心智:UI 动效靠「起始值 go.set + 目标值 go.animate」;角色运动靠「不同属性配不同曲线 + 完成回调接力」——把缓动曲线当参数调,手感是调出来的不是写出来的。

速查表

需求做法
属性补间go.animate(url, prop, mode, to, easing, dur)
只动一个分量属性路径 "position.y" / "tint.w"
加速(进/落)IN_QUAD / IN_CUBIC
减速(到/停)OUT_QUAD / OUT_CUBIC
回弹 / 弹簧OUT_BACK / OUT_ELASTIC
弹跳着地OUT_BOUNCE
柔和跟随SINE
自定义曲线传 function(t, duration)
并行多属性多次调用 go.animate
串行序列完成回调里调下一个
错峰入场第 7 个参数 delay
取消动画go.cancel_animations(url, prop)
慢动作缩放 dt,作用在程序动画上

一句话记忆:补间 = from + (to - from) × ease(t/duration)——go.animate 七参数管声明,IN 管加速、OUT 管减速、BACK/BOUNCE/ELASTIC 管越界手感;内置不够就传自定义函数,并行多次调用、串行走完成回调、错峰用 delay;cancel 停在当前值所以能被打断,慢动作只作用在 dt 上——手感是曲线调出来的。

小结

补间动画的价值在于把「怎么动」从代码里抽出来变成数据:属性、目标、时长、曲线四个参数一填,动效就成型了。工程上抓住四点即可:一是选曲线先想物理——加速用 IN、减速用 OUT、要「Q 弹」才上 BACK/ELASTIC;二是编排只用三种原语(并行多次调用、串行完成回调、错峰 delay 参数),别自己造调度器;三是自定义曲线永远走 f(t, duration) 归一化形态,这样换 from/to 不用改函数;四是性能上只动位置/缩放/旋转/透明度,尺寸类属性交给图集重建是不划算的。把这四点做扎实,Defold 的动效就能覆盖从 UI 到角色运动的绝大多数需求。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「defold」更多文章

  1. Defold 行为树与游戏 AI 决策
  2. Defold 材质与着色器语言详解
  3. Defold HTML5 导出与 Web 性能优化