こんにちは!!! 株式会社スパーククリエイティブの小林です。
今回はセル調に調整・改変したライティングの実装をしていきます。内容は前回の続きになりますが、最後の実装から2か月ほどの月日が経過してしまいました。
その結果一部実装方針を忘れているため、なんか前回と違わない? と思われる箇所はこのような背景があると思って見逃してもらえると助かります。
それではエンジン改造の最終回、やっていきましょう。

ライティングの実装
第4回と同じながれで、表現ごとに実装方法を紹介していきます。
記事に落とし込む関係で表現ごとにコミットをわけていますが、皆さんが確認する際には最新までの差分を一括で取得することを推奨します。
今回のようなライティングに関わるシェーダファイルは書き換えるたびに約1,200件ほどのシェーダコンパイルが発生するため、開発環境が弱いと確認するだけでひと苦労なのです。

特定の表現・実装方法だけを確認したいこともあると思うので、そのあたりはご自由にどうぞ。ただしコンパイルの拘束時間は取られますよ、とだけお伝えしておきます。
マスターとインスタンスマテリアルの作成
エンジン改造の最終回にしてようやくマテリアルの登場です。この遠回りな感じはいかにもエンジン改造しているなぁと実感しますね。
マスターマテリアル(M_ToonBase)と、そのマスターマテリアルからインスタンスマテリアル(MI_ToonBase)を作成します。

マスターマテリアル(M_ToonBase)のShading Modelは当然Toon Litにします。

あとはインスタンスマテリアル(MI_ToonBase)からインスタンスマテリアルを作成してあげれば準備完了です。
今回はタマモというモデル1体しか使わないので、この程度の親子関係で充分です。使用するモデルが2桁や3桁の場合は、さらに部位ごと(顔や髪など)のインスタンスマテリアルをつくって、そこから作成した方が一括調整が楽になります。
※画像は既にノードを組み込んでいる関係でマテリアルに色が付いていますが、初期状態では真っ黒のはずです。

ポストプロセスのパラメータ設定
Unreal Engineは良くも悪くもポストエフェクトの影響が大変強いです。セル調表現においては後者の悪い影響、不都合を与える要因になります。
エンジン改造の本筋ではないので詳細は触れませんが、今回は自動露出とビネットを無効化しています。トーンマッパとブルームは筆者が個人的に好きな効果という理由で有効にしています。
トーンマッパはベタ塗りと相性が悪いことで有名だとは思いますが、白飛びを簡単に防止できるという結構な利点があるため、なんだかんだで無効にしづらいのですよね。


トゥーンライティング関数を準備
標準のライティング関数内で分岐を作成するのもアリですが、可読性を重視してトゥーンライティング用にAccumulateDynamicToonLighting関数を作成しています。
GitHubで実装を見る(L427-L486)ライティング計算をするピクセルのシェーディングモデルがToonLitの場合、AccumulateDynamicToonLighting関数を実行します。
GitHubで実装を見る(L499-L507)※リンク先の実装を閲覧するには、第4回「エンジンの取得方法」の手順に含まれるEULAの同意・承認を行なったGitHubアカウントが必要です。
AccumulateDynamicLighting関数との大きなちがいは以下のとおりです。
- ちがい
理由
- ToonLit以外のClearCoatやSubsurfaceなどのシェーディングモデルの分岐を排除
ToonLit専用のライティング関数のため、他のシェーディングモデルを想定する必要がない
- LightMaskの分岐を反転
可読性重視
- SHADING_PATH_MOBILE、NON_DIRECTIONAL_DIRECT_LIGHTING、REFERENCE_QUALITY バリエーションを排除
本改造では不要なバリエーションのため
- Transmissionの影響を排除
Subsurface系のライティングは不要
- Shadow.SurfaceShadow + Shadow.TransmissionShadowの条件分岐を排除
標準ライティングでは影(Shadow)はBlackになるが、トゥーン表現では影色を指定するため、条件分岐が不都合
この時点では何も表示されず真っ黒です。

陰 / シェード / Shade
皆さんご存じNoLです。
NoLやNoVなどの頻繁に使用される内積解はBxDFContextに纏められており、計算はInit関数で一括に行われます。便利ですね。

