🐈

【WebGL / GLSL】three.jsで、EffectComposer使わないでポストエフェクトをやる!

2021/03/11に公開

はじめに

three.jsを使用してポストエフェクトを実装したい場合、EffectComposerを使用すれば簡単に実装することが可能です。そしてEffectComposerにはいくつもレパートリーがあり、ライブラリ既製品を使うも良し、自分でオリジナルのシェーダーを書くも良しです。

ですが、今回はこの便利神様モジュールを使わずにポストエフェクトをやってみたいと思います。
じゃあ生のWebGL APIから始めようぜ!という話になるのですが、そこだけは妥協してthree.jsを使うチキンぶりをお許しください。

ポストエフェクトとは


出典: three.js examples

ポストエフェクト(ポストプロセス)とは、
3D空間にレンダリングしたシーンに対してエフェクトをかけることです。
上図はグリッチのエフェクトがかかっていますが、
要は2ステップってところがポイントで、
①立方体を3D空間にレンダリングする
②そのレンダリングした結果にグリッチエフェクトを加える
という手順で出来ています。

EffectComposerだとここらへん何も考えずに実現できてしまうのですが、自前でやるとまずはこの基本が大事になってきます。

ポストエフェクトまでの手順

もうちょっと詳しくポストエフェクトの原理に迫ってみようと思います。

上図が大まかな流れです。
結論、我々がディスプレイ上で見ていたのは板ポリに貼られたテクスチャ一枚だったということになります。

え? じゃあ3D空間に描画した円柱はどこでレンダリングしてるんだよ!?という疑問が生まれます。

VRAMとフレームバッファとオフスクリーンレンダリング

繰り返しますが、我々が見ているのは板ポリに貼られたテクスチャ一枚なので、
円柱を描画しているシーンはディスプレイ上に表示させる必要はありません。
このように、ディスプレイに表示させないけどレンダリングさせることを、オフスクリーンレンダリングといいます。

では、どこでオフスクリーンレンダリングを実行しているのか。
それは、GPUのVRAMという場所です。
GPUっていうのは、macでいうと

設定画面の「グラフィックス」欄にあるやつのことです。
私のPCは「AMD Radeon Pro 5500M 8GB」ってやつが搭載されているらしいです。

そして、VRAMというのはGPUの記憶領域のこと。

で、その中に描画結果を記憶させるわけですね。
円柱をレンダリングした結果はVRAMに保存されるわけです。
例えるなら、PS2にてメモリーカードにセーブデータを保存するようなものです。
メモリーカードがVRAMで、セーブデータが円柱のレンダリング結果。

そして、このセーブデータひとつひとつを、フレームバッファと呼びます。
フレームバッファの中身には、深度情報が入った深度バッファや、色情報が入ったカラーバッファなどが入っていて、それら子要素をレンダーバッファと呼びます。
(今回肝となるテクスチャは、カラーバッファーに設定できます。)

ちなみに、ポストプロセスに関係なく、WebGL使うと必ずひとつは暗黙的にフレームバッファが生成されるようです。(知らず知らずのうちに今まで使ってた)
そして重要なのは、我々が見るディスプレイに表示されるのは、このデフォルトのフレームバッファのみということです。

