【Unity】セルシェーディングを1から作ってみるメモ【Shader】

はじめに

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

セルシェーディングを作る機会があったので忘れないように覚え書きしておきます。

できたものがこちらです。

前準備

まず最初に陰影をシェーダーで確認したいため、元のテクスチャーに含まれている陰影を平坦化して加工します。

こんな感じでベタ塗り加工した

UnityのStandardシェーダーで描画したもの

試しに Unity 標準の Standardシェーダーで表示したもの。テカテカでビニール製のようです。

トゥーンシェーディングしてみる

テクスチャーカラーのみ描画したもの

基本このパスがベースとなっていきます。

アルベドカラーだけ出力するシェーダーです。
参考に公開しておきます。

Pass
{
	CGPROGRAM

	#pragma vertex vert
	#pragma fragment frag

	sampler2D _MainTex;
	float4 _MainTex_ST;
	float4 _MainTex_TexelSize;

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

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

	v2f vert( appdata v)
	{
		v2f o = (v2f)0;
		o.pos = UnityObjectToClipPos( v.vertex);
		o.uv = TRANSFORM_TEX( v.uv, _MainTex);
		return o;
	}

	half4 frag( v2f i) : SV_Target
	{
		float4 sample = tex2D( _MainTex, i.uv);
		return sample;
	}

	ENDCG
}//Pass

ランバート拡散照明で陰影を描画

ランバート拡散照明 の計算結果です。
ライトと法線から 0から1 で出力されます。
バンプマップを適応する場合は、ここに追加するといい感じになりますが、今回のテーマでは必要ないですね。

Pass
{
	CGPROGRAM

	#pragma vertex vert
	#pragma fragment frag

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

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

	v2f vert( appdata v)
	{
		v2f o = (v2f)0;
		o.vertex = UnityObjectToClipPos( v.vertex);
		o.normal = UnityObjectToWorldNormal( v.normal);

		return o;
	}

	half4 frag( v2f i) : SV_Target
	{
		float3 L = normalize( _WorldSpaceLightPos0.xyz);
		float3 N = normalize( i.normal);
		float nl = dot( N, L);
		return float4( nl, nl, nl, 1);
	}
	ENDCG
}//Pass

トゥーンマップでアニメ調にレンダリング

単純に アルベドカラーとランバート拡散照明を乗算すると以下の結果になります。

全くアニメっぽくないので トゥーンマップ を用意します。
トゥーンマップからランバートの結果をもとに明るさを参照するようにします。

このようなテクスチャを作り陰り具合をマッピングする

Wrap Mode は Clamp に設定しておいたほうが良いです。Repeatだと 一番明るい部分がリピートして暗くなることがあります。

テクスチャの左が一番暗い部分、右が一番明るい部分とします。
テクスチャにしているのはデザイナーさんが調整しやすいためというのもありますが、昔はGPUに情報を渡すにはテクスチャーに入れて参照するしかなかったので、その名残かなーと思ってます。

Pass
{
	CGPROGRAM

	#pragma vertex vert
	#pragma fragment frag

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

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

	sampler2D _MainTex;
	float4 _MainTex_ST;
	sampler2D _ToonMap;

	v2f vert( appdata v)
	{
		v2f o = (v2f)0;
		o.vertex = UnityObjectToClipPos( v.vertex);
		o.normal = UnityObjectToWorldNormal( v.normal);
		o.vertexW = mul( unity_ObjectToWorld, v.vertex);
		o.uv = TRANSFORM_TEX( v.uv, _MainTex);

		return o;
	}

	half4 frag( v2f i) : SV_Target
	{
		float3 L = normalize( _WorldSpaceLightPos0.xyz);
		float3 V = normalize( _WorldSpaceCameraPos - i.vertexW.xyz);
		float3 N = i.normal;

    // 明度計算 0.0 ~ 1.0
		float nl = max( 0.0, dot( N, L));
    // トゥーンマップから明度を参照する
		float toon = tex2D( _ToonMap, half2( nl, 0));

		float3 sample = tex2D( _MainTex, i.uv).rgb;
    // アルベドと明度を乗算
		float4 color = float4( sample * toon, 1);
		return color;
	}
	ENDCG
}

この部分で明るさをトゥーンマップの UVとして利用しています。

float toon = tex2D( _ToonMap, half2( nl, 0));

その後 アルベド と トゥーンを乗算します。

float4 color = float4( sample * toon, 1);

だいぶそれっぽくなりましたが、あまり綺麗ではないですね。
実は 明度 だけでは綺麗に出ません。彩度も調整して色を濃くする必要があります。

