グレイスケール変換
今回のサンプルの実行結果
モノクロームな風景
前回、前々回と二回に渡ってシャドウマッピングを解説しました。深度バッファを用いることでモデルの配置されている位置を調べることができ、それを判断基準として影を描画するテクニックでしたね。
今回は少々趣向を変えまして、レンダリング結果のグレイスケール化をやってみます。
グレイスケールはその名の通り、白黒の濃淡だけで表現された状態を表します。白黒、モノクロ、なんていうふうに言うこともありますね。プログラムでこれをやるためにはいったいどんな処理を行なえばいいのでしょうか。
パッとすぐに思いつくのは、フラグメントシェーダを使って色を全てグレイスケール化する方法ですね。
確かに、フラグメントシェーダで色を変換してやれば理論上グレイスケールは簡単にできてしまいます。ただし、この場合気をつけなくてはならないのが、シェーダによって処理されるフラグメントはあくまでもモデルがレンダリングされることになるピクセルだけ、ということです。
WebGL のセオリーとしては、レンダリングを開始する前に canvas をクリアするために clear
メソッドを呼び出しますね。このとき、たとえば背景を青や緑でクリアしていた場合には、シェーダでいくらがんばっても何もモデルがレンダリングされなかった領域は、クリアされたときの色のままになります。
さて、それではシーン全体を漏れなくグレイスケール化するにはどうしたらいいのでしょう。
きっと、勘のいい人なら、既にその方法に察しがついているのではないでしょうか。
2 パスでレンダリングする
今回のサンプルでは、何もモデルがレンダリングされていない領域も含め、シーン内の全ての色をグレイスケールでフィルタリングします。そのために、レンダリングを 2 パスに分けて行ないます。
まずは、フレームバッファを用いてバックグラウンドでテクスチャにシーンをレンダリングします。そして、ふたつ目のパスで canvas の領域目一杯に覆いかぶさる板ポリゴンをレンダリングし、ここにグレイスケール変換したテクスチャを貼り付けます。
必然的に、今回のサンプルはマルチシェーダ、つまりふたつのシェーダを使い分けながら処理することになります。ひとつ目のシェーダは、普通にライティングを行ないながらシーンをレンダリングするシェーダ。ふたつ目のシェーダは全てのフラグメントをグレイスケール変換するシェーダです。
早速、これらのシェーダのソースを見てみましょう。
まずは、ライティングを行いながらモデルをレンダリングするシェーダ、つまりテクスチャへのレンダリングに使うシェーダです。
グーローシェーディングを行なう頂点シェーダ
attribute vec3 position;
attribute vec3 normal;
attribute vec4 color;
uniform mat4 mvpMatrix;
uniform mat4 invMatrix;
uniform vec3 lightDirection;
uniform vec3 eyeDirection;
uniform vec4 ambientColor;
varying vec4 vColor;
void main(void){
vec3 invLight = normalize(invMatrix * vec4(lightDirection, 0.0)).xyz;
vec3 invEye = normalize(invMatrix * vec4(eyeDirection, 0.0)).xyz;
vec3 halfLE = normalize(invLight + invEye);
float diffuse = clamp(dot(normal, invLight), 0.0, 1.0);
float specular = pow(clamp(dot(normal, halfLE), 0.0, 1.0), 50.0);
vec4 amb = color * ambientColor;
vColor = amb * vec4(vec3(diffuse), 1.0) + vec4(vec3(specular), 1.0);
gl_Position = mvpMatrix * vec4(position, 1.0);
}
続いてこれと対になるフラグメントシェーダ。
フラグメントシェーダ
precision mediump float;
varying vec4 vColor;
void main(void){
gl_FragColor = vColor;
}
これを見るとわかるとおり、まずフレームバッファにレンダリングする際には、グーローシェーディングによって頂点単位でライティングの陰影付けを行い、フラグメントシェーダはその色情報を受け取っているだけです。これらのシェーダについては以前に解説しましたので、ここでは詳細には触れません。
さて、続いてはこれをグレイスケール変換するための、第二のシェーダ。
先ほどとは逆に、今度は頂点シェーダではあまりすることがなく、フラグメントシェーダのほうがボリュームのあるソースになっています。
グレイスケール変換頂点シェーダ
attribute vec3 position;
attribute vec2 texCoord;
uniform mat4 mvpMatrix;
varying vec2 vTexCoord;
void main(void){
vTexCoord = texCoord;
gl_Position = mvpMatrix * vec4(position, 1.0);
}
頂点シェーダでは、頂点座標の出力と、テクスチャ座標をフラグメントシェーダに送ることだけしかやっていません。
一方、フラグメントシェーダは少々長いソースになります。
グレイスケール変換フラグメントシェーダ
precision mediump float;
uniform sampler2D texture;
uniform bool grayScale;
varying vec2 vTexCoord;
const float redScale = 0.298912;
const float greenScale = 0.586611;
const float blueScale = 0.114478;
const vec3 monochromeScale = vec3(redScale, greenScale, blueScale);
void main(void){
vec4 smpColor = texture2D(texture, vTexCoord);
if(grayScale){
float grayColor = dot(smpColor.rgb, monochromeScale);
smpColor = vec4(vec3(grayColor), 1.0);
}
gl_FragColor = smpColor;
}
まず、今回のフラグメントシェーダでは定数を使って RGB の色要素それぞれに掛け合わせる係数を決めています。この係数は、NTSC 系加重平均法と呼ばれるグレイスケール変換に使われる手法に則ったものです。
グレイスケール変換に利用する係数は、あらかじめ vec3
型の変数に入れておきます。また main
関数の中では、この変数を使ってテクスチャから抽出した色をグレイスケール変換しています。ちょうど、内積をとっているところがそれにあたりますね。
今回のサンプルではグレイスケール化するかどうかを、HTML 内の input 要素からフラグを持ってきて判断するようにしているので、そのための uniform 変数が使われていることに注意しましょう。
uniform 変数である grayScale
がグレイスケール化するかどうかのフラグ、もう一つの uniform 変数が、フレームバッファにアタッチされているテクスチャを受け取るための sampler2D
型になっています。
NTSC 系加重平均法
今回のサンプルで利用している NTSC 系加重平均法は、テレビなどで利用されてきたグレイスケール変換のための手法です。これは、人間の視覚が、RGB のそれぞれの要素から受ける影響を考慮したグレイスケールの変換手法で、総じて青系の色が比較的暗くなるように処理されます。
これとは異なるグレイスケール変換の方法に、単純に RGB の要素を全て足して 3 で割る方法があります。こちらは単に色の平均を求めているだけで、単純平均法などと呼ばれているようです。
単純平均法では、RGB の各要素を足して 3 で割っているだけなので、たとえば RGB が (1.0, 0.0, 0.0) だろうが、(0.0, 0.0, 1.0) だろうが、結果的に同じ処理結果になります。となると、仮に三つのピクセルがそれぞれ R G B それぞれのベタ塗り状態だった場合には、各ピクセルをグレイスケール変換しても視覚的には同じ色に見えてしまうことになります。これはどうも違うだろうという気がして、今回は NTSC 系加重平均法を使いました。
メインプログラム
今回のサンプルでは、先述の通りふたつのシェーダを使います。
ひとつ目のシェーダでは、事前にバインドしてあったフレームバッファにライティングを施したトーラスをレンダリングします。また、今回のサンプルでは背景をクリアする色も、フレームごとに変化するようにしており、これには HSV 系の色変換を使っています。
フレームバッファ(にアタッチされたテクスチャ)に対するレンダリングが終わったら、今度はこのフレームバッファに描き込まれたシーンを、グレイスケール変換しながら canvas にレンダリングします。
ここでは正射影変換を使って、canvas 全体を覆うような板ポリゴンをレンダリングします。この板ポリゴンに、先にレンダリングしてあったフレームバッファの内容をグレイスケール変換して貼り付けるわけですね。
恒常ループのなかのコードだけを抜粋して掲載しますが、これだけでもおおよそどんなことをやっているのかはわかると思います。
恒常ループ内の処理だけを抜粋
// 恒常ループ
(function(){
// カウンタをインクリメントする
count++;
if(count % 2 == 0){count2++;}
// カウンタを元にラジアンを算出
var rad = (count % 360) * Math.PI / 180;
// プログラムオブジェクトの選択
gl.useProgram(prg);
// フレームバッファのバインド
gl.bindFramebuffer(gl.FRAMEBUFFER, fBuffer.f);
// フレームバッファを初期化
var hsv = hsva(count2 % 360, 1, 1, 1);
gl.clearColor(hsv[0], hsv[1], hsv[2], hsv[3]);
gl.clearDepth(1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
// ビュー×プロジェクション座標変換行列
var eyePosition = new Array();
var camUpDirection = new Array();
q.toVecIII([0.0, 20.0, 0.0], qt, eyePosition);
q.toVecIII([0.0, 0.0, -1.0], qt, camUpDirection);
m.lookAt(eyePosition, [0, 0, 0], camUpDirection, vMatrix);
m.perspective(90, c.width / c.height, 0.1, 100, pMatrix);
m.multiply(pMatrix, vMatrix, tmpMatrix);
// トーラスをレンダリング
set_attribute(tVBOList, attLocation, attStride);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, tIndex);
for(var i = 0; i < 9; i++){
var amb = hsva(i * 40, 1, 1, 1);
m.identity(mMatrix);
m.rotate(mMatrix, i * 2 * Math.PI / 9, [0, 1, 0], mMatrix);
m.translate(mMatrix, [0.0, 0.0, 10.0], mMatrix);
m.rotate(mMatrix, rad, [1, 1, 0], mMatrix);
m.multiply(tmpMatrix, mMatrix, mvpMatrix);
m.inverse(mMatrix, invMatrix);
gl.uniformMatrix4fv(uniLocation[0], false, mvpMatrix);
gl.uniformMatrix4fv(uniLocation[1], false, invMatrix);
gl.uniform3fv(uniLocation[2], lightDirection);
gl.uniform3fv(uniLocation[3], eyePosition);
gl.uniform4fv(uniLocation[4], amb);
gl.drawElements(gl.TRIANGLES, torusData.i.length, gl.UNSIGNED_SHORT, 0);
}
// プログラムオブジェクトの選択
gl.useProgram(oPrg);
// フレームバッファのバインドを解除
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
// canvas を初期化
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clearDepth(1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
// 正射影用の座標変換行列
m.lookAt([0.0, 0.0, 0.5], [0.0, 0.0, 0.0], [0.0, 1.0, 0.0], vMatrix);
m.ortho(-1.0, 1.0, 1.0, -1.0, 0.1, 1, pMatrix);
m.multiply(pMatrix, vMatrix, tmpMatrix);
// フレームバッファをテクスチャとして適用
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, fBuffer.t);
// エレメントからグレイスケール化するかどうかのフラグを取得
var gray = eGrayS.checked;
// 板ポリゴンのレンダリング
set_attribute(vVBOList, oAttLocation, oAttStride);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, vIndex);
gl.uniformMatrix4fv(oUniLocation[0], false, tmpMatrix);
gl.uniform1i(oUniLocation[1], 0);
gl.uniform1i(oUniLocation[2], gray);
gl.drawElements(gl.TRIANGLES, index.length, gl.UNSIGNED_SHORT, 0);
// コンテキストの再描画
gl.flush();
// ループのために再帰呼び出し
setTimeout(arguments.callee, 1000 / 30);
})();
正射影によりテクスチャを画面いっぱいにレンダリングする方法は、フレームバッファを用いる処理では時折利用しますので、覚えておくとよいかもしれません。まぁ、手法としてはリアルタイムキューブマッピングなどでも同様のものを使っていますので、ここまで順番にテキストを読み進めてきた人なら問題ないでしょう。
まとめ
さて、グレイスケール変換、いかがでしたでしょうか。
ただ単にレンダリングされるモデルをグレイスケール変換したいだけであれば、一度フレームバッファを介するという今回のような手法を用いなくてもいくらでも処理できます。キューブマップなどを使って背景までレンダリングするのであれば、あえてフレームバッファを使う必要性は薄いでしょう。この辺は、どんな実装を行ないたいのかによって適宜調整すればいいことです。
このようなフィルタ系の処理では、なにかとフレームバッファを使うことが多くなります。フレームバッファについて理解が進んでないうちは、まずそちらから復習することをおすすめします。
今回も、実際に動作するサンプルへ下記のリンクから飛ぶことができます。ソースを見るなり、実際に動作させてみるなり、やってみてください。