Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
Help us understand the problem. What is going on with this article?

VRMでブラウザで動く多人数オープンワールドゲームVZeroを作った

VZero ~ Reborn Virtual from Zero. ~

URL:
https://facevtuber.com/vzero/index.html

  • ブラウザで動く
  • VRMで自分のアバターが持ち込める
  • 多人数で入れる
  • ボイスチャット・チャット機能
  • オープンワールド

のゲームを作りました(ゲームといっても、ただ歩き回れるだけですが)。
今回は、このVZeroの作ったコンセプトや、オンライン同期の部分を自作したときのはまったバグや、実際に作った内容について書きたいと思います。

基本コンセプト ~キー入力同期型~

以前、「[CEDEC 2010]ネットゲームの裏で何が起こっているのか。ネットワークエンジニアから見た,ゲームデザインの大原則」という記事を読んでおり、オンラインゲームは大変そうだなぁ。という実感だけはあり、なんかのタイミングで作ってみようか。と思っていました。そこで、たまたまハッカソンに誘われたので、じゃあ作ってみるか。と思って、作ったのがこれになります。基本的には、

  • キー入力を1フレームごとにサーバーへ送る
  • 定期的に位置座標・回転情報を送る

image.png

という、とてもシンプルな実装を考えます。
上記の記事の「バーチャファイター5 Live Arena」の事例で、キー入力をフレーム単位で送り、同期するタイプのオンラインゲームの実装の話が掲載されています。記事では、遅延を少なくする。といった視点で書かれており、そのために必要な複雑な工夫が書かれています。しかし、遅延に目をつむり、キー入力さえ送られていれば、そこそこ同期したものが作れるのでは?という発想に至りました。というわけで、今回のナイーブな実装を試みました。
"このような実装を行った場合、どんな不具合があるのか?どんなバグが起こるのか"を体験するというのが主な狙いでした。
そして、今回は、同期のプロトコルとしてwebsocketを選定しました。これは「ハッカソンで動いて、みんなに体験できるものを提示したい。」という思いがありました。しかし、デモの時間や評価の都合上、審査員にapkやexeの配布をするのは難しいと判断しました。そのため、ブラウザで動くことを目標にしました。そうすると、選択肢として、udpや生のtcpなどは選択できず、webRTCやwebsocketあたりが候補にあがります。その中で、webRTCをUnityのwebGLで動かしているのを見たことがなく、自分としてもサーバー側の経験もありませんでした。そういった経緯から、消去法的ではありますがwebsocketに決まりました。
ちなみに、似たようなプロダクトとしてclusterがありますが、clusterはプロトコルとしてMQTTを選んでいるそうです。

WebsocketとBestHttpの相性問題

今回、Unity側に注力したいためサーバー側の実装はとてもシンプルにしました。

image.png

そのため、図のように、Clientが何かメッセージ(hello)を送る①と、すべてのつながったクライアントにメッセージ(hello)を送り返す②とても簡単なものにしました。

そして、websocketを選定した理由として大きかったのが、クライアントアプリの存在でした。

image.png

いわゆるクライアント・サーバーをどちらも自作する場合、何が原因で動かないかを判定するのか難しい問題があります。一般的なREST APIだと、簡易的にcurlなどでhttpのリクエストを作り、問題の切り分けをすることは容易です。しかし、websocketだとどうするのか?と思っていました。そんな中で、

というOSSを見つけました。これはwebsocket版のcurlともいえるもので、簡単にwebsocketのサーバーへクライアントとして接続出来たり、そもそもwebsocketの簡易サーバーとして動かすこともできます。そのため、自作クライアントが正確なリクエストを送ることができているかの検証も行えます。しかも、websocatに至っては、今回作ろうとしたサーバーはコマンドラインオプションのみで実行可能です。そのため、サーバー側は自作する必要がありません。

しかし、その発想が間違いでした。WebSocketのサーバーに、つながらないのです。

今回、Unity側はBestHttpというアセットを使いました。有料のアセットだけあり、使いやすく、ドキュメントもあるので重宝していました。しかし、今回、websocat等への接続がうまくいきませんでした。
 何がどううまくいかないのか。というと難しいのですが、そもそもコネクションが貼れていない。という感じがします。websocat側には何も表示されず、一方的にBestHttpがwebsocketのopenの時点でUnityのConsoleLogにエラーが出て、動かない。という挙動でした。正直な話、BestHttpのwebsocketの内部実装を修正する。というのはハードルが高すぎるので、色々な実装を試しました。
 websocketsはPythonによる実装で、wsはnode.jsによる実装です。それぞれを試した結果は以下のようになっています。

