👀

【URP】キャラクターの透ける眉を実装してみる

2023/06/05に公開

はじめに

透ける眉を実装してみました。


SDユニティちゃん 3Dモデルデータに透ける眉を実装

サンプル(シェーダーのみ)

サンプルコードはGistにアップしています。
https://gist.github.com/rngtm/b4a50de08eab6b46140456f73ef2ea62

実装について

眉毛を不透明で描画した後、Zテスト無効でアルファブレンド描画しています。

Zテストを無効にして描画を行うと、おかしな見た目(描画の不具合)が起きてしまいます。
本記事では、こういった不具合を回避する方法についても紹介します。

オブジェクトの手前に眉が表示されてしまう

使用するデータ

今回は、「SDユニティちゃん 3Dモデルデータ」を使用します。
https://unity-chan.com/download/index.php

環境

  • Unity2021.3.16f1
  • UniversalRP 12.1.8

STEP1. キャラシェーダーを作成する

SDユニティちゃんの眉のメッシュデータはマテリアルが分かれていないため、眉だけを描画できません。
今回は、眉のマスクテクスチャを用意し、眉をくり抜いて描画することにしました。


眉だけを白く塗りつぶしたマスクテクスチャ

シェーダー

今回は以下の4つのシェーダーファイルを作成します。
複数の.shaderファイルで同じ処理を使いまわすため、シェーダー関数は.hlslの中にまとめます。

ファイル名 概要
CharacterPass.hlsl シェーダー関数の定義
Character-Face.shader キャラ 顔 シェーダー
Character-Hair.shader キャラ 髪 シェーダー
Character-Body.shader キャラ 体 シェーダー
CharacterPass.hlsl

CharacterPass.hlsl

複数shaderファイルで使う処理は、CharacterPass.hlslの中にまとめていきます。

CharacterPass.hlsl
#ifndef CHARACTER_PASS_INCLUDED
#define CHARACTER_PASS_INCLUDED

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

CBUFFER_START(UnityPerMaterial)
sampler2D _MainTex; // ベースカラーテクスチャ
sampler2D _EyeMaskTex; // 眉をくり抜くマスクテクスチャ
half4 _EyeBrowColor; // 眉の色
CBUFFER_END

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

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

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

half4 frag (v2f i) : SV_Target
{
    // カラーテクスチャのサンプリング
    half4 col = tex2D(_MainTex, i.uv);

    #ifdef CHARACTER_EYEBROW_PASS
    half4 mask = tex2D(_EyeMaskTex, i.uv);
    clip(mask.r - 0.001); // マスクが黒いところをくり抜く
    col *= _EyeBrowColor;
    #endif
    
    return col;
}

#endif
Character-Face.shader

Character-Face.shader

キャラの顔用シェーダー

Character-Face.shader
Shader "Zenn/Character-Face"
{
    Properties
    {
        _EyeBrowColor ("Eyebrow Color", Color) = (1, 1, 1, 0.5)
        _MainTex ("Main Texture", 2D) = "white" {}
        _EyeMaskTex ("Eye Mask Texture", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            Name "Base"

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            
            #include "CharacterPass.hlsl"
            
            ENDHLSL
        }
        
        Pass
        {
            Name "EyeBrow"
            
            Tags { "LightMode"="EyeBrow" }

            Blend SrcAlpha OneMinusSrcAlpha
            ZTest Off

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #define CHARACTER_EYEBROW_PASS
            
            #include "CharacterPass.hlsl"
            ENDHLSL
        }
    }
}
Character-Body.shader

Character-Body.shader

キャラの体用シェーダー

Character-Body.shader
Shader "Zenn/Character-Body"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            Name "Body"

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "CharacterPass.hlsl"
            ENDHLSL
        }
    }
}
Character-Hair.shader

Character-Hair.shader

キャラの髪用シェーダー

Character-Hair.shader
Shader "Zenn/Character-Hair"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            Name "Hair"

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "CharacterPass.hlsl"
            ENDHLSL
        }
    }
}

結果 (眉が透けない)

