目次

WebGLFundamentals.org

WebGL三次元指向性光源

この記事はWebGLのシリーズの一つである。最初の記事はWebGLの基本についてだった。 前回の記事は三次元カメラについてだった。まだ読んでいなかったら先に読んで下さい。

照明の計算のし方が色々ある。多分一番簡単な方法は指向性光源である。

指向性光源なら光がひとつの方向均一に進んでいく流れていく。 晴れている日の太陽は指向性光源だと考えられている。 太陽は相当遠いから光線が全て平行進んで、あるオブジェクトに当っているようである。

指向性光源の計算は結構簡単である。光線の方向が分かって、オブジェクトの表面の向きも分かっていれば、 その2つの方向の内積(dot product)を計算すると、その2つの方向の間の角度の余弦が出る。

これは例である。

頂点を移動してみて

頂点を移動して、お互いに真逆の方向にすると内積は-1になる。同じ方向に近づけると内積は1になる。

それがどういうふうに便利なのか?さあ、オブジェクトの表面の方向と光線の方向の両方が分かれば、 その2つの方向の内積を計算すると、照明が表面に向いている場合1になる。反対を向いている場合-1になる。

方向を回転してみて

表面の色を内積に掛けると照明になる!

まだ一つの問題がある。三次元のオブジェクトの表面方向がどういうふうに分かるか。

法線ベクトルの登場

法線ベクトルは方向を表している。今回三次元のオブジェクトの表面の向きのために使う。

これは四角柱と球体の法線ベクトルである。

オブジェクトから出ている線は頂点ごとの法線ベクトルを表している。

四角柱の場合、角ごとに法線ベクトルが三本あることに注目して下さい。 それはその三つの表面が接しているので、三の法線ベクトルが必要である。

Notice the cube has 3 normals at each corner. That's because you need 3 different normals to represent the way each face of the cube is um, .. facing.

上の図形で法線ベクトルの色は方向を表している。 +Xは, 上は 、そして+Zは

照明する為に前回の記事のFに法線ベクトルを追加しよう! Fは四角柱と同じように表面がXとYとZ軸と同調しているので結構簡単である。 前を向いている面の法線ベクトルは0, 0, 1である。裏を向いている面の法線ベクトル0, 0, -1である。 左を向いている法線ベクトルは-1, 0, 0である。右は1, 0, 0、上は0, 1, 0、下は0, -1, 0である。

function setNormals(gl) {
  var normals = new Float32Array([
          // 前面の左縦列
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,

          // 前面の上の横棒
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,

          // 前面の中の横棒
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,
          0, 0, 1,

          // 裏面の左縦列
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,

          // 裏面の上の横棒
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,

          // 裏面の中の横棒
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,
          0, 0, -1,

          // 上面
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,

          // 上横棒の上面
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,

          // 上横棒の下面
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,

          // 上横棒と中横棒の間面
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,

          // 中横棒の上面
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,
          0, 1, 0,

          // 中横棒の右面
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,

          // 中横棒の下面
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,

          // 下の部分の右面
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,
          1, 0, 0,

          // 下面
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,
          0, -1, 0,

          // 左面
          -1, 0, 0,
          -1, 0, 0,
          -1, 0, 0,
          -1, 0, 0,
          -1, 0, 0,
          -1, 0, 0,
  ]);
  gl.bufferData(gl.ARRAY_BUFFER, normals, gl.STATIC_DRAW);
}

そしてそれをセットアップする。その間照明を分かり易くする為に頂点の色を抜く。

// データはどれの属性に与えなければいけないかを調べる。
var positionLocation = gl.getAttribLocation(program, "a_position");
-var colorLocation = gl.getAttribLocation(program, "a_color");
+var normalLocation = gl.getAttribLocation(program, "a_normal");

...

-// 色のバッファーの作成。
-var colorBuffer = gl.createBuffer();
-gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
-// バッファーに色を入れる。
-setColors(gl);

+// 法線ベクトルのバッファーを作成する。
+var normalBuffer = gl.createBuffer();
+gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer);
+// バッファーに法線ベクトルを入れる。
+setNormals(gl);

そして描画する時

-// 色の属性オンにする。
-gl.enableVertexAttribArray(colorLocation);
-
-// colorBufferをARRAY_BUFFERに結び付ける。
-gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
-
-// 属性にどうやってcolorBuffer(ARRAY_BUFFER)からデータを取り出すか。
-var size = 3;                  // 呼び出すごとに3つの数値
-var type = gl.UNSIGNED_BYTE;   // データは8ビット符号なし整数
-var normalize = true;          // データをnormalizeする(0〜255から0−1に)
-var stride = 0;                // シェーダーを呼び出すごとに進む距離
-                               // 0 = size * sizeof(type)
-var offset = 0;                // バッファーの頭から取り始める
-gl.vertexAttribPointer(
-    colorLocation, size, type, normalize, stride, offset)

