OpenGLのCompute shaderでアンチエイリアシングを実装する

こんにちはtatsyです。

最近、いろいろな場所でVRやARに関係する技術が盛んに発表されていて、自分も少しは勉強せねばと思っている今日この頃です。

そんなこともあり、最近VRなどで使われるFoveated renderingという技術について調べておりました。そうすると、これらの技術はいかに情報がいらないところのシェーディングを低負荷に行うか、という部分に注力しているようでした。

このような考え方はMultisample antialiasing (MSAA)というアンチエイリアシングの考え方を大本にしているようです。そこで、今回はMSAAをOpenGLの機能ではなくCompute shaderを使って実装して、中身を少し理解してみたいと思います。

MSAAとは?


前述の通り、MSAAとはMultisample antialiasingの略で、1画素に複数のサンプルを取って、それらのサンプルに対するシェーディング結果の平均を取ることで、アンチエイリアシングを実現する技術です。

とはいうものの、MSAAはより高負荷ながらも、より鮮明な画像が得られるSSAA (Subsampling antialiasing) または FSAA (Fullscreen antialiasing) などと呼ばれる技術とは考え方が少し違います。

SSAAでは、1つの画素を評価するために、すべての画素に対して同じ数だけサンプル点を取ります。例えば2×2で4点のサンプルを取ったとすれば、1つのピクセルが占める四角形領域の中で4回シェーディングの評価が行われて、その平均値が実際の画素の出力値になります。

当然ながら、これは通常の画像処理やオフラインのレンダリング処理にも共通するアイディアで、きれいなアンチエイリアシング結果を得ることができます。

一方で、上記の処理では実際に描画したい解像度の2倍の解像度の画像を描画しなければならないため、計算負荷は4倍になってしまいます。

そこでMSAAでは極力シェーディング、特にフラグメントシェーダ (あるいはピクセルシェーダ) におけるシェーディング計算を減らすために深度値を使います。

通常、レンダリングされるシーンにおけるジャギー (ピクセルのギザギザしたノイズのこと) は物体境界にあらわれることが多いということに着目して、深度値がサンプル画素の中で大きく異なれば境界のピクセル、そうでなければ物体内部のピクセルであると判断します。

その結果を使って境界にあるピクセルでだけ、シェーディングするサンプルの数を増やせば、物体内部の画素に対するシェーディング評価は1回でいいので、大幅に計算時間が削減できるはず、というわけです。

一方で、MSAAは物体の境界に対してしか、複数回のシェーディングを実施しないので、例えばテクスチャのサンプリングに対するエイリアシングを抑えることができないという欠点もあります。

参考ページ

MSAAをオンにする方法


MSAAはいろいろと訳がありまして、通常、プログラマがコントロールできる頂点シェーダ → フラグメントシェーダのような枠組みでは実装ができません (と思います) 。

MSAAは多くの場合、OpenGLやDirectXなどのGraphics API側でサポートされていて、OpenGLの場合で言えばMultisamplingという機能をオンにすることでMSAAが実行されるようになります。

この辺りは、いろいろいと詳しく解説してくださっている方がいらっしゃるので、今回はそちらをご参照いただければ幸いです。

参考ページ

Compute shaderを使った実装


今回の実装はGPU Pro 7 (外部ページに飛びます)にて紹介されているDeferred Coarse Pixel Shadingと呼ばれる技術の実装 (GitHub) を参考にしながら作成いたしました。

こちらの実装では、高解像度のG-bufferを作って、そのG-bufferをCompute shaderで参照しながらアンチエイリアシングをしています (Coarse pixel shadingは必ずしもアンチエイリアシングの技術ではないですが)。

以下では、実際のG-bufferの作り方とCompute shaderにおける処理の部分を解説したいと思います。シェーダ部分以外はQtを使って実装されているので、そのほかのフレームワークをお使いの方には、申し訳ないのですが、ソースコードは全て私のGitHubで公開しています。

G-bufferの作成

