【JavaScript】ベジェ曲線を使ったアニメーション【Canvas】

#はじめに
canvasでアニメーションすることって多いと思います。
今回はある点を直線的に移動するときのアニメーションを、
ベジェ曲線を使って定義できるようにしたいと思います。
本記事は少しはベジェ曲線について知っている人向けの記事です。

#ベジェ曲線についての簡単な説明
簡単にベジェ曲線について説明します。
今回扱うのは3次ベジェ曲線です。
横軸をx,縦軸をyとします。
3次ベジェ曲線には、制御点が4つあります。
制御点の座標が(0, 0), (0, 0.5), (0.5, 1), (1, 1)のベジェ曲線は以下のようになります
(青色の曲線がベジェ曲線、青色の点と緑色の点が制御点です)

![----.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/163605/f667db61-f2f5-2be5-0b00-15782094685a.png)

3次ベジェ曲線は、パラメータtを使って表されます。
tは0以上、1以下の値をとります。

t=0.3のときのベジェ曲線上の点を求めてみます。

点を4つから3つに減らします。
(0, 0)と(0, 0.5)を0.3:0.7に分割する点を求めます => (0, 0.15)
(0, 0.5)と(0.5, 1)を0.3:0.7に分割する点を求めます => (0.15, 0.65)
(0.5, 1)と(1, 1)を0.3:0.7に分割する点を求めます => (0.65, 1)

点を3つから2つに減らします。
(0, 0.15)と(0.15, 0.65)を0.3:0.7に分割する点を求めます => (0.045, 0.3)
(0.15, 0.65)と(0.65, 1)を0.3:0.7に分割する点を求めます => (0.3, 0.755)

点を2つから1つに減らします。
(0.045, 0.3)と(0.3, 0.755)を0.3:0.7に分割する点を求めます => (0.1215, 0.4365)![0.3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/163605/91c8cec7-4034-7793-c4d2-4242d50104c9.png)
この点がt=0.3のベジェ曲線上の点となります。

t = 0.5の場合
![0.5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/163605/252c315d-b32a-eb5e-ffb0-799da880c711.png)

t = 0.8の場合
![0.8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/163605/d6227f14-9e34-7542-0fd5-1db94571e2be.png)

t = 0 からt = 1 まで0.1刻みでベジェ曲線上の点を表示
![all.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/163605/ca14623f-4e88-a657-1cb8-753f334297e3.png)

#アニメーションをtとベジェ曲線のy値で定義する
jQueryのanimate関数やCSSの<a href="https://developer.mozilla.org/ja/docs/Web/CSS/timing-function">cubic-bezier</a>のようなアニメーションを実現させましょう。
今、我々はtが定まれば、制御点からベジェ曲線上の点を求める方法を知っています。
円を点Aから点Bへアニメーションする場合、
パラメータtをアニメーション時間の割合とし、ベジェ曲線の点のY座標を点Aからの移動の割合とすればうまくいきそうです。
点Aの位置ベクトルをa,点Bの位置ベクトルをbとすれば、
a+(b-a) * (パラメータtのベジェ曲線のY座標) です。
###tについて
点Aから点Bへ2秒かけてアニメーションする場合、
60fpsで現在のフレームがn(1≦n≦120,nは整数)なら
t = n / (2 * 60) となる

###プログラム的にはどう書くか
```js
// tが0.2のときのベジェ曲線のY値を求める

const points = [
    { x: 0, y: 0 },    // この点は固定
    { x: 0, y: 0.5 },  // この点は自由に設定してください
    { x: 0, y: 0.5 },  // この点は自由に設定してください
    { x: 1, y: 1 },    // この点は固定
];
const ret = getCubicBezierPoint(points, 0.3); // ret.yがベジェ曲線のY値です。これをベクトルABにかければよい。

```

###再現可能なイージング

<a href="http://www.knockknock.jp/archives/184">こちら</a>にあるcubic-bezierの引数を、
上に書いたプログラムのpoints[1],points[2]に代入すれば再現できます。

#全ソース
このプログラムは、制御点を動かし、それに伴うベジェ曲線のY値の変化を見るプログラムです。
その前にちょっと説明。
<b>t軸をx軸に重ねて描画しています。</b>
アニメーション時に灰色の点がX軸に沿って動き、
赤い点がベジェ曲線とY軸に沿って動くと思います。
<b>灰色の点はtを表していることに注意してください。</b>

![app.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/163605/4b62c921-ccd9-4565-0560-7ba2f99c9e97.png)