+// 法線ベクトルの属性オンにする。
+gl.enableVertexAttribArray(normalLocation);
+
+// normalBufferをARRAY_BUFFERに結び付ける。
+gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer);
+
+// 属性にどうやってnormalBuffer(ARRAY_BUFFER)からデータを取り出すか。
+var size = 3;                  // 呼び出すごとに3つの数値
+var type = gl.FLOAT;           // データは32ビットの数値
+var normalize = false;         // データをnormalizeしない
+var stride = 0;                // シェーダーを呼び出すごとに進む距離
+                               // 0 = size * sizeof(type)
+var offset = 0;                // バッファーの頭から取り始める
+gl.vertexAttribPointer(
+    normalLocation, size, type, normalize, stride, offset)

シェーダーに法線ベクトルを使わせよう

まず、頂点シェーダーで法線ベクトルをピクセルシェーダーに伝えるように更新する。

attribute vec4 a_position;
-attribute vec4 a_color;
+attribute vec3 a_normal;

uniform mat4 u_matrix;

-varying vec4 v_color;
+varying vec3 v_normal;

void main() {
  // positionを行列に掛ける。
  gl_Position = u_matrix * a_position;

-  // 色をピクセルシェーダーに渡す。
-  v_color = a_color;

+  // 線ベクトルをピクセルシェーダーに渡す。
+  v_normal = a_normal;
}

そして、ピクセルシェーダーで光線の方向と法線ベクトルの内積を計算する。

precision mediump float;

// 頂点シェーダーに渡された。
-varying vec4 v_color;
+varying vec3 v_normal;

+uniform vec3 u_reverseLightDirection;
+uniform vec4 u_color;

void main() {
+   // v_normalはバリイングなので頂点の間に補間される。
+   // なので単位ベクトルになっていない。ノーマライズすると
+   // 単位ベクトルに戻す。
+   vec3 normal = normalize(v_normal);
+
+   float light = dot(normal, u_reverseLightDirection);

*   gl_FragColor = u_color;

+   // 色部分だけ照明に掛けよう(アルファ/透明の部分を無視)
+   gl_FragColor.rgb *= light;
}

u_coloru_reverseLightDirectionのユニフォム・ロケーションを調べなきゃ。

  // ユニフォムを調べる。
  var matrixLocation = gl.getUniformLocation(program, "u_matrix");
+  var colorLocation = gl.getUniformLocation(program, "u_color");
+  var reverseLightDirectionLocation =
+      gl.getUniformLocation(program, "u_reverseLightDirection");

そして、それを設定しなきゃ。

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

+  // 色を設定する。
+  gl.uniform4fv(colorLocation, [0.2, 1, 0.2, 1]); // green
+
+  // 光線の方向を設定する。
+  gl.uniform3fv(reverseLightDirectionLocation, m4.normalize([0.5, 0.7, 1]));

以前に説明したm4.normalizeであるベクトルを単位ベクトルに出来る。この場合、 x = 0.5+xの意味は指向性光源が右から左に向いてることである。 y = 0.7+yの意味は指向性光源が上から下に向いてることである。 z = 1+zの意味は指向性光源が前から中を目指していることである。 お互いの相対値の意味は光線がほとんど中を目指していて、さらに左より下への方向性が強いことである。

さあ、これである。

Fを回転してみるとちょっと可怪しいに気付くだろう。「F」が回転しているが、照明が変わらないことである。 「F」を回転しながら照明に向いている部分は一番明るくなって欲しいだろう?

それを直す為にオブジェクト座標を回転させるのと同じように、法線ベクトルも回転させなければいけない。 positionと同じように法線をある行列に掛ける必要がある。それはそう考えるとワールド行列を思い起こすだろう。 今はu_matrixという一つの行列しか使われていない。行列を2つにしよう。一つはu_worldとして、 それがワールド行列になる。もう一つとu_worldViewProjectionとして、 それが今までのu_matrixと同じような行列になる。

attribute vec4 a_position;
attribute vec3 a_normal;

*uniform mat4 u_worldViewProjection;
+uniform mat4 u_world;

varying vec3 v_normal;

void main() {
  // positionを行列に掛ける。
*  gl_Position = u_worldViewProjection * a_position;

*  // 法線ベクトルの向きを計算して、ピクセルシェーダーに渡す。
*  v_normal = mat3(u_world) * a_normal;
}

a_normalmat3(u_world)に掛けていることに注目して。 法線ベクトルは方向だけなので行列の移動する部分が要らない。行列の向き部分は上の3x3の部分である。

ユニフォームを調べなきゃ。

  // ユニフォームを調べる。