client \ server websocat wscat websockets ws
websocat
wscat
BestHttp × × ×

BestHttpでつながったのは、wsのみで、その他のサーバーは動きませんでした。しかし、これだけだと、そもそもサーバーの実装がまずいのではないか?という疑念はあります。そこで、websocatやwscatでもつないでみましたが、それらではきちんとつながりました。したがって、BestHttpの実装がクロかなぁ。ということがわかりました。

分裂するアバター

ss.png

他のユーザーが参加する部分を作っていたとき、奇妙なバグに遭遇しました。それは、ユーザー参加時にアバターが複数表示される。というバグです。今回作ったVZeroに関しては、ユーザーの出現と位置の同期は同じ命令になっています。そのため、

  1. 位置の同期命令を受け取る
  2. 現在、画面内にユーザーが居るか?
  3. 居る場合は、キャラクターの位置と回転を同期させる
  4. 居ない場合は、キャラクターをロードし、位置と回転を同期させる

という処理になっています。そのため、基本的に排他制御は必須で、序盤から措置はとっていました。しかし、分裂する。というバグを引きました。
当初、ユーザーの出現は以下のようなコードになっていました。

class CharacterManager {
    private Dictionary<string, GameObject> characters;

    async void sync(SyncCommand syncCmd) {
        if( !characters.ContainsKey(syncCmd.userId) ){
            var vrm = await LoadVRM(syncCmd);
            characters.Add(syncCmd.userId,vrm);
        } else {
            syncState(characters[syncCmd.userId],syncCmd);
        }
    }
}

characters.ContainsKeyでユーザーのモデルが出現済みかどうかを判定しており、一見、問題なさそうなコードに見えます。脳内では以下のようなフローを想定していました。

image.png

しかし、これが問題になった理由は2つあります。1点目は、「同期は3秒ごとに行われる」という仕様と、2点目は「VRMのロードには時間がかかる」ということです。

image.png

ContainsKeyからLoadVRM、Addの流れは、ごく短時間で行われる想定でした。しかし、実際には3秒以上かかっていました。また、「同期は3秒ごとに行われる」という仕様のため、次の同期命令がAddより前に追い付いてしまう。という現象が発生しました。その場合、もちろんcharactersにデータは入っていないため、そのまま出現の処理が走ってしまい、2体目のアバターが出現してしまう。というバグが発生しました。そこで、実装を修正しました。

class CharacterManager {
    private HashSet<string> exists;
    private Dictionary<string, GameObject> characters;

    async void sync(SyncCommand syncCmd) {
        if( !exists.Add(syncCmd.userId) ){
            var vrm = await LoadVRM(syncCmd);
            characters.Add(syncCmd.userId,vrm);
        } else {
            if( !characters.ContainsKey(syncCmd.userId) ) {
                syncState(characters[syncCmd.userId],syncCmd);
            }
        }
    }
}

多分C#っぽい書き方ではないのですが、このような形で実装しました。HashSetのAddは、引数に該当するデータがある場合はfalseを返し、データがない場合はtrueを返す仕様です。HashSetのAddをいわゆるCASのように使い、1アクションで排他制御ができるようにしています。このようにロックをするために必要な時間を短くすることで、アバターの分裂を防いでいます。
しかし、これには副作用もあり、syncStateの前に、ContainsKeyを呼ぶ必要があります。これは、先ほどの解説と同じで、VRMをロードしている間に、同期命令が飛んでくる場合があります。今回は、HashSetのおかげで分裂はしませんが、アバターの準備が整っていないのに、位置を同期をしようとする問題が発生します。そのため、ContainsKeyでアバターの情報を確認し、準備が整っていない場合は無視する。というルーチンにしています。

位置がズレる

