曲线轨迹动画原理

一.动画函数

动画,是位移关于时间的函数:s = f(t)

自变量是t,因变量是s,物体的位移随着时间变化,看起来就是动画,例如:

// 已知
var property = 'marginLeft';
var s0 = 100;   // 起点
var s1 = 200;   // 终点
var duration = 1000;

// 由题意得
var S = s1 - s0;    // 总位移
var T = duration;   // 总时间

// 求任意时刻t对应的位移
var t0 = +new Date();
var tick, interval = 1000 / 60;
setTimeout(tick = function() {
    var t = +new Date() - t0;
    // 完成度
    var p = Math.min(t / T, 1);
    // t时刻相对起点的位移s
    var s = S * p;
    document.body.style[property] = s0 + s + 'px';

    if (p !== 1) setTimeout(tick, interval);
}, interval);

marginLeft100px200px均匀改变,body先向右跳100px,然后在1秒内匀速向右移动100px

要实现这样的动画,面临的唯一问题是:已知总位移S和总时间T,求任意时刻t相对起点的位移s

我们实现了匀速直线运动,看起来好像没有用到s = vt,其实是有的:

s = v * t
  = (S / T) * t
  = S * (t / T)
  = S * p

因为动画函数是s = f(t),里面没有v,需要把v换成已知量,因为完成度p = t / T,所以动画也是位移关于完成度的函数

二.匀变速运动

同样的道理,换掉匀变速运动位移公式中的va,得到位移s关于时间t的函数

匀加速

位移公式:

// v0 = 0时,只有一个未知量a
s = 1/2at^2

已知总时间T、总位移S、完成度p = t / T,求任意时刻t相对起点的位移s

// 终点处有
S = 1/2 * a * T^2
// 得
a = 2S / T^2
// 任意时刻
s = 1/2 * a * t^2
  = 1/2 * (2S / T^2) * t^2
  = 1/2 * 2S * (t^2 / T^2)
  = S * p^2

匀减速

位移公式:

// 含有2个未知量v0和a
s = v0t - 1/2at^2

已知总时间T、总位移S、完成度p = t / T,求任意时刻t相对起点的位移s

// 1.逆向匀加速求v0
// 起点处有
S = 1/2 * a * T^2
// 得
a = 2S / T^2
v0 = aT = 2S / T^2 * T = 2S / T
// 2.任意时刻
s = v0 * t - 1/2 * a * t^2
  = (2S / T) * t - 1/2 * (2S / T^2) * t^2
  = 2S * (t / T) - S * (t^2 / T^2)
  = 2S * p - S * p^2
  = S * p * (2 - p)

三.曲线运动

简单的曲线运动可以分解成直线运动,例如正弦函数y = sinx可以分解为:

// x轴匀速直线
x = S * p = 2PI * p
// y轴sinx
y = sinx = sin(2PI * p)

平抛运动可以分解为:

// x轴匀速直线
x = S * p = X * p
// y轴匀加速
y = S * p^2 = Y * p^2

抛物线的左半边可以看作向左平抛的逆向运动:

// x轴匀速直线
x = S * p = X * p
// y轴匀减速
y = S * p * (2 - p) = Y * p * (2 - p)

圆周运动稍微特殊一点,代数方程(x - a)^2 + (y - b)^2 = r^2,计算x, y存在取正负号的问题(比较麻烦,但可行),所以考虑用参数方程:

// 对于圆上任意一点,有
sinθ = y / r, cosθ = x / r
// 得参数方程
x = a + r * cosθ, y = b + r * sinθ

// 圆心为(0, 0)时
x = r * cosθ = r * cos(θ * p), y = r * sinθ = r * sin(θ * p)

角度[0, 2PI]均匀变化,x, y随角度变化

P.S.圆周运动用极坐标解释起来有些牵强r(θ) = r,设置圆心,再[0, 360]均匀rotate,没有transform的时代要怎么计算位置?)

