T.TAO
ブログへ戻る
/17 min read/Technical Art

Unity Shader #13 ポストプロセス

#Unity#Shader#TechnicalArt
Unity Shader #13 ポストプロセス

このノートでは画面のポストプロセスについて、実装方法とその原理を扱います。ここでの実装は OnRenderImage コールバックに基づいており、この関数は Built-in レンダリングパイプラインでのみ有効です。URP では別の配線が必要になりますが、原理は同じです。まず Built-in で導入し、最後にパイプラインの移行を扱います。

フルスクリーンシェーダー

Built-in パイプラインで Image Effect Shader を作成します。この種の Shader は通常フルスクリーンエフェクトに使われ、Hidden/ 以下に置かれるため、マテリアル上から直接選択することはできません。

ポストプロセスでは頂点に対して特別な処理を行う必要がほとんどありません。したがって頂点シェーダーには組み込みの vert_img をそのまま使い、フラグメントシェーダーでも自前の v2f ではなく組み込みの v2f_img 構造体を使います。

HLSLShader "Hidden/PPSomeEffect" {
    Properties { } // 一部プロパティは省略
    SubShader {
        Pass {
            CGPROGRAM
            #pragma vertex vert_img
            #pragma fragment frag
            #include "UnityCG.cginc"
            // プロパティ
            fixed4 frag (v2f_img i) : SV_Target { return col; }
            ENDCG
        }
    }
}

ポストプロセスはいずれにせよ既に描画された結果を後から加工するものなので、フラグメントシェーダーの第一歩は常に _MainTex のサンプリングになります。

HLSLfixed4 frag (v2f_img i) : SV_Target {
    fixed4 col = tex2D(_MainTex, i.uv);
    // col に対する何らかの加工
    return col;
}

C# から駆動する

Shader だけでは何も起こりません。カメラに付けたコンポーネントの OnRenderImage が、描画済みのフレームを src として受け取り、結果を dest に書き込みます。

C#[ExecuteInEditMode, RequireComponent(typeof(Camera))]
public class BrightnessEffect : MonoBehaviour
{
    public Shader shader;
    [Range(0f, 3f)] public float brightness = 1f;

    private Material material;

    void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        if (shader == null || !shader.isSupported) { Graphics.Blit(src, dest); return; }
        if (material == null) material = new Material(shader) { hideFlags = HideFlags.HideAndDontSave };

        material.SetFloat("_Brightness", brightness);
        Graphics.Blit(src, dest, material);
    }
}

Graphics.Blitdest にフルスクリーンのクアッドを描き、src_MainTex にバインドして、全ピクセルに対して Shader を走らせます。フォールバックに注目してください。Shader が非対応の場合でも srcdest に blit しなければ、画面は真っ黒になります。

明度 / 彩度 / コントラスト

もっとも単純な三つのエフェクトです。

明度

定義どおり、出力色の RGB に明度係数を掛けるだけです。

HLSLfixed3 finalColor = col.rgb * _Brightness;

彩度

彩度が最低のとき色はグレースケールに近づき、高いときはより鮮やかになります。

まず、色のついた画像を脱色してグレーにするには次の式を使います。

HLSLfixed lum = 0.2125 * col.r + 0.7154 * col.g + 0.0721 * col.b;
fixed3 lumColor = fixed3(lum, lum, lum);

0.2125 R、0.7154 G、0.0721 B という係数は経験式です。人間の目は青よりも緑にはるかに敏感であるため、この比率で合成したグレーがもっとも自然に見えることが知られています。

あとはこの lumColor と元の色を線形補間します。

HLSLfixed3 finalColor = lerp(lumColor, col, _Saturation);

線形補間の計算を思い出しておきます。

Plain Text// 0 <= delta <= 1 が前提
lerp(A, B, delta) = (1 - delta) * A + delta * B

したがって _Saturation が 0 と 1 の間にあるとき、0 に近いほど結果は lumColor(グレースケール)に近づき、1 に近いほど元のサンプル色 col に近づきます。Unity では delta が 1 を超えても lerp は値を返します。ここでは計算を展開しませんが、彩度の文脈では「_Saturation が高いほど鮮やか、0 に近いほど灰色」と理解して差し支えありません。

