のりまき日記

Unityなどの活用リファレンスブログ。「こうしたい時どうする」をまとめたい

unity)PS5「ハリーポッター」のゲームみたいにデッサンっぽい描画をしてみたい

デッサンっぽい表現

はじめに

調べても実装方法が出てこなかったので自分で考えながら、ポストエフェクトとして実装します。あまり精査していないので最適化出来ていませんが、色々勉強できたのでメモとして残しておきます。他の方にも試していただいて、よりよい実装方法を知りたいです。

デッサン風に描画

鉛筆画像を準備

デッサン用画像

手書きイラスト風のタイリングテクスチャを用意しました。濃度によって3種類用意しています。以下のサイトからダウンロードして使わせていただきました。

www.freepik.com

ポストエフェクト用スクリプト

ポストエフェクト用にカメラから必要なデータを送ります。

[ExecuteInEditMode, RequireComponent(typeof(Camera))]
public class NormalTest : MonoBehaviour
{
    [SerializeField]
    Material m_Material;

    Camera m_Camera;

    private void Awake()
    {
        m_Camera = GetComponent<Camera>();
    }

    void Start()
    {
        // DepthとDepthNormalsを描画する
        m_Camera.depthTextureMode = DepthTextureMode.Depth | DepthTextureMode.DepthNormals;
    }

    private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        // uvとdepthからワールド座標に戻すマトリックス
        var v = m_Camera.worldToCameraMatrix;
        var p = GL.GetGPUProjectionMatrix(m_Camera.projectionMatrix, true);
        var ivp = (p * v).inverse;
        m_Material.SetMatrix("_I_VP", ivp);

        // カメラ座標からワールド座標に戻すマトリックス
        m_Material.SetMatrix("_ViewToWorld", m_Camera.cameraToWorldMatrix);

        Graphics.Blit(src, dest, m_Material);
    }
}

note

// DepthとDepthNormalsを描画する
m_Camera.depthTextureMode = DepthTextureMode.Depth | DepthTextureMode.DepthNormals;

DepthやNormalsがあると使えそうなのでTextureとして取得できるようにしています。

// uvとdepthからワールド座標に戻すマトリックス
var v = m_Camera.worldToCameraMatrix;
var p = GL.GetGPUProjectionMatrix(m_Camera.projectionMatrix, true);
var ivp = (p * v).inverse;
m_Material.SetMatrix("_I_VP", ivp);

ポストエフェクトで参照できる情報からワールド位置を取得するのに使用します。

 // カメラ座標からワールド座標に戻すマトリックス
m_Material.SetMatrix("_ViewToWorld", m_Camera.cameraToWorldMatrix);

いろいろワールド座標で扱いたいので、これも渡しておきます。

ポストエフェクト用シェーダー

まずはスクリーン座標で描画結果をグレースケール化し、グレースケールの濃度によって鉛筆画像を適応してみます。

Shader "Hidden/Sketch"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _BlushTex ("濃いBlush", 2D) = "white" {}
        _Blush2Tex ("中間Blush", 2D) = "white" {}
        _Blush3Tex ("薄いBlush", 2D) = "white" {}
        _Min ("Min", Range(0,1)) = 0
        _Max ("Max", Range(0,1)) = 1
    }
    SubShader
    {
        // No culling or depth
        Cull Off ZWrite Off ZTest Always

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

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

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

            sampler2D _CameraDepthTexture;
            sampler2D _CameraDepthNormalsTexture;

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

            sampler2D _MainTex;
            sampler2D _BlushTex;
            sampler2D _Blush2Tex;
            sampler2D _Blush3Tex;

            // グレースケール上での適応範囲
            float _Min;
            float _Max;
            
            // スクリプトから
            float4x4 _I_VP;
            float4x4 _ViewToWorld;

            fixed4 frag (v2f i) : SV_Target
            {
                // 描画結果を取得
                fixed4 col = tex2D(_MainTex, i.uv);

                // 描画結果をグレースケールにする
                col = dot(col.rgb, fixed3(0.299, 0.587, 0.114));
                float4 gray = (col - _Min) / (_Max - _Min);

                // 画面上のuvから各種鉛筆画像を取得する
                float4 s = tex2D(_BlushTex, i.uv * 3);
                float4 s2 = tex2D(_Blush2Tex, i.uv * 3);
                float4 s3 = tex2D(_Blush3Tex, i.uv * 3);

                // グレースケール値から画像を選択
                s = lerp(s, s2, saturate(gray * 3));
                s = lerp(s, s3, saturate(gray * 3 - 1));
                s = lerp(s, 1, saturate(gray * 3 - 2));

                // 元のグレースケール色を薄くする
                col.rgb = lerp(col.rgb, 1, .7);

                // 元のグレースケール色に鉛筆画像を合成
                return col * s;
            }
            ENDCG
        }
    }
}

