18
3

CanvasMaterialでUI Shaderをシンプルに(+Unity完全に理解した勉強会のフォローアップ)

Last updated at Posted at 2023-12-02

前書き

この記事は、2023のUnityアドカレの12/3の記事です。
今年は、完走賞に挑戦してみたいと思います。Qiita君ぬい欲しい!

前書き2

また、今回の記事は、12/2に開催された、「Unity UI 完全に理解した勉強会」で発表した内容を文章化したものです。こちらも併せてご覧いただけましたら幸いです。

はじめに

今回は、UnityのUIシステム、uGUIにおけるUIShaderの管理についてです。

具体的には、UIShader単体ではなく、ゲーム開発プロジェクトを俯瞰する目線で、UIShaderをどう設計し管理すると良いだろう…ということを考えてみたいと思います。

チーム開発をしているとこんなことがよくあるんじゃないかと思います。

  • UIデザイナーからの機能要望が多い
  • 仕様変更への耐性
  • 担当者の交代時のコストを下げたい
  • 作業履歴の追跡を容易にしたい

…といったことに対して、私もこれまでどうしたものか…とやってきました。
そんな経験の中で、試行錯誤して現在までにたどり着いた形をご紹介しましょう。

UnityのUIシステムについておさらい

現在、Unity公式ではuGUI、ImGUI、UIElementsの3つのシステムが提供されています。

UI Elements

UXMLとUSSというDLSを用い、HTML/CSSのような雰囲気でUIがデザインできるフレームワークです。Unityさん的には、これから推していきたそうな雰囲気ですが、まだ「RuntimeではuGUIの方が推奨」ということらしいです。

UI ElementsのShaderに関しては、PanelSettingsアセットのフィールド(Hidden)を差し替えることでShaderを改造版に差し替えることができるようです。

これは、Panel全体の描画に使われるShaderです。個別のElementについてShaderを切り替えたりDOMからShaderへ渡すパラメータを個別に上書きするということも難しそうです。そもそもUI Elements自体、ほぼすべてのElementを1つのDrawCallにバッチするという戦略をとっています。Element個別に描画されるわけではないのです。

ImmediateModeElementで、Graphics.DrawMeshNowなどが使えるらしいので、このように自作した割り込みElementなら何でもありかもしれません。

ImGUI

ImGUIは、MonoBehaviourのOnGUIメソッドで描画できるあのUIのことです。
シンプルなコードオンリーで描画ができるので、デバッグなどに重宝する昨日です。ちなみに、Unity2021/Android/Vulkanの構成ではクラッシュします。これはUnityにも直せないって言われました。なんてこったい。

ImGUIのShaderの在処は、UnityEditor.TextCore.Text.EditorShaderUtilities.xxxMaterialです。Editorでは、これを差し替えることで不可能ではなさそうです。しかし、GUI.xxxといった描画命令メソッドにMaterialやShaderを引数にすることはできず、コンポーネントごとに設定できないので、UI Element同様、全てのコンポーネントに影響してしまいます。

まぁ、UI ElementsもImGUIもRenderTextureなど、いったんオフスクリーンのバッファに書き出して使うのが定石です。

uGUI

Canvasの中に、GameObjectを配置していき、ImageやRawImageを置いていくフレームワークです。
Unity初心者でなければ、詳しい説明は不要でしょう。

こちらは、UIShaderの変更は容易に行えます。二つの方法があります。

Materialスロット

uGUIのImageやRawImageのコンポーネントにはMaterialをアタッチできるスロット(フィールド)が用意されています。改造ShaderからMaterialを作成し、Materialスロットにアタッチすることで、そのコンポーネントはそのMaterialで描画されます。

image.png

CanvasMaterial

CanvasMaterialというMaterialがあり、これはImageなどのコンポーネントのMaterialスロットがnullだった場合に利用されます。初期化時([RuntimeInitializeOnLoad][InitializeOnLoad])に Canvas.GetDefaultCanvasMaterialにセットすることで差し替え可能が可能です。また、後述するIMaterialModifierでコンポーネントごとにShaderパラメータを上書きすることもできます。

ひな形のShader

Unity Download ArchiveのBuild in Shadersからダウンロードできます。その中のUI-DefaultがuGUIのメインShaderになりますので、これを改造し、プロジェクトにインポートしてください。

具体的なUIShader管理を考える

