目次

WebGLFundamentals.org

WebGL二次元行列数学

本記事はWebGLシリーズの一つである。最初の記事は WebGLの基本で始まった。そして、前回の記事は 二次元図形の拡大と縮小についての記事である

前回の3つの記事で図形の移動, 回転, と 拡大と縮小について取り上げた。 移動、回転、拡大/縮小は全部変換である。 変換ごとにシェーダーの編集が必要であった。その上変換順番もお互いに依存していた。 前回のサンプルで拡大してから、回転して、最後移動した。 別の順番なら違う結果が出る。

例えばこれは「2,1」の拡大、30度の回転、そして「100,0」の移動である。

そして、これは「100,0」の移動、30度の回転、「2,1」の拡大である。

結果が全然違う。さらに悪い点は二番目の結果が欲しければ、その順番に変換を掛ける別のシェーダーを書かなければいけないことである。

私よりずいぶん頭がいい人のお陰で、それを全部行列数学で出来る方法を開発した。 二次元の場合3x3の行列を使う。3x3行列は9個のグリッドのようなことである。

1.02.03.0
4.05.06.0
7.08.09.0

行列数学で計算するためにpositionを行列の桁に掛けて、結果を加える。 二次元で2つの値しかない(xとy)だがWebGLは2x3の行列がないので3番目の値に1を使う。

この場合の結果はこれである。

newX = x * 1.0 +newY = x * 2.0 +extra = x * 3.0 +
y * 4.0 +y * 5.0 + y * 6.0 +
1 * 7.0 1 * 8.0  1 * 9.0 

これを見ると「なんの意味があるの?」と考えているだろう? じゃあ, 移動の距離があるとしよう。移動距離は「tx」と「ty」と呼ぶことにする。 このような行列を作ろう。

1.00.00.0
0.01.00.0
txty1.0

チェックしてみよう

newX = x * 1.0 +newY = x * 0.0 +extra = x * 0.0 +
y * 0.0 +y * 1.0 + y * 0.0 +
1 * tx 1 * ty  1 * 1.0 

代数学を思い出せば0に掛ける所を削除出来る。1に掛けたら何も変わらないので単純化してみよう。

newX = x * 1.0 +newY = x * 0.0 +extra = x * 0.0 +
y * 0.0 +y * 1.0 + y * 0.0 +
1 * tx 1 * ty  1 * 1.0 

それとももっと簡潔に

newX = x + tx;
newY = y + ty;

「extra」の部分を無視していい。これは移動サンプルの移動のし方に結構似てるだろう?

同じように回転もしよう。 回転の記事で書いたように回転角度の正弦(sine) と余弦(cosine)しか要らない。。。

s = Math.sin(angleToRotateInRadians);
c = Math.cos(angleToRotateInRadians);

そしてこのように行列を作る。

c-s0.0
sc0.0
0.00.01.0

positionに行列を適用して

newX = x * c +newY = x * -s +extra = x * 0.0 +
y * s +y * c + y * 0.0 +
1 * 0.0 1 * 0.0  1 * 1.0 

0と1に掛けている所を消したらこれになる。

newX = x * c +newY = x * -s +extra = x * 0.0 +
y * s +y * c + y * 0.0 +
1 * 0.0 1 * 0.0  1 * 1.0 

そして単純化すると

newX = x *  c + y * s;
newY = x * -s + y * c;

これは回転サンプルと全く同じである。

最後は拡大もしよう。拡大の値は「sx」と「sy」と呼ぶことにする。

このように行列を作って

sx0.00.0
0.0sy0.0
0.00.01.0

行列を適用したら

newX = x * sx +newY = x * 0.0 +extra = x * 0.0 +
y * 0.0 +y * sy + y * 0.0 +
1 * 0.0 1 * 0.0  1 * 1.0 

それは実はこれである。

newX = x * sx +newY = x * 0.0 +extra = x * 0.0 +
y * 0.0 +y * sy + y * 0.0 +
1 * 0.0 1 * 0.0  1 * 1.0 

単純化されたらこう

newX = x * sx;
newY = y * sy;

拡大/縮小サンプルと同じである。

まだ「前より大変じゃないが!複雑でどういう意味があるの?」と思っているだろう。

ここで魔法のようなことが起こる。複数行列を掛け合わせたら全部の変換に適用出来る。 m3.multiplyという2つの行列を掛けて、結果の返り値を戻る関数があるとしよう。