G-bufferは高解像度、すなわち2×2のサブサンプルをとるのであれば2倍の解像度で作成しておきます。今回のG-bufferには頂点位置、法線方向、拡散反射色、鏡面反射色、光沢度の情報を含めます。コード上では1つのFBOに5つのColor attachmentをくっつけて、これらに対してMultiple render target機能を使って描画しています。

ここで一つ注意なのですが、今回、これらのテクスチャをRGBA32F型などで作成してしまうと、恐ろしくメモリを消費します。そこで、今回のコード(対応部分)では、

  • 頂点位置と法線方向 – RGBA16F
  • 拡散反射色と鏡面反射色 – RGBA8_SNORM
  • 光沢度 – R32F

の型で作成しています。ちなみにRGBでも大丈夫そうな部分をRGBAにしているのはCompute shaderで3チャネルの画像が読めないためです。

G-bufferへの書き出し

書き出しは当然ながらシェーダで行います。対応するシェーダについては以下のソースコードをご覧いただければ幸いです。

少し自分のプログラムの仕様が分かりづらいかもしれないのですが、フラグメントシェーダに対しては、各オブジェクトのテクスチャや反射色などが渡されていて、それをG-bufferに書き込んでいます。また、深度値については頂点位置のw成分に書き込んでいます。

Compute shaderにおける処理

上記のG-bufferに書き出しができたら、これらの情報を使ってCompute shaderでシェーディングをします。ソースコードはこちらです。

#version 450

#define AA_TYPE_NONE 0
#define AA_TYPE_SSAA 1
#define AA_TYPE_MSAA 2

uniform mat4 u_mvMat;
uniform mat4 u_normMat;
uniform vec3 u_lightPos;
uniform int u_aaType;
uniform int u_subsample;

layout(rgba16f, binding = 0) readonly uniform image2D positionMap;
layout(rgba16f, binding = 1) readonly uniform image2D normalMap;
layout(rgba8_snorm, binding = 2) readonly uniform image2D diffuseMap;
layout(rgba8_snorm, binding = 3) readonly uniform image2D specularMap;
layout(r32f, binding = 4) readonly uniform image2D shininessMap;
layout(rgba8_snorm, binding = 5) writeonly uniform image2D renderTarget;

layout(local_size_x = 32, local_size_y = 32) in;

float EPS = 1.0e-8;

vec3 shading(ivec2 pixelPos) {
    vec3 position = imageLoad(positionMap, pixelPos).xyz;
    vec3 normal = imageLoad(normalMap, pixelPos).xyz;
    vec3 diffuse = imageLoad(diffuseMap, pixelPos).xyz;
    vec3 specular = imageLoad(specularMap, pixelPos).xyz;
    float shininess = imageLoad(shininessMap, pixelPos).x;

    vec3 posView = (u_mvMat * vec4(position, 1.0)).xyz;
    vec3 normView = (u_normMat * vec4(normal, 0.0)).xyz;
    vec3 lightPosView = (u_mvMat * vec4(u_lightPos, 1.0)).xyz;

    vec3 V = normalize(-posView);
    vec3 N = normalize(normView);
    vec3 L = normalize(lightPosView - posView);
    vec3 H = normalize(V + L);

    float ndotl = max(0.0, dot(N, L));
    float ndoth = max(0.0, dot(N, H));

    vec3 rgb = diffuse * ndotl + specular * pow(ndoth + EPS, shininess);
    return rgb;
}

vec3 shadingSSAA(ivec2 pixelPos) {
    vec3 rgb = vec3(0.0, 0.0, 0.0);
    for (int i = 0; i < u_subsample; i++) {
        for (int j = 0; j < u_subsample; j++) {
            ivec2 subpixel = pixelPos * u_subsample + ivec2(i, j);
            rgb += shading(subpixel);
        }
    }
    rgb /= (u_subsample * u_subsample);

    return rgb;
}

