点光源によるライティング
今回のサンプルの実行結果
ポイントライト
前回はグーローシェーディングとフォンシェーディングについて解説しました。
フォンシェーディングの手法を用いることによって、より自然な陰影付けが可能になり、3D シーンはさらにリアルにレンダリングされるようになります。その代わりに計算の負荷が高くなるなどデメリットもありましたね。これはケースバイケースで調節するしかない部分なので、悩ましいところです。
さて今回は、またライティングネタです。またかよ……という声が聞こえてきそうですが
今回のテーマは点光源(ポイントライト)の実装です。点光源はその名の通り、頂点と同じように光源が点として表される光源です。
今までは、全ての光源処理に平行光源を使ってきました。平行光源は無限遠から降り注ぐような一定の方向を示す光源でした。三次元空間上の全てのモデルは、同じ指向性の光によってライティングされていましたね。一方で点光源を用いた処理では、光源の位置が三次元空間上に固定されます。これにより、三次元空間上のどこにモデルが描画されるかによって、光の当たり方が変わってきます。
現実世界では、電球などがこの点光源と似た効果を生み出します。しかし、実際には電球の光は減衰を起こしますね。距離が遠ざかるほど光が届く量が減るわけです。今回実装する点光源の処理では、この光の減衰は考慮しません。オブジェクトがどれほど光源から離れていても、同様の影響を受けるようにプログラムを組みます。ですから、完全に現実世界の点光源をシミュレートするわけではありませんのであしからず。
点光源の考え方
点光源の実装方法は、それほど難しくありません。
平行光源の場合はライトベクトル、つまり光の方向が一定でした。点光源の場合、光源の位置が決まっていますので、光源から頂点に向かうベクトルを算出してライトベクトルとし、このライトベクトルを用いて陰影付けの計算を行ないます。
光源から頂点へ向かうベクトルの算出が必要になるため、平行光源を用いたライティングよりも若干計算の負荷が増えます。しかし、ライトベクトルを求めるところさえクリアできてしまえば、あとは平行光源で行なっていたライティングの計算をそのまま流用できますので、あまり難しく考えなくとも大丈夫です。
頂点シェーダを修正する
今回は前回と同様にフォンシェーディングでライティングします。修正の大半はフラグメントシェーダ側になりますが、頂点シェーダ側にも若干の修正が必要です。
点光源の処理では、先ほども書いたように光源から頂点へと向かうベクトルの算出が必要になります。このことからもわかるように、ライトベクトルを算出するためには、頂点の位置情報が必要になりますね。
頂点シェーダに入ってきた位置情報をフラグメントシェーダに渡すことになるので、新たに varying
変数が必要になることは容易に想像できますね。しかし、ただ頂点の位置情報を渡すと言ってもちょっとした問題があります。
頂点の位置情報は、通常ローカル座標として頂点シェーダに渡されます。ですから、たとえばモデル座標変換でモデルを移動したり回転させたりしていた場合、頂点の位置は変わってしまいますね。ローカル座標系では(1.0, 1.0, 1.0)にあった頂点であっても、移動や回転を行なったことによって別の座標(たとえば 0.5, 2.0, 5.5 とか)へと変換されている可能性があるわけです。
点光源から発された光のライトベクトルは、このモデル座標変換を行なったあとの頂点の位置を考慮したものでなければなりません。ですから、頂点シェーダにはモデル座標変換行列を新たに渡す必要があります。それを踏まえて、頂点シェーダのソースを修正してみましょう。
頂点シェーダのソース
attribute vec3 position;
attribute vec3 normal;
attribute vec4 color;
uniform mat4 mvpMatrix;
uniform mat4 mMatrix;
varying vec3 vPosition;
varying vec3 vNormal;
varying vec4 vColor;
void main(void){
vPosition = (mMatrix * vec4(position, 1.0)).xyz;
vNormal = normal;
vColor = color;
gl_Position = mvpMatrix * vec4(position, 1.0);
}
前回からの変更点は大きく分けると二つあります。
一つ目の変更点は頂点の位置情報をフラグメントシェーダに渡すための varying
変数である vPosition
の追加です。頂点の位置情報を表すデータであるため vec3
として定義してあります。
二つ目の変更点は新しい uniform
変数である mMatrix
を追加していることです。先ほども書いたように、頂点シェーダに入ってくる頂点の位置情報はローカル座標系ですので、これをモデル座標変換行列を適用したあとの形(つまりワールド座標系)に変換するために、 uniform
修飾子付き変数を使ってモデル座標変換行列をシェーダ側が受け取れるようにしているわけですね。
フラグメントシェーダへ頂点の位置情報を渡す際には、モデル座標変換行列を表す mMatrix
と、頂点のローカル座標を表す position
とを掛け合わせてから vPosition
に代入します。これによりフラグメントシェーダは、モデル座標変換が適用されたあとの頂点の位置を知ることができるようになるのですね。
フラグメントシェーダを修正する
続いてはフラグメントシェーダ側の修正です。フラグメントシェーダでは、頂点の位置と点光源の位置とを使ってライトベクトルをその都度算出しなければなりません。
このときのライトベクトルを計算する方法は非常に簡単で、単なるベクトルの減算のみで求めることができます。
また、今回は点光源による処理ですので、今まで使っていたライトベクトル用の uniform
変数(lightDirection
)に代わり、点光源の位置を示す uniform
変数である lightPosition
が登場します。
フラグメントシェーダのソース
precision mediump float;
uniform mat4 invMatrix;
uniform vec3 lightPosition;
uniform vec3 eyeDirection;
uniform vec4 ambientColor;
varying vec3 vPosition;
varying vec3 vNormal;
varying vec4 vColor;
void main(void){
vec3 lightVec = lightPosition - vPosition;
vec3 invLight = normalize(invMatrix * vec4(lightVec, 0.0)).xyz;
vec3 invEye = normalize(invMatrix * vec4(eyeDirection, 0.0)).xyz;
vec3 halfLE = normalize(invLight + invEye);
float diffuse = clamp(dot(vNormal, invLight), 0.0, 1.0) + 0.2;
float specular = pow(clamp(dot(vNormal, halfLE), 0.0, 1.0), 50.0);
vec4 destColor = vColor * vec4(vec3(diffuse), 1.0) + vec4(vec3(specular), 1.0) + ambientColor;
gl_FragColor = destColor;
}
シェーダ内の main
関数の一行目、変数 lightVec
に点光源から頂点へと向かうライトベクトルが入ります。先述のとおり、これは単なる減算だけで求められますので簡単ですね。そして、ここで得られたライトベクトルを使って、平行光源のときと同じように逆行列を適用したり、あるいはハーフベクトルを求めたりしながら拡散光や反射光を計算していきます。
仕組みさえ理解できてしまえば、前回までのサンプルとそれほど変わらないのがわかると思います。要は、ライトベクトルの扱いが異なるだけで、ライティング手法自体はほとんど同じなのですね。
javascript の修正
シェーダが修正できたら、次はメインプログラムである javascript の修正です。
今回は結構細かい部分での修正が多くなっているので、ポイントを絞って解説します。今までのサンプルではトーラスだけを用いてレンダリングしてきましたが、今回はトーラスに加え球体を使ってレンダリングしているのが冒頭の画像を見るとわかると思います。トーラスの頂点データ、さらには球体用の頂点データを別途用意しなければなりません。
球体モデルの頂点データを生成するのは、以下の関数。実装としてはトーラスの頂点データを生成する関数と似たような感じになっています。
球体の頂点データを生成する関数
// 球体を生成する関数
function sphere(row, column, rad, color){
var pos = new Array(), nor = new Array(),
col = new Array(), idx = new Array();
for(var i = 0; i <= row; i++){
var r = Math.PI / row * i;
var ry = Math.cos(r);
var rr = Math.sin(r);
for(var ii = 0; ii <= column; ii++){
var tr = Math.PI * 2 / column * ii;
var tx = rr * rad * Math.cos(tr);
var ty = ry * rad;
var tz = rr * rad * Math.sin(tr);
var rx = rr * Math.cos(tr);
var rz = rr * Math.sin(tr);
if(color){
var tc = color;
}else{
tc = hsva(360 / row * i, 1, 1, 1);
}
pos.push(tx, ty, tz);
nor.push(rx, ry, rz);
col.push(tc[0], tc[1], tc[2], tc[3]);
}
}
r = 0;
for(i = 0; i < row; i++){
for(ii = 0; ii < column; ii++){
r = (column + 1) * i + ii;
idx.push(r, r + 1, r + column + 2);
idx.push(r, r + column + 2, r + column + 1);
}
}
return {p : pos, n : nor, c : col, i : idx};
}
球体を形成する頂点は、一枚の大きなポリゴン群で出来た膜を、球の形に丸めるような方法で定義します。この sphere
関数は四つの引数を取ります。第一引数には、球体を形成する膜状のポリゴンの板の縦の分割数(頂点数)です。地球にたとえると緯度の方向ですね。第二引数は横の分割数になりますので、こちらは地球で言うなら経度の方向ということになります。
第三引数には球体の半径が入ります。第四引数には、球体に色をつける場合にはその色を四つの要素を持つ配列として渡します。色が指定されていない場合には HSV カラーが自動的に適用されるようになっています。
この関数の使い方としては、適切に引数を指定して呼び出し、その戻り値を変数に受け取ります。戻り値はオブジェクトなので適切にプロパティを参照します。実際にメインプログラムの中で使っている部分を見てみましょう。
関数 sphere の使用箇所を抜粋
// 球体の頂点データからVBOを生成し配列に格納
var sphereData = sphere(64, 64, 2.0, [0.25, 0.25, 0.75, 1.0]);
var sPosition = create_vbo(sphereData.p);
var sNormal = create_vbo(sphereData.n);
var sColor = create_vbo(sphereData.c);
var sVBOList = [sPosition, sNormal, sColor];
// 球体用IBOの生成
var sIndex = create_ibo(sphereData.i);
上記のようにすると縦横それぞれ 64 頂点からなる球体が生成されますね。半径は 2.0 で、今回は青みがかった色も付くように指定しています。ここでのポイントは、後々の処理のためにあらかじめ VBO を配列に格納してリスト化している部分です。こうしておくことによって、attributeLocation と VBO を紐付ける作業が非常にスムーズに行なえます。これについては後述します。
さてどんどん行きます。次は uniformLocation の取得部分。今回は平行光源から点光源に変更したことによって、ライトの向きを指定していた部分がライトの位置を指定する形に変わっています。
uniform 周辺の処理
// uniformLocationを配列に取得
var uniLocation = new Array();
uniLocation[0] = gl.getUniformLocation(prg, 'mvpMatrix');
uniLocation[1] = gl.getUniformLocation(prg, 'mMatrix');
uniLocation[2] = gl.getUniformLocation(prg, 'invMatrix');
uniLocation[3] = gl.getUniformLocation(prg, 'lightPosition');
uniLocation[4] = gl.getUniformLocation(prg, 'eyeDirection');
uniLocation[5] = gl.getUniformLocation(prg, 'ambientColor');
// 中略
// 点光源の位置
var lightPosition = [0.0, 0.0, 0.0];
シェーダのほうで行なった uniform
修飾子付き変数の変更を、こちらでもしっかり反映させておきます。また、今回のサンプルでは点光源の位置は原点としています。
点光源の効果をわかりやすくするために、サンプルでは点光源の位置(つまり原点)を中心にトーラスト球体が回転するようにモデル座標変換行列を生成します。二つのモデルを同時に描画するので、恒常ループの中で適切に VBO や IBO を適用しながらモデルをレンダリングしていきます。
少し長いコードになりますが、注意深く見ていけばわかると思います。ポイントとなるのは、先ほども書いたとおり、VBO を格納した配列を使って自作関数による VBO のバインド処理を行なっている箇所ですね。
恒常ループ内の描画処理
// カウンタをインクリメントする
count++;
// カウンタを元にラジアンと各種座標を算出
var rad = (count % 360) * Math.PI / 180;
var tx = Math.cos(rad) * 3.5;
var ty = Math.sin(rad) * 3.5;
var tz = Math.sin(rad) * 3.5;
// トーラスのVBOとIBOをセット
set_attribute(tVBOList, attLocation, attStride);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, tIndex);
// モデル座標変換行列の生成
m.identity(mMatrix);
m.translate(mMatrix, [tx, -ty, -tz], mMatrix);
m.rotate(mMatrix, -rad, [0, 1, 1], mMatrix);
m.multiply(tmpMatrix, mMatrix, mvpMatrix);
m.inverse(mMatrix, invMatrix);
// uniform変数の登録と描画
gl.uniformMatrix4fv(uniLocation[0], false, mvpMatrix);
gl.uniformMatrix4fv(uniLocation[1], false, mMatrix);
gl.uniformMatrix4fv(uniLocation[2], false, invMatrix);
gl.uniform3fv(uniLocation[3], lightPosition);
gl.uniform3fv(uniLocation[4], eyeDirection);
gl.uniform4fv(uniLocation[5], ambientColor);
gl.drawElements(gl.TRIANGLES, torusData.i.length, gl.UNSIGNED_SHORT, 0);
// 球体のVBOとIBOをセット
set_attribute(sVBOList, attLocation, attStride);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, sIndex);
// モデル座標変換行列の生成
m.identity(mMatrix);
m.translate(mMatrix, [-tx, ty, tz], mMatrix);
m.multiply(tmpMatrix, mMatrix, mvpMatrix);
m.inverse(mMatrix, invMatrix);
// uniform変数の登録と描画
gl.uniformMatrix4fv(uniLocation[0], false, mvpMatrix);
gl.uniformMatrix4fv(uniLocation[1], false, mMatrix);
gl.uniformMatrix4fv(uniLocation[2], false, invMatrix);
gl.drawElements(gl.TRIANGLES, sphereData.i.length, gl.UNSIGNED_SHORT, 0);
// コンテキストの再描画
gl.flush();
各種座標変換行列の生成、さらには逆行列の生成が済んだら、点光源の位置や視点ベクトルなどと一緒にシェーダにプッシュします。さらに、VBO と IBO のバインド処理を行なった上で描画命令を発行します。
二つのモデルを描画するために必要となる一連の処理が、繰り返し行なわれていることに注意すれば、特に難しいことはやっていませんので、焦らずじっくり考えてみてください。
まとめ
点光源を用いたライティングは、基本的なライティングの概念は平行光源と同様です。ライトベクトルと頂点の法線や視点ベクトルとの内積を取ることによって陰影付けを行ないます。平行光源と異なるのは、ライトベクトルがあらかじめ一定の値なのか、そうではないのか、簡単に言ってしまえばそれだけです。点光源ではモデル座標変換を行なったあとの頂点の位置と光源の位置とを使って、その都度ライトベクトルを算出するようにしますので若干ですが計算量が増えます。
平行光源では光の向きが一定だったので、全体的に光が均等に当たります。しかし点光源では頂点の座標に応じて詳細に光の当たり具合が変化します。今回のサンプルも前回同様フラグメントシェーダ内でライトの計算を行なうフォンシェーディングですので、非常に綺麗なライティング処理が行なわれます。
今回のテキストでとりあえずライティングに関する基礎的な部分は実装できたと考えていいでしょう。WebGL ではシェーダを工夫することで様々なエフェクトや演出効果を実装できますので、ここからは応用力も必要になってきますね。特殊なテクニックに関してはまたいずれ解説できればと思っています。
さて、今回も実際に動作するサンプルを用意してあります。後述するリンクからサンプルページに飛べます。
次回はいよいよテクスチャをやります。お楽しみに。