これらのシェーダーからマテリアルを作成し、ユニティちゃんに割り当てると以下のような見た目になります。
眉はまだ透けていません。

眉が透けない理由

Character-Face.shaderには半透明描画用のEyeBrowパスを持たせていますが、
URPの初期設定ではEyeBrowパスは実行されず、眉が透けません。
EyeBrowパスは実行させるためには、RendererFeatureを登録する必要があります。

パス名 Tags 概要
Base なし 不透明描画パス
EyeBrow "LightMode"="EyeBrow" Zテスト無効・半透明描画

STEP2. RendererFeatureの登録

Universal Renderer Data に、RenderObjectsを登録します。
LightMode Tags には EyeBrow を指定します。

これにより、"LightMode"="EyeBrow" が指定されたパスが実行されます。

Frame Debugger

結果

眉が表示されました。

描画の不具合

ここで、顔を回転させてみると、眉が不自然に描画される時があります。

不具合1: 顔に隠れた眉が透けて見える

顔に隠れた眉も透けて見えてしまいます。これは不自然です。

不具合2: 頭の裏の眉が透けて見える

頭を後ろから見たときに、眉が透けてしまいます。これも不自然です。

不具合3: 他のオブジェクトより手前に眉が表示される

眉の手前にオブジェクトを置いたとき、眉がオブジェクトの手前に出てしまいます。

不具合の解決

不具合1, 2, 3 はそれぞれ異なるアプローチで解消します。

修正1 : カメラと反対側の眉を隠す

カメラと反対側の眉を隠すことで、不具合1は解決します。

修正2 : 両方の眉を隠す

カメラが顔の横に来た時(頭の後ろ)にある時、両方の眉を隠すことで、不具合2は解決します。

修正3 : ステンシルでマスクをかける

髪のシェーダーでステンシルを有効化し、眉ではステンシルを用いてマスクをかけることで、
不具合3は解決します。

修正1 : カメラと反対側の眉を隠す

STEP1. 顔の半分だけを抽出するマスクを作る

顔の頂点座標のx成分を利用すると、顔の左半分だけを抽出するようなマスクを作ることができます。

v2f vert (appdata v)
{
    v2f o;
    o.vertex = TransformObjectToHClip(v.vertex); 
    o.eyeMask = step(v.vertex.x, 0); // 顔の半分だけを抽出するマスクを作る
    return o;
}

half4 frag (v2f i) : SV_Target
{
    return i.eyeMask;
}

STEP2. 眉のマスクテクスチャと乗算する

眉のマスクテクスチャと乗算することで、片方の眼だけを抽出できます。

v2f vert (appdata v)
{
    v2f o;
    o.vertex = TransformObjectToHClip(v.vertex); 
    o.eyeMask = step(v.vertex.x, 0); // 顔の半分だけを抽出するマスクを作る
    return o;
}

half4 frag (v2f i) : SV_Target
{
    half4 mask = tex2D(_EyeMaskTex, i.uv);
    return mask.r * i.eyeMask;
}

STEP3. カメラに近い眉を抽出する

カメラ位置の判定

外積を利用することで、カメラが右にあるか、左にあるか、という判定を取ることができます。

// カメラが顔の右側にあるときは0.0、カメラが顔の左側にあるときは1.0
float isRightSide = step(0.0, cross2d(camFrontXZ, faceFrontXZ));

cross2dは自作の関数で、2次元ベクトルの外積を計算します。

float cross2d(float2 a, float2 b)
{
    return (a.x * b.y - a.y * b.x);
}

カメラが顔の右側にあるときは0.0、カメラが顔の左側にあるときは1.0が取れるようになります。

カメラに近い眉を抽出

この値を利用することで、カメラと反対側の眉のマスクを0.0にする (眉を隠す)という処理を組むことができます。