note

float4 gray = (col - _Min) / (_Max - _Min);

InverseLerpになります。グレースケールにしても0や1の値はあまり出てこないのでmin〜maxの間のグレースケール値に対して適応するようにしています。

// グレースケール値から画像を選択
s = lerp(s, s2, saturate(gray * 3));
s = lerp(s, s3, saturate(gray * 3 - 1));
s = lerp(s, 1, saturate(gray * 3 - 2));

グレースケールの値によって3種類+1種類(鉛筆なし)の鉛筆画像を使いわけるための処理です。もっとよい方法がありそうです。

スクリーン座標で適応

グレースケールの値によって濃淡のある鉛筆画像を使って描画できました。スクリーン座標なのでカメラが移動しても追従しません。ワールド座標に描画する方法を考えていきます。

ワールド座標で適応するシェーダー

Shader "Hidden/Sketch"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _BlushTex ("濃いBlush", 2D) = "white" {}
        _Blush2Tex ("中間Blush", 2D) = "white" {}
        _Blush3Tex ("薄いBlush", 2D) = "white" {}
        _Min ("Min", Range(0,1)) = 0
        _Max ("Max", Range(0,1)) = 1
    }
    SubShader
    {
        // No culling or depth
        Cull Off ZWrite Off ZTest Always

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

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

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

            sampler2D _CameraDepthTexture;
            sampler2D _CameraDepthNormalsTexture;

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

            sampler2D _MainTex;
            sampler2D _BlushTex;
            sampler2D _Blush2Tex;
            sampler2D _Blush3Tex;

            // グレースケール上での適応範囲
            float _Min;
            float _Max;
            
            // スクリプトから
            float4x4 _I_VP;
            float4x4 _ViewToWorld;

            fixed4 frag (v2f i) : SV_Target
            {
                // 描画結果を取得
                fixed4 col = tex2D(_MainTex, i.uv);

                // 描画結果をグレースケールにする
                col = dot(col.rgb, fixed3(0.299, 0.587, 0.114));
                // グレースケールの内エフェクトを適応する範囲を指定する
                float4 gray = (col - _Min) / (_Max - _Min);

                // DepthNormalsTextureからdepthとnormalを取り出す処理
                float3 normal;
                float depth;
                DecodeDepthNormal(tex2D(_CameraDepthNormalsTexture, i.uv), depth, normal);

                // 上記depthはrawな値じゃなさそうなのでこっちを使う
                float rawDepth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);

                // ワールド法線にする
                float3 wNormal = mul(_ViewToWorld, normal);
                
                // 平面が正面を向いてる時に1になる
                float d = dot(wNormal, float3(0, 0 , -1.0));

                // uvとdepthからワールド座標に戻す
                float4 pPos = float4(i.uv * 2.0 - 1.0, rawDepth, 1.0);
                // 座標系を合わせる
                #if UNITY_UV_STARTS_AT_TOP
                    pPos.y = -pPos.y;
                #endif
                float4 tmp = mul(_I_VP, pPos);
                float3 wpos = tmp.xyz / tmp.w;
                
                // depthをカメラからの距離にする
                float vDepth = LinearEyeDepth(rawDepth);

                // 遠くのものには適応しない
                gray = lerp(gray, 1, smoothstep(0, 100, vDepth));

                // 濃い鉛筆画像や薄い鉛筆画像を取得
                float4 s = tex2D(_BlushTex, float2(length(wpos.xy), length(wpos.zy)));
                float4 s2 = tex2D(_Blush2Tex, float2(length(wpos.xy), length(wpos.zy)));
                float4 s3 = tex2D(_Blush3Tex, float2(length(wpos.xy), length(wpos.zy)));

                // float4 s = tex2D(_BlushTex, i.uv * 3);
                // float4 s2 = tex2D(_Blush2Tex, i.uv * 3);
                // float4 s3 = tex2D(_Blush3Tex, i.uv * 3);

                // グレースケール値から画像を選択
                s = lerp(s, s2, saturate(gray * 3));
                s = lerp(s, s3, saturate(gray * 3 - 1));
                s = lerp(s, 1, saturate(gray * 3 - 2));

                // 元の色を薄くする
                col.rgb = lerp(col.rgb, 1, .7);

                // 元の色に鉛筆画像を合成
                return col * s;
            }
            ENDCG
        }
    }
}