彩度も変更した結果がこちらです。

彩度・明度を調整

全体的に引き締まった絵になりました。

HSVに変換して 彩度・明度を調整する

彩度・明度を調整するにあたって RGB では調整しにくいため 一旦 HSV に変換して 彩度・明度 を調整します。

【Unity】RGBをHSVに変換して明るさとかを変えるシェーダー
こちらのサイトを参考にさせていただき、最適化してみました。

// RGB->HSV変換
float3 rgb2hsv( float3 rgb)
{
	float3 hsv;

	// RGBの三つの値で最大のもの
	float maxValue = max( rgb.r, max( rgb.g, rgb.b));
	// RGBの三つの値で最小のもの
	float minValue = min( rgb.r, min( rgb.g, rgb.b));
	// 最大値と最小値の差
	float delta = maxValue - minValue;

	// V(明度)
	// 一番強い色をV値にする
	hsv.z = maxValue;

	// S(彩度)
	// 最大値と最小値の差を正規化して求める
	hsv.y = lerp( 0.0, delta / maxValue, abs( sign( maxValue - 0.0)));

	// H(色相)
	// RGBのうち最大値と最小値の差から求める
	if( hsv.y > 0.0)
	{
		hsv.x = lerp( 
			lerp( 4 + (rgb.r - rgb.g) / delta, 2 + (rgb.b - rgb.r) / delta, 1-abs(sign(rgb.g-maxValue))),
			(rgb.g - rgb.b) / delta,
			1-abs(sign(rgb.r - maxValue))
		) / 6.0;
		hsv.x += lerp( 0.0, 1.0, 1-step( 0, hsv.x));
	}
	return hsv;
}

// HSV->RGB変換
float3 hsv2rgb( float3 hsv)
{
	float3 rgb;

	if( hsv.y == 0)
	{
		// S(彩度)が0と等しいならば無色もしくは灰色
		rgb.r = rgb.g = rgb.b = hsv.z;
	}
	else
	{
		// 色環のH(色相)の位置とS(彩度)、V(明度)からRGB値を算出する
		hsv.x *= 6.0;
		float i = floor (hsv.x);
		float f = hsv.x - i;
		float aa = hsv.z * (1 - hsv.y);
		float bb = hsv.z * (1 - (hsv.y * f));
		float cc = hsv.z * (1 - (hsv.y * (1 - f)));

		rgb =
			lerp(
				lerp(
					lerp(
						lerp(
							lerp(
								float3( hsv.z, aa, bb),
								float3( cc, aa, hsv.z),
								1-step(5,i)
							),
							float3( aa, bb, hsv.z), 1-step(4,i)
						),
						float3( aa, hsv.z, cc), 1-step(3,i)
					),
					float3( bb, hsv.z, aa), 1-step(2,i)
				),
				float3( hsv.z, cc, aa), 1-step(1,i)
			);
	}
	return rgb;
}

条件式を可能な限り省いて lerp と step に置き換えています。デバッグ不足でエラーがあるかもしれません:p

これを利用してフラグメントシェーダーを書き換えます。

float3 sample = tex2D( _MainTex, i.uv).rgb;
// hsvに変換し彩度・明度を調整する
float3 hsv = rgb2hsv( sample);
hsv.y = min( hsv.y + toon, 1.0); // 彩度変更
hsv.z = max( hsv.z - toon, 0.0); // 明度変更
float4 color = float4( hsv2rgb( hsv), 1);
return color;

ハイライトを追加する

アニメでよく見る髪のハイライトのようなもの

↓追記(2020年5月23日) 髪ハイライト更新しました。

例えばこのような表現です。

赤で囲んだ部分

フォン鏡面反射モデルでハイライトを描画

スペキュラ計算してハイライトのみレンダリングしたものです。
元画像に加算する予定なので暗くしています。

参考サイト様
【Unityシェーダ入門】フォン鏡面反射で金属っぽくしてみる

結果は加工してます。
ハイライトを 0.5 で間引いて 0.2 で描画

