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分調べたら直せそう」という感じではありません。 現状弊社では急ぎ対応が必要なわけではないのですが、 要望がありましたら調べて対処いたします(それでわかったこともブログにすると思います)。

スマホ実機でデバグ用のhttpサーバを動かしてみる

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

この記事では、スマホ実機でhttpサーバを動かしてPCその他のブラウザ からアクセスできるようにことで、 開発を効率化できる可能性について考えます。

ただし、今のところ大規模製品への実戦投入はしておりません。 あくまでサンプルとお考えください。

何を作ったか

スマホで動くUnityアプリの中にhttpサーバを実装し、 同じネットワークにいるPCのブラウザからアクセスします。

今回の例では、ファイルのアップローダを用意し、 受信すると同時にアセットを差し変えています。 上の動画では、画像、音声、そして回転速度が書かれたjsonを差し換えています。

動機

簡単に言えば、PC上で何かを変更した時に、 それを最短で手元のスマホに反映させたい、というのが動機です。

前に参加した東京プリズンという製品では、 何らかの変更をスマホの実機に反映させるには 以下のどれかの手順を踏む必要がありました。

  • UnityEditorで変更を加え、githubに置いてjenkinsでビルド、スマホに送って実行
  • 素材をgithubに置き、アセットバンドル生成をjenkinsで走らせ、これを含むサーバを立て、実機からアクセス
  • google spread sheetを書き換え、githubにその変更を取り込んでサーバを立て、実機からアクセス
  • google spread sheetを書き換え、実機がそれを見に行って反映

最初のものはコード及び組み込みの素材、次は絵や音、後ろの2つはパラメータや文言の類です。 前3つはgithubとjenkinsを経由しており、手数が多く時間もかかります。 最後のものは直通なので効率は良いのですが、 限られた場所でしかこのフローにはなっていなかったように思いますし、 もし複数人で同じシートを編集したくなると厄介だったかもしれません。

反復速度と、複数人での並列作業のことだけを言えば、 手元で作れるデータは手元で作り、 実機で動かしていい感じになってからgithubに入れる、 という方が良さそうに思えます。 例えばアーティストが絵を直すのであれば、

  • 直す
  • 保存すると手元のスマホに反映される

というのがおそらく最短です。これを実現するための第一歩として、 まずはアーティストのPCとスマホが直接通信をしてデータを反映できる必要があります。 今回はそこに挑戦してみました。

設計

PCからスマホにデータを送る場合、どちらがサーバをやるかの選択肢があります。

  • PCがサーバをやる
    • スマホ側からポーリング、あるいは、スマホ側で反映操作。
  • スマホがサーバをやる
    • PC側で保存した際にアップロード、スマホ側は受け取ったら反映。

前者は実装が楽です。PCで動くhttpサーバはいくらでもあり、 macならば最初からapache が入っています。 普通のスマホゲームでは元々httpでデータを取ってくる仕組みを使っていますから、 ほとんど追加実装はいりません。更新があった素材をダウンロードして persistentData にでも保存し、すでにメモリにロードしていれば破棄してリロードする、 というだけで終わりです。

しかし、どうにも 「アーティストやゲームデザイナーにPCでhttpサーバを動かす設定をしてもらう」 というところで悪い予感がします。説明の手間やトラブル対応が軽く済むとは思えません。 実装の手間は一回だけですが、運用の手間はずっと続きますから、 「使用者のPCでの準備はゼロ」が理想でしょう。

そこで、クライアント側をサーバとすることにしました。

  • スマホのクライアントが起動するとhttpサーバが動く
  • 画面にIPが出る
  • PCのブラウザにそのIPを入れるとサイトが出る
  • そこでアップロード等の操作をする
  • スマホで反映される

という流れです。PC側では何の準備もいりませんから、 使う人は楽なはずです。 ブラウザ上でアップロード等の操作をしなければならないのが若干手間ですが、 特定のファイルを直しては反映、直しては反映、というフローであれば、 送るファイルの指定は一度で済みます。 「保存する度に送信ボタンを押す」だけなら、まあ許容できるでしょう。

もちろん理想的は「保存すると勝手に送られる」で、 これはphotoshop等のプラグインからスマホにhttpアクセスすれば実現できます。 ただ、使うソフトの数だけ作ることになるので本当に元が取れるかは チームの状況や規模によるかと思います。

