🐰

ブラウザベースのメタバースを作る4

に公開

前回までのあらすじ

この記事シリーズではブラウザベースのメタバースを作っていきます。
前回はメタバース実装の準備として、WebRTCによるP2Pチャットを試作しました。

記載するコードの動かし方

第1回をまだ見てない人は、第1回の最後にある環境構築の項目を呼んで環境を構築してください。
https://zenn.dev/velserm/articles/localverse_0001
環境構築したディレクトリを「配置先ディレクトリ」とします。
その後、ターミナルを開いて以下のコマンドを入力してhttpサーバを起動します。

cd 配置先ディレクトリ
node httpd.js

次に、もう1窓ターミナルを開いて以下のコマンドを入力してシグナリングサーバを起動します。

cd 配置先ディレクトリ
node wss_signaling.js

配置先ディレクトリを基準として、ファイルの種類によって以下のディレクトリに配置します。

ファイル種類 配置先
html web/
js web/
vrm web/asset/vrm/
fbx web/asset/animation/
glb web/asset/model/
ssl証明書 ssl/
その他設定ファイル config/

今回は、チャットサーバ及びシグナリングサーバを作成するので、httpdサーバ以外にこれらもバックエンドで実行することになります。

これらもjavascriptで記述してNode.jsで実行します。

ロードマップ

  1. 環境構築
  2. three-vrmのサンプルを基にしてプレイヤー操作でアバターを動かせるようにする
  3. (前回)WebRTCによるチャットを実装する
  4. (今回)WebRTCによるdom要素の位置同期と画像同期を実現する
  5. 他のプレイヤーのVRMアバターを表示して同期する

今回やりたいこと

最終的に、WebRTCのp2p接続によるVRMアバター同期をしたいわけですが、その前の段階としてdomを同期対象として、位置や背景画像を同期対象として更新する試作を行います。

こんな感じになります。
https://velserm.com/movies/localverse/localverse-003.mp4
箱の中の画像をアバターに見立てています。

前回でシグナリングサーバは完成したので、今後はクライアントの試作だけになります。

位置同期

前回試作したp2pチャットのサンプルを改造してdom要素の箱を表示して、キー操作で上下左右に移動するものを試作します。
以下の仕様とします。

  1. aswdで上下左右に移動します。
  2. チャット入力欄にフォーカスがある場合は移動しません。
  3. 初期位置は画面中央にあるものとします。
  4. 識別のために箱の上に名前を表示します。
  5. 発言時は箱の横に発言内容を一定時間表示します。

これを実現するために、次の実装が必要になります。

処理条件 処理
ログイン時 自分のアバターを表示する。既存のメンバーのアバターを表示する。
他のプレイヤーが参加 参加したプレイヤーのアバターを表示
他のプレイヤーが離脱 離脱したプレイヤーのアバターを削除
自分が移動入力 他のプレイヤーに移動通知
移動通知受信 送信したプレイヤーのアバターを移動
自分がチャット入力 自分のアバターの横に発言表示。他のプレイヤーにチャット通知
チャット通知受信 送信者のアバターの横に発言表示。

移動通知は、連続的に発生するのでチャットとは別にtransformという名前でデータチャネルを追加することにします。
前回の実装でchannels_defにチャネルの定義を書いているのでそこを拡張します。

移動入力はキーイベントでハンドリングしますが、その時点では速度だけ更新し、フレーム処理で経過時間と速度を掛けて位置を更新することにします。
通知は位置の変化が発生した場合に行うことにします。
ただその場合、あとから参加したプレイヤーに現在位置が届かないので、最短インターバルを設定して移動してない場合でも一定間隔で通知することにします。

実装例

https://github.com/frakiaongithub/localverse/blob/main/web/p2p_syncbox.html

https://localhost:10443/p2p_syncbox.html を2窓で開きます。

ログイン後、青い箱が表示されることを確認します。
箱の上に名前が表示されていることを確認します。
ASWDでの移動が行われることを確認します。
チャット入力で箱の横に表示されることを確認します。
移動とチャットが他のプレイヤーの画面に反映されることを確認します。

アバター同期

位置の同期が出来たので、次にアバターの同期を考えます。
プレイヤーが独自のアバターファイルを設定したとき、他のプレイヤーの画面にも反映するためには、他のプレイヤーのクライアントにもそのアバターファイルが必要です。
つまりアバターファイルを転送する必要があります。
そこでまずはファイル転送について考えます。

ファイル転送

