🎨

【Unity URP】キャラクタートゥーンシェーダの表現手法をまとめる その1(Lambert二値化)

2023/05/21に公開

はじめに

いつかキャラクタートゥーンシェーダの記事を書きたいと考えていたので,基礎的なところから書いていこうと思います.この記事では,キャラクタートゥーン表現をおこなう上での表現を,手法別に記載していきます.

環境は,UnityのURPを前提としてシェーダを書いていきます.また,シェーダのお作法など基礎的な話は省略して説明していくのでご了承ください.

Unityの環境は以下の通りです.

Unity 2021.3.8f1

キャラクターモデルはsonoさんのQuQuオリジナルアバター”U”ちゃんを用いています(かわいいのでみんな買おう!).

QuQu - BOOTH

手法1:Lambert反射の二値化

トゥーンシェーダの王道にして基礎

拡散反射

拡散反射とは,非金属表面付近で起きる光の反射のうち,界面で発生する鏡面反射を除いた成分のことを指します.Lambert反射は拡散反射の近似式で,モデルの方線とライトベクトルを用いて拡散反射を表現します.

キャラクターの法線ベクトルと,キャラからライトに向かった方向ベクトルの内積を0~1に正規化し,これをカラーのIntensity(強さ)とします.


画像:Lambert拡散反射を符号なし正規化(0~1)し,キャラモデルに反映させたもの.

二値化陰の考え方

この値を用いて,カラーを二値化(陰or陰じゃないか)します.ユーザが入力した定数よりも内積の値が小さい場合,陰であると判定し,陰判定となった部分には陰色を描く,といった処理になります.


画像: 二値化陰判定の考え方(内積の数値は適当です).

画像: PBR(Physically based rendering)とLambert二値化での見た目比較.

コード解説

では実際にシェーダをみていきます.先に全コードを載せておきます.

Shader "InPro/Character-Toon-Lambert"
{
    Properties
    {
        [Header(Lambert)]
        _MainTex ("MainTex", 2D) = "white" {}
        _LambertThresh("LambertThresh", float) = 0.5 
    }
    SubShader
    {
        Tags 
        {
            "RenderType"="Opaque"
            "RenderPipeline"="UniversalPipeline"
        }

        Pass
        {
            Name "Character-Toon"
            Tags { "LightMode"="UniversalForward" }

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag

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

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

            struct v2f
            {
                float4 vertex : SV_POSITION;
                float3 normal : NORMAL;
                float2 uv : TEXCOORD1;
            };

            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);
            
            float4 _MainTex_ST;
            float _LambertThresh;
            
            v2f vert (appdata v)
            {
                v2f o;

                VertexPositionInputs inputs = GetVertexPositionInputs(v.vertex.xyz);
                // スクリーン座標に変換.
                o.vertex = inputs.positionCS;
                // ワールド座標系変換.
                o.normal = normalize(TransformObjectToWorldNormal(v.normal));
                
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                
                return o;
            }

            float4 frag (v2f i) : SV_Target
            {
                // Main light情報の取得.
                Light mainLight;
                mainLight = GetMainLight();
                
                float4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
                
                //  UNorm lambert. 0~1.
                float uNormDot = saturate(dot(mainLight.direction.xyz, i.normal) * 0.5f + 0.5f);
                // _LambertThreshを閾値とした二値化.
                // step(y,x) ... y < x ? 1 : 0
                float ramp = step(uNormDot, _LambertThresh);
                // mainLight.colorの乗算を影色とする.
                color.rgb = lerp(color, color * mainLight.color, ramp);
                return color;
            }
            ENDHLSL
        }
    }
}

まず,ユーザが制御できるパラメータをPropertiesに定義します

Properties
{
    [Header(Lambert)]
    _MainTex ("MainTex", 2D) = "white" {}
    _LambertThresh("LambertThresh", float) = 0.5 
}

今回はテクスチャサンプリング用と,二値化の閾値を決められるように2つのパラメータを用意しています.

頂点シェーダでは,ピクセルシェーダで扱う情報を計算しています.

今回は描画用にスクリーン空間の頂点座標,Lambert計算用にワールド空間の法線ベクトル,テクスチャサンプリング用にuv座標を取得します.

v2f vert (appdata v)
{
    v2f o;

    VertexPositionInputs inputs = GetVertexPositionInputs(v.vertex.xyz);
    // スクリーン座標に変換.
    o.vertex = inputs.positionCS;
    // ワールド座標系変換.
    o.normal = normalize(TransformObjectToWorldNormal(v.normal));
    
    o.uv = TRANSFORM_TEX(v.uv, _MainTex);
    o.color = v.color;
    
    return o;
}

ピクセルシェーダでは,テクスチャサンプリング,二値化陰判定用に計算しています.

float4 frag (v2f i) : SV_Target
{
    // Main light情報の取得.
    Light mainLight;
    mainLight = GetMainLight();
    
    float4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
    
    //  UNorm lambert. 0~1.
    float uNormDot = saturate(dot(mainLight.direction.xyz, i.normal) * 0.5f + 0.5f);
    // _LambertThreshを閾値とした二値化.
    // step(y,x) ... y < x ? 1 : 0
    float ramp = step(uNormDot, _LambertThresh);
    // mainLight.colorの乗算を影色とする.
    color.rgb = lerp(color, color * mainLight.color, ramp);
    return color;
}

