👻

【Unity】ハーフトーンシェーダーでコミック風の表現(URP)

2022/07/11に公開

ハーフトーンとは

ハーフトーン

水玉模様(ドット)使って陰影を表現する手法です。
色の使えない雑誌や漫画で色の違いや陰影を表現するために使われることが多く、漫画を描く人やよく読む人にとっては「スクリーントーン」という単語の方がしっくりくるかもしれません。

星のカービィ スターアライズ
星のカービィ スターアライズ

3Dのグラフィックスでは紙面と違って色に制限はないので繊細なグラデーションで陰影をつけることができますが、あえてハーフトーンによって陰影をつけることでコミカルな雰囲気を作ったり、印象的なビジュアルを作ったりすることができます。

スパイダーマン:スパイダーバース
スパイダーマン:スパイダーバース

スパイダーバースはまさに「アメコミ」のようなコンセプトで、3D映画ながら往年のアメコミ雑誌のような印象を与える効果がたくさん盛り込まれています。
基本的な陰影はグラデーションで表現されていますが、リムライト(逆光)の境界のグラデーションにハーフトーンが使われています。このように、エッセンス的にハーフトーン表現を使うのも面白いです。

今回作ったもの

https://twitter.com/flankids/status/1545709811559370753?s=20&t=aN4fm7VKzbKImHkLbSSVHA

こんな感じで、テクスチャの色を使いつつ影の境界がハーフトーンで表現されるようなものを作りました。

ハーフトーンシェーダーの考え方

  1. まずパラメータで指定したインターバルで、画面上を細かく区画分けします

  1. シェーダーで色を決める対象の各ピクセルが画面上のどの区画に属しているかを計算します

  1. 対象の区画の中央座標から、現在のピクセルがどのくらい離れているか計算します

  1. 距離がしきい値より大きいか小さいかで対象ピクセルを陰色で塗るかどうか判定します

すると画面内の各区画の中央から一定範囲が影色で塗られ、上図の左の球のようにモデルがドット柄になります。
この後、モデルの形を考慮してドットのサイズを変えて立体感を出していきますが、まずはここまでのシェーダーを書いていきましょう。

ドット柄をつくるシェーダー

今回、シェーダーはURPで書いていきます。
まずは単にモデルにテクスチャを貼り付けるだけのシェーダーを添付します。これに処理を追加していきましょう。

テクスチャだけのシェーダー
HalfTone.shader
Shader "Custom/HalfTone"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    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/Core.hlsl"

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

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

            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);

            CBUFFER_START(UnityPerMaterial)
            float4 _MainTex_ST;
            CBUFFER_END

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

            float4 frag (v2f i) : SV_Target
            {
                // sample the texture
                float4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
                return col;
            }
            ENDHLSL
        }
    }
}

①区画のインターバル変数_HalftoneScale

始めに画面内の区画のインターバルを指定する変数_HalftoneScaleを追加しましょう。
画面の横幅の長さを1として、今回は0.001〜0.1の範囲で指定できるようにしています。

HalfTone.shader
Properties
{
    _MainTex ("Texture", 2D) = "white" {}
+   _HalftoneScale ("Halftone Scale", Range(0.001, 0.1)) = 0.02
}
HalfTone.shader
     CBUFFER_START(UnityPerMaterial)
     float4 _MainTex_ST;
+    float _HalftoneScale;
     CBUFFER_END

②対象ピクセルの画面上の位置screenPos

次に描画対象のピクセルが画面上のどの位置にあるかをComputeScreenPosを使って計算し、変数screenPosを介してフラグメントシェーダーに渡します。

HalfTone.shader
struct v2f
{
    float2 uv : TEXCOORD0;
    float4 vertex : SV_POSITION;
+   float4 screenPos : TEXCOORD1;
};
HalfTone.shader
v2f vert (appdata v)
{
    v2f o;
    o.vertex = TransformObjectToHClip(v.vertex.xyz);
    o.uv = TRANSFORM_TEX(v.uv, _MainTex);
+   o.screenPos = ComputeScreenPos(o.vertex);
    return o;
}

