夏が終わる
まだ暑さは残っているものの、今頃の空は、なーんか切ないものを感じますね。子供の頃の夏休みが終わってしまった喪失感の記憶でしょうか。Twitter で「パトラッシュ、ぼくもう疲れたよ」ってつぶやいたら、その直後においでになったお客さんに「パトラッシュってつぶやいてましたよね」と言われてしまいました。ああ、夏はもう終わってしまうんですね…
夏休み前に学生さんに GPU Gems 3 の Chapter 27. Motion Blur as a Post-Processing Effect を実装してみてって。自分はロクに読みもせずに訳本を渡しました。でも、いきなり投げられても困るかなって思って自分でも実装してみたら、なんだか似て非なるものになってしまいました。はい、文献はちゃんと読みましょうよ私。
Motion Blur
CG におけるアニメーションでは、フレームごとに少しずつ形の異なる静止画を表示することによって、動きを再現します。しかし、その個々の静止画を、被写体が動いているにもかかわらず静止したものとして生成しているなら、それは被写体を無限のシャッタースピードで撮影したのと同じことになります。その結果、フレームとフレームの間の動きがアニメーションに反映されず、ちらつきを感じたり、動きが飛んで見えたり、あるいは異なる動きとして知覚されたりします。これは時間的なエリアシングです。現実の世界では、速く動く物体を人間の目で見た時には残像が発生しますし、カメラで撮影した場合はシャッタースピードに応じたブレが発生します。

このエリアシングを抑制するには、静止画を作成する際に、動きによる画像の明るさの変化に対してローパスフィルタをかける必要があります1。従来、これにはアニメーションのフレームの表示間隔よりも細かく静止画を生成し、それらを合成して1フレームの静止画を作成する手法が用いられてきました。

