【ShaderLab】FoVによらずに一定の太さのアウトラインを作る

2022/07/09に公開

前回の記事では、カメラ距離によらずに一定の太さのアウトラインを作る方法を紹介しました。
https://zenn.dev/r_ngtm/articles/shaderlab-outline

実はこのシェーダー、カメラのFoVを変えると太さが変化します。


シェーダーコード(Outline.shader)
Outline.shader
Shader "Unlit/Clip Space Outline"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _Color ("Color", Color) = (0, 0, 0, 1)
        _OutlineColor ("Outline Color", Color) = (1, 1, 1, 1)
        _OutlineSize ("Outline Size", Range(0,0.1)) = 0.003
    }

    SubShader
    {
        Tags
        {
            "RenderType"="Opaque"
        }
        LOD 100

        CGINCLUDE
        float _OutlineSize;
        float4 _OutlineColor;
        float4 _Color;
        #include "UnityCG.cginc"
        
        struct appdata
        {
            float4 vertex : POSITION;
            float3 normal : NORMAL;
        };

        struct v2f
        {
            float4 pos : SV_POSITION;
            float3 normal : TEXCOORD1;
        };
        ENDCG

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            v2f vert(appdata v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.normal = UnityObjectToWorldNormal(v.normal);
                return o;
            }

            fixed4 frag(v2f i) : SV_Target
            {
                float3 L = _WorldSpaceLightPos0.xyz;
                return dot(i.normal, L) * _Color;
            }
            ENDCG
        }

        Pass
        {
            Cull Front

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            v2f vert(appdata v)
            {
                v2f o;

                // MVP座標変換
                o.pos = UnityObjectToClipPos(v.vertex);

                // 法線の座標変換 : モデル空間 -> カメラ空間
                float3 norm = mul ((float3x3)UNITY_MATRIX_IT_MV, v.normal);

                // 投影
                float2 offset = TransformViewToProjection(norm.xy);

                // クリップ空間で頂点を動かす
                o.pos.xy += offset * o.pos.w * _OutlineSize;

                return o;
            }

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

FoVの打ち消し

結論を先に述べると、アウトラインの頂点を押し出す量を unity_CameraProjection._m11 で割ることで、
FoVによらずに常に一定の太さのアウトラインになります。

Outline.shader
- o.pos.xy += offset * o.pos.w * _OutlineSize;
+ o.pos.xy += offset * o.pos.w * _OutlineSize / unity_CameraProjection._m11;


シェーダーコード(Outline.shader)
Outline.shader
Shader "Unlit/Clip Space Outline"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _Color ("Color", Color) = (0, 0, 0, 1)
        _OutlineColor ("Outline Color", Color) = (1, 1, 1, 1)
        _OutlineSize ("Outline Size", Range(0,0.1)) = 0.003
    }

    SubShader
    {
        Tags
        {
            "RenderType"="Opaque"
        }
        LOD 100

        CGINCLUDE
        float _OutlineSize;
        float4 _OutlineColor;
        float4 _Color;
        #include "UnityCG.cginc"
        
        struct appdata
        {
            float4 vertex : POSITION;
            float3 normal : NORMAL;
        };

        struct v2f
        {
            float4 pos : SV_POSITION;
            float3 normal : TEXCOORD1;
        };
        ENDCG

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            v2f vert(appdata v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.normal = UnityObjectToWorldNormal(v.normal);
                return o;
            }

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

        Pass
        {
            Cull Front

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            v2f vert(appdata v)
            {
                v2f o;

                // MVP座標変換
                o.pos = UnityObjectToClipPos(v.vertex);

                // 法線の座標変換 : モデル空間 -> カメラ空間
                float3 norm = mul ((float3x3)UNITY_MATRIX_IT_MV, v.normal);

                // 投影
                float2 offset = TransformViewToProjection(norm.xy);

                // クリップ空間で頂点を動かす
                o.pos.xy += offset * o.pos.w * _OutlineSize / unity_CameraProjection._m11;

                return o;
            }

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

解説

プロジェクション座標変換

カメラが映す領域は、プロジェクション座標変換によって、一辺の長さが1となる立方体の領域へと変換されます。

https://www.youtube.com/watch?v=qNAbYR3MAtg

スクリーン座標変換

クリップ空間は、スクリーンへと投影されます。

オブジェクトの画面上の大きさ

カメラに近くにあるオブジェクトは、大きく表示されます。

カメラから離れた位置にあるオブジェクトは、大きく表示されます。

数式で表してみる

ここで、オブジェクトの大きさを数式を使って表してみたいと思います。

以下のような変数を定義します。

\theta : カメラの画角
s : オブジェクトの大きさ (ワールド空間)
d : カメラからオブジェクト中心までの距離
S(d, \theta) : オブジェクトが画面上を占める割合

図にすると、以下のようになります。

カメラが映す領域の上から下までの長さは 2 \cdot {d} \cdot tan ({\theta}) 、オブジェクトの縦の大きさは s なので

オブジェクトが画面全体を占める割合 S({d}, {\theta}) は以下のように求まります。

S(\red{d}, \red{\theta}) = \dfrac{s}{2 \cdot \red{d} \cdot tan (\red{\theta}) }

上記の式は、カメラ距離 \red{d} と 画角 \red{\theta} を変化させたとき、
画面上のオブジェクトの大きさ S(\red{d}, \red{\theta}) が変化することを示しています。

d を変化させたときの S の変化

\red{d} を変化させた場合のS(\red{d}, \theta)は、 以下のようになります。

S(\red{d}) = \gray{ \dfrac{s}{2 \cdot tan (\theta)} } \cdot \frac{1}{\red{d}}

ヨコ軸を \red{d} 、タテ軸を S として、グラフを描くと以下のようになります。

\red{d} を増やすとオブジェクトが小さくなり、\red{d} を減らすとオブジェクトが大きくなることが分かります。

実験 : dを変化させる

カメラから近い場合

カメラ近くでオブジェクトを動かすと、画面上で大きく変化します。
https://www.youtube.com/watch?v=9pcU-y2w0Jg

カメラから遠い場合

カメラから離れているオブジェクトを動かした場合は、画面上での変化が小さいです。
https://www.youtube.com/watch?v=BZ_FdD-J_xI

\theta を変化させたときの S の変化

\red{\theta} を変化させた場合のS(d, \red{\theta})は、 以下のようになります。

S(\red{\theta}) = \gray{ \dfrac{s}{2 \cdot d }} \cdot \frac{1}{tan (\red{\theta})}

ヨコ軸を \red{\theta} 、タテ軸を S として、グラフを描くと以下のようになります。

{\theta} = 90 \degree の時、オブジェクトの大きさ S= 0 となり、オブジェクトが見えなくなります。

\theta = 90 \degree の時、 UnityカメラのFoV = 180°になりますが、UnityカメラのFoVの最大値は179°です。

実験 : FoVを変化させる

FoVを増やすとオブジェクトが小さくなり、FoVを減らすとオブジェクトが大きくなることが分かります。
FoVが180°に近づくと、オブジェクトは限りなく小さくなります。
https://www.youtube.com/watch?v=AnHQ6uUs974

FoVによる大きさの変化を打ち消す

ここで、FoVによるオブジェクトの大きさの変化を打ち消すことを考えたいと思います。

オブジェクトの大きさS(d, {\theta}) は、以下のような計算式であらわすことができます。

S(d, \theta) = \dfrac{s}{2 \cdot d \cdot tan (\theta) }

両辺に tan (\theta) を乗算します。

\gray{ tan (\theta) } \cdot S({d}, {\theta}) = \red{ \dfrac{s}{2 \cdot {d}} }

右辺は、FoVに依存しない値 \red{ \dfrac{s}{2 \cdot {d}} } を取るようになりました。

シェーダーによる実装

unity_CameraProjection._m11 には tan (\theta) の逆数が格納されています。

クリップ空間での頂点移動量をunity_CameraProjection._m11で割る (tan(\theta)で乗算する)ことで、FoVを打ち消すことができます。

Outline.shader
- o.pos.xy += offset * o.pos.w * _OutlineSize;
+ o.pos.xy += offset * o.pos.w * _OutlineSize / unity_CameraProjection._m11;

結果

FoVを変化させても、アウトラインの太さが変化しなくなります。
https://www.youtube.com/watch?v=hPKBXo4drhQ

Discussion