应用场景
贝塞尔曲线的基本概念,大家可以自行了解:简单来说就是由n个点最终确定1个点的运动轨迹
。
本文重点介绍贝塞尔曲线的两个应用场景:1.实现非匀速动画
、2.绘制一条带弧度的直线(通常直线看起来太生硬)
。
如何绘制一条贝塞尔曲线
我们知道,canvas
提供了 bezierCurveTo
等相关绘制贝塞尔曲线的api
,实际上只要涉及到直线的基本都会用该api
实现,例如react-signature-canvas
绘制的笔迹,
Rough.js官网的demo
,你能看到的直线
基本都是贝塞尔曲线
。那么如何不借助api
实现一条贝塞尔曲线呢?
贝塞尔曲线的本质就是借用积分思想绘制某个点的坐标轨迹
直接套贝塞尔曲线的轨迹方程P = (1−t)3P1 + 3(1−t)2tP2 +3(1−t)t2P3 + t3P4
开始实现:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, minimum-scale=1, initial-scale=1, user-scalable=yes">
<title>Bezier Demo</title>
</head>
<body>
<canvas width="800" height="800"></canvas>
<script>
const canvas = document.querySelector('canvas');
const w = canvas.offsetWidth;
const h = canvas.offsetHeight;
const dpr = window.devicePixelRatio;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.cssText = 'width:' + w + 'px;' + 'height:' + h + 'px;';
const ctx = canvas.getContext('2d');
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
// api绘制
ctx.beginPath();
ctx.save();
ctx.lineWidth = 10;
ctx.moveTo(100, 100);
ctx.bezierCurveTo(200, 50, 300, 150, 400, 100);
ctx.stroke();
ctx.restore();
// 手动绘制
ctx.beginPath();
ctx.save();
ctx.strokeStyle = 'red';
ctx.lineWidth = 5;
ctx.moveTo(100, 100);
for (let t = 0; t <= 1; t += 0.01) {
let x = (1 - t) ** 3 * 100 + (1 - t) ** 2 * t * 3 * 200 + t ** 2 * (1 - t) * 3 * 300 + t ** 3 * 400;
let y = (1 - t) ** 3 * 100 + (1 - t) ** 2 * t * 3 * 50 + t ** 2 * (1 - t) * 3 * 150 + t ** 3 * 100;
ctx.lineTo(x, y);
}
ctx.stroke();
</script>
</body>
</html>
可以看到效果基本一致,如果我们设置t += 0.1
,曲线平滑度会明显降低:
贝塞尔动画曲线
相对来说,运动轨迹是贝塞尔曲线的情况要稍许复杂。首先我们通过任意4个点
很难想象运动轨迹,其次,还需要把运动轨迹理解成动画。只有理解了前面两步才能实现一个贝塞尔运动曲线
。
我们可以通过贝塞尔曲线调试工具来调试贝塞尔曲线
如何理解上图中的cubic-bezier(0.12, 1.29, 0.88, -0.33)
, 实际上完整的贝塞尔曲线应该是cubic-bezier(0.12, 1.29, 0.88, -0.33, 1, 1)
, 横坐标是时间
, 纵坐标是动画属性目标值
。假如这个动画属性是left
,那么该图表示的是left
从0
开始,随时间变化的轨迹: 先变大,再变小,再变大。
贝塞尔曲线的本质是用贝塞尔方程求出每一个时刻所对应的进度
下面开始用代码实现:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, minimum-scale=1, initial-scale=1, user-scalable=yes">
<title>Bezier Demo</title>
<style>
.app {
width: 100px;
height: 100px;
background: red;
margin-bottom: 20px;
}
.move1 {
animation: act1 cubic-bezier(0.12, 1.29, 0.88, -0.33) 3s forwards;
}
.move2 {
animation: act2 cubic-bezier(0.12, 1.29, 0.88, -0.33) 3s forwards;
}
@keyframes act1 {
0% {
margin-left: 0;
}
100% {
margin-left: 200px;
}
}
@keyframes act2 {
0% {
margin-left: 200px;
}
100% {
margin-left: 0;
}
}
</style>
</head>
<body>
<div id="app" class="app"></div>
<div id="app2" class="app"></div>
<script>
const start = 0; // 要执行动画属性起始值
const end = 200; // 要执行动画属性结束值
let current = 0; // 区分正/反向动画
let timeDuration = 3000; // 动画总时间
const p1 = [0.12, 1.29]; // 贝塞尔曲线第一个控制点
const p2 = [0.88, -0.33]; // 贝塞尔曲线第二个控制点
document.onclick = () => {
// css 贝塞尔动画曲线
if (app.classList.contains('move1')) {
app.classList.remove('move1');
app.classList.add('move2');
} else {
app.classList.remove('move2');
app.classList.add('move1');
}
// 手动实现一个贝塞尔动画曲线
let points = [];
if (current === start) {
current = end;
// 比例换算成真实坐标轴的值
points = [
[0, start],
[p1[0] * timeDuration, p1[1] * end],
[p2[0] * timeDuration, p2[1] * end],
[timeDuration, end]
];
} else {
current = start;
points = [
[0, end],
[(1 - p2[0]) * timeDuration, p2[1] * end],
[(1 - p1[0]) * timeDuration, p1[1] * end],
[timeDuration, start],
];
}
let beginTime = performance.now();
function cb() {
const duration = performance.now() - beginTime;
if (duration >= timeDuration) {
app2.style.marginLeft = points[points.length - 1][1] + 'px';
return;
}
// duration = (1 - t) ** 3 * points[0][0] + (1 - t) ** 2 * t * 3 * points[1][0] + t ** 2 * (1 - t) * 3 * points[2][0] + t ** 3 * points[3][0];
// 根据duration求出t后、在求出y坐标,即动画属性的值
// 我们采用二分查找
let t = 0;
let r = 1;
while (t < r) {
let mid = t + (r - t) / 2;
let d = (1 - mid) ** 3 * points[0][0] + (1 - mid) ** 2 * t * 3 * points[1][0] + t ** 2 * (1 - mid) * 3 * points[2][0] + mid ** 3 * points[3][0];
if (Math.abs(d - duration) <= 1e-2) {
break;
} else if (d < duration) {
t = mid + 0.05;
} else if (d > duration) {
r = mid - 0.05;
}
}
t = Math.min(1, Math.max(0, t));
let y = (1 - t) ** 3 * points[0][1] + (1 - t) ** 2 * t * 3 * points[1][1] + t ** 2 * (1 - t) * 3 * points[2][1] + t ** 3 * points[3][1];
console.log(t + ';' + y);
app2.style.marginLeft = y + 'px';
requestAnimationFrame(cb);
}
requestAnimationFrame(cb);
}
</script>
</body>
</html>
实际轨迹还是有不少误差,但是动画曲线是一致的,经尝试修改两个控制点为任意值,曲线也基本一致。
这里求解精度如果设置太高会导致动画太卡,大家下去可以自行调试。如果大家有更好的求解方案,欢迎留言。