③対象ピクセルと区画の中心点の距離に応じて塗り分ける

フラグメントシェーダーの計算対象ピクセルscreenPos_HalftoneScaleによって仕切った区画のどこに所属するかと、その中心点との距離を計算して、一定距離未満なら色を塗ることでドット柄を表現します。

HalfTone.shader
float4 frag (v2f i) : SV_Target
{
    // sample the texture
    float4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);

+   // wで除算して0~1の値にする
+   float2 screenPos = i.screenPos.xy / i.screenPos.w;
+   // 画面サイズのy幅がx幅の何倍かを計算する
+   float aspect = _ScreenParams.x / _ScreenParams.y;
+   // 各区画のサイズ
+   float2 cellSize = float2(_HalftoneScale, _HalftoneScale * aspect);
+
+   // 対象ピクセルが属する区画の中心点を計算する
+   float2 cellCenter;
+   cellCenter.x = floor(screenPos.x / cellSize.x) * cellSize.x + cellSize.x / 2;
+   cellCenter.y = floor(screenPos.y / cellSize.y) * cellSize.y + cellSize.y / 2;
+
+   // 区画の中心点との差分ベクトルを0~1の範囲に補正する
+   float2 diff = screenPos - cellCenter;
+   diff.x /= cellSize.x;
+   diff.y /= cellSize.y;
+
+   // 対象ピクセルと区画の中心点の距離がしきい値より小さかったら色を暗くする(0.5乗算)
+   float threshold = 0.3; // しきい値
+   col.rgb *= lerp(1, 0.5, step(length(diff), threshold));

    return col;
}

これで、下図のようにモデルをトーン柄で塗ることができました。

ついでに色味の調整のために、トーン柄として乗算する値をパラメータで指定できるようにしておきましょう。

HalfTone.shader
Properties
{
    _MainTex ("Texture", 2D) = "white" {}
    _HalftoneScale ("Halftone Scale", Range(0.001, 0.1)) = 0.02
+   _ShadeColor ("Shade Color", Color) = (0.5, 0.5, 0.5)
}
HalfTone.shader
    CBUFFER_START(UnityPerMaterial)
    float4 _MainTex_ST;
    float _HalftoneScale;
+   float3 _ShadeColor;
    CBUFFER_END
HalfTone.shader
-   // 対象ピクセルと区画の中心点の距離がしきい値より小さかったら色を暗くする(0.5乗算)
+   // 対象ピクセルと区画の中心点の距離がしきい値より小さかったら色を塗る
    float threshold = 0.3; // しきい値
-   col.rgb *= lerp(1, 0.5, step(length(diff), threshold));
+   col.rgb *= lerp(1, _ShadeColor, step(length(diff), threshold));

    return col;


塗り色が指定できるように

陰の濃さをドットの大きさで表現

ここまでで、モデルを指定した粒度のドット柄で塗り潰すシェーダーを作りました。
最後の仕上げとして、上図のようにドットの大きさをコントロールしてモデルの陰影を表現します。

まず、モデルの法線情報を使うため、normalを計算してフラグメントシェーダーに渡します。

HalfTone.shader
struct appdata
{
    float4 vertex : POSITION;
    float2 uv : TEXCOORD0;
+   float3 normal : NORMAL;
};

struct v2f
{
    float2 uv : TEXCOORD0;
    float4 vertex : SV_POSITION;
    float4 screenPos : TEXCOORD1;
+   float3 normal : NORMAL;
};
HalfTone.shader
v2f vert (appdata v)
{
    v2f o;
    o.vertex = TransformObjectToHClip(v.vertex.xyz);
    o.uv = TRANSFORM_TEX(v.uv, _MainTex);
    o.screenPos = ComputeScreenPos(o.vertex);
+   o.normal = TransformObjectToWorldNormal(v.normal);
    return o;
}

次にライトの角度を取得するため、Lighting.hlslをincludeしてフラグメントシェーダー内でメインライトの情報をローカル変数に入れておきます。