ピクセルシェーダを全体的に見ると、floatとhalfがごちゃ混ぜに使用されています。
今回の対象プラットフォームであるPC(HLSL)は、基本的にはhalf型はfloat型として扱われるため、雰囲気で書いている程度と思ってください。Platform.ushを覗くとなんとなく把握できると思います。
モバイル環境ではGPUによりますが、律儀にhalf精度で動いてくれますので、意識して書かないと精度起因の不具合が発生して発狂することでしょう。
顔陰 / FaceShadow
第4回ではFace Shadow Textureという名称のとおり顔陰の形状をテクスチャで指定していましたが、精度不足で楕円がガタガタになってしまったので、UVからランタイムに計算するかたちに変更しました。
形状の算出が大雑把過ぎてVの上端と末端に丸みが生じる不都合と、ライトベクトルが真上に近いと荒ぶるという2つの不都合を抱えていますが、見て見ぬふりをしています。丸みを帯びさせるためにカーブ係数を中央と縁で補間しています。

顔陰表現を適用するかはFace Shadow Vectorがゼロベクトルか否かで判定して、ゼロベクトルの場合にはToonNoLを適用しています。
また、顔陰適用時でもFace Shadow TextureのR・Gが共にゼロの場合には常時明色にしています。これは、白目箇所を暗くすると元の色が白いということもあり結構どす黒くなってしまうので、いっそのこと暗くしなくてもいいかという判断です。
Face Shadow Vectorはベクトルなので、値の範囲を0.0〜1.0から-1.0〜1.0にデコードする必要があります。
ただし、愚直にデコードしてもゼロベクトルは取得できません。エンコード時にゼロベクトルは0.5になりますが、8bitで表現するときには0.5なんてものはなく 127 / 255 で約0.498で扱われます。あんまり深く触れても大多数の方は面白くないと思うので、なんかヘンなお作法をしているなぁと思ってください。

以下は、マスターマテリアル側のノードの組み方です。



UV.x = UV.x > 0.5 ? smoothstep(0.3, 0.5, 1.0 - UV.x) : smoothstep(0.3, 0.5, UV.x);
UV.y = UV.y > 0.5 ? smoothstep(0.1, 0.5, 1.0 - UV.y) : smoothstep(0.0, 0.5, UV.y);
return UV;
▲Face Shadow Textureの計算のカスタムノードその1

UV -= 0.5;
float Theta = atan2(UV.x, UV.y);
float Radius = sqrt(dot(UV, UV));
Radius *= 1.0 + Strength * (Radius * Radius);
UV = 0.5 + Radius * float2(sin(Theta), cos(Theta));
return UV.x;
▲Face Shadow Textureの計算のカスタムノードその2

影 / シャドウ / Shadow
影を落とすためにエンジン改造をしたと言っても過言ではないでしょう。UEはシャドウマップの扱いが難しいですからね。
Shadow Mapテクスチャは8bit精度です。stepで2値化するにはちょっと心許なかったため、smoothstepで幅寄せ程度にしています。直線状の影を落とす分にはstepでも意外と耐えてくれるのですが、曲線になった途端に階段状になってしまうのですよね。

明色と陰影色 / Base Color & Shadow Color
陰影が決まったので、それに応じて明色(ライトが当たる箇所の色)と陰影色(ライトが当たらない箇所の色)を塗っていきます。
GBufferから明色と陰影色を取得(L971-L972) 明暗に従って色を乗せる(L992) FaceShadowのマスク部分は常時明色(L996) 明暗に従って色を乗せる(L1002)
色が乗ると一気にそれっぽくなりますね。ここからは256色に収めるとアーティファクトが発生して本来の見た目を共有できなくなるので、GIFではなく静止画が多めになります。



スペキュラ / Specular
衣類に適用する光沢表現です。
トゥーン表現はベタ塗りが基本になりますが、それゆえに画が単調になりすぎます。そんなときは適当にスペキュラでグラデーションを付けてあげることで意外と誤魔化せます。



