【ShaderLab】常に同じ太さのアウトラインを作る

2022/06/15に公開

はじめに

カメラ距離に依存せず、画面上で一定の太さになるようなアウトラインを作る方法について考えてみようと思います。


実験1. ワールド空間で頂点を動かしてみる

頂点座標(xyz)に法線を加算し、頂点を法線方向に動かしてみます。

v2f vert (appdata v)
{
    v2f o;
    o.pos = UnityObjectToClipPos(v.vertex + float4(v.normal * _OutlineSize, 0));
    return o;
}

結果

カメラから離れると、アウトラインは細くなり、カメラに近づくとアウトラインは太くなります。

実験2. クリップ空間で頂点を動かす

クリップ空間で、頂点を法線方向に動かしてみます。

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 * _OutlineSize;

    return o;
}

結果

カメラから離れると、アウトラインは細くなり、カメラに近づくとアウトラインは太くなります。

アウトラインが太くなる理由

MVP座標変換によって、頂点座標はクリップ空間に変換されます。(同次座標になります)
Unityは同次座標を通常座標に戻す際に、座標のXYZ成分をWで割ります。

カメラから遠い座標のW値は大きく、カメラから近い座標のW値は小さくなるため、
カメラに近い頂点は大きく動きます(アウトラインが太くなります)

実験3. アウトラインの太さを一定にする

W値をXYZ座標に乗算することで、W除算を打ち消すことができます。
(アウトラインの太さを一定にすることができます)

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 * _OutlineSize;
+    o.pos.xy += offset * o.pos.w * _OutlineSize;

    return o;
}

結果

カメラ距離に依存せず、常に同じ太さのアウトラインになりました。

シェーダーコード

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
        }
    }
}

関連

Unity のトゥーンシェーダについて調べてみた
_Object2World or UNITY_MATRIX_IT_MV?

Discussion