目次

WebGLFundamentals.org

WebGL三次元でカメラ

この記事はWebGLシリーズのーつである。最初の記事はWebGLの基本で始まった。 そして、前回の記事は透視投影についてだった。まだ読んでいなかったら先に読んで下さい。

前回の記事で、m4.perspectiveの関数が原点(0,0,0)から-zNear-zFarの空間を描画するので、 「F」をその錘台の中に移動しなければいけなかった。

現実の世界でカメラを移動して、撮影したい物(ビル、山、森)に向ける。

カメラを物に移動する。

現実の世界で何かを撮影したい場合、その物をカメラの前に移動することはほとんどない。

物をカメラに移動する。

でも前回の記事で出来た透視投影が原点から-Zの方に向いていた。 今回の目的を達成する為にカメラを原点に移動して、他のものを全てカメラの移動と連動して相対的に移動しなければいけない。

物をビューに移動する。

その結果を出すためにワールドをカメラの前に移動するようにしなければいけない。 それに逆行列を使えばいい。逆行列を計算する方法は複雑だが、コンセプトは簡単である。 逆行列はある行列を否定する行列である。例えば123の逆は-123である。5倍拡大行列の逆行列は 0.2縮小行列である。X軸で30度に回転行列の逆行列はX軸で-30度の行列である。

今まで「F」の位置と向きを調整するために移動、回転、拡大縮小行列を使った。 全部掛け合わせたら、その移動、回転、スケールが一つの行列で表している。 カメラでも同じように出来る。カメラを原点から好きな位置に移動して、 好きな向きに回転する行列が出来たら、その行列の逆行列を計算して、 その逆行列でカメラが原点から-Zの方に向いて、表示したいものを全てカメラの前に移動出来る。

上の「F」の円のような三次元シーンを作成しよう。

まず、5つの物を描画して、全部同じ投影行列を使うので、ループの前に透視投影行列を計算する。


// 透視投影行列を計算する。
var aspect = gl.canvas.clientWidth / gl.canvas.clientHeight;
var zNear = 1;
var zFar = 2000;
var projectionMatrix = m4.perspective(fieldOfViewRadians, aspect, zNear, zFar);

次にカメラの行列を計算する。この行列はカメラの位置と向きを表している。 下記のコードは向きが常に原点で、原点からradiusを1.5掛けの距離で回転している行列を作成する。

カメラの動き

var numFs = 5;
var radius = 200;

// カメラ行列を計算する。
var cameraMatrix = m4.yRotation(cameraAngleRadians);
cameraMatrix = m4.translate(cameraMatrix, 0, 0, radius * 1.5);

そして、カメラ行列からビュー行列を計算する。 ビュー行列はある物を全てカメラの前に移動する行列である。カメラは原点にあるとして、 他のものがカメラと相対しているように移動出来るような行列である。 inverseという関数を使ってカメラ行列の逆行列計算が出来る。

この場合、カメラ行列は原点を基点とした位置と向きでカメラが移動する行列である。 その行列をinverse関数に与えて、 出た逆行列はカメラを原点にして、他の物がカメラと相対的に移動する。

// カメラ行列からビュー行列を作成する。
var viewMatrix = m4.inverse(cameraMatrix);

そして、ビュー行列と投影行列を組み合わせてビュー投影行列を作成する。

// ビュー投影行列を計算する。
var viewProjectionMatrix = m4.multiply(projectionMatrix, viewMatrix);

最後に、「F」の円を描画する。「F」ごとにビュー投影行列から始まって、そして回転して、radius単位で外側へ移動する。

for (var ii = 0; ii < numFs; ++ii) {
  var angle = ii * Math.PI * 2 / numFs;
  var x = Math.cos(angle) * radius;
  var y = Math.sin(angle) * radius

  // ビュー投影行列から「F」の行列を計算する。
  var matrix = m4.translate(viewProjectionMatrix, x, 0, y);

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

  // 図形を描画する。
  var primitiveType = gl.TRIANGLES;
  var offset = 0;
  var count = 16 * 6;
  gl.drawArrays(primitiveType, offset, count);
}

「F」の円の周りを回転しているカメラの出来上がり!カメラ角度のスライダでカメラを移動してみて下さい。

それで良しとすることも出来るけど、移動と回転でカメラを目標に向かせることはあまり簡単ではないような気がする。 例えば一つの特定の「F」を目指したければ、「F」の円の回りに回っているカメラが特定の「F」に向ける計算はウンザリするものかもしれない。

幸いにも、もっと簡単な方法がある。カメラの位置を好きにして、目標も好きにして、そのデータで行列の計算が出来る。 行列の動き方で驚くほど簡単である。

まず、カメラの位置を決める。それは「cameraPosition」と呼ぶ。そして、目標を位置も決める。 それは「 target」と呼ぶ。cameraPositionからtargetを引くとカメラから目標を目指しているベクトルが出る。それはzAxisと呼ぼう。 カメラは-Zの方向に向かうので逆に計算しよう「cameraPosition - target」。その結果をノーマライズする。それを行列のZの部分に直接入れる。

+----+----+----+----+
|    |    |    |    |
+----+----+----+----+
|    |    |    |    |
+----+----+----+----+
| Zx | Zy | Zz |    |
+----+----+----+----+
|    |    |    |    |
+----+----+----+----+

