Raycasterを自作してデバグUIをUGUI非依存にしてみた

f:id:hirasho0:20190615224618p:plain

こんにちは。技術部平山です。

以前デバグUIのライブラリを作ったことについて書きました が、HoloLensで動かないことがわかり、改良しました。 前のコードに上書きしてありますが 、前回と違って実製品でのテストを経ていない上に、 以前とはインターフェイスが異なっているのでご注意ください。 packageもあります。

今回の記事では、その改良の過程で行った、 クリックやドラッグを検出する機構である Raycasterの自作について書きます。

Raycaster概要

EventSystem はマウスやタッチの入力が どのgameObjectに対して行われたかを判定してするのに、 Raycasterというものを使っています。

3D空間に置かれたColliderに対しての当たり判定は PhysicsRaycaster 、Canvas内要素に対しての当たり判定は GraphicRaycaster が担当します。

いずれも UnityEngine.EventSystems.BaseRaycaster を継承したクラスであり、コンポーネントとしてどこかのgameObjectについています。 公式のコード によれば、OnEnableやOnDisableでEventSystems内の staticなリストに登録される作りのようです。

そして、いくつRaycasterがあってもイベントが発火するのは一つなので、 例えばCanvas内のButtonに対して発火させてしまえば、 画面の同じ位置にColliderがついたキューブがあったとしても、そちらは発火しません。

今回の改造の背景

私のデバグUIライブラリ は、 いくつボタンやスライダーがあってもgameObject一個しか生成しない、 というのがウリのもので、 描画は CommandBuffer で行い、 クリックやドラッグはGraphicRaycasterで取っていました。 GraphicRaycasterは Canvas がなければ使えませんが、 Canvasの描画は使っていないわけで、 イベント取得のためだけにCanvasを AddComponent するという気持ちの悪い状態になっていました。

普通、 MeshRenderer で描画するものに関してクリックその他を検出したければ、 PhysicsRaycasterをCameraにつけ、MeshRendererを持つオブジェクトに Colliderをつけるでしょう。 そうできれば良かったのですが、残念ながらこれがうまく行きません。

何故なら、私のDebugUiはボタンやスライダーなどの要素が gameObjectを持っておらず、 ボタンがいくつあろうとも、GameObjectは一つしかないからです。 どのボタンに当たったのかわかりません。 もしそれがどうにかなったとしても、ボタン等々のUI要素が多数画面にあれば、 かなり複雑な形状のColliderが必要になります。 MeshCollider が必要でしょう。さらに、要素は動くので、 下手をすると毎フレーム頂点を設定する羽目になります。 あまり明るい未来が見えません。

その点、GraphicRaycasterであれば、 ICanvasRaycastFilter.IsRaycastLocationValid() を実装することで「ボタンとボタンの間にカーソルがあれば発火させない」 といった制御を簡単に行うことができます。 そのために気持ち悪いながらもCanvasを併用していたわけです。

しかし最近、Raycasterを自分で作れることを知りました。 だったら話は違ってきます。

実装

要するに、Canvasがなくても使える「GraphicRaycasterみたいなもの」 があればいいわけです。 GraphicRaycasterはBaseRaycasterを継承しており、 自分も同じように継承したクラスを作ればCanvasなしで 同じことができるでしょう。

やってみたところ、うまく行きました。