コントラスト

コントラストが低いとき、すべての色は区別できなくなり、均一なグレー(0.5, 0.5, 0.5)へ収束します。したがってこれも単純な補間で解決できます。

HLSLfixed3 avgColor = fixed3(0.5, 0.5, 0.5);
fixed3 finalColor = lerp(avgColor, col, _Contrast);

エッジ検出

エッジ検出には、まず畳み込みと畳み込みカーネルの概念が必要です。

畳み込み(convolution)は本質的に重み付き総和です。サンプル点を中心に周囲の一定領域を覆い、畳み込みカーネルが定義する重み付けに従ってその点の値を求めます。画像に対して一度畳み込みを行う処理はフィルタリングとも呼ばれます。

たとえば 3×3 の近傍で、カーネル Gy による中心点 P5 の値は次のようになります。

Plain TextP5 = -1 * P1 + (-2) * P2 + (-1) * P3 + 0 * P4 + 0 * P5 + 0 * P6 + 1 * P7 + 1 * P8 + 1 * P9
   = (P7 - P1) + 2 * (P8 - P2) + (P9 - P3)

Sobel が定義したカーネルで画像を畳み込むと、エッジ付近で値が大きくなります。エッジ付近では色が急激に変化し、この差分はまさにそれを測っているからです。

Sobel カーネルは Gx と Gy の二つに分かれています。x 方向と y 方向に一度ずつフィルタをかければ、両方向の勾配が求まります。

つまり、各ピクセルの最終色は自身を含む周囲 9 ピクセルに影響されます。ここで少し厳密にしておくと、これはフルスクリーン Shader なので、受け取る _MainTex はポストプロセス前の結果を保持したテクスチャです。したがって 1 ピクセルの色を計算するとき、実際にサンプリングしているのは _MainTexテクセルです。周囲のテクセルを集める処理は頂点シェーダーで行います。

9 個のテクスチャ座標をまとめて運ぶために、v2f 構造体には uv を 1 つではなく 9 つ持たせます。

HLSLstruct v2f {
    half2 uv[9] : TEXCOORD0;
    float4 pos : SV_POSITION;
};

テクセルの実際の位置は組み込み変数 _MainTex_TexelSize から求めます。_MainTex_TexelSize.x が 1 テクセルの水平サイズ、.y が垂直サイズです。これで周囲の uv は簡単に得られます。

HLSLv2f vert (appdata_img v) {
    v2f o;
    o.pos = UnityObjectToClipPos(v.vertex);

    // 現在の頂点の uv
    half2 uv = v.texcoord;

    // 周囲 9 テクセルの uv
    o.uv[0] = uv + _MainTex_TexelSize.xy * half2(-1, -1);
    o.uv[1] = uv + _MainTex_TexelSize.xy * half2( 0, -1);
    o.uv[2] = uv + _MainTex_TexelSize.xy * half2( 1, -1);
    o.uv[3] = uv + _MainTex_TexelSize.xy * half2(-1,  0);
    o.uv[4] = uv + _MainTex_TexelSize.xy * half2( 0,  0);
    o.uv[5] = uv + _MainTex_TexelSize.xy * half2( 1,  0);
    o.uv[6] = uv + _MainTex_TexelSize.xy * half2(-1,  1);
    o.uv[7] = uv + _MainTex_TexelSize.xy * half2( 0,  1);
    o.uv[8] = uv + _MainTex_TexelSize.xy * half2( 1,  1);

    return o;
}

この手法は v2f により多くの情報を載せる有効な方法です。ここで o.uv[4] だけがその頂点自身のテクスチャ上の位置です。あとはその周囲 9 点をサンプリングして記録します。

エッジ検出は、結局のところグレースケール画像に対する Sobel 畳み込みです。グレースケールは彩度の節で既に用意しました。

HLSLfixed luminance(fixed4 color) {
    return 0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b;
}

