贝塞尔曲线及其应用

1,479 阅读4分钟

简介

在前端工作中,贝塞尔曲线被多个地方应用。就如最近年会抽奖中,开始滚动的加速度和结束滚动时如何缓慢停止在中奖号码的位置,就用到了贝塞尔曲线。所以顺势就介绍一下贝塞尔曲线和简单应用。 其一般参数公式:

注1

该曲线是由P0开始,止于Pn,中间有n-2个点控制曲线的走势。根据控制点个数不同,得到不同的特殊曲线公式。

在实际工作中,常用的是二次贝塞尔曲线三次贝塞尔曲线。一次贝塞尔曲线,呈现出来的是一条直线。下面是几种常见的贝塞尔曲线的介绍和应用。

常见的曲线

线性公式

n=1时,控制点个数: 0,仅有开始和结束两个点,得到的是一条直线。
注2

二次方公式

n=2时,控制点个数: 1
注3

三次方公式

n=3时,控制点个数: 2
注4

公式说明

二次方公式为例,转换为函数如下,其中P0为起点,P2为终点,P1为控制点。得到的是从P0到P2的关于t的二次函数,t的范围[0, 1]。

function QuadraticBezier(P0, P1, P2){
  return (t) => (1 - t) * (1 - t) * P0 + 2 * t * (1 - t) * P1 + t * t * P2;
}

应用

SVG的应用

SVG中,用q或者Q可以绘制二次方贝塞尔曲线(q指相对位置,Q指绝对位置)。以svg为例, 关键代码<path d="M 100 350 Q 250 50 400 350"/> ,起点P0(100 350), 终点P2(400 350), 控制点P1(250 50)。将X坐标和Y坐标分别带入函数QuadraticBezier中,可以获得某个时刻的具体坐标。

// 分别计算X、Y
const getX = new QuadraticBezier(100, 250, 400);
const getY = new QuadraticBezier(350, 50, 350);

const point = document.getElementById("point");  // 随着t动态移动的点
let t = 0;  // 初始值为0,逐步增加到1
function setXY () {
  point.style.top = getY(t) + "px";
  point.style.left = getX(t) + "px";
  if (t >= 1) {
    cancelAnimationFrame(frame);
  } else {
    t = t + 0.005;
    frame = requestAnimationFrame(setXY);
  }
}
let frame = requestAnimationFrame(setXY);

随着t的增加,分别计算出point的top和left,可以得到如下动画:

注5

canvas中的apiquadraticCurveTobezierCurveTo与绘制SVG类似。

CSS animation-timing-function

CSS动画中由animation-timing-function规定动画的速度曲线,已经预设了几个值: lineareaseease-inease-outease-in-out,自定义速度时使用cubic-bezier()灵活控制。 可以修改例子中数字看看效果。

JS中应用

CSS属性animation-timing-function的值cubic-bezier接收的值正常范围是[0, 1]的,即在三次贝塞尔曲线中,默认起始点P0(0, 0),终点P3(1, 1),可以推导出公式

const CubicBezier = P0 * (1-t)^3 + 3 * P1 * t * (1-t)^2 + 3 * P2 * t^2 * (1-t) + P3 * t^3
// 令 P0(0, 0), P3(1, 1), P1(x1, y1), P2(x2, y2),代入
x = 3 * x1 * t * (1-t)^2 + 3 * x2 * t^2 * (1-t) + t^3;
  = (3 * x1 - 3 * x2 + 1) * t^3 + (3 * x2 - 6 * x1) * t^2 + 3 * x1 * t;
y = (3 * y1 - 3 * y2 + 1) * t^3 + (3 * y2 - 6 * x1) * t^2 + 3 * y1 * t;

即
function CubicBezier(x1, y1, x2, y2) {
  this.x1 = x1;
  this.y1 = y1;
  this.x2 = x2;
  this.y2 = y2;
}
CubicBezier.prototype.sampleCurveX = function(t){
  const x1 = this.x1, x2 = this.x2;
  return (3 * x1 - 3 * x2 + 1) * t^3 + (3 * x2 - 6 * x1) * t^2 + 3 * x1 * t;
}
CubicBezier.prototype.sampleCurveY = function(t){
  const y1 = this.y1, y2 = this.y2;
  return (3 * y1 - 3 * y2 + 1) * t^3 + (3 * y2 - 6 * x1) * t^2 + 3 * y1 * t;
}

XY的函数结合起来,即可计算t时刻的值:

