凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Unity WebGL x WebSocket で複数クライアント間の大量のオブジェクトを簡単に同期できる仕組みを作ってみた

はじめに

まだ実験中なのですが、Unity WebGL x WebSocket で大量のクライアント間で大量のオブジェクトを同期できる仕組みを作っています。UNET や Photon によって近いうちに対応されると思うのですが、現状では使えないのと、制限なく扱えたりチューニング次第で速く出来る可能性があるので作ってみようと思いました。

昨日、技術デモを公開しまして、最大で同時に 30 〜 40 人ほどの方に同時に遊んでもらいました。皆様ありがとうございました!200 オブジェクトくらいならフレーム落ちすること無くサクサク動いたと思うので、現状の仕組みや知見などを備忘録も兼ねて共有できればと思います。

開発環境

デモ

いつ止めるかは未定ですが、以下から遊ぶことが出来ます。

Mac x Chrome だとキーがバグるので、それ以外の組み合わせで遊んでみてください(Firefox 推奨)。

  • WASD: 移動
  • QE: 回転
  • Space: ジャンプ
  • ドラッグ: 視点移動
  • F: 弾発射

動画

@itachin さん、撮影ありがとうございます。

プロジェクト一式

参考程度にご覧ください。

仕組み

まず、以下の2つの要件を考えました。

  • 速い、サクサク
  • オブジェクトの同期がすごい簡単、スケールする

これを実現するために、仕組みを考えたり色々実験してみました。最終的に仕組みの全体像は、煩雑ですが以下の様な形にしました。

f:id:hecomi:20150101174133p:plain

まず、ゲームとしての体感向上のために、マスターがいて、そこで動かした内容を同期するのではなく、自機・自分が生成したオブジェクトは自分のところで動かして、それを他のクライアントと同期する形にしました。

f:id:hecomi:20150101184015p:plain

同期は、上図でいう SynchronizedComponent を通じて行います。これは Send()OnReceive() を通じてメッセージのやり取りをします。例えば位置の同期は具体的には以下の様なスクリプトを書いて、位置を同期したいオブジェクトにアタッチしておくだけ同期されるようにすることでスケールするような仕組みにしました。

using UnityEngine;

public class PositionSync : SynchronizedComponent
{
    private Vector3 to_;
    private bool isFirst_ = true;

    // ローカルで指定した間隔で呼ばれる
    protected override void OnSend()
    {
        // リモートに情報を伝える
        Send(transform.position);
    }

    // リモートのオブジェクトが Send を受信したら呼ばれる
    protected override void OnReceive(Vector3 value)
    {
        if (isFirst_) {
            transform.position = to_ = value;
            isFirst_ = false;
        } else {
            to_ = value;
        }
    }

    // リモートで毎フレーム呼ばれる(isLocal == false)
    protected override void OnRemoteUpdate()
    {
        transform.position += (to_ - transform.position) * easing;
    }

    // ローカルで毎フレーム呼ばれる(isLocal == true)
    protected override void OnLocalUpdate()
    {
    }
}

他にも姿勢やデモ中のプロフィール部分の同期などは以下のコードに成ります。

Send() で登録した変数はいったんプールされます。この時一つのコンポーネントからのメッセージは以下の様な独自のフォーマットに自動で変換されます。

// Command  ComponentID  GameObjectID  PrefabPath  ComponentName  Params  Type
u   a6936e8f-9669-4c0e-b04c-c6df99ff5998    13f76ab3-b871-4201-a8e8-4d7152ad9e79    Prefabs/Network Player  PositionSync    -0.9182832,0.5,-1.529093  vector3

ComponentNameGetType().Name で取得、ID は GUID を生成しておきます。SynchronizedComponent をアタッチすると SynchronizedObject も自動でアタッチされ、GameObject 自体の管理がされます。ここで GameObject の ID、およびどの Prefab から生成したかという PrefabPathResources 下のパス)を EditorScript を通じて自動的に取得しておきます。

f:id:hecomi:20150101220518p:plain

これを自動的に生成される Synchronizer スクリプトを通じて送信します。コンポーネントから Send() で送られてきたメッセージは、前回送ったデータから変化があったらいったんプールして、適当な間隔でサーバに送信されます。

サーバ側では、送られてきたメッセージを全てマージして各クライアントへ配信します。そして、別のクライアントでそのデータを受信したときに、ルックアップテーブルに ID で該当のコンポーネントを問い合わせ、無かったら与えられた PrefabPath の Prefab を Instantiate() し、そこにアタッチされたコンポーネントを登録、サーバから送られてきたパラメタを通知します。これによって複数クライアント間のコンポーネントの同期が可能になり、面倒な生成の手順も省くことが出来ます。また、このようにルックアップテーブルを用意しておくことで ID をキーに要素が取得できるので、Find() したり GetComponent() するコストが削減できます。

