#はじめに
canvasでアニメーションすることって多いと思います。
今回はある点を直線的に移動するときのアニメーションを、
ベジェ曲線を使って定義できるようにしたいと思います。
本記事は少しはベジェ曲線について知っている人向けの記事です。
#ベジェ曲線についての簡単な説明
簡単にベジェ曲線について説明します。
今回扱うのは3次ベジェ曲線です。
横軸をx,縦軸をyとします。
3次ベジェ曲線には、制御点が4つあります。
制御点の座標が(0, 0), (0, 0.5), (0.5, 1), (1, 1)のベジェ曲線は以下のようになります
(青色の曲線がベジェ曲線、青色の点と緑色の点が制御点です)
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)
この点がt=0.3のベジェ曲線上の点となります。
t = 0 からt = 1 まで0.1刻みでベジェ曲線上の点を表示
#アニメーションを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を表していることに注意してください。
<!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>