はじめに
Unityを触り始めてから幾数年、ずっと疑問に思っていることがありまして。
他社のUnityゲームって、どうやってUI描画してるんだろう...?
いきなり何を言ってるのか意味不明ですよね。順を追って説明しますね。
Linear or Gamma ?
まずUnityというエンジンは、レンダリング時の色空間を "Linear(リニア色空間)" か "Gamma(sRGB色空間)" のどちらかから選択する方式になっています。
この設定は"Project Settings"に存在し、ランタイムで動的に書き換えることが出来ません。つまり、どちらかを選択したらレンダリングワークフロー全体が選択した方法に固定されてしまいます。
完全に2D描画のみに特化したゲームならともかく、昨今の多少なりとも3Dが使われるゲームであればリニアワークフローは当たり前ですから、"Linear"を選択しない選択肢はほぼありません。Unityのデフォルト設定もURP環境であれば"Linear"になっています。
ここで大きな問題となるのがUI描画です。メインとなる3D描画にはリニアワークフローを採用したゲームであっても、UI描画はsRGB色空間でレンダリングされることが一般的だと私は認識しています。
私の前職までの(非Unity環境)経験ではUIは全てsRGB色空間で描画していましたし、PhotoShopの作業スペースのデフォルト動作もsRGBです。
シリコンスタジオの川瀬さんによる"HDR出力対応の理論と実践"といった資料でも、UI描画はトーンマップ/ガンマ補正を済ませた非線形色空間でやるもの、と書かれていますね。
(ちなみに今エントリはHDR出力については一切考慮していません、モバイルゲームなどSDR出力なアプリを前提に話を進めています。HDR警察怖い!!)
しかし、Unityはリニアワークフローを選択した時点で、UIも全部リニアで描画しなさい、と指示してくるわけです。ガンマ補正はレンダリングパイプラインの中で隠蔽されており、アプリケーション側でそのタイミングをカスタマイズすることは出来ません。
リニア色空間でUI描画した際に大きく差異が出てしまうのはアルファブレンドです。下の画像はアルファ値0.5で半透明描画したテクスチャですが、sRGB色空間とLinear色空間では色が変わってしまっています。
そんなわけで
冒頭の疑問に戻るわけです。
他社のUnityゲームって、どうやってUI描画してるんだろう...?
素直にリニア色空間でUIを描いている...?それともリニアワークフローを捨ててGamma設定で3D描画している...?流石に「特に何も考慮せず適当」って訳にはいかない程度の差異は生まれるし...?
それなりに大きな引っ掛かりポイントだと思うのですが、日本語圏でこれが話題になっているのを見掛けたことがありません。本当に疑問なんですよね。
(海外に目を向ければいくつかトピックは見つかります↓ので、私の認識に大きな齟齬がある、というわけではないと思うのですが...)
https://forum.unity.com/threads/linear-space-for-scene-gamma-for-ui.309016/
https://github.com/TakeshiCho/UI_RenderPipelineInLinearSpace
「うちの会社はこうしてるよ!」とかの情報がありましたら何らかの手段で連絡頂けるととても嬉しいです。
リニア色空間UIを受け入れる?抗う?
この問題について、取りうる選択肢は2つあるかと考えています。
- UI描画もリニア色空間でそのまま頑張る
素直にUnityに従うならこちらになります。エンジニアの実装コストやランタイムの描画負荷は(何もしなくて良いので)安く済みますが、その分、負担が掛かるのはUIアーティストです。過去の作品創りの中で培った経験値はリセットされ、慣れないアルファブレンドの感覚での作業を余儀なくされます。
また、UIオーサリングツールがリニアワークフローに対応している必要があります。PhotoShopは設定を弄ればなんとかなりますが、その他に利用しているアプリ/ツールがsRGB依存していた時点で破綻します。
TAやグラフィックスエンジニアはUIアーティストから「なんかUIの裏の背景の色が、Unityに持っていくと明るくなるんだけど?」みたいな相談をほうぼうから受けることになります。辛いです。
- 「UIだけsRGB色空間で描画」する仕組みをUnity上で自作する
はい、というわけで、上記の仕組みを用意します。しました。
今エントリはその解説記事になります。ここからが本題というわけですね。
Unity2022.3.18f1 + URP 14.0.10 を前提として書いています。
URP内部の書き換えを前提としている為、他バージョンでは多少の差異が生まれるかと思いますが、その辺りは適宜読み替えをお願いします。
画面解像度について
さて、まずは前提条件として、ランタイム上での画面解像度について3種類の大きな枠として整理しておきます。
-
ネイティブ画面解像度
これはモバイル端末であれば、その端末の画面解像度そのままの値を指します(非モバイルであれば、モニタの解像度が該当するかと思います)。このネイティブ画面解像度はGPU性能に比して無駄に高すぎることが多々あり、ゲーム上でこの解像度をそのまま利用することは避けた方が良いでしょう。アスペクト比だけ拾っておいて、後は忘れます。 -
UI(スクリーン)解像度
UI描画に用いられる画面解像度です。Screen.SetResolution()で設定します。
サイズ自体は端末の性能に合わせて調整しますが、タイトル開発の初期段階で想定される基本的なスクリーン解像度、をレギュレーションとして決めておきます。それに合わせてUIテクスチャ解像度なども決められます。
アスペクト比だけはランタイム上でネイティブ画面解像度に合わせます。 -
3D(メイン)解像度
3D描画に用いられる画面解像度です。スクリーン解像度にRenderScaleを掛けた値が該当します。この値は1未満を標準としておいてパフォーマンスを稼ぎ、GPU負荷に応じて更に動的に増減させます。
という話を前回のQiitaエントリに書きました。
ここで注目してほしい大事なことは「ネイティブ解像度をUI解像度として使わない」ことと「UI解像度と3D解像度を分離する」ことです。特に後者は、UI解像度がRenderScaleの増減によって変わらないようにするべきです。UI解像度がコロコロ変わるのは見栄えが悪いですからね。
GammaUI:「UIだけsRGB色空間で描画」実装の詳細
今エントリでは主題である「UIだけsRGB色空間で描画」する仕組みを"GammaUI"と名付けています。以下の文章でもその名詞が時折出てきますが、名称自体ははどうでもいいですので、お好きなように改変ください。
1.URPをカスタムパッケージ化する
何はなくともURPは弄らないと話になりません。
com.unity.render-pipelines.core@14.0.10
com.unity.render-pipelines.universal@14.0.10
com.unity.render-pipelines.universal-config
com.unity.shadergraph@14.0.10
この辺りを"Library\PackageCache"から"Packages"にまるっと移動させるだけで、後はよしなにUnityがやってくれます。簡単ですね。
Unity/URPのバージョンアップが必要になる未来には地獄が待っていますが、記憶に蓋をして忘れましょう。
2.BaseCameraを2つ用意する
シーン上に、RenderType:Baseなカメラを2つ用意します。それぞれ3D用カメラ(MainCamera)とUI用カメラ(UICamera)として機能させます。この2つ以外に、RenderTexture構築用以外でBaseCameraは不要なはずです。
RenderType:Overlayなカメラはいくら増やしてStackさせても構いませんが、Unityにおけるカメラの追加は尋常でなく重たい処理になりますので、CPU負荷と相談しましょう。
MainCameraはポストプロセスを必ずOnにしておき(トーンマップすら使わない3Dゲームはまずあり得ないでしょうから、問題にはならないはずです)、RenderTextureは割り当てずにURPが提供するバックバッファをそのまま使うようにします。これでURPAssetのRenderScaleが反映されるようになり、前エントリの動的解像度と共存することができるようになります。
3.全てのUI描画をRenderTextureに隔離する
UICameraのオブジェクトにはスクリプトをアタッチします。このスクリプトは以下のような実装で、非常にシンプルな中身になっています。ExecuteAlwaysを指定して常時動作させるようにします。
- CameraのclearFlagsを"SolidColor", backgroundColorを"Color.clear"に固定させる。
- Screen.width/Height と同じ解像度のRenderTextureを作成し、CameraのtargetTextureに割り当てる。フォーマットはARGB32、DepthStencilは不要(※)。
- Screen.width/Heightを監視しておいて、前フレからの変更があればそれにRenderTextureの解像度も追従する(既存RTの破棄/新しいRTの生成を行う)。
- RenderTextureは適当な名前でGlobalTextureとしてシェーダから参照出来るようにしておく。 →
Shader.SetGlobalTexture("_GammaUIRT", gammaUIRT);
(※ UI描画でMaskなどステンシルが欲しいという要望が出てくるかもしれませんが、このMaskはアンチエイリアスの掛からないピクセル単位のマスク処理しか出来ないので画質面でとても微妙です。それをアーティストと協議してもなお欲しいという話であれば、追加しても問題ありません。)
固定のRenderTextureを用意することで、RenderScaleの影響を受けずに解像度を一定に保つことが出来ます。また、このカメラ/RenderTextureへの描画は全てsRGB色空間であるとすることでリニアワークフローとの分離を行います。
3.5.CanvasをUICameraに向かせる
UI描画用Canvasは全てRenderModeを"ScreenSpace-Camera"としてUICameraを向くように設定します。MainCameraに向かせてしまうとUI描画がRenderScaleの影響を受けてしまいますし、"ScreenSpace-Overlay"だとLinear色空間で描画されてしまいます。
これらの対応により、UI描画が全て1枚のRenderTextureに描き込まれるようになります。ただし、まだこの段階では描き込まれたUI描画はLinear色空間のままです。
4.UI描画用マテリアルのシェーダを差し替える
UI描画に使われているビルトインシェーダUI-Default.shader
をUnity download archiveから拾ってきます。
UI-Default.shader
のシェーダ名を適当に差し替えた上で、以下のように書き換えます。
half4 color = (tex2D(_MainTex, IN.texcoord) + _TextureSampleAdd) * IN.color;
↓
half4 color = (FastLinearToSRGB(tex2D(_MainTex, IN.texcoord)) + _TextureSampleAdd) * FastLinearToSRGB(IN.color);
リニアライズされたUI用テクスチャ/頂点カラーを無理やりsRGBに変換しています。とても間の抜けたコードに見えますが、仕方がありません。(リニアライズ自体を止める方法もありますが、それはそれでクセの強い仕組みになってしまうので妥協でこのようにしています)
FastLinearToSRGB()
が見当たらなければ、適当にググってコピペしてください。Fast版でなくても良いです。
そしてこのシェーダをプロジェクト内に含めて、UI用標準マテリアルのシェーダとして割り当てます。
Canvas.GetDefaultCanvasMaterial().shader = Shader.Find("GammaUI/UI-Default");
4.5.カスタムシェーダもLinearToSRGB対応を行う
UI用のカスタムシェーダに関しても、UI-Default.shader
と同様にLinearToSRGBを通してsRGB色空間への対応が必要になります。TextMeshProの各種シェーダもそこには含まれます。
ShaderGraphへの対応も簡単になるように、「UIで色を拾ったらこれを通してね」というおまじないSubGraphを用意しています。
ここまでの対応で、UI描画がsRGB色空間で行われるようになりました。ですが、まだRenderTextureに描画されただけで実際のゲーム画面には反映されていません。3D+UIの合成、コンポジット処理が必要ですね。やりましょう。いよいよURPの書き換えの出番です。
5.URPを書き換える
書き換えるファイルは最小で2つです。
FinalPost.shader
com.unity.render-pipelines.universal@14.0.10\Shaders\PostProcessing\FinalPost.shader
以下のコードを追加します。
//----
TEXTURE2D(_GammaUIRT);
SAMPLER(sampler_GammaUIRT);
...
//----
const half3 color_srgb = LinearToSRGB(color.rgb);
const half4 ui_srgb = SAMPLE_TEXTURE2D(_GammaUIRT, sampler_GammaUIRT, uv);
color.rgb = SRGBToLinear(color_srgb.rgb * (1 - ui_srgb.a) + ui_srgb.rgb);
FinalPost.shader
はポストプロセスの最終段に呼ばれるシェーダで、3DCameraがレンダリングした結果を最終的なフレームバッファへ書き込む処理が行われています。
このタイミングでUI描画が描き込まれたRenderTextureを読み出し、色空間をsRGBに揃えた後でコンポジットを行い、今度はリニア色空間に戻します。
脇道に逸れますが最後の行、UI描画には乗算済みアルファが使われていますので、単なるlerpではないことに注意してください。乗算済みアルファについては過去に私が書いたこちらのエントリを参照ください。
UniversalRenderer.cs
com.unity.render-pipelines.universal@14.0.10\Runtime\UniversalRenderer.cs
以下の書き換えを行います。
bool applyFinalPostProcessing = anyPostProcessing && lastCameraInTheStack &&
((renderingData.cameraData.antialiasing == AntialiasingMode.FastApproximateAntialiasing) ||
((renderingData.cameraData.imageScalingMode == ImageScalingMode.Upscaling) && (renderingData.cameraData.upscalingFilter != ImageUpscalingFilter.Linear)) ||
(renderingData.cameraData.IsTemporalAAEnabled() && renderingData.cameraData.taaSettings.contrastAdaptiveSharpening > 0.0f));
↓
bool applyFinalPostProcessing = anyPostProcessing && lastCameraInTheStack &&
(renderingData.cameraData.cameraType != CameraType.SceneView);
FinalPost.shader
は「FXAA/TAAを使っていない&&アップスケーリングがBilinear」など、呼ぶ必要がない条件だとスルーされてしまいます。ので、常に呼ばれるように条件を減らします。が、ただ減らしただけだとSceneViewにも適用されてしまうので、SceneViewは無視するように追加します。
以上の書き換えによって、UIがsRGB色空間でRenderTextureに描画され、3D+UIのコンポジットもsRGB色空間で行われ、最終的にフレームバッファへ反映されるようになりました。
なお、これはあくまで最小規模の修正であって、実際に運用するのであればもう少しURPの書き換えが必要になるかと思われます。例えばRenderTextureへ3D描画を行うカメラを追加した際、それをポストプロセスOnにしてしまうと、この3D+UIコンポジット処理が走ってしまいます。回避するには例えば
com.unity.render-pipelines.universal@14.0.10\Runtime\Passes\PostProcessPass.cs
のRenderFinalPass()
辺りなどをチョコチョコ書き換えたり、もっと頑張るならFinalPost.shader
にshader_featureを追加するなどの修正が必要になるでしょう。
他、諸々の対応に追われるかと思いますが、エントリが長くなりすぎるので割愛します。
余談
URPの特にFinalPost.shader
を書き換えることによってシンプルな3D+UIコンポジットが出来る、ということに気付く以前は、「ScreenSpace-OverlayなCanvasを一つ用意して、その中で3D+UIコンポジット用RawImageを配置する」という回りくどい実装を行っていました。ScreenSpace-OverlayはFinalPost.shader
よりも後に、RenderScaleの影響を受けることなくフル解像度でフレームバッファへの塗りつぶしを行うことが可能なんですよね。実際にこのような実装で、とあるタイトルは動いてしまっています。
盲腸のようなとても気分の良くはない手法でしたが、「URP書き換えはどうしてもやりたくない」という条件ではこちらの選択肢もないわけではありません。
...が、この実装ではFinalPost.shader
で画面を塗りつぶしたあとにもう一度同じような画面を塗りつぶす、という完全に無駄な全画面フィルが発生することになるので、描画効率がとてつもなく悪いことに注意してください。この効率の悪さを回避するにはURPの書き換えが必要になるので、畢竟、今回紹介した実装が(私が把握している限り)最善なんですよね...
結果
ここまでに書かれた対応を行うことで、Unityの色空間をLinearに設定したまま、UIのみをsRGB色空間で描画することが出来るようになります。なりました。
ここで話が終わってくれればまだマシなのですが、残念ながら続きがあります。長くなりすぎたので少々流し気味に書きます。
SceneViewへの対応
表題の通りです。GameView(ランタイム)では上記までの実装で正しく目的を達成出来たのですが、SceneViewについては放置状態でした。
SceneView上のUI描画はただの半透明描画として扱われ、RenderTextureに逃がすこともFinalPost.Shaderでコンポジットすることも出来ません。
改善方法は以下のとおりです。
-
SceneView専用のRendererを用意する。
このRendererを他のカメラからは参照されないように割り当て、URPAssetのRendereListからDefault設定することでSceneViewからのみ参照されるようになります。 - テクスチャをFastLinearToSRGB / FastSRGBToLinearで色空間を変更するだけのシェーダを用意する。
- BeforeRenderingTransparents / AfterRenderingTransparentsのタイミングで、それぞれ2で作成したシェーダを通すRendererFeatureを作成し、1のSceneView専用Rendererに追加する。
一言でまとめると、半透明描画の区間だけsRGB色空間であるように偽装します。
このRendererFeatureにより、SceneView上でもUI描画をsRGBで描画したルックを手に入れることが出来ます。
とはいえ、これではUI以外の3Dなど通常の半透明オブジェクトが(本来のLinearではなく)sRGB色空間で描画されてしまいます。これを同居させることは難しいので、今回用意したRendererFeatureに「UIを確認したいときにのみOn、UI以外を確認したいときにはOffに手動で切り替えが出来るようなボタン」を用意しました。
謎のボタンが一つ増えて不便さは上がってしまいましたが、それでも、これでやっとGameView(ランタイム)でも、SceneViewでも、正しくUIをsRGBで描画することが出来るようになりました!!
さいごに
ただUIをsRGBで描きたい、というそれだけのことに、何故ここまで手間が必要になるのでしょうか...つらたにえん...
このエントリ、書き始めてからは数時間程度で完成したのですけれども、1行目を書き始めるまでに半年以上掛かりました。。どうにもテンションが上がらず。非常に難産でした。
「こんなニッチな内容、誰が読むんだろう?」という自問は書き終えた今でも消えませんが...
今回は以上になります。お疲れ様でした。