サンプルコード
v2f vert (appdata v)
{
    v2f o;
    o.vertex = TransformObjectToHClip(v.vertex); 
    o.uv = v.uv;
    
    // カメラの正面ベクトルのXZ成分
    float2 camFrontXZ = -UNITY_MATRIX_V[2].xz;

    // カメラの正面ベクトルのXZ成分 (_CharacterFaceFrontは独自に用意した変数)
    float2 faceFrontXZ = _CharacterFaceFront.xz;

    // カメラが顔の右側にあるときは0.0、カメラが顔の左側にあるときは1.0
    float isRightSide = step(0.0, cross2d(camFrontXZ, faceFrontXZ));

    // カメラと反対側を0.0にするような値
    o.eyeMask = lerp(step(0, v.vertex.x), step(v.vertex.x, 0), isRightSide);

    return o;
}

half4 frag (v2f i) : SV_Target
{
    half4 mask = tex2D(_EyeMaskTex, i.uv);
    return mask.r * i.eyeMask; // マスクの確認用のデバッグ処理
}

結果 (カメラと反対側の眉を隠す)

以下のような表示になります。
顔に隠れた眉が見えなくなりました。

CharacterFace.cs (顔の正面ベクトルをShaderに送信)

顔の正面方向のベクトルをシェーダー側で利用するため、以下のコンポーネントをユニティちゃんの顔にアタッチしておきます。

CharacterFace.cs

using UnityEngine;

[ExecuteAlways]
public class CharacterFace : MonoBehaviour
{
    private void Update()
    {
        // Shaderに値を送信する
        Shader.SetGlobalVector(ShaderPropertyId.CharacterFaceFront, transform.forward);
    }
    
    private static class ShaderPropertyId
    {
        public static readonly int CharacterFaceFront = Shader.PropertyToID("_CharacterFaceFront");
    }
}
CharacterPass.hlsl (差分)

CharacterPass.hlsl

CharacterPass.hlsl

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

struct v2f
{
    float4 vertex : SV_POSITION;
    float2 uv : TEXCOORD0;
+    float eyeMask : TEXCOORD1; // 眉をくり抜くマスク値
};

+ float cross2d(float2 a, float2 b)
+ {
+     return (a.x * b.y - a.y * b.x);
+ }

v2f vert (appdata v)
{
    v2f o;
    o.vertex = TransformObjectToHClip(v.vertex); 
    o.uv = v.uv;
    
+   float2 camFrontXZ = -UNITY_MATRIX_V[2].xz;
+   float2 faceFrontXZ = _CharacterFaceFront.xz;
+   float isRightSide = step(0.0, cross2d(camFrontXZ, faceFrontXZ));
+   o.eyeMask = lerp(step(0, v.vertex.x), step(v.vertex.x, 0), isRightSide);
    return o;
}

half4 frag (v2f i) : SV_Target
{
    // カラーテクスチャのサンプリング
    half4 col = tex2D(_MainTex, i.uv);
    
    #ifdef CHARACTER_EYEBROW_PASS
    half4 mask = tex2D(_EyeMaskTex, i.uv);
    clip(mask.r - 0.001); // マスクが黒いところをくり抜く
    col *= _EyeBrowColor;
+   col.a *= i.eyeMask;
    #endif
    
    return col;
}

#endif

修正2 : 両方の眉を隠す

頭の後ろから見たときに、両方の眉を見えなくします。

STEP1. 顔の方向と視線の内積の内積を計算する

顔の正面方向と、カメラまでの方向の内積を計算することで、
顔の正面付近では1.0、顔の真横で0.0となるような値が取れます。
顔よりカメラのほうが後ろにある場合はマイナスの値になります。

half dotFaceCamera = dot(_CharacterFaceFront, -camFront); 
CharacterPass.hlsl (差分)
CharacterPass.hlsl
v2f vert (appdata v)
{
    v2f o;
    o.vertex = TransformObjectToHClip(v.vertex); 
    o.uv = v.uv;
    
    float3 camFront = -UNITY_MATRIX_V[2];
    float2 camFrontXZ = -UNITY_MATRIX_V[2].xz;
    float2 faceFrontXZ = _CharacterFaceFront.xz;
    float isRightSide = step(0.0, cross2d(camFrontXZ, faceFrontXZ));
    o.eyeMask = lerp(step(0, v.vertex.x), step(v.vertex.x, 0), isRightSide);

+   half dotFaceCamera = dot(_CharacterFaceFront, -camFront); 
+   o.eyeMask *= saturate(dotFaceCamera);
    return o;
}