分かり易くするために移動、回転、拡大の行列の制作関数を作ろう。

var m3 = {
  // 移動行列
  translation: function(tx, ty) {
    return [
      1, 0, 0,
      0, 1, 0,
      tx, ty, 1,
    ];
  },

  // 回転行列
  rotation: function(angleInRadians) {
    var c = Math.cos(angleInRadians);
    var s = Math.sin(angleInRadians);
    return [
      c,-s, 0,
      s, c, 0,
      0, 0, 1,
    ];
  },

  // 拡大行列
  scaling: function(sx, sy) {
    return [
      sx, 0, 0,
      0, sy, 0,
      0, 0, 1,
    ];
  },
};

シェーダーを変更しよう。前回のシェーダーはこれである。

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform vec2 u_resolution;
uniform vec2 u_translation;
uniform vec2 u_rotation;
uniform vec2 u_scale;

void main() {
  // Scale the position
  vec2 scaledPosition = a_position * u_scale;

  // Rotate the position
  vec2 rotatedPosition = vec2(
     scaledPosition.x * u_rotation.y + scaledPosition.y * u_rotation.x,
     scaledPosition.y * u_rotation.y - scaledPosition.x * u_rotation.x);

  // Add in the translation.
  vec2 position = rotatedPosition + u_translation;
  ...

新しいシェーダーはそれよりずいぶん簡単である。

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform vec2 u_resolution;
uniform mat3 u_matrix;

void main() {
  // Multiply the position by the matrix.
  vec2 position = (u_matrix * vec3(a_position, 1)).xy;
  ...

そして、このように使う。

  // シーンを描画する。
  function drawScene() {

    ,,,

    // 行列を計算する。
    var translationMatrix = m3.translation(translation[0], translation[1]);
    var rotationMatrix = m3.rotation(angleInRadians);
    var scaleMatrix = m3.scaling(scale[0], scale[1]);

    // 行列を掛け合わせる。
    var matrix = m3.multiply(translationMatrix, rotationMatrix);
    matrix = m3.multiply(matrix, scaleMatrix);

    // シェーダーの行列ユニフォームを設定する。
    gl.uniformMatrix3fv(matrixLocation, false, matrix);

    // 図形を描画する。
    var primitiveType = gl.TRIANGLES;
    var offset = 0;
    var count = 18;  // 6三角形、三角形ごとに3頂点
    gl.drawArrays(primitiveType, offset, count);
  }

これは最新コードを使っているサンプルである。 移動、回転、拡大のスライダは前回と同じである。 だが、シェーダーで使われている方法はもっと簡単になった。

まだ「で?それは便利か?」と思っているだろう?でも、順番を変更したければシェーダーを更新しなくていい。 だだ計算式だけ更新したらいい。

    ...
    // 行列を掛ける。
    var matrix = m3.multiply(scaleMatrix, rotationMatrix);
    matrix = m3.multiply(matrix, translationMatrix);
    ...

これはそのバージョンである。

人体の腕とか、太陽の周りを回っている惑星を回っている月とか、木の枝などの階層的なアニメーション のためにこのように行列を適用することは特に重要である。例えば、階層的なアニメーションの例として、 「F」を5回描画して、「F」ごとに前の「F」の行列から始めよう。

  // シーンを描画する。
  function drawScene() {
    // キャンバスをクリアーする。
    gl.clear(gl.COLOR_BUFFER_BIT);

    // 行列を計算する。
    var translationMatrix = m3.translation(translation[0], translation[1]);
    var rotationMatrix = m3.rotation(angleInRadians);
    var scaleMatrix = m3.scaling(scale[0], scale[1]);

    // 最小の行列
    var matrix = m3.identity();

    for (var i = 0; i < 5; ++i) {
      // 行列を掛ける。
      matrix = m3.multiply(matrix, translationMatrix);
      matrix = m3.multiply(matrix, rotationMatrix);
      matrix = m3.multiply(matrix, scaleMatrix);

      // 行列を設定する。
      gl.uniformMatrix3fv(matrixLocation, false, matrix);

      // 図形を描画する。
      var primitiveType = gl.TRIANGLES;
      var offset = 0;
      var count = 18;  // 6三角形、三角形ごとに3頂点
      gl.drawArrays(primitiveType, offset, count);
    }
  }

そのためにm3.identityという単位行列の作成する関数を追加した。単位行列は 「1.0」と同じように何かに掛けても何も変わらない。このようなことで

X * 1 = X

行列でも同じようになる。

matrixX * identity = matrixX

単位行列の作成コードはここにある。

var m3 = {
  identity function() {
    return [
      1, 0, 0,
      0, 1, 0,
      0, 0, 1,
    ];
  },

  ...

5つの「F」はこれである。

もう一つのサンプルを見よう。 今までのサンプルは全て、「F」の左上角を軸として回転している(上にある順番を変えたサンプル以外)。 それは、使っている計算式は原点軸を中心として周りを回転するものだから。「F」の左上の頂点は原点(0,0)にある。

だが今は、行列数学を使っているから行列掛ける順番を選べる。それで原点を移動出来る。

    // 「F」の原点に「F」の真ん中に移動する行列を作成する。
    var moveOriginMatrix = m3.translation(-50, -75);
    ...

    // 行列を掛ける。
    var matrix = m3.multiply(translationMatrix, rotationMatrix);
    matrix = m3.multiply(matrix, scaleMatrix);
+    matrix = m3.multiply(matrix, moveOriginMatrix);

これは「F」の中心で回転と拡大するサンプルである。

この方法を使うとどの位置からでも回転と拡大縮小が出来る。 フォトショップがどういうふうに好きな点で回転する機能を作ったか分かるようになって来た!

もっとクレージにしよう!最初の記事「WebGLの基本」を思い出したら、このピクセル空間からクリップ空間に 計算しているシェーダーコードがあった。

  ...
 // positionはピクセルから0〜1に
  vec2 zeroToOne = position / u_resolution;

  // 0〜1から0〜2に
  vec2 zeroToTwo = zeroToOne * 2.0;

  // 0〜2から-1〜+1に(クリップ空間)
  vec2 clipSpace = zeroToTwo - 1.0;

  gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);

そのコードのステップを順番に見よう。ステップ1「positionはピクセルから0〜1に」は 実は拡大縮小の変換である。ステップ2「0〜1から0〜2に」も拡大の変換である。 ステップ3は移動の変換で、最後のステップは−1にサイズ変換である。それは全てシェーダーに 渡す行列で出来る。ーつの1.0/resolutionの縮小変換行列を作成して、もう一つ2.0に拡大行列を作成して、 「-1,-1」で移動行列作成して、Yに-1をサイズ拡大縮小行列を作成して、全部掛け合わせることが出来るが、 その数学は簡単なのでその結果の行列を作る関数を作ろう。

var m3 = {
  projection: function(width, height) {
    // Note: Y軸で0は上の方にするためYを弾く行列
    return [
      2 / width, 0, 0,
      0, -2 / height, 0,
      -1, 1, 1
    ];
  },

  ...

そうすれば、シェーダーコードをもっと単純化出来る。これは最新のシェーダーの全て

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform mat3 u_matrix;

void main() {
  // positionを行列に掛ける。
  gl_Position = vec4((u_matrix * vec3(a_position, 1)).xy, 0, 1);
}
</script>

そしてJavaScriptで行列をprojection行列に掛けることが必要である。

  // シーンを描画する。
  function drawScene() {
    ...

    // 行列を計算する。
    var projectionMatrix = m3.projection(
        gl.canvas.clientWidth, gl.canvas.clientHeight);

    ...

    // 行列を掛ける。
    var matrix = m3.multiply(projectionMatrix, translationMatrix);
    matrix = m3.multiply(matrix, rotationMatrix);
    matrix = m3.multiply(matrix, scaleMatrix);

    ...
  }

解像度「resolution」を指定するコードも消した。この最後の更新で、6〜7ステップもあるシェーダーから、 たった一つのステップのシェーダーに辿り着いた。行列数学のお陰で凄くシンプルになった。

次の記事へ行く前にもちょっと単純化しよう。複数行列を作成して、その後お互いに掛けることは珍しくないけど、 作成しながら掛けることも通常の方法である。このような関数を作ろう。

var m3 = {

  ...

  translate: function(m, tx, ty) {
    return m3.multiply(m, m3.translation(tx, ty));
  },

  rotate: function(m, angleInRadians) {
    return m3.multiply(m, m3.rotation(angleInRadians));
  },

  scale: function(m, sx, sy) {
    return m3.multiply(m, m3.scaling(sx, sy));
  },

  ...

};

その関数を使ったら前にある行列計算コード7行をこのような4行にできる。

// 行列を計算する。
var matrix = m3.projection(gl.canvas.clientWidth, gl.canvas.clientHeight);
matrix = m3.translate(matrix, translation[0], translation[1]);
matrix = m3.rotate(matrix, angleInRadians);
matrix = m3.scale(matrix, scale[0], scale[1]);

これはそのバージョンである。

もう一つのこと、。。。上記の頭で話したように順番が大事だ。最初のサンプルで

移動 * 回転 * 拡大

その後これがあった。

拡大 * 回転 * 移動

その2つの方法の違いを見た。

行列数学の考える方法はこのようである。クリップ空間から始まる。行列ごとに行列を適用するとその空間が変更される。

ステップ1: 行列無し(それとも単位行列)

クリップ空間

白い部分はキャンバスで、青い部分はキャンバス以外である。 これはクリップ空間。シェーダーに与える座標はクリップ空間で与える。

ステップ2: matrix = m3.projection(gl.canvas.clientWidth, gl.canvas.clientHeight);

クリップ空間からピクセル空間へ

ピクセル空間になった。 X=0〜400,Y=0〜300で、0,0は左上である。 座標はピクセル空間で与えなければいけない。フラッシュは+1=上から+1=下Y軸を繰り返する時である。

ステップ3: matrix = m3.translate(matrix, tx, ty);

原点をtx, tyに移動

原点は「tx, ty」になった。(空間は移動された。)

ステップ4: matrix = m3.rotate(matrix, rotationInRadians);

33°を回転

空間は「tx, ty」を中心に回転された。

ステップ5: matrix = m3.scale(matrix, sx, sy);

空間を拡大

「tx, ty」を中心に回転された空間は拡大された。

ステップ6: シェーダーでgl_Position = matrix * position;にした。そのpositionの座標はその最後の空間にある。

この行列説明は分かり易かったかな〜〜〜。まだ二次元のことに興味があればキャンバスAPIのdrawImage関数を再作成の記事はお奨めである。そしてキャンバスの二次元行列スタックを再作成という記事も。

それでは、次は三次元へ進もう。三次元の行列数学の原則は2次元と同じである。 二次元の方が分かり易いのでそこから始めた。

clientWidthclientHeightは何?

今までキャンバスのサイズを参照した時canvas.widthcanvas.height を使ったが、上記でm3.projectionを呼び出した時その変わりにcanvas.clientWidthcanvas.clientHeightを使った。なぜだろう?

投影行列はクリップ空間(−1〜+1)からピクセル空間の変換であるが、ブラウザでピクセル空間が2つある。 一つはキャンバスの解像度である。例えばこのように定義されたキャンバス

  <canvas width="400" height="300"></canvas>

あるいはこのように定義されたキャンバス

  var canvas = document.createElement("canvas");
  canvas.width = 400;
  canvas.height = 300;

これら両方の横は400ピクセルで縦は300ピクセルである。でも、キャンバスの表示サイズは別に定義されている。 CSSで表示サイズを定義する。例えばこのようにキャンバスを作成したら


  <style>
  canvas {
    width: 100vw;
    height: 100vh;
  }
  </style>
  ...
  <canvas width="400" height="300"></canvas>

キャンバスはウインドウサイズと同じサイズで表示される。それはほとんど400x300じゃないだろう。

ここにキャンバスの表示サイズをページと同じサイズにするサンプルが2つである。最初のサンプルは canvas.widthcanvas.height。別のウインドを開いて、ウインドのサイズを変更してみよう。「F」の縦横の比率がよくないことに注目して、

この二番目のサンプルでcanvas.clientWidthcanvas.clientHeightを使う。 canvas.clientWidthcanvas.clientHeightはブラウザに実際に表示されているサイズである。それを使うと「F」の縦横比率がよくなる。

ウインドのサイズに合わせるアプリはcanvas.widthcanvas.heightcanvas.clientWidthcanvas.clientHeightに設定する。表示しているピクセルごとに一つのキャンバスピクセルにして欲しいから。でも上記のサンプルを見て、それは一般的なケースではない。なので、投影行列の場合canvas.clientHeightcanvas.clientWidthを使う方がもっと正しいかも。

質問? stackoverflowで質問(英語).
問題点/バグ? githubでissueを作成.
comments powered by Disqus