Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
26
Help us understand the problem. What is going on with this article?
@PG0721

【html/canvas】問題を解きながら座標変換や行列を学ぼう【JavaScript】

More than 1 year has passed since last update.

問題を解きながら、座標変換や行列を学ぼう

行列と座標変換って聞くだけでも嫌な人いるんじゃないでしょうか?
そこで今回は座標変換の問題を、なるべく専門用語を使わず分かりやすく説明したいと思います。

2020/03/05 追記: 続編的な記事を書きました。
2020/03/09 追記: さらに続編的な記事を書きました。

各問題に共通のcanvas

各問題共通でcanvasの幅は360,高さは240とします。

index.html
<canvas width="360" height="240"></canvas>

common_canvas.png
ちなみにcanvasは右向きにX+,下向きにY+です。

問題1

下の三角形を青色の矩形で囲っている範囲がcanvasに合うように描画しなさい。
new1.png

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というフラグ切り替えられるようにしています。

プログラムによる出力
answer1.png

正しく表示されているようですね。

行列を使用しているプログラムを見てみましょう。

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に合うように描画しなさい。
new2.png

今度は原点(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というフラグで、解き方を変更できるようにしています。

プログラムによる出力
answer1.png
今回も正しく表示されているようです。

移動してから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にも上向きの三角形であること)
new3.png

おっと、今度は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というフラグで、解き方を変更できるようにしています。

プログラムによる出力
answer1.png
今回も正しく表示されているようです。

問題はY軸の方向が違うことですが、これはYに-1をかけることで解決します。

解き方1:後で反転
考え方としては、問題2と同じ変換をする。
このままだとX軸に反転してしまうので、Yに-1をかける。
最後にYの左上隅を原点に合わせる。

q3_2.png

解き方2:先で反転
まずX軸に反転。
その後、青い矩形の左上隅を元の位置に合わせる。
あとは問題2と同じ変換をする。

q3_1.png

数式で書くと
後で反転

\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 追記: さらに続編的な記事を書きました。

26
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
PG0721
最近は機械学習、統計、データ解析に興味があります。

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
26
Help us understand the problem. What is going on with this article?