tanaka's Programming Memo

プログラミングについてのメモ。

水面やUnlitシェーダーとDepth of Fieldの不具合対策メモ

Depth of Fieldと相性が悪いもの

オブジェクト指向を勉強するための素材となる海賊をモチーフにしたスゴロクもどきゲームを開発しています。

制作中の海賊スゴロク(仮)

箱庭っぽいイメージを狙ってPost ProcessingのDepth of Field(DOF:被写界深度)を設定して、水面はフリーながらカッコいいLowPoly Waterを組み込みました。いい感じでできたと思ったのですが、水面に配置したFadeの波紋オブジェクトが消えたり、手前にある水面がボヤけたりと謎の症状が発生しました。それらの問題の原因と解決策の備忘録です。

目次

問題

問題を検証するためのプロジェクトを作成しました。手元で確認したい場合にご活用ください。セットアップ方法はリポジトリのREADME.mdにあります。

github.com

3つ並んでいるうちの真ん中のCubeは正常に描画されています。Materialの設定は、シェーダーはStandard、Rendering ModeはOpaqueです。Cutoutでも正常に描画できます。

FadeとUnlitと水面

左右のCubeも同じ奥行きに配置しているのですが、以下のような問題が起きてます。

  1. 左のCubeの下の消失。StandardシェーダーのRendering ModeをFadeにすると発生します。Transparentでも同じ状態になります
  2. 右のCubeのボヤけ。Unlitシェーダーで描画しています
  3. 真ん中のCubeの下の水面のボヤけ。遠くにあるようにボヤけていますが、水面は立方体の直下です

原因

1. FadeやTransparentが消える

この症状は、FadeやTransparentのオブジェクトと、LowPoly Waterで作成した水面オブジェクトの位置関係で発生します。

消えたり現れたり

上からみるとこんな感じ

水面の中心がCubeの中心よりカメラに近くなると不具合が発生します。

LowPoly Waterの水面のRender Queueは、FadeやTransparentと同じ3000に設定されています。Render Queueは、描画処理の仕方や描画順を決める値です。詳しくは ShaderLab: SubShader 内のタグ - Unity マニュアルレンダリング順 - Queue タグにあります。

Render Queueは描画順などを決める設定で、値が小さい方から描画します。Render Queueが同じ値の時は、2500以下かどうかでルールが変わります。2500以下は不透明扱いになり、Zバッファ(奥行きバッファ=デプスバッファ)への書き込みとテストをしながら手前から奥へ描画していきます。Zバッファより奥のものは見えないはずなので描画を省いて時間を節約します。2500より大きい場合は半透明として扱います。奥の物が透けて見える可能性があるので、Zバッファは更新せず奥から手前に上書きしていきます。

ここで問題となるのが水面のシェーダーです。LowPoly Waterは独自のWaterShadedシェーダーで水面を描画します。Zバッファを参照して、水面下の不透明物と水面の距離に応じて波を表現したり、水面下を半透明で描いたりしています。この処理が描画済みの半透明のオブジェクトを考慮していないため、FadeやTransparentのオブジェクトが奥にあって先に描画されていた場合に水面が上書きして消してしまうのです。

2. Unlitのオブジェクトがボヤける

ボヤけるのはDepth of Fieldの効果ですが、Unlitのオブジェクトの奥行きが正しく反映されずボヤけ過ぎてしまっています。よく観察してみると位置によってボヤけ方が変わっています。

水面の下を観察

赤で囲った部分とそれより下では、囲んだ範囲の方が少しだけボヤけ具合が弱くなっています。また、左のFadeのCubeの表示範囲と赤で囲った部分が一致しています。FadeやTransformはZバッファに奥行きを書き込まないので、奥にある不透明なものまでの距離に応じたエフェクトがかかっているのです。Unlitも原因は同じです。Unlitは不透明でもZバッファに奥行きが書き込まれないのです。

Zバッファに書き込まれる時の条件が書かれている公式マニュアル。

docs.unity3d.com

以下の3つの条件が成立している状態でShadow Casterパスを描画する時に、ハードウェアがZバッファに奥行きを書き込みます。

  • マテリアルのRender Queueが2500以下
  • ZWrite On
  • ShadowCasterのパスが有効であること

Unlitは光の影響を受けないので影が描画されません。つまり、Zバッファを書き込むShadowCasterパスがないのです。Depth of Fieldが正しく反映されないのはこれが理由です。

3. 水面がボヤける

水面シェーダーのRender Queueが3000なので、水面の高さはZバッファに書き込まれません。どうせ半透明の描画順を調整するので、水面シェーダーを不透明なQueueにして、Shadow Casterを有効にすれば解決と思ったのですが、別の問題が発生しました。

