🌈

そろそろShaderをやるパート47 水面の歪みのような表現

2021/11/27に公開

そろそろShaderをやります

そろそろShaderをやります。そろそろShaderをやりたいからです。
パート100までダラダラ頑張ります。10年かかってもいいのでやります。
100記事分くらい学べば私レベルの初心者でもまあまあ理解できるかなと思っています。

という感じでやってます。

※初心者がメモレベルで記録するので
 技術記事としてはお力になれないかもしれません。

下準備

下記参考
そろそろShaderをやるパート1 Unite 2017の動画を見る(基礎知識~フラグメントシェーダーで色を変える)

デモ

水面より後ろにあるオブジェクトがゆらゆらと屈折して見えるようなイメージです。

見た目はシンプルそうですが、考慮しないといけないことが多くて大変でした。

Shaderサンプル

Shader "Custom/Distortion"
{
    Properties
    {
        _DistortionPower("Distortion Power", Range(0, 0.1)) = 0
        [HDR]_WaterColor("WaterColor", Color) = (0,0,0,0)
    }

    SubShader
    {
        Tags
        {
            "Queue" = "Transparent" "RenderType" = "Transparent"
        }

        //不当明度を利用するときに必要 文字通り、1 - フラグメントシェーダーのAlpha値 という意味
        Blend SrcAlpha OneMinusSrcAlpha

        //描画結果をテクスチャーとして取得可能に
        GrabPass
        {
            //GrabPassを持つShaderごとにそれぞれ描画したい内容が異なる場合がほとんどなので、この名前はユニークが望ましい
            //Tagも使えるらしい?
            "_GrabPassTextureForDistortion"
        }

        //揺らぎの表現を頑張る 描画結果を利用する
        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

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

            struct v2f
            {
                float4 vertex : SV_POSITION;
                float2 uv : TEXCOORD0;
                float4 grabPos : TEXCOORD1;
                float4 scrPos : TEXCOORD2;
            };

            //Shader内でグローバルシェーダープロパティーとして定義された深度テクスチャを取得
            sampler2D _CameraDepthTexture;
            //GrabPassの名前と合わせる
            sampler2D _GrabPassTextureForDistortion;
            float _DistortionPower;

            v2f vert(appdata v)
            {
                v2f o = (v2f)0;

                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                //正しいテクスチャ座標を取得
                o.grabPos = ComputeGrabScreenPos(o.vertex);
                //ComputeScreenPosによってxyが0〜wに変換される
                o.scrPos = ComputeScreenPos(o.vertex);

                return o;
            }

            fixed4 frag(v2f i) : SV_Target
            {
                //サンプリングするUVをずらす sin波でゆらゆら
                float2 distortion = sin(i.uv.y * 50 + _Time.w) * 0.1f;
                distortion *= _DistortionPower;
                float4 depthUV = i.grabPos;
                //サンプリング用のUVによる歪みは少し大きくしておく
                //https://catlikecoding.com/unity/tutorials/flow/looking-through-water/
                depthUV.xy = i.grabPos.xy + distortion * 1.5f;
		//深度テクスチャをサンプリング
                float4 depthSample = SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(depthUV));
                //既に描画済みのピクセルの深度情報
                float backgroundDepth = LinearEyeDepth(depthSample);
                //今描画しようとしているピクセルの深度情報
                float surfaceDepth = UNITY_Z_0_FAR_FROM_CLIPSPACE(i.scrPos.z);
                //Depthの差を利用した補間値
                float depthDiff = saturate(backgroundDepth - surfaceDepth);

                //w除算 普段はGPUが勝手にやってくれる
                //補間値を利用してUVをずらして良いピクセルとそのままにするピクセルを塗り分け
                float2 uv = (i.grabPos.xy + distortion * depthDiff) / i.grabPos.w;

                return tex2D(_GrabPassTextureForDistortion, uv);
            }
            ENDCG
        }

        //水面の色だけ描画
        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"


            struct appdata
            {
                float4 vertex : POSITION;
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
            };

            float4 _WaterColor;

            v2f vert(appdata v)
            {
                v2f o = (v2f)0;
                o.vertex = UnityObjectToClipPos(v.vertex);
                return o;
            }

            fixed4 frag(v2f i) : SV_Target
            {
                return _WaterColor;
            }
            ENDCG
        }
    }
}

処理の流れは下記です。
①描画結果をGrabPassで取得
②描画結果を利用し、深度情報に基づいて水面下のオブジェクトを歪ませる
③水面を描画する

①描画結果をGrabPassで取得

