[Unity] 深度バッファから法線を復元しようとしてうまくいかなかった話


概要

会社で雑談していて、深度バッファから法線を復元したらどうか、みたいな話をしていて、それがそもそもできるのかなーと思って試したというお話です。(そしてなんかうまく行ったような行ってないような感じです)

ただ、とりあえず勉強になったのでせっかくだからメモがてら書いておこうという趣旨ですw

ちなみに実際に書いてみた結果はこんな感じ↓

なんだか合っているような合ってないような、微妙な感じの結果になりました。
(あと後述しますが、モデルのエッジもなんだかおかしい)

方針

今回の実装方針は、深度バッファから深度値を読み出し、その点をビュー空間座標に変換して利用しました。
法線の計算は、対象ピクセルの近傍テクセルを4点取り出し、2点ずつを結んだ2ベクトルの外積を計算して、それを法線として採用する、という形で実装しました。

ShaderLabコード

そんなに長くないコードなので全文を載せます。

Shader "Custom/DepthNormal" {
    SubShader {
        Tags { "RenderType"="Transparent" "Queue"="Transparent+10" }
        LOD 200

        Pass
        {
            CGPROGRAM

            #include "UnityCG.cginc"
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 3.0

            uniform sampler2D  _CameraDepthTexture;
            uniform float4x4 _InverseProjection;

            struct v2f
            {
                float4 pos : SV_POSITION;
                float2 uv : TEXCOORD0;
            };

            v2f vert(appdata_img v)
            {
                v2f o = (v2f)0;
                o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
                o.uv = v.texcoord.xy;
                return o;
            }

            float4 frag(v2f i) : SV_Target
            {
                float ep = 0.001;

                float2 xy1 = float2(i.uv.x + ep, i.uv.y + ep);
                float2 xy2 = float2(i.uv.x - ep, i.uv.y + ep);
                float2 xy3 = float2(i.uv.x - ep, i.uv.y - ep);
                float2 xy4 = float2(i.uv.x + ep, i.uv.y - ep);

                float z1 = tex2D(_CameraDepthTexture, xy1);
                float z2 = tex2D(_CameraDepthTexture, xy2);
                float z3 = tex2D(_CameraDepthTexture, xy3);
                float z4 = tex2D(_CameraDepthTexture, xy4);

                float4 pPos1 = float4(xy1 * 2 - 1, z1, 1.0);
                float4 p1 = mul(_InverseProjection, pPos1);
                p1.xyz /= p1.w;

                float4 pPos2 = float4(xy2 * 2 - 1, z2, 1.0);
                float4 p2 = mul(_InverseProjection, pPos2);
                p2.xyz /= p2.w;

                float4 pPos3 = float4(xy3 * 2 - 1, z3, 1.0);
                float4 p3 = mul(_InverseProjection, pPos3);
                p3.xyz /= p3.w;

                float4 pPos4 = float4(xy4 * 2 - 1, z4, 1.0);
                float4 p4 = mul(_InverseProjection, pPos4);
                p4.xyz /= p4.w;

                float3 v1 = normalize(p3.xyz - p1.xyz);
                float3 v2 = normalize(p4.xyz - p2.xyz);

                float3 n = normalize(cross(v1, v2));

                return half4(n, 1);
            }

            ENDCG
        }
    }
    FallBack "Diffuse"
}

この実装にあたってこちらの記事(G-Buffer の深度値からワールド空間の位置を復元した秋 2016)を参考にさせていただきました。

実装

実装に関しては、方針そのままです。

近傍テクセルを取り出す

まず、計算中のピクセルの近傍4テクセルを取り出します。

float ep = 0.001;

float2 xy1 = float2(i.uv.x + ep, i.uv.y + ep);
float2 xy2 = float2(i.uv.x - ep, i.uv.y + ep);
float2 xy3 = float2(i.uv.x - ep, i.uv.y - ep);
float2 xy4 = float2(i.uv.x + ep, i.uv.y - ep);

