JavaScript
WebGL
WebGLDay 1

[WebGL] Bloom表現を実装してみる

More than 3 years have passed since last update.

WebGL Advent Calendar 1日目の記事です。


前々からどうやるんだろうと気になっていた Bloom
どういうものかと言うと、窓から光が差し込んだりして光が溢れている様子を表現する方法です。

今回はこれを実装する方法を書きたいと思います。

[2015.12.01 20:45 追記]
Bloom処理でブラーの処理が不適切だったため、修正しました。

※ 本記事はゲームエフェクトマニアックスを参考にさせてもらいました。

サンプル

UnityのサイトにBloomに関する記事とイメージ画像があったので引用させてもらいます。

▼ Bloomあり
Bloomあり

▼ Bloomなし
Bloomなし

車の窓に反射した太陽の光がふわっと広がっているのが分かるかと思います。
シーンの印象自体もだいぶ違いますね。

リアリティが増すのに加えて、例えばStarWarsのライトセーバーみたいに、自分が光を放つものに適用することでより「らしい」表現にもなります。

今回作ったやつは動作するデモもアップしてあります。

大まかな概要

Bloomの実装にはいくつかのパスを経由して情報を収集しなければなりません。
ざっくり言うと以下の手順となります。

  1. 通常のシーンをレンダリングする
  2. レンダリング結果から輝度の高い部分を抽出する
  3. 抽出したピクセルをぼかす(*)
  4. (3)で生成した、ぼかした結果と(1)のレンダリングを加算合成する
  • ぼかし処理は2パス使って行います。縦方向のぼかしと、それをさらに横にぼかすことで実現しているためです。

※ 今回の記事のためにサンプルを作成しました。Githubにも公開されているのでコード全体などはそちらをご覧ください。

通常のレンダリング

まずは通常のレンダリングを行います。(レンダリング手順についてはここでは触れません)
今回は単純なPlaneにテクスチャを貼ったものを回転させるだけのものです。

通常シーンの頂点シェーダ

normal-scene-vertex
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);
}

なにも特殊なことはしていません。普通に座標変換して、必要なデータをフラグメントシェーダに送っています。

通常シーンのフラグメントシェーダ

normal-scene-fragment
precision mediump float;

uniform sampler2D texture;

varying vec4 vColor;
varying vec2 vTextureCoord;

void main() {
    vec4 texel = texture2D(texture, vTextureCoord);
    gl_FragColor = texel;
}

こちらも、渡されたテクスチャをレンダリングしているだけです。
今回はこのシーンにBloomを加えます。

normal-scene
// 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をかけるという方法を取っています。

bright-vertex
attribute vec3 position;
attribute vec2 textureCoord;

varying vec2 vTextureCoord;

void main() {
    vTextureCoord = textureCoord;
    gl_Position = vec4(position, 1.0);
}

高輝度部分を抜き出すシェーダは、レンダリングされた画面をそのままテクスチャとしつつ、それを画面全体に再レンダリングすることで得ます。
なので頂点は座標変換せず、画面全体となるように頂点データを渡します。

bright-fragment
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 で指定した値以上のピクセルのみを抽出してレンダリングします。
minBright0.0 - 1.0 の間で指定し、1.0 にすると全ピクセルが無視されます)

bright
// 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 の生成は以下のように行っています。

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);

↓高輝度部分を抽出した状態
bright.png

Bloomを生成する

さて、最後の生成処理は Bloom です。
これは、抽出された高輝度部分を「ぼかす」ことで手に入れます。
あふれる光はふわっと周りに光を発散しますよね。その雰囲気をぼかしによって作り出します。
(ただ、本と同じ処理を書いてもうまくぼけませんでした。テクスチャサイズが関係してるのかも・・)

ぼかし処理が間違っていました。直したバージョンに差し替えています。
具体的には、一回のパスで縦横のピクセルを抽出していたのが問題でした。
2パスで、縦と横のサンプリングを別にすることで解消しています。

bloom-vertex
attribute vec3 position;
attribute vec2 textureCoord;

varying vec2 vTextureCoord;

void main() {
    vTextureCoord = textureCoord;
    gl_Position = vec4(position, 1.0);
}

Bloomの頂点シェーダは、実は高輝度抽出シェーダと同じです。
本質的にはどちらも、いわゆるポストエフェクトの類なので、画面全体を覆う平面にレンダリングするイメージです。

bloom-fragment
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側で行い、それをデータとして渡しています。計算はガウシアンブラーのものですね。

[追記]
修正前は縦横のピクセルをひとつのパスでサンプリングしていましたが、これが問題でした。
縦でぼかしたやつを、さらに横にぼかすことで望みのブラーが手に入ります。
なので縦か横かで処理を分けています。

bloom
// 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回に分けて行うことでぼかしを実現します。

ちなみにオフセットなどの計算は事前に行っておきます↓

offset-and-weight
// 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.png

合成して最終結果を表示

さて、最後です。
最後は、最初にレンダリングしたシーンとBloomのシーンを加算合成することで完成です。

result-vertex
attribute vec3 position;
attribute vec2 textureCoord;

varying vec2 vTextureCoord;

void main() {
    vTextureCoord = textureCoord;
    gl_Position = vec4(position, 1.0);
}

3度目ですw
結局のところ、結果画面のレンダリングもポストエフェクト的に処理を行うので頂点シェーダは同じとなります。

result-fragment
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以上だとどんどん色が白く飛んでいきます)

result
// 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 になっていることに注意してください。

結果はこんな感じになります↓
result.png

↓元画像
texture.png

まとめ

さて、実際に自分で作ってみてだいぶ色々と学びがありました。
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 のことを忘れていました。

これを、生成したテクスチャサイズに合わせてあげないと書き込まれるデータがずれてしまって意図した動作になりません。という備忘録。