HalfTone.shader
    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
+   #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
HalfTone.shader
+   // ライト情報を取得
+   Light light = GetMainLight();

    // 対象ピクセルと区画の中心点の距離がしきい値より小さかったら色を塗る
    float threshold = 0.3; // しきい値
    col.rgb *= lerp(1, _ShadeColor, step(length(diff), threshold));

そして、拡散反射のシェーディングの要領でライトの向きベクトルと法線の内積をドットを作るしきい値に利用します。

HalfTone.shader
    // ライト情報を取得
    Light light = GetMainLight();
+   // ピクセルの法線とライトの方向の内積を計算する
+   float threshold = 1 - dot(i.normal, light.direction);

    // 対象ピクセルと区画の中心点の距離がしきい値より小さかったら色を塗る
-   float threshold = 0.3; // しきい値
    col.rgb *= lerp(1, _ShadeColor, step(length(diff), threshold));

これによって、ハーフトーンによるモデルの陰影が表現できました!
https://twitter.com/flankids/status/1546462342338457600?s=20&t=7aEwXitGq6Mpu704selh5g

まとめ

今回はシンプルなハーフトーンの処理について解説しましたが、トゥーンシェーディングのようなしきい値の範囲設定でドットのグラデーションを微調整するようなこともできます。

https://twitter.com/flankids/status/1546466203606351877?s=20&t=7aEwXitGq6Mpu704selh5g

影色の作り方も単純な乗算ではなく、いろいろな手法を使うことで発展した表現を考えるのも楽しそうです。

https://twitter.com/flankids/status/1541009547464015873?s=20&t=7aEwXitGq6Mpu704selh5g

紙媒体やイラストの表現を3Dに落とし込むシェーディングは、ハーフトーン以外にも水彩風や墨絵風など、いろんなものがあるので今後もそういったシェーディングに挑戦したいです。

今回のシェーダー全文

HalfToneシェーダー
HalfTone.shader
Shader "Custom/HalfTone"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _HalftoneScale ("Halftone Scale", Range(0.001, 0.1)) = 0.02
        _ShadeColor ("Shade Color", Color) = (0.5, 0.5, 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/Core.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"

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

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

            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);

            CBUFFER_START(UnityPerMaterial)
            float4 _MainTex_ST;
            float _HalftoneScale;
            float3 _ShadeColor;
            CBUFFER_END

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = TransformObjectToHClip(v.vertex.xyz);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                o.screenPos = ComputeScreenPos(o.vertex);
                o.normal = TransformObjectToWorldNormal(v.normal);
                return o;
            }

            float4 frag (v2f i) : SV_Target
            {
                // sample the texture
                float4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);

                // wで除算して0~1の値にする
                float2 screenPos = i.screenPos.xy / i.screenPos.w;
                // 画面サイズのy幅がx幅の何倍かを計算する
                float aspect = _ScreenParams.x / _ScreenParams.y;
                // 各区画のサイズ
                float2 cellSize = float2(_HalftoneScale, _HalftoneScale * aspect);

                // 対象ピクセルが属する区画の中心点を計算する
                float2 cellCenter;
                cellCenter.x = floor(screenPos.x / cellSize.x) * cellSize.x + cellSize.x / 2;
                cellCenter.y = floor(screenPos.y / cellSize.y) * cellSize.y + cellSize.y / 2;

                // 区画の中心点との差分ベクトルを0~1の範囲に補正する
                float2 diff = screenPos - cellCenter;
                diff.x /= cellSize.x;
                diff.y /= cellSize.y;

                // ライト情報を取得
                Light light = GetMainLight();
                // ピクセルの法線とライトの方向の内積を計算する
                float threshold = 1 - dot(i.normal, light.direction);

                // 対象ピクセルと区画の中心点の距離がしきい値より小さかったら色を塗る
                col.rgb *= lerp(1, _ShadeColor, step(length(diff), threshold));

                return col;
            }
            ENDHLSL
        }
    }
}

Discussion