ファイルを転送するのも、基本的にはチャットと同じです。
チャットテキストの代わりに、ファイルの内容を送信するだけです。
ただし、WebRTCのデータチャネルで一度に送信できるサイズは64kbまでです。
一般的にVRMは軽量モデルでも数MBのサイズがあります。
そこで64kbよりも小さいサイズに分割して送信する必要があります。
送信に関しては、単に分割して送信可能になるまで待って送信すればいいのですが問題は受信です。
データの受信はmessageイベントで処理する必要があります。
分割送信されている場合、複数回のイベントで取得した受信データを全体がそろった時点で1つのファイルに結合する必要があります。
これを解決するアプローチはいくつかあります。
大別すると

  1. データチャネル1個で解決する
    分割データの先頭にファイル名、分割数、分割インデックス、サイズの情報を入れる。
    受信側ではその情報を使って、全体がそろったかどうかを判断する。
    全体がそろった時点で転送後の処理を実行する。

  2. 制御用と転送用でチャネルを分ける
    送信側ではファイル名とサイズと転送用チャネル名を制御用チャネルで送信する。
    受信側ではそのチャネル名で転送用チャネルを作り待ち受ける。
    送信側は、datachannelイベントで転送用チャネル名のチャネルが生成されたときに送信を開始する。
    受信側ではファイルサイズ分受信した時点で受信完了と判定して転送後の処理を実行する。
    この場合転送用チャネルは使い捨てです。

ぱっと見では1の方が単純そうですが、
1は受信毎にヘッダを読み込んで処理を分岐する必要があるので意外と複雑です。
また複数ファイルの転送中にエラーが出た場合に全部巻き添えになる可能性があります。
2はデータチャネル作成後の送受信部分に関してファイル毎に独立しているので巻き添えの可能性はありません。
実装の手間は大差なさそうなので2を採用することにします。
ただチャネルを使い捨てるやり方が負荷面で正解なのかは不明です。

まずは簡単なテストとしてファイルを転送する試作を行います。
こんな仕様にします。

  1. 画面にファイルをドロップしたら、現在の発信対象にファイルを転送する。
  2. 受信した側は、樹脂完了時にチャットログにダウンロードリンクを表示する。

具体的に処理の流れを詰めていきます。

ユーザーがファイルをドロップしたとき、送信側クライアントは、一意な転送データチャネル名を決定し、それをキーとして送信用連想配列に以下の値を記録します。

  1. ファイル名
  2. ファイルサイズ
  3. ファイルデータ

そして制御用データチャネルで次の値を持つメッセージを受信側に送ります。

  1. ファイル名
  2. ファイルサイズ
  3. 転送デーチャネル名

受信側はこのメッセージを受けて、createDataChannelで転送データチャネルを作成し、onmessageに受信時の処理を設定します。
この処理では、ファイルサイズ分のデータを受信した時点で、データを結合してファイルデータを復元します。
そして、dataURLの形に変換してチャットログにリンクを表示してダウンロードできるようにします。

送信側はondatachannelで転送データチャネル名のデータチャネルが生成された場合、onopenで送信用連想配列の値に基づいて送信を開始します。
データチャネルの送信待ちデータの状態を見て送信可能になったタイミングで分割して送信します。

注意点としてこれまではcreateDataChannelを実行するのはofferだけでしたが、転送用データチャネルに関してはoffer/answerいずれの側も実行する可能性があります。
つまりoffer側もondatachannelに対応する必要があります。

実装例

https://github.com/frakiaongithub/localverse/blob/main/web/p2p_transfile.html

https://localhost:10443/p2p_transfile.html を2窓で開きます。

p2p_chatと同じ動作が出来ているか確認します。

ファイルをドロップして別窓側でダウンロードできるか確認します。

アバター反映

ドロップしたファイルが画像だった場合に、アバターとしてその画像を表示するようにしてみます。
受信時の動作が違うので、ファイル転送とは別のメッセージにします。

送信は2つのタイミングで発生します。

  1. 画像を設定したとき
    これについては、部屋にいる全員にアバター転送要求を行います。
  2. 新しいプレイヤーが参加したとき
    1だけでは新しいプレイヤーのクライアントには反映されません
    そこで制御用データチャネルがopenした時点でアバター転送要求を行います。

またリロード時や部屋移動時に毎回アバター設定をするのは煩雑なので、リロードしてもアバターが維持されるようにします。
認証トークンの保存はsessionStorageにしましたが上限容量の関係でアバターには適しません。
ブラウザで大きいデータを保存する場合はindexedDBを使うことになります。

sessionStorageやlocalStorageと違い、indexedDBは同期的な実行が出来ず値の取得はコールバックになります。
これはさすがに面倒なのでPromiseを使ってawaitで同期的に操作できるラッパーを作ります。