Unity標準のDirectionalLight情報はLight構造体に格納されており,GetMainLight関数で取得することができます.

Light mainLight;
mainLight = GetMainLight();

そして,二値化陰の計算はここが重要となります.

//  UNorm lambert. 0~1.
float uNormDot = saturate(dot(mainLight.direction.xyz, i.normal) * 0.5f + 0.5f);
// _LambertThreshを閾値とした二値化.
// step(y,x) ... y < x ? 1 : 0
float ramp = step(uNormDot, _LambertThresh);
// mainLight.colorの乗算を影色とする.
color.rgb = lerp(color, color * mainLight.color, ramp);

dot(mainLight.direction.xyz, i.normal)

でサーフェイスからみたライト方向ベクトルと法線ベクトルの内積を計算します.そして * 0.5f + 0.5f で0~1に正規化しています.

float ramp = step(uNormDot, _LambertThresh);

が二値化の判定処理です.step関数を使ってuNormDot_LambertThresh よりも小さいかどうかで0 or 1を返します.

ステップ - Win32 apps


画像:ramp値をそのままキャラモデルに描画した様子.影になる部分(ramp=1)が白く表示されている.

_LambertThreshはマテリアル側から制御するユーザパラメータで,この値によって陰のなりやすさが変化します.

最後に

color.rgb = lerp(color, color * mainLight.color, ramp);

で色を決定します.ramp=1すなわち陰判定となっている場合ライトカラーを乗算した値を最終カラーとします.

lerp - Win32 apps

これでキャラクターに二値化陰が反映されました!

おまけ:MainLightのdirectionについて

Light構造体にdirection(ライトの方向ベクトル)が存在しています.

// RealtimeLights.hlsl

struct Light
{
    half3   direction;
    half3   color;
    float   distanceAttenuation;// full-float precision required on some platforms
half    shadowAttenuation;
    uint    layerMask;
};

Light構造体はGetMainLight()でそのフレームのライト状態を取得することができます.directionは_MainLightPositionという変数を代入しているようです.

Light GetMainLight()
{
    Light light;
    light.direction = half3(_MainLightPosition.xyz);

		...

では_MainLightPositionはどこで更新されているのか?どういった値を持っているのか?

SRP(URP)でフォワードレンダリングを採用している場合(今回はURPのフォワードレンダリングを採用しています)は,ForwardLights.csのSetupMainLightConstants関数で_MainLightPositionを更新しています.

LightConstantBuffer._MainLightPositionが_MainLightPositionです.

// ForwardLights.cs

void SetupMainLightConstants(CommandBuffer cmd, ref LightData lightData)
{
    Vector4 lightPos, lightColor, lightAttenuation, lightSpotDir, lightOcclusionChannel;
    uint lightLayerMask;
    InitializeLightConstants(lightData.visibleLights, lightData.mainLightIndex, out lightPos, out lightColor, out lightAttenuation, out lightSpotDir, out lightOcclusionChannel, out lightLayerMask);

    cmd.SetGlobalVector(LightConstantBuffer._MainLightPosition, lightPos);
    cmd.SetGlobalVector(LightConstantBuffer._MainLightColor, lightColor);
    cmd.SetGlobalVector(LightConstantBuffer._MainLightOcclusionProbesChannel, lightOcclusionChannel);
    cmd.SetGlobalInt(LightConstantBuffer._MainLightLayerMask, (int)lightLayerMask);
}

lightPosの更新を追ってみると,UniversalRenderPipelineCore.csのInitializeLightConstants_Common関数で更新をおこなっています.

// UniversalRenderPipelineCore.cs 
// void InitializeLightConstants_Common

VisibleLight lightData = lights[lightIndex];
if (lightData.lightType == LightType.Directional)
{
    Vector4 dir = -lightData.localToWorldMatrix.GetColumn(2);
    lightPos = new Vector4(dir.x, dir.y, dir.z, 0.0f);
}
else
{
    Vector4 pos = lightData.localToWorldMatrix.GetColumn(3);
    lightPos = new Vector4(pos.x, pos.y, pos.z, 1.0f);
}

LightTypeがDirectional Lightの場合,ライトのワールド座標ではなく,ワールド座標の方向ベクトルを返していることがわかります.

さらに,

Vector4 dir = -lightData.localToWorldMatrix.GetColumn(2);

をみるとわかるように,ライトの方向ベクトルに-1をかけています.これは”サーフェイスから見たライトへの方向ベクトル”を返していることがわかります.

引用で補足説明を追記します.

the direction of a directional light is determined by its rotation. The light shines along its local Z axis. We can find this vector in world space via the **[VisibleLight](http://docs.unity3d.com/Documentation/ScriptReference/Rendering.VisibleLight.html)**.localtoWorld matrix field. The third column of that matrix defines the transformed local Z direction vector, which we can get via the **[Matrix4x4](http://docs.unity3d.com/Documentation/ScriptReference/Matrix4x4.html)**.GetColumn method, with index 2 as an argument. That gives us the direction in which the light is shining, but in the shader we use the direction from the surface toward the light source. So we have to negate the vector before we assign it to visibleLightDirections. As the fourth component of a direction vector is always zero, we only have to negate X, Y, and Z.

Lights

つまり何が言いたいかというと,Light構造体にあるdirectionは

DirectionalLightの場合

このベクトルじゃなくて

こっちのベクトルを返しています!

Discussion