DirectXの話 第117回

Morphological Anti-Aliasing

今回は Morphological Anti-Aliasing(MLAA) です。

一時期は Cell や Larrabee でもなければ実装は無理と言われていましたが、Lionhead Studio の方が GPU でも実装しました。

その記事は『GPU Pro 2』に掲載されていますし、サンプルプログラムならソースコード付きでWEB上にも存在します。

上記記事では MLAA の理論部分より、それを実装する上での工夫について書かれています。

こちらでもそれを中心に解説していこうと思います。

まず、MLAA の理論を簡単に紹介しておきます。

Deferred MSAA や SRAA は MSAA の延長であり、あるピクセルに対してサブピクセルが存在するものとして扱う技術です。

MLAA は MSAA と全くと言っていいほど違う技術で、自身のピクセルが所属するエッジの形状に応じて周囲とブレンドを行います。

なお、"Morphological" は "形状的な、形態上の" という意味です。

エッジの形状は L型、U型、Z型の3つに分類され、その長さに応じてブレンド率が変化します。

これ以上の説明はしませんが、詳しくは Intel の該当Paperをお読みください。

私もこのPaperを読んだときはピクセルシェーダじゃ無理かな、と思ったものです。

ピクセルシェーダは各ピクセルごとに処理を行う技術のため、エッジ形状という複数ピクセルにまたがるものは処理しづらいはずです。

もしも素直に実装してしまうとかなり重い処理になるはずですが、そこは頭のいい人の考えることなので色々な工夫が存在するわけです。

と言うわけで、順番に処理を見ていきましょう。

MLAAの実装は3パスで行われています。

まず最初のパスではエッジを抽出します。

エッジ抽出方法はどんな方法でもかまいません。

今回のサンプルでは深度、および法線でエッジを抽出していますが、深度を使用できない環境であればカラーや輝度のエッジでもOKです。

あるピクセルについて上下左右の4方向のエッジを調べ、エッジであれば 1.0 を、エッジでなければ 0.0 を出力します。

ここで、エッジは4方向それぞれで取ります。どれかがエッジかどうか、ではなく、全ての部分でエッジかどうか調べ、格納します。

RGBA8バッファに対して、Rが左、Gが上、Bが右、Aが下となります。

サンプルの場合、深度の差が閾値以上か、法線同士の内積が閾値以下かでエッジとして認識しています。

第2のパスではこのエッジ情報からブレンドウェイト値を求めます。ここが重要な部分です。

まず重要なポイントですが、あるピクセルが保持するブレンドウェイト値は自身の上下左右に対するウェイト値ではありません。

各ピクセルは自身の左と上、および自身の左ピクセルの右、自身の上ピクセルの下のウェイト値を保持します。

このようにしている理由としては、各ピクセルのウェイト値を求める際に左と上だけをチェックすればいいからです。

左と上のウェイト値を求めると自ずと相手ピクセルの右、下のウェイト値も求まるんです。

では、自身の上と自身の上ピクセルの下のウェイト値を求めるソースを少し見てみましょう。

[branch]

if( edge.g )

{ // 上がエッジになっている場合

// 左右方向にエッジの距離を求める

float2 start = float2(1.5, 0.0) * screenParam.zw;

float2 delta = float2(2.0, 0.0) * screenParam.zw;

float2 distance = float2(

SearchLeftRight(inPixel.texCoord - start, -delta),

SearchLeftRight(inPixel.texCoord + start, delta) );

// 左右端点のエッジ形状を求める

// 0.25ドット上にずらしてリニアフィルタでサンプリングすると以下のいずれかの値が入ってくる

// 0.0 : フラット

// 0.25 : 上に折れている

// 0.75 : 下に折れている

// 1.0 : エッジがクロスしている

// 左右どちらも左のエッジ情報を調べるため、右は (距離+1) ドットの部分を調べる

float4 coord = inPixel.texCoord.xyxy + float4(-distance.x, -0.25, distance.y + 1.0, -0.25) * screenParam.zwzw;

float shape1 = texEdge.SampleLevel( samLinear, coord.xy, 0 ).r;

float shape2 = texEdge.SampleLevel( samLinear, coord.zw, 0 ).r;

// ウェイト値をエリアテクスチャから求める

weight.rg = GetWeight( distance, float2(shape1, shape2) );

}

左と左ピクセルの右のウェイト値を求める方法も基本的に同じなのでそちらは省略します。

edge には第1パスで求めた自身のエッジ情報が入っています。edge.g は上側エッジである場合に true になります。

上側エッジである場合、このエッジ形状は左右に伸びていると考えられますので、エッジ端点までの長さを左右両方で求めます。

検索するコードは以下の通りです。

float SearchLeftRight( float2 coord, float2 delta )

{

// 検索開始地点は (x -/+ 1) と (x -/+ 2) の中間地点

// 1回のテクスチャフェッチで2ピクセル分の検索を行うため

float edge = 0.0;

int i = 0;

for( ; i < stepSearch; i++ )

{

edge = texEdge.SampleLevel( samLinear, coord, 0 ).g; // 0.0, 0.5, 1.0 のいずれかを取得できる(精度の問題はあるので注意)

[flatten] if( edge < 0.9 ) { break; } // リニアフィルタの精度対策

coord += delta;

}

// 距離をピクセル単位で返す

// 端点まで検索できなかった場合は検索した場所までの距離が返る

return min( 2.0 * i + 2.0 * edge, 2.0 * stepSearch );

}

左右どちらも同じコードを使用してますので、左側に検索した場合を例に動作を解説します。

最初に調べるのは自身のピクセルから 1.5 ピクセル左の edge.g です。