機能要件として、AdditiveとDistortionという機能をuGUIに追加する場合についてシミュレーションしてみましょう。作業ルートは、

  1. まずは、ひな形のUI-Defaultを用意
  2. そこからAdditiveShaderとDistortionShaderをそれぞれ作る
  3. それぞれMaterialを作る
  4. 必要なImageコンポーネントのMaterialスロットにアタッチする

と、なります。

要件をそのまま愚直に実装

こんな感じの構成をとっているプロジェクトは少なくないかと思います。

image.png

AdditiveとDistortionの2機能ぐらいなら、ぶっちゃけこれでもいいの大丈夫です。しかし、大量のUIを必要とするソシャゲとか作ってると、機能追加要望が多くなりがちです。同じように、Shader→Material→アタッチを繰り返しましょうか…?

勘の良い方や、やったことのある方はピンと来たかもしれません。

組み合わせ問題

そう、組み合わせの考慮が必要です。

Distortionで歪んだUIを、さらにAdditiveで光らせて欲しい!…みたいなこと、絶対言われますよね?仕方ないので、AdditiveDistortionShaderを作りましょう。

image.png

これは組み合わせの問題です。今は2機能しか考えていないので、組み合わせは3つしかありません。しかし、3機能なら7Shader、10機能なら1023Shader…と加速度的に組み合わせShaderが増加していきます。これを管理保守なんて、とてもできません。

実際には、必要になるまでその組み合わせを作らなければ良いですが、UIデザイナーが欲しいと言ってから使えるまでにエンジニアの作業が必要になってしまったり、前の担当者が追加した機能の組み合わせを実装しないといけない…などの問題も出てきます。

UberShader

ShaderはUberShaderにしちゃいましょう。UberShaderは、Materialパラメータで機能のOnOffが出来るような設計のShaderのことです。一般的にはShaderKeywordを使って静的に分岐させますが、Additive程度であれば動的分岐でもいいかもしれません。

image.png

ということで、Shaderは1つになってすっきりしました。この、1つのShaderから、いろいろな機能を有効無効したMaterialが作成出来るようになります。

しかし、まだMaterialの組み合わせ爆発が残っています。MaterialはShaderの違いだけでなく、パラメータ違いのバリアントもあり得ます。例えば同じ加算でも、緑を加算と青を加算…といったように。

もし、Materialを1つに出来たら?

もしMaterialも一つにすることが出来れば、このようにかなりスッキリします!

コンポーネントごとに機能ON/OFFやパラメータを変えたい場合どうするんだ?ということは、後述するとし、話を進めます。

image.png

今度は、全部同じMaterialなのに、毎度これをImageのスロットにアタッチするのが面倒ではないでしょうか?Preset機能などを使えば半自動化は出来ます。しかし、過去に作ったPrefabに遡っての適用は手動でやるしかありません。うっかりデタッチしてしまうこともあり得ます。

CanvasMaterial

ここで出てくるのが、CanvasMaterialです。

CanvasMaterialは、Materialスロットがnullの場合、それがImageを描画するMaterialとして使われます。CanvasがBuildされた後では、時すでに遅しなので、InitializeOnLoadRuntimeInitializeOnLoadで設定するのがオススメです。

CanvasMaterialのMaterialインスタンスはCanvasに用意されています。このMaterialをCanvas.GetDefaultCanvasMaterialで取得して、ShaderをUberShaderに差し替えます。

image.png

管理すべきMaterialは、1つどころか無くなってしまいました😊

ところで、先ほど脇に置いておいた、Materialが一つで良くなる仕組みについても何とかする必要があります。

IMaterialModifirer

そこで使えるのがIMaterialModifirerです。このinterfaceを実装したコンポーネントをModifierと呼ぶことにします。

このModifierをImageにAddCompornentすることで、CanvasMaterialをそのImage専用に特殊化し、パラメータを上書きすることが出来ます。

image.png

これで、管理すべき対象は、UberShaderと、コンポーネントにつけられたModifierだけです。さらに、ModifierはUIPrefabの中に含まれている(Serialiaizeされている)ので、Materialを作りスロットにアタッチするよりも、設定の集約度が上がります。

Modifierの実装方法

UIコンポーネント毎にMaterialを上書きするためのModifierの詳しい作り方を説明します。

IMaterialModifierを実装したクラスで、IMaterialModifier.GetModifiedMaterial(Material)を実装します。引数で渡されるのがCanvasMaterial、もしくは他のModifierがCanvasMaterialを特殊化したMaterialです。この中でMaterialを特殊化し、必要なShaderパラメータを上書きします。