結果 (両方の眉を隠す)

頭の後ろから見たときの眉が表示されなくなりました。

STEP2. ある角度から眉を表示させないようにする

おおむね問題ないですが、眉が不自然に立体的に見えてしまう瞬間があります。
見栄えが良くないので、remapをかけてフェードのかかり方を補正します。


内積の計算結果を補正する

内積の計算結果は、顔の正面では1.0、顔の真横では0.0となるような数値になっています。
顔のある角度(Edge1)を超えるまでは1.0、その角度を超えたらだんだん0.0に近づいていく、といった数値の動きを作ることを考えます。

CharacterPass.hlsl
    half dotFaceCamera = dot(_CharacterFaceFront, -camFront); 
+   o.eyeMask *= remap(dotFaceCamera, _EyeMaskEdge1, _EyeMaskEdge2, 0.0, 1.0);
    o.eyeMask *= saturate(dotFaceCamera);

remapは自作の関数で、値xを範囲[a,b]から範囲[c,d]へ変換します。

float remap(float x, float a, float b, float c, float d)
{
    return saturate((x - a) / (b - a)) * (d - c) + c;
}
Character-Face.shader (差分)

Character-Face.shader

Character-Face.shader の差分はプロパティのみです。

Character-Face.shader
Properties
{
    _EyeBrowColor ("Eyebrow Color", Color) = (1, 1, 1, 0.5)
    _MainTex ("Main Texture", 2D) = "white" {}
    _EyeMaskTex ("Eye Mask Texture", 2D) = "black" {}
+    _EyeMaskEdge1 ("Eye Mask Edge 1", Range(0, 1)) = 0.4 // フェードがかかりはじめる角度 (角度ではなく、cosの値を指定)
+    _EyeMaskEdge2 ("Eye Mask Edge 2", Range(0, 1)) = 0.0 // フェードがかかり終わる角度 (角度ではなく、cosの値を指定)
}
CharacterPass.hlsl (差分)

CharacterPass.hlsl

CharacterPass.hlsl

CBUFFER_START(UnityPerMaterial)
sampler2D _MainTex; // ベースカラーテクスチャ
sampler2D _EyeMaskTex; // 眉をくり抜くマスクテクスチャ
half4 _EyeBrowColor; // 眉の色
float3 _CharacterFaceFront; // キャラクターの顔の正面
+ half _EyeMaskEdge1; // フェードがかかり始める角度 (cosの値を指定)
+ half _EyeMaskEdge2; // フェードがかかり終わる角度 (cosの値を指定)
CBUFFER_END

...

+ float remap(float x, float a, float b, float c, float d)
+ {
+     return saturate((x - a) / (b - a)) * (d - c) + c;
+ }

v2f vert (appdata v)
{
    v2f o;
    o.vertex = TransformObjectToHClip(v.vertex); 
    o.uv = v.uv;
    
    float3 camFront = -UNITY_MATRIX_V[2];
    float2 camFrontXZ = -UNITY_MATRIX_V[2].xz;
    float2 faceFrontXZ = _CharacterFaceFront.xz;
    float isRightSide = step(0.0, cross2d(camFrontXZ, faceFrontXZ));
    o.eyeMask = lerp(step(0, v.vertex.x), step(v.vertex.x, 0), isRightSide);

+   half dotFaceCamera = dot(_CharacterFaceFront, -camFront); 
+   o.eyeMask *= remap(dotFaceCamera, _EyeMaskEdge1, _EyeMaskEdge2, 0.0, 1.0);
+   o.eyeMask *= saturate(dotFaceCamera);
    return o;
}
#endif

パラメータ

今回はパラメータを、以下のように設定しました。

結果 (補正後)

修正3 : オブジェクトの手前に出る眉を直す

Zテストを無効にしているため、手前にオブジェクトがある場合にも眉が表示されてしまいます。
今回は、ステンシルを用いてこの問題を解決することにします。

ステンシルを用いて眉をくりぬく