この方法は表示されるフレーム数より多くの静止画を生成するため、時間がかかってしまいます。CG においてこういうローパスフィルタ、すなわち積分は、本当にいろんなところで立ちはだかりやがる敵だと思います。空間的なエリアシングの抑制では、従来の空間をより細かくサンプリングしてフィルタをかける手法(T-Buffer、A-Buffer、アキュムレーションバッファ [サンプルプログラム]、マルチサンプリング [半透明処理の記事] 等) に対して、Morphological antialiasing (MLAA)2 や NVIDIA の FXAA3、Enhanced Subpixel Morphological Antialiasing (SMAA)4 など、生成された画像に対して後処理 (Post-Processing) により今はやりの「超解像化」みたいなフィルタを適用する手法が提案されています。時間的なエリアシングの抑制、すなわち Motion Blur においても、生成された一枚の静止画に対して後処理でフィルタを適用する手法が提案されています。ほんと Post-Processing はブームですねぇ。
Screen Space Motion Blur
GPU Gems 3 の方法は、まず画素のクリッピング空間における位置(スクリーン上の位置 x、y とデプス値 z、w = 1)にビュー・投影変換の逆変換をかけて、画素のワールド座標上の位置を求めます。次に、それに前のフレームをレンダリングしたときに使ったビュー・投影変換行列をかけて、その画素の前のフレームでのスクリーン上の位置を求めます。そして、この二つのスクリーン上の位置の差から画素の速度ベクトルを求め、その方向にカラーバッファを等間隔にサンプリングします。
この方法は (1) 元のレンダリング手順に手を加えることなく後処理のみで実現でき、(2) カラーバッファとデプスバッファを参照するだけで他の補助的なバッファ (メモリ) を必要としない、という巧妙で効率の良い手法です。しかし、画素の前のフレームにおけるスクリーン上の位置を求めるために用いるビュー・投影変換行列が、すべての画素で同一である (すなわち個々のオブジェクトに対するモデリング変換が反映されない) ために、冒頭の学生さんの研究に応用するには少し難があるようにも思えます。
速度バッファ
そこで、ここでは「速度バッファ」を使った実装を考えてみたいと思います。速度バッファはその画素を通過したオブジェクトの速度を画素ごとに記録するバッファです。このオリジナルがどこにあるのかまた真面目にサーベイしてないので分からない (てゆうか実は GPU Gems 3 の方法も速度バッファを使ってると思い込んでた) んですが、例によって自分なりに適当に実装してみたいと思います。ここでは速度バッファへの書き込みに Multiple Render Target (MRT) を使って1パスの追加で済ます方法と、ジオメトリシェーダで速度バッファに書き込んで2パス追加する方法の二つを試してみました。
Multiple Render Target を使う方法
この方法の手順は以下のようになります。
- 速度バッファを追加したフレームバッファオブジェクトを準備する。
- 頂点のスクリーン上の現在位置を記憶するフィードバックバッファオブジェクトを二つ準備する。
- 1パス目:
- バーテックスシェーダ: 頂点の現在位置を出力するとともに、フィードバックバッファに保存されている以前の頂点の位置と現在の頂点の位置から頂点の速度を求め、それをフラグメントシェーダに送る。
- フラグメントシェーダ: フラグメントの色を出力するとともにフラグメントの速度を出力する。
- 2パス目:
- バーテックスシェーダ: クリッピング空間を覆う1枚の正方形を描く。
- フラグメントシェーダ: 速度バッファの内容をもとにカラーバッファをサンプリングしてフィルタをかけてフラグメントの色を出力する。
フレームバッファオブジェクトの準備
速度バッファはフレームバッファオブジェクト (FBO) を作成する際に、カラーバッファとデプスバッファに加えて用意します。速度バッファとして使うテクスチャには色ではなく速度を格納するので、internalFormat に GL_RGBA32F を指定して浮動小数点テクスチャにします。FBOWIDTH、FBOHEIGHT はフレームバッファオブジェクトのサイズです。
// フレームバッファオブジェクト
static GLuint fbo;
...
// テクスチャ
GLuint tex[2];
glGenTextures(2, tex);
// カラーバッファ用テクスチャ
glBindTexture(GL_TEXTURE_2D, tex[0]);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, FBOWIDTH, FBOHEIGHT, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
// 速度バッファ用テクスチャ
glBindTexture(GL_TEXTURE_2D, tex[1]);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, FBOWIDTH, FBOHEIGHT, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
// デプスバッファ用レンダーバッファ
GLuint rb;
glGenRenderbuffersEXT(1, &rb);
glBindRenderbufferEXT(GL_RENDERBUFFER_EXT, rb);
glRenderbufferStorageEXT(GL_RENDERBUFFER_EXT, GL_DEPTH_COMPONENT, FBOWIDTH, FBOHEIGHT);
glBindRenderbufferEXT(GL_RENDERBUFFER_EXT, 0);
// フレームバッファオブジェクトの作成
glGenFramebuffersEXT(1, &fbo);
glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, fbo);
glFramebufferTexture2DEXT(GL_FRAMEBUFFER_EXT, GL_COLOR_ATTACHMENT0_EXT, GL_TEXTURE_2D, tex[0], 0);
glFramebufferTexture2DEXT(GL_FRAMEBUFFER_EXT, GL_COLOR_ATTACHMENT1_EXT, GL_TEXTURE_2D, tex[1], 0);
glFramebufferRenderbufferEXT(GL_FRAMEBUFFER_EXT, GL_DEPTH_ATTACHMENT_EXT, GL_RENDERBUFFER_EXT, rb);
glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, 0);
フィードバックバッファオブジェクトの準備
フィードバックバッファに使うバッファオブジェクトは、頂点の現在位置を保存する先に使うものと、以前の位置を参照するために使うものの二つを用意します。この二つを描画のたびに入れ替えます。いわゆるダブルバッファリングですね。GLfloat[4] は頂点データのデータ型です。n は頂点数です。このバッファオブジェクトはしょっちゅう書き換えるので、usage は GL_DYNAMIC_COPY にしています。
// フィードバックバッファオブジェクト
static GLuint fb[2];
...
glGenBuffers(2, fb);
glBindBuffer(GL_ARRAY_BUFFER, fb[0]);
glBufferData(GL_ARRAY_BUFFER, sizeof (GLfloat[4]) * n, 0, GL_DYNAMIC_COPY);
glBindBuffer(GL_ARRAY_BUFFER, fb[1]);
glBufferData(GL_ARRAY_BUFFER, sizeof (GLfloat[4]) * n, 0, GL_DYNAMIC_COPY);
このバッファオブジェクトの片方に初期値をいれておかないとプログラム開始直後の1フレーム目が正常に表示できませんが、めんどくさいのでそれは捨てることにします。
1パス目のバーテックスシェーダ
1パス目のバーテックスシェーダでは、次のフレームで頂点の速度を求めるために頂点の現在位置を保存し、現在の頂点の速度をフラグメントシェーダに送ります。
#version 120
...
// 変換行列
uniform mat4 mc; // クリッピング座標系への変換行列
...
// 頂点属性
attribute vec4 pv; // ローカル座標系の頂点位置
...
// 反射光強度
varying vec4 iamb; // 環境光の反射光
varying vec4 idiff; // 拡散反射光
varying vec4 ispec; // 鏡面反射光
// transform feedback
attribute vec4 p0; // スクリーン上での以前の頂点位置
varying vec4 p1; // 現在の頂点位置を保存するフィードバックバッファ
varying vec3 vel; // ラスタライザに送る速度
void main(void)
{
// 陰影計算
...
iamb = ...;
idiff = ...;
ispec = ...;
// 頂点位置の算出
gl_Position = mc * pv;
// 現在の頂点位置を保存する
p1 = gl_Position;
// 頂点のスクリーン上の速度ベクトルをラスタライザに送る
vel = p1.xyz / p1.w - p0.xyz / p0.w;
}
varying 変数 p1 をフィードバックに使う設定は、シェーダプログラムのリンク時に行います。
... (シェーダソースのコンパイル)
// feedback に varying 変数 p1 を使う
static char *varyings[] = { "p1" };
glTransformFeedbackVaryings(program, 1, varyings, GL_SEPARATE_ATTRIBS);
// シェーダプログラムのリンク
glLinkProgram(program);
...
1パス目のフラグメントシェーダ
1パス目のフラグメントシェーダでは、varying で受け取った頂点色 (の補間値) をカラーバッファに格納し、頂点の速度 (の補間値) を速度バッファに格納します。
#version 120
// 反射光強度
varying vec4 iamb; // 環境光の反射光
varying vec4 idiff; // 拡散反射光
varying vec4 ispec; // 鏡面反射光
// 頂点の速度ベクトルの補間値
varying vec3 vel;
void main(void)
{
// カラーバッファへの書き込み
gl_FragData[0] = iamb + idiff + ispec;
// 速度バッファへの書き込み
gl_FragData[1] = vec4(vel, 1.0);
}
2パス目のバーテックスシェーダ
2パス目はクリッピング空間いっぱいの正方形を描くだけですから、ほとんど何もすることはありません。描画する正方形の頂点が (-1、-1、0)、(1、-1、0)、(1、1、0)、(-1、1、0) の4点なら、頂点位置の attribute 変数 pv をそのまま gl_Position に出力します。その際に、クリッピング空間におけるフラグメントの座標値をテクスチャ座標として得られるよう varying 変数も設定します。これはフラグメントシェーダにおいて gl_FragCoord から算出することもできます。
#version 120
// クリッピング空間を覆うポリゴンの頂点位置
attribute vec4 pv;
// 頂点の位置から求めるテクスチャ座標
varying vec2 t;
void main(void)
{
// 頂点位置はそのままラスタライザに送る
gl_Position = pv;
// ラスタライザで頂点位置を補間してクリッピング空間中の画素位置を求める
t = pv.xy * 0.5 + 0.5;
}
2パス目のフラグメントシェーダ
この処理の最大のキモが2パス目のフラグメントシェーダです。あるフレームにおける一つのフラグメントの色は、前のフレームから現在のフレームに至るまでの、その位置における色の平均になります。