水面が真っ白に!

これは開発中の海賊スゴロクで水面の高さをZバッファに書き込んだ時のスクショです。ボヤけは解決しましたが水面が真っ白になっています。水面の高さをZバッファに書き込んだことで水面のすぐ下に物があると水面シェーダーが判定してしまい、波の白で塗りつぶしてしまうのです。水系のシェーダーはこの辺りの対策が必要です。

これで全ての原因が分かったので解決していきます。

解決編

FadeとTransparentの消失とボヤけ問題

消失問題はRender Queueを調整して水面を先に描画するようにすれば解決します。更にボヤけを防ぐには、Render Queueを2500以下、かつ、Shadow Casterパスの追加をしたカスタムシェーダーを作ります。

新規にUnlitシェーダーを作成して、ZBufferShaderのような名前にして以下のようにします。

Shader "Unlit/ZBufferShader"
{
    SubShader
    {
        Tags { "Queue"="AlphaTest+1"}
        LOD 100

        Pass
        {
            Tags { "LightMode"="ShadowCaster"}

            ZWrite On
            ColorMask 0
        }
    }
}
  • Render QueueはAlphaTest+1として、Cutoutより後で描画します
  • ColorMask 0は色もアルファ値も書き込まないようにする設定で、Zバッファだけ書き込みます

StandardシェーダーのFadeでの描画に加えて、このシェーダーでもCubeを描画します。Standardシェーダーに手を加えるとか、自前で後付け描画するなどのスマートな方法が考えられますが、今回はMesh Rendererに複数マテリアルを設定することにします。

このシェーダーを設定したマテリアルを作成して、FadeのCubeのMesh Rendererにテクスチャを追加します。

Zバッファ専用マテリアル

「同じメッシュを別のマテリアルでそれぞれ描画するからパフォーマンス落ちるぞ。マルチパスを推奨」と言われてます。しかし、マルチパスでRender Queueを変える方法がなさそうだったので承知の上で押し通します。

これで問題が解決です。真っ白な部分がありますが、これは水面シェーダーが原因なので後で直ります。

水面シェーダーの影響で真っ白

水面がなければ大丈夫

注意!!

不透明なQueueにしたことで、手前から奥に描画することになります。そのため、このMaterialを設定したFadeやTransparentのオブジェクト同士が重なると奥にあるものが描画されなくなります。この辺りを完全に解決する手段はなさそうで、Render Queueをいじるなどして調整するようです。

Materialに表示されている警告が気になる場合は、自前でスクリプトを作成して描画すれば表示されなくなります。そのものズバり欲しい情報がLIGHT11さんのブログにありました。

light11.hatenadiary.com

やってることはMesh Rendererに警告されたことを自前のスクリプトに移動しただけのような気がするので、警告は消えますがパフォーマンス的には同じような気がします。本題の海賊スゴロクではこの問題は対応しなかったので今回は手を出しませんでした。サブメッシュが絡んできたら必要になるかも知れませんし、実際にはパフォーマンスの違いがあるかも知れません。その辺の問題が出た時にまた調査ということにします。

Unlitシェーダーのボヤけ問題

カスタムのUnlitシェーダーを作成して、ShadowCasterを有効にしたパスを追加します。これはよくある問題のようであちこちで解決策が見つかりました。公式を参考に解決します。

docs.unity3d.com

ついでにデフォルトだと半透明に対応していないので対応させました。新規にUnlitシェーダーを作成して、UnlitZBufferという名前にして以下のコードを書きます。

