読者です 読者をやめる 読者になる 読者になる

Unityでドット絵シェーダ

ユニティちゃんライセンス

このコンテンツは、『ユニティちゃんライセンス』で提供されています

Blenderでドット絵シェーダを作っている方がいましたので、Unityで実装方法考えてみました。
ドット絵シェーダー v1.0 - 3Dモデルをドット絵風に描画するシェーダーが公開!試行錯誤した流れが分かる制作日誌にも注目!

自分でドット絵は描いたことがない(加えて2Dスプライトを使ったゲームも作ったことはない)ので、間違ってるところがあるかもしれません。

ドット絵について

 実装するにあたって、ドット絵がどうやって作られるか考えてみます。
ドット絵の元(ドット絵になる前の絵)は、"高解像度の絵"です。
(この"高解像度"の絵はドッターの脳内にあるかもしれませんし、CGでレンダリングしたものかもしれません)
 
なるべく情報量を保ったまま解像度を落とす、ことを考えます。


1.色選択アルゴリズム
 高解像度の絵からドット絵に解像度を落とすとき、1ドットあたりに複数のピクセルが含まれることになります。例えば4x4ピクセルを1ドットとするなら、16ピクセルが含まれることになります。
 この16ピクセルから1つの色を選択、あるいは合成するアルゴリズムが必要になります。

2.有効ピクセル選択アルゴリズム
 通常ドット絵がレンダリングされる際、当然ながら1ドットに一つの色しか含まれません。
"背景"の上に"キャラクター"がレンダリングされる場合、背景の色とキャラクターの色が1つのドット内で混ざることは無いのです。
 しかし高解像度の絵からドット絵の解像度に落とす際、1ドットに複数のオブジェクトの色が入り込む場合があります。
 これを避けるには、まずそのドットを占めるオブジェクト(マテリアル)を決めてから、有効なピクセルを抜き出す処理が必要になります。

有効ピクセル選択アルゴリズム

 現在は、16ピクセルのうち最もカメラに近いもの(深度が浅いもの)を、そのドットが占めるマテリアルとします。
 マテリアルIDもレンダリングしていれば、それと同じIDをもつピクセルを有効ピクセルと見なせます。マテリアルIDが無い場合は、深度値が近いものをおなじマテリアルのピクセルとみなします(今回はこれ)。

色選択アルゴリズム

 ここはアーティスティックな部分で、色々な方法があると思います。とりあえず思いついたのは…

1.ピクセルを平均する。
 ただし、輝度が0か1に近いものを優先するように重み付けする。
(メリハリをつけるため)

2.最大輝度と最小輝度の差が一定値を超えたら(つまりエッジ部分)平均せずに代表色を用いる。
(エッジ部分をはっきりさせるため)

の二つのアルゴリズムを使っています。

実装例

以下ソースコードの抜粋です。
この例では3x3ピクセルのドットを生成しています。
有効ピクセルの深度差、重み付け、エッジ強調時の輝度差はパラメータ化出来ると思います。