このフラグメントの位置をオブジェクトを基準にして考えると次のようになります。

すなわち、あるフラグメントの色は、そのフラグメントの位置における速度方向にあるフラグメントの色を平均したものになります。

したがって、処理対象のフラグメントがオブジェクトの内部にあるとき、そのフラグメントの位置におけるオブジェクトの速度を速度バッファから取り出して、その速度方向の延長線上にカラーバッファをサンプリングして、そのフラグメントの色を決定します。
#version 120
// 露光時間比
const float exp_rate = 0.8;
// 露光遅延
const float exp_delay = 0.3;
// サンプル数
const int samples = 16;
// 色バッファ
uniform sampler2D tex0;
// 速度バッファ
uniform sampler2D tex1;
// スクリーン上の位置
varying vec2 t;
// v 方向の n 画素の色の平均を求める
vec4 average(in vec2 v, in int n)
{
vec4 c = vec4(0.0);
for (int i = 0; i < n; ++i)
{
c += texture2D(tex0, t + v * (float(i) / float(n) * exp_rate - exp_delay));
}
return c / float(n);
}
void main(void)
{
// 速度バッファから速度を取り出す
vec4 v = texture2D(tex1, t);
if (v.a != 0.0)
{
// フラグメントがオブジェクト上ならそこをぼかす
gl_FragColor = average(v.xy, samples);
}
else
{
// カラーバッファの色をフラグメントの色とする
gl_FragColor = texture2D(tex0, t);
}
}
これでも一応ブレは再現されるのですが、オブジェクトのシルエット部分のブレが再現されません。