*  var worldViewProjectionLocation =
*      gl.getUniformLocation(program, "u_worldViewProjection");
+  var worldLocation = gl.getUniformLocation(program, "u_world");

そして、行列を設定しているところの更新が必要である。

*// 行列を設定する。
*gl.uniformMatrix4fv(
*    worldViewProjectionLocation, false,
*    worldViewProjectionMatrix);
*gl.uniformMatrix4fv(worldLocation, false, worldMatrix);

そして、これ。

「F」を回転してみて、指向性光源に向いている部分が明るくなることに注目して下さい。

まだ問題があるが説明しにくいので図形で見よう。 normalの向きを変更するためにu_worldの行列に掛けている。 ワールド行列にスケールを掛けるとどうなるか?法線ベクトルはダメになる。

クリックして表示したかが変わる

私も解決方法の理論はまだ学んでないけど、ワールド行列の逆行列を計算して、その行列の転置行列を 使えば、法線ベクトルの方向が正しくなる。転置行列はある行列の横列と縦列を交換した行列である。

上の図形での球体はスケールに掛けられてない。 左にある赤い球体がスケールに掛けられて、法線ベクトルは ワールド行列に掛けられている。なんとなく何かが間違えていることに気付くだろう? 右にある青い球体は転置されたワールドの逆行列に掛けられている。

図形をクリックしたら表示方法が変わる。スケールが大きくなるほど掛けられている時左の方の法線ベクトル(world)は 球体の表面に垂直になっていないことがよく見えるだろう。右の方(worldInverseTranspose)はいつも 球体表面に垂直になっている。最後の表示のしかたで全部赤で描画して、外側の2つの球体は結構違って表示されていることが 見えるはず。どっちが正しいか分かりやすくないかもしれないけど、他の表示のし方でworldInverseTranspose の方が正しいと分かるはず。

この解決方法を実装するためにこういうふうに変更しよう。まず、シェーダーを更新する。 技術的にu_worldの値だけ変更出来るが、正しい意味がある変数の名前を付けた方がいい。 そうしないとコードが分かりにくくなる。

attribute vec4 a_position;
attribute vec3 a_normal;

uniform mat4 u_worldViewProjection;
*uniform mat4 u_worldInverseTranspose;

varying vec3 v_normal;

void main() {
  // positionを行列に掛ける。
  gl_Position = u_worldViewProjection * a_position;

  // 法線ベクトルの向きを計算して、ピクセルシェーダーに渡す。
*  v_normal = mat3(u_worldInverseTranspose) * a_normal;
}

そして、それを調べなきゃ

-  var worldLocation = gl.getUniformLocation(program, "u_world");
+  var worldInverseTransposeLocation =
+      gl.getUniformLocation(program, "u_worldInverseTranspose");

そして、計算して設定しなきゃ。

var worldViewProjectionMatrix = m4.multiply(viewProjectionMatrix, worldMatrix);
var worldInverseMatrix = m4.inverse(worldMatrix);
var worldInverseTransposeMatrix = m4.transpose(worldInverseMatrix);

// 行列を設定する。
gl.uniformMatrix4fv(
    worldViewProjectionLocation, false,
    worldViewProjectionMatrix);
-gl.uniformMatrix4fv(worldLocation, false, worldMatrix);
+gl.uniformMatrix4fv(
+    worldInverseTransposeLocation, false,
+    worldInverseTransposeMatrix);

行列を転置するコードはこれである。

var m4 = {
  transpose: function(m) {
    return [
      m[0], m[4], m[8], m[12],
      m[1], m[5], m[9], m[13],
      m[2], m[6], m[10], m[14],
      m[3], m[7], m[11], m[15],
    ];
  },

  ...

スケールに掛けてないので前のサンプルと同じように表示されているが、 スケールがに掛ける場合に準備が出来た。

この照明の計算の第一歩は分かりやすかったかな〜。次回は点光源である

mat3(u_worldInverseTranspose) * a_normalの代わり

上のシェーダーにこのような行がある。

v_normal = mat3(u_worldInverseTranspose) * a_normal;

このようにしても構わない。

v_normal = (u_worldInverseTranspose * vec4(a_normal, 0)).xyz;

wを0にすると行列の移動する部分は0に掛けられる。なので移動の部分消されることになる。 どっちの方が一般的か分からない。今回はmat3の方法の方が綺麗なような気がした。下のようにしたこともある。

もう一つの方法としてu_worldInverseTransposemat3にすることもあるが、 そのようにしない方がいい理由が2つある。一つ目はmat4u_worldInverseTransposeを 使う場合もあるのでmat4として渡せばmat4で必要な場合にも使える。 二つ目は今まで使っている行列数学ライブラリは mat4しか作れない。mat3のライブラリを作るのは面倒で、 mat4からmat3に変更することもコンピューターにさせたくないので、 特別の理由がなければmat4でいいと思う。

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