実装

Httpサーバ側

C#の標準ライブラリにはHttpListener という便利な機能があります。 これを使ってDebugServerなるクラスを作りました。

public DebugServer(int port)
{
    if (!HttpListener.IsSupported)
    {
        return;
    }
    listener = new HttpListener();
    listener.Prefixes.Add("http://*:" + port + "/");
    ...(省略)...
    listener.Start();
    listener.BeginGetContext(OnRequestArrival, this);
}

クラス名はDebugServer で、コンストラクタでサーバを立てます。 HttpListenerをnewして、Prefixes.Add() を呼んで受け入れるurlパターンを指定し、 Start() して、BeginGetContext() します。 BeginGetContext()にはコールバック(OnRequestArrival)を渡しておき、 アクセスがあるとそれが呼ばれます。 async/awaitを使えばもっと綺麗に書けますが、 古いUnityで使うことも考慮して古い書き方をしておきました。

そして、そのコールバックはこんな感じです。

void OnRequestArrival(IAsyncResult asyncResult)
{
    var context = listener.EndGetContext(asyncResult);
    listener.BeginGetContext(OnRequestArrival, this); // 次の受け取り
    lock (requestContexts)
    {
        requestContexts.Enqueue(context);
    }
}

EndGetContext() を呼んでリクエストの中身が入ったHttpListnerContext なるものをもらい、 また次のリクエストに備えてBeginGetContext()を呼んでおきます。

ここで、このコールバックはどこのスレッドで呼ばれるかわからないので、 ユーザ処理をメインスレッドで実行することを保証すべく、一旦Queueに溜めています。 今回は、毎フレームManualUpdate()という関数が手動で呼ばれる前提とし、 そこでキューに溜まったものを処理するようにしました。 手動で呼ぶのが嫌なら、DebugServerをMonoBehaviour 派生クラスとして、 Updateを実装するのが良いでしょう。 私は極力gameObjectを増やさない方が好きなので、こうしています。

httpサーバのインターフェイス

次に、このDebugServerクラスの使い勝手を見てみます。

public class DebugServer : IDisposable
{
    public delegate void OnRequest(
        out string outputHtml,
        System.Collections.Specialized.NameValueCollection queryString,
        System.IO.Stream bodyData);

    public void RegisterRequestCallback(string path, OnRequest onRequest);
    public static string GetLanIpAddress();
    public DebugServer(int port);
    public void Dispose();
    public void ManualUpdate();
}

publicだけを抜き出したものです。製品ではダミー実装に差し換える、 といったことをするならインターフェイスを設けても良いでしょう。

ポートを指定してコンストラクトしてサーバを立て、 RegisterRequestCallback()でアクセスされるurlごとにコールバックを渡します。 例えばこんな感じです。

debugServer = new DebugServer(port: 8080);
debugServer.RegisterRequestCallback("/", OnWebRequestRoot);
debugServer.RegisterRequestCallback("/api/file/upload", OnWebRequestUploadFile);
debugServer.RegisterRequestCallback("/api/file/delete", OnWebRequestDeleteFile);
debugServer.RegisterRequestCallback("/api/file/delete-all", OnWebRequestDeleteAllFile);

仮にスマホのアドレスが192.168.0.3だった場合、 http://192.168.0.3/にアクセスすれば、OnWebRequestRoot()が呼ばれ、 http://192.168.0.3/api/file/uploadにアクセスすれば、OnWebRequestUploadFile() が呼ばれる、といった具合です。

コールバックは以下の形をしています。

public delegate void OnRequest(
    out string outputHtml,
    NameValueCollection queryString,
    Stream bodyData);

javascript側がサーバに送った情報をqueryString及びbodyDataとして受け取って、 アクセスしてきたブラウザに返すhtmlを返す、というインターフェイスです。 所詮デバグ用なので、極力簡単にすべくこのように定義しました。 高機能化に伴って引数が増えていくかもしれません。

ここまでのお話でわかるように、このDebugServerの層では ファイルの置換、アップロード処理、などの具体的な機能は担当しません。 それは上の層になります。

脱線: outか?戻り値か?

ところで、このコールバックのインターフェイスなのですが、 「戻り値をstringにしてhtmlを返させればいいだろ」 とお考えの人もいるかもしません。 私がこうしたのは、C#では戻り値に名前がつけられないからです。

