はじめに
UnityのURPで、色鉛筆で描いた絵本がそのまま動いているような見た目を目指したキャラクターシェーダーの実装を解説します。
きっかけは『葬送のフリーレン』第2期のノンクレジットエンディングを観て、途中「これって3DCGだよな」と思い、めちゃくちゃいい出来だなと思いながら、自分でUnityを使って再現してみました。
コードは考え方を説明するために必要な部分だけ、簡略化して載せています。発想の参考として読んでもらえると嬉しいです。係数やパラメータの値は載せていません。また、背景の処理は別の仕組みで、本記事では扱いません。
環境は Unity 6000.6.5f1、URP 17.6、カラースペースはLinearです。
手本の特徴と実装方針
| 手本の特徴 | シェーダーの方針 |
|---|---|
| 絵は1秒に12枚。2コマ打ちのアニメーション | 時間を12fpsで刻み、1枚ごとに乱数を変える |
| 1枚ごとに絵全体が上下左右に跳ぶ | クリップ座標をランダムにずらす |
| 動いて見える線は、紙の白ではなく元の色の濃淡 | 線の色をテクスチャの色から作る |
| 白は白のまま、暗い色は同じ色味のまま深くなる | べき乗で色を作る |
| 影は灰色ではなく、青紫の鉛筆を重ねた色 | 影は黒ではなく寒色を乗算する |
基本の部品
ハッシュ
シェーダーには標準の乱数関数がないので、座標から擬似乱数を作ります。sinを使わないハッシュ(Dave HoskinsのHash without Sine)を、-1〜1 の範囲に変換して使っています。
float2 Hash22(float2 p)
{
float3 p3 = frac(p.xyx * float3(0.1031, 0.1030, 0.0973));
p3 += dot(p3, p3.yzx + 33.33);
return frac((p3.xx + p3.yz) * p3.zy) * 2.0 - 1.0;
}
グラデーションノイズ
バリューノイズは格子の形が残りやすく、漫画のスクリーントーンのようなマス目が出ます。そこで、格子点に勾配を持たせるグラデーションノイズを使います。補間には5次のフェード関数を使い、格子点で2階微分まで連続にしています。
float Noise(float2 p)
{
float2 i = floor(p);
float2 f = frac(p);
float2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
float a = dot(Hash22(i), f);
float b = dot(Hash22(i + float2(1, 0)), f - float2(1, 0));
float c = dot(Hash22(i + float2(0, 1)), f - float2(0, 1));
float d = dot(Hash22(i + float2(1, 1)), f - float2(1, 1));
return lerp(lerp(a, b, u.x), lerp(c, d, u.x), u.y);
}
回転
線の向きを変えるには、座標を2次元回転します。sincos はsinとcosをまとめて求める HLSL の組み込み関数です。
float2 Rotate(float2 p, float angle)
{
float s, c;
sincos(angle, s, c);
return float2(c * p.x - s * p.y, s * p.x + c * p.y);
}
解像度に依存しない座標
線の太さなどのサイズは、すべて「高さ 1080 の画面での px」で指定しています。スクリーン UV をこの座標に変換すれば、解像度が変わっても絵の見た目は変わりません。
float2 ReferencePixels(float4 screenPos)
{
float2 uv = screenPos.xy / max(screenPos.w, 1e-5); // 透視除算
return uv * float2(_ScreenParams.x / _ScreenParams.y, 1.0) * 1080.0;
}
ライティング
ライティングの結果は色に直接掛けず、shade(0 が明るい、1 が暗い)という値にまとめます。この値を、線の筆圧や影の鉛筆の量に使います。
Light light = GetMainLight(TransformWorldToShadowCoord(positionWS));
float3 viewDirWS = GetWorldSpaceNormalizeViewDir(positionWS);
// ラップライティング。_LightWrap = 1 のとき Half Lambert と同じ形になる
float ndotl = saturate((dot(normalWS, light.direction) + _LightWrap) / (1.0 + _LightWrap));
float lightValue = saturate(_Ambient + ndotl * light.shadowAttenuation * _LightIntensity);
// フレネル項に近い形で、輪郭付近を少し暗くする
float silhouette = pow(1.0 - saturate(dot(normalWS, viewDirWS)), _SilhouettePower);
float shade = saturate(1.0 - lightValue + silhouette * _SilhouetteShade);
裏面も描くマテリアルでは、法線を反転しないと内側が正しく照らされません。
half4 Fragment(Varyings input, FRONT_FACE_TYPE frontFace : FRONT_FACE_SEMANTIC) : SV_Target
{
float3 normalWS = normalize(input.normalWS) * IS_FRONT_VFACE(frontFace, 1.0, -1.0);
// ...
}
線
線の基本は、引き伸ばしたノイズです。座標を回転して線の向きを x 軸にそろえてから、x と y で極端に違うスケールのノイズを重ねます。
// 簡略化したもの
float StrokeLayer(float2 px, float angle)
{
float2 r = Rotate(px, -angle); // 線の向きを x 軸にそろえる
float wide = Noise(float2(r.x / (len * a), r.y / (width * b)));
float main = Noise(float2(r.x / len, r.y / width));
float fine = Noise(float2(r.x / (len * c), r.y / (width * d)));
return saturate(0.5 + wide * w0 + main * w1 + fine * w2);
}
len = width * _StrokeLength なので、_StrokeLength は線の縦横比になります。
このノイズを smoothstep でしきい値処理して、濃い線の量を決めます。筆圧が高いほどしきい値が下がり、濃い線が増えます。
float pressure = lerp(_LightPressure, _ShadowPressure, shade);
float threshold = 1.0 - saturate(pressure);
float coverage = smoothstep(threshold - _StrokeSoftness, threshold + _StrokeSoftness, stroke + grain * k);
濃い部分だけを描いて残りを空けると、広い平面で隙間が一列に並び、雨のように見えます。そのため隙間は作らず、濃い線と淡い線の2色で全面を塗っています。
紙の目
鉛筆の芯は紙の凸部にしか乗らないので、線は細かい粒に割れます。これを、線の方向に少し伸ばしたノイズで作ります。ノイズの入力座標を別のノイズでずらす(ドメインワープ)と、粒の輪郭が丸くならず、ギザギザになります。
// 簡略化したもの
float PaperTooth(float2 px, float angle)
{
float2 r = Rotate(px, -angle);
float2 q = float2(r.x / (size * stretch), r.y / size);
float2 warp = Noise2(q * a + seed0) * b; // ドメインワープ
float n = Noise(q + warp) + Noise(q * c + warp + seed1) * d;
return smoothstep(lo, hi, 0.5 + n); // 1:凸部、0:谷
}
Noise2 は、入力をずらして Noise を2回呼び、2次元の値を返す関数です。この結果が 0 に近いところは、紙の色に置き換えます。
color = lerp(color, _PaperColor.rgb, saturate(paper));
色
べき乗で2本の色鉛筆を作る
手本の線は、紙の白が透けたものではなく、元の色の濃い版と淡い版が重なったものでした。そこで、1つの色から濃淡の2色を作ります。
// 簡略化したもの
float3 StrokeDeep(float3 color)
{
float3 p = max(color, 1e-4); // pow(0, x) を避ける
return pow(p, DeepPower(PencilLuminance(p))); // 指数は 1 より大きい
}
float3 StrokeLight(float3 color)
{
float3 p = max(color, 1e-4);
return pow(p, LightPower(PencilLuminance(p))); // 指数は 1 より小さい
}
float3 result = lerp(StrokeLight(color), StrokeDeep(color), coverage);
べき乗には次の性質があります。
- 白は白のまま残る。 1 は何乗しても 1 なので、ハイライトが濁りません。
- 暗くすると色が濃くなる。 RGB の比が広がるので、灰色に沈みません。たとえば (0.8, 0.4) を2乗すると (0.64, 0.16) になり、比は 2 倍から 4 倍になります。
指数を固定すると、暗いところが「真っ黒の上に明るい筋」になるので、輝度に応じて指数を変えています。
輝度は Rec.709 の係数で求めます。
float PencilLuminance(float3 color)
{
return dot(color, float3(0.2126, 0.7152, 0.0722));
}
色の計算は、テクスチャを読んだままのリニアの色で行っています。同じ指数でも、sRGB の値に掛ける場合とは効き方が変わります。
彩度
暗いところほど彩度を上げます。輝度のグレーと元の色を線形補間し、1 を超える係数で外挿すると彩度が上がります。
float3 PencilSaturate(float3 color, float amount)
{
return saturate(lerp(PencilLuminance(color).xxx, color, amount));
}
float3 pigment = PencilSaturate(baseColor, 1.0 + shade * _ShadowSaturation);
影
影は暗くするのではなく、青紫の鉛筆を乗算で重ねます。2層目の線を、1層目とは別のシードで作って使います。
// 簡略化したもの
float3 shadowPencil = lerp(float3(1, 1, 1), _ShadowTint.rgb, _ShadowTintStrength);
color = lerp(color, color * shadowPencil, shadowDeposit);
shadowDeposit は、2層目の線を shade と受けた影の強さ(1.0 - light.shadowAttenuation)でしきい値処理した値です。
輪郭線
Cull Front のパスで、頂点を法線方向に膨らませて描く背面法です。線の太さをノイズと陰影で変えています。
// 頂点シェーダー(簡略化したもの)
float3 p = input.positionOS.xyz * scale;
float2 seed = Hash22(float2(frame, s)) * k * _BoilContour;
float pressure = saturate(0.5 + Noise(p.xy + p.z * q + seed));
// 光が当たる側を細くする
float lightThinning = lerp(1.0 - _ContourLitThinning, 1.0, shade);
float width = _OutlineWidth * lerp(thin, thick, pressure) * lightThinning;
float3 expanded = input.positionOS.xyz + normalize(input.normalOS) * width;
output.positionCS = BoilClipPosition(TransformObjectToHClip(expanded));
フラグメントシェーダーでは、ノイズを clip にかけて線を途切れさせます。線の色は黒ではなく、テクスチャの色を暗くして _ShadowTint を少し混ぜたものです。
float lift = saturate(0.5 + Noise((px + boil) / scale) * k);
clip(lift - _ContourBreakup);
動き:1秒に12枚、描き直す
時間を floor で刻むと、値が1秒に12回だけ変わります。
float frame = floor(_Time.y * _BoilFPS); // 12 なら1秒12枚
あとは frame をハッシュに入れて、次のものを1枚ごとに変えています。
- 線の位置と、わずかな角度
- 紙の目
- シルエット
- 色と線のズレ
シルエットをずらす
クリップ座標を、画面上の px 単位でずらします。w を掛けているのは、透視除算の後で指定した距離だけ動かすためです。
float4 BoilClipPosition(float4 positionCS)
{
float2 toPx = 0.5 * 1080.0 * float2(aspect, 1.0);
float2 px = positionCS.xy / positionCS.w * toPx;
float2 offsetPx = BoilShift(frame) + BoilField(px, frame) * _BoilSilhouette;
positionCS.xy += offsetPx / toPx * positionCS.w;
return positionCS;
}
BoilShift は全体のズレ、BoilField は場所ごとに変わる滑らかなズレです。
この処理は、色や深度を書くすべてのパスに入れます。一部のパスだけずらすと、深度が一致しません。
紙は画面に、線はキャラクターに
線を単純にスクリーン座標で作ると、キャラクターが動いたときに模様の上を滑っているように見えます。そこで、オブジェクトの原点を基準にした座標を別に作ります。
float4 anchorPos = ComputeScreenPos(TransformObjectToHClip(float3(0, 0, 0))); // 頂点シェーダー
float2 paperPx = ReferencePixels(screenPos); // 紙の目:画面に固定
float2 drawPx = paperPx - ReferencePixels(anchorPos); // 線:キャラクターに追従
紙の目は、位置の基準を画面に置いたまま、乱数のオフセットだけを毎コマ変えています。
float2 toothPx = paperPx + Hash22(float2(frame, s)) * size * _BoilPaper; // _BoilPaper = 0 なら同じ紙のまま
色をはみ出させる
手描きの塗りは線とぴったり合わないので、テクスチャを読む UV も毎コマずらします。UV の密度はモデルの部位ごとに違うので、画面微分を使って「画面の px」から UV の量に換算します。
float2 uvDdx = ddx(uv);
float2 uvDdy = ddy(uv);
float2 uvPerPxX = uvDdx / ddx(paperPx.x);
float2 uvPerPxY = uvDdy / ddy(paperPx.y);
float2 colorUV = uv + uvPerPxX * offset.x + uvPerPxY * offset.y;
// ずらす前の UV の微分を渡して、ミップレベルの飛びを防ぐ
float3 baseColor = SAMPLE_TEXTURE2D_GRAD(_BaseMap, sampler_BaseMap, colorUV, uvDdx, uvDdy).rgb;
オフセットが急に変わる場所では、colorUV の微分が大きくなります。普通にサンプリングすると、そこだけ低いミップが選ばれてぼけるので、SAMPLE_TEXTURE2D_GRAD で微分を明示しています。
まとめ
私自身もまだまだ動画の見た目の完全再現することは難しいと考えており、このシェーダーも調整中なのですが、色鉛筆、絵本風のシェーダーを作っている方の参考になれば幸いです。

