3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2020-03-30

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

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

----.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
この点がt=0.3のベジェ曲線上の点となります。

t = 0.5の場合
0.5.png

t = 0.8の場合
0.8.png

t = 0 からt = 1 まで0.1刻みでベジェ曲線上の点を表示
all.png

#アニメーションをtとベジェ曲線のy値で定義する
jQueryのanimate関数やCSSのcubic-bezierのようなアニメーションを実現させましょう。
今、我々は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) となる

###プログラム的にはどう書くか

// 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にかければよい。

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

こちらにあるcubic-bezierの引数を、
上に書いたプログラムのpoints[1],points[2]に代入すれば再現できます。

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

app.png

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?