Easing 基础函数的理解

938 阅读5分钟

前言

在缓冲动画中,前端经常会接触:Ease-In, Ease-Out, Ease-In-Out, 所以就学习下 Robert Penner 方程式,他就是用来表示动画的缓动函数。这里我们先学习二次方(Quadratic)来了解动画过程。

  • EaseIn
  • EaseOut

实现公式

先直接给出 2 个基础公式,然后我们再来推导,看这个公式怎么得出来。其实如果有几何思维就会特别简单,没有太多推导过程,但是如果要解释清楚,还是推导下好一些。

function easeIn(t) {
  return t * t;
}

function easeOut(t) {
  return t * ( 2 - t );
}

第一个问题:为什么是 t * t?

Ease-In 的效果就是:开始慢慢的,后来逐渐变快,类似长跑的物理函数。

  • 假设有个 t,他在 [0,1] 区间取值

我们这里都是假设 t 是一个时间的变量,t = [0, 1] 的一个范围来表示动画的比例进度,这里肯定会问:为什么是[0, 1]?因为我们这里是一个移动目标和时间的一个缩影,可以理解是一个动画百分比的一个表示,在10%, 50%, 75%, 100%等阶段,我们看到的绘制是什么样,当静态绘制连续播放的时候,他就是一个动画。

  • 然后用几何的方式来解释曲率 K,表示一个 ease-in 的运动轨迹(蓝色线)

可以看到下面图其实是一个二次曲线,那么我们要先慢后快:y = t * t 公式,我们学过直线公式 y = x * t, 我们想象一下 x 如果是一个变化的值,那么 x >>> [0, 1] 无限趋近于 t 的值,那么这不就产生了先缓后陡的曲线感觉么?几何就是很简单下面这个“蓝色曲线”。

image.png

如果想用代数的方式来推导,可能要使用[代入]的方式才能推导, y = t^2,然后用点来带入来:

首先我们需要基础理论:t^2 是一个增长函数,这个是 t=[0,1] 过程,y 也是 [0,1],那么推导:

  1. t 无限小的时候,t^2 无限接近 0,那么增长是慢的
  2. t 无限接近 1 的时候,t^2 无限接近 1,那么增长是快的,垂直嘛

所以可以取 [0, 0.5, 1] 三个值来验证所得。

所以我们可以是 easeIn(y) = t * t 这样来计算一个缓动函数。

第二个问题:为什么是 t * (2 - t)?

上面介绍了蓝色曲线是慢到快,那么我们按 45 度斜率翻转一下,就得到了 ease-out 这个缓动表达的曲线,这个曲线的公式我们是:t * (2 - t),这里我们看了几何后看下代数的推导过程。首先他是根据 y = t * t 来反转函数开始。我们 t 的反转就是 1 - t,那么我们带入 y = t * t 这个方程:y = (1 - t) * (1 - t),然后我们要 0 -> 1 快到慢,我们就 1 - y(t) 那么我们就得到了:y = 1 - (1 - t) * (1 - t),通过开平方运算合并 t,得到 y = 2t - t^2 => y = t * (2 - t) 这样的公式。

上面一段代数方式的推导就是麻人,我这边理解成过程式推导,但是我们不是最强 AI 么?所以看“绿色曲线”+数学公式所得即可。最后代数这个依然可以采用代入点的方式去让这个过程可推导。

如何获取 [0, 1] 的动画进度

上面公式说了这么多,那么我们不是工程师么?用代码怎么表示其中的东西?比如在浏览器得到 [0, 1] 这个变化进度。我们只需要借助 requestAnimationFrame 来获取 repaint 的差值,然后使用 (再次绘制时间-上一次绘制时间) / 总时长,这不就得到了比例么?然后放入缓冲函数得到具体移动的进度即可。