```html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>cubic-bezier</title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js" type="text/javascript"></script>
<style>
body {
    overflow: hidden;
}
#input-area {
    position: absolute;
    left: 0;
    top: 0;
    z-index: 100;
}
#canvas {
    position: absolute;
    left: 0;
    top: 0;    
}
#demo-area {
    position: absolute;
    left: 0;
    top: 800px;
    z-index: 100;
}
</style>
<script>

// 行列クラス(行列の計算に使用)
class Matrix {
    // m0は行列、m1は行列又はベクトル
    // 行列は大きさ9の1次元配列であること。 ex. [ 1, 0, 0, 0, 1, 0, 0, 0, 1 ]
    // ベクトルはxとyをプロパティに持つ連想配列であること。 ex. { x: 2, y: 4 }
    // 左からベクトルをかけることは想定していない
    static multiply(m0, m1) {
        if(m1.length && m1.length === 9) {// m1は行列
            return [
                m0[0] * m1[0] + m0[1] * m1[3] + m0[2] * m1[6],
                m0[0] * m1[1] + m0[1] * m1[4] + m0[2] * m1[7],
                m0[0] * m1[2] + m0[1] * m1[5] + m0[2] * m1[8],
                m0[3] * m1[0] + m0[4] * m1[3] + m0[5] * m1[6],
                m0[3] * m1[1] + m0[4] * m1[4] + m0[5] * m1[7],
                m0[3] * m1[2] + m0[4] * m1[5] + m0[5] * m1[8],
                m0[6] * m1[0] + m0[7] * m1[3] + m0[8] * m1[6],
                m0[6] * m1[1] + m0[7] * m1[4] + m0[8] * m1[7],
                m0[6] * m1[2] + m0[7] * m1[5] + m0[8] * m1[8],
            ];
        } else {// m1はベクトル
            return {
                x: m0[0] * m1.x + m0[1] * m1.y + m0[2],
                y: m0[3] * m1.x + m0[4] * m1.y + m0[5],                
            };
        }
    }
    // 単位行列
    static identify() {
        return [1, 0, 0, 0, 1, 0, 0, 0, 1];
    }
    // 平行移動行列
    static translate(x, y) {
        return [1, 0, x, 0, 1, y, 0, 0, 1];
    }
    // 拡大縮小行列
    static scale(x, y) {
        return [x, 0, 0, 0, y, 0, 0, 0, 1];
    }
    // 回転行列
    static rotate(theta) {
        const cos = Math.cos(theta),
            sin = Math.sin(theta);
        return [cos, -sin, 0, sin, cos, 0, 0, 0, 1];
    }
    // 逆行列を求める
    static inverse(m) {
        const det = Matrix.determinant(m),
            inv = [
                m[4] * m[8] - m[5] * m[7],    -(m[1] * m[8] - m[2] * m[7]),   m[1] * m[5] - m[2] * m[4],
                -(m[3] * m[8] - m[5] * m[6]), m[0] * m[8] - m[2] * m[6],      -(m[0] * m[5] - m[2] * m[3]),
                m[3] * m[7] - m[4] * m[6],    -(m[0] * m[7] - m[1] * m[6]),   m[0] * m[4] - m[1] * m[3]
            ];
        return inv.map(elm => elm / det);
    }
    // 行列式を求める
    static determinant(m) {
        return m[0] * m[4] * m[8] 
        + m[1] * m[5] * m[6] 
        + m[2] * m[3] * m[7]
        - m[2] * m[4] * m[6]
        - m[1] * m[3] * m[8]
        - m[0] * m[5] * m[7];
    }
}

// ベクトルクラス(ベクトルの計算に使用)
class Vector {
    // 足し算
    static add(v0, v1) {
        return {
            x: v0.x + v1.x,
            y: v0.y + v1.y,
        };
    }
    // 引き算
    static subtract(v0, v1) {
        return {
            x: v0.x - v1.x,
            y: v0.y - v1.y,
        };
    }
    // 掛け算
    static scale(v, s) {
        return {
            x: v.x * s,
            y: v.y * s,
        };
    }
    // ベクトルの長さを返す
    static length(v) {
        return Math.sqrt(v.x * v.x + v.y * v.y);
    }
    // 単位ベクトルを返す(非破壊的)
    static unit(v) {
        const len = Vector.length(v);
        return {
            x: v.x / len,
            y: v.y / len
        };
    }
    // 内積
    static innerProduct(v0, v1) {
        return v0.x * v1.x + v0.y + v1.y;
    }
}

$(() => {    

    const points = [
        { x: 0, y: 0 },
        { x: 0, y: 0.5 },
        { x: 0.5, y: 1 },
        { x: 1, y: 1 }
    ];

    // 2つの制御点(0,0), (1,1)は固定
    // 他の2つの制御点は動作可能
    const fixedIndexes = [0, 3];

    let mode = '';
    let worldPrePos;
    let pickedIndex;
    let animCheck = true;
    let animCnt = 0;
    let demoAnimFrame = 0;

    const FIXED_POINT_COLOR = 'blue';
    const MOVE_POINT_COLOR = 'green';
    const TIME_POINT_COLOR = 'gray';
    const CURRENT_POINT_COLOR = 'red';
    const POINT_RADIUS = 5;
    const BEZIER_DIVIDE_COUNT = 100;
    const DEMO_INTERVAL = 2;    // デモは2秒で動く

    let m = Matrix.identify();
    const m1 = Matrix.scale(400, 400);
    const m2 = Matrix.scale(1, -1);
    const m3 = Matrix.translate(0.2, -1.2);
    
    m = Matrix.multiply(m, m1);
    m = Matrix.multiply(m, m2);
    m = Matrix.multiply(m, m3);

    $('#canvas').prop({
        width: window.innerWidth,
        height: window.innerHeight,
    });

    $('#anim-check').prop({ checked: animCheck });

    $('#anim-check').on('change', e => {
        animCheck = $('#anim-check').prop('checked');
        console.log(animCheck);
    });

    $('#canvas').on('mousedown', e => {
        if(mode) { return; }
        e.preventDefault();
        const cursorPos = { x: e.pageX, y: e.pageY };

        const inv = Matrix.inverse(m);
        worldPrePos = Matrix.multiply(inv, cursorPos);
        pickedIndex = pick(worldPrePos, 2 * POINT_RADIUS / 400);
        if(pickedIndex !== -1) {// ピック出来た
            mode = 'picked';
        } 
    });

    $(window).on('mousemove', e => {
        if(!mode) { return; }

        const cursorPos = { x: e.pageX, y: e.pageY };

        const inv = Matrix.inverse(m);
        const cursorWorldPos = Matrix.multiply(inv, cursorPos);

        const worldVec = Vector.subtract(cursorWorldPos, worldPrePos);

        points[pickedIndex] = Vector.add(points[pickedIndex], worldVec);

        worldPrePos = cursorWorldPos;
    });

    $(window).on('mouseup', e => {
        if(!mode) { return; }
        mode = '';
    });

    $('#demo-move-button').on('click', e => {
        demoAnimFrame = 1;
    });

    anim();

    function pick(worldPos, radius) {
        return points.findIndex((p, i) => {
            if(fixedIndexes.indexOf(i) >= 0) {// 固定
                return false;
            } else {
                const v = Vector.subtract(p, worldPos)
                const len = Vector.length(v);
                return len <= radius;
            }            
        });
    }

    function anim() {
        const ctx = $('#canvas')[0].getContext('2d'); 

        ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);

        // X軸, Y軸を描画
        drawLine(ctx, [{ x: 0, y: 0, }, { x: 1, y: 0, }].map(p => Matrix.multiply(m, p)), 1, 'black');
        drawLine(ctx, [{ x: 0, y: 0, }, { x: 0, y: 1, }].map(p => Matrix.multiply(m, p)), 1, 'black');

        // ベジェ曲線を描画
        drawCubicBezierLines(ctx, points.map(p => Matrix.multiply(m, p)), BEZIER_DIVIDE_COUNT, 2, 'blue');

        // 制御点結ぶ線分を描画
        points.forEach((p, i) => {
            if(i >= points.length - 1) { return; }
            // 点の色を決定
            drawLine(ctx, [p, points[i + 1]].map(p => Matrix.multiply(m, p)), 1, '#888');
        }); 

        // 制御点を描画
        points.forEach((p, i) => {
            // 点の色を決定
            let color;
            if(fixedIndexes.indexOf(i) >= 0) {// 固定
                color = FIXED_POINT_COLOR;
            } else {// 動作可能
                color = MOVE_POINT_COLOR;
            }

            drawPoint(ctx, Matrix.multiply(m, p), POINT_RADIUS, color);
        }); 

        // 点情報を描画
        points.forEach((p, i) => {            
            const base = { x: 20, y: 600 + i * 40 };
            drawPointInformation(ctx, p, base, i, '24px serif', 'black');
        });  

        if(animCheck) { 
            // 時間に沿って動く点を描画
            const t = animCnt / 100;
            const tp = getCubicBezierPoint(points, t);
            drawPoint(ctx, Matrix.multiply(m, tp), POINT_RADIUS, CURRENT_POINT_COLOR);

            // 点をY軸に射影
            const ytp = { x: 0, y: tp.y };
            drawPoint(ctx, Matrix.multiply(m, ytp), POINT_RADIUS, CURRENT_POINT_COLOR);

            // 時間(t)をX軸に表示
            const xtp = { x: t, y: 0 };
            drawPoint(ctx, Matrix.multiply(m, xtp), POINT_RADIUS, TIME_POINT_COLOR);  
            
            animCnt += 1;   
            if(animCnt > 100) {
                animCnt = 0;
            }     
        }

        // 下側の始点終点を描画
        drawPoint(ctx, { x: 100, y: 800 }, 10, 'gray');
        drawPoint(ctx, { x: 600, y: 800 }, 10, 'gray');

        // 下側の現在の点を描画
        const t = demoAnimFrame / (DEMO_INTERVAL * 60);
        const point = getCubicBezierPoint(points, t);
        const start = { x: 100, y: 800 };
        const end = { x: 600, y: 800 };
        const vec = Vector.subtract(end, start);
        const cur = Vector.add(start, Vector.scale(vec, point.y));
        drawPoint(ctx, cur, 10, 'red');

        // 下の点を動かす
        if(demoAnimFrame > 0) {
            demoAnimFrame += 1;
            if(demoAnimFrame % (DEMO_INTERVAL * 60) === 0) {
                demoAnimFrame = 0;
            }
        } 

        requestAnimationFrame(anim);  
    }

    // 点の座標情報を描画
    function drawPointInformation(ctx, point, base, index, font, color) {
        ctx.font = font;
        ctx.fillStyle = color;
        // 項目
        ctx.fillText(`p[${index}]`, base.x, base.y);
        // X座標
        ctx.fillText(`X: ${point.x.toFixed(4)}`, base.x + 100, base.y);
        // Y座標
        ctx.fillText(`Y: ${point.y.toFixed(4)}`, base.x + 100 + 200, base.y);
    }    

    // パラメータを指定して3次ベジェ曲線上の座標を求める
    function getCubicBezierPoint(points, t) {
        let sub0, sub1, sub2;

        if(t === 0) {
            return points[0];
        } else if(t === 1) {
            return points[3];
        }

        sub0 = subdivide([ points[0], points[1] ], t);
        sub1 = subdivide([ points[1], points[2] ], t);
        sub2 = subdivide([ points[2], points[3] ], t);

        sub0 = subdivide([ sub0, sub1 ], t);
        sub1 = subdivide([ sub1, sub2 ], t);
        
        return subdivide([ sub0, sub1 ], t);

        // 内分点を求める
        function subdivide(points, t) {
            return {
                x: (1 - t) * points[0].x + t * points[1].x,
                y: (1 - t) * points[0].y + t * points[1].y,
            };
        }
    }

    // 3次ベジェ曲線を微小線分に分割して描画
    function drawCubicBezierLines(ctx, points, divides, width, color) {

        ctx.save();

        ctx.strokeStyle = color;
        ctx.lineWidth = width;

        ctx.beginPath();

        for(let i = 0; i <= divides; i += 1) {
            const t = i / divides;

            // パラメータを指定して3次ベジェ曲線上の座標を求める
            const bezierPoint = getCubicBezierPoint(points, t);

            if(i === 0) {
                ctx.moveTo(bezierPoint.x, bezierPoint.y);
            } else {
                ctx.lineTo(bezierPoint.x, bezierPoint.y);
            }
        }

        ctx.stroke();

        ctx.restore();
    }

    // 点を描画
    function drawPoint(ctx, point, radius, color) {
        ctx.save();

        ctx.fillStyle = color;
    
        ctx.beginPath();
        ctx.arc(point.x, point.y, radius, 0, 2 * Math.PI);
        ctx.closePath();
        ctx.fill();
        
        ctx.restore();
    }

    // 線分を描画
    function drawLine(ctx, points, width, color) {
        if(points.length !== 2) { return; }

        ctx.save();

        ctx.strokeStyle = color;
        ctx.lineWidth = width;
    
        ctx.beginPath();
        ctx.moveTo(points[0].x, points[0].y);
        ctx.lineTo(points[1].x, points[1].y);
        ctx.stroke();
        
        ctx.restore();
    }
});

</script>
</head>
<body>
<div id="input-area">
    <input id="anim-check" type="checkbox" />
    <label for="anim-check">anim</label>
</div>
<div id="demo-area">
    <input id="demo-move-button" type="button" value="move" />
</div>
<!-- メインのcanvas -->
<canvas id="canvas"></canvas>
</body>
</html>
```