float z1 = tex2D(_CameraDepthTexture, xy1);
float z2 = tex2D(_CameraDepthTexture, xy2);
float z3 = tex2D(_CameraDepthTexture, xy3);
float z4 = tex2D(_CameraDepthTexture, xy4);

それぞれのテクセル位置をep分だけオフセットして取り出しているのが分かるかと思います。

-1 〜 1の範囲に変換する

次に、取り出したテクセル値(つまり深度値)と、参照したUV座標を元に、-1 〜 1の範囲に変換したpositionを求めます。

float4 pPos1 = float4(xy1 * 2 - 1, z1, 1.0);

ビュー空間での位置に変換する

そして求めたpositionに、C#側から渡したプロジェクション行列の逆行列を掛けて「ビュー空間」の座標に戻します。
その上で、そのxyzw除算することで位置を復元します。

float4 p1 = mul(_InverseProjection, pPos1);
p1.xyz /= p1.w;

求めた4点から2つのベクトルを求め、法線を算出する

そして最後に、求まった4つの点から2つのベクトルを作り、それの外積を取ることで法線としました。

float3 v1 = normalize(p3.xyz - p1.xyz);
float3 v2 = normalize(p4.xyz - p2.xyz);
float3 n = normalize(cross(v1, v2));

今回はこの法線の値をそのまま色として出力しています。

C#側でポストエフェクト

今回の実装は、最終的に得られた深度バッファを画面に表示しています。
なので、いわゆるポストエフェクト的な形で出力しています。

ポストエフェクトはOnRenderImageメソッドを実装することで実現します。
そして今回の処理には「プロジェクション変換行列」の逆行列を利用するため、このメソッド内でマテリアルに行列情報を渡して計算しています。

こちらのコードも短いので全文載せておきます。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[ExecuteInEditMode]
public class DepthEnabler : MonoBehaviour
{
    public Material _material;

    void Start()
    {
        Camera.main.depthTextureMode |= DepthTextureMode.Depth;
    }

    private void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
        Matrix4x4 mat = Camera.main.projectionMatrix;
        _material.SetMatrix("_InverseProjection", mat.inverse);
        Graphics.Blit(source, destination, _material);
    }
}

ちなみにこれはCameraにアタッチして使う想定です。
なお、Forwardレンダリングの場合は、デフォルトでは深度バッファを使えないのでビットでモードを指定して取得できるようにしています。

該当箇所は以下の部分ですね。

Camera.main.depthTextureMode |= DepthTextureMode.Depth;

こうすることで、シェーダ側では「_CameraDepthTexture」という名称で深度バッファを格納したテクスチャが利用できるようになります。
そこからUV座標を元に深度値を取り出して計算に利用しています。

分からなかった点

今回実装してみてちょっと分からなかった点が、UV座標の精度です。
実装方針としては、対象ピクセルの近傍のピクセルを取り出して、その4点のベクトルの外積結果を法線として採用する、というものでした。
が、その「近傍テクセル」をフェッチする際に、0.001くらいの差でアクセスしたら大丈夫だったんですが、それ以上小さい値でアクセスすると同じ値が取られてしまうために、正常に表示されない、という問題がありました。

画面サイズに応じて変化しているので、テクスチャサイズによるんだと思いますが、そこまで小さな値ではない気がしているのに、同じ値が取られてしまうのはなぜかなーというのが分からなかった点です。

あまり小さい値を指定できないために、若干、データのエッジがずれてしまっているのが冒頭のキャプチャで分かると思います。
もう少し小さな差分で取れたらよかったのにと思いつつ、解決策が思いつかなかった(分からなかった)のでとりあえずそのままですw

そもそも深度から精度高く法線を服点する、ってのは土台無理な話なんですかね・・( ;´Д`)
(法線マップなどの細かい点は考慮せず、メッシュの外観だけが求まればOKな前提ですが)