問題を解きながら、座標変換や行列を学ぼう
行列と座標変換って聞くだけでも嫌な人いるんじゃないでしょうか?
そこで今回は座標変換の問題を、なるべく専門用語を使わず分かりやすく説明したいと思います。
2020/03/05 追記: 続編的な記事を書きました。
2020/03/09 追記: さらに続編的な記事を書きました。
各問題に共通のcanvas
各問題共通でcanvasの幅は360,高さは240とします。
<canvas width="360" height="240"></canvas>
問題1
下の三角形を青色の矩形で囲っている範囲がcanvasに合うように描画しなさい。
canvasの幅が360,高さが240で、青色の矩形の幅が180,高さが120と
青色の矩形のサイズが2倍になっていますね。
ですので、三角形を描画するときに三角形の座標を2倍して描画すればOKです。
// 問題1のソース
var USE_MATRIX = true; // ここを変更してください
var points = [
{ x: 40, y: 80 },
{ x: 120, y: 100 },
{ x: 80, y: 20 }
];
var ctx = $('canvas')[0].getContext('2d');
var i, len, inext;
if(USE_MATRIX) {// 行列を使ったプログラム
var matrix = MATRIX.scale(2, 2);
for(i = 0, len = points.length; i < len; i++) {
inext = (i + 1) % len;
line(MATRIX.multiplyVec(matrix, points[i]), MATRIX.multiplyVec(matrix, points[inext]));
}
} else {// 行列を使っていないプログラム
for(i = 0, len = points.length; i < len; i++) {
inext = (i + 1) % len;
line({x: points[i].x * 2, y: points[i].y * 2 },
{x: points[inext].x * 2, y: points[inext].y * 2 });
}
}
function line(point0, point1) {
ctx.beginPath();
ctx.moveTo(point0.x, point0.y);
ctx.lineTo(point1.x, point1.y);
ctx.stroke();
}
行列を使った座標変換と行列を使用しないケースの2パターンを
USE_MATRIXというフラグ切り替えられるようにしています。
正しく表示されているようですね。
行列を使用しているプログラムを見てみましょう。
var matrix = MATRIX.scale(2, 2);
for(i = 0, len = points.length; i < len; i++) {
inext = (i + 1) % len;
line(MATRIX.multiplyVec(matrix, points[i]), MATRIX.multiplyVec(matrix, points[inext]));
}
まず拡大行列を作成して、それを点(ベクトル)に掛けて座標を変換しています。
行列を使った座標変換を数式で表すとこんな感じです。
\begin{pmatrix}
2& 0 & 0\\
0 & 2 & 0\\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
x\\
y\\
1
\end{pmatrix}
=
\begin{pmatrix}
2* x + 0 * y + 0 * 1\\
0 * x + 2 * y + 0 * 1\\
0 * x + 0 * y + 1 * 1
\end{pmatrix}
=
\begin{pmatrix}
2x\\
2y\\
1
\end{pmatrix}
x, y ともに2倍になっていますね。
ところで、2次元の問題なのに行列は3次元になっていますね。
気になる方はこちらを参照して下さい。
問題1のプログラムはこちらで動作可能です。78行目からお読みください。
問題2
下の三角形を青色の矩形で囲っている範囲がcanvasに合うように描画しなさい。
今度は原点(X=0,Y=0の点)がずれていますね、先ほどの問題ほど簡単にはいかないようです。
まず青色の矩形の左上隅の座標を、原点に一致するように移動させる必要があります。
そうすれば、こっちのものですね。
あとは問題1と一緒なので、三角形を描画するときに三角形の座標を2倍して描画すればOKです。
// 問題2のソース
var USE_MATRIX = true; // boolean ここを変更してください
var ANSWER_TYPE = 0; // 0 or 1 ここを変更すると説き方のタイプが変わります
var points = [
{ x: 80, y: 120 },
{ x: 160, y: 140 },
{ x: 120, y: 60 }
];
var ctx = $('canvas')[0].getContext('2d');
var i, len, inext;
if(USE_MATRIX) {// 行列を使用したプログラム
if(ANSWER_TYPE === 0) {// 移動してから2倍
var scale = MATRIX.scale(2, 2);
var trans = MATRIX.translate(-40, -40);
var matrix = MATRIX.multiply(scale, trans);
for(i = 0, len = points.length; i < len; i++) {
inext = (i + 1) % len;
line(MATRIX.multiplyVec(matrix, points[i]), MATRIX.multiplyVec(matrix, points[inext]));
}
} else {// 2倍してから移動
var scale = MATRIX.scale(2, 2);
var trans = MATRIX.translate(-80, -80);
var matrix = MATRIX.multiply(trans, scale);
for(i = 0, len = points.length; i < len; i++) {
inext = (i + 1) % len;
line(MATRIX.multiplyVec(matrix, points[i]), MATRIX.multiplyVec(matrix, points[inext]));
}
}
} else {// 行列を使用していないプログラム
if(ANSWER_TYPE === 0) {// 移動してから2倍
for(i = 0, len = points.length; i < len; i++) {
inext = (i + 1) % len;
line({x: (points[i].x - 40) * 2, y: (points[i].y - 40) * 2 },
{x: (points[inext].x - 40) * 2, y: (points[inext].y - 40) * 2 });
}
} else {// 2倍してから移動
for(i = 0, len = points.length; i < len; i++) {
inext = (i + 1) % len;
line({x: points[i].x * 2 - 80, y: points[i].y * 2 - 80 },
{x: points[inext].x * 2 - 80, y: points[inext].y * 2 - 80 });
}
}
}
function line(point0, point1) {
ctx.beginPath();
ctx.moveTo(point0.x, point0.y);
ctx.lineTo(point1.x, point1.y);
ctx.stroke();
}
あれ?待てよ?
拡大してから移動させちゃだめなのか?もちろんOKです。
2倍の拡大を先にした場合、移動量も2倍になります。
今回のプログラムでは問題1同様、行列を使用するかどうかのUSE_MATRIXというフラグと
ANSWER_TYPEというフラグで、解き方を変更できるようにしています。
移動してから2倍、2倍してから移動の行列を使用していないプログラムを比較すると
移動してから2倍
{x: (points[i].x - 40) * 2, y: (points[i].y - 40) * 2 },
2倍してから移動
{x: points[i].x * 2 - 40 * 2, y: points[i].y * 2 - 40 * 2 },
括弧を外しただけで、結局やってることは一緒ですね。
行列を使ったプログラムも比較してみます。
移動させてから2倍
var scale = MATRIX.scale(2, 2);
var trans = MATRIX.translate(-40, -40);
var matrix = MATRIX.multiply(scale, trans);
for(i = 0, len = points.length; i < len; i++) {
inext = (i + 1) % len;
line(MATRIX.multiplyVec(matrix, points[i]), MATRIX.multiplyVec(matrix, points[inext]));
}
2倍してから移動
var scale = MATRIX.scale(2, 2);
var trans = MATRIX.translate(-80, -80);
var matrix = MATRIX.multiply(trans, scale);
for(i = 0, len = points.length; i < len; i++) {
inext = (i + 1) % len;
line(MATRIX.multiplyVec(matrix, points[i]), MATRIX.multiplyVec(matrix, points[inext]));
}
上の2つのプログラムの行列同士を掛けるところのプログラムを見てみましょう。
var matrix = MATRIX.multiply(scale, trans);
var matrix = MATRIX.multiply(trans, scale);
という具合に異なっています。行列は書ける順番が大事で、
今のところは右から順番に掛けると覚えておきましょう。
数式で書くと
移動してから2倍は
\begin{pmatrix}
2& 0 & 0\\
0 & 2 & 0\\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
1& 0 & -40\\
0 & 1 & -40\\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
x\\
y\\
1
\end{pmatrix}
=
\begin{pmatrix}
2& 0 & -80\\
0 & 2 & -80\\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
x\\
y\\
1
\end{pmatrix}
=
\begin{pmatrix}
2x - 80\\
2y - 80\\
1
\end{pmatrix}
2倍してから移動は
\begin{pmatrix}
1& 0 & -80\\
0 & 1 & -80\\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
2& 0 & 0\\
0 & 2 & 0\\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
x\\
y\\
1
\end{pmatrix}
=
\begin{pmatrix}
2 & 0 & -80\\
0 & 2 & -80\\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
x\\
y\\
1
\end{pmatrix}
=
\begin{pmatrix}
2x - 80\\
2y - 80\\
1
\end{pmatrix}
当然ですが、計算結果は同じになりますね。これが行列の面白いところでもあります。
問題2のプログラムはこちらで動作可能です。78行目からお読みください。
問題3
下の三角形を青色の矩形で囲っている範囲がcanvasに合うように描画しなさい。
三角形の見た目は同じようにすること。(canvasにも上向きの三角形であること)
おっと、今度はY軸の向きが違いますね。
そのまま描画してしまうと、canvasとはY軸の向きが違うため、図形が反転してしまいますね。
よーく考えてみましょう。
// 問題3のソース
var USE_MATRIX = true; // boolean ここを変更してください
var ANSWER_TYPE = 0; // 0 or 1 ここを変更すると説き方のタイプが変わります
var points = [
{ x: 80, y: 80 },
{ x: 160, y: 60 },
{ x: 120, y: 140 }
];
var ctx = $('canvas')[0].getContext('2d');
var i, len, inext;
if(USE_MATRIX) {// 行列を使用したプログラム
if(ANSWER_TYPE === 0) {// 後で反転
var scale = MATRIX.scale(2, 2);
var trans = MATRIX.translate(-40, -40);
var invert = MATRIX.invertX();
var transY = MATRIX.translate(0, 240);
var matrix = MATRIX.multiplyArray([transY, invert, scale, trans]);
for(i = 0, len = points.length; i < len; i++) {
inext = (i + 1) % len;
line(MATRIX.multiplyVec(matrix, points[i]), MATRIX.multiplyVec(matrix, points[inext]));
}
} else {// 先に反転
var scale = MATRIX.scale(2, 2);
var trans = MATRIX.translate(-40, -40);
var invert = MATRIX.invertX();
var transY = MATRIX.translate(0, (40 * 2 + 120));
var matrix = MATRIX.multiplyArray([scale, trans, transY, invert]);
for(i = 0, len = points.length; i < len; i++) {
inext = (i + 1) % len;
line(MATRIX.multiplyVec(matrix, points[i]), MATRIX.multiplyVec(matrix, points[inext]));
}
}
} else {// 行列を使用していないプログラム
if(ANSWER_TYPE === 0) {// 後で反転
for(i = 0, len = points.length; i < len; i++) {
inext = (i + 1) % len;
line({x: (points[i].x - 40) * 2, y: ((points[i].y - 40) * 2) * (-1) + 240 },
{x: (points[inext].x - 40) * 2, y: ((points[inext].y - 40) * 2) * (-1) + 240 });
}
} else {// 先に反転
for(i = 0, len = points.length; i < len; i++) {
inext = (i + 1) % len;
line({x: (points[i].x - 40) * 2, y: (points[i].y * (-1) + (40 * 2 + 120) - 40) * 2 },
{x: (points[inext].x - 40) * 2, y: (points[inext].y * (-1) + (40 * 2 + 120) - 40) * 2 });
}
}
}
function line(point0, point1) {
ctx.beginPath();
ctx.moveTo(point0.x, point0.y);
ctx.lineTo(point1.x, point1.y);
ctx.stroke();
}
今回のプログラムも問題2同様、行列を使用するかどうかのUSE_MATRIXというフラグと
ANSWER_TYPEというフラグで、解き方を変更できるようにしています。
問題はY軸の方向が違うことですが、これはYに-1をかけることで解決します。
解き方1:後で反転
考え方としては、問題2と同じ変換をする。
このままだとX軸に反転してしまうので、Yに-1をかける。
最後にYの左上隅を原点に合わせる。
解き方2:先で反転
まずX軸に反転。
その後、青い矩形の左上隅を元の位置に合わせる。
あとは問題2と同じ変換をする。
数式で書くと
後で反転は
\begin{pmatrix}
1& 0 & 0\\
0 & -1 & 0\\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
1 & 0 & 0\\
0 & 1 & 240\\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
2& 0 & 0\\
0 & 2 & 0\\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
1& 0 & -40\\
0 & 1 & -40\\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
x\\
y\\
1
\end{pmatrix}
=
\begin{pmatrix}
1& 0 & 0\\
0 & -1 & 240\\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
2& 0 & -80\\
0 & 2 & -80\\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
x\\
y\\
1
\end{pmatrix}
=
\begin{pmatrix}
2& 0 & -80\\
0 & -2 & 320\\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
x\\
y\\
1
\end{pmatrix}
=
\begin{pmatrix}
2x - 80\\
-2y + 320\\
1
\end{pmatrix}
先に反転は
\begin{pmatrix}
2& 0 & 0\\
0 & 2 & 0\\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
1& 0 & -40\\
0 & 1 & -40\\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
1 & 0 & 0\\
0 & 1 & 200\\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
1& 0 & 0\\
0 & -1 & 0\\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
x\\
y\\
1
\end{pmatrix}
=
\begin{pmatrix}
2& 0 & -80\\
0 & 2 & -80\\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
1& 0 & 0\\
0 & -1 & 200\\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
x\\
y\\
1
\end{pmatrix}
=
\begin{pmatrix}
2& 0 & -80\\
0 & -2 & 320\\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
x\\
y\\
1
\end{pmatrix}
=
\begin{pmatrix}
2x - 80\\
-2y + 320\\
1
\end{pmatrix}
もちろん結果はおなじになります。
実はこの問題3で書いたプログラムで、canvasへの座標変換のプログラムが完成しました。
ユーザーが平行移動、拡大などのビュー操作をした場合、プログラム的には青い四角を再計算すればよいことになります。
問題3のプログラムはこちらで動作可能です。78行目からお読みください。
逆行列について
逆行列とはこのページで言うと、座標を変換前の座標に戻す行列のことを言います。
例えば、X方向に20移動する行列の逆行列はX方向に-20移動する行列のことです。
\begin{pmatrix}
1& 0 & 20\\
0 & 1 & 0\\
0 & 0 & 1
\end{pmatrix}
の逆行列は
\begin{pmatrix}
1& 0 & -20\\
0 & 1 & 0\\
0 & 0 & 1
\end{pmatrix}
です。
X方向に20移動する行列と2倍する行列の逆行列は
1/2倍する行列の逆行列はX方向に-20移動する行列と掛けたものになります。
掛ける順番も逆になりますので、ご注意下さい。
\begin{pmatrix}
2& 0 & 0\\
0 & 2 & 0\\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
1& 0 & 20\\
0 & 1 & 0\\
0 & 0 & 1
\end{pmatrix}
の逆行列は
\begin{pmatrix}
1& 0 & -20\\
0 & 1 & 0\\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
1/2 & 0 & 0\\
0 & 1/2 & 0\\
0 & 0 & 1
\end{pmatrix}
です。
ちなみに一般的に行列$M$の**逆行列は$M^{-1}$**と書きます。
$M$$M^{-1}$は単位行列になります。
canvasの行列について
実はcanvasにも行列があります。
今回あえてプログラムには使いませんでしたが、
詳しくはこちらを参照してください。
※1つだけ注意
transform(),やsetTranform()の引数ですが
\begin{pmatrix}
m0 & m2 & m4\\
m1 & m3 & m5\\
0 & 0 & 1
\end{pmatrix}
となっておりますので、使う際には注意してください。
#行列
メモ程度に載せておきます。
行列についての詳細は各自勉強して下さい。
行列同士の掛け算
\begin{pmatrix}
m0 & m1 & m2\\
m3 & m4 & m5\\
m6 & m7 & m8
\end{pmatrix}
\begin{pmatrix}
n0 & n1 & n2\\
n3 & n4 & n5\\
n6 & n7 & n8
\end{pmatrix}
=
\begin{pmatrix}
m0 * n0 + m1 * n3 + m2 *n6 & m3 * n0 + m4 * n3 + m5 *n6 & m6 * n0 + m7 * n3 + m8 *n6\\
m0 * n1 + m1 * n4 + m2 *n7 & m3 * n1 + m4 * n4 + m5 *n7 & m6 * n1 + m7 * n4 + m8 *n7\\
m0 * n2 + m1 * n5 + m2 *n8 & m3 * n2 + m4 * n5 + m5 *n8 & m6 * n2 + m7 * n5 + m8 *n8
\end{pmatrix}
行列とベクトルの掛け算
\begin{pmatrix}
m0 & m1 & m2\\
m3 & m4 & m5\\
m6 & m7 & m8
\end{pmatrix}
\begin{pmatrix}
x\\
y\\
1
\end{pmatrix}
=
\begin{pmatrix}
m0 * x + m1 * y + m2 * 1\\
m3 * x + m4 * y + m5 * 1\\
m6 * x + m7 * y + m8 * 1
\end{pmatrix}
単位行列
\begin{pmatrix}
1 & 0 & 0\\
0 & 1 & 0\\
0 & 0 & 1
\end{pmatrix}
平行移動行列
\begin{pmatrix}
1 & 0 & x\\
0 & 1 & y\\
0 & 0 & 1
\end{pmatrix}
拡大行列
\begin{pmatrix}
scalex & 0 & 0\\
0 & scaley & 0\\
0 & 0 & 1
\end{pmatrix}
回転行列
\begin{pmatrix}
cos\theta& -sin\theta & 0\\
sin\theta & cos\theta & 0\\
0 & 0 & 1
\end{pmatrix}
X軸反転行列
\begin{pmatrix}
1 & 0 & 0\\
0 & -1 & 0\\
0 & 0 & 1
\end{pmatrix}
Y軸反転行列
\begin{pmatrix}
-1 & 0 & 0\\
0 & 1 & 0\\
0 & 0 & 1
\end{pmatrix}
#行列のプログラム
今回問題解く際に使用したmatrix.jsを載せておきます。
var MATRIX = (function() {
'use strict';
// 行列の積
var _multiply = function(m1, m2) {
return [
m1[0] * m2[0] + m1[1] * m2[3] + m1[2] * m2[6],
m1[0] * m2[1] + m1[1] * m2[4] + m1[2] * m2[7],
m1[0] * m2[2] + m1[1] * m2[5] + m1[2] * m2[8],
m1[3] * m2[0] + m1[4] * m2[3] + m1[5] * m2[6],
m1[3] * m2[1] + m1[4] * m2[4] + m1[5] * m2[7],
m1[3] * m2[2] + m1[4] * m2[5] + m1[5] * m2[8],
m1[6] * m2[0] + m1[7] * m2[3] + m1[8] * m2[6],
m1[6] * m2[1] + m1[7] * m2[4] + m1[8] * m2[7],
m1[6] * m2[2] + m1[7] * m2[5] + m1[8] * m2[8]
];
},
// 行列をまとめて掛け算する
_multiplyArray = function(matArray) {
var mat = _identify();
matArray.forEach(function(el) { mat = _multiply(mat, el); });
return mat;
},
// 行列とベクトルの積(戻り値はベクトル)
_multiplyVec = function(m, v) {
return {
x: m[0] * v.x + m[1] * v.y + m[2],
y: m[3] * v.x + m[4] * v.y + m[5]
};
},
// 単位行列を作成する
_identify = function() {
return [1, 0, 0, 0, 1, 0, 0, 0, 1];
},
// 平行移動行列を作成する
_translate = function(x, y) {
return [1, 0, x, 0, 1, y, 0, 0, 1];
},
// 拡大行列を作成する
_scale = function(x, y) {
return [x, 0, 0, 0, y, 0, 0, 0, 1];
},
// 回転行列を作成する(angleはラジアンで指定すること)
_rotate = function(angle) {
return [Math.cos(angle), -Math.sin(angle), 0, Math.sin(angle), Math.cos(angle), 0, 0, 0, 1];
},
// X軸反転
_invertX = function() {
return [1, 0, 0, 0, -1, 0, 0, 0, 1];
},
// Y軸反転
_invertY = function() {
return [-1, 0, 0, 0, 1, 0, 0, 0, 1];
};
return {
multiply: _multiply,
multiplyArray: _multiplyArray,
multiplyVec: _multiplyVec,
identify: _identify,
translate: _translate,
scale: _scale,
rotate: _rotate,
invertX: _invertX,
invertY: _invertY
};
}());
#最後に
2Dや3Dの描画用のライブラリは沢山ありますが、
今回ご紹介しました行列の知識・理解がある人とそうでない人には
同じライブラリを使うとしても雲泥の差が出てくるものと思われます。
行列は無理して使うことはありませんが、
使えるケースでは積極的に使うようにした方がよいと思います。
間違いなどありましたら、コメントでご指摘ください。
2020/03/05 追記: 続編的な記事を書きました。
2020/03/09 追記: さらに続編的な記事を書きました。