Sobel 畳み込み本体は次のとおりです。

HLSLhalf Sobel (v2f i) {
    // Sobel カーネル
    const half Gx[9] = {-1, 0, 1, -2, 0, 2, -1, 0, 1};
    const half Gy[9] = {-1, -2, -1, 0, 0, 0, 1, 2, 1};

    half texColor;
    half edgeX = 0;
    half edgeY = 0;

    // 畳み込み和
    for (int j = 0; j < 9; j++) {
        texColor = luminance(tex2D(_MainTex, i.uv[j]));
        edgeX += texColor * Gx[j];
        edgeY += texColor * Gy[j];
    }

    half edge = 1 - abs(edgeX) - abs(edgeY);

    return edge;
}

edge はエッジ上で 0 に近く、それ以外で 1 に近い値になるので、そのままマスクとして使えます。線の色と元画像を lerp するか、線の色と単色背景を lerp すれば純粋な線画になります。

先に知っておく価値のある落とし穴が二つあります。ひとつめ、輝度ベースの Sobel は、明度が同じで色相だけが違う二色の境界を検出できません。それが問題になるなら、色ではなく深度と法線に対して演算子をかけます。ふたつめ、カーネルはテクセル単位で定義されているため結果は解像度依存です。同じシーンでも低解像度ほど線が太く検出されます。

ガウシアンブラー

ブラーはポストプロセスの主力です。ブルーム、被写界深度、ソフトシャドウはいずれもこの上に築かれます。

半径 r のガウシアンカーネルは、素直にやると (2r+1)² 回のサンプリングになります。しかし 2 次元ガウシアンは分離可能で、二つの 1 次元ガウシアンの積として書けます。したがって水平パスと垂直パスに分ければ、2(2r+1) 回で同じ結果が得られます。半径 3 なら 49 回に対して 14 回です。

Shader 側は一つのフラグメント関数を共有する二つの Pass になり、違いはオフセットの方向だけです。

HLSLhalf4 frag (v2f i) : SV_Target {
    float weight[3] = {0.4026, 0.2442, 0.0545};   // 正規化した 5 タップのガウシアン

    fixed3 sum = tex2D(_MainTex, i.uv[0]).rgb * weight[0];
    for (int it = 1; it < 3; it++) {
        sum += tex2D(_MainTex, i.uv[it * 2 - 1]).rgb * weight[it];
        sum += tex2D(_MainTex, i.uv[it * 2    ]).rgb * weight[it];
    }
    return fixed4(sum, 1.0);
}

C# 側では二枚の一時 RenderTexture を ping-pong させます。

C#void OnRenderImage(RenderTexture src, RenderTexture dest)
{
    int rtW = src.width / downSample;
    int rtH = src.height / downSample;

    RenderTexture buffer0 = RenderTexture.GetTemporary(rtW, rtH, 0);
    buffer0.filterMode = FilterMode.Bilinear;
    Graphics.Blit(src, buffer0);

    for (int i = 0; i < iterations; i++)
    {
        material.SetFloat("_BlurSize", 1.0f + i * blurSpread);

        RenderTexture buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);
        Graphics.Blit(buffer0, buffer1, material, 0);   // 水平
        RenderTexture.ReleaseTemporary(buffer0);

        buffer0 = RenderTexture.GetTemporary(rtW, rtH, 0);
        Graphics.Blit(buffer1, buffer0, material, 1);   // 垂直
        RenderTexture.ReleaseTemporary(buffer1);
    }

    Graphics.Blit(buffer0, dest);
    RenderTexture.ReleaseTemporary(buffer0);
}

ここで実際に効いているのは安価な二つの工夫です。downSample は画面解像度の何分の一かでブラーを実行します。ブラーはそもそも低周波なので劣化は見えず、コストは二乗で下がります。またバイリニアフィルタリングにより、1 回のサンプリングが既に 4 テクセルを無償で平均しています。5 タップのカーネルが 5 ピクセルよりずっと広く見えるのはそのためです。