vec3 shadingMSAA(ivec2 pixelPos) {
    // Edge test
    float zValue = imageLoad(positionMap, pixelPos * u_subsample).w;
    bool isEdge = false;
    for (int i = 0; i < u_subsample; i++) {
        for (int j = 0; j < u_subsample; j++) {
            ivec2 subpixel = pixelPos * u_subsample + ivec2(i, j);
            float zSub = imageLoad(positionMap, subpixel).w;
            if (abs(zValue - zSub) > 1.0e-4) {
                isEdge = true;
                break;
            }
        }

        if (isEdge) {
            break;
        }
    }

    // Shading
    if (isEdge) {
        return shadingSSAA(pixelPos);
    } else {
        return shading(pixelPos * u_subsample);
    }
}

void main(void) {
    ivec2 pixelPos = ivec2(gl_GlobalInvocationID.xy);

    vec3 rgb;
    if (u_aaType == AA_TYPE_NONE) {
        rgb = shading(pixelPos * u_subsample);
    } else if (u_aaType == AA_TYPE_SSAA) {
        rgb = shadingSSAA(pixelPos);
    } else if (u_aaType == AA_TYPE_MSAA) {
        rgb = shadingMSAA(pixelPos);
    }
    imageStore(renderTarget, pixelPos, vec4(rgb, 1.0));
}

こちらには大きく分けて3種類の関数が書かれています。一番上にある shading 関数は名前の通り、G-bufferの情報を使ってシェーディングをする関数です。ここでは通常のBlinn-Phongのシェーディングモデルを使っています。

基本的にその下の2つの関数、すなわち shadingSSAAshadingMSAA は内部でこの shading 関数を呼び出しているだけです。ですが、呼び出し回数に差があります。

見ていただいて分かる通りかと思いますが、 shadingSSAA 関数はサブサンプルの全てで shading 関数を評価して、その平均を取っています。

一方で shadingMSAA 関数は左上のサンプルの深度と他のサンプルの深度がどのくらい違うかを見て、もし違いの大きなサンプルが1つでもあれば、サブサンプルで shading 関数を呼び出しています。

実際のMSAAの場合には深度を取るサンプルがピクセルの中心であること、また、サブサンプルでシェーディングをするときに、プリミティブの番号などを見て、処理を減らすなどしていることを除けば、おおむね考え方はこのようになります。

結果


Crytek Sponzeのシーンを上記のコードを使って実行したところ、アンチエイリアシングなしのものは60FPS以上、SSAAは10FPS 程度、MSAAは30FPS程度となりました。このときの描画環境はGPUがGeForce GTX 960、描画サイズがおよそ1000×1500でした。

描画の結果を比較すると、以下のような感じです。

アンチエイリアシングなし

SSAA

MSAA

ご覧いただいて分かる通り、SSAAは物体境界もテクスチャもアンチエイリアシングされていますが、MSAAは物体境界のみのアンチエイリアシングとなっています。

ちなみに、今回のコードでアンチエイリアシングなしと比べてMSAAが遅くなっているのはG-bufferの作成に時間がかかってしまっているためでした。ですので、描画パイプラインの中に同じような処理を実装できれば、テクスチャメモリに対する書き込み時間等を削減できて、もう少しアンチエイリアシングなしの計算時間に近づくのではないかと思っています。

まとめ


今回はCompute shaderを使ってMSAAに似た処理を実装してみました。実際のMSAAは描画パイプラインの中に処理が組み込まれているのでもっと計算が速くなるかと思いますが、自分でこのような描画パイプラインの一部を変えたようなテストをしてみたい場合には、Compute shaderを使った実装が役に立つのではないかと思いました。

ちなみにDirectXの場合にはAttilaというCPUの描画パイプラインエミュレータというものがあるらしく、こちらを使うと、上記のような描画パイプラインを自分でカスタマイズしたようなコードが書けるらしいです(あまり分かっていません)。

時間があれば、こちらの仕様や使い方についても調べてみたいと思います。

それでは、今回の記事はここまでにしたいと思います。今回も最後までお読みいただき、ありがとうございました。