まず、従来DebugUiManagerはUnityEngine.UI.Graphic を継承していましたが、 これをやめて、BaseRaycasterを継承します。

   public class DebugUiManager : BaseRaycaster
        , IPointerClickHandler
        , IPointerDownHandler
        , IPointerUpHandler
        , IBeginDragHandler
        , IDragHandler
    {

そして、必要な実装を用意します。必要なのは、 eventCameraプロパティ と、 Raycast関数 です。 eventCameraは元から描画に使うカメラをフィールドに持っていたのでそれを返し、 Raycast関数では、以前IsRaycastLocationValid()でやっていたことを ほぼほぼそのまま行います。

public override void Raycast(
    PointerEventData eventData,
    List<RaycastResult> resultAppendList)
{
    // 何かに当たるならイベントを取り、何にも当たらないならスルーする
    var sp = eventData.position;
    float x = sp.x;
    float y = sp.y;
    ConvertCoordFromUnityScreen(ref x, ref y);
    bool hit = false;

    // ドラッグ中ならtrueにする。でないと諸々のイベントが取れなくなる
    if (isDragging)
    {
        hit = true;
    }
    // 何かに当たればtrue
    else if (_root.RaycastRecursive(0, 0, x, y))
    {
        hit = true;
    }
    else
    {
        // 外れたら離したものとみなす。
        _input.isPointerDown = false;
        _input.pointerX = -float.MaxValue;
        _input.pointerY = -float.MaxValue;
        hit = false;
    }
    // 当たったらraycastResult足す
    if (hit)
    {
        var result = new RaycastResult
        {
            gameObject = gameObject, // 自分
            module = this,
            distance = _screenPlaneDistance,
            worldPosition = _camera.transform.position + (_camera.transform.forward * _screenPlaneDistance),
            worldNormal = -_camera.transform.forward,
            screenPosition = eventData.position,
            index = resultAppendList.Count,
            sortingLayer = 0,
            sortingOrder = 32767
        };
        resultAppendList.Add(result);
    }
}

まず、もらったスクリーン座標を、DebugUi内部の座標に変換します (ConvertCoordFromUnityScreen)。 DebugUiの座標は、指定した仮想解像度を持ち、Yが下向きの座標系です。 仮想解像度を1136x640に設定すれば、 実際の解像度がいくつであれ、画面が1136x640であるかのように 拡大縮小されます (アスペクト比が異なる場合は全体が入るように余りが出ます)。

次に、再帰的にボタンやスライダーなどの各UI要素に問い合わせて、 ポインターに当たっているかを調べます(RaycastRecursive())。 当たっていれば、 RaycastResult に結果を入れ、 引数でもらったListにAddします。 UI要素は2次元で、しかも全て長方形ですから、 この計算はPhysicsRaycasterが行う計算よりもずっと単純で高速です。 自作しても大した手間ではありません。

RaycastResultに何を入れるかに関しては、 公式のPhysicRaycasterのコード が参考になります。ほぼほぼその真似をしました。

ただ違うところもあります。 本来当たった物を入れるべきRaycastResult.gameObjectは、 何に当たってもDebugUiManagerがついているgameObjectにします。 他にgameObjectがないからです。

sortingOrderが32767となっているのは、 「DebugUiより優先度が高いものはないはず」という仮定による決め打ち設定です。 これにより、DebutUiの要素が何か当たれば、 他のColliderやCanvas要素には反応しなくなります。

他の応用

1つのgameObjectが内部的に複数の部品に分かれていて、 そのどれをタップしたのか知りたい、みたいな時には 今回と似たような手が使えるのではないかと思います。 動的にメッシュを生成してパーティクルを散らすが、 それぞれタップしたら反応するようにしたい、 というような時ですね。

もちろん、パーティクルの数だけgameObjectとMeshRenderer、Colliderを 用意する標準的なアプローチで負荷の問題がないなら そんなことをする必要はありません。 ただ、物理演算は行わず、パーティクル相互の衝突もなく、 でもタップ判定だけはしたい、 そしてパーティクル一つあたりは4頂点かそこらしかない、 みたいな話だと、gameObjectやMeshRenderer、Colliderの オーバーヘッドは少々もったいない気がします。

DebugUiの改良について

今回Raycasterを用意したのは、DebugUiの改良の一環ですので、 これ以降はそれについても書いておきます。

改良点概要

  • CommandBufferによる描画をやめて普通にMeshRendererにした
    • HoloLensでも使用可能
  • Raycasterを自作してUnityEngine.UIに依存しない形にした
  • 右寄せ、中央配置、下寄せ、等の配置機能
  • メニュー(DebugUiMenu)に機能を追加
  • グラフ(DebugUiGraph)の高機能化
  • safeArea対策
  • 大幅なコード整理

最初に述べたように、改造をはじめたきっかけは、 HoloLensで絵が出なかったことです。 原因を詳しく調べたわけではありませんが、 VRやARにおいては二つの目それぞれのために描画が走るため、 カメラごとに一度しか走らないCommandBufferでは何かマズいことがあるのだろうと想像しました。 いろいろと工夫すれば出るのかもしれませんが、 標準のMeshRendererで3D空間中に配置してしまえば他のモデルと同じなので確実に出るはずです。

他の改造は必要になってやったものもあれば、 なんとなく気になったからやっただけのものもあります。 Raycasterの自作化も「Canvas作るの気持ち悪い」 という感情的な理由で、それほど必要性があったわけではありません。

MeshRenderer化

CommandBufferで描画していたのは単なる趣味であり、 Unityで物を描画する時の標準的な手段はMeshRendererです。 趣味は捨てて本来の姿にすることにしました。

また、これに伴って初期化関連を簡略化し、 シーンにGameObjectを置く必要をなくしました。 描画に使うカメラを指定して DebugUiManager.Create()を行うと、 その子のgameObjectが作られて、 DebugUiManager、MeshRendefer、MeshFilter の3つのコンポーネントが自動でつけられます。

void Start()
{
    _debugUi = DebugUiManager.Create(
        _camera,
        _textShader,
        _texturedShader,
        _font,
     referenceScreenWidth: 1136,
     referenceScreenHeight: 640,
     screenPlaneDistance: 100f,
     triangleCapacity: 8192);
}

f:id:hirasho0:20190614160327p:plain

実行時のhierarchyウィンドウを見ると、 Main Cameraの子にDebugUi、さらにその子にDebugUiMesh がありますが、これらはCreate()実行時に作られます。 前もって用意する必要はありません。

なお、MeshRendererを使った描画の本体は DebugPrimitiveRenderer が行っています。 よろしければ動的にメッシュを生成して描画するサンプルとしてお使いください。

配置方法が選べる

要素を加える時に、配置を選べるようにしました。 サンプルではウィンドウは右下に寄せてあります。

_debugUi.Add(_sampleWindow, 0, 0, DebugUi.AlignX.Right, DebugUi.AlignY.Bottom);

中央配置も可能です。実装は、 DebugUiPanel にあります。

メニュー機能

DebugUiMenu を拡充し、階層メニューを表現できるようにしました。

f:id:hirasho0:20190614160330p:plain

最初は上のSubA,1,2の3つだけが出ていて、 SubAを押すと、A1,A2,SubBが現れます。 SubBを押すと、さらにB1,B2が現れます。

_menu = new DebugUiMenu(100, 40);
var subA = new DebugUiSubMenu("SubA", 100, 40, DebugUi.Direction.Down);
subA.AddItem("A1", () => Debug.Log("A1"));
subA.AddItem("A2", () => Debug.Log("A2"));
var subB = new DebugUiSubMenu("SubA", 100, 40, DebugUi.Direction.Down);
subB.AddItem("B1", () => Debug.Log("B1"));
subB.AddItem("B2", () => Debug.Log("B2"));
subA.AddSubMenu(subB, DebugUi.Direction.Right);
_menu.AddSubMenu(subA, DebugUi.Direction.Down);
_menu.AddItem("1", () => Debug.Log("1"));
_menu.AddItem("2", () => Debug.Log("2"));
_debugUi.Add(_menu, 0, 0);

DebugUiSubMenuを作ってDebugUiMenuにAddSubMenuすることで、 階層を深くすることができます。

また、配置の方向も設定できます。 上の例では、根本のメニューはデフォルトの右方向に展開され、 SubA、1、2の順に右に配置されます。 一方SubAは下方向に展開されるので、A1,A2,SubBは縦並び下方向です。 そしてSubBを押して出てくるB1,B2は、右方向にずらした後に、 下方向に配置されます。

グラフ

DebugUiGraph をもう少し使えるレベルに拡充しました。

f:id:hirasho0:20190614160324p:plain

グラフのスケールは自動調整され、下端と上端の値が表示されます。 また、この例では出てきませんが、グラフは複数本表示可能です。 それぞれ色を変えることもできます。

上端と下端の数字が中途半端だと見辛いので、 キリのいい数字に限定する、という改造を加えたいところですが、 ヒマになったらやります。

テキストのサイズ調整を改良

ボタンのテキストはボタンのサイズに応じて自動でサイズ調整していますが、 出来がイマイチでした。

「ボタンの幅を文字数で割ったフォントサイズで描画」という方法なのですが、 結構余ってしまいます。 例えば、幅200で、10文字あれば、フォントサイズは20です。 フォントサイズはおおよそ縦の長さなので、 もし全部がアルファベットだった場合、横幅はかなり狭くなります。 その結果余ってしまうわけです。スペースを有効活用できず、字も読みにくくなります。

今回の改造では、仮のサイズでレイアウト計算を済ませてから幅を測定し、 それによって拡大縮小率を計算するようにしました。

レイアウト計算を二回やらずに済ますために、 仮のサイズのまま頂点配列に詰めてしまい、 拡大率がわかった所で、頂点配列内の頂点に拡大縮小をかけます。 毎フレーム文言が変わっても拡大縮小率が対応して変化するので、いい感じです。 最初にこの「詰めちゃってから拡大する」が浮かばなかったのは悔やしいですね。 実装の中心は DebugPrimitiveRenderer にあり、それを使用する側は、 DebugPrimitiveRenderer2D と、 DebugPrimitiveRenderer3D にあります。

safeArea対策

iPhoneX等で画面端に置いたものが消えていたので、対策しました。 Screen.safeArea を見て、欠けた部分には描画しないようにしています。 デフォルトではsafeAreaを薄い赤で塗って、それとわかるようにしています (冒頭の画面写真をご覧ください)。

内部実装の整理

以前はUI部品系クラスの基底である DebugUiControl というクラスが木構造の操作機能を全て持っていました。 子を足す、子を削除する、子の並びを変更する、といった機能まで 持っていたわけです。

しかし、ほとんどのDebugUiControl派生クラスは子を持ちません。 ボタンやスライダー、テキストなどは子を持たないので、 機能が過剰であり、実際ボタンに子を足す関数を呼べてしまったりもしました。

そこで、子を持つことに関連した機能は DebugUiContainer に分離しました。

終わりに

こういう開発支援機能を作るのが妙に楽しくなってしまうのは、 私だけでしょうか。

クライアント側でhttpサーバを走らせる試みができたことで、 このDebugUiライブラリで定義したUIがブラウザ上にも出る、 という所まで持っていくのも、そう遠いことではなくなりました。

しかし、これが楽しいというのは、実際問題良くないわけです。 「開発環境作ってないでゲーム作れよ!そのためのUnityだろ!」 という内なる声が聴こえます。 でも、仕方ないですよね。標準だけでは足りないのですから。

以前の記事でも書きましたが、 スマホの性能は据置きのゲーム機とそれほど差がない所まで 来ています。どこかの会社が 「家庭用と同じ技術、同じ資金をつっこんで物を作る」 ということをやって、もしそれが売れてしまえば、 たちまちのうちにそれがスマホにおいても標準になるでしょう。

開発規模が家庭用レベルになるならば、 家庭用の開発で整備するのと同レベルの開発支援が必要になるわけで、 ツール類を充実させる必要性は今までよりは増すのではないかと 思うわけです。

それを高速に安く行って、楽しいゲームを楽しく作るためには、 やっぱり他の会社の事例がたくさん見られる状況になると 良いのではないかな、と思います。 どうせデバグ系の機能なんてどこの会社でも作っているでしょうから、 そういう製品に関わらないコードは、 どこの会社もみんなgithubに置いて公開したら良いのではないでしょうか。

私個人としては、製品に直結しないものであれば 「何か作ったら即公開」でやっていきたいと思っております。

補足: LWRPでの利用について

現状、LWRP(LightWeight Render Pipeline)を使ったプロジェクトで使うと、 少なくとも2019.1.3のAndroidビルドで絵が紫になります。 エディタでは正常に動くので、なかなか厄介な不具合です。

フォントテクスチャを扱う関係でシェーダをカスタマイズしており、 それが問題なのだろうと思われます。 そもそも「LRWPで自作シェーダを使うための条件」 がはっきりしておらず、「30分調べたら直せそう」という感じではありません。 現状弊社では急ぎ対応が必要なわけではないのですが、 要望がありましたら調べて対処いたします(それでわかったこともブログにすると思います)。