🎨

【Unity URP】キャラクタートゥーンシェーダの表現手法をまとめる その2(明暗境界線の彩度上げ)

2023/05/23に公開

はじめに

いつかキャラクタートゥーンシェーダの記事を書きたいと考えていたので,基礎的なところから書いていこうと思います.この記事では,キャラクタートゥーン表現をおこなう上での表現を,手法別に記載していきます.
環境はUnityのURPを前提としてシェーダを書いていきます.また,シェーダのお作法など基礎的な話は省略して説明していくのでご了承ください.
 Unityの環境は以下の通りです.

Unity 2021.3.8f1

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

なお,この記事はこれの続きになります
【Unity URP】キャラクタートゥーンシェーダの表現手法をまとめる(Lambert二値化)

手法2:明暗境界線の彩度上げ

目指したい表現について

イラストの表現において,明暗境界線の彩度を上げる手法があります.いわゆるSSS,サブサーフェス・スキャタリングの表現なのでは?というお話もあり,より光がキャラクターに馴染むような表現だなと思います.

SSS(サブサーフェイススキャッタリング)のお話|高原さと

イラストで表現してみます.光と影の境界線部分に着目してみてください.左のイラストは明暗境界線の彩度上げ表現なし,右が彩度上げ表現ありです.キャラクターが受ける光が,よりやわらかく感じられると思います.

画像:明暗境界線の彩度上げ表現.最近はまっている”崩壊:スターレイル”よりゼーレを描いてみました.

明暗境界線の彩度上げをイラストで表現する場合,オーバーレイというレイヤー効果で,肌の色より彩度が高い色で,エアブラシでふわっとかけてあげます.

画像:明暗境界線の彩度上げ表現をイラスト制作でおこなう場合のワークフロー.

この表現はイラスト特有というわけでもなく,3Dグラフィックスにおいても有効です.この明暗境界線の扱いをどうするかによって,そのゲームの特色が出るといっても過言ではないと思います.例としてスターレイルを見てみましょう.

画像:スターレイルよりゼーレの明暗境界線表現.

うっすら赤色がグラデーションでかかっているのがわかると思います.このカラーや範囲,境界線をはっきり出すかどうか等々…様々な要素でキャラクターの雰囲気が変わります.

実装の考え方

表現したい方法によって何パターンか実装方法はありますが,今回自分が表現したいものは下記のようなものと定義しました.

  • 明暗境界線から影のみにオーバーレイをかける
  • 明暗境界線の境界部分が一番オーバーレイの効果がのり,徐々にグラデーションになるように影色とブレンドする

これらを実装してみます.

オーバーレイ効果とは

明暗境界線の彩度上げをイラストで表現する場合,オーバーレイというレイヤー効果で,肌の色より彩度が高い色で,エアブラシでふわっとかけてあげます.

前節でイラストで表現する上での表現でサラッと出しましたが,オーバーレイの効果を見てみます.オーバーレイとは,ベースカラーの色によってスクリーン効果と乗算効果が適用される効果です.明るいベースカラーにはスクリーン効果が,暗いベースカラーには乗算効果がかかります.

乗算は名前の通り
演算後のカラー = ベースカラー × 合成カラー
で計算されます.計算の通り,より暗い色になります.

スクリーンは,
演算後のカラー = ベースカラー + 合成カラー -(ベースカラー × 合成カラー)
で計算され,ヒストグラムで考えると乗算の逆の効果がかかります.よって,より明るい色になります.

オーバーレイは,カラー値(0~255)を正規化(0~1)したときに,
ベースカラー < 0.5の場合:
演算後のカラー = 2 ×(ベースカラー × 合成カラー)
ベースカラー > 0.5の場合:
演算後のカラー = 1 – 2 ×(1 – ベースカラー)×(1 – 合成カラー)
で計算されます.ベースカラーの明るさによって処理内容が変化し,
明るい場合→スクリーン,暗い場合→乗算
の効果がのります.

詳しくはこちらのサイトがわかりやすくまとめられていますので,気になる方は確認してみてください.
Photoshopの描画モード(ブレンドモード)を理解するための、画像合成は計算だという話 | 俺CG屋

コード解説

前回同様全コードを先に書きます.前回の内容も含まれているので少し長くなっています.