public delegate string OnRequest(
    NameValueCollection queryString,
    Stream bodyData);

だと、戻り値がhtmlであることの説明がありません。 コメントを書けば良い、という考えもあるかと思いますが、 私はコメントがないと使い方がわからないコードを書いたら負けだと考えています。

これは名前の問題でして、名前がOnRequest()でなくGenerateOutputHtml()であれば 戻り値が何なのかがわかりやすいでしょう。しかしそうすると、 「リクエストがあった時に何かをする関数」という意味合いが薄れて、 htmlを生成する方に重きが置かれてしまいます。 イマイチしっくりくる名前が思いつかなかったので、outで返すことにしました。 私はこういう葛藤によく悩むのですが、皆さんはどうお考えでしょうか。

C#もgoのように 戻り値に名前がつけられれば良かったのかもしれませんね。

htmlファイル

ブラウザからアクセスしたらhtmlを返さないといけませんが、 これは現状アプリ側で用意する想定です。 今回のサンプルでも、htmlファイルをAssetsの下に用意してあります。 ルートディレクトリにアクセスされた時には、これをそのまま返すだけです。

void OnWebRequestRoot(
    out string outputHtml,
    NameValueCollection queryString,
    Stream bodyData)
{
    outputHtml = debugServerIndexHtml;
}

htmlファイルはAssetsの下に置くとTextAssetとして取れるので、 Inspectorでスクリプトにセットし、 ここからテキストを抜いてoutputHtmlにセットします。 もしアクセスされたurlによって異なるhtmlを返す本格的な作りになるならば、 htmlをいくつも書くことになるでしょう。

Javascript側

サーバには、パスとファイルの中身を送信する必要があります。 今回は、パスはquery stringとして送り、 ファイルの中身はmessage bodyとして送ります。

関連コードは以下のような感じです。

var onLoad = function (arrayBuffer) {
    var path = document.getElementById('path').value;
    var request = new XMLHttpRequest();
    request.onload = function () {
        log.value = 'アップロード受理\n';
    };
    request.onerror = function () {
        log.value = 'アップロード失敗\n';
    };
    request.open(
        'PUT',
        document.location.origin + '/api/file/upload?path=' + path,
        true);
    request.send(new Int8Array(arrayBuffer));
};
var onUpload = function () {
    var files = document.getElementById('file').files;
    if (files.length == 0) {
        return;
    }
    var reader = new FileReader();
    reader.onload = function (e) {
        onLoad(e.target.result);
    };
    reader.readAsArrayBuffer(files[0]);
};
document.getElementById('upload').addEventListener('click', onUpload, false);

送信に使う機能はXmlHttpRequest です。 onloadとonerrorを用意した後に、 open() でurlとメソッドを指定します。query stringにはパスを含めるので、

request.open(
    'PUT',
    document.location.origin + '/api/file/upload?path=' + path,
    true);

となります。?path=ほげほげをurlの後ろにつけるわけです。 ここで、サーバ(つまりスマホ)のアドレスはdocument.location.origin で取れます。

メソッドはPUTにしていますが、これは 「何回呼んでも同じ結果になるものはPUT」 という指針があるようなので、それに従いました。

最後に、send()します。

request.send(new Int8Array(arrayBuffer));

引数にはInt8Array のようなArrayBufferView を渡します。

ファイル置換

では、サーバ側、つまりUnity側のファイルのアップロード受け付け処理を見てみましょう。 先程RegisterRequestCallback()で渡したOnWebRequestUploadFile()です。 これはhttp://サーバ名/api/file/uploadにアクセスが来た時に呼ばれます。

void OnWebRequestUploadFile(
    out string outputHtml,
    NameValueCollection queryString,
    Stream bodyData)
{
    outputHtml = null;
    if (bodyData == null)
    {
        outputHtml = "中身が空.";
        return;
    }
    var path = queryString["path"];
    if (string.IsNullOrEmpty(path))
    {
        outputHtml = "アップロードしたファイルのパスが空.";
        return;
    }
    DebugServerUtil.SaveOverride(path, bodyData);
    loadRequested = true;
}

queryStringをpathをキーにして値を取り出せば、そこにパスが入っています。 あとは、DebugServerUtil というファイル保存や置換用の便利クラスを 用意しておいたので、そのSaveOverride()を呼んで保存します。