Shader "Unlit/UnlitZBuffer"
{
    Properties
    {
        _Color("Main Color", Color) = (1,1,1,1)
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue" = "AlphaTest+1" "IgnoreProjector"="True"}
        LOD 100

        Pass
        {
            Tags {"LightMode"="ShadowCaster"}
            ZWrite On
            ColorMask 0
        }

        Pass
        {
            Blend SrcAlpha OneMinusSrcAlpha

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            // make fog work
            #pragma multi_compile_fog

            #include "UnityCG.cginc"

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

            struct v2f
            {
                float2 uv : TEXCOORD0;
                UNITY_FOG_COORDS(1)
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;
            fixed4 _Color;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                UNITY_TRANSFER_FOG(o,o.vertex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                // sample the texture
                fixed4 col = tex2D(_MainTex, i.uv) * _Color;
                // apply fog
                UNITY_APPLY_FOG(i.fogCoord, col);
                return col;
            }
            ENDCG
        }
    }
}

今回は追加パスとかは不要なので、既存のMaterialのシェーダーをこれに変更して解決です。

カスタムUnlitZシェーダー適用!

半透明の部分が変な感じになりますが、これも水面の問題です。中央のCubeの色が変わるのはgif化した時の影響なので実際には起きません。

水面のボヤけ問題

この解決には、水面シェーダー用のカメラが必要になります。解決のヒントは先日ゲットしたTanuki Digital - Asset StoreさんのSUIMONO Water Systemで見つけました。

assetstore.unity.com

確認したところ、水の演出のために沢山のカメラが使われていました!!

カメラ軍団!!

水面と水面下でそれぞれDOFを適用するなら別の奥行きが必要になりますし、水面下の半透明物は?とか考え出すと水系は大変だなと実感しました。優秀なアセット作者の皆様に感謝!!

水面シェーダー用のカメラの作成

水面下のZバッファを描画する水面用のカメラを作ります。メインカメラと同じものが必要なので、分かりやすくメインカメラの子供にしました。描画前に位置を合わせればいいので実際には場所はどこでも良さそうです。SUIMONOでは水面用のオブジェクトにまとめられてました。

  • HierarchyウィンドウのMain Cameraを右クリックして、Cameraを追加します
  • 追加したCameraのAudio ListenerをRemoveします
  • 新規でC#スクリプトを作成して、名前をUnderWaterCameraにして、以下のようにします
using UnityEngine;
using UnityEngine.Rendering;

[ExecuteInEditMode]
public class UnderWaterCamera : MonoBehaviour
{
    /// <summary>
    /// RenderTextureのサイズ
    /// </summary>
    static int renderTextureSize => 512;

    /// <summary>
    /// ビット数
    /// </summary>
    static int renderTextureDepth => 24;

    Camera sourceCamera;
    Camera underWaterCamera;
    RenderTexture underWaterTex;
    float currentAspect;

    private void Awake()
    {
        sourceCamera = transform.parent.GetComponent<Camera>();
        underWaterCamera = GetComponent<Camera>();
        underWaterCamera.CopyFrom(sourceCamera);
        underWaterCamera.cullingMask = underWaterCamera.cullingMask & (-1 ^ LayerMask.GetMask("Water"));
        underWaterCamera.clearFlags = CameraClearFlags.Depth;
        underWaterCamera.depth = -100;
        underWaterCamera.depthTextureMode = DepthTextureMode.Depth;
        underWaterCamera.targetTexture = null;
        UpdateRenderTex();
    }

    private void LateUpdate()
    {
        UpdateRenderTex();
        Shader.SetGlobalTexture("_WaterDepthTex", underWaterTex);
    }

    private void OnDestroy()
    {
        DestroyTexture();
    }

    void DestroyTexture()
    {
        if (underWaterCamera.targetTexture != null)
        {
            underWaterCamera.targetTexture = null;
        }
        if (underWaterTex != null)
        {
            DestroyImmediate(underWaterTex);
            underWaterTex = null;
        }
    }

    /// <summary>
    /// RenderTextureを生成
    /// </summary>
    void UpdateRenderTex()
    {
        if ((sourceCamera == null) || (underWaterCamera == null)) return;

        if (underWaterTex != null)
        {
            if (currentAspect != sourceCamera.aspect)
            {
                currentAspect = sourceCamera.aspect;
                DestroyTexture();
            }
            else
            {
                return;
            }
        }
        underWaterTex = new RenderTexture(
            renderTextureSize, renderTextureSize,
            renderTextureDepth,
            RenderTextureFormat.Depth,
            RenderTextureReadWrite.Linear);
        underWaterTex.dimension = TextureDimension.Tex2D;
        underWaterTex.autoGenerateMips = false;
        underWaterTex.anisoLevel = 1;
        underWaterTex.filterMode = FilterMode.Point;
        underWaterTex.wrapMode = TextureWrapMode.Clamp;
        underWaterCamera.aspect = sourceCamera.aspect;
        underWaterCamera.targetTexture = underWaterTex;
        currentAspect = sourceCamera.aspect;
    }
}
  • これを先に作成したCameraにアタッチします
  • Hierarchyウィンドウで Ocean をクリックして選択して、LayerをWaterにします

これで水面下を描画するカメラと、水面のレイヤー設定ができました。スクリプトでは、Main Cameraの設定をCopyFromでコピーしたり、Waterレイヤーを描画候補から外すためのcullingMaskの設定などをしています。描画するテクスチャは_WaterDepthTexという名前で、シェーダーにGlobalTextureで渡します。

警告やエラーが出る場合

RenderTexture.Create: Depth|ShadowMap RenderTexture requested without a depth buffer. Changing to a 16 bit depth buffer.というような警告が表示されて、grabがどうこうというエラーが出る場合があります。その時は、81行目のDepthをDefaultに変更してみてください。

// 81:
            RenderTextureFormat.Default,

これで一旦エラーが消えるので、作業を完了させてください。作業が完了したらDepthに戻します。それでとりあえず動きました。このエラーは水系の色々なアセットで発生しているようですが、今回は直す決め手を見つけることはできませんでした。

WaterShadedの改造

仕上げにLowPoly WaterのWaterShadedシェーダーに手を加えます。やることは以下の通りです。

  1. 水面下の距離を_WaterDepthTexから取得するようにします
  2. Render Queueを、不透明でFadeやUnlitより前に描画するように変更します(AlphaTest-1)
  3. 水面描画時にZWriteをOnにします。これをしないと水面が描画されません
  4. Shadow Casterパスを追加します。これでDOFを有効にします

ProjetウィンドウのAssets > LowPolyWater_Pack > Shaders フォルダーを開いて、WaterShadedシェーダーをダブルクリックして開きます。以下に従って修正してください。

  • 26行目付近の_CameraDepthTexture_WaterDepthTexに書き換えます
  • 159行目のhalf depth=から始まる行にある_CameraDepthTexture_WaterDepthTexに書き換えます
// 159:
            half depth = SAMPLE_DEPTH_TEXTURE_PROJ(_WaterDepthTex, UNITY_PROJ_COORD(i.screenPos));

これでMain CameraのZバッファではなく、こちらで作成した_WaterDepthTexから奥行きを取り出すようになります。カメラのaspectを正しく設定しておけば、正方形のRenderTextureからちゃんとアスペクト比に従って参照してくれます。

次にRender Queueを変更します。

  • 183行目付近のTagsを以下のように修正します
// 183:
    Tags {"RenderType"="Transparent" "Queue"="AlphaTest-1"}

水面描画時のZWriteをOnにします。

// 193:
            ZWrite On

207行目の}の後ろで改行して、以下のShadowCasterのパスを追加します。