DotEffect.cs(イメージエフェクトのスクリプト

[ImageEffectOpaque]
void OnRenderImage (RenderTexture source, RenderTexture destination)
{
    CreateMaterials ();

    // RTは確実にポイントでフェッチするため
    // RT2は実際にドット絵をレンダリングするため
    // どちらもフィルタモードをポイントにして、補間を無効にする
    var RT = RenderTexture.GetTemporary(Screen.width, Screen.height);
    var RT2 = RenderTexture.GetTemporary(Screen.width /3, Screen.height / 3);
    RT.filterMode = FilterMode.Point;
    RT2.filterMode = FilterMode.Point;

    // 高解像度(参照テクスチャ)のピクセルサイズを渡す必要あり
    m_dotMaterial.SetVector("_PixelSize", new Vector4(1.0f / Screen.width, 1.0f / Screen.height, 0, 0));

    // 参照テクスチャ作成
    Graphics.Blit(source, RT);
    // ドット絵変換
    Graphics.Blit(RT, RT2, m_dotMaterial, 0);


    Graphics.Blit(RT2, destination);

    RenderTexture.ReleaseTemporary(RT);
    RenderTexture.ReleaseTemporary(RT2);
}

dotShader.shader

sampler2D _MainTex;
sampler2D _CameraDepthNormalsTexture;
float4 _PixelSize;
sampler2D _Noise;

half4 frag(v2f_ao i) : SV_Target
{
	float4 centerCol = tex2D(_MainTex, i.uv);

	float4 nearCol = 0; // もっとも手前のピクセル
	i.uv -= 1.5 * _PixelSize.xy; // i.uvは中心と考えられるため、半分ずらす。いらないかも

	// minDepthの検出
	float minDepth = 1;
	for (int x = 0; x <3; x++)
	{
		for (int y = 0; y <3; y++)
		{
			float2 uv2 = i.uv + _PixelSize.xy * float2(x, y);
			float4 c = tex2D(_MainTex, uv2);
			float4 depthnormal = tex2D(_CameraDepthNormalsTexture, uv2);
			float3 viewNorm;
			float depth;
			DecodeDepthNormal(depthnormal, depth, viewNorm);

			if (minDepth > depth)
			{
				minDepth = depth;
				nearCol = c;
			}
						
		}
	}

	float4 col = 0;
	float count = 0;
	float minLum = 1;
	float maxLum = 0;

	for (int x = 0; x <3; x++)
	{
		for (int y = 0; y <3; y++)
		{
			float2 uv2 = i.uv + _PixelSize.xy * float2(x, y);
			float4 c = tex2D(_MainTex, uv2);
			float4 depthnormal = tex2D(_CameraDepthNormalsTexture, uv2);
			float3 viewNorm;
			float depth;
			DecodeDepthNormal(depthnormal, depth, viewNorm);
						
			// 有効ピクセルのみ処理する
			if (abs(depth - minDepth) < 0.01)
			{
				float Lum = Luminance(c.rgb);
			        float alpha =  pow(Lum - 0.5, 2) * 4;	// 白と黒を強調する(重み付け)
				col += c * alpha;
				count += alpha;

				minLum = min(minLum, Lum);
				maxLum = max(maxLum, Lum);
			}
		}
	}
	col /= count;

        // エッジ強調
	if (maxLum - minLum > 0.5)
	{
		col = nearCol;
	}

	return col;
}

結果

 Unityちゃんで試してみます。

処理前のもの
f:id:IARIKE:20150211133044j:plain

パースカメラ(vFov60)
f:id:IARIKE:20150211133059j:plain
f:id:IARIKE:20150211133106j:plain
f:id:IARIKE:20150211133114j:plain
f:id:IARIKE:20150211133119j:plain
f:id:IARIKE:20150211133125j:plain

オルソカメラ
f:id:IARIKE:20150211133132j:plain
f:id:IARIKE:20150211133136j:plain
f:id:IARIKE:20150211133142j:plain

やはり正投影のほうがそれっぽく見えますね。
右(Candy Rock Star)の方は顔のテクスチャと目のテクスチャを少し弄って、黒目やハイライト、鼻と口が見えやすいようにしています。
左の普通のUnityちゃんと比べるとわかるかと。

 輪郭抽出には標準のイメージエフェクトの線色だけ変えたもの(設定はRobertsのDepthが1、Normal0)を使ってます。
またUnityちゃん自体のシェーダが出す輪郭線もパラメータ弄って太くしています。

さて…ここまで書いておいてアレですが、最初に紹介した記事の制作日記読むと同じようなアルゴリズム書いてあるんですね…リアルタイム化したってことでどうかお許しを。

 今回書いたImageEffectシェーダは深度付き高解像度の絵をドット絵化するだけのものであり、ライティングとはまた別の話です。
なので、そこら辺トゥーンレンダリングしたり、色々工夫の余地はあると思います。
が、Unityちゃんシェーダは弄りづらい(弄ってもこれ以上良くならない)ので、Unityちゃんに限れば標準でいいかなって気がします。