Shader "InPro/Character-Toon-Lambert-sss"
{
    Properties
    {
        [Header(Shading)]
        _MainTex ("MainTex", 2D) = "white" {}
        _LambertThresh("LambertThresh", float) = 0.5 
        _GradWidth("ShadowWidth", Range(0.003,1)) = 0.1
        _Sat("Sat", Range(0, 2)) = 0.5
    }
    SubShader
    {
        Tags {
            "RenderType"="Opaque"
            "RenderPipeline"="UniversalPipeline"
        }
        LOD 100

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForward" }

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            
            #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;
            float _GradWidth;
            float _Sat;
            
            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.5 + 0.5);
                // step(y,x) ... y < x ? 1 : 0
                float isShaded = step(uNormDot, _LambertThresh);
                
                if(isShaded)
                {
                    // 影色の決定.
                    half3 multiColor = mainLight.color * color.rgb;
                    // 中間色をオーバーレイでグラデーションするために,彩度を上げる.
                    half3 hsv = RgbToHsv(mainLight.color);
                    hsv.g *= _Sat;
                    half3 overlayInputColor = HsvToRgb(hsv);

                    // オーバーレイ演算.
                    // if(基本色 < 0.5) 結果色 = 基本色 * 合成色 * 2
                    // else if(基本色 ≧ 0.5) 結果色 = 1 – 2 ×(1 – 基本色)×(1 – 合成色)
                    half3 overlayThreshold = step(0.5f, multiColor);
                    // overlayThreshold == 0 ... 乗算, overlayThreshold == 1 ... スクリーン
                    // 乗算カラーをベースに,オーバーレイ効果ブレンドする.
                    half3 overlayColor = lerp(overlayInputColor * multiColor * 2.f, 1.f - 2 * (1.f - overlayInputColor) * (1.f - multiColor), overlayThreshold);
                    // オーバーレイと乗算をグラデーションして最終陰色を決定.
                    color.rgb = lerp(overlayColor, multiColor, 1 - saturate(uNormDot - (_LambertThresh - _GradWidth)) / _GradWidth);
                }
                return color;
            }
            ENDHLSL
        }
    }
}

今回新しく追加した部分を解説します.まずはユーザパラメータ部分.

_GradWidth("ShadowWidth", Range(0.003,1)) = 0.1
_Sat("Sat", Range(0, 2)) = 0.5

_GradWidthはグラデーションの範囲を制御する用です._Satはオーバーレイカラーの彩度調整用です.
 ピクセルシェーダ以外は特に目立った変更はないため,ピクセルシェーダの処理を見ていきます.

// 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.5 + 0.5);
// step(y,x) ... y < x ? 1 : 0
float isShaded = step(uNormDot, _LambertThresh);

ここまでは前回の処理とほぼ同じですが,isShadedに陰になるかどうかの判定をもたせています.
isShadedが1の場合,オーバーレイのグラデーションと陰色処理をおこないます.isShadedが1の場合,下記が実行されます.

if(isShaded)
{
    // 影色の決定.
    half3 multiColor = mainLight.color * color.rgb;
    // 中間色をオーバーレイでグラデーションするために,彩度を上げる.
    half3 hsv = RgbToHsv(mainLight.color);
    hsv.g *= _Sat;
    half3 overlayInputColor = HsvToRgb(hsv);

    // オーバーレイ演算.
    // overlayThreshold == 0 ... 乗算, overlayThreshold == 1 ... スクリーン
    half3 overlayThreshold = step(0.5f, multiColor);
    // if(基本色 < 0.5) 結果色 = 基本色 * 合成色 * 2
    // else if(基本色 ≧ 0.5) 結果色 = 1 – 2 ×(1 – 基本色)×(1 – 合成色)
    half3 overlayColor = lerp(overlayInputColor * multiColor * 2.f, 1.f - 2 * (1.f - overlayInputColor) * (1.f - multiColor), overlayThreshold);
    // オーバーレイと乗算をグラデーションして最終陰色を決定.
    color.rgb = lerp(overlayColor, multiColor, 1 - saturate(uNormDot - (_LambertThresh - _GradWidth)) / _GradWidth);
}

前半と後半で分けていて,前半は陰色とオーバーレイカラーの決定をしています.前節で定義した”ベースカラー”はテクスチャカラー,”合成カラー”はディレクショナルライトカラーにあたります.陰色はテクスチャカラーとライトカラーの乗算にしています.
オーバーレイカラーは陰色よりも彩度が高い色を採用したいので,ライトカラーをRGB→HSV変換し,彩度を上げてからHSV→RGB変換しています(SがSaturation,彩度情報をもっています). 後半はオーバーレイ処理をおこないます.

// overlayThreshold == 0 ... 乗算, overlayThreshold == 1 ... スクリーン
half3 overlayThreshold = step(0.5f, multiColor);

で乗算かスクリーンかどちらの演算処理をおこなうかのフラグを定義します.RGBごとに判定をとっています.

// if(ベースカラー < 0.5) 演算後のカラー = ベースカラー * 合成カラー * 2
// else if(ベースカラー ≧ 0.5)  演算後のカラー  = 1 – 2 ×(1 – ベースカラー)×(1 – 合成カラー)
// 乗算カラーをベースに,オーバーレイ効果ブレンドする.
half3 overlayColor = lerp(overlayInputColor * multiColor * 2.f, 1.f - 2 * (1.f - overlayInputColor) * (1.f - multiColor), overlayThreshold);

ここで先ほどの乗算orスクリーン判定値をもとに,乗算カラーもしくはスクリーンカラーを定義します.このoverlayColorがオーバーレイカラーとなります.
最後に,オーバーレイ部分の範囲と完全な陰範囲をグラデーションでブレンドします.明暗境界線となる内積値の場合オーバーレイカラーを返し,内積が小さくなるにつれ徐々に乗算カラーをブレンドしたカラーを返します.

color.rgb = lerp(overlayColor, multiColor, 1 - saturate(uNormDot - (_LambertThresh - _GradWidth)) / _GradWidth);

これでオーバーレイのグラデーションがかかりました!

あとはお好みでオーバーレイの彩度や長さを調整します.

Discussion