note

// DepthNormalsTextureからdepthとnormalを取り出す処理
float3 normal;
float depth;
DecodeDepthNormal(tex2D(_CameraDepthNormalsTexture, i.uv), depth, normal);

DepthNormalsTextureからnormalとdepthを分離して取得しています。

// 上記depthはrawな値じゃなさそうなのでこっちを使う
float rawDepth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);

DepthTextureは非線形らしいです。上記から得られるdepthは線形に補正されてる?っぽく生のdepthではなかったので別途取得しています。

// ワールド法線にする
float3 wNormal = mul(_ViewToWorld, normal);

今回は使用しなかったのですがワールド座標での法線を取得しています。渡ってくる法線はカメラ座標での法線なのでワールド座標に変換しています。

// 平面が正面を向いてる時に1になる
float d = dot(wNormal, float3(0, 0 , -1.0));

使えそうなので取得しましたが今回は使っていません。

// uvとdepthからワールド座標に戻す
float4 pPos = float4(i.uv * 2.0 - 1.0, rawDepth, 1.0);
// 座標系を合わせる
#if UNITY_UV_STARTS_AT_TOP
    pPos.y = -pPos.y;
#endif
float4 tmp = mul(_I_VP, pPos);
float3 wpos = tmp.xyz / tmp.w;

uvとdepthがあれば「UNITY_MATRIX_VP」の逆行列を使って、そのピクセルのワールド座標に戻せるようです。そのための計算です。

// depthをカメラからの距離にする
float vDepth = LinearEyeDepth(rawDepth);

depthをカメラから見た距離に変換しています。

// 濃い鉛筆画像や薄い鉛筆画像を取得
float4 s = tex2D(_BlushTex, float2(length(wpos.xy), length(wpos.zy)));
float4 s2 = tex2D(_Blush2Tex, float2(length(wpos.xy), length(wpos.zy)));
float4 s3 = tex2D(_Blush3Tex, float2(length(wpos.xy), length(wpos.zy)));

ポストエフェクトなので各メッシュのuvが使えません。上記で求めた放線を使えばいい感じにuvを指定出来そうですが、うまくいかなかったのでこんな感じでなんとなく取得しました。xz平面上に貼り付けつつyも考慮するといったイメージです。

結果です

ワールド座標を元に鉛筆画像を貼り付けたので、カメラが動いても追従してくれるようになりました。

おわり

デッサン風に描画

探せばそれっぽいアルゴリズムがありそうですが、今回は自分で考えて実装してみました。楽しかったです。

参考にさせていただいた記事

light11.hatenadiary.com

enginetrouble.net

forum.unity.com

xr-hub.com

zenn.dev

usagi-meteor.com

docs.unity3d.com

qiita.com

light11.hatenadiary.com

whaison.jugem.jp

docs.unity3d.com

docs.unity3d.com

github.com

感謝です。