平行光源によるライティング
https://wgld.org/d/webgl/w021.html
さて、今回はライティングをやります。
ライティングとひと括りにしてしまうと非常に単純に聞こえますが、3D プログラミングにおけるライティングはそれなりに種類も手法もたくさんあり、ライティングを極めるとなるとそれなりに大変です。
現実世界では物体に光が当たっている状態を日常的に目にしますね。
というか、光が一切当たっていない状態では、我々の視覚はそれを認識することさえできません。
3D プログラミングの世界では、別にライティングを行なわなくてもモデルをレンダリングすることは可能です。
今までも、特に光を当てるような処理はせずにポリゴンを描画してきましたものね。しかし、ライティングを実装させる、つまりは光が当たっている状態をシミュレートすることによって、3D レンダリングのリアリティは飛躍的に向上します。がんばってマスターしましょう。
今回紹介するライティングは、一般に平行光源(ディレクショナルライト)によるライティングで、比較的簡単に実装できるライティング手法の一つです。
平行光源によるライティングの詳細な実装の解説に移る前に、簡単にライティングそのものについて解説しましょう。
光をシミュレートすることとは
そもそも平行光源によるライティングとは、光を当てる処理というよりは影を演出する処理と言えます。これは、前回のサンプルの描画結果と、今回のサンプルの描画結果を見比べれば明白です。
https://wgld.org/i/site/w021_02.jpg
ディレクショナルライティングの有無による比較
ライティングを施すと、光が当たっているはずの部分は明るい色に、逆に光があまり当たっていない部分の色は暗い色になりますね。
ライティングが全く施されていない状態では、全ての色が均等に発色しますが、光をシミュレートすることによって、光よりもむしろ影がシーンを演出することになるわけです。
WebGL では、色の強度を 0 ~ 1 の範囲で指定しますね。RGBA の各要素に、それらの数値を設定することで色を表現します。
ライティングを行なう際には、もともとあった RGBA の値にライティングによる係数を掛けます。
このライティング係数も色の情報と同様に 0 ~ 1 の範囲にまとまるようにしておくことで、光が目一杯当たっている部分はそのままの色に近い状態に、影となる部分は暗い色になるわけです。
たとえば、RGBA の各要素が 0.5 だったとして、ライティング係数を 0.5 にしたとします。これらを掛け算すると、RGBA の各要素は 0.5 x 0.5 = 0.25 となり、本来の色より暗くなりますね。このように、光の強さと色の強さをそれぞれ算出しておき、最終的にそれらを掛けあわせることによって光と影を表現することができるようになるのです。
平行光源とは
平行光源とは、無限に遠いところから、三次元空間全体に平行に降り注ぐ光を発する光源のことです。こうして言葉にして表すと非常にわかりにくいですね。
要するに、常に光の向きが一定で、三次元空間のどこにあるモデルであっても、同じ向きからやってくる光を適用してライティングするということですね。絵にして表すと、次のような感じです。
平行光源から発せられた光の例
https://wgld.org/i/site/w021_03.jpg
黄色い矢印が光の向きを表します。
平行光源によるライティングは計算の負荷もそれほど大きくなく、比較的簡単に実装できることから 3D プログラミングではよく使われます。そして、平行光源によるライティングを実装するにあたって必要となるのが光の向きです。これをベクトルとして定義してやりシェーダに渡すことで、ライティングを実装することが可能になります。
しかし、実は光の向きだけではライティングは実装できません。もう一つ、頂点の法線情報が必要になるのです。法線とはいったいなんなのか、次項で詳しく見ていきましょう。
光の反射
https://wgld.org/i/site/w021_04.jpg
上の図で言うところのピンク色の線が光の軌道です。ぶつかった面の向きによって光の進む方向が変わっていますね。このように、モデルを形成している面の向きが光の軌道を大きく左右するのがわかります。
3D のライティングでは、あくまでも光は演算によって擬似的に表現されるものでしかありません。ですから、現実世界の光の軌道や働きを全て完全に再現する必要はありません。というか、それをやろうとすると負荷が高くなりすぎてしまいます。今回の平行光源によるライティングは、頂点の持つ法線ベクトルと光の向き(ライトベクトル)をもとにして、面がどの程度光を拡散・反射させるのか、その影響力を計算します。
光が、面に向かってまっすぐに降り注いでいるとすれば、その面は光を完全に反射することができます。つまり、ライトによる影響が大きいと言えますね。逆に、面に光が全く当たらない状態なら光は一切拡散されません。これを図に表すと次のようになります。
光の影響力
ライトベクトルと法線ベクトルによって形成される角の角度が 90 度以上ある場合には光の影響力がなくなることがわかりますね。これを計算するには、ベクトル同士で内積を取ります。※内積についてここでは詳しく触れませんのでさらに詳細を知りたい方は各自調べてください
内積はシェーダに組み込まれているビルトイン関数で簡単に計算できますので、あまり心配しなくても大丈夫です。要は、正しくデータさえ用意することができれば、そこからの計算は全て WebGL がやってくれます。
というわけで今回は頂点シェーダに修正を加えなければなりません。当然ですが、javascript のほうにも修正が入りますので大変ですが、じっくりと見ていきましょう。
ディレクショナルライティングシェーダ
さて、それではシェーダの実装から見ていきます。ちなみに今回修正を加えるシェーダは頂点シェーダのみです。頂点シェーダでライティングの計算を行い、その結果をフラグメントシェーダに渡します。
頂点シェーダのソース
code:glsl
attribute vec3 position;
attribute vec3 normal;
attribute vec4 color;
uniform mat4 mvpMatrix;
uniform mat4 invMatrix;
uniform vec3 lightDirection;
varying vec4 vColor;
void main(void){
vec3 invLight = normalize(invMatrix * vec4(lightDirection, 0.0)).xyz;
float diffuse = clamp(dot(normal, invLight), 0.1, 1.0);
vColor = color * vec4(vec3(diffuse), 1.0);
gl_Position = mvpMatrix * vec4(position, 1.0);
}
今までのサンプルからの変更点が多いので、パッと見た感じ複雑に見えるかもしれませんね。ポイントを絞って解説します。
まず変数関連から。
シェーダの attribute 変数には新たに normal が追加されています。これは頂点の法線情報を格納するための変数です。さらに uniform 変数に二つの追加がありますね。一つ目はモデル座標変換行列の逆行列を受け取るための変数である invMatrix です。そして二つ目がライトの向き、つまり平行光源から発せられる光の向きを表すベクトルを受け取る lightDirection です。
逆行列とはなんぞや
今回頂点シェーダに追加された invMatrix は、モデル座標変換行列の逆行列を格納するための変数です。しかし、逆行列と急に言われてもなんのことやらわからない人も多いでしょう。
平行光源によるライティング(つまりディレクショナルライトによるライティング)では、常にライトベクトルが一定である必要があります。三次元空間上の全てのモデルは、同じ向きから来る平行な光によって照らされます。しかし、ここで考えてみてください。モデル座標変換では、モデルの拡大縮小はもとより、回転や移動も行うことができましたね。モデルの位置が動いたり、あるいは向きが回転したりしているわけですから、単に法線とライトベクトルだけを用いて演算すると、ライトの向きや位置が、モデルの向きや位置によって影響を受けてしまいます。
本来は一定であるはずのライトの位置や向きが、モデル座標変換によって影響を受けてしまうのでは正しくレンダリングが行なえません。そこで、モデルに適用した座標変換の全く逆の変換を用いることで、モデル座標変換の影響をまるまる相殺してしまうという方法を使います。
モデルが X を軸に 45 度回転したなら、逆に巻き戻すように逆方向に 45 度回転させる行列を適用します。これで回転は相殺され、モデルが回転して描画されても光源の位置と光の向きは固定されます。同様に、モデルにスケーリングが掛かっているなら、それとは真逆のスケーリングを掛けることで相殺するわけです。
このように、ライティングではモデル座標変換行列の逆行列を用意しなければならない場合があります。そして、minMatrix.js には逆行列を生成するための関数も用意されていますので、当サイトではそれらを利用しながらライティングを行なっていきます。
さて、続いてライティングを行なうための係数を計算します。該当する部分だけを抜粋して再度掲載します。
ライティングのための係数の算出
code:glsl
vec3 invLight = normalize(invMatrix * vec4(lightDirection, 0.0)).xyz;
float diffuse = clamp(dot(normal, invLight), 0.1, 1.0);
vColor = color * vec4(vec3(diffuse), 1.0);
まず最初に vec3 型の変数として invLight という変数を宣言し、そこになにかの計算結果を代入していますね。
まず最初に normalize というビルトイン関数ですが、これはベクトルなどを正規化(規格化とも言う)するための関数です。モデル座標変換の逆行列とライトベクトルを掛け合わせ、これを正規化しています。モデルが回転などの座標変換を行なっていても、それと真逆の変換をライトベクトルに適用することでこれを相殺します。これらの計算に続けて .xyz と記述されていますが、これは変換結果を三次ベクトルとして正しく変数に代入するためです。
続いては float 型の変数である diffuse になにかの値を取得していますね。実はここで法線とライトベクトルとの内積を取っています。ここで登場している clamp と dot はいずれも GLSL のビルトイン関数で、 clamp は値を一定の範囲にクランプする(範囲内に収める)役割を果たします。第二引数に最小値、第三引数に最大値を指定します。クランプする理由は単純で、ベクトルの値によっては内積を取ることで計算結果が負の数値になる場合があり、それを抑制するためにこのような処理を行ないます。
もう一つのビルトイン関数 dot は内積を取る関数です。法線と、逆行列を適用したライトベクトルを引数に指定しています。
最後に、算出したライト係数と、頂点色とを掛け合わせてフラグメントシェーダに varying 変数として渡しています。フラグメントシェーダは、ここで受け取った数値を元に最終的な色を決定してくれます。
VBO に法線情報を追加する
さて、今回は変更点が多いのですが javascript のほうも見ていきます。
前回のテキストで登場した、トーラスの頂点データを生成する関数を今回少しいじっています。内容的には、法線情報を一緒に返すように修正しています。前回までは、位置、色、インデックスしか返していませんでしたが、そこにプラスアルファで法線の情報も返すようにしたわけです。
法線は、先ほども書いたように向きを表すベクトルなので、位置情報と同じように X Y Z の三つの要素で表されます。そして、法線は正規化されている状態(0 ~ 1 の範囲に収まっている状態)になっているのが基本です。
トーラスの生成と法線情報の追加
code:js
// トーラスを生成する関数
function torus(row, column, irad, orad){
var pos = new Array(), nor = new Array(),
col = new Array(), idx = new Array();
for(var i = 0; i <= row; i++){
var r = Math.PI * 2 / row * i;
var rr = Math.cos(r);
var ry = Math.sin(r);
for(var ii = 0; ii <= column; ii++){
var tr = Math.PI * 2 / column * ii;
var tx = (rr * irad + orad) * Math.cos(tr);
var ty = ry * irad;
var tz = (rr * irad + orad) * Math.sin(tr);
var rx = rr * Math.cos(tr);
var rz = rr * Math.sin(tr);
pos.push(tx, ty, tz);
nor.push(rx, ry, rz);
var tc = hsva(360 / column * ii, 1, 1, 1);
col.push(tc0, tc1, tc2, tc3);
}
}
for(i = 0; i < row; i++){
for(ii = 0; ii < column; ii++){
r = (column + 1) * i + ii;
idx.push(r, r + column + 1, r + 1);
idx.push(r + column + 1, r + column + 2, r + 1);
}
}
return pos, nor, col, idx;
}
トーラスを生成する関数から、適切に法線情報を出力するようにしています。ポイントは、トーラス生成の関数が 位置情報 ・ 法線情報 ・ 頂点色 ・ インデックス の順番でデータを配列として返してくるという仕様になっているところでしょうか。そのことさえわかっていれば、とりあえずこの関数を使うことはできるでしょう。
関数の中で何をやっているのかはいまいちピンと来ないかもしれませんが、法線情報は先ほども書いたように正規化されていることが望ましいので、トーラスの頂点座標を出力する部分と、法線情報を出力する部分をそれぞれ切り離して処理しています。
次に、このトーラス生成関数を呼び出している辺りの処理を見てみましょう。
頂点データに関する処理抜粋
code:js
// attributeLocationを配列に取得
var attLocation = new Array();
attLocation0 = gl.getAttribLocation(prg, 'position');
attLocation1 = gl.getAttribLocation(prg, 'normal');
attLocation2 = gl.getAttribLocation(prg, 'color');
// attributeの要素数を配列に格納
var attStride = new Array();
attStride0 = 3;
attStride1 = 3;
attStride2 = 4;
// トーラスの頂点データを生成
var torusData = torus(32, 32, 1.0, 2.0);
var position = torusData0;
var normal = torusData1;
var color = torusData2;
var index = torusData3;
// VBOの生成
var pos_vbo = create_vbo(position);
var nor_vbo = create_vbo(normal);
var col_vbo = create_vbo(color);
前回までのサンプルとは違い、法線を扱うために normal という配列データを用意していることがわかりますね。その配列から VBO を生成していることもわかると思います。また、頂点シェーダ内で法線情報を受け取るために attribute 修飾子付きの変数を宣言しているので、attributeLocation を取得しておくことも忘れずに。
また、 uniform 修飾子付きの変数も増えていましたよね。こちらも、きちんと uniformLocation を取得する処理を追加しておきます。
uniform 関連の処理を抜粋
code:js
// uniformLocationを配列に取得
var uniLocation = new Array();
uniLocation0 = gl.getUniformLocation(prg, 'mvpMatrix');
uniLocation1 = gl.getUniformLocation(prg, 'invMatrix');
uniLocation2 = gl.getUniformLocation(prg, 'lightDirection');
最初のうちは把握しにくいかもしれませんが、シェーダとスクリプトは切っても切れない関係です。必ず双方が対になるようにソースを記述する癖をつけましょう。
ライトに関する処理を追加する
さて、最後にライトに関するパラメータをシェーダに渡すための処理を見ていきます。まずはソース。
ライトと行列に関するデータを登録する
code:js
// 各種行列の生成と初期化
var mMatrix = m.identity(m.create());
var vMatrix = m.identity(m.create());
var pMatrix = m.identity(m.create());
var tmpMatrix = m.identity(m.create());
var mvpMatrix = m.identity(m.create());
var invMatrix = m.identity(m.create());
// ビュー×プロジェクション座標変換行列
m.lookAt(0.0, 0.0, 20.0, 0, 0, 0, 0, 1, 0, vMatrix);
m.perspective(45, c.width / c.height, 0.1, 100, pMatrix);
m.multiply(pMatrix, vMatrix, tmpMatrix);
// 平行光源の向き
var lightDirection = -0.5, 0.5, 0.5;
三つの要素を持つベクトルとして lightDirection を定義しています。今回の場合には、左やや後方あたりから、原点に向かって進む光の向きになっています。また、行列の初期化を行なっている部分では、新たに invMatrix を初期化していますね。この invMatrix にデータをセットしているのが以下のコード。
逆行列の生成と登録
code:js
// カウンタをインクリメントする
count++;
// カウンタを元にラジアンを算出
var rad = (count % 360) * Math.PI / 180;
// モデル座標変換行列の生成
m.identity(mMatrix);
m.rotate(mMatrix, rad, 0, 1, 1, mMatrix);
m.multiply(tmpMatrix, mMatrix, mvpMatrix);
// モデル座標変換行列から逆行列を生成
m.inverse(mMatrix, invMatrix);
// uniform変数の登録
gl.uniformMatrix4fv(uniLocation0, false, mvpMatrix);
gl.uniformMatrix4fv(uniLocation1, false, invMatrix);
gl.uniform3fv(uniLocation2, lightDirection);
通常どおり生成したモデル座標変換行列を、minMatrix.js に実装されている inverse メソッドで逆行列に変換します。そして、uniformLocation を正しく指定して登録します。この際、ライトの向きを表す lightDirection も同時に登録しています。
ライトの向きは今回は不変なので、実際にはループの中で毎回登録する必要はないのですが、わかりやすくするために一緒に処理しています。ライトベクトルは三つの要素を持つベクトルなので、行列を扱う場合とは違い uniform3fv を使うことに注意しましょう。引数の数などが違うので間違えないように。
まとめ
随分長いテキストになってしまいました。やっぱり比較的簡単とは言っても、ライトに関する処理は冗長になりがちですね。
ポイントとなるのは、3D レンダリングにおけるライティングとは、所詮は現実世界を擬似的にシミュレートすることしかできないということです。これは逆に言ってしまうと、それっぽく見えればそれで正解であるとも言えます。
自然界で起こっている真面目な物理学をそのまま使ってしまうと、非常に多くの計算をしなければならなくなります。その代用として、今回解説したような平行光源であったり、法線であったり、逆行列であったり、様々なものを駆使し、ある程度簡単に、ある程度それらしく見せるための技術を使います。
今回のテキストを理解するにあたっては、ある程度、数学の知識が必要になってしまいます。ベクトル、法線、行列といった、普段の生活の中ではほとんど意識することのないような言葉がたくさんでてきますが、少し考えればなんとなく理解できるレベルだと思います。
サンプルはいつものようにテキストの最後にリンクがあります。今回はちょっと修正箇所も多かったので、一応ソースコードの全文を掲載しておきます。
次回はライティングに関するもっと深い内容に挑戦します。