WebGL Advent Calendar 1日目の記事です。
前々からどうやるんだろうと気になっていた Bloom。
どういうものかと言うと、窓から光が差し込んだりして光が溢れている様子を表現する方法です。
今回はこれを実装する方法を書きたいと思います。
[2015.12.01 20:45 追記]
Bloom処理でブラーの処理が不適切だったため、修正しました。
※ 本記事はゲームエフェクトマニアックスを参考にさせてもらいました。
サンプル
UnityのサイトにBloomに関する記事とイメージ画像があったので引用させてもらいます。
車の窓に反射した太陽の光がふわっと広がっているのが分かるかと思います。
シーンの印象自体もだいぶ違いますね。
リアリティが増すのに加えて、例えばStarWarsのライトセーバーみたいに、自分が光を放つものに適用することでより「らしい」表現にもなります。
今回作ったやつは動作するデモもアップしてあります。
大まかな概要
Bloomの実装にはいくつかのパスを経由して情報を収集しなければなりません。
ざっくり言うと以下の手順となります。
- 通常のシーンをレンダリングする
- レンダリング結果から輝度の高い部分を抽出する
- 抽出したピクセルをぼかす(*)
- (3)で生成した、ぼかした結果と(1)のレンダリングを加算合成する
- ぼかし処理は2パス使って行います。縦方向のぼかしと、それをさらに横にぼかすことで実現しているためです。
※ 今回の記事のためにサンプルを作成しました。Githubにも公開されているのでコード全体などはそちらをご覧ください。
通常のレンダリング
まずは通常のレンダリングを行います。(レンダリング手順についてはここでは触れません)
今回は単純なPlaneにテクスチャを貼ったものを回転させるだけのものです。
通常シーンの頂点シェーダ
attribute vec3 position;
attribute vec4 color;
attribute vec2 textureCoord;
uniform mat4 mvpMatrix;
varying vec4 vColor;
varying vec2 vTextureCoord;
void main() {
vColor = color;
vTextureCoord = textureCoord;
gl_Position = mvpMatrix * vec4(position, 1.0);
}
なにも特殊なことはしていません。普通に座標変換して、必要なデータをフラグメントシェーダに送っています。
通常シーンのフラグメントシェーダ
precision mediump float;
uniform sampler2D texture;
varying vec4 vColor;
varying vec2 vTextureCoord;
void main() {
vec4 texel = texture2D(texture, vTextureCoord);
gl_FragColor = texel;
}
こちらも、渡されたテクスチャをレンダリングしているだけです。
今回はこのシーンにBloomを加えます。
// Pass
{
// メインシーンをレンダリング
// メインシーン用プログラムを使用
gl.useProgram(program1);
gl.viewport(0, 0, width, height);
// デバイス用のバッファにレンダリングするためframebufferをnullに
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
// クリアを実行
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clearDepth(1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
// モデルの状態をアップデート
mat4.rotate(mMatrix, rad, vec3(0, 1, 0), mMatrix);
mat4.multiply(tmpMatrix, mMatrix, mvpMatrix);
// uniform変数にデータをアップロード
gl.uniformMatrix4fv(matrixLocation, false, mvpMatrix);
// 0番のテクスチャを使用
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.uniform1i(textureLocation1, 0);
// メインシーンをdraw
gl.drawElements(gl.TRIANGLES, indexData.length, gl.UNSIGNED_SHORT, 0);
}
通常シーンに渡しているデータはこんな感じです。
高輝度部分を抽出
続いて、上記のレンダリングが完了したシーンの中で輝度が高い部分を抜き出す処理を書きます。
今回はレンダリングが完了したバッファ(つまり画面の状態)をコピーして、それにBloomをかけるという方法を取っています。
attribute vec3 position;
attribute vec2 textureCoord;
varying vec2 vTextureCoord;
void main() {
vTextureCoord = textureCoord;
gl_Position = vec4(position, 1.0);
}
高輝度部分を抜き出すシェーダは、レンダリングされた画面をそのままテクスチャとしつつ、それを画面全体に再レンダリングすることで得ます。
なので頂点は座標変換せず、画面全体となるように頂点データを渡します。
precision mediump float;
uniform sampler2D texture;
uniform float minBright;
varying vec2 vTextureCoord;
void main() {
vec3 texel = max(vec3(0.0), (texture2D(texture, vTextureCoord) - minBright).rgb);
gl_FragColor = vec4(texel, 1.0);
}
フラグメントシェーダでは、パス1でレンダリングされたシーンのキャプチャをテクスチャとして使い、全ピクセル中、minBright
で指定した値以上のピクセルのみを抽出してレンダリングします。
(minBright
は 0.0 - 1.0
の間で指定し、1.0
にすると全ピクセルが無視されます)
// Pass
{
// 輝度を集めるシェーダ
// 現在のバッファの状態をコピー
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, originalScreen);
gl.copyTexImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 0, 0, window.innerWidth, window.innerHeight, 0);
gl.useProgram(program0);
gl.viewport(0, 0, textureWidth, textureHeight);
gl.bindFramebuffer(gl.FRAMEBUFFER, collectBrightBuffer.framebuffer);
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clearDepth(1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, originalScreen);
gl.uniform1i(textureLocation0, 1);
gl.uniform1f(minBrightLocation, 0.5);
gl.drawElements(gl.TRIANGLES, indexData.length, gl.UNSIGNED_SHORT, 0);
}
高輝度抽出シェーダに渡しているデータです。
minBright
を数値で渡していますが、これを外部から指定できるようにすれば色々と調整が可能になります。
また今回は copyTexImage2D
を使って、現在のバッファの情報をテクスチャにコピーしています。
originalScreen
の生成は以下のように行っています。
var originalScreen = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, originalScreen);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, window.innerWidth, window.innerHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.bindTexture(gl.TEXTURE_2D, null);
Bloomを生成する
さて、最後の生成処理は Bloom
です。
これは、抽出された高輝度部分を「ぼかす」ことで手に入れます。
あふれる光はふわっと周りに光を発散しますよね。その雰囲気をぼかしによって作り出します。
(ただ、本と同じ処理を書いてもうまくぼけませんでした。テクスチャサイズが関係してるのかも・・)
ぼかし処理が間違っていました。直したバージョンに差し替えています。
具体的には、一回のパスで縦横のピクセルを抽出していたのが問題でした。
2パスで、縦と横のサンプリングを別にすることで解消しています。
attribute vec3 position;
attribute vec2 textureCoord;
varying vec2 vTextureCoord;
void main() {
vTextureCoord = textureCoord;
gl_Position = vec4(position, 1.0);
}
Bloomの頂点シェーダは、実は高輝度抽出シェーダと同じです。
本質的にはどちらも、いわゆるポストエフェクトの類なので、画面全体を覆う平面にレンダリングするイメージです。
precision mediump float;
uniform sampler2D texture;
#define SAMPLE_COUNT 15
uniform vec2 offsetsH[SAMPLE_COUNT];
uniform float weightsH[SAMPLE_COUNT];
uniform vec2 offsetsV[SAMPLE_COUNT];
uniform float weightsV[SAMPLE_COUNT];
uniform bool isVertical;
varying vec2 vTextureCoord;
void main() {
vec4 color = vec4(0.0);
if (isVertical) {
for (int i = 0; i < SAMPLE_COUNT; i++) {
color += texture2D(texture, vTextureCoord + offsetsV[i]) * weightsV[i];
}
}
else {
for (int i = 0; i < SAMPLE_COUNT; i++) {
color += texture2D(texture, vTextureCoord + offsetsH[i]) * weightsH[i];
}
}
gl_FragColor = vec4(color.rgb, 1.0);
}
Bloom、というかぼかしを行っている部分です。
途中 for
文で生成している color
を見ると、オフセットと重み付けを考慮したテクセルを足しこんでいるのが分かるかと思います。
オフセットと重み付け自体はJS側で行い、それをデータとして渡しています。計算はガウシアンブラーのものですね。
[追記]
修正前は縦横のピクセルをひとつのパスでサンプリングしていましたが、これが問題でした。
縦でぼかしたやつを、さらに横にぼかすことで望みのブラーが手に入ります。
なので縦か横かで処理を分けています。
// Pass
{
// Bloom1を生成するシェーダ
gl.useProgram(program2);
gl.viewport(0, 0, textureWidth, textureHeight);
gl.bindFramebuffer(gl.FRAMEBUFFER, bloomBuffer.framebuffer);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, collectBrightBuffer.texture);
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clearDepth(1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.uniform1i(textureLocation2, 0);
gl.uniform1i(isVerticalLocation, true);
gl.uniform2fv(offsetsLocationV, offsetV);
gl.uniform1fv(weightsLocationV, weightV);
gl.drawElements(gl.TRIANGLES, indexData.length, gl.UNSIGNED_SHORT, 0);
}
// Pass
{
// Bloom2を生成するシェーダ
gl.useProgram(program2);
gl.viewport(0, 0, textureWidth, textureHeight);
gl.bindFramebuffer(gl.FRAMEBUFFER, collectBrightBuffer.framebuffer);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, bloomBuffer.texture);
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clearDepth(1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.uniform1i(textureLocation2, 0);
gl.uniform1i(isVerticalLocation, false);
gl.uniform2fv(offsetsLocationH, offsetH);
gl.uniform1fv(weightsLocationH, weightH);
gl.drawElements(gl.TRIANGLES, indexData.length, gl.UNSIGNED_SHORT, 0);
}
オフセットと重み付けのデータを送ると同時に、縦横どちらのぼかしを行うかを boolean
で送っています。
これを縦横2回に分けて行うことでぼかしを実現します。
ちなみにオフセットなどの計算は事前に行っておきます↓
// Sampling
var SAMPLE_COUNT = 15;
var offsetH = new Array(SAMPLE_COUNT);
var weightH = new Array(SAMPLE_COUNT);
{
var offsetTmp = new Array(SAMPLE_COUNT);
var total = 0;
for (var i = 0; i < SAMPLE_COUNT; i++) {
var p = (i - (SAMPLE_COUNT - 1) * 0.5) * 0.0006;
offsetTmp[i] = p;
weightH[i] = Math.exp(-p * p / 2) / Math.sqrt(Math.PI * 2);
total += weightH[i];
}
for (var i = 0; i < SAMPLE_COUNT; i++) {
weightH[i] /= total;
}
var tmp = [];
for (var key in offsetTmp) {
tmp.push(offsetTmp[key], 0);
}
offsetH = new Float32Array(tmp);
}
var offsetV = new Array(SAMPLE_COUNT);
var weightV = new Array(SAMPLE_COUNT);
{
var offsetTmp = new Array(SAMPLE_COUNT);
var total = 0;
for (var i = 0; i < SAMPLE_COUNT; i++) {
var p = (i - (SAMPLE_COUNT - 1) * 0.5) * 0.0006;
offsetTmp[i] = p;
weightV[i] = Math.exp(-p * p / 2) / Math.sqrt(Math.PI * 2);
total += weightV[i];
}
for (var i = 0; i < SAMPLE_COUNT; i++) {
weightV[i] /= total;
}
var tmp = [];
for (var key in offsetTmp) {
tmp.push(0, offsetTmp[key]);
}
offsetV = new Float32Array(tmp);
}
だいぶ冗長になっているのはご愛嬌。
まずは動かすことを再優先に書きました;
データのパックなどうまく設計すればシェーダに渡すデータもシンプルにできると思います。
合成して最終結果を表示
さて、最後です。
最後は、最初にレンダリングしたシーンとBloomのシーンを加算合成することで完成です。
attribute vec3 position;
attribute vec2 textureCoord;
varying vec2 vTextureCoord;
void main() {
vTextureCoord = textureCoord;
gl_Position = vec4(position, 1.0);
}
3度目ですw
結局のところ、結果画面のレンダリングもポストエフェクト的に処理を行うので頂点シェーダは同じとなります。
precision mediump float;
uniform sampler2D originalTexture;
uniform sampler2D bloomTexture;
uniform float toneScale;
varying vec2 vTextureCoord;
void main() {
vec4 texel = vec4(0.0);
texel = texture2D(originalTexture, vTextureCoord) * toneScale;
texel += texture2D(bloomTexture, vTextureCoord);
gl_FragColor = texel;
}
こちらが合成している部分。加算合成なので、渡された通常シーンのテクスチャとBloomのテクスチャのピクセルを +
で連結しています。
通常シーンの方は toneScale
がありますが、トーンの調整のためです。値は 0.0 - 1.0
です。(1.0以上だとどんどん色が白く飛んでいきます)
// Pass
{
// 最終結果シーン
gl.useProgram(program3);
gl.viewport(0, 0, width, height);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clearDepth(1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, originalScreen);
gl.uniform1i(textureLocation3, 0);
gl.uniform1f(toneScaleLocation, 0.7);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, collectBrightBuffer.texture); // ※1
gl.uniform1i(textureLocation4, 1);
gl.drawElements(gl.TRIANGLES, indexData.length, gl.UNSIGNED_SHORT, 0);
}
最終結果のシーンには、通常シーンのキャプチャと、Bloomのテクスチャを渡すのみです。
※1 今回のサンプルでは高輝度収集→ぼかし縦→ぼかし横と3パスでデータを収集しています。
それぞれのフェーズでテクスチャを用意してもいいのですが、今回は高輝度収集で使用したテクスチャを最後のぼかし処理を手に入れるために使いまわしているため、結果画面で利用しているバッファは collectBrightBuffer
になっていることに注意してください。
まとめ
さて、実際に自分で作ってみてだいぶ色々と学びがありました。
WebGL自体を生で書くことが減っていたので楽しかったのと、分かった気でいた部分が意外と分かっていなくて、それがクリアになったのは別の意味で収穫でした。
そして作ったものが思った以上に重くて、だいぶ改善の余地があるなーという点です( ;´Д`)
Advent Calendarの都合上ちょっと時間がなかったですが、時間を見てブラッシュアップしていきたいと思います。
その他気づいたメモ
gl.activeTexture
enable
的なニュアンスで捉えていたんですが、アクティブの名が示す通り、「現在」アクティブなテクスチャユニットを指定し、続くテクスチャに関する操作が影響するテクスチャユニット番号を指定する必要があります。
なので、
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.activeTexture(gl.TEXTURE0);
gl.uniform1i(location, 0);
とした場合、TEXTURE1
番のテクスチャユニットにバインドされ、その後に 0
番がアクティブになりますが、バインドされているのは 1
番なので適切にテクスチャが参照されません。
ポイントは「現在の」アクティブなテクスチャユニットなので、常に操作したいテクスチャユニットをアクティブに切り替える必要がある、という点です。
gl.viewportを忘れずに
今まで色々サンプルを作ったりしていましたが、基本的に画面サイズと同じサイズのバッファを用意して作成していました。
今回、負荷軽減のためにテクスチャサイズを小さくしてサンプリングする、ということをやった際に、意図しない結果になって「???」だったんですが、すっかり gl.viewport
のことを忘れていました。
これを、生成したテクスチャサイズに合わせてあげないと書き込まれるデータがずれてしまって意図した動作になりません。という備忘録。