ここ最近、学園アイドルマスター(学マス)の鼻や眉毛のシェーダーがTwitter(現X)で話題になっていました。
学マス、鼻先の黒い点が横から見た時に消えるよう処理をしている
— のすけ (@noske2801) May 16, 2024
顔面全体の輪郭線を切ってるのもあって横顔で急な黒が浮いて悪目立ちしたり、鼻の頂点が欠けてシルエットの綺麗さが失われてしまうことに対する対処かな pic.twitter.com/82TiB41IDR
学マス、顔周りでいうと眉毛と目周りが正面からはステンシルして髪より前に見えているが回転させていくと横顔に近づくある程度の角度でフェードアウトしてく。キャラのデザインによっては横から見た時ステンシルしてる眉毛はチラつきが気になったりするのでとても真似したい pic.twitter.com/8O7WjcbF9w
— とれ (@clubnemos) May 16, 2024
鼻のアウトラインがカメラの角度で消える実装は容易に思いつくのですが(カメラのViewベクトルと頭のforwardベクトルの内積からディゾルブ等)、
眉毛が角度でフェードする処理(正面から見ると眉毛が前髪より手前に、横顔に近づくと眉毛がフェードアウトする処理)の実装はすぐには思いつきませんでした。
技術的にも面白そうなテーマだと思ったので、Unityで再現することにしました。
Unity URP上の再現
Stencilを使うパターンと、Stencilを使わずにDepth Offsetするパターンの2つをUnity URP上で実装しました。
プロジェクトファイルはGitHubでも公開しました。
モデルの準備
UnityちゃんシリーズのSDトーコちゃんの3Dモデルを使わせていただきました。
眉毛のメッシュが顔のメッシュとマージされていたので、Blenderの練習も兼ねて眉毛を独立したメッシュとして分割しました。
Meshを独立させることで、Unity上で独立したパスとして描画ができます。
使わせていただいた3DモデルはSDトーコちゃんです。https://t.co/0THUJaTto6
— がむ (@gam0022) May 23, 2024
眉毛だけ独立したPassで描画したかったので、Blenderの練習も兼ねて眉毛の部分だけMeshを分割する調整をしました。 pic.twitter.com/GGSB62osG6
Stencilを使うパターン
まずはStencilを使うパターンを実装しました。
学マスの眉毛シェーダー※をUnity上で再現できた。
— がむ (@gam0022) May 23, 2024
※正面から見ると眉毛が前髪より手前に、横顔に近づくと眉毛がフェードアウトする処理
眉毛でStencilを書き込み、前髪の1Pass目で眉毛ではない領域を不透明描画、前髪の2Pass目で眉毛の上から半透明描画してアルファを制御したら、うまくできた🎉 https://t.co/DSVmsS66hd pic.twitter.com/Gvvvq3LqsE
眉毛と前髪を特殊なシェーダーにして、前髪は2Passで描画しています。
- 眉毛でStencil(今回はStencil値を2)を書き込む
- 前髪の1Pass目で眉毛に重ならない領域(Comp NotEqual)を不透明描画
- 前髪の2Pass目で眉毛に重なった領域(Comp Equal)を半透明描画してアルファを制御
前髪の2Pass目を省略すると、眉毛が常に不透明度100%で描画されます。2Pass目によって前髪を眉毛の上からアルファブレンドすることで、眉毛を半透明に見せています。
2Pass目の前髪のアルファ値が眉毛の透明度、つまり 1.0-眉毛の不透明度
になります。
Depth Offsetするパターン
Stencilは使わずにDepth Offsetするパターンでも実装しました。
学マスの眉毛シェーダー、Stencilを使わないパターンもできた🎉
— がむ (@gam0022) May 25, 2024
眉毛だけ2Passで描画
・1Pass目:普通に不透明で描画
・2Pass目:View空間上でZ Offsetして前髪より手前に移動した状態でアルファブレンドで描画。アルファを顔の角度でフェード
この方法が一番シンプルな気がします! pic.twitter.com/r7niDobHzC
眉毛だけ2Passで描画しています。
- 眉毛1Pass目:普通に不透明で描画
- 眉毛2Pass目:View空間上でDepth Offsetして前髪より手前に移動した状態でアルファブレンドで描画。アルファを顔の角度でフェード
非常にシンプルな実装です。
眉毛の2Pass目を省略すると通常の描画(前髪に隠れた眉毛は描画されない)になります。
2Pass目はDepth Offsetをして3D空間上で眉毛を前髪よりも手前に移動することで、実質的にZ Testの無効化と同じ効果があります。
Z Testを無効化しても同じ効果を得られますが、Z Testを無効化してしまうと背景や他のキャラクターまで眉毛が貫通して描画してしまうため、Depth Offsetの方が利点が多いように思います。
DepthのOffset量についてはパラメーターなどで調整可能にして、前髪より眉毛が手前になるべく小さい値にしています。
Depth Offsetのアプローチについては、こちらの中国語の記事で知りました。
Depth Offsetは頂点シェーダーでこのような処理をしています。
クリッピング空間上のZにしか影響を与えないように実装したので、深度情報のみが変化し、メッシュのシルエットは変化しません。
// View空間上でDepth Offset
// https://zhuanlan.zhihu.com/p/696515379
float3 positionWS = TransformObjectToWorld(input.positionOS.xyz);
float3 positionVS = mul(UNITY_MATRIX_V, float4(positionWS, 1.0)).xyz;
// View空間上でDepth Offset
positionVS.z += _DepthOffset;
float4 positionHCS = TransformWViewToHClip(positionVS);
float depth = positionHCS.z / positionHCS.w;
output.positionHCS = TransformObjectToHClip(input.positionOS.xyz);
// クリッピング空間上でオフセットされた深度を適用
output.positionHCS.z = depth * output.positionHCS.w;
この記事だけを読むと、前髪に被っている/被っていないで、眉毛の透明度が変化するのが不思議に思ったのですが、こちらのツイートで疑問が解決しました。
前髪に隠れるように普通に眉描いて、その後から前髪の上から半透明でもう一度眉描けば、特別な仕組みは要らなそうですが
— フィン (@phyn_ndk) May 23, 2024
前髪に被ってるところは髪+眉のブレンドで、被ってないところは眉+眉のブレンドだからアルファ値がいくつでも眉の色がそのまま出ます https://t.co/Dr85ppiivn
シェーダー側では何か特別な処理をしなくても、眉毛をDepth Offset(もしくはZ Test無効)してアルファブレンドすれば自然に意図した結果になります。
- 前髪に被ってるところ
- 髪+眉のブレンドなので、眉の不透明度は眉のアルファ値で変化
- 前髪に被ってないところ
- 眉+眉のブレンドなので、眉のアルファ値がいくつでも眉の色がそのままの色になる(眉の不透明度は100%で固定)
色々と頭を捻りましたが、最終的にはこんなシンプルな仕組みでも同じ効果が得られて、おもしろいなと思いました。
考察とまとめ
2パターン実装してみましたが、Depth Offsetするパターンの方が使い勝手の面でも性能面でも優位性がありそうだと思いました。
Stencilを使うアプローチでは、キャラクターが複数になったときに、顔が重なると破綻してしまいます。たとえば、キャラクターの頭が重なったときに、奥側のキャラクターの眉毛が手前のキャラクターの頭を貫通するということが起こり得ます。
Depth Offsetするパターンでは、こうした問題を回避できます。
描画負荷の面でも、眉毛の面積は前髪よりも小さいので、眉毛の方を2PassにするDepth Offsetの方がGPU負荷が低いと予想できます。
特殊な眉毛のキャラクターを描画をする機会があれば、DetphOffsetを使ってみたいと思いました。
ちなみに、目(眼球)のようにZ Test無効にすると眼球全体が最前面になって見た目が破綻する要素については、Depth Offsetでは難しいので、Stencilを使うしかないと考えています。