UI动效背后的数学原理:从时间轴t到阻尼振动

UI动效背后的数学原理:从时间轴t到阻尼振动 前两周帮同事review一个弹窗动画他把新产品引导里的“弹性进入”换成了纯线性理由是“弹一下太吵而且我调不好参数”。我问他有没有调过阻尼比他愣了一下。这种情形在做UI动效的时候太常见了大家每天都在跟 duration、scale、opacity、timing function 打交道可真要解释“为什么减速曲线更自然”“为什么弹簧会过冲”“为什么透明视频在部分手机上出现黑边”大多数人只能“调一调、试一试”。把这些问题拍扁了看底层都是一些不算高深的数学一个从 0 到 1 的归一化时间 t一条三次贝塞尔曲线一个阻尼振动方程一组对顺序敏感的变换矩阵甚至是一条像素混合公式。这篇就顺着 UI 动效里最常见的几个场景把背后的数学原理逐个拆开。适合前端、客户端开发也适合想深入理解动效逻辑的 UI/UX 设计师——看完至少能明白每次“随手一调”到底在调什么以及为什么这样调是有效的。1. 时间轴上的那个t动效世界的基本货币1.1 从一条最朴素的插值公式开始所有时间驱动型动效不管做出来多复杂本质都在做同一件事让某个数值从起点状态在指定时长内逐步移动到终点状态。这个“逐步”必须被一个时间参数驱动通常记作 t并且被归一化到 0 和 1 之间// startTime 是动画开始时刻now 是当前时刻 // duration 是动画总时长 let t (now - startTime) / duration; // 防止浏览器掉帧导致 t 超过 1 t Math.min(Math.max(t, 0), 1);拿到 t 之后把任意可动属性做线性映射function lerp(from, to, t) { return from (to - from) * t; }位置可以用 lerp透明度可以用 lerp颜色可以用 lerp——颜色的每个通道 R、G、B、A 各自插值一次再拼回去。一个简单的淡入淡出、位移动画核心代码就这么短。之所以强调 t 是“基本货币”是因为后面所有高级动效——贝塞尔缓动、弹簧、圆周运动、路径动画——都建立在“对 t 做二次加工”这件事上。很多人写动画时会忽略 clamp这是一个常见的隐患。在一台性能忽高忽低的安卓设备上帧率抖动导致 now 远远落后于预期如果 t 没被截断在 1 以内动画会直接冲到“目标值之外十几帧”的位置再跳回来表现就是界面闪一下。动画结束时也必须手动回调 onDone不能依赖“t 大于 1 就什么都不做”。1.2 直接用线性插值为什么总觉得“像机器”线性 lerp 意味着属性在整段时间里匀速变化每帧前进一样的步幅。可现实世界几乎没有匀速运动一个物体从静止到运动必然要先加速从运动到停止必然要减速。一个 toast 从屏幕底部滑上来如果全程匀速视觉上就像仓库里的液压杆冷冰冰的。所以动画领域引入了 easing 的概念不是直接用 t 去插值而是先把 t 映射成另一个值const easedT easing(t); onUpdate(lerp(from, to, easedT));这样位移曲线的“密度”就可以随时间变化。最常用的 easeOut 让 t 越接近 1easedT 增长越慢效果就是开头快速冲出去、结尾慢慢停稳。人眼对这种模式非常熟悉因为它符合有摩擦的真实世界。反过来easeIn 适合元素“被推出去”的场景比如底部抽屉从屏幕外被快速推进来先慢后快更有“被外力作用”的感觉。1.3 duration 的选择本身也是一个数学问题duration 直接决定每帧 t 的步长。60Hz 刷新率下每帧间隔约 16.7ms如果动画时长是 200ms每帧 t 约跳 0.083人眼能感知到明显的分段如果动画时长是 800ms每帧只跳 0.021动画细腻但对渲染稳定性更敏感——任何一帧卡顿都会因为“期望位移与实际位移的差值”被放大而被感知成抖动。所以我的经验是小于 100ms 的动画不适合承载信息变化只适合做“按钮按下”这种即时反馈100~300ms 适合小元素位移和透明度变化400~800ms 适合页面级转场再长就要非常克制否则用户会认为系统变慢了。现在很多设计系统里把这个写成了规范比如 Material Design 的 duration 档位本质上就是拿数学上 t 的步长和用户感知做了权衡。2. 缓动函数不是玄学多项式、贝塞尔和那点过冲2.1 easeOutQuad 这类名字里藏着物理含义Robert Penner 那套经典缓动函数到今天依然统治着绝大多数代码库。它们表面上是一堆公式实际里每一项都有运动学含义。比如你可能背过 easeInQuad 是t * teaseOutQuad 是1 - (1 - t) * (1 - t)可你想过为什么t²会有“加速感”吗把position t²对时间求导速度是2t即速度随时间线性增加再求导加速度是个常数 2。换句话说easeInQuad 模拟的是匀加速直线运动。easeOutQuad 则相反开始速度大随后均匀减速模拟的是“带摩擦滑到目标点停下”的状态。速度和加速度的概念在调 UI 动效时很少被直接提起但每一个缓动曲线其实都隐式定义了速度和加速度曲线。这解释了为什么有些动画“位置看着没问题但总觉得一顿一顿”——大概率是速度曲线有突变加速度在某帧突然变为无穷大。常用的缓动函数和感觉可以对照看函数公式体感lineart匀速机器感适合旋转类循环动画easeInQuadt²从静止匀加速启动适合被弹出的抽屉easeOutQuad1-(1-t)²快速启动再减速停稳引导类动画首选easeInOutCubict0.5 ? 4t³ : 1-(-2t2)³/2柔和的缓入缓出页面转场常用实际项目里我建议不要背公式而是记住三条规律幂次越高缓入缓出的“曲率”越明显easeOut 是 UI 里用得最多的因为绝大多数元素是“进入”或“回复”而不是“被推走”easeInOut 适合两个静止状态之间的过渡比如 Tab 切换后的卡片重排。2.2 设计师和工程师共用的一套语言三次贝塞尔设计师丢给你一个“弹一下”的动效不会给你公式而是给一段cubic-bezier(0.34, 1.56, 0.64, 1)。这条曲线背后的数学是三次贝塞尔B(t) (1-t)³P0 3(1-t)²tP1 3(1-t)t²P2 t³P3在 CSS 里P0 固定为 (0,0)P3 固定为 (1,1)真正可调的是中间两个控制点 P1 和 P2。横轴代表时间纵轴代表进度。为了动画在时间上不倒退CSS 规范限制控制点的 x 必须在 0 到 1 之间但 y 不受限可以大于 1也可以小于 0。当 y 大于 1意味着动画进度先冲过目标值再回落——这就是 over-shoot也就是“弹一下”的来源。所以在 css 里cubic-bezier(0.34, 1.56, 0.64, 1)控制点 P1 的 y 为 1.56进度在中间某个时刻超过 100% 大约 10~15%随后回落到 100%。这个“超过一点点再回来”的幅度就是卡片按下去之后反弹效果的数学本质。几个常见的标准曲线名称等价 cubic-bezier说明easecubic-bezier(0.25, 0.1, 0.25, 1.0)CSS 默认ease-outcubic-bezier(0, 0, 0.58, 1)快速进入平稳停止ease-in-outcubic-bezier(0.42, 0, 0.58, 1)平滑过渡强调弹跳cubic-bezier(0.34, 1.56, 0.64, 1)小幅度过冲工程师拿到设计稿里的曲线不要只看“长什么样”要用可视化工具体验速度变化横轴时间是均匀的曲线越陡的位置单位时间进度变化越大也就是速度越快。同样的曲线如果中间有一段几乎平坦那一带就是“缓慢蠕动”放在列表里会显得拖沓。2.3 back、elastic、bounce别让橡皮筋捅用户的眼睛除了贝塞尔还有三类特殊缓动back 系列产生一次明显的过头回落elastic 是指数衰减乘以正弦波模拟弹簧阻尼振荡bounce 是分段抛物线模拟球体落地反弹。三者都很有表现力但也很容易滥用。以 penner 的 easeOutBack 为例const c1 1.70158; const c3 c1 1; return 1 c3 * Math.pow(t - 1, 3) c1 * Math.pow(t - 1, 2);这个函数在 t0 时为 0t1 时为 1但中间会超过 1从而在视觉上造成“冲过头再收回来”的效果。c1 越大冲过头越多。默认的 1.70158 大概会产生 10% 的过冲适合点赞、收藏这类需要强调的小反馈。elastic 和 bounce 则更适合图标强调、或者游戏化场景。正式界面上建议少用完整 elastic因为多段大幅振荡会直接拉走用户注意力而且如果是在一个高频出现的元素上比如列表刷新图标反复弹跳三四个周期非常容易晕。我现在的处理原则是可打断的、高频出现的交互用小过冲甚至无过冲需要短暂吸引注意的一次性反馈才允许 elastic/bounce。安全性上任何情况下都不建议让动画产生超过 1.5 倍的缩放容易造成亲密感缺失甚至引发部分敏感用户的眩晕。3. 物理型动效弹簧背后的阻尼振动方程3.1 easing 曲线做不出“可打断”的弹簧感为什么说 duration 贝塞尔曲线模拟弹簧是“假弹簧”因为真弹簧的价值不在最终效果而在过程中的可中断性。想象一个卡片被手指拖动松手后回弹到中心这中间系统必须感知“手指松开那一刻的位置和速度”然后把它们当作初始条件继续积分。duration-based 缓动做不到这一点因为它的 t 完全由开始时间和总时长决定你没法中途灌入一个速度强行在松开时重新播放曲线就会出现位置跳变也就是你看到的“闪一下”。真正的物理弹簧只认三个量目标位置、当前速度、当前加速度。任意时刻被打断它都能从当前状态继续计算。这就是为什么 iOS 的 SwiftUI、Android 的 SpringAnimation、以及 Framer Motion 里的 spring() 如今越来越流行——不是因为它曲线更“自然”而是因为它能在任意事件点无缝接管。3.2 阻尼比 ζ决定过冲、临界和慢吞吞的关键一维弹簧的动力学方程是典型的二阶常微分方程。如果把目标位置当作原点用 x 表示当前相对目标的偏移则m·x c·x k·x 0其中 m 是质量c 是阻尼系数k 是刚度。为了工程上容易讨论通常把它改写为x 2ζω0·x ω0²·x 0这里的 ω0 √(k/m)称为无阻尼自然角频率决定“多快弹回”ζ c / (2√(mk)) 是无量纲的阻尼比决定“怎么弹回”。方程的解按 ζ 分为三种形态ζ 范围行为UI 体感0 ζ 1欠阻尼振荡过冲后收敛弹性进入、回弹卡片ζ 1临界阻尼最快无过冲收敛列表项移入干净利落ζ 1过阻尼慢吞吞爬回目标笨重基本不用欠阻尼解的形式是 x(t) e^(-ζω0t)·(Acos(ωdt) Bsin(ωdt))其中 ωd ω0√(1-ζ²)。指数项 e^(-ζω0t) 决定衰减速度后面的正弦项决定振荡频率。这段话你可能已经还给大学物理了但那些调参工具里的 dampingRatio 参数本质就是在调 ζ。值越小晃得越多值越大越早停住。0.8 会产生轻微过冲1.0 几乎不过冲0.4 以下就开始明显“橡皮筋”了。3.3 把方程变成可运行的代码半隐式欧拉实际写代码时不需要解解析解直接用数值积分即可。最常用的是一阶半隐式欧拉——先更新速度再更新位置let velocity 0; let position 1; // 初始偏移量比如 1 表示距离目标 100% function springStep(dt, target, stiffness, damping) { // 加速度刚度拉的回来阻尼推得回去 const acceleration -stiffness * (position - target) - damping * velocity; velocity acceleration * dt; position velocity * dt; return position; }每一步先算加速度用加速度更新速度再用新速度更新位置。这个顺序比“先更新位置再用旧速度”的显式欧拉稳定得多。具体来说显式欧拉在步长稍大时会让系统不断累积能量弹簧越弹越远半隐式欧拉则不会那么夸张。想要进一步稳定可以把一个大 dt比如一帧 16.7ms拆成 2 个 8.35ms 子步进迭代两次再输出效果会好很多。3.4 数值稳定性dt 一抖弹簧就抖物理动效调试中最隐蔽的坑是动画在某些手机上抖、在开发者工具里好得很原因是设备帧率不稳定导致 dt 一高一低。帧率 60 时 dt 约 16.7ms掉到 30 时 dt 变 33ms两倍的步长直接让积分结果偏差变大表现就是弹簧在低帧设备上“像没阻尼一样乱颤”。解决思路有三个方向第一是 clamp dt超过 50ms 就按 50ms 算并且只更新一帧而不是把跳帧的累计时间全部塞进下一步第二是子步进把大 dt 切成若干固定小步进用 1/120s 作为基本步长一帧 16.7ms 就走两步第三是限制初始速度绝对值避免卡片被“用力甩出”后从屏幕外飙回来时因为速度过大而过冲成神经系统。实际项目中我会再加一个最大位移 clamp弹簧动效允许短暂越过目标 10%~20%但禁止越过 50% 以上。数学上这会让收敛曲线不再严格符合原方程但每次都是可行的简化——UI 是设计的产品不是物理仿真报告你需要的只是“像弹簧”而不是“真的是弹簧”。4. 圆与波三角函数出场的场景比想象中多4.1 一个公式描述所有重复运动UI 里凡是“循环往复”的动效几乎都可以用同一个模型描述y(t) A·sin(2πf·t φ) y0A 是振幅决定摆动或缩放的幅度f 是频率决定一个周期多快φ 是相位决定从哪个起点开始y0 是基准值决定摆动的中心位置。呼吸灯、加载小点、脉冲按钮、提示气泡全都是这个公式的变体。举个例子一个按钮的“呼吸”效果可以写成opacity.value 0.5 0.5 * Math.sin(2 * Math.PI * 0.8 * elapsedTime);0.5 是基准透明度0.5 是振幅频率 0.8Hz 表示约 1.25 秒呼吸一个完整周期。这样写出来的动画只有一行代码但能保证无限循环、平滑可预测而不是随便硬编码一堆关键帧。4.2 相位 offset做“错峰”动画最可靠的数学手段设计师常要求“四个点依次弹跳”不少人的第一反应是给每个点写一个独立的 setTimeout每个延迟 100ms。但 setTimeout 回调受主线程 load 影响三个定时器之间的真实间隔会慢慢漂移动画执行几分钟后错峰就乱了。正确的做法是所有点共用同一个全局时钟用相位差区分function dotOffset(i) { // 四个点均匀分布在 2π 相位上 const phase (2 * Math.PI * i) / 4; return 0.6 0.6 * Math.sin(2 * Math.PI * 1.5 * t - phase); }加载指示器里四个点依次起伏本质是同一个正弦波在四个不同相位上被采样。相位差 2π/4、2π/2、3π/2视觉上就成了“波”在点与点之间推进。这个方法比设定不同 delay 更可靠因为不管掉不掉帧点与点之间的相对关系永远由同一时钟决定不会累积误差。4.3 路径动画从直线插值升级到参数方程很多高层次动效不再满足于“A 点平移到 B 点”而是让元素沿弧线、圆环这样运动比如一个按钮展开成弧形菜单、一个加载进度绕圆环填充。这些场景的底层也是三角函数。圆上任意一点可以写成极坐标x cx R·cosθy cy R·sinθ。只要让 θ 随归一化时间变化就能得到圆周运动让 θ 经过缓动函数处理就能得到“加速旋转后暂停”的效果让不同按钮的 θ 起始角错开就能排出一圈扇形菜单。路径类动效里另有一个高频题是“沿路径画线”SVG 线条被绘制出来。实现时计算整条路径的周长 L然后用 stroke-dasharray 和 stroke-dashoffset 控制显示长度progress easedT; // 0 到 1 ctx.strokeDasharray ${L * progress} ${L}; ctx.strokeDashoffset L * (1 - progress);这里的 progress 同样可以先过一遍缓动、甚至弹簧让“画线”也有手感。路径本身如果是贝塞尔曲线则本质上是在多个控制点坐标之间做插值原理和前面讲过的贝塞尔完全一致只是参数从“时间 vs 进度”变成了“路径坐标”。5. 像素上的数学Alpha 混合与透明视频的兼容性5.1 为什么 Android 礼物动效要把 RGB 和 Alpha 拆成两路视频UI 动效不只是“时间轴上的 t”。在安卓直播场景里礼物动效常要求高性能播放带透明通道的动画比如一个超人从屏幕右侧飞过最好还是 30fps、带特效的复杂视频。普通视频编码标准H.264/HEVC没有透明通道的概念所以腾讯开源的 VAP 这类方案就把透明信息拆出来单独存成一个灰度视频一路是 RGB 彩色画面另一路是 Alpha 通道。播放时 GPU 拿到两路帧逐像素做 alpha 混合。这套方案的数学基础只有一个像素叠加公式。5.2 src-over 的混合公式以及 premultiplied 的坑把一张半透明贴纸叠加到画面上最常见的叠加方式是 src-over即目标色 源色 × 源Alpha 背景色 × (1 - 源Alpha)outColor srcColor · srcAlpha dstColor · (1 - srcAlpha) outAlpha srcAlpha dstAlpha · (1 - srcAlpha)这条公式本身不难难在设备实现上。OpenGL 里做 alpha 混合时能否直接套这个公式取决于纹理里的 RGB 分量是否已经“预乘过 Alpha”。如果纹理是 straight alpha即每个像素的 RGB 还没乘 alpha那么混合函数应该设置成// straight alpha源颜色在片元里是未预乘的混合单元需要帮你乘一次 glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);如果纹理已经预乘RGB 等于原色乘以 alpha公式可以省掉一次乘法// premultiplied alphaRGB 已经乘过 Alpha混合单元直接加就行 glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA);很多 VAP 兼容性问题的根源就是这里。在 A 手机上视频解码输出的是 straight alpha 纹理在 B 手机上引擎预处理成了 premultiplied如果你用同一份 glBlendFunc 去处理就会看到边缘出现一圈黑边或者白边。黑边的原因是 straight alpha 的 RGB 没有乘 alpha导致混合后边缘多出了半透明的暗色白边则常见于 premultiplied 纹理被当作 straight alpha 处理边缘被过度抬亮。5.3 选型不只看热闹还要看像素成本和兼容策略用 RGBAlpha 双视频流做礼物动效好处是硬解码效率高坏处是双流同步和纹理格式五花八门的设备兼容问题。出现黑边、掉帧、闪帧时排查顺序基本是这样现象可能原因解决方向边缘发黑混合函数设置错误 / 未预乘检查纹理是否预乘切换 glBlendFunc边缘发白预乘纹理被当直通纹理统一预处理流程掉帧解码或像素拷贝超过了预算切硬解降分辨率低端机走 CPU 合成音画/双流不同步时间戳对齐出问题用同一时钟驱动两台解码器从工程角度看透明视频方案并不是无脑最优解。礼物动效时长通常 3~6 秒帧率 30一个 720×1280 的双流视频每帧要处理两张位图内存开销不小。复杂方案在高端机上流畅不代表入门机上也能抗住。一个稳妥的降级是检测到设备不支持硬解或 GPU 混合时把动画预渲染成序列帧或者 Lottie屏幕空间尽量控制在 720p 以内并降低帧率到 20fps——数学上像素数量和混合次数是线性关系这是最直接的优化杠杆。6. 变换矩阵与一帧16.7ms流畅度的底层账本6.1 你写的每个 transform都是矩阵乘法前端写 CSS 做动效随手就是transform: translate(20px, 40px) rotate(45deg) scale(1.2)。形式上它是三个函数的组合底层其实是三个 3×3 矩阵依次相乘平移[[1,0,tx],[0,1,ty],[0,0,1]]旋转[[cosθ, -sinθ, 0],[sinθ, cosθ, 0],[0,0,1]]缩放[[sx,0,0],[0,sy,0],[0,0,1]]二维齐次坐标把 (x, y) 扩成 (x, y, 1)一次变换就是一次矩阵乘法。多个变换串联就是多个矩阵相乘。这里有个新手几乎都踩过的坑矩阵乘法不满足交换律所以transform: translate(100px,0) scale(2)和transform: scale(2) translate(100px,0)的最终位置不一样。前者先移动再放大位移也会被放大后者先放大再移动位移保持不变。从数学上理解这件事之后就不用靠试错来猜了。6.2 透视不是魔法是除法3D 变换里的 perspective 看着挺玄其实就是把三维点投影到屏幕时做一个除法。CSS 里设置perspective: 800px意思是视距 800px。一个三维点 (x, y, z) 投影后的屏幕坐标大约为screenX x / (1 - z / 800) screenY y / (1 - z / 800)分母越小缩放倍数越大。当元素某一部分的 z 接近甚至超过 800 时画面会无限放大或者直接“穿模”。这就是为什么把 perspective 调得太小比如 200旋转卡片时会看到撕裂感——数学上就是很多像素除法的分母接近 0结果在数值上爆发。做 3D 卡片翻转时perspective 建议从视距 600~1000px 起步再根据效果向下试。6.3 动效成本预算16.7ms 里没有多余的懒散做高性能 UI 动效心里要有一本 16.7ms 的账。60Hz 每帧预算大约是环节预算参考说明JavaScript4ms所有动画计算尽量少style / layout3ms改几何属性容易超支paint5ms大片绘制占大头composite2msGPU 合成系统 vsync 等剩余喘息的余地为什么强调改用 transform 而不是 left/top因为改动 left/top 会触发 style、layout、paint 三个阶段尤其是 layout 溢出后整棵渲染树都要重排而 transform 的多数情况只触发 compositeGPU 直接拿矩阵乘一下像素把原有纹理摆到新位置成本低一个量级。一次按钮位移前者可能花掉 8ms 做布局后者可能连 1ms 都用不到差距就是这样累积成卡顿的。另一个容易过度优化出反效果的是will-change: transform。它确实会为元素创建独立合成层让变换不碰 render tree但合成层的本质是一张纹理每个都占显存。一个列表里 50 个元素全部加 will-change等于 50 张纹理绑定在 GPU 上低端机直接内存告急。正确做法是只在动画开始前加上动画结束后的下一帧移除或者干脆交给浏览器的自动优化除非你能证明它确实需要。把“感觉”翻译成数字之后动效就好聊了这两年我做动效养成的最大习惯是永远盯着速度曲线看而不是只看位移曲线。位移曲线圆润不代表速度曲线平滑——有时候中间有一个速度尖峰肉眼看就是“卡了一帧”。调任何动画先用工具把曲线画出来cubic-bezier.com、Framer Motion 的可视化页面、或者自己在 desmos 里敲一个方程都行设计师说“要弹一点”你就把过冲参数从 0 提到 0.1说“硬一点”就把阻尼比从 0.8 调到 0.95。这些对话一旦变成了曲线上的数字评审和交付都会省很多力气。真机验证也别只看裸眼我习惯用手机慢动作录屏回放240fps 的视频会把真实帧序列拉长四倍掉帧和跳变一清二楚。搞懂 UI 动效背后的数学原理不是写几个看起来很专业的公式而是让你在做每一次“随手调一下”的时候都知道自己在调什么。