Application.persistentDataPath の下に書きこむだけです。 ただし、エディタでは同じアプリの複数バージョンを同時に持ちたいので、 プロジェクトフォルダの下に保存しています。

public static string GetPersistentDataPath()
{
    string ret;
#if UNITY_EDITOR // エディタではプロジェクト直下の方が便利
    // GetCurrentDirectoryがプロジェクトパスを返すことに依存している。動作が変われば動かなくなる!
    ret = Path.Combine(Directory.GetCurrentDirectory(), "PersistentData");
#else
    ret = Application.persistentDataPath;
#endif
    return ret;
}

あとは、すでに読んでいるファイルをリロードすれば画面に反映されます。 このサンプルでは画像、音声、jsonの3ファイルを全部ロードしなおしていますが、 実際には変更があったファイルだけをリロードする必要がありますし、 リロードに要するコードを最小にすべく、仕組みを整える必要もあるでしょう。

ロード時の置き換え

今回のサンプルでは、 「StreamingAssetsからロードするファイルは、同名のものがpersistentDataにあれば、 そちらをロードする」という処理をしています。

例えば、StreamingAssets/image/hoge.pngがあった時に、 persistentDataPath/Override/image/hoge.pngが存在していれば、 そちらを代わりにロードします。ファイルの存在チェックをする分だけ ロードが遅くなるので、真面目に実装するなら、 ファイルが存在するかどうかの情報を起動時にDictionaryにしておくのが良いでしょうが、 サンプルではやっていません。

もちろん、製品にする時にはこの機能は無効化し、 また、StreamingAssets以下のファイルを最終版に書き換えるか、 AssetBundle化してゲームサーバに置くことになると思います。

なお、やろうと思えばAssetBundleに関しても同じようにpersistentDataに 置いたものを優先してロードさせるように書くことができます。 標準のDownloadHandlerAssetBundle は使えなくなりますが、 それほどの手間ではないでしょう。 作業する人が自分のPCでAssetBundleを作るフローであれば、 効率化が可能かと思います。

課題と未来

さて今回はほんの手始めでして、これを製品にどう導入して効率化するか? が問題です。 ここでは現状の課題と、今後の使い方について考えてみましょう。

htmlとjavascript書くのが面倒くさい

今回一番面倒くさかったのはhtmlとjavascriptを書く所です。 普段C#で生活しているので、別の言語に頭を切り換えるにはコストがかかります。 まして、javascriptの経験がない人であれば、そもそもやろうとも思わないでしょう。

ですから、C#でコードを書けば、htmlやjavascriptが生成される、というのが理想です。 アップロード用のファイル選択入力、メッセージログ、 テキストボックス、スライダー、ボタン、などのUI要素を C#で指定して、それを元にhtmlを生成する感じです。 所詮開発用ですから美観は無視できるわけで、レイアウト指定みたいな機能を作らなければ それほどの手間にはならないと思います。

実際にはStreamingAssetsにモノを置かない

一定以上の規模のゲームであれば、アプリの本体容量を極力削る必要があり、 StreamingAssetsに物を置くことは滅多にないかと思います。 初回実行時にサーバからダウンロードしてくることでしょう。

そうすると、「元々StremaingAssetsにあったものをロードする時に置き換える」 では使い勝手が良くありません。 サーバからダウンロードする物に関しても同じ置換が働くように拡張するのが良いでしょう。

AssetBundleに入れないものにしか使えない

Unityのゲームは大抵データをAssetBundleに入れます。 しかし、データを作るアーティストやゲームデザイナーが普段Unityで作業をしない のであれば、 AssetBundleのビルドは手元でできません。 手元でできなければ、「手元のPCからスマホに直転送」の意味はなくなってしまいます。

となれば、「元々AssetBundleに入っていたものも置換可能」 な仕掛けにする必要があるでしょう。 例えば、hoge.unity3dに入っているimage.pngを置換するには、 アップロードのパス指定を"hoge.unity3d/image.png"とする、 というようにします。このパスにファイルが存在していれば、 これをAssetBundle内の素材の代わりに用いるわけです。

しかし、実装は結構厄介です。可能かどうかすらわかりません。 AssetBundle内のSpriteが依存しているTextureだけ差し換える、 といったことをどうやるかはまだ考えてもいません。