GetTemporary は必ず ReleaseTemporary と対にしてください。解放を忘れるのは、エディタセッションで VRAM を漏らす古典的な方法のひとつです。

ブルーム

ブルームは三段階です。明るい部分を抽出し、ぼかし、足し戻す。

HLSL// pass 0: 明部の抽出
fixed4 fragExtractBright(v2f_img i) : SV_Target {
    fixed4 c = tex2D(_MainTex, i.uv);
    fixed  val = clamp(luminance(c) - _LuminanceThreshold, 0.0, 1.0);
    return c * val;
}

// pass 3: 合成。_Bloom にはぼかした明部が入っている
fixed4 fragBloom(v2fBloom i) : SV_Target {
    return tex2D(_MainTex, i.uv.xy) + tex2D(_Bloom, i.uv.zw);
}

硬い step ではなく閾値の減算を使っている点が重要です。これによりピクセルは明るくなるにつれて徐々にブルームへ入っていきます。step だと突然現れることになり、動いている画面では激しくちらつきます。

色空間についてもう一点。ガンマ空間ではすべての値がクランプされるため 1 を超える閾値は成立せず、ブルームは 1 未満の閾値で駆動される様式的な効果にとどまります。HDR カメラターゲットを伴うリニア空間では値が実際に 1 を超えるため、1.0 を閾値にでき、物理的な裏づけを持たせられます。同じ Shader でも両者の結果ははっきり違います。ブルームがおかしいときは、パラメータをいじる前に色空間を確認してください。

URP への移行

URP に OnRenderImage はありません。また SRP の内部で Graphics.Blit を直接使うと問題が起きます(現在のカメラターゲットを無視し、XR を壊します)。対応するのは Renderer FeatureRender Pass の組み合わせです。

C#public class PostProcessFeature : ScriptableRendererFeature
{
    class Pass : ScriptableRenderPass
    {
        private Material material;
        private RTHandle temp;

        public override void Execute(ScriptableRenderContext context, ref RenderingData data)
        {
            CommandBuffer cmd = CommandBufferPool.Get("Custom Post");
            RTHandle source = data.cameraData.renderer.cameraColorTargetHandle;

            Blitter.BlitCameraTexture(cmd, source, temp, material, 0);
            Blitter.BlitCameraTexture(cmd, temp, source);

            context.ExecuteCommandBuffer(cmd);
            CommandBufferPool.Release(cmd);
        }
    }

    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData data)
        => renderer.EnqueuePass(pass);
}

実務上変わるのは四点です。

  1. Graphics.BlitBlitter.BlitCameraTexture に、RenderTextureRTHandle になる。
  2. 同一 Pass 内で同じターゲットを読み書きできないため、一時テクスチャを経由する ping-pong は最適化ではなく必須になる。
  3. renderPassEvent がフレーム内のどこでエフェクトを実行するかを決める。半透明の前か後か、組み込みポストスタックの前か後か。
  4. Shader 自体は HLSL と URP の include に移行する。手順は《Unity Shader #3 URP へのアップグレード》で述べたとおり。vert_img / v2f_imgBlit.hlslFullscreenVert ヘルパーに置き換わります。

上で扱った数学はまったく変わりません。変わるのは配線だけです。これは Built-in から URP へ何かを移すときに、いつも繰り返されるテーマでもあります。

このシリーズの記事

Unity Shader
  1. 01Unity Shader #0 数学の基礎
  2. 02Unity Shader #1 レンダーパイプライン
  3. 03Unity Shader #2 コードの基礎
  4. 04Unity Shader #2.1 基本コマンド
  5. 05Unity Shader #3 URP へのアップグレード
  6. 06Unity Shader #4 UV 上での図形描画
  7. 07Unity Shader #5 リムライト
  8. 08Unity Shader #6 スキャンとホログラム
  9. 09Unity Shader #7 アニメトゥーンシェーダー
  10. 10Unity Shader #8 ディゾルブ
  11. 11Unity Shader #9 レンズの雨滴
  12. 12Unity Shader #13 ポストプロセス
  13. 13URP #1 Universal Lit と URP ShaderLab