class UIAddColor : MonoBehaviour, IMaterialModifier
{
    [SerializedField][ColorUsage(showAlpha: false)] Color color;

    Material IMaterialModifier.GetModifiedMaterial(Material baseMaterial)
    {
        var m = new Material(baseMaterial);
        m.renderQueue = baseMaterial.renderQueue; // 何故かCopyされないので
        m.hideFlags |= HideFlags.NotEditable;
        m.EnableKeyword("ENABLE_UI_ADD_COLOR");
        m.SetVector("_UIAddColor", color);
        return m;
    }
}

このImageでは、CanvasMaterialの代わりに、返したMaterialが使われます。

HideFlagをどうするかは、まぁ一長一短ありますしどっちでもいいです。どのみちこれは、シリアライズされない、揮発性のMaterialAssetです。

ちなみに、組み込みのMaskもこの方法で実装されています。

image.png

ちなみにちなみに、IMaterialModifirerだけでなくIMeshModifierというのもあるので、良かったら使ってみてください。影を付けたりするのに重宝すると思います。

IMaterialModifierの仕組み

実装方法では、IMaterialModifierの中を見ましたが、IMaterialModifierを呼び出す側についても理解しておくとよいでしょう。

image.png

まず、ImageはCanvasMaterialへの参照を取得します。この段階はまだ特殊化はされておらず、sharedです。

// UnityEngine.UI.Graphic
static public Material defaultGraphicMaterial
    => s_DefaultUI = Canvas.GetDefaultCanvasMaterial();
public virtual Material defaultMaterial => defaultGraphicMaterial;
public virtual Material material => m_Material ?? defaultMaterial;

ImageはGetComponentsでModifierをかき集め、パイプラインのようにMaterialを流していきます。

// UnityEngine.UI.Graphic
public virtual Material materialForRendering { get
{
    var modifiers = GetComponents(typeof(IMaterialModifier));
    var currentMat = material;
    for (var i = 0; i < components.Count; i++)
        currentMat = modifiers.GetModifiedMaterial(currentMat);
    return currentMat;
}}

最後のModifierがMaterialを返したら、これをCanvasに登録し、実際のDrawを依頼します。
Canvasは、UnityEngine内部実装により、(同一MaterialはBatchして)実際のDrawCallを発行します。

メリット/デメリット

UberShader化することについて

  • 👍Shader数が爆発しない
  • 👍Shaderコードの一覧性、変更追跡が向上する
  • 👍UnityアップデートでUI Defaultが変わっても、変更は1箇所
  • 👎使っていない組み合わせのShaderまで生成されてしまう

CanvasMaterialを使うことについて(MaterialAssetがなくなるメリット)

  • 👍Material数が爆発しない
  • 👍IMaterialModifierさえ追跡すれば良くなる
  • 👍意図せぬUIが影響を受けることを防げる
  • 👎Materialスロットと違い、UIに露出していない

注意点

CanvasのバッチはMaterialが共通していないと利かない

先ほどのようにModifier毎にMaterialをインスタンス化してしまうと、Materialインスタンスがバラバラなのでバッチががまったく効きません。バッチを効かせるには、例えば、パラメータをキーからMaterialインスタンスへのDictionaryを用意してインスタンスを共有するなどが良いかと思います。

Maskはそのように実装されています。

ShaderKeywordをmulti_compileにすべし

shaderKeywordは、shader_featureではなく、multi_compileにする必要があります。
Materialをランタイムでインスタンス化する都合、ビルド時にshader_featureを要不要判定をすり抜けて、Variantから漏れてしまうのです。逆に、Editorではちゃんと機能が効くのに、実機ビルドでのみ効かない場合は、これを疑ってみると良いでしょう。

MaterialやそのShaderだけを他PJにコピペ流用出来ない

PJ間で流用する際には、ShaderだけでなくModifierとセットで持ち込む必要があります。Materialアセットを作ってスロットに刺す場合のように、Imageから参照を追っていけばShaderまでたどり着くわけではありません。

Modifierの中身を見て、UberShaderの移植も必要だなと、理解できる程度のメンバーが一人は必要です。個人や小規模開発などで、エンジニアレスなプロジェクトで手を出すにはハードルが高いでしょう。

まとめ

  • UIのShaderは(も)UberShader化しよう
  • CanvasMaterialにセットし全uGUIで共有しよう
  • IMaterialModifierで、コンポーネント毎にMaterialを特殊化しよう
  • 困ったらMaskを参考に