この方法では、当然ながら、現在のフレームでオブジェクトの内部にないフラグメントでは、過去にオブジェクトが通過していたとしても、それを見落としてしまいます。

そこで、オブジェクトが存在しないフラグメントでは、その周囲の速度バッファをランダムにサンプリングします。そしてオブジェクトが存在するところが見つかったら、その位置の速度でフラグメントを通過したオブジェクトの速度を近似することにして、カラーバッファをサンプリングします。

ランダムサンプリングに使う乱数はメインプログラム側で生成し、uniform 変数の配列を使ってフラグメントシェーダに渡します。
#version 120
...
// 乱数
uniform vec2 rn[16];
...
void main(void)
{
vec4 v = texture2D(tex1, t);
if (v.a != 0.0)
{
// フラグメントがオブジェクト上ならそこをぼかす
gl_FragColor = average(v.xy, samples);
}
else
{
// フラグメントがオブジェクトの外部なら
int count = 0;
vec4 d = vec4(0.0);
for (int i = 0; i < 16; ++i)
{
// そのフラグメントの周囲をランダムにサンプリングして
vec4 p = texture2D(tex1, t + rn[i]);
if (p.a != 0.0)
{
// オブジェクト上のフラグメントが見つかったらそこをぼかす
d += average(p.xy, samples);
++count;
}
}
if (count == 0)
gl_FragColor = texture2D(tex0, t);
else
gl_FragColor = d / float(count);
}
}
しかし、この方法では周囲にゴミが出てしまいました。

そこで、ランダムサンプリングした先の速度をそのまま使うのではなく、現在のフラグメントの位置にその速度を加えることによって、そのフラグメントを通過したオブジェクトの位置を推定し、その位置における速度を使うようにしてみました。
#version 120
...
void main(void)
{
vec4 v = texture2D(tex1, t);
if (v.a != 0.0)
{
// フラグメントがオブジェクト上ならそこをぼかす
gl_FragColor = average(v.xy, samples);
}
else
{
// フラグメントがオブジェクトの外部なら
int count = 0;
vec4 d = vec4(0.0);
for (int i = 0; i < 16; ++i)
{
// そのフラグメントの周囲をランダムにサンプリングして
vec4 p = texture2D(tex1, t + rn[i]);
if (p.a != 0.0)
{
// オブジェクト上のフラグメントが見つかったら
vec4 q = texture2D(tex1, t + p.xy);
if (q.a != 0.0)
{
// その先のフラグメントがオブジェクト上ならそこをぼかす
d += average(q.xy, samples);
++count;
}
d += average(p.xy, samples);
++count;
}
}
if (count == 0)
gl_FragColor = texture2D(tex0, t);
else
gl_FragColor = d / float(count);
}
}
ゴミは幾分少なくなりましたが、それでも出ます。