この行列部分はZ軸を表している。この場合はカメラのZ軸になっている。 ベクトルをノーマライズするというのは1.0を表しているベクトルにするという意味である。 以前の二次元回転についての記事へ戻ったら, 単位円で二次元回転が出来た。三次元なら単位球体が必要で、 ノーマライズをされたベクトルは単位球体の表面の位置を表している。

z軸

それだけでは情報が足りない。一つのベクトルだけで単位球体表面の一つにポイントが分かってくるが、 そのポイントからどの傾きにしたらいいかまだ分からない。他の行列の部分に何かを入れなきゃ。 特にX軸とY軸の部分に何か入れる必要がある。三次元行列の軸はほとんどお互いに垂直の関係であることもう知っている。 その上、カメラは普段真上を目指さないことも知っている。なので、上の方向が分かれば、今の場合(0,1,0)、 「外積」という計算でX軸とY軸を計算出来る。

数学としての外積の意味は全然分からないが、 分かっているところは2つの単位ベクトルを外積すると、その2つのベクトルに垂直なベクトルが出るということだ。 例えば、南東を目指しているベクトルと上を目指しているベクトルを外積すると、南西か北東を目指しているベクトルが出る。 その2つは南東と上に垂直になっているからだ。外積する順番によって逆の結果が出る。

いずれにせよ、z軸を外積するとカメラのx軸が出る。

z軸の外積 = x軸

x軸が出来たらz軸x軸を外積したらy軸を計算出来る。

z軸x軸の外積 = y軸

最後に三つの軸を行列に入れなきゃ。その行列はcameraPositionからtargetを目指すと同じような向きになる。 ただpositionも追加して、cameraPositionからtargetを目指す行列になる。

+----+----+----+----+
| Xx | Xy | Xz |  0 |  <- x軸
+----+----+----+----+
| Yx | Yy | Yz |  0 |  <- y軸
+----+----+----+----+
| Zx | Zy | Zz |  0 |  <- z軸
+----+----+----+----+
| Tx | Ty | Tz |  1 |  <- カメラ位置
+----+----+----+----+

これは2つのベクトルの外積計算法である。

function cross(a, b) {
  return [a[1] * b[2] - a[2] * b[1],
          a[2] * b[0] - a[0] * b[2],
          a[0] * b[1] - a[1] * b[0]];
}

これは2つのベクトルの引き算のコードである。

function subtractVectors(a, b) {
  return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
}

これはベクトルをノーマライズするコードである。(単位ベクトルにする)

function normalize(v) {
  var length = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
  // 0で割らないようにする。
  if (length > 0.00001) {
    return [v[0] / length, v[1] / length, v[2] / length];
  } else {
    return [0, 0, 0];
  }
}

これは”lookAt”(目指す)行列を計算するコードである。

var m4 = {
  lookAt: function(cameraPosition, target, up) {
    var zAxis = normalize(subtractVectors(cameraPosition, target));
    var xAxis = normalize(cross(up, zAxis));
    var yAxis = normalize(cross(zAxis, xAxis));

    return [
       xAxis[0], xAxis[1], xAxis[2], 0,
       yAxis[0], yAxis[1], yAxis[2], 0,
       zAxis[0], zAxis[1], zAxis[2], 0,
       cameraPosition[0],
       cameraPosition[1],
       cameraPosition[2],
       1,
    ];
  }

そして、これはカメラを移動しながら、特定の「F」を目指す方法の一つである。

  ...

  // 最初の「F」の位置を計算する。
  var fPosition = [radius, 0, 0];

  // 行列数学でカメラを円の回りの位置を計算する。
  var cameraMatrix = m4.yRotation(cameraAngleRadians);
  cameraMatrix = m4.translate(cameraMatrix, 0, 0, radius * 1.5);

  // 行列からカメラの位置を取る。
  var cameraPosition = [
    cameraMatrix[12],
    cameraMatrix[13],
    cameraMatrix[14],
  ];

  var up = [0, 1, 0];

  // ”lookAt"でカメラ行列を計算する。
  var cameraMatrix = m4.lookAt(cameraPosition, fPosition, up);

  // カメラの行列でヴュー行列を作成する。
  var viewMatrix = m4.inverse(cameraMatrix);

  ...

これはその結果である。

スライダを操作して、カメラは特定の「F」を狙っていることに注目しよう。

lookAtの関数はカメラ以上に色々なことで使える。 通常使う場合はあるキャラクターの顔が別のキャラクターを追いかけることとか。 旋回砲塔を目標に向けることとか。オブジェクトが通路通りにすすで行くこととか。 そのオブジェクトが通路の何処にあるかを計算して、 その直後通路に何処に存在するかを計算して、その2つの位置をlookAtの関数に入れたら、 そのオブジェクトが通路を進んでいく行列が出る。そうするとオブジェクトがただしい方向で通路の先に向かう。

次にアニメーションを習おう.

lookAtの規格

通常三次元数学ライブラリにはlookAt関数がある。 それは大抵カメラ行列ではなく、ビュー行列を作成するの為に作られた。 つまり、カメラを移動して向きを決める行列じゃなくて、他の物を全てカメラの前に移動する行列を作る。

それはあまり便利じゃないような気がする。 以前に指摘したようにlookAtのような関数は多くの事に利用出来る。 ビュー行列が必要な場合、簡単にinverseを呼び出せる。 でも、キャラクタの頭を他のキャラクタに追いかけさせる時とか、旋回砲塔を目標に狙わせる時とか、オブジェクトを移動させて、 向きを設定させる行列を作成するlookAt関数の方が便利だと個人的に思う。

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