1.5 ピクセル左と言うことは、自身の左のピクセルとそのまた左のピクセルの中間となります。

この場所でリニアフィルタを用いてテクスチャをフェッチします。

すると、取得した edge.g には 0.0、0.5、1.0 のいずれかの値が入ってきます。

なぜなら、各ピクセルの edge.g は 0.0 か 1.0 のみで、両ピクセルが 0.0 なら 0.0 が、1.0 なら 1.0 が、片方だけ 1.0 なら 0.5 が入ってきます。

ただし、精度による誤差があるため、正確にこの3つのどれかが入ってくるわけではないことに注意してください。

1.0 が入ってきた場合は端点ではないのでその地点から今度は 2.0 ピクセル左に移動します。これを stepSearch 回数分だけ続けます。

1.0 以外が入ってきたらエッジ端点に到達していますので、ループを終了します。

最終的に、(ループ回数 * 2 + edge.g * 2) が端点までの長さとなります。

左右の端点が見つかったら今度は形状を調べます。先に紹介した、L型、U型、Z型というやつです。

この形状調査もちょっと面白いです。

形状は左なら端点のピクセルの左(edge.r)、右なら端点+1のピクセルの左(edge.r)を調べます。

この場所のエッジテクスチャをフェッチする際に、0.25 だけ上にずらし、リニアフィルタでフェッチします。

こうするとどうなるか、というと、取得した edge.r には 0.0、0.25、0.75、1.0 のいずれかの値が入ってくることになります。

これらにどういう意味があるかは以下の図をご覧ください。

この図において、A が自身のピクセル、B が端点のピクセル、C が端点の上のピクセルです。

(a) は端点がフラットな場合で、この場合は B,C 双方に 0.0 が入っているため 0.0 となります。

(b) は C の方にエッジが折れた場合で、B = 0.0, C = 1.0 となっているため、edge.r には 0.25 が入ります。

(c) は B の方にエッジが折れた場合で、B = 1.0, C = 0.0 となっているため、edge.r には 0.75 が入ります。

(d) は端点がクロスしている場合で、B,C 双方に 1.0 が入っているため 1.0 を取得できます。

これを左右に対して求めるのですから、例えば片方が 0.0 or 1.0 でもう片方が 0.25 or 0.75 なら L型です。

両方が 0.25 or 0.75 のどちらかであれば U型、どちらかが 0.25 でもう片方が 0.75 なら Z型ということになります。

双方が 0.0 or 1.0 ならブレンドは行われません。

左右の形状とそれぞれの長さによるウェイト値は予め2Dテクスチャに保存しておき、LUTとして使用します。

常に計算するのは無理がありすぎです。出来ないとは言いませんが、LUTにしておいた方が断然速いです。

テクスチャは以下のコードでルックアップします。

float2 GetWeight( float2 distance, float2 shape )

{

// エリアテクスチャは (最大距離+1)*5 のサイズになっている

float areaSize = (maxDistance + 1.0) * 5;

// round( 4.0 * shape ) は形状の値(0.0, 0.25, 0.75, 1.0)からテーブルのベースポイントを求める計算

// リニアフィルタの精度誤差を解消するため round() 命令を用いている

float2 pixCoord = (maxDistance + 1.0) * round( 4.0 * shape ) + distance;

float2 texCoord = pixCoord / areaSize;

return texArea.SampleLevel( samPoint, texCoord, 0 ).rg;

}

LUTはサンプルコードにあるとおり (最大距離+1)*5 の幅と高さを持っています。

このLUTには左右の形状の組み合わせ分(4^2)のテーブルが入っています。

それなら (最大距離+1)*4 でいいのでは?と思う人もいるかもしれませんが、高速化のためにあえて *5 にしてあります。

というのも、形状の数値は 0.0、0.25、0.75、1.0 であり、これに対して round( 4.0 * shape ) をすると 0, 1, 3, 4 となるのです。

この計算で必要なテーブルの基準点を求め、あとは左と右のそれぞれの長さを利用してテーブルを引くだけです。

なお、LUTの生成方法についての解説は行いませんが、Intel の Paper にあるとおりに生成すれば問題ありません。

また、LUTはRG8バッファとなっていますが、ここで R は自身のブレンド値、B は向かい側ピクセルのブレンド値となりますので注意してください。

第2パスはこれと同じことを上下に伸びるエッジに対しても行います。

また、ステンシルバッファを利用することで、第1パスでエッジとして認識されなかった部分を第2パスで処理しないようにすることが可能です。

このようにした方が高速に動作しますので、おすすめです。

第3のパスでは第2パスで生成したブレンドバッファを用いてブレンドするだけです。

自身のピクセルには左と上に対するブレンド値しか入っていないので、自身の右と下のブレンドバッファピクセルもフェッチしましょう。

ブレンド方法については Intel の Paper とはちょっと違うようですが、これが良かったらしいので私もそれに倣いました。

難しいコードはありませんので解説はしません。

サンプルは以下からDLしてください。

Multisample ありで法線深度バッファを描画する必要がないため、Deferred MSAA や SRAA より高速です。その上綺麗。

MSAAと比べたときの弱点としては、通常描画で消えてしまった細い線は補完できないという点です。

例えば金網とか電線とかを描画した際、所々切れてしまっても対応できません。

ただし、これは Deferred MSAA や SRAA でも同様です。ポストエフェクトでしかないので仕方ないですね。

また、文字や画面情報などに対して使用すると思わぬブレンドがされてしまうこともありますので注意してください。

オブジェクト描画→MLAA→画面情報・文字描画とやるのがいいでしょう。

個人的には、Light Pre-Pass に対してはMLAAしかないんじゃないかと考えています。それくらい便利で綺麗。