Pass
{
	Blend OneMinusDstColor One // スクリーン / 比較(明)
	CGPROGRAM

	#pragma vertex vert
	#pragma fragment frag

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

	struct v2f
	{
		float4 vertex : SV_POSITION;
		float4 vertexW : TEXCOORD0;
		float3 normal : TEXCOORD1;
	};

	v2f vert( appdata v)
	{
		v2f o = (v2f)0;
		o.vertex = UnityObjectToClipPos( v.vertex);
		o.vertexW = mul( unity_ObjectToWorld, v.vertex);
		o.normal = UnityObjectToWorldNormal( v.normal);

		return o;
	}

	float _Spec1Power;

	half4 frag( v2f i) : SV_Target
	{
		// ハイライト計算
		float3 L = normalize( _WorldSpaceLightPos0.xyz);
		float3 V = normalize( _WorldSpaceCameraPos - i.vertexW.xyz);
		float3 N = i.normal;
		float specular = pow( max( 0.0, dot( reflect(-L, N), V)), _Spec1Power);
		if( specular < 0.5)
		{
			discard; // 中止
		}

		return half4( 0.2, 0.2, 0.2, 1);
	}

	ENDCG
}//Pass

この部分で視線、法線、ライトの向きを利用してスペキュラーを求めてます。
反射ベクトルは HLSL にメソッド reflect があります。便利ですねー

float specular = pow( max( 0.0, dot( reflect(-L, N), V)), _Spec1Power);

これを合成した結果がこちらです。

顔や体にもハイカラーが描画されています。
このままでは不味いのでハイカラーを表示するマスク画像を作成します。

ユニティちゃんの髪の部分だけ白くしたテクスチャ

このテクスチャを参照しつつ白い部分だけハイライトを合成していきます。
描画結果がこうなります。髪の毛にのみハイライトがあたってます。
もっと綺麗にマスクすれば、ハイライトの天使の輪にできそうです。

髪だけハイライトが落ちました。ライトの位置関係で綾波っぽくないですがとりあえずこれで。

Pass
{
	Blend OneMinusDstColor One // スクリーン / 比較(明)

	CGPROGRAM

	#pragma vertex vert
	#pragma fragment frag

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

	struct v2f
	{
		float4 vertex : SV_POSITION;
		float4 vertexW : TEXCOORD0;
		float3 normal : TEXCOORD1;
		float2 uv : TEXCOORD2;
	};

	float4 _HighlightMask_ST;

	v2f vert( appdata v)
	{
		v2f o = (v2f)0;
		o.vertex = UnityObjectToClipPos( v.vertex);
		o.vertexW = mul( unity_ObjectToWorld, v.vertex);
		o.normal = UnityObjectToWorldNormal( v.normal);
		o.uv = float2( v.uv * _HighlightMask_ST.xy + _HighlightMask_ST.zw);

		return o;
	}

	float _Spec1Power;
	sampler2D _HighlightMask;

	half4 frag( v2f i) : SV_Target
	{
		// ハイライトマップを見て描画の有無を判定する
		half4 cutoff = tex2D( _HighlightMask, i.uv);
		if( cutoff.r < 0.5)
		{
			discard; // 中止
		}

		// ハイライト計算
		float3 L = normalize( _WorldSpaceLightPos0.xyz);
		float3 V = normalize( _WorldSpaceCameraPos - i.vertexW.xyz);
		float3 N = i.normal;
		float specular = pow( max( 0.0, dot( reflect(-L, N), V)), _Spec1Power);
		if( specular < 0.5)
		{
			discard; // 中止
		}

		return half4( 0.2, 0.2, 0.2, 1);
	}

	ENDCG
}//Pass

この部分でマスクを参照し、黒なら描画を中断しています。

half4 cutoff = tex2D( _HighlightMask, i.uv);
if( cutoff.r < 0.5)
{
	discard; // 中止
}

ちょっと微調整

顔の影が濃すぎてちょっと怖いので、顔のトゥーンマップを別途用意して加工します。

顔専用のトゥーンマップ

ほとんど白く、影がうっすら暗いマップです。
部位ごとにマテリアルを調整できるのも便利ですね。
あと少しライトを移動し、影の確認のため床を追加とポーズを変更しました。

セルフシャドウを追加する

何も考えずに 標準の SHADOW_ATTENUATION マクロの結果を乗算してみます。

当たり前ですが、すっごい濃いです。
次は影もトゥーン化していきます。

トゥーンマップを参照しつつ影を描画する

影のマクロは AutoLight.cginc に定義されているのでインクルードします。

キャスト部分とレシーブ部分を書きます。キャストは特にいじらないでそのまま流します。
レシーブ部分で、影の濃さからトゥーンマップを参照し、アルベドを変更していきます。ほとんど先に書いたトゥーンのコードと同じです。

// 影キャスト
Pass
{
	Name "ShadowCast"
	Tags {"LightMode" = "ShadowCaster"}

	CGPROGRAM

	#pragma vertex vert
	#pragma fragment frag
	#pragma multi_compile_shadowcaster

	struct v2f
	{
		V2F_SHADOW_CASTER;
	};

	v2f vert( appdata_base v)
	{
		v2f o;
		TRANSFER_SHADOW_CASTER_NORMALOFFSET( o)
		return o;
	}

	float4 frag( v2f i) : SV_Target
	{
		SHADOW_CASTER_FRAGMENT( i)
	}

	ENDCG
}//Pass

