贝塞尔曲线

58 阅读4分钟

应用场景


贝塞尔曲线的基本概念,大家可以自行了解:简单来说就是由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>

image.png

可以看到效果基本一致,如果我们设置t += 0.1,曲线平滑度会明显降低:

image.png

贝塞尔动画曲线


相对来说,运动轨迹是贝塞尔曲线的情况要稍许复杂。首先我们通过任意4个点很难想象运动轨迹,其次,还需要把运动轨迹理解成动画。只有理解了前面两步才能实现一个贝塞尔运动曲线

我们可以通过贝塞尔曲线调试工具来调试贝塞尔曲线

image.png

如何理解上图中的cubic-bezier(0.12, 1.29, 0.88, -0.33), 实际上完整的贝塞尔曲线应该是cubic-bezier(0.12, 1.29, 0.88, -0.33, 1, 1), 横坐标是时间, 纵坐标是动画属性目标值。假如这个动画属性是left,那么该图表示的是left0开始,随时间变化的轨迹: 先变大,再变小,再变大。

贝塞尔曲线的本质是用贝塞尔方程求出每一个时刻所对应的进度

下面开始用代码实现:

<!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>

实际轨迹还是有不少误差,但是动画曲线是一致的,经尝试修改两个控制点为任意值,曲线也基本一致。

这里求解精度如果设置太高会导致动画太卡,大家下去可以自行调试。如果大家有更好的求解方案,欢迎留言。