後は、OnDestroy() 時に自動的に破棄のメッセージを通知して、全クライアントで該当の GameObject を Destroy() します。突然終了した場合はそれが検知できないので、 HeartBeat() という形でに最新のパラメタを一定時間おきに Send() することにし、しばらく通知が来ない場合は自動的に GameObject を破棄 or 最もアクセス時間の長いクライアントに引き継ぎます。また、これによりしばらく動いてないオブジェクトからメッセージが送られないことで、新しく参加したクライアント上でそのオブジェクトが Instantiate() されないのを防ぐことも出来ます。

後は、サーバ側に幾つかのコンポーネントのパラメタは保存するようにしたいのですが、まだ未実装です。現状のサーバ側(Node.js)のコードは次のようになります。

var ws       = require('ws').Server;
var server   = new ws({ port: 3000 });
var messages = [];
var fps = 30;

server.broadcast = function(data) {
    var self = this;
    this.clients.forEach(function(client, index) {
        var info = ['i', (index === 0 ? 'true' : 'false'), +new Date(), self.clients.length].join('\t');
        var message = info + '\n' + data;
        client.send(message, function(err) {
            if (err) console.error(err);
        });
    });
};

server.on('connection', function(socket) {
    socket.on('message', function(message) {
        messages.push(message.toString());
    });

    setInterval(function() {
        if (messages.length === 0) return;
        isSendToClientsProcessing = true; {
            var currentMessages = messages;
            messages = [];
            if (currentMessages.length > 0) {
                server.broadcast(currentMessages.join('\n'));
            }
        }
    }, 1000 / fps);
});

process.on('uncaughtException', function(e) {
    console.error("Unexpected Exception:", e.stack);
});

知見

JSON はツライ、独自フォーマットが速い

最初はメッセージのやり取りを JSON でやろうと思っていて、SimpleJSON や MiniJSON を使っていたのですが、エンコードとデコードにかなりの時間を費やすためやめました。

その後、@mohammedari さんに MessagePack の Unity 実装を教えていただき、MiniMessagePack を使ってみたのですが、それでもパフォーマンス的に十分ではありませんでした。

そこで、送るメッセージもシンプルなので JSON でなくてもいいやと思い、前述のようなシンプルなフォーマットを作ってみたところパフォーマンスが劇的に改善されて、プロファイラを見ても ToString()Concat()Split() が支配的になっていたので、独自フォーマットで行くことにしました。バイナリにすればもっと速いと思います。結果として実装もシンプルになって良かったです。

WebSockets.unitypackage で受信が上手くいかない

先日紹介した、WebSockets.unitypackage ですが、どうにも受信がうまく動かないようです...。Unity Editor 上ではうまく動くのですが、if ディレクティブで Editor と WebSocket ビルドした時の動作が切り分けらており、DLL 中に実装がされているので諦め、Unity 5 x WebGL について詳しく調べてみた - 凹みTips で行ったようにブラウザと WebSocket 通信のやりとりをする薄いラッパーを書いて解決しました。

Editor 上では普通に WebSockets.unitypackage を利用し、ビルド後はブラウザと通信する形です。ブラウザでは次のようなコードを書いておくことでやりとりできます。

var ws = new WebSocket('ws://127.0.0.1:3000');

window.addEventListener('load', function() {
    var isInitialized = false;
    window.init = function() {
        isInitialized = true;
        SendMessage('Local Player', 'SetMessage', 'Hello!');
    };

    ws.onmessage = function(event) {
        if (isInitialized) {
            SendMessage('Synchronizer', 'PushWebSocketData', event.data);
        }
    };
});

init() は Unity の世界から呼ばれる関数で、準備ができたことを意味します。その後 Unity の中の世界へと送られてきたメッセージを通知します。

Editor 上で遅い

ビルドして動かすとサクサク動くのですが、なぜか Editor 上で通信しながら動かすと遅い...。ここは開発にも影響するので調査して分かり次第報告します。

おわりに

まだ小さなものでしか試してないのでパフォーマンス的にも開発的にもスケールするかは不明ですが、このまま色々作っていきたいです。HTML5 の力を適宜借りながら色々と出来るようにといった実験もしていきたいと思っています。次はゲームとして皆で遊べるような形にしたいです。