髪を描画する時に、ステンシルの値を画面に書きこんでおきます。
眉をステンシルでくり抜くことで、眉がオブジェクトの手前に表示されなくなります。
髪と眉が重なった部分は、眉が透けてくれます。

STEP1. 髪でステンシルを描く

髪シェーダーにステンシル機能を追加します。

Character-Hair.shader (ステンシルを追加)
Character-Hair.shader
Shader "Zenn/Character-Hair"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        
+       [Header(Stencil)]
+       [Space]
+       _StencilRef("Stencil Ref", Int) = 0
+       [Enum(UnityEngine.Rendering.CompareFunction)]_StencilComp("Stencil Comp", Int) = 0
+       [Enum(UnityEngine.Rendering.StencilOp)]_StencilPassOp("Stencil Pass", Int) = 0
+       [Enum(UnityEngine.Rendering.StencilOp)]_StencilFailOp("Stencil Fail", Int) = 0
+       [Enum(UnityEngine.Rendering.StencilOp)]_StencilZFailOp("Stenci ZFail", Int) = 0
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            Name "Hair"
+           Stencil 
+           {
+               Ref [_StencilRef]
+               Comp [_StencilComp]
+               Pass [_StencilPassOp]
+               Fail [_StencilFailOp]
+               ZFail [_StencilZFailOp]
+           }
            
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "CharacterPass.hlsl"
            ENDHLSL
        }
    }
}

マテリアル側でステンシル 1 を書きこむようにしておきます。

STEP2. 眉をステンシルでマスクする

顔のシェーダーにステンシル機能を追加します。

Character-Face.shader (ステンシルを追加)
Character-Face.shader
Shader "Zenn/Character-Face"
{
    Properties
    {
        _EyeBrowColor ("Eyebrow Color", Color) = (1, 1, 1, 0.5)
        _MainTex ("Main Texture", 2D) = "white" {}
        _EyeMaskTex ("Eye Mask Texture", 2D) = "black" {}
        _EyeMaskEdge1 ("Eye Mask Edge 1", Range(0, 1)) = 0.4 // フェードがかかりはじめる角度 (角度ではなく、cosの値を指定)
        _EyeMaskEdge2 ("Eye Mask Edge 2", Range(0, 1)) = 0.0 // フェードがかかり終わる角度 (角度ではなく、cosの値を指定)
        
+       [Header(EyeBrow Stencil)]
+       [Space]
+       _StencilRef("Stencil Ref", Int) = 1
+       [Enum(UnityEngine.Rendering.CompareFunction)]_StencilComp("Stencil Comp", Int) = 0
+       [Enum(UnityEngine.Rendering.StencilOp)]_StencilPassOp("Stencil Pass", Int) = 0
+       [Enum(UnityEngine.Rendering.StencilOp)]_StencilFailOp("Stencil Fail", Int) = 0
+       [Enum(UnityEngine.Rendering.StencilOp)]_StencilZFailOp("Stenci ZFail", Int) = 0
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            Name "Base"

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            
            #include "CharacterPass.hlsl"
            
            ENDHLSL
        }
        
        Pass
        {
            Name "EyeBrow"
            
            Tags { "LightMode"="EyeBrow" }
+           Stencil 
+           {
+               Ref [_StencilRef]
+               Comp [_StencilComp]
+               Pass [_StencilPassOp]
+               Fail [_StencilFailOp]
+               ZFail [_StencilZFailOp]
+           }

            Blend SrcAlpha OneMinusSrcAlpha
            ZTest Off

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #define CHARACTER_EYEBROW_PASS
            
            #include "CharacterPass.hlsl"
            ENDHLSL
        }
    }
}

マテリアル側で、ステンシル1でマスクがかかるように設定します。

結果 (ステンシルでマスク)

眉が手前に表示されなくなりました。

サンプル(シェーダーのみ)

サンプルコードはGistにアップしています。
https://gist.github.com/rngtm/b4a50de08eab6b46140456f73ef2ea62

ライセンス表記


この作品はユニティちゃんライセンス条項の元に提供されています

Discussion