例えばフレームバッファが4つあったとして、それらすべてに描画しても、
デフォルトのフレームバッファ一個しかディスプレイに表示されません。
残り三つはスクリーンにでません。(これがオフスクリーンレンダリング

ポストプロセスは2ステップで描画するので、フレームバッファが2つ必要になります。
そのため、デフォルトのものに加えて新しくひとつ作ります。しかし一つ問題があり。。

フレームバッファを新しく作ると、
新しい方がアクティブになり、デフォルトの方が非アクティブになります。
このアクティブっていう概念なのですが、
要はアクティブになったフレームバッファのみ描画対象となると考えてください。

ディスプレイに表示できるのはデフォルトのフレームバッファなので、最終的にはデフォルトの方をアクティブにする必要があります。

ややこしくなってきたので図解するとこんな感じです。

上図を踏まえてもう一度手順を整理すると、
①フレームバッファBを新しく作成。ここでBがアクティブ状態(描画対象)になる。
②フレームバッファBに、円柱を描画する。
③フレームバッファBにて空のテクスチャを作成し、そのテクスチャに円柱の描画結果を焼き込む。
←← ここまでオフスクリーンレンダリング →→
④デフォルトのフレームバッファAをアクティブにする。
⑤フレームバッファAに、③で作成したテクスチャを貼った板ポリを描画する。
⑥シェーダーを書いて板ポリにポストエフェクトをかける。
こんな感じです。長いですね。。。

生のWebGL APIだとめっちゃ大変

この工程、ライブラリの力を借りずに実装すると結構大変です。
WebGL APIには色々用意されていて、例えば、、

  • フレームバッファを新規作成するgl.createFramebuffer()
  • レンダーバッファーを作成するgl.createRenderbuffer()
  • フレームバッファとレンダーバッファを紐づけるgl.framebufferRenderbuffer()
  • フレームバッファにテクスチャを紐づけるgl.framebufferTexture2D()

...
などなど、気が遠くなります。
WebGLRenderingContext - Web APIs | MDN
MDNのここらへんを見ておけば、地獄を覗けると思います。

さて、ここはthree.jsに頼りましょう。

three.jsだと簡単にできる

めちゃ簡単です。
three.jsにはWebGLRenderTarget
ってやつがおりまして、

new THREE.WebGLRenderTarget();

と書いてあげれば、

  • フレームバッファの新規作成
  • そのフレームバッファに描画したシーンをテクスチャに焼き込み

などを一瞬でやってくれます。
もちろん内部的にはWebGL APIのメソッドをもろもろ設定してくれていると思うのですが、やっぱりthree.jsは偉大です。

実装してみよう

ここからは実際にthree.jsを使用して実装していきます。
簡単な例として、
「球を1個描画して、グレースケールさせるポストエフェクトを与える」例をやってみます。

まずは球を描画

以降、ポイントとなる部分だけコードで記述します。
球を描画します。

const scene = new THREE.Scene();
const geometry = new THREE.SphereGeometry(5, 32, 32);
const material = new THREE.MeshNormalMaterial({
    wireframe: true,
});
const mesh = new THREE.Mesh( geometry, material );
scene.add(mesh);

こんな感じで表示されるのですが、
復習すると新規作成したフレームバッファに球を描画させる必要があるのでした。
今画面で表示されているのは、デフォルトのフレームバッファに描画されているからです。

フレームバッファを作成する

const renderTarget = new THREE.WebGLRenderTarget(1024, 1024, {
    depthBuffer: false,
    stencilBuffer: false,
    magFilter: THREE.NearestFilter,
    minFilter: THREE.NearestFilter,
    wrapS: THREE.ClampToEdgeWrapping,
    wrapT: THREE.ClampToEdgeWrapping
});

render() {
    renderer.setRenderTarget(renderTarget);
    renderer.render(scene, camera);
    //...省略
}

new THREE.WebGLRenderTarget()を使用し、フレームバッファを作成します。
引数について、最初の1024はテクスチャのサイズです。あんまり小さいとボケます。
depthbuffer: falseとありますが、これは深度情報のレンダーバッファを作成するかの選択です。
今回はカラー情報というかテクスチャだけあれば良いので、falseにしておきます。
残りの引数は通常のテクスチャの設定と同じですね。

そして、render関数内部でrenderer.setRenderTarget(renderTarget);とし、新規作成したフレームバッファを有効にします。
ディスプレイに描画されるのはデフォルトのフレームバッファだけなので、この時点でディスプレイは真っ黒になります。

デフォルトのフレームバッファに板ポリを描画する

次に、デフォルトのフレームバッファをいじります。
板ポリを描画しましょう。

import fragment from '../shaders/fragment.glsl';
import vertex from '../shaders/vertex.glsl';

const postScene = new THREE.Scene();
const postGeometry = new THREE.PlaneBufferGeometry(2,2);
const postMaterial = new THREE.ShaderMaterial({
    fragmentShader: fragment,
    vertexShader: vertex,
    uniforms: {
        t1: {type: "t", value: renderTarget.texture},
    },
});

const postMesh = new THREE.Mesh(postGeometry, postMaterial);
postScene.add(postMesh);

板ポリは、楽なのでPlaneBufferGeometryで作ります。
widthとheightはそれぞれ2にしますが、これ2じゃないとディスプレイにぴったり板ポリが合わなくなります。。(詳細は後述)

shaderはShaderMaterialで。
ここでシェーダーに渡しているuniform変数は、さっき新規作成したフレームバッファに描画した球のシーンを焼き付けたテクスチャです。
THREE.WebGLRenderTarget().textureでアクセスできます!

シェーダーにグレースケールの記述します。

vertex.glsl
varying vec2 vUv;

void main() {
    vUv = uv;
    gl_Position = vec4(position, 1.0);
}
fragment.glsl
uniform sampler2D t1;
varying vec2 vUv;

const float redScale   = 0.298912;
const float greenScale = 0.586611;
const float blueScale  = 0.114478;
const vec3 monochromeScale = vec3(redScale, greenScale, blueScale);

void main() {
    vec4 samplerColor = texture2D(t1, vUv.st);
    float gray = dot(samplerColor.rgb, monochromeScale);
    gl_FragColor = vec4(vec3(gray), 1.);
}

ここで頂点シェーダーに注目します。
ポイントは、座標変換を行わないことです。

座標変換を行わない場合、
gl_Positionの座標系は「-1.0から1.0の正立方体」の世界になります。
この立方体の空間は、最終的には内部で我々が見ているスクリーンのサイズに修正される(ビューポート変換)のですが、とにかく座標変換をしないとただの正立方体の空間に描画するだけになります。

なので、ここに描画させるものは-1.0から1.0の間に頂点座標を収めなければなりません。
だからnew THREE.PlaneBufferGeometry(2,2)だったんですね。
これ、

console.log(postGeometry.attributes);

すると、以下の出力結果を得ます。

position.arrayに注目です。これは頂点座標なのですが、
つまりこの板ポリは頂点四つ、
A(-1, 1, 0)、B(1, 1, 0)、C(-1, -1, 0)、D(1, -1, 0)
で構成されているとわかります。

ちゃんとさっきの正立方体におさまりますね!
これがビューポート変換されて、ディスプレイにピッタリおさまります。
なので座標変換しなくて良いわけです。今回は板ポリ置くだけですから。

逆に座標変換してしまうと、正立方体じゃなく視錐台のことを考慮する必要がでてきます。
こうなると、カメラの視野角とか計算してatanで角度だして、、みたいな複雑な計算をするハメになります。

新規作成したフレームバッファを非アクティブにして、デフォルトをアクティブに

このままだと新規作成した方のフレームバッファが有効になっているので、
せっかく板ポリにポストエフェクトかけてもそれがディスプレイに描画されません。

そのため、デフォルトのフレームバッファを有効にします。

render() {
    renderer.setRenderTarget(renderTarget);
    renderer.render(scene, camera);

    renderer.setRenderTarget(null);
    renderer.render(postScene, camera);
    //...省略
}

renderer.setRenderTarget(null);で新規作成したフレームバッファを非アクティブにし、板ポリの方をrenderer.render(postScene, camera);で描画します。
そしたら完成!

まとめ

ちょっと自力でやろうとすると、途端に根本的な知識が必要になりますね。
いかにthree.jsが楽かわかります。

ところで、EffectComposerもちらっとソースコード覗くとTHREE.WebGLRenderTarget()使ってるんですよね。
根本原理がわかるとちょっと楽しくなります。

とはいえ生のWebGL APIで書くのはコワイので遠慮しておきます。

おわり

Discussion