【Unity】ハーフトーンシェーダーでコミック風の表現(URP)
ハーフトーンとは
水玉模様(ドット)使って陰影を表現する手法です。
色の使えない雑誌や漫画で色の違いや陰影を表現するために使われることが多く、漫画を描く人やよく読む人にとっては「スクリーントーン」という単語の方がしっくりくるかもしれません。
3Dのグラフィックスでは紙面と違って色に制限はないので繊細なグラデーションで陰影をつけることができますが、あえてハーフトーンによって陰影をつけることでコミカルな雰囲気を作ったり、印象的なビジュアルを作ったりすることができます。
スパイダーバースはまさに「アメコミ」のようなコンセプトで、3D映画ながら往年のアメコミ雑誌のような印象を与える効果がたくさん盛り込まれています。
基本的な陰影はグラデーションで表現されていますが、リムライト(逆光)の境界のグラデーションにハーフトーンが使われています。このように、エッセンス的にハーフトーン表現を使うのも面白いです。
今回作ったもの
こんな感じで、テクスチャの色を使いつつ影の境界がハーフトーンで表現されるようなものを作りました。
ハーフトーンシェーダーの考え方
- まずパラメータで指定したインターバルで、画面上を細かく区画分けします
- シェーダーで色を決める対象の各ピクセルが画面上のどの区画に属しているかを計算します
- 対象の区画の中央座標から、現在のピクセルがどのくらい離れているか計算します
- 距離がしきい値より大きいか小さいかで対象ピクセルを陰色で塗るかどうか判定します
すると画面内の各区画の中央から一定範囲が影色で塗られ、上図の左の球のようにモデルがドット柄になります。
この後、モデルの形を考慮してドットのサイズを変えて立体感を出していきますが、まずはここまでのシェーダーを書いていきましょう。
ドット柄をつくるシェーダー
今回、シェーダーはURPで書いていきます。
まずは単にモデルにテクスチャを貼り付けるだけのシェーダーを添付します。これに処理を追加していきましょう。
テクスチャだけのシェーダー
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の範囲で指定できるようにしています。
Properties
{
_MainTex ("Texture", 2D) = "white" {}
+ _HalftoneScale ("Halftone Scale", Range(0.001, 0.1)) = 0.02
}
CBUFFER_START(UnityPerMaterial)
float4 _MainTex_ST;
+ float _HalftoneScale;
CBUFFER_END
screenPos
②対象ピクセルの画面上の位置次に描画対象のピクセルが画面上のどの位置にあるかをComputeScreenPos
を使って計算し、変数screenPos
を介してフラグメントシェーダーに渡します。
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
+ float4 screenPos : TEXCOORD1;
};
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
によって仕切った区画のどこに所属するかと、その中心点との距離を計算して、一定距離未満なら色を塗ることでドット柄を表現します。
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;
}
これで、下図のようにモデルをトーン柄で塗ることができました。
ついでに色味の調整のために、トーン柄として乗算する値をパラメータで指定できるようにしておきましょう。
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)
}
CBUFFER_START(UnityPerMaterial)
float4 _MainTex_ST;
float _HalftoneScale;
+ float3 _ShadeColor;
CBUFFER_END
- // 対象ピクセルと区画の中心点の距離がしきい値より小さかったら色を暗くする(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
を計算してフラグメントシェーダーに渡します。
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;
};
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してフラグメントシェーダー内でメインライトの情報をローカル変数に入れておきます。
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
+ #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
+ // ライト情報を取得
+ Light light = GetMainLight();
// 対象ピクセルと区画の中心点の距離がしきい値より小さかったら色を塗る
float threshold = 0.3; // しきい値
col.rgb *= lerp(1, _ShadeColor, step(length(diff), threshold));
そして、拡散反射のシェーディングの要領でライトの向きベクトルと法線の内積をドットを作るしきい値に利用します。
// ライト情報を取得
Light light = GetMainLight();
+ // ピクセルの法線とライトの方向の内積を計算する
+ float threshold = 1 - dot(i.normal, light.direction);
// 対象ピクセルと区画の中心点の距離がしきい値より小さかったら色を塗る
- float threshold = 0.3; // しきい値
col.rgb *= lerp(1, _ShadeColor, step(length(diff), threshold));
これによって、ハーフトーンによるモデルの陰影が表現できました!
まとめ
今回はシンプルなハーフトーンの処理について解説しましたが、トゥーンシェーディングのようなしきい値の範囲設定でドットのグラデーションを微調整するようなこともできます。
影色の作り方も単純な乗算ではなく、いろいろな手法を使うことで発展した表現を考えるのも楽しそうです。
紙媒体やイラストの表現を3Dに落とし込むシェーディングは、ハーフトーン以外にも水彩風や墨絵風など、いろんなものがあるので今後もそういったシェーディングに挑戦したいです。
今回のシェーダー全文
HalfToneシェーダー
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