function animate({ timing, draw, duration }) {
  let start = performance.now();

  requestAnimationFrame(function animate(time) {
    let timeFraction = (time - start) / duration;
    if (timeFraction > 1) timeFraction = 1;

    let progress = timing(timeFraction);

    draw(progress);

    if (timeFraction < 1) {
      requestAnimationFrame(animate);
    }
  });
}

形象点?

其实移动的数据表达在上面这个函数差不多了,但是前端上可以再增强下,我们用 Canvas 画一个,然后让他动起来试试。

  • 首先画下坐标轴

坐标轴就长上面图片的样子,通过最简单的 moveTo/lineTo 来实现绘制。其中需要理解的就是曲线绘制公式,这个曲线理解成很多个小短线逐渐变化其中的 yIn 值来达到 y 轴变化目的就很好理解了。ease 表示的是变化快慢,整个区间是 height - 50 * 2, 100 是留白的位置。

function drawGraph() {
  // Draw the coordinate system
  ctx.clearRect(0, 0, width, height);
  ctx.beginPath();
  ctx.moveTo(50, height - 50);
  // 画条线
  ctx.lineTo(50, 50);
  // 画个箭头
  ctx.lineTo(60, 60);

  ctx.moveTo(50, 50);
  ctx.lineTo(40, 60);
  ctx.moveTo(50, height - 50);
  ctx.lineTo(width - 50, height - 50);
  ctx.lineTo(width - 60, height - 60);
  ctx.moveTo(width - 50, height - 50);
  ctx.lineTo(width - 60, height - 40);
  ctx.stroke();

  // Label axes
  ctx.font = '16px Arial';
  ctx.fillText('0', 40, height - 40);
  ctx.fillText('1', 40, 40);
  ctx.fillText('1', width - 40, height - 40);
  ctx.fillText('t', width - 40, height - 20);
  ctx.fillText('y', 20, 40);

  // Draw the function curves
  ctx.strokeStyle = 'blue';
  ctx.beginPath();
  for (let t = 0; t <= 1; t += 0.01) {
    let x = 50 + t * (width - 100);
    let yIn = height - 50 - easeIn(t) * (height - 100);
    if (t === 0) {
      ctx.moveTo(x, yIn);
    } else {
      ctx.lineTo(x, yIn);
    }
  }
  ctx.stroke();

  ctx.strokeStyle = 'green';
  ctx.beginPath();
  for (let t = 0; t <= 1; t += 0.01) {
    let x = 50 + t * (width - 100);
    let yOut = height - 50 - easeOut(t) * (height - 100);
    if (t === 0) {
      ctx.moveTo(x, yOut);
    } else {
      ctx.lineTo(x, yOut);
    }
  }
  ctx.stroke();
}
  • 然后开始根据曲线运动下球球

不断的重新绘制球就行,主要是 arc 前面 2 个参数:x, y 的坐标。其中 y - 偏移(这个就是 ease 所起到的效果,是一个有缓冲的偏移)

function startAnimation() {
  let x = 50;
  let y = height - 50;

  function drawIn(progress) {
    ctx.clearRect(0, 0, width, height);
    drawGraph();
    ctx.fillStyle = 'red';
    ctx.beginPath();
    ctx.arc(x + progress * (width - 100), y - easeIn(progress) * (height - 100), 7, 0, 2 * Math.PI);
    ctx.fill();
  }

  function drawEaseOut(progress) {
    ctx.clearRect(0, 0, width, height);
    drawGraph();
    ctx.fillStyle = 'red';
    ctx.beginPath();
    ctx.arc(x + progress * (width - 100), y - easeOut(progress) * (height - 100), 7, 0, 2 * Math.PI);
    ctx.fill();
  }

  animate({
    timing: easeIn,
    draw: drawIn,
    duration: 2000
  });

  // 接着运行 ease-out
  setTimeout(() => {
    animate({
      timing: easeOut,
      draw: drawEaseOut,
      duration: 2000
    });
  }, 2500);
}
  • 最后看看效果

ani.gif