Unityのシェーダーでディザリングを実装するには

Contents

ディザリングとは

ディザリングとは、ピクセルを歯抜けにすることによって半透明っぽく見せる技法のことです。等間隔でピクセルの描画スキップすることで、その隙間から後ろのオブジェクトが透けて見えることで半透明に見える、という原理です。古い時代のゲームでよく見られた手法なのですが、最近だとTransparentが使えないディファードレンダリングにおいて再び注目を集めるようになりました。もちろんフォワードレンダリングでも使えます。

ピクセルが歯抜けになっている

パターンの定義

ディザリングのパターン生成には様々な手法が存在しますが、ディザリングフェードによく使われるパターンはBayerMatrixというパターンです。ディザリングと調べるといろいろな手法が検索にひっかかりますが、色に関して変換をかけるアルゴリズムがほとんどで、フェードに使用するには不向きなものが多いです。

BayerMatrix4x4

なお、BayerMatrixの詳しい生成方法はここでは紹介しません。というのも、シェーダー内で計算するメリットがほとんどなく、数値をテーブルとして持っておくだけで十分なためです。数値自体は検索すればいろいろなところで見つかります。より詳しく知りたい方はwikipediaのページ(こちら)を読んでみてください。

さて、ディザリングパターンをあらかじめ定数として宣言しておくには、以下のように書くといいでしょう。Unityでは、二次元配列はエラーにはならないのですが、二段目にアクセスできないので一次配列を使用します。ほかのサイトのサンプルを参考にする場合は注意してください。

static const int pattern[16] = { //staticがないとアクセスできないので注意!
     0,  8,  2, 10,
    12,  4, 14,  6,
     3, 11,  1,  9,
    15,  7, 13,  5
};

スクリーンのピクセル座標を求める

スクリーンに対してディザリングパターンを張り付けるため、スクリーンでのピクセル座標を求めます。ピクセル座標を求める工程は、以下の通りです。

v2f vert (appdata v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex); //頂点をMVP行列変換
    o.screenPos = ComputeScreenPos(o.vertex); //クリップ座標からスクリーン座標を計算
    return o;
}

fixed4 frag (v2f i) : SV_Target
{
    float2 viewPortPos = i.screenPos.xy / i.screenPos.w; //wで除算し、スクリーンでの投影位置を取得
    float2 screenPosInPixel = viewPortPos.xy * _ScreenParams.xy; //0~1の線形からピクセルに変換
}

この計算で重要なポイントは2点です。ComputeScreenPosというマクロと_ScreenParamsという定義済み変数です。

ComputeScreenPosは、クリップ座標からスクリーン座標に変換します。引数に渡す数値はクリップ座標なので、UnityObjectToClipPosで変換したものを入れるといいでしょう。

仮にこの時点で計算された変数viewPortPosを使ってテクスチャを表示すると、下のようにカメラ角度にかかわらずスクリーンに対してテクスチャが貼られたような表現になります。

しかし、このままでは少し都合が悪い部分があります。それは、viewPortPosの値は0~1の線形になっているという点です。最終的には、スクリーンのピクセル単位で描画する・しないを計算したいため、ピクセル単位に変換しておきます。

そこで、スクリーン座標に_ScreenParamsを除算することで、ピクセル単位に変換します。この定義済み変数である_ScreenParamsには、具体的に以下のような要素が定義されています。ターゲットテクスチャという言い方が少しわかりにくいのですが、ディスプレイサイズと読み替えても大丈夫です。

xカメラのターゲットテクスチャの幅(単位:ピクセル)
yカメラのターゲットテクスチャの高さ(単位:ピクセル)
z1.0 + 1.0/幅
w1.0 + 1.0/高さ
https://docs.unity3d.com/ja/2018.4/Manual/SL-UnityShaderVariables.html

clipによって描画しないピクセルを決める

ディザリングの目的は、等間隔でピクセルの描画スキップすることで、その隙間から後ろのオブジェクトが透けて見えることで半透明を表現する、というものです。そのため、先ほど求めたスクリーンのピクセル座標をもとに、描画をスキップするピクセルを求めていきます。

今回のシェーダーでは、ディザリングパターンは4×4のサイズを採用しています。そのため、4で割った余りを求め、それをもとに配列インデックスを計算し、パターン値を取得します。

そして、ピクセルを描画するかどうかを判定するには、clipというメソッドを使います。clipは引数が0以下だと以降の処理をスキップするというメソッドです。

// ディザリングテクスチャ用のUVを作成
int ditherUV_x = (int)fmod(screenPosInPixel.x, 4.0f); //パターンの大きさで割ったときの余りを求める
int ditherUV_y = (int)fmod(screenPosInPixel.y, 4.0f); //今回のパターンサイズは4x4なので4で除算
float dither = pattern[ditherUV_x + ditherUV_y * 4]; //求めた余りからパターン値を取得

clip(dither - _DitherLevel); //閾値が0以下なら描画しない

シェーダー全体

ここまでのコードをすべてまとめると、以下のようになります。

Shader "CAGraphicsAcademy/DitheringFade"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _DitherLevel("DitherLevel", Range(0, 16)) = 1 //ディザリングの具合を調整
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
                float2 uv : TEXCOORD0;
                float4 screenPos : TEXCOORD1;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;
            int _DitherLevel;

            static const int pattern[16] = { //staticがないとアクセスできないので注意!
                 0,  8,  2, 10,
                12,  4, 14,  6,
                 3, 11,  1,  9,
                15,  7, 13,  5
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex); //頂点をMVP行列変換
                o.uv = TRANSFORM_TEX(v.uv, _MainTex); //テクスチャスケールとタイリングを加味
                o.screenPos = ComputeScreenPos(o.vertex); //クリップ座標からスクリーン座標を計算
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                float2 viewPortPos = i.screenPos.xy / i.screenPos.w; //wで除算し、スクリーンでの投影位置を取得
                float2 screenPosInPixel = viewPortPos.xy * _ScreenParams.xy; //0~1の線形からピクセルに変換

                // ディザリングテクスチャ用のUVを作成
                int ditherUV_x = (int)fmod(screenPosInPixel.x, 4.0f); //パターンの大きさで割ったときの余りを求める
                int ditherUV_y = (int)fmod(screenPosInPixel.y, 4.0f); //今回のパターンサイズは4x4なので4で除算
                float dither = pattern[ditherUV_x + ditherUV_y * 4]; //求めた余りからパターン値を取得

                clip(dither - _DitherLevel); //閾値が0以下なら描画しない

                float4 color = tex2D(_MainTex, i.uv); //メインテクスチャからサンプリング
                return color;
            }
            ENDCG
        }
    }
}

まとめ

最近になって、ディザリングはいろいろなところで見かけることが増えてきたように感じます。あらためて実装を精査してみると、それほど原理は難しくないことがわかりますが、スクリーンのピクセル座標を計算する必要があったりと、ちょっと手ごわいシェーダーかもしれません。パターンを変えてみるとまた違ったフェードアウトになるので、いろいろ実験してみてはいかがでしょうか。

返信を残す

メールアドレスが公開されることはありません。