始まり
こんにちは!!! エンジニアR&Dの小林です。
最後に投稿した頃はクライアントエンジニアでしたが、部署移動の末、晴れてR&Dというかっこいい肩書を手に入れてしまいました。
むふふです。
会社ブログの方ではお久しぶりですね。
相変わらずレンダリング業務に従事している筆者さんですが、ふと、思うことがありました。
多光源とセルルックがもっと仲良くなってほしいなと。
「仲悪いよねー」と共感できる方はきっと同業のことでしょう。
そして領域外の方からしたら「はて?」となることでしょう。
そんなお話をしていきます。
作業環境
- Windows 11
- Unreal Engine 5.4
- Visual Studio 2022
- Visual Studio Code
- RenderDoc, PIX
セルルックの特徴的な明暗
セル調・セルルック・NRPはリアル調・PBRと異なり明暗の階調がどんなに多くても3影までなことがほとんどだと思います。
見た目の塩梅とアーティストに負担させられるコスト的にも。

ぱっきりと割れた明暗に違和感を覚える方もいると思いますが、これがセルルックの基本的な仕様です。
適当なアニメ作品を一時停止してキャラクターをまじまじと見てください。
脳内でポストエフェクトを外して見ると、意外と使用されているパレットや階調が限られていることでしょう。
多光源の処理
Unreal Engineに限らず多光源を扱う場合には、基本的にはディファードライティング環境が適しています。
Forward+とか魔改造とか、プロジェクトに特化した環境を用意出来ればその限りではないのですが、キリがないのでディファードライティングとします。
そんなディファードライティング環境で置かれた大量のライトたちの反映方法はBlendMode:Addです。

ペイントツールのレイヤー合成でいうところの加算ですね。
わかりますよね、加算、つまりは色をどんどん重ねて・乗せていきます。
問題の挙動、見た目
試しに先ほどのセルルックなシェーダーで多光源なライティングをしてみましょう。




見ての通り、ライトを2つ以上同じ場所に当ててしまうと、加算ブレンドにより階調が維持できなくなります。
結果的に3影を通り越してN影な見た目になってしまいました。
階調が維持できない以外にも、色を加算し続けているので、白飛びするリスクも抱えています。
自動露出による抑制は画面内に1キャラだけ描画される環境であればある程度効果的に作用するのですが、2キャラ以上だと、キャラごとの輝度に偏りがあると、片方が明るくて片方が暗くなるという、これまた仕様上の弱点があるので、難しいのです。
ちなみに輝度の偏りは、片方の子にスポットライトを当てる、もう片方の子にスポットライトを当てないという、たったこれだけで起きちゃいます。
階調と白飛び、これら2つの問題はディファードライティングの根本的な設計が要因のひとつである以上、避けることは出来ないのです。
個人的にはこの階調こそがディファードライティングしている感があり好きなのですが、セルルックにおいてはあまりにも相性が悪くて、嫌われております。
アーティスト的には3影しか設定していないのに「なに!?このグラデーション!!」となるわけです。
仕組みを知っている側からしたら「そりゃ、当然やろ」の一言なのですが、万人がレンダリングという、言葉を選ばなければヘンな分野とお友達な訳でもないですし、仕方がない疑問なことは承知しているので「仕様で直せませんよ♪」という優しい返答がテンプレートと化しています。
とはいえです。
何回かルックデヴをやっていると、なんとかなるんじゃね?と思うことも稀によくあります。
という訳で、そんな知的好奇心を満たすために、今回は階調抑制と白飛び抑制、この2つの実験、検証をしていきます。
前提
技術検証という名目の気分転換で始めた内容のため、実運用は考えていないです。
つまり処理負荷という現実と向き合うことは放棄しております。
つよつよGPUがすべてを解決してくれます。
NVIDIAさんありがとう。
白飛び抑制
まずは簡単そうな白飛び抑制から試していきましょう。
白飛びの抑制方法としては、クランプをするか、正規化をするかの2択ですかね。
前者はライトを一定数配置するとRGBの各成分が最大値に到達、真っ白になって色味が死んじゃう未来が見えるので採用不可です。
後者でしたら色味も保持しつつ正規化の最大値をパラメータに出すことで割と自由に調整できそうなので、実験する価値がありそうですね。
ということで正規化を試していきます。
尚エンジンは問答無用で魔改造します。
ライトカラーの蓄積を記録
ライトカラーを正規化するために、そのピクセルに照射されるライトカラーの蓄積を計算してみます。
ライトパスにMRT1を追加して、そこにライトカラーの蓄積を記録するバッファを突っ込みます。
あとはシェーダーを蓄積パスとライティングパスでバリエーション作成すれば準備完了です。
これでそのピクセルに照射されるライトカラーの最大値が取得できました。
ライトカラーの成分の内訳は、ライトカラー、ライトファンクション、距離減衰の基本的な3要素です。
蓄積されたライトカラーを元に正規化
シンプルに現行のライトカラー計算の末尾にrcp(LightColorAccumulation.rgb)
を乗算して正規化してみます。
配置するライトはポイントライトとスポットライトをいっぱいです。
正規化しない場合はこんな感じで圧倒的な白飛びです。
正規化すると想定通り白飛びが抑制されましたね。
結果
白飛びの抑制が無事成功です。
いえーい。
明暗の階調を抑制
白飛び抑制が上手くいったので、次は階調です。
ただこの子は結構な問題児なんですよね。
白飛びと同じように各ライトパスで抑制しようとすると、ライトの照射範囲外はプリミティブが存在しないので、そもそもSceneColorに対する書き込みが発生しません。
要は先ほどのような小細工が出来ないので不可避な階調です。