// 影レシーブ
// 影の濃さからトゥーンマップを参照し、アルベドを上書きする
Pass
{
	Name "ShadowReceive"
	Tags {"LightMode" = "ForwardBase"}

	CGPROGRAM
	#pragma vertex vert
	#pragma fragment frag
	#pragma multi_compile_fwdbase
	#include "AutoLight.cginc"

	struct v2f
	{
		float2 uv : TEXCOORD0;
		SHADOW_COORDS(1) // put shadows data into TEXCOORD1
		float4 pos : SV_POSITION;
	};

	sampler2D _ToonMap;
	sampler2D _MainTex;
	float4 _MainTex_ST;

	v2f vert( appdata_base v)
	{
		v2f o;
		o.pos = UnityObjectToClipPos( v.vertex);
		o.uv = TRANSFORM_TEX( v.texcoord, _MainTex);
		// compute shadows data
		TRANSFER_SHADOW(o)
		return o;
	}

	fixed4 frag(v2f i) : SV_Target
	{
		// compute shadow attenuation (1.0 = fully lit, 0.0 = fully shadowed)
		fixed shadow = SHADOW_ATTENUATION( i);
		// 濃いめの影だけ描画する
		if( shadow > 0.25)
		{
			discard; // 中止
		}
		float toon = 1-tex2D( _ToonMap, half2( shadow, 0));

		float3 sample = tex2D( _MainTex, i.uv).rgb;
		// hsvに変換し彩度・明度を調整する
		float3 hsv = rgb2hsv( sample);
		hsv.y = min( hsv.y + toon, 1.0); // 彩度変更
		hsv.z = max( hsv.z - toon, 0.0); // 明度変更
		float3 color = hsv2rgb( hsv);
		return float4( color, 1);
	}
	ENDCG
}//Pass

結果こうなります。

脇腹付近の真っ黒の部分がちゃんとトゥーンを反映した影になりました。

レシーブ部分の3箇所

SHADOW_COORDS(1) // put shadows data into TEXCOORD1
TRANSFER_SHADOW(o)
fixed shadow = SHADOW_ATTENUATION( i);

が影計算をよろしくやってくれる部分で、その結果をトゥーンとし、アルベドの彩度・明度を変更しています。

影は以上です。

リムライトを入れる

リムライトを入れようと思ったのですが、セル画を検索しても入っているものはあまりなかったでした。
最近のゲームなどのセルシェーディングでは見かけるのですが、入れないほうがアニメっぽいのかな?

参考
Shield shader

とりあえず入れてみます。

光源無視の背後から光があたっている感じになり、輪郭付近が明るくなります。
色指定はせず、グレーを加算で描画しています。

Pass
{
	Blend One One // 加算

	CGPROGRAM

	#pragma vertex vert
	#pragma fragment frag

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

	struct v2f
	{
		float4 pos : SV_POSITION;
		float4 vertexW : TEXCOORD0;
		float3 normal : TEXCOORD1;
	};

	v2f vert( appdata v)
	{
		v2f o = (v2f)0;
		o.pos = UnityObjectToClipPos( v.vertex);
		o.vertexW = mul( unity_ObjectToWorld, v.vertex);
		o.normal = UnityObjectToWorldNormal( v.normal);

		return o;
	}

	float _RimPower;

	half4 frag( v2f i) : SV_Target
	{
		float3 V = normalize( _WorldSpaceCameraPos - i.vertexW.xyz);
		float rim = 1 - saturate( dot( i.normal, normalize( V)));
		rim = pow( rim, 2);
		if( rim <= 1-_RimPower)
		{
			discard; // 中止
		}

		return float4( 0.5, 0.5, 0.5, 1);
	}

	ENDCG
}//Pass

リボン部分が表示エラーしているのでこちらもマスクを用意すれば出す部分を限定できるかと思います。今回はこのまま行きます。

アウトラインを入れる

アウトラインを入れて一度完成です。
エッジの検出方法は3種類を合成しています。
目も暗いので調整していきます。
ハイライトも少しギドいのでマスクを調整していきます。
詳細は次回につづく…

アウトライン編書きました。↓


その他参考サイト

条件分岐のためにstep関数を使う時の考え方をまとめてみた

Outline Shader

3 Comments

Add a Comment

メールアドレスが公開されることはありません。 が付いている欄は必須項目です