// 208:

    Pass{
            Tags { "LightMode" = "ShadowCaster"}
            ZWrite On
            ColorMask 0
    }

以上で完了です。Playすると水面がくっきり表示されるようになります。

水面くっきり!

この時、島の下が真っ白になっている場合は、CameraにアタッチしたスクリプトDepthのところをDefaultに変更したのではないかと思います。元のDepthに戻してみてください。

Unlitが白くなるのを解消する

右のCubeの下半分が真っ白です。これはCubeのZバッファに水面シェーダーが反応して波を描いてしまうからです。水面と同じくこのCubeのレイヤーをWaterにすることでぱっと見では解決です。

Waterで問題を回避

よくよく見るとCubeの場所だけ水面がくっきり見えています。DOFがCubeの距離で反映するからですが、これ以上は別テーマになりそうなので今回はここまでにしておきます。

まとめ

ちょっと見栄えを良くしようと思って入れたDepth of Fieldが思わぬ問題を巻き起こしました。理屈を知ってみると、DOFと透過物との相性の悪さ、特に水面はなかなかに難儀な対応が必要ということが実感できました。これらを対策済みの水や透明系アセットの有難さが分かりました。

この調査を通じて、以下のようなことを知ることができました。

  • Render Queueは2500以下が不透明、それ以降が透明扱いになり、描画ルールが変わる
  • ZバッファはRender Queueが2500以下、ZWrite On、Shadow Casterのパスでハードウェアに書き込まれる
  • 不透明のキューでもBlend設定で半透明の描画はできる
  • ShadowCasterでZバッファを書き込む場合は、他のパスもZWrite Onにしないと描画されなかった(水面)
  • エフェクト用のカメラでRenderTextureに欲しい画像を書き込んで、Shader.SetGlobalTexture()でシェーダーに渡せる
  • カメラのaspectを設定すれば、正方形のRenderTextureからスクリーン座標で色を取り出せる
  • RenderTextureを作成する時に謎の警告とエラーがでたら、とりあえずRenderTextureFormat.Defaultで作成しておく

今回の作業では、公式ドキュメントに加えてLIGHT11さんのブログに大変お世話になりました。助かりました。

Win, Mac, WebGL, Android(Pixel3a)では動作確認しました。Pixel3aだとかなり重い感じでしたが動いてはいました。

memo:シェーダーのRender Typeについて

Render Typeは特殊なエフェクトなどのためにシェーダーを置き換えたい時に、置き換える候補を指定するのに使うとのこと。

docs.unity3d.com

Zバッファのみとか不透明なQueueで半透明にしてたりとかイレギュラーなことをしていて何を設定するのが正解か分からなかったので、今回はなんとなくで設定しています。何かのエフェクトが正しく描画されない場合はこの辺りが原因かも知れません。

参考/関連URL