このノートでは日本風のトゥーンレンダリング(いわゆるアニメ調レンダリング)の Shader を扱います。ここで扱う Shader はすべて URP 上で動かします。
アニメ調レンダリングは大きく二つに分かれます。輪郭線を描くアウトラインと、シェーディングです。この二つは独立しているので、別々にデバッグできます。
アウトライン
基本原理は、各頂点を法線方向に押し出し、裏面だけを残すというものです。一般に反転ハル(inverted hull)と呼ばれます。手順はおおよそ次のとおりです。
法線を押し出す → 表面をカリング → 描画順を調整 → 視距離を補正 → 途切れを修復 → 太さを制御
法線の押し出しと表面カリング
アウトラインは追加の Pass で描きます。この Pass では各頂点を法線方向に少し押し出し、Cull Front で裏面だけを残します。シェルがモデルより大きくなっているため、裏面がシルエットの外側にはみ出し、その帯が輪郭線になります。
HLSLPass
{
Name "Outline"
Tags { "LightMode" = "SRPDefaultUnlit" }
Cull Front // 裏面だけを残す。表面はメイン Pass の担当
ZWrite On
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
CBUFFER_START(UnityPerMaterial)
float4 _OutlineColor;
float _OutlineWidth;
CBUFFER_END
struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float4 color : COLOR; };
struct Varyings { float4 positionCS : SV_POSITION; };
Varyings vert(Attributes IN)
{
Varyings OUT;
float3 posWS = TransformObjectToWorld(IN.positionOS.xyz);
float3 normalWS = TransformObjectToWorldNormal(IN.normalOS);
posWS += normalWS * _OutlineWidth * 0.01 * IN.color.a;
OUT.positionCS = TransformWorldToHClip(posWS);
return OUT;
}
half4 frag(Varyings IN) : SV_Target { return _OutlineColor; }
ENDHLSL
}
LightMode タグが SRPDefaultUnlit である点に注意してください。URP は認識できない LightMode の Pass を破棄するため、この行は省略できません。
描画順の調整
アウトライン Pass は深度を書き込みますが、ライティングは一切不要です。したがって早く描き終わるほど有利です。メイン Pass の前に置けば、覆われたピクセルは early-Z で破棄され、高価なシェーディングまで届きません。逆にメイン Pass を先に描くと、直後に上書きされるピクセルのシェーディング代を払うことになります。
視距離の補正
ワールド空間で押し出すと、モデルが遠ざかるほど画面上の線は細くなり、やがて消えます。スクリーン空間で一定の太さにするには、押し出し量を距離で補正する必要があります。もっとも簡単なのはクリップ空間で押し出し、positionCS.w を掛ける方法です。
HLSLfloat3 normalCS = TransformWorldToHClipDir(normalWS);
OUT.positionCS = TransformWorldToHClip(posWS);
OUT.positionCS.xy += normalCS.xy * _OutlineWidth * 0.01 * OUT.positionCS.w;
実務では折衷案を取ることが多いです。距離に応じてスケールしつつ、最小幅と最大幅でクランプする。そうしないとクローズアップでタイヤのような太さになります。
途切れの修復
この手法でもっとも典型的な破綻です。ハードエッジ(UV シームや法線が分割されている箇所)では、同じ位置に法線の異なる頂点が複数あります。押し出すとそれらが離れ、輪郭線が裂けます。
対処は Shader ではなくデータ側です。平滑化した法線を頂点カラーか予備の UV チャンネルにベイクし、アウトライン Pass ではその平滑法線で押し出し、メイン Pass は元の法線でシェーディングを続けます。ジオメトリのハードエッジを保ったまま、アウトラインのシェルだけを連続にできます。
HLSL// 平滑法線を uv2 にベイク(接空間に圧縮した xy)
float3 smoothNormalOS = DecodeSmoothNormal(IN.uv2);
float3 normalWS = TransformObjectToWorldNormal(smoothNormalOS);
太さの制御
キャラクターの輪郭線は一定の太さであるべきではありません。顔は胴体よりずっと細い線を必要としますし、指のような小さな構造も個別に絞る必要があります。太さ係数を頂点カラーの alpha チャンネルに塗っておけば、アーティストが DCC ツール上で直接その変化をブラシで描けます。Shader 側は掛けるだけです(上のコードの IN.color.a)。
シェーディング
アニメ調シェーディングの核心は、連続的なライティングの階調を捨て、少数のバンドに量子化することです。
Lambert からバンドへ
half-Lambert を計算して NdotL を [0, 1] に再マップし、その結果を ramp テクスチャで引くか、smoothstep で閾値処理します。
HLSLLight mainLight = GetMainLight(shadowCoord);
half NdotL = dot(normalWS, mainLight.direction);
half halfLambert = NdotL * 0.5 + 0.5;
// 方式 A: ramp テクスチャ。バンドの作り方は完全にアーティストの管轄
half3 ramp = SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, float2(halfLambert, 0.5)).rgb;
// 方式 B: 解析的な閾値。硬さを調整できる
half shadowMask = smoothstep(_Threshold - _Softness, _Threshold + _Softness, halfLambert);
half3 shaded = lerp(_ShadowColor.rgb * baseColor, baseColor, shadowMask);
方式 A は柔軟で、テクスチャを差し替えるだけで見た目が変わります。方式 B はパラメータが少なくアニメーションさせやすい。実際のプロジェクトでは両方を混ぜ、ramp で全体の関係を決め、閾値で動的に調整することが多いです。
見落としやすい点がひとつ。影色をベースカラーにグレーを掛けて作ってはいけません。 日本のイラストでは影側は紫や青に寄り、彩度はむしろ上がることが多い。影色を独立したパラメータとして露出するだけで、結果は目に見えて良くなります。
スペキュラとリムライト
アニメ調のスペキュラも同様にハードエッジです。Blinn-Phong の NdotH を求め、step で切ります。
HLSLhalf3 halfDir = normalize(mainLight.direction + viewDirWS);
half NdotH = saturate(dot(normalWS, halfDir));
half spec = step(1 - _SpecSize, pow(NdotH, _SpecPower));
color += spec * _SpecColor.rgb * _SpecIntensity;
リムライトは単純なフレネル項で構いません。Unity Shader #5 リムライトとまったく同じ作り方です。ただしここでは NdotL も掛けるのが普通です。そうしないと逆光側まで光ってしまい、絵が壊れます。
顔について
この手法がもっとも確実に破綻するのが顔です。鼻梁と眼窩まわりの法線が顔面に細かい暗部を散らし、ramp をどう調整しても意図した絵には見えません。業界標準の答えは、顔専用の SDF ライティングテクスチャです。あらゆる水平角度から光が来たときに明暗境界がどこに来るかを記録したテクスチャを用意し、実際の法線ではなく、頭部の水平面に投影したライト方向でそれを引きます。これで顔のターミネータは常に一本のきれいな曲線になります。
これは単独の記事に値する話題なので、ここでは印だけ残しておきます。
まとめ
アニメ調レンダリングはスタイルの問題に見えますが、実際に作ってみると作業量の大半はデータとエッジケースにあることがわかります。平滑法線のベイク、輪郭線の太さの描き込み、顔の特別扱い。Shader そのものは、むしろいちばん簡単な部分でした。