image.png

 最初から想定はしていましたが、他人のキャラクターの位置がズレる。ということが発生しました。図にあるように、キー入力だけを送っている場合、間にあるパケットが消失して、うまく動かない。ということは考えられます。こういう現象はVRChatや他のオンライン系のゲームでも発生していて、歩き出して、止まったと思ったら、突然、別の位置にワープする。ということはよくあるように思います。しかし、ちょっと今回は事情が違います。
 今回、プロトコルとしてWebSocketを使っています。これはTCPの上にある実装なので、必ず相手までパケットが到達するハズです。他のオンラインゲームであれば、採用されているプロトコルがUDPベースなので、パケットの到達順序や、そもそも到達保証がないので位置がズレることは想像に難くありません。しかし、今回はTCPベースでも、このようなズレが起こり、しかも、割と大きい。ということが起こりました。
 これはおそらく実装に問題があると思います。私は以下のように実装しています。

class CharacterController {
    private bool isForward = false;

    public void forward(){
        isForward = true;
    }

    public void Update() {
        if(isForward){
            //前進処理
        }

        isForward = false;
    }
}

想定では以下の画像のようなフローを考えていました。サーバーから、前進の命令(Forward)を受けたとき、CharacterControllerにあるforwardを呼ぶことで、そのアバターの前進のフラグを立てておき、それをUpdateで拾い上げ、処理したのち、前進のフラグを下す。ということを行っていました。
image.png

この実装は、実はサーバーがローカル環境にある場合は、そこまで問題になりませんでした。しかし、実際のクラウド環境にサーバーを置くと、誤差がひどくなる。という現象が起こりました。これは、"おそらく"、以下のような現象が起こっているように思います。

image.png

ネットワークの遅延により命令が遅延し、別々のフレームに到達してほしい命令が、同一のフレームにまとまって到達することで、一部の命令を消失させている気がしています。基本的に、ローカル環境は性質が良く、ネットワークの遅延等もそれほど大きくありません。ネットワークはなぜ遅延が生じるのかに書かれていますが、もしサーバーが東京に置かれて、クライアントが大阪にいるならば5ms程度の遅延は必須になります。これは、1フレームの1/3程度です。それに、自宅のルータの性能や、ISPのネットワーク機器の性能、ネットワークの混雑の具合によっても遅延は発生します。そうすると、1フレーム1パケットで遅延を想定しない。という仕様はかなり無理のあるものだということがわかります。そういった遅延が如実に表れ、今回のような大幅なずれが発生したように思います。
 これは流石に実装が雑だろう。と思われるかもしれませんが、実際、雑な実装をすると、どれくらいダメか?というのは、よくわからないものです。あと、真面目にバッファリングや遅延などを考慮すると割と実装はめんどくさいと思います。今回は、ハッカソンで出したものなので、雑実装で動けばラッキー。とりあえずやってみた。というぐらいのものです。
当初、同期は3秒に一度でしたが、最終的には1秒間に1回、位置・回転の同期のための情報を送っています。

image.png

おそらくVRChatなども似たような感じで、定期で情報を送ることで位置ずれを補正しているのではないかなぁ。と思います。そのため、ドラゴンボールの武空術のようなワープが発生しているのではないか。と思います。

VRMのセキュリティ

最近は一時期ほどは聞くことも少なくなってきましたが、VRChatなどでのアバターのコピー(ぶっこぬき)が問題になっていた時期がありました。VZeroはブラウザで動いており、アバターフォーマットとしてVRMを使っています。そのため、簡単な実装をしていると、ブラウザのdevToolなどで通信内容を見て、アバターの情報を取り出すことが出来るようになります。そのため、今回、VZeroでは、アバターデータの暗号化を施しています。そのため、devToolで見るぐらいであれば、アバターのデータのコピーは出来ないような仕掛けにしています。そのようにしてアバターデータの防護策も施行しています。もし、コピー方法がわかってしまった場合、Qiitaに記事を書くのではなく、私のTwitterのDMへ送ってもらい、一緒に安全な方法を考えていただけたら幸いです。

エンジン部分の工数感

大体、3日ぐらいでした。

1日目:

  • WASDでのキャラクターの移動(オフライン)
  • Websocketサーバーの自作
  • BestHttpによるWebsocketのサーバー接続の試行

2日目

  • BestHttpでのWebsocketへのメッセージ送信・受信
  • WebGLビルドでのBestHttpでのWebsocketの動作の確認
  • Websocketのサーバーを外部ドメインに置いた時の動作確認
  • WebGLビルドを外部ドメインに置いた時の動作確認

3日目:

  • メッセージ受信とキャラクターの移動のつなぎ込み
  • Windowsビルドでの動作確認
  • WebGLビルドでの動作確認