多分、どこかに考え違いか見落としがあると思うので、誰か教えてください。なお、後で説明するジオメトリシェーダを使う方法では、このゴミは発生しませんでした。
描画
配列変数 vp は現在のビューポートの大きさで、ウィンドウのリサイズ時か glGetIntegerv(GL_VIEWPORT, vp) で得ます。pass1、pass2 はそれぞれ1パス目、2パス目のシェーダプログラムです。p0Loc は pass1 のシェーダプログラムで使用している attribute 変数 p0 の場所です。これはシェーダプログラムをリンクする前に glBindAttribLocation() で設定するか、リンク後に glGetAttribLocation() で得ます。また tex0Loc、tex1Loc はそれぞれ pass2 のシェーダプログラムでテクスチャを参照するために使うサンプラの uniform 変数 tex0、tex1 の場所です。これはシェーダプログラムのコンパイル後に glGetUniformLocation() で得ます。
/*
** ビューポート
*/
static int vp[4];
/*
** シェーダ
*/
static GLuint pass1, pass2;
/*
** 1パス目で使う attribute 変数 p0 の場所
*/
static GLuint p0Loc;
/*
** 2パス目で使うサンプラの uniform 変数 tex0, tex1 の場所
*/
static GLuint tex0Loc, tex1Loc;
フレームバッファオブジェクトへの描画に切り替えて、Multiple Render Target を設定します。一つ目のターゲットをカラーバッファ、二つ目のターゲットを速度バッファに使います。
static void display(void)
{
// フレームバッファオブジェクトへのレンダリングを開始する
glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, fbo);
// レンダーターゲットのリスト
static const GLenum bufs[] =
{
GL_COLOR_ATTACHMENT0_EXT, // 色
GL_COLOR_ATTACHMENT1_EXT, // 速度
};
// レンダーターゲット指定
glDrawBuffers(sizeof bufs / sizeof bufs[0], bufs);
1パス目のレンダリングに使うシェーダを準備します。頂点位置の保存に使うフィードバックバッファと、次のフレームでそれを参照する頂点バッファオブジェクトの設定を行います。その後、Transform Feedback を開始します。
// 1パス目のシェーダ
glUseProgram(pass1);
// フィードバックのソースとなるバッファオブジェクトを選択する
static int select = 0;
glBindBuffer(GL_ARRAY_BUFFER, fb[select]);
// 前のフレームの頂点位置を保存したバッファオブジェクトを attribute 変数 p0 に割り当てる
glEnableVertexAttribArray(p0Loc);
glVertexAttribPointer(p0Loc, 4, GL_FLOAT, GL_FALSE, 0, 0);
// フィードバックのターゲットとなるバッファオブジェクトを選択する
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, fb[select = 1 - select]);
// Transform Feedback 開始
glBeginTransformFeedback(GL_TRIANGLES);
フレームバッファオブジェクトのサイズいっぱいにビューポートを設定し、シーンを描画します。scene() はシーンを描画する関数です。描画が終わったら Transform Feedback を終了し、通常のフレームバッファへの描画に戻します。
// ビューポートの設定
glViewport(0, 0, FBOWIDTH, FBOHEIGHT);
// レンダリング
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glEnable(GL_DEPTH_TEST);
scene(); // シーンの描画
// Transform Feedback 終了
glEndTransformFeedback();
// attribute 変数 p0 のバッファオブジェクトの割り当てを解除する
glDisableVertexAttribArray(p0Loc);
// フレームバッファオブジェクトへのレンダリングを終了する
glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, 0);
// レンダーターゲットを元に戻す
glDrawBuffer(GL_BACK);
2パス目の描画に使うシェーダを準備します。このシェーダは1パス目でデータを格納したカラーバッファと速度バッファをテクスチャとして参照します。rect() はクリッピング空間いっぱいに正方形を描く関数です。
// 2パス目のシェーダ
glUseProgram(pass2);
// カラーバッファのテクスチャ
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, tex[0]);
glUniform1i(tex0Loc, 0);
// 速度バッファのテクスチャ
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, tex[1]);
glUniform1i(tex1Loc, 1);
// 画面いっぱいの矩形の描画
glViewport(vp[0], vp[1], vp[2], vp[3]);
glDisable(GL_DEPTH_TEST);
rect();
// カラーテクスチャの解放
glBindTexture(GL_TEXTURE_2D, 0);
glActiveTexture(GL_TEXTURE0);
// ダブルバッファリング
glutSwapBuffers();
}
ジオメトリシェーダを使う方法
もう気力が尽きてきたので、簡単に書きます。また時間と気力のある時に追加するかもしれません。前述の Multiple Render Target を使う方法では、オブジェクトのないところの処理にいろいろ問題が発生します。これは速度バッファにオブジェクトの移動によって得られる掃引図形、すなわちオブジェクトの軌跡を描くことができれば解決します。