GrabPassというPassを利用することで定義した時点での描画結果を取得可能です。

名前を定義し、その名前を以後のPassで利用することができます。

    //描画結果をテクスチャーとして取得可能に
    GrabPass
    {
        //GrabPassを持つShaderごとにそれぞれ描画したい内容が異なる場合がほとんどなので、この名前はユニークが望ましい
        //Tagも使えるらしい?
        "_GrabPassTextureForDistortion"
    }

GrabPassを定義したShaderを複数のオブジェクトに適用する場合、注意が必要です。
同名のGrabPassが存在する場合、フレームごとに最初にGrabPassを利用したオブジェクトにのみテクスチャーの取得が実施されます。

平たく言うとバグるのでGrabPassを受け取る変数は
Shaderごとにユニークな名前を定義した方が良いです。
一方で、GrabPassが増えるとその分、負荷も増すのでそこは要注意です。

②描画結果を利用し、深度情報に基づいて水面下のオブジェクトを歪ませる

GrabPassによって取得した描画結果を歪ませたいところですが、
そのまま利用すると下記GIFのようになります。

何が問題かというと、オブジェクトの水面より手前の部分まで歪んでしまっています。

これは水面の表現として正しくありません。そこで深度情報を利用します。
【参考リンク】:そろそろShaderをやるパート44 深度テクスチャで波打ち際の表現

深度情報を使って歪ませるべき箇所とそうでない箇所を塗分けます。

fixed4 frag(v2f i) : SV_Target
{
    //サンプリングするUVをずらす sin波でゆらゆら
    float2 distortion = sin(i.uv.y * 50 + _Time.w) * 0.1f;
    distortion *= _DistortionPower;
    float4 depthUV = i.grabPos;
    //サンプリング用のUVによる歪みは少し大きくしておく
    //https://catlikecoding.com/unity/tutorials/flow/looking-through-water/
    depthUV.xy = i.grabPos.xy + distortion * 1.5f;
    //深度テクスチャをサンプリング
    float4 depthSample = SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(depthUV));
    //既に描画済みのピクセルの深度情報
    float backgroundDepth = LinearEyeDepth(depthSample);
    //今描画しようとしているピクセルの深度情報
    float surfaceDepth = UNITY_Z_0_FAR_FROM_CLIPSPACE(i.scrPos.z);
    //Depthの差を利用した補間値
    float depthDiff = saturate(backgroundDepth - surfaceDepth);

    //w除算 普段はGPUが勝手にやってくれる
    //補間値を利用してUVをずらして良いピクセルとそのままにするピクセルを塗り分け
    float2 uv = (i.grabPos.xy + distortion * depthDiff) / i.grabPos.w;

    return tex2D(_GrabPassTextureForDistortion, uv);
}

正しい歪みを作る上で、サンプリングするためのUVも歪ませたうえで深度テクスチャを作成しているのですが、その際に生成される下記画像の"汚いやつ"が邪魔でした。

UVを歪ませている箇所で強引に大きめに歪ませて汚い奴を無理やり消しました。歪みが一定の大きさを超えると破綻するため、きれいなアプローチではないと思います。

//サンプリング用のUVによる歪みは少し大きくしておく
depthUV.xy = i.grabPos.xy + distortion * 1.5f;

【参考リンク】:Looking Through Water Underwater Fog and Refraction

③水面を描画する

後は、水面を描画するだけです。
Passを分けて、後から描画しているのはいろいろ検証した結果です。
下記が検証した手法と、その手法が不採用となった理由です。

・歪ませるPassより先に水面を描画するPassを用意する手法
 →歪ませる前のオブジェクトの描画結果もGrabPassで取得してしまう。(画像のように二重になる)

・歪ませるPassにおいて描画結果に加算・乗算する手法
 →GrabPassで既に描画結果が取得されているため、色を上乗せすると望んだ水面の色にならない。(右は乗算結果)

・歪ませるPassにおいて描画結果と水面をlerpなどで塗分ける手法
 →GrabPassで取得した描画結果を利用して歪ませる段階で、水面はまだ描画されていない。つまり、この手法で歪んだ箇所に水面の色を塗ることはできない。(思いつかなかっただけかも。GIFのようになる)

(こちら原因教えてくださった方、感謝です)

参考リンク

Unityシェーダースニペット集
ShaderLab: GrabPass
【Unity】プロジェクション行列は掛けるだけじゃなくてw除算しなきゃダメだよという話
水中の屈折(shader)
Unityゲーム プログラミング・バイブル 2nd Generation

Discussion