VRChatワールドにおける半透明の取り扱いについて ~描画順の仕組みを理解しよう~
今月のワールド製作者向け記事では描画順について、書いてみようと思います。
ワールドでは沢山の半透明なオブジェクトを取り扱うことが数多くありますが、Unityの描画順の仕組みによって、前後関係が違ったり、背後にあるものが消えてしまったりと、望んだとおりの絵が得られないことがあります。
描画順に関して、根本的に理解するためにはシェーダーとレンダリングパイプラインへの理解が必要になりますが、そこまで含めると非常に難しい内容になってしまいます。
そこで今回は一つの記事内で、前編と後編に内容を分けています。
前編ではシンプルに理解しやすさを重視し、専門的な話を抜きに描画順について解説しています。後編では専門的な話として、シェーダーとレンダリングパイプラインについて言及しつつ、前半を補足しています。
基本的には前編だけ読んでいただいても大丈夫なように作ってありますので、後半は読みたい人だけ読んでください。
Unityにおけるオブジェクトの前後関係について
VRChatで普段何気なく遊んでいる皆さんはあまり意識したことが無いかもしれませんが、VRChatには”奥行き”があります。
人間が“奥行き”を認識するためには何が大事でしょうか?
勿論、『遠くにあるものほど小さくなる』という性質でもある程度認識できますが、もっと分かりやすいのは2つ以上の物体の『前後関係』ですよね。
手前側の物体によって、奥側にある物体が隠される。これは現実に即した非常にシンプルで分かりやすい特徴です。
ですが、VRChat……そしてその土台となるUnityはプログラムですので、現実とは違います。きちんとしたルールが無ければ前後関係を上手く処理できません。
今回はそのルールと、気を付けるべきことについてお話ししようと思います。
Unityでは主に3つの方法で前後関係を決めます。基本的には①や②の組み合わせで描画結果が決まりますが、決まらない時は③に従います。
① 深度による比較
② RenderQueueによる比較
③ オブジェクト中心位置による比較
この3つの方法について解説していこうと思います。
① 深度による比較
まず最初にカメラの深度での比較です……と言われても、深度?ってなる方がいらっしゃると思います。そこから説明していきましょう。
現実世界でも深度カメラというものがあります。これはなんと、カメラからの距離を撮影・可視化できるカメラです。そして、距離が分かるということは、物体の前後関係も分かりますよね?
つまり、ここでいう深度(またはDepthともいう)は、奥行きそのものです。
Unityではゲームオブジェクトを描画するとき、基本的には一つずつ描画を行います。
そして、この時、描画するゲームオブジェクトの深度をチェックします。
そして、既に手前側のオブジェクトが描画されていた場合、そのオブジェクトの隠れている部分の絵を描画しないということをします。
これによってゲームオブジェクトの描画される部分と描画されない部分が発生しますが、描画される部分については、描画された場所の深度を書きこんで記録しておき、残りのゲームオブジェクトを描画する時に参考にします。
これが最初の方法で、Zバッファ法と呼ばれています(興味があったら調べてみてください)
この時点で、
『え?奥行きが判別できるなら、前後関係は何の問題もないのでは?』
って思われてる方がいらっしゃると思いますが、この方法には弱点があります。
それは『半透明の物体の前後関係』を上手く処理できない点です。
半透明の物質というのは『奥が透けて見える』という性質を持っていますよね?
ですが、このZバッファ法では『奥にあるものは描画しない』ということを行っています。
この時、手前側にある半透明のオブジェクトを先に描画した後に、奥側のオブジェクトを描画しようとすると、「半透明オブジェクト越しに透けて見えてほしい奥側のオブジェクトが描画されない」ということが起こってしまいます。
この矛盾を解決するために、Unityでは描画する順番を付けて工夫しています。具体的に言うと、不透明と半透明で描画する順番を変えています。
まず不透明のオブジェクトを手前側から描画していきます。手前側から描画することで、Zバッファ法により、手前側のオブジェクトで隠れている部分を描画せずに済みます。
そして、不透明のオブジェクトを全て描画し終わった後に、半透明のオブジェクトを奥側から描画していきます。
既に描画されたものはZバッファ法によって後から消されることがありませんので、奥側から描画していくことでZバッファ法との矛盾を解決できます。
その描画する順番を決めるための仕組みが、次に説明するRenderQueue……なのですが、その前にZバッファ法の対象にならない例外のケースが存在することを知っておいてください。
それはゲームオブジェクトが深度を書きこまない場合です。
ゲームオブジェクトが描画された時、深度を書きこむかどうかはシェーダーの設定によって決まります。
深度を書きこまない場合は『Zバッファ法で隠れた部分が描画されない』が起こりません。これは後ほどRenderQueueの項目で再び説明することになるので、頭の片隅に入れておいてください。
② RenderQueue(レンダーキュー)による比較
RenderQueue、直訳すると『描画待ち行列』というややこしい感じですが、要は『描画順』です。小さければ小さい数値ほど先に描画されるパラメータになります。
このRenderQueueはオブジェクトのマテリアルの部分に設定項目がありますので、ゲームオブジェクト単位で設定するものだと思ってください。
御覧の通り、数値が入っています。この例だと3000番目に描画されるようです。
まるで3000個も描画するものがあるように感じるかもしれませんが、番号に割り当てられたものが無ければその番号はスキップされます(同じ番号の場合は、他の二つのルールを参考にしつつ、描画順が決められますが、これは後ほど説明します)
RenderQueueには、不透明のものは2500以下、それより上は半透明という決まりがあります。つまりそこを境にして、描画方法が異なります。不透明は手前側から描画されていき、半透明は奥側から描画されていきます。
これについては、以下のページで紹介されているイラストが非常に分かりやすいです。
不透明のRenderQueueは、基本的に2000です。Zバッファ法によって、同じRenderQueueにしていても正しく処理されますので、不透明のRenderQueueを調整することはあまりありません。
一方で半透明ですが、こちらはRenderQueueの値によっては問題が起きることがあります。
一般的な半透明では、『既に描画されたものと今描画するものの色を、同じ明るさになるように混ぜる』という方式で、既に描画された奥側のオブジェクトの色を薄め、混ぜ合わせながら描画していきます。
この方式ですと、一番手前側のオブジェクトがくっきり見えるので、正しい見た目に見えるというわけです。
ですが、奥側→手前側の順番に描画されるようにRenderQueueが設定されていないと、奇妙な見た目になってしまいます。
この時、深度を書きこんでいない場合と書きこんでいる場合で違う現象が起きます。
では仮に、RenderQueueの値を間違って、手前側のオブジェクトを先に描画してしまったとしましょう(手前3000、奥3001)
まず前者、深度を書き込んでいない場合、手前側のオブジェクトの色が奥側のオブジェクトより薄められてしまいます。そのため奥側のオブジェクトのほうがくっきり見え、手前側のオブジェクトは色が薄くなるという見た目になります。
次に後者、深度を書きこんでいる場合ですが、この時はZバッファ法によって、既に手前側のオブジェクトが深度を書きこんでいるので、奥側のオブジェクトは描画されません。アバターや着ている服などが水中で消える現象はこれに当たります。
自分も今回の記事を執筆中に初めて気が付いたのですが、アセットストアやBoothで販売・配布されてる水シェーダーを3種類ほど確認したところ、全て深度を書き込んでいました。ですので、ワールドの水面とかだと色が薄くなるより、消える現象のほうが多そうです。
③ 位置関係による比較
深度を書きこまないオブジェクトで、同一RenderQueueだった場合など、①・②で前後関係が決まらないケースがあります。
この場合はゲームオブジェクトの中心位置からカメラまでの距離で決まります。要は距離が短いほうが手前側、長いほうが奥側としてしまうわけです。
シンプルで分かりやすい方法なので、全部同一RenderQueueにして、この方法による判定でいいじゃんと思うかもしれませんが、オブジェクトの"中心位置"による判定は違和感に繋がるケースがあります。
具体例を作ってみました。赤い床の中心位置(1枚目)と青いキューブの中心位置(2枚目)の丁度中心辺りにプレイヤーを立たせて、前進と後退を繰り返してみました。
その時の映像が以下の動画です。赤の中心位置に近いときは、青→赤の順番で描画されるので問題ありませんが、青の中心位置のほうが近くなると描画順が逆になり、違和感のある見た目になります。
両方とも深度を書き込むタイプのシェーダーを使ったので、青いキューブの上半分は赤の色が混ぜられて、下半分はZバッファ法によって消えています。
このように、オブジェクトの中心というのは大きいものほど意外な位置にあったりします。特にワールドでは良く水シェーダーという超巨大な半透明オブジェクトがあったりするので、同一RenderQueueにしているとこのようなことが起こります。
ちなみにVRだと頭(=カメラ)だけ動かせるような状態なので、境目などに立っていると視線の向きを変えただけで前後関係が入れ替わることがあります。
もし視線の向きや位置によって、半透明ゲームオブジェクトがちらついたり消えるのであれば、中心位置やRenderQueueを見直す必要があるでしょう。
例外:同一オブジェクト内での順番について
これについてはキチンと検証したことが無いので、書けませんが、同一オブジェクト内では頂点番号の順で表示されるようです。詳しくはこの記事に記載されているので、そちらを参照してみてください。
VRChatにおける半透明の取り扱いについて
さて、上記を踏まえて、半透明オブジェクトをどのように設定したら良いかについて考えていきましょう。
RenderQueueによる前後関係は、どの角度から見られるかという想定があって初めて成り立ちます。
例えば水中にある半透明オブジェクトについて考えてみましょう。
水面越しで見るのと、水中で見るのとでは、水面とオブジェクトの手前側・奥側という関係性が異なっています。なので、どちら側からも見れてしまうオブジェクトにはRenderQueueの正解はありません。
ですので、プレイヤーがどこにメインで滞在するかを意識して、そこから見たときに破綻のないように設定すると良いと思われます。半透明のRenderQueueを設定するときは『奥側は小さく、手前側は大きく』なので、自分は「遠近法とRenderQueueは一緒」という覚え方をしています。
ただし、最初から『これが手前で、これが奥だから……』などと考えて作る必要は全くありません。RenderQueueが一緒でも、大体は距離による判定で上手く処理できるからです。ワールドのテスト中とかに違和感を感じるようだったら直しましょう。
ちなみに、深度を書きこまないタイプの半透明シェーダーであればRenderQueueが正しくなくても、色が薄くなるだけなので、注意深く見られなければ大丈夫だと思います。
(ただし体感的には2枚以上重ねると、ノーマルマップ由来の模様などが見えなくなるケースが多い気がします。シェーダーの処理にもよると思いますが)
一方で深度を書き込むタイプの半透明については、その後ろにある半透明ゲームオブジェクトが全く見えなくなるので注意が必要です。
もしどちらから見ても違和感なく見えてほしい場合は、色を混ぜる方式ではない半透明Shaderを使うのも一手になるかもしれません。
ParticlesにあるStandard SurfaceやStandard UnlitシェーダーにはAdditiveモードがありますが、これは色を混合するのではなく加算する方式です。そのためどちらから見ても、色が足し合わされるだけなので同じ見た目になります。しかし、"加算"ですので、重ねるほど明るくなっていく点には注意が必要です。
余談その1:視線の向きや位置によって消えるケース
先ほど位置関係の比較の場合では、視線の向きや位置によってゲームオブジェクトが消えるケースがあるという話をしました。
“消える”ケースというのは描画順に限った話ではなく、色々な場合が想定されるので注意が必要です。以下にパッと思いつくパターンを4パターンほど書いておきます。
1つ目のパターン:視界外カリングされる
下のシェーダーはパーティクルのように見えますが、実態は輪郭線が示しているようにただの板です。この板から視線を外すと、視界外カリングの対象になって消えてしまいます。
この問題はboundsを大きくすれば解決しますが、Skined Mesh Rendererには設定項目がある一方、Mesh Renderにはその項目がありません。ワールドでしたらUdonが使えますので、Udonで大きく設定することが可能です。以下に雑に作ったコードを置いておきます(自ワールドで使っているので問題なく動くはずです)
using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
using VRC.Udon;
public class MeshBoundsHolder : UdonSharpBehaviour
{
public Bounds savedBounds;
void Start()
{
MeshFilter meshFilter = this.transform.GetComponent<MeshFilter>();
meshFilter.sharedMesh.bounds = savedBounds;
}
public override void OnPlayerJoined(VRCPlayerApi player)
{
if (Networking.LocalPlayer == player)
{
MeshFilter meshFilter = this.transform.GetComponent<MeshFilter>();
meshFilter.sharedMesh.bounds = savedBounds;
}
}
}
2つ目のパターン:オブジェクトがカメラのFarより遠くにある
カメラのNearやFarの値によって、視界外カリングされてしまうパターンも良くあります。きちんと設定していても、VRCWorldコンポーネントのReferenceCameraに設定するのを忘れていることもあります。遠景が見えなくなっている場合はこれを確認しましょう。
3つ目のパターン:Occlusion Cullingによって消える
Occlusion Cullingが暴発していることで、オブジェクトが完全に遮蔽していないにもかかわらずカリングされているケースがあります。これについては以前書いているので、以下の記事を参考にしてください。
4つ目のパターン:LODによって消える
LOD Groupコンポーネントというものがあります。これはプレイヤーの位置によって表示される3Dモデルを切り替えてくれる機能で、一番遠くに行くとモデルを非表示にします。
非表示にする距離を自分で設定できるのですが、購入してきたUnityアセットなどでは既に設定済みのケースが多いです。意図せぬ距離で消えるようなら、このLOD Groupの各色のバーの境目を掴んで左右に動かしてあげることで調整できます。
余談その2:Cutoutって何?半透明なの?
Standard ShaderのモードにもあるCutoutは、透明・不透明がくっきり分かれたものです。分類上は半透明に相当すると思いますが、RenderQueue上では2450に割り当てられ、不透明の分類です。
利点としては部分的に不透明として扱われるので、Zバッファの恩恵により前後関係を気にしなくても良いことです。そのため木の葉っぱなどによく使われています。
(補足:木は群生していて、葉っぱが入り乱れています。そのため2本以上の木が隣り合っていた場合に、1本の木の中で手前側になる部分と奥側になる部分が両方存在することがあります。そのため半透明だと正しい描画順にするのが難しいです)
ただ、描画負荷としては不透明より高いです。半透明はシェーダー内で工夫しないと、影を受けたり・落としたりできない一方、Cutoutは影を不透明同様に扱えるので、影の分でTransparentよりも描画負荷が高いケースすらあります。注意して使うべきかもしれません。
余談その3:不透明は必ずしも手前からではない
不透明は厳密に手前からではないみたいです
正直入れ替わって奥から描かれても、半透明と違って透けて見えることはないので、見た目は問題ないですよね。
一方、半透明は厳密に制御されているらしいので、安心してください。
------------------ここから後半----------------------
さて、ここからは前半ではほとんどしなかったShaderやレンダリングパイプラインについての話をします。詳しく知りたい人向けの発展的な内容になっていますので注意してください。
描画順と密接にかかわるShaderの話
前半では一切話をしないことを心掛けていましたが、描画に関する話はShaderの専門分野になります。そのためより詳しく知るためにはShaderへの理解を深める必要があります。
具体的に前半に話したどの部分が該当するかというと
『深度を書き込むか、書きこまないか(関連ワード:ZWrite)』
『深度比較時の合格条件(関連ワード:ZTest)』
『半透明の色をブレンドするときの方法(関連ワード:Blend)』
等はShaderによって規定されています。そのためShaderをいじれないとここら辺のコントロールが全くできないことになります。
ここら辺については詳しく書かれているリファレンスがあるので、リンクを掲載することで説明を割愛します。
ZWriteやZTestについて
Blendについて
シェーダーとレンダリングパイプラインについて
レンダリングパイプラインを端的に説明すると、Unityが行っている描画までの手順を示す用語です。前半でした描画順の話は、Shaderとレンダリングパイプライン、この二つの概念と密接に結びついています(なので前半は意図的に言葉を省いたりしていました)
とはいっても、私が文章で説明するのを見るより、以下の動画を見たほうが良いです。Unity Technologies Japanが公開している動画ですが、シェーダーとレンダリングパイプラインについて分かりやすく解説を行っています。
これは本当にお勧めできる動画です。
じっと動画を見るのがつらい人は、スライドもありますのでそちらを読んでいただいても大丈夫です。こちらは72枚目のスライドまでが上の動画の内容になります。
では上のリンクの内容を見たことを前提に、今回のお話に関係する話を少し補足します。あくまで私の理解なので間違っていたらコメント欄などでご指摘ください。
前半でお話ししたZバッファ法の利点として、フラグメントシェーダーの部分がスキップされるというのがあります。
フラグメントシェーダーは描画点に打つべき色を計算する処理です。なので、画素数分の計算が走ります。例えば現代では比較的小さい1024×1024の解像度の画面であっても、1024×1024 ≒ 100万回の計算が行われます。
しかもこれは、それぞれの画素が1回の描画で済むパターンの話で、実際は半透明などが色を混ぜる形で再度塗りなおしますので、100万回よりもっと多い計算が行われることになります。これが途轍もない計算数であることは想像していただけると思います。
(これが半透明は重いと言われる理由でもあります)
ですので、フラグメントシェーダーの部分をスキップできるというのは、実に大きいメリットになります。
ちなみにバーテックス(頂点)シェーダーは、その名の通り3Dモデルの頂点ごとに行われる処理ですので、頂点数にもよりますが比較的少ない計算数で済みます。
(余談ですが、このバーテックスシェーダーの処理も割愛するのが、Occlusion Cullingだったりします)
ここまで読んでいただけた方にはある程度伝わったかもしれませんが、この『シェーダーとレンダリングパイプラインに関する話』は色々な側面に波及します。この概念を念頭に置いておくと、Unityの色々な機能やUnity上で発生する現象についても相互的な理解が深まるでしょう。
シェーダーが書ける必要性はないですが、是非『シェーダーとレンダリングパイプラインに関する話』は覚えておくことをお勧めします。
余談:Shaderについてもう少し知りたい人にはXJINEさんのUnity Shader Programmingを購入して読むことをお勧めします。Vol.01~06まで、全6巻あります。今回のこの記事の執筆時もVol.01をかなり参考にさせて頂きました。
おわりに
正直、今回の記事を書く前は
『RenderQueue周りって色々解説あるし、書く必要あるかな~?』
って思ってたんですけど、過去一番書くのが大変でした。
何も考えずにレンダリングパイプラインの話を書くと、急に読者側のハードルが上がってしまうので、それを書かずにふんわりイイ感じに執筆するのに苦労しました。
本来はレンダリングパイプラインがあっての描画順な気がするのですが、摂理に逆らってる感ありましたね(笑)
また、自分自身も、誤解が色々あったことに気づかさせられました。
例えば、RenderQueue周りを解説しているいくつかの記事で
「Transparentはデプス (深度) を通常は書き込まない」
みたいなニュアンスの文章があるせいで、特殊なケースでしか深度を書きこまないのかとずーっと思っていました。
そんな中
「水面越しに消える場合あるけど、なんで消えるんだろう……」
って呟いたら、知り合い二人から
「深度書き込んでいるからでは(←要約)」
という話を頂いて、その偏見が解消されました(お二方ありがとうございます)
その話を頂いてから、色々シェーダー覗いてみたら普通に深度を書きこんでいる半透明が結構ありました(というかShaderの仕様上、意図的にOFFにしてないと書きこむので、少ないどころかむしろ多い気がしますね)
ネットの記事は便利ですけど、やっぱり時折疑って自分の目で確かめてみるのが大事だなーとしみじみと思いました。
そんな苦労がありつつ執筆した記事なので、読んでくださった方のためになってくれたら嬉しいです。
以上、長々とお付き合いいただきありがとうございました。
長文読了お疲れ様です。