天使の輪 / Angel Ring
Face Shadow Textureと似たような理由で、Jitter Textureの解像度が低い&用意するのが手間(ノイズ付与するだけですが)だったので、Hash関数でJitterを適用しています。
ヘアハイライトの実装(L1014-L1027) 格納先を間違えていたので修正&0.0〜1024.0の範囲を格納(L215-L216) 0.0〜1.0から0.0〜1024.0にデコード(L470)



#define Hash(Value) frac(sin(dot(Value, float2(12.9898, 78.233))) * 43758.5453)
// manual bilinear filtering
float4 Values00 = float4(Hash(floor(UV + float2(0.0, 0.0))),
Hash(floor(UV + float2(1.0, 0.0))),
Hash(floor(UV + float2(0.0, 1.0))),
Hash(floor(UV + float2(1.0, 1.0))));
float2 Fraction = fmod(UV, 1.0);
float2 HorizontalLerp00 = lerp(Values00.xz, Values00.yw, Fraction.xx);
return lerp(HorizontalLerp00.x, HorizontalLerp00.y, Fraction.y);
▲Highlight Jitter Intensityの計算のカスタムノード

ライトカラーの反映
現状では、明色と陰影色はマテリアルから指定されたBase ColorとShadow Colorを出力しているだけのベタな処理です。
これにライトカラーの色味を反映させて、夕暮れなら橙色っぽく、夜空なら青白くなど環境やシーンに合った出力をできるようにします。

スカイライトの反映
ライトカラーの次はスカイライトカラーの反映をさせます。両ライトカラーを考慮することで、色味の反映が極端に偏ることが改善できます。正規化しているので色が混ざっているようなものですが。
ライトカラーと同様にスカイライトカラーも調整して反映(L488-L489) スカイライトカラーを反映したことで、光量が1.0を超過するので最終調整(L492)
追加ライト(Point、Spot、Rect)の反映
ポイントやスポットなどの追加ライトを簡単に反映できるのも、エンジン改造の利点のひとつです。
ディレクショナルライトとは異なり、追加ライトには距離減衰を考慮する必要があります。今回はライトカラーを正規化しているのでそこまで違和感がないのですが、普通は光量がオーバーフロー状態で異常発光します。

距離減衰を適用するとこのような見た目になります。
比べてみるとセル調表現だから、正直前者でもいい気もしちゃいますね。ライティングのルールは自由に捻じ曲げていいので好きな方を採用してください。一般的には減衰を考慮するので後者で進めていきます。


リムライト
リムライトは内線法で算出して書いています。



const uint MaxOffset = 8;
const float2 Offsets[MaxOffset] = {
float2(-1.0, 1.0), float2(0.0, 1.0), float2(1.0, 1.0),
float2(-1.0, 0.0), float2(1.0, 0.0),
float2(-1.0,-1.0), float2(0.0,-1.0), float2(1.0,-1.0),
};
// Sky Mask.
const float SceneDepth = SceneTextureLookup(ClampSceneTextureUV(ViewportUVToSceneTextureUV(UV, PPI_SceneDepth), PPI_SceneDepth), PPI_SceneDepth, 0).r;
BRANCH if (SceneDepth > 100000.0) { return 0.0; }
// ToonLit Pixel Only.
const bool bIsToonLit = SceneTextureLookup(ClampSceneTextureUV(ViewportUVToSceneTextureUV(UV, PPI_ShadingModelID), PPI_ShadingModelID), PPI_ShadingModelID, 0).r == SHADINGMODELID_TOON_LIT;
BRANCH if (bIsToonLit == false) { return 0.0; }
float Weight = 0.0;
UNROLL for (uint i = 0; i < MaxOffset; ++i) {
float2 NewUV = UV + Radius * Offsets[i] * TexelSize;
float2 NewClampedUV = ClampSceneTextureUV(ViewportUVToSceneTextureUV(NewUV, PPI_SceneDepth), PPI_SceneDepth);
float NewSceneDepth = SceneTextureLookup(NewClampedUV, PPI_SceneDepth, 0).r;
Weight += (NewSceneDepth - SceneDepth);
}
return saturate(smoothstep(Threshold - 0.1, Threshold + 0.1, Weight));
▲リムライトの計算のカスタムノード
アウトライン
アウトラインもリムライト同様に内線法で書いています。
一般的には外線法で書くものですが、使用しているアンチエイリアスがTSRの兼ね合いで、外線法で書いてしまうと深度と速度をサンプリングしないと残像が発生するため、内線法を選んでいます。