実装例

class DB{
  constructor(db_name,storenames){
    this.db_name = db_name;
    this.storenames = storenames;
    this.db = null;
    this.stores = {};
  }
  
  
  _opendb(){
    const that =this;
    return new Promise((resolve,reject) => {
      try {
        const request = window.indexedDB.open(that.db_name, 3);
        request.onupgradeneeded = (event) => {
          const db = event.target.result;
          for(const storename of that.storenames){
            if (!db.objectStoreNames.contains(storename)) {
              db.createObjectStore(storename, { keyPath: 'id' });
            }
          }
        };
        request.onsuccess = (event) => {
          const db = event.target.result;
          resolve(db);
        };
        request.onerror = (event) => {
          console.log(event.target.error);
          reject(null);
        };
      } catch (error) {
        console.log(error);
        reject(error);
      }
    });
  }
  
  async openDatabase(){
    //setData/getDataを使う前にこれをawaitで実行する
    this.db = await this._opendb();
  }
  closeDatabase(){
    if(this.db){
      this.db.close();
      this.db = null;
    }
  }
  removeDatabase(){
    //何故か開発ツールでdbごと消す機能がないので実装
    //アンインストール的なことをする場合に呼ぶ想定
    const that = this;
    return new Promise((resolve,reject) => {
      that.closeDatabase();//openしている場合閉じないと削除できない。
      const req= window.indexedDB.deleteDatabase(that.db_name);
      req.onsuccess = (event) => {
        resolve(true);
        console.log("データベース["+that.db_name+"]が正常に削除されました。");
      };
      req.onerror  = (e) =>{
        reject(false);
        console.log("データベース["+that.db_name+"]の削除に失敗しました。");
      };
    });
  }
  
  getStore(storename){
    if(storename in this.stores){
      return this.stores[storename];
    }else{
      const transaction =  this.db.transaction([storename], 'readwrite');
      return transaction.objectStore(storename);
    }
  }

  getAllData(storename){
    const that = this;
    const store = this.getStore(storename);
    return new Promise((resolve,reject) => {
      try{
        const request = store.getAll();
        request.onsuccess = (event) => {
          if (event.target) {
            const result = event.target.result;
            if(result){
              resolve(result);
            }else{
              resolve(null);
            }
          }
        };
      }catch(e){
        console.log(e);
        reject(null);
      }
    });
  }
  
  getData(storename,key){
    const that = this;
    const store = this.getStore(storename);
    return new Promise((resolve,reject) => {
      try{
        const request = store.get(key);
        request.onsuccess = (event) => {
          if (event.target) {
            const result = event.target.result;
            if(result){
              resolve(result.data);
            }else{
              resolve(null);
            }
          }
        };
      }catch(e){
        console.log(e);
        reject(null);
      }
    });
  }
  
  setData(storename,key,value){
    const that = this;
    const store = this.getStore(storename);
    return new Promise((resolve,reject) => {
      try{
        const request = store.put({id:key,data:value});
        request.onsuccess = (event) => {
          resolve(true);
        };
      }catch(e){
        console.log(e);
        reject(false);
      }
    });
  }
}

使用例

const db = new DB("testdb",["teststore"]);
await db.openDatabase();
const result = await db.setData("teststore","key1","aaaaa");
const stored_data = await db.getData("teststore","key1");
console.log("stored_data",stored_data);

上の例では文字列を保存していますが、indexedDBはArrayBuffer等のオブジェクトをそのまま保存可能です。
これにより、ドロップしたファイルデータをエンコードせずにそのまま保存することが出来ます。

これを利用して、自分のアバター作成時にindexedDB上にアバターが保存されている場合はそれを使用し、
transportが送信可能になった時点でもしアバターが設定されていれば、送信処理を行うようにします。

実装例

https://github.com/frakiaongithub/localverse/blob/main/web/p2p_syncpic.html

https://localhost:10443/p2p_syncpic.html を2窓で開きます。

ログイン後、箱が表示されることを確認します。
箱の上に名前が表示されていることを確認します。
ASWDでの移動が行われることを確認します。
チャット入力で箱の横に表示されることを確認します。
移動とチャットが他のプレイヤーの画面に反映されることを確認します。
画像(32x32くらい推奨)をドロップして箱の位置に表示されることを確認します。
設定した画像が他のプレイヤーの画面にも反映されることを確認します。

次回予告

  1. 他のプレイヤーのVRMアバターを表示して同期する

今回通信処理は完成したので2回目で作ったVRMアバターの処理と統合します。

Discussion