そこで Multiple Render Target の代わりにフレームバッファオブジェクトを二つ用意し、一つ目にカラーバッファ、二つ目に速度バッファを組み込みます。1パス目の処理ではカラーバッファへの書き込みと頂点位置のフィードバックバッファへの保存を行います。2パス目ではジオメトリシェーダを使ってバッファオブジェクトに保存されている現在のフレームと前のフレームの頂点位置を結ぶ掃引図形を生成し、速度バッファに書き込みます。3パス目ではこの速度バッファを使ってカラーバッファの内容をぼかします。

この方法では複数のオブジェクトが交差する場合に手前のオブジェクトのブレしか再現されません (速度バッファの内容も隠面消去処理されるので)。しかしこれは半透明処理と同じ性質の問題であり、対応を考えるのはややこしすぎるので目をつぶります。どうせブレてるんだし。
Cartoon Blur
これをやってるうちに、以前うちの卒業生も卒研で実装していた Cartoon Blur5 のうち、「歪みの効果」はバーテックスシェーダで簡単に実装できるじゃね?って思ったのでやってみました。
平行移動だと、こんな風にゆがみます。

ぐるぐる回転させると、こんな風に歪みます。

研究しなきゃ
学生さんの研究の参考にしてもらうつもりで書き始めたプログラムですが、なんかはまってしまっていろいろ遊んでしまいました。遊んでないで自分も自分の研究しないといけないとは思ってるんですが。これも、掘り下げていけばちゃんとネタになるかもっていうスケベ心がないわけではないんですけど、こっち方面はもう既成のゲームエンジンでやり尽くされてると思うので、やっぱり他のネタを探そうかと思います。
-
Michael Potmesil, Indranil Chakravarty, “Modeling motion blur in computer-generated images,” SIGGRAPH ‘83 Proceedings ↩
-
Alexander Reshetov, “Morphological antialiasing,” Proceedings of the Conference on High Performance Graphics 2009 ↩
-
出典が見つけられん ↩
-
Jorge Jimenez, Jose I. Echevarria, Tiago Sousa, Diego Gutierrez, “SMAA: Enhanced Morphological Antialiasing,” Computer Graphics Forum (Proc. EUROGRAPHICS 2012) ↩
-
川岸祐也、初山和秀、近藤邦雄、”カートゥンブラー : セルアニメーションのための非写実的モーションブラー,” 情報処理学会研究報告 グラフィクスとCAD研究会報告 2002(33) ↩