const uint MaxOffset = 8;
const float2 Offsets[MaxOffset] = {
float2(-1.0, 1.0), float2(0.0, 1.0), float2(1.0, 1.0),
float2(-1.0, 0.0), float2(1.0, 0.0),
float2(-1.0,-1.0), float2(0.0,-1.0), float2(1.0,-1.0),
};
// Sky Mask.
const float SceneDepth = SceneTextureLookup(ClampSceneTextureUV(ViewportUVToSceneTextureUV(UV, PPI_SceneDepth), PPI_SceneDepth), PPI_SceneDepth, 0).r;
BRANCH if (SceneDepth > 100000.0) { return SceneColor; }
// ToonLit Pixel Only.
const bool bIsToonLit = SceneTextureLookup(ClampSceneTextureUV(ViewportUVToSceneTextureUV(UV, PPI_ShadingModelID), PPI_ShadingModelID), PPI_ShadingModelID, 0).r == SHADINGMODELID_TOON_LIT;
BRANCH if (bIsToonLit == false) { return SceneColor; }
float Weight = 0.0;
UNROLL for (uint i = 0; i < MaxOffset; ++i) {
float2 NewUV = UV + Radius * Offsets[i] * TexelSize;
float2 NewClampedUV = ClampSceneTextureUV(ViewportUVToSceneTextureUV(NewUV, PPI_SceneDepth), PPI_SceneDepth);
float NewSceneDepth = SceneTextureLookup(NewClampedUV, PPI_SceneDepth, 0).r;
Weight += NewSceneDepth - SceneDepth;
}
Weight = smoothstep(0.0, 1.0, saturate(PositiveClampedPow(Weight * Intensity, Bias)));
return lerp(SceneColor, SceneColor * OutlineColor, Weight);
▲アウトラインの計算のカスタムノード
完成
CustomData.aとToonData1、ToonData2の3つのスロットに、何を詰め込むか思い出せずに完成してしまいました。まぁ余剰がある分にはブラッシュアップの幅があるということでいったん片付けておきます。
いい感じのレベルで完成したライティングを見て終わりにします。朝昼は比較的ベタな色が出て、夜は月夜に照らされてる感じが出ていていいですね。遺跡チックな場所でポイントライトを置いただけですが、意外と雰囲気が出てくれました。



おわり!!!
3回にわたってエンジン改造でセル調のルックを作ってきました。
実装を見返すとライトカラーを強引(適当)に正規化したり、影のグラデーションの幅をsmoothstepで寄せたり、非現実的な計算を色々していました。既存のDefaultLitなどではこのようなライティングルールの改変はできませんし、Unlitで実装できたとしても2重にコストがかかるためパフォーマンスが悪化します。
そういう諸々のボトルネックを最小限に抑えつつ、理想のルックをつくることができる手段のひとつが「エンジン改造」です。
最近のUEのアップデート頻度を考慮すると、実装と保守コストがとんでもなく高いですが、製品版のほとんどでエンジン改造されている背景には、こういう面倒ながらも負荷を最小限にしつつ何でもできるという点があると思います。
ところどころ説明を端折ったり、レンダリングまわりが完全初見な方には難しい部分もあったと思いますが、少しでもエンジン改造という便利で面倒くさい作業の敷居を下げることができたら嬉しいです。

小林新汰/Kobayashi Arata
新卒でSPARK CREATIVEに入社後、Unreal Engineをはじめとした様々なグラフィック業務に従事している。
●SPARKグループ公式サイト
spark-group.jp
●SPARK CREATIVE Techブログ
tech.spark-creative.co.jp
●SPARK CREATIVE 採用ページ(新卒採用/キャリア採用)
spark-group.jp/recruit/
TEXT_小林新汰 / Kobayashi Arata(SPARK CREATIVE)
EDIT_小村仁美 / Hitomi Komura(CGWORLD)