であるならば「そもそもAssetBundleに入れなくていいものは入れない」 ということも考えられるかとは思います。 テクスチャ圧縮をしなくて良い、と割り切れば、 画像はpngやjpegから直接テクスチャにできますから、 独自にzipに入れておいても読み込むことはできます。

例えばSpineでエフェクトを作っている場合、 Spineから吐かれるものはテクスチャのpngとアニメーション情報が入った独自バイナリです。 これをzipに固めてアップロードするプラグインをspineに用意すれば、 Unityが入っていないマシンでも、 「spineでセーブしたら即座にスマホのゲームに反映される」状態を作れます。

もちろん、アーティストやゲームデザイナーがUnityEditorを使う文化であれば そういった問題はなく、 単にAssetBundleをビルドして送信すれば済みます。 たぶんそれがUnityの本来の使い方でしょう。 ほとんどプログラマだけがUnityEditorを使っている 弊社が特殊な気はします(最近はアーティストでも使う人が増えてきましたが)。 ただし、その場合もボタン一つでAssetBundleをビルドして実機に送信、 といったツールの整備は必要です。 1クリックでも余計な手数を減らすことが、量産効率を上げる上でも、 サポートの手間を減らす上でも重要かと思います。

リロードの実装が案外面倒くさい

リロードの実装は案外面倒です。 もし全ての素材について、アップロードされた瞬間にリロードできるようにしようと思えば、 個別には書かずに済む統一された仕掛けが必要でしょう。

例えば画像の場合、 最終的にUI.ImageなりMaterialなりにセットされて表示されているわけで、 新しくアップロードされた画像がどこのImageやMaterialに差さっているのかを 識別して差し換えに行かねばなりません。 何か素敵な基底クラスを作って、その素敵な基底クラスから派生したクラスを使って キャラ絵や背景画像、アイテム画像を差し換えていれば、 アップロードに対応した置換が起こる、というようなことができると良いのではないかと思います。 実際にどうやってやるかは今後考えますが。

パラメータ調整がgoogle spread sheetなのどうしよう

弊社のフローではアイテムのデータ、ヘルプの文章、敵のデータ、 のようなものはgoogle spread sheet上で設定、調整されます。 そもそもローカルのPCにデータがないので、今回の手法は何も貢献できません。

ローカルのexcelでやっていればcsvを吐いてスマホに 直転送して置換、といったこともできるのですが、 そういうフローの製品がないので貢献しようがないわけです。

小規模なパズルゲームを作る、というような場合であれば、 配置データやパラメータをステージごとにjson化して、 これを書き換えては試す、というフローが良いと思うわけですが、 いわゆるソーシャルゲーム的なものに合うのかはわかりません。 そのあたりは今後ゲームデザイナーと話をしていきたいところです。

なお、小規模なパズルゲームなどであれば、 ブラウザ上で動くステージエディタを作って今回の仕組みに乗せてしまうと良いでしょう。 javascriptのスキルが活きる局面は多いと思います。

妥協

以上のように厄介なことが多数あるので、 少なくとも近い将来における利用に関しては、 以下のような形になるのではないかと思います。

  • ファイル置換に関しては、「ロードし直す場面遷移を経れば置換される」で良しとする。
    • 例えばタイトル画面に戻ってもう一度その画面に行けば置換される、とか。
    • 前の画面に戻る、くらいで済む所も多数あるでしょう。
  • スライダーやテキストボックスでゲームパラメータを調整することはやりやすい。
    • htmlとjavascriptさえ書けば、例えばエフェクトの再生速度、オフセット、などを調整できたり、ゲーム中の敵の強さを調整できたりするUIをブラウザに作ることはできる。技術的な問題は少ない。
  • 単純な情報閲覧だけでも効果はある。
    • 実機上にログが出ているとゲームの邪魔になるが、ログをブラウザに送りつけて広い画面で見られるとうれしい。保存できるとなお良い。
    • FPS情報、メモリ情報、ゲームオブジェクト数情報、ゲームの進捗状況、などをブラウザに表示するのは結構便利。
    • hierarchyとinspectorをブラウザに出して編集できるのではというアイディアも

終わりに

まだまだ課題は多いのですが、 PCとスマホ実機を直接つなぎ、 スマホの狭い画面をブラウザに拡張できる今回の手法は、 いろいろと使いみちがあるのではないかと思っています。

皆さんからのアイディアをお待ちしております!