被写界深度
今回のサンプルの実行結果
まるで現実の世界のような景観
前回はグレアフィルタを扱いました。グレアフィルタを用いると、レンダリング結果のうちの反射光、つまりスペキュラ成分だけがぼんやりとぼやけることで幻想的なシーンを演出できます。使いどころは限定されますが、深みのある光の表現を行うことができるグレアフィルタは有用なテクニックの一つでしょう。
さて、今回も gaussian フィルタを用いたエフェクト処理の一つである被写界深度(depth of field)をやってみたいと思います。
被写界深度は、写真を嗜む方にとっては比較的なじみの深い言葉ではないでしょうか。要は、ピントの合っていない部分がぼやけて写るという、現実世界では当たり前のように起こるカメラの特性を 3D レンダリングで再現しようというわけです。
被写界深度ではピントを合わせたい深度を決めてやり、その深度に応じて、ぼけていないシーンとぼけたシーンとを合成してやる必要があります。ピントが合っている深度の部分では鮮明に、ピントがずれている深度の部分ではぼやけた感じに、最終的なシーンを合成します。そのためには、以下のようなテクニックを組み合わせる必要があります。
- 深度に関する情報をシェーダ間で橋渡しする
- ぼかしたシーンをバックバッファに準備する
- 深度に応じてレンダリング結果を合成する
一番目の深度の橋渡しに関しては、以前シャドウマッピングを行なったときに使ったテクニックであるフレームバッファへの深度のレンダリングを使います。一度、フレームバッファに深度値をレンダリングすることで、異なるシェーダ間で深度値の橋渡しを行うことが可能になります。
参考:シャドウマッピング
二番目のぼかしたシーンのレンダリングには、ここ数回のテキストで扱ってきた gaussian ブラーが活躍してくれます。
三番目の合成に関してはいろいろなやり方があると思いますが、今回はシェーダ内でうまいこと比率を計算して合成してやることにします。
さて、言葉で表すのは簡単ですが、今回はかなり処理が冗長になります。たった一つのシーンをレンダリングするために、それなりに大量の準備が必要になります。臆せずじっくり、見ていきましょう。
ボケとぼかし
厳密な意味では、写真などの撮影で現れる[ ボケ ]と、ガウシアンブラーなどの[ ぼかし ]は異なるものです。
テキスト執筆時には、実は私自身このことを意識さえしていなかったのですが、おおぶ(@oovch)さんが親切に教えてくださいました。
ボケとぼかしの違いについて細かいことを言いだしたらきりがありませんが、得てして言えることは、単なるガウシアンブラーを用いる場合は全体的にぼんやりとした、霞んだような結果になります。現実のカメラや、肉眼が捉える風景とは違い、少々味気ない雰囲気になってしまうことが多いです。
今回のテキストでは、実装をそこまで複雑にしないこと、そして厳密な計算をして負荷が高くなりすぎてしまうことを避けるために、簡易的な被写界深度の実装としてガウシアンブラーを採用しています。厳密な方法による被写界深度の実装は、いずれ……やるかもしれません。
被写界深度に使うシェーダ
さて、まずは最終的な被写界深度を適用したシーンを完成させるまでに、事前にやっておくべき手順を確認しておきます。
まず前提として、今回は四つのシェーダを使います。もうなんかこの時点で若干危ない気配がしますね。
一つ目のシェーダは、モデルをライティングしながらレンダリングするシェーダです。このシェーダを使って一切ぼかしの掛かっていないシーンをレンダリングします。まぁ要するに、このシェーダは普通にレンダリングするためのシェーダです。
二つ目のシェーダは深度値を描き込むためのシェーダです。シャドウマッピングのときとはちょっと違う、カスタム版を今回は使います。なぜそのようにするのかは後述しますが、被写界深度をそれらしく見せるために深度値を少し加工した上でレンダリングします。
三つ目のシェーダは gaussian フィルタを適用するシェーダです。こちらは今回特別なことはなにもしません。以前解説した gaussian フィルタをそのまま流用します。
最後、四つ目のシェーダを使って、ここまでにレンダリングしてきたシーンを合成します。もちろん、深度値を読み出してから、その深度値に応じて複数のシーンを合成するのがこのシェーダの役割になります。
それでは早速、シェーダのソースから順番に見ていきます。
ライティングシェーダ
// 頂点シェーダ
attribute vec3 position;
attribute vec3 normal;
attribute vec4 color;
attribute vec2 texCoord;
uniform mat4 mvpMatrix;
uniform mat4 invMatrix;
uniform vec3 lightDirection;
uniform vec3 eyeDirection;
uniform vec4 ambientColor;
varying vec4 vColor;
varying vec2 vTexCoord;
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.1, 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);
vTexCoord = texCoord;
gl_Position = mvpMatrix * vec4(position, 1.0);
}
// フラグメントシェーダ
precision mediump float;
uniform sampler2D texture;
varying vec4 vColor;
varying vec2 vTexCoord;
void main(void){
gl_FragColor = vColor * texture2D(texture, vTexCoord);
}
こちらは純粋に、頂点シェーダでライティングの計算をしてモデルをレンダリングするシェーダです。フラグメントシェーダ側では、頂点シェーダから送られてきた色情報とテクスチャを合成するだけです。
ちなみに、今回はトーラスをいくつかレンダリングするのですが、あえてテクスチャを使って模様をつけています。そうすることで、ぼやけた部分がわかりやすくなると考えた上での実装です。
続いては深度値を描き込むためのシェーダ。
深度マップ用シェーダ
// 頂点シェーダ
attribute vec3 position;
uniform mat4 mvpMatrix;
varying vec4 vPosition;
void main(void){
vPosition = mvpMatrix * vec4(position, 1.0);
gl_Position = vPosition;
}
// フラグメントシェーダ
precision mediump float;
uniform float depthOffset;
varying vec4 vPosition;
const float near = 0.1;
const float far = 30.0;
const float linerDepth = 1.0 / (far - near);
vec4 convRGBA(float depth){
float r = depth;
float g = fract(r * 255.0);
float b = fract(g * 255.0);
float a = fract(b * 255.0);
float coef = 1.0 / 255.0;
r -= g * coef;
g -= b * coef;
b -= a * coef;
return vec4(r, g, b, a);
}
float convCoord(float depth, float offset){
float d = clamp(depth + offset, 0.0, 1.0);
if(d > 0.6){
d = 2.5 * (1.0 - d);
}else if(d > 0.4){
d = 1.0;
}else{
d *= 2.5;
}
return d;
}
void main(void){
float liner = linerDepth * length(vPosition);
vec4 convColor = convRGBA(convCoord(liner, depthOffset));
gl_FragColor = convColor;
}
先程とは逆に、今度はフラグメントシェーダのほうがコード量が多くなります。
やっていることの基本は、シャドウマッピングのときと同じです。シェーダ内で深度値を読み出すことができる gl_FragCoord.z
を使ってもいいのですが、今回は頂点シェーダから頂点位置をフラグメントシェーダに渡してやり、その位置情報を使って深度値に相当する値を算出するようになっています。
圧倒的にわかりにくいフラグメントシェーダに絞って、詳細に説明していきます。
まず、このフラグメントシェーダが受け取る uniform 変数は一つです。それが float
型の depthOffset
です。これは、被写界深度をシミュレートする際、どこにフォーカスするか、つまりどの深度にピントを合わせるかを決めるために使います。フォーカスする深度値は、HTML に埋め込んだ input 要素から取得するようにしています。
また、定数として三つの float
型定数を定義しています。それが near
・ far
・ linerDepth
の三つです。カメラからどの程度の距離の深度を扱うのかによって、この定数は適宜変更するようにしましょう。本来は uniform 変数として入ってくるようにしたほうがいいのでしょうが、今回は定数で決め打ちしています。
シェーダ内で定義されている関数は二つ。始めに定義されている convRGBA
は深度値をテクスチャへ描き込む際、32 ビット精度で深度をやりとりするためのコンバータとして機能します。この関数の詳細な解説はシャドウマッピングのテキスト内にあります。
もう一つの関数である convCoord
は、ちょっと特殊なことをしているのでじっくり見てみましょう。
convCoord 関数だけを抜粋
float convCoord(float depth, float offset){
float d = clamp(depth + offset, 0.0, 1.0);
if(d > 0.6){
d = 2.5 * (1.0 - d);
}else if(d >= 0.4){
d = 1.0;
}else{
d *= 2.5;
}
return d;
}
この関数は、深度値と、フォーカスするためのオフセット値の二つの引数を受け取ります。まず関数の冒頭で GLSL のビルトイン関数 clamp
を使ってターゲットとなる深度値の範囲を 0 ~ 1 にクランプします。
その後、深度値に応じて三つの処理に分岐します。深度値が 0.6 より大きい場合、1 から深度値を引いた数値に 2.5 を掛けます。逆に、0.4 よりも小さい場合には純粋に 2.5 を掛けます。これはなにをやっているのかというと、フォーカスする深度値の範囲を広げるためにこのようなことをやっています。
冒頭でも説明したとおり、被写界深度ではピントが合っている深度領域ではぼけてないシーンを使います。逆にピントがずれている深度領域は、ぼけたシーンを使います。今回のサンプルでは、この二つのシーンをどの程度の割合で合成するのかを 0 ~ 1 の範囲で決めます。ここで求めた指数が 1 に近ければ近いほど、ぼけていないシーンの比率を大きくして鮮明なイメージになるようにします。逆に指数が 0 に近いほどぼやけたシーンの比率を上げます。
たとえば、ピントを合わせたい深度が、深度全体を 0 ~ 1 の範囲で考えたときにちょうど中間の 0.5 だった場合を思い浮かべてみましょう。
フォーカス位置が 0.5 の場合
これを見るとわかるとおり、純粋にピントの合っている深度だけを指数 1 にすると、指数が 1 となる範囲は非常に狭くなってしまいます。先程も書いたとおり、指数が 1 の部分は鮮明に、そして 0 に近づくほどぼかしていくわけですから、これでは鮮明に映る部分の範囲が非常に狭くなってしまいます。
そこで、さきほどの convCoord
関数の内部では、フォーカスする深度を元に指数を算出する際に、1 の範囲を広げるように処理しています。すると深度マップは次のようになります。
フォーカスする深度範囲を広げた場合
フォーカスする深度の範囲を広げてやることで、鮮明な部分はより鮮明になります。こうすることで、より自然な被写界深度によるレンダリング結果を得ることができるようになります。
深度指数に応じた合成
さて、ぼけていないシャープなレンダリング結果と、gaussian フィルタを用いたぼけているシーン、そして深度マップの全てが準備できたらいよいよ最終レンダリング結果の合成作業に移ります。
ちなみに、ぼやけたシーンを用意するための gaussian フィルタシェーダは、以前のテキストで解説したものをそのまま使っています。ただし、ぼやけたシーンは今回 2 枚準備しています。小さくぼけたシーンと、大きくぼけたシーンの二つを準備し、合成結果がより自然な仕上がりになるようにするためです。
今回は先述のとおり、シェーダ内で各種レンダリング結果の合成を行ないます。それが以下のシェーダ。
合成シェーダ
// 頂点シェーダ
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 depthTexture;
uniform sampler2D sceneTexture;
uniform sampler2D blurTexture1;
uniform sampler2D blurTexture2;
uniform int result;
varying vec2 vTexCoord;
float restDepth(vec4 RGBA){
const float rMask = 1.0;
const float gMask = 1.0 / 255.0;
const float bMask = 1.0 / (255.0 * 255.0);
const float aMask = 1.0 / (255.0 * 255.0 * 255.0);
float depth = dot(RGBA, vec4(rMask, gMask, bMask, aMask));
return depth;
}
void main(void){
float d = restDepth(texture2D(depthTexture, vec2(vTexCoord.s, 1.0 - vTexCoord.t)));
float coef = 1.0 - d;
float blur1Coef = coef * d;
float blur2Coef = coef * coef;
vec4 sceneColor = texture2D(sceneTexture, vec2(vTexCoord.s, 1.0 - vTexCoord.t));
vec4 blur1Color = texture2D(blurTexture1, vTexCoord);
vec4 blur2Color = texture2D(blurTexture2, vTexCoord);
vec4 destColor = sceneColor * d + blur1Color * blur1Coef + blur2Color * blur2Coef;
if(result == 0){
gl_FragColor = destColor;
}else if(result == 1){
gl_FragColor = vec4(vec3(d), 1.0);
}else if(result == 2){
gl_FragColor = sceneColor;
}else if(result == 3){
gl_FragColor = blur1Color;
}else{
gl_FragColor = blur2Color;
}
}
頂点シェーダには、正射影による座標変換行列が入ってきます。つまり、この頂点シェーダがやっていることは正射影で板ポリゴンを一枚レンダリングすることだけです。
難しいのはフラグメントシェーダだと思いますが、これも実はそんなに難しいことはやっていません。
まず前提として、このフラグメントシェーダには四つのテクスチャが入ってきていることを確認しておきましょう。
uniform 変数の sampler2D
型変数四つ、これがテクスチャのインデックスです。深度マップテクスチャ、ぼけていないシーンのテクスチャ、小さくぼけたシーンのテクスチャ、大きくぼけたシーンのテクスチャです。
深度マップテクスチャに関しては、これを描き込む段階で 32 ビット精度で深度をやり取りするために値を変換していますので、これをこのシェーダ内で逆変換して元に戻します。それを一手に引き受けている関数が restDepth
関数ですね。この辺りはシャドウマッピングのテキストで詳しく解説しています。
さて、深度値を逆変換して元に戻したら、そこから各テクスチャの合成比率を算出してやります。フラグメントシェーダの main
関数の冒頭で float
型変数 d
に深度値がまず入ります。さらに 1 から d
を減算して得られる値を使って、ぼやけたシーンをどの程度合成するのかを決めてやります。
今回のサンプルでは、HTML に埋め込んだ input 要素からレンダリング結果を選択できるようになっているため、シェーダ内で uniform 変数である result
の値に応じて出力する色を変化させています。ただ、被写界深度を適用したシーンの結果は destColor
に入るようになっていますので、実際に被写界深度を適用したシーンをレンダリングする際には、今回のサンプルのような無駄な if
文によるネストは必要ありません。
まとめ
さて、だいぶ長いテキストになってしまいました。しかもシェーダの解説をしただけなのに……です。困ったものですね。
今回のサンプルでは、四つのシェーダを使い分けます。さらに、javascript のメインプログラム側ではフレームバッファを五つ生成して利用しています。
- ぼけていないシーンをレンダリングするバッファ
- 小さくぼけたシーンをレンダリングするバッファ
- 大きくぼけたシーンをレンダリングするバッファ
- ぼけたシーン生成用の一時バッファ
- 深度マップレンダリング用のバッファ
gaussian フィルタのテキストでも解説しましたが、ぼかすという作業を行なうためにはどうしても 2 パスでのレンダリングが必要になるため、ぼけたシーンを生成するための一時的なバッファを用意しているのがポイントです。
最終的には、このぼけたシーン用の一時バッファ以外のフレームバッファ四つが、最終レンダリング結果の合成に使われるテクスチャとなります。メインプログラムはかなり冗長になっているのでここでは載せませんが、いくらでも最適化できるようになっています。要は、わかりやすくするために、特殊な記述方法をしないように、できる限りプレーンな処理手順にするようにしたためにこのようなことになっています。
実際に動作するサンプルは以下にリンクがあります。今回は今までのサンプルに比べてバックヤードで処理している部分がかなりありますので、若干動作が重いかもしれません。その点には注意してください。
今回実際に被写界深度を実装してみて、結構扱いが難しいなぁというのが正直な感想です。というのも、手前がくっきり、奥がぼんやりという場合にはそれほど気になりませんが、逆の場合には結構怪しいことになります。手前にあるものがぼやける場合、これを正しくシミュレートしようと思ったら深度に応じてさらに別々にフレームバッファに書き込んでから、うまいことアルファブレンドをかましてやるなど、さらに複雑なプログラムを組んでやる必要があるでしょう。
とは言え、奥にあるものをぼかすだけの被写界深度の実装であれば、実装自体は今回のサンプルよりも簡潔に記述できると思いますし、使いどころさえ間違えなければ非常に魅力的なエフェクトになるでしょう。どのように活用するのかは、これを利用する人次第ということですね。
実際に動作するサンプルをご覧になって、いろいろと感じていただければと思います。