CubicBezier.prototype.solve = function(t){
  return this.sampleCurveY(this.sampleCurveX(t))
}

此时简单结合起来,误差较大。因为根据绘制SVG例子中可以看出,函数sampleCurveXsampleCurveY计算的是t时刻的坐标xy,能画出该三次贝塞尔曲线。但是应用到属性值的变化上时,曲线上t时刻的切线代表t时刻属性值变化的速度,与t时刻的坐标xy不是直接关系。所以需要在对函数进行再加工。牛顿迭代法:

CubicBezier.prototype.solve = function(x){
  if (x === 0 || x === 1) {             // 对 0 和 1 两个特殊 t 不做计算
    return this.sampleCurveY(x);
  } 
  let t = x
  for (let i = 0; i < 8; i++) {         // 进行 8 次迭代
    const g = this.sampleCurveX(t) - x
    if (Math.abs(g) < this.epsilon) {   // 检测误差到可以接受的范围, 如:this.epsilon = 1e-7;
      return this.sampleCurveY(t)
    }
    const d = 3 * (3 * x1 - 3 * x2 + 1) * t^2 + 2 * (3 * x2 - 6 * x1) * t + 3 * x1;  // 对 x 求导
    if (Math.abs(d) < 1e-6) {           // 如果梯度过低,说明牛顿迭代法无法达到更高精度
      break
    }
    t = t - g / d;
  }
  return this.sampleCurveY(t)                   // 对得到的近似 t 求 y
}

在误差可接受的范围内,迭代后的效果与CSS动画还是有微微的差距的。测试效果如下:

注6

老虎机中的应用

在年会抽奖页面中,也用到了三次贝塞尔曲线,如:开始滚动时,滚动速度要从0加速到指定速度maxSpeed,需要传入:初始速度0,最大速度maxSpeed,当前时间,加速到maxSpeed所需时间以及控制点。可得如下函数:

function initCubicBezier(startTime, totalTime, startValue, targetValue, controlArr){
  let cubic = new CubicBezier(...controlArr);
  function getValue(time){
    let progress = (time - startTime) / totalTime;
    if (progress >= 1) {
      progress = 1
    }
    const value = cubic.solve(progress);
    return value * (targetValue - startValue) + startValue;
  }
  return getValue;
}
  1. 开始滚动

    1. 创建获取当前时间对应速度的函数getSpeed
    2. 根据top值使图片偏移,达到移动效果
    3. top = top + speed, 修改speed的值使增量变化,达到加速效果
    4. 速度未达到最大速度时,getSpeed获取新的速度
    5. 重复2-4,主要代码如下
function start() {
  let top = 0; // 当前图片移动的位置
  let speed = 0;  // 初始速度
  const targetSpeed = 100;  // 最大速度
  const totalTime = 3000;   // 3000毫秒加速到最大速度
  const getSpeed = initCubicBezier(date.now(), totalTime, speed, targetSpeed, [.5,.5,.5,.5]);
  
  function run () {
    if (speed < targetSpeed) {
      speed = getSpeed(Date.now());
    }
    top = top + speed;
    drawImage(top);  // 重新绘制图片
    requestAnimationFrame(run);
  }
  requestAnimationFrame(run); 
}
  1. 结束滚动

    1. 需要结束时根据top计算当前显示的数字showNum
    2. 根据中奖数字targetNumshowNum计算出,停止滚动时的值targetTop
    3. 创建获取某时刻的top函数getTop
    4. 计算此刻图片的偏移量top
    5. 判断top < targetTop时,说明还未滚动中奖号码,继续滚动
    6. 判断top >= targetTop时,停止滚动。
function end(targetNum) {
  const targetTop = getTargetTop(top, targetNum);  // 根据当先位置,和中奖数字,获取停留的位置
  const getTop = initCubicBezier(date.now(), 3000, top, targetTop, [.39,.61,.74,.99]);
  function run () {
    if (top >= targetTop) {
      cancelAnimationFrame(frame);
    } else {
      top = getTop(Date.now());
      drawImage(top);  // 绘制新图片
      frame = requestAnimationFrame(run);
    }
  }
  let frame = requestAnimationFrame(run);
}

最终效果如下:

注7

注释

因不能直接引用mp4格式,所以文中的动画都转成GIF格式了,效果差了很多,原视频地址请查看对应链接。 图片存放在github,若打不开也请查看对应链接。 如有关于视频存放或者其他建议烦请联系我,感谢