四.easing函数

对比上面得出的公式:

s = S * p               // 匀速
s = S * p^2             // 匀加速
s = S * p * (2 - p)     // 匀减速
s = S * cos(2PI * p)    // cos
s = S * sin(2PI * p)    // sin

发现总位移S不变,后面的部分不同,所以:

var easings = {
    linear: function(p) { return p; },
    acceleration: function(p) { return p * p; },
    deceleration: function(p) { return p * (2 - p); },
    sin: function(p) { return Math.sin(2 * Math.PI * p); },
    cos: function(p) { return Math.cos(2 * Math.PI * p); }
}

这些easing函数用来修正p,所以动画应该是:

// 任意时刻t对应的位移
st = s0 + S * easing(p)
// 即
// 当前值 = 初始值 + totalDelta * easing函数修正后的完成度

因为p = t / T,所以实际上easing作用于t,也叫时间控制函数timingFunction

动画库都是这样干的,例如jQuery

// from https://github.com/jquery/jquery/blob/2d4f53416e5f74fa98e0c1d66b6f3c285a12f0ce/src/effects/Tween.js
jQuery.easing = {
    linear: function( p ) {
        return p;
    },
    swing: function( p ) {
        return 0.5 - Math.cos( p * Math.PI ) / 2;
    },
    _default: "swing"
};

velocity

// from https://github.com/ayqy/velocity-1.4.1/blob/master/velocity.js
Velocity.Easings = {
    linear: function(p) {
    // 线性,直接返回完成度
        return p;
    },
    swing: function(p) {
    // 两头慢中间快,cos从+1到-1变化,中间斜率最大变化最快
        return 0.5 - Math.cos(p * Math.PI) / 2;
    },
    /* Bonus "spring" easing, which is a less exaggerated version of easeInOutElastic. */
    spring: function(p) {
    // easeInOutElastic的温和版
        return 1 - (Math.cos(p * 4.5 * Math.PI) * Math.exp(-p * 6));
    }
};

其它复杂的easing,比如摩擦力(spring)、重力(bounce)等物理效果,常见的时间控制easing系列(各种Bezier曲线对应的缓动函数),step效果也是同样的原理,修正完成度,也就是所谓的速度控制

五.在线Demo

通过velocity自定义easingRedirects来实现这些曲线轨迹,例如:

// 自定义缓动函数
// 匀加速
Velocity.Easings.acceleration = function (p, opts, tweenDelta) {
    return p * p;
};
// 匀减速
Velocity.Easings.deceleration = function (p, opts, tweenDelta) {
    return p * (2 - p);
};

// 自定义动画效果
Velocity.Redirects['throw-h'] = function(element, options, elementsIndex, elementsSize, elements, promiseData) {
    Velocity(this, {
        translateX: [300, 'linear', 0],
        translateY: [300, 'acceleration', 0]
    }, options);
};

// run
Velocity(document.body, 'throw-h', 3000);

详细见Demo:http://ayqy.net/temp/curve-path-animation.html

Demo过程中发现velocity在完成度为1时,会强制赋值一遍终点,这在sin之类的场景下有问题,源码如下:

else if (percentComplete === 1) {
// 已完成,手动赋值,确保终点准确(不受计算精度影响)
//!!! 不应该手动赋值终点了,因为sin之类的,终点是0
//!!! 强制赋值就错了
    /* If this is the last tick pass (if we've reached 100% completion for this tween),
     ensure that currentValue is explicitly set to its target endValue so that it's not subjected to any rounding. */
    // currentValue = tween.endValue;
    currentValue = tween.currentValue;
}

修复方法是信任easing函数(直接去掉上面这部分内容),终点处也通过easing计算得到当前值,这样做的缺点是存在计算精度的问题,比如sin2PI为0,计算结果是一个极小值,而不是0,但没什么实际影响

参考资料

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code