という感じでした。経験上、UnityのWebGLビルドは時間がかかる割にハマる。という辛いことはわかっていたので、色々と動かない可能性を序盤に楽につぶせたのは良かったです(しかし、BestHttpのWebsocketの相性問題には参った)。こういうクライアント・サーバーの両方を自作する場合は、デバッグが困難になることが多いのですが、websocat等のツールを知ってることで、割と楽にデバッグができて良かったです。これらのツールがいいところは、メッセージのフォーマットを知っていれば、適当なテキストをwebsocketに流すことで、クライアントを作らずに動作のチェックができることです。また、サーバーも全クライアントにメッセージを流す実装なので、ゲーム側が正確にメッセージを流せているかが一目瞭然なことです。この辺りが、開発効率が良かったなぁ。と思うことでした。
 現在のオンライン同期のエンジン部分は、800~1,000行なので、最小で動く範囲だと500行くらいだと思います。今回は、WebGLで動くか?といったことだったり、ドメインがlocalhost以外で動くか?という検証をやっているため時間はかかっていますが、こだわらなければ1日あれば割と動く範囲まで作れると思います。

感想

エンジニアあるあるかもしれませんが、

理屈上は出来るのは分かる。しかし、実際作ってみないと、どんなバグが起こるかわからないし、作ったことないから分からない。

みたいなことってよくあると思います。「他人が動かしてるキャラクターの入力を何らかのサーバー経由して、受信して動かしたらネトゲって作れるよね?」と言われると、確かに"理屈上は"動くよね。じゃあ、作ったことはある?いやない。みたいな。オンラインの同期はそういう類のもので、サーバーも用意しなくちゃいけないから、Unityエンジニアからはハードルが高いし、サーバーエンジニアからすると、そもそもUnityがハードルが高い。みたいな。どっちがやるか押し付け合いになりそうな話ではあると思います。そういうわけで、なんかやりたくはあるけど、色々勉強すること多いし、まぁ、今やんなくていいよね?とか適当な理由をつけてやらない内容だなぁと。しかし、"こだわらず、完成度を求めず"技術的試行の範囲でゆるくやってしまえば、そこそこ動くものなのだなぁと感じました。
 今回、Unityでまともなプロダクトっぽいものを作るのは初めてでしたが、本当に"何も作らなくていい"感じはしますね。それこそ、昔はDXライブラリや、SDL、pygameなんかも触ってましたが、そこからすると雲泥の差です。初期化書いてスプライト表示するまで大分長かったですが、今では、GUIでポンポンポンで出来てしまう。大規模開発だったり、多人数開発、リソースのマネジメントとかを考え始めると、おそらく今でも大変でしょうが、個人がちょこちょこ作る分にはUnityは楽だなぁ。と思いました。
 あとVRChatだったり、VTuberからUnityを触ると、かなり亜流な入門になってしまい、Unityの良さ。みたいなものは、かなり感じにくかったんだな。と痛感しました。私自身、ゲームっぽいものを作るのは10年ぶりぐらいでしたが、なるほど。これが本来のUnityなのか。と感じました。しかし、やはり作り方はスクリプトに寄ってしまうので、その辺はうまく作れるようにならないと。
 最近、FallGuysが流行ってますが、自分でオンラインゲームを作ってみると、マジで仕組どうなってんだ。とか思うようになりました。普段は、漫然と、「60人が同時でやるくにおくんの運動会みたいなゲームだなー」とか思ってましたが、作った後だと、「これシステム辛そう。」みたいな視点になります。それとか、「[CEDEC 2010]ネットゲームの裏で何が起こっているのか。ネットワークエンジニアから見た,ゲームデザインの大原則」も、ふーん。技術的に面白いなー。とか思って読んでいましたが、作った後だと、あーなるほど。そう作ればよかったのかー。と腹落ちする部分が増えてきたりしてきます。これは、ちょっと新鮮な感覚でした。
 そういうわけで、「やはり技術ってのは広いなぁ。」と改めて実感した、オンラインゲーム製作でした。

kotauchisunsun
FaceVTuberの作者.VTuberの講演も行っている. 基本的には,色々やってるPythonista.TypeScriptやクリーンアーキテクチャにご執心. 趣味レベルの事業開発・サービス開発も少々.
https://www.pixiv.net/fanbox/creator/12173373
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away