ただし、カスタムUIShaderはワンポイントでし使わないようなプロジェクトでは、ややオーバーエンジニアリングかもしれません。大量かつ多種多様なUI機能が必要とされる「ソシャゲ」などには適しています。

…ということで、UIShaderの管理手法の一例を提案させて頂きましたが、もし「こんな管理方法してるよ!」みたいなものがあれば、コメントなどで教えていただけましたら幸いです!

快適なUI開発ライフを!

P.S. (フォローアップ)

MaterialVariantでもよいのではないか?あるいはMaterialVariantと共存はできないのか?

発表後、こんな質問をいただきました。

MaterialVariantはUnity2022.1から導入された機能です。Materialアセットを継承して、新たなMaterialアセット(Variant)を作ることができます。VariantなMaterialは任意のパラメータを上書きすることができます。一方で、上書きしていないパラメータは、継承元のMaterialのパラメータを引き継ぎます。

つまり、継承元のMaterialを変更することで、継承先のMaterialすべてに影響を与えることができます。

私はまだ、2022でUI関連を担当した経験が少なかったため、完全に見落としていました。確かにMaterialVariantを使えば、Material爆発しても、管理しやすくなります。なので2022以降はMaterialVariantをUIにも活用することは新たな選択肢になるでしょう。

UberShaderとの親和性

むしろ、MaterialVariantを使うことを考えるなら、Shaderは積極的にUberShaderにすべきです。なぜなら、継承先でも、そもそも無いパラメータを上書きすることはできないからです。

先のAdditiveShader、DistortionShaderが分かれている例ですと、AdditiveのMaterialを継承して、Distortionを有効にするということをすることができません。

また、MaterialVariantを三角継承することもできません。

image.png

CanvasMaterialとの親和性

皆無です。

CanvasMaterialは、Materialアセットが不要になる方法ですので、MaterialVariantの入る余地はありません。

ModifierがMaterialを特殊化しますが、これはランタイムの話です。MaterialVariantは、非ランタイム、つまりAssetの状態での管理を支援する機能です。そもそもビルド時には継承関係も解決され、普通のMaterialとまったく同じになります。よって、ModifierでMaterialVariantを活用するということも無いでしょう。

MaterialVariantとModifierのどっちを使ったら良いか

私の意見としては、それでもCanvasMaterial+Modifierに分があるように感じます。

データ集約の観点

UI作りはPrefab作りです。つまりPrefabを編集することがメインになります。そうであれば、視覚的パラメータ(Shaderパラメータ)もPrefabの中に入っていた方がデータの集約度が高くなります。MaterialVariantをスロットに刺している場合、Materialアセットの設定にも意識を払わねばなりません。しかも、そのMaterialAssetを編集する場合、ほかのUIからの参照からの参照を考慮せねばならない点も避けられません。
さらに、Gitなどのバージョン管理システムでも、変更ファイルが分散してしまいます。

継承関係の観点

MaterialVariantの機能は、継承を簡単に管理する機能です。

「○○」という基礎があり、そこに少し修飾したもの」

具体的例を考えてみましょう。

「木材の材質」という基礎があり、「濡れた木材の材質」を派生させる
「プラスチックの材質(白)」という基礎があり、「青いプラスチックの材質」
「プラスチックの材質(白)」という基礎があり、「赤いプラスチックの材質」

このように、まず、概念的な継承関係をイメージできる必要があります。非UIであれば、簡単に思いつきそうですが、UIではちょっと難しいのではないでしょうか?

なぜなら、UIの「材質」は基本的テクスチャで表現してしまうからじゃないかと思います。Shader機能では「それが何であるか」よりも、どう強調したいか…みたいな感覚になるのではないでしょうか?便宜上、材質(Material)と呼んでいますが、材質の概念とちょっと違うんじゃないかなと…ちょっと説明が難しいですね😥

Materialが隠蔽できるという観点

Materialアセットがあれば、そのアセットが正しいことを保守しなければなりません。Variantであろうが、できれば管理対象は少なく、データ構造を簡単にしたいものです。しかもMaterialはデータ構造が複雑なアセットです。コンポーネントであれば、実装次第で最低限の構造体にできます。

さらに、UIはUIデザイナーが構築している現場もあろうかと思います。エンジニアが構築するならともかく、Materialアセットの仕組みを学習してもらう必要が無くなるのも大きいかと思います。

18
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
3