結構迷走しましたが、最終的にはこんな感じになりました。
- 明暗(NoL)の結果を蓄積
- 蓄積が終わったら明暗の最小と最大値をキャラごとにComputeShaderで算出
- 蓄積された明暗を、そのキャラクターの最小、最大明暗を元に正規化して、このタイミングでセル調な量子化(2影、3影など)を行う
明暗の蓄積を記録
先ほど作成した蓄積を記録するバッファのAlphaに明暗の情報を追加します。
正規化というか量子化は最後の最後に行いたいので、ここで記録していく明暗は最小値を0.0としたシンプルなNoLです。
あとライト減衰やライトファンクションといった基本的な要素も乗算されています。
キャラクターごとに明暗の最小と最大値を算出
明暗を正規化するために必要な最小と最大値をComputeShaderで計算しています。

InterlockedAdd, Min, Max
で頑張って画面内の最小と最大値を探しています。
Addは検証で使っているだけなので実際にはMinとMaxだけだったかな。

最小と最大値をバッファに突っ込んでます。
ライトシェーダーではこれをSRVとして突っ込んで読み込みです。

キャラの四方八方からライトが当たっていて、影になる場所がない場合は最小の部分が0.0超過する感じですね。
今回キャプチャした内容はキャラの前方からしか当てていないので、背後が全影になることがあったのでしょう。
明暗の正規化と量子化
最小と最大値を元に0..1に正規化、それを2影や3影などに量子化して、セル調なシャドウを降臨させています。
計算したシャドウを元に色を塗るわけですが、ライトパスごとに色を塗ると先ほど言及したプリミティブ云々問題が起きちゃいます。
対策として明暗(明色、1影、2影)の色塗りはディレクショナルライトパスのみが行うことにしています。
明暗は計算済みなので、単一のパスのみで解決可能なのです。
その他のポイントライトやスポットライトは、リムやスペキュラなどの追加で色を乗せる部分だけ計算してもらっています。


結果
でけた!
遊ぶ
アホみたいにライトを配置します。
自分で見返してもほぼ分からないのですが、スペキュラとリムをいじりながら再生しています。
スカートの裏面が若干黒いのは背面法なアウトラインなので気にしないでください。
ランタイムに適当に計算したハードエッジな法線を、頂点カラーバッファに突っ込んでいるだけなので色々と甘いのです。
それはさておき、マスターマテリアルから雑に一括調整しているだけなのですが、結構それっぽい見た目になりますね。
大量のスペキュラはディファードライティング味がすごいですね。
陰影はライトやカメラ位置によってはチカチカ変わることがあるので、理想を言えば前フレームとの差分の何割かを移動量として反映した方がより綺麗に魅せられるんですかね。
おわり!!!
楽しかった!(こなみかん)
ランタイムで破綻なく魅せるための前準備はキレるレベルで面倒ですけど、やっぱり Unreal Engine ならこれくらいライトをたくさん置きたいですね。
いえーい。
今回は処理負荷を無視して実装してみましたが、ダウンサンプリングやステンシルテスト、蓄積用に処理を分岐を極限まで削ったバリエーションの用意、1フレーム遅延が許容されるなら明暗はRegisterExternalTexture
で前フレームに計算した情報を参照など、頑張れば現実的な範囲に落とし込めそうな雰囲気は感じました。
関連
外部サイトですけど、セルシェーディングの情報を載せてたりします。