ブラウザベースのメタバースを作る2
前回までのあらすじ
この記事シリーズではブラウザベースのメタバースを作っていきます。
前回は第1回として、企画の趣旨と環境構築について書きました。
今回はメタバース実装の準備として、ブラウザ上でVRMを表示してキーボード操作で歩かせるコードを試作します。
記載するコードの動かし方
第1回をまだ見てない人は、第1回の最後にある環境構築の項目を呼んで環境を構築してください。
その後、ターミナルを開いて以下のコマンドを入力してhttpサーバを起動します。
cd 配置先ディレクトリ
node httpd.js
配置先ディレクトリを基準として、ファイルの種類によって以下のディレクトリに配置します。
ファイル種類 | 配置先 |
---|---|
html | web/ |
js | web/ |
vrm | web/asset/vrm/ |
fbx | web/asset/animation/ |
glb | web/asset/model/ |
ssl証明書 | ssl/ |
その他設定ファイル | config/ |
ロードマップ
- (前回)環境構築
- (今回)three-vrmのサンプルを基にしてプレイヤー操作でアバターを動かせるようにする
- WebRTCによるチャットを実装する
- WebRTCによるdom要素の位置同期と画像同期を実現する。
- 他のプレイヤーのVRMアバターを表示して同期する
今回やりたいこと
最終的にメタバースで自分のアバターを表示して操作する処理の原型になるものを作ります。
具体的には、このへんの動作ができるものを作ります。w
- VRMアバターの表示
- ユーザー操作による移動とカメラ制御
- ユーザーが用意したVRMファイルでのアバター変更
- ユーザーが用意したモーションデータによるエモート
使うライブラリ
three-vrm
人型アバターを完全に自力実装で表示するのはかなり難しいですが、幸いなことにブラウザ上でVRMを表示して動かすことのできるライブラリthree-vrmが存在しています。
VRMは人型アバターのファイル形式として標準的なものです。
このライブラリを公開しているpixivは、VRMを簡単に作成できるデザインソフトVRoidStudioやアバターをネットに公開できるサービスVRoidHubも出しているので互換性の問題をほとんど考えなくてすみます。
また、このライブラリは、ソースが公開されており、外部サービスに依存していません。
上記の理由でこの記事シリーズではアバター表示にthree-vrmを使っていきます。
Three.js
three-vrmはThree.js用のライブラリとして実装されています。
Three.jsはブラウザの3Dグラフィック機能を扱いやすくするためのライブラリです。
15年以上の歴史があり、日本語の資料も豊富です。
「Three.js 入門」とかで検索すれば初心者向けのサイトも多く見つかると思いますが、基本的なことについて軽く触れます。
Three.jsで3Dグラフィックを表示する場合、その構成要素は次のものになります。
- ページ上のdom要素に3Dグラフィックを描画するRenderer
- 視点と投影面を定義するCamera
- 空間に存在する物体を定義するObject3D
ブラウザ上で見える表示はCameraから見たObject3Dを投影面に映したものをRendererがdom要素に描画することで生成されています。
人間の体で例えると、
- Object3D = 外界にある物体
- Cameraの視点=眼球の位置と向き
- Cameraの投影面 = 網膜
という感じです。
現実世界では網膜に投影されているのは外界から眼球に入る光そのものですが、Three.jsにおいてはRendererがオブジェクトを構成する面の頂点の投影面への射影位置を計算して面の前後関係を判定して色を塗っています。
実際に作る
今後の工程で、Three.jsの基本的な知識が必要になるので、今回は簡単な例から段階を踏んで行きます。
各段階の動作を動画にしたもの。
箱を表示する
上で書いた流れでいうとこの場合
- Rendererを作る。
- Cameraを作る。
- 箱を作る。
- Rendererで表示する。
という記述が必要になります。
前回構築した環境でhttpサーバを起動して、以下のファイルをwebディレクトリに作成します。
ファイル保存後に、ブラウザで https://localhost:10443/show_box.html を開きます。
問題なければ斜め上から見た立方体が表示されるはずです。
表示されてない場合は前回作成したhttpサーバを起動しているか確認してください。
箱を動かす
今回の記事では最終的には画面内でアバターを動かす必要がありますがその準備段階として箱を動かしてみたいとします。
単純に考えると、boxの位置をタイマーで一定間隔で更新すれば動きそうに見えます。
例えばこんなコードが思いつきます。
setInterval(()=>{
box.rotation.y = 2*Math.PI * (Date.now()/1000)*0.5;//毎秒0.5回転
},100);
これをrenderer.render(scene, camera);の下に入れたとします。
試してみればわかる通りこれではピクリとも動きません。
また開発ツールを見てもエラーは出ていません。
これは何故かというと、オブジェクトの位置を更新しても描画が実行されるわけではないからです。
つまり上の修正を表示に反映するには次のように書く必要があります。
setInterval(()=>{
box.rotation.y = 2*Math.PI * (Date.now()/1000)*0.5;//毎秒0.5回転
renderer.render(scene, camera);
},100);
これで動作はしますがだいぶがくついています。
負荷に応じて可能な範囲でなめらかに動かしたい場合は次にようなコードになります。
const clock = new THREE.Clock();//
const tick = (dt) =>{
//箱回し関数
//dtは経過時間(秒)
box.rotation.y = Math.PI * dt * 2*0.5;//毎秒0.5回転を経過時間分回す
};
const animation_loop = () =>{
const dt = clock.getDelta();//前のフレームからの経過時間
tick(dt);//オブジェクトの更新
renderer.render(scene, camera); // レンダリング
requestAnimationFrame(animation_loop);//次フレームの予約
};
requestAnimationFrame(animation_loop);
このコードで重要なことは次の点です。
- オブジェクトの移動はrenderメソッドを実行するまでは表示に反映されない。
- オブジェクト更新関数では、速度と経過時間をかけた変位を現在の状態に加算して更新を行う。
three-vrmの公式サンプルコードを読む
three-vrmの基本的な使い方は公式に書かれていますがトップページにあるのはコードの抜粋なので依存ファイル等が書かれていません。
最終的に必要なのは、「アバターの表示」「アバターの差し替え」「モーションの再生」の3つなのでこれら全てが含まれているhumanoidAnimationのサンプルを参考にします。
簡単な説明をしておくと、このサンプルページでは、
- VRMファイルをドロップすると表示されているアバターが差し変わります。
- mixamoで作ったモーションのfbxファイルをドロップするとそのモーションが再生されます。
このサンプルのソースは公式gitに公開されています。
ここを見ると以下の4個のファイルで構成されていることがわかります。
index.html
loadMixamoAnimation.js
main.js
mixamoVRMRigMap.js
開発ツールでネットワークログを見るとそのほかに
を参照しています。これが初期表示されているアバターでしょう。4つのファイルを見てみます。
index.htmlには実装はなくmain.jsを読み込んでいるだけです。
つまり、実装はmain.jsで補助ライブラリとして残りの2個のjsを使っていると予想できます。
loadMixamoAnimation.js は名前からmixamoで作ったモーション用fbxを読み込むコードと予想できます。
mixamoVRMRigMap.js については中身を見てみると連想配列が定義されています。
VRMのボーン定義を見るとこの連想配列の値が入っています。
mixamoVRMRigMap.js の連想配列のキーの方はmixamoというプレフィックスが付いているので、mixamoのモーションデータをVRM用に変換するのに使うもののようです。
main.jsを読んでみましょう。
Three.jsの初期化に関しては上で書いた箱を表示するサンプルと同じようなことをしていますが、それと別にいくつか関数が定義されています。
これらを読んでいきます。
- loadVRM
指定したurlからVRMを読み込んでいます。
ここを参考にすればアバターを読み込む処理を実装できそうです。
注意点があって、この関数では読み込んだVRMをmain.jsのスコープで定義した変数 currentVrm に代入しています。
つまり1つしか対応しません。
自分だけ表示する場合はともかく他のプレイヤーを表示する場合はそのままでは使えません。 - loadFBX
指定したurlからloadMixamoAnimationを使ってAnimationClipを生成しています。
currentVrmに対してTHREE.AnimationMixerを生成してcurrentMixerに代入し、生成したAnimationClipを再生させています。
ここも1と同様に1つしか対応しません。 - animate
上で書いた箱表示でも定義したような描画更新関数です。
注目するべき点は、currentVrmとcurrentMixerに対してupdateメソッドを実行している点です。
本来描画するだけならrendererのrenderメソッドだけで問題ないはずなので気になります。
currentVrm.updateを無効化したらVRMが表示されなかったり、currentMixer.updateを無効化したらモーション再生されなくなるのではないかと推測されます。 - dropイベントハンドラ
dropされたファイルの内容からdataurlを作成して、vrmの場合はloadVRM、fbxの場合はloadFBXを呼び出しています。
three-vrmの公式サンプルをいじって仮説を検証する
コードを見るだけでは、挙動がわかりにくいのでサンプルをダウンロードしてローカルで実行してみます。
ダウンロードしたファイルをwebディレクトリに配置しますがindex.htmlはすでにあるのでvrm_animation.htmlにリネームします。
インポートマップでサンプルサイトの上の階層を見ている場所があるので以下のように書き換えます
- 書き換え前
"@pixiv/three-vrm": "../../lib/three-vrm.module.js"
- 書き換え後
"@pixiv/three-vrm": "/npm/@pixiv/three-vrm/lib/three-vrm.module.min.js"
結果
main.jsについてもアバターモデルをサンプルサイトの上の階層から参照しているので以下のように書き換えます。
- 変更前
const defaultModelUrl = '../models/VRM1_Constraint_Twist_Sample.vrm';
- 変更後
const defaultModelUrl = 'https://pixiv.github.io/three-vrm/packages/three-vrm/examples/models/VRM1_Constraint_Twist_Sample.vrm';
結果
この状態でブラウザで https://localhost:10443/vrm_animation.html を開きます。
正しく修正されていれば公式サンプルページと同じ動作になるはずです。
ここで描画更新関数animateで呼ばれている2つのupdateメソッドの意味を確かめてみます。
まず、currentVrm.update( deltaTime );をコメントアウトしてみます。
字面で見ると
currentMixer.update( deltaTime );
をコメントアウトしたらアニメーションしなくなって、
currentVrm.update( deltaTime );
をコメントアウトしたらVRM自体が表示されなそうなものですがアバターは表示されたままです。
次に、mixamoで適当にモーションfbxを作ってドロップしてみます。
今度は、currentMixer.update( deltaTime );とcurrentVrm.update( deltaTime );のいずれかがコメントアウトされていると再生されないことがわかります。
つまり、アバターを動かしたかったらAnimationMixerのupdateとVRMのupdateどちらも描画更新関数で実行する必要があることがわかりました。
リソースの準備
ここから先の作業では、アバターとモーションが必要になるのでVRMファイルとモーションファイルを用意します。
VRMファイルについてはVRoidStudioを使えば比較的簡単につくれます。
これをtest.vrmという名前でweb/asset/vrm/の下に配置します。
モーションファイルについてはmixamoで、歩行モーションと静止モーションを取得します。
mixamoにログイン後、walkで検索すれば歩行モーションがいくつか表示されるので1つを選択し、プレビューの横に表示される設定項目で、「in place」にチェックを入れてDOWNLOADボタンを押します。
ダウロード設定ダイアログが表示されるのでSkin項目をWithout Skinに設定してDOWNLOADボタンを押してfbxファイルをダウンロードします。
これを、walk.fbxという名前でweb/asset/animation/の下に配置します。
待機モーションについてもidleで検索して歩行モーションと同様の手順でダウンロードし、idle.fbxという名前で配置します。
仕様を考える
今回は、キー操作による歩行を目的とします。
wasdで前後左右に移動しqeで左右に向きを変えることにします。
移動中は歩行モーションを再生し、停止中は待機モーションを再生するものとします。
アバターの差し替えや、モーションの動的追加はまだ考えません。
大まかな処理の流れとしてはこんな感じになります。
- Three.js初期化
- アバター読み込み
- モーション読み込み
AnimationMixerにwalk/idleを登録 - キーイベント登録
キーが押されたときに、移動速度と回転速度を設定
キーが離されたときに、移動速度と回転速度をリセット - 描画更新処理
移動速度が0でなくかつ現在のモーションがidle >> walkを再生。
移動速度が0かつ現在のモーションがwalk >> idleを再生
rendererで描画
単純にアバターを表示してみる
上で書いた仕様は結構複雑です。
公式サンプルで得た知識を仕様にそって組み立てるために、単純な機能に切り出します。
まずアバターの読み込みと表示だけする簡単なものを作ります。
上で作った箱の表示サンプルを基にして箱を作る代わりにloadVRM関数でVRMファイルを読み込んでそれを表示します。
この状態でブラウザで https://localhost:10443/show_vrm.html を開きます。
正常ならTポーズで立っているアバターが表示されるはずです。
アバターを歩かせる
次に歩行モーションの再生方法を確認します。
公式サンプルを参考にして表示用サンプルにFBX読み込みとモーション再生を実装します。
実装例
この状態でブラウザで https://localhost:10443/walk_vrm.html を開きます。
正常ならその場で歩行モーションをとるアバターが見えるはずです。
モーションを切り替える
仕様としては移動していない場合は待機モーションをとってほしいので、モーションを切り替える試作を行います。
上の例を考えると、キー押下イベントか何かで、playMotion読んでモーション名をトグルすればよさそうです。
//待機モーション読み込み
await loadMotion('asset/animation/idle.fbx','idle',vrm);
//何かキー押したらモーション切り替える
window.addEventListener("keydown",(e)=>{
console.log("keydown",vrm.current_animation);
if(vrm.current_animation=="walk"){
vrm.playMotion("idle");
}else{
vrm.playMotion("walk");
}
});
こんな感じのコードを
scene.add(vrm.scene);
の上に入れてみます。
テストしてみましょう。
初期状態では歩行しています。
ここで何かキーを押します。待機モーションに移るはずです。
あれ?待機しません。なんか歩幅が小さくなっています。
もう1回キーを押します。歩行モーションに移るはずです。
なんと、そのままです。
しかしエラーは出ません。
これは何故かというと、clipActionのplayメソッドはそのモーションの再生を開始しますが、他のclipActionの再生を停止するわけではないからです。
つまり、
初期状態 walkだけ再生されている >> 歩行モーション
1回キー押した walkとidleが再生されている。 >> 歩行と待機が混じって歩幅小さく
2回目以降 walkとidleが再生されている。 >> 状態変わらず
となっています。
つまり、playMotionで現在再生中のclipActionを止める必要があります。
playMotionの定義を次のように書き換えます。
vrm.playMotion = (name) =>{
if(name in vrm.dic_actions){
const prev_animation = vrm.current_animation
const action = vrm.dic_actions[name];
vrm.current_animation = name;
action.play();
if( prev_animation in vrm.dic_actions){
const prev_action = vrm.dic_actions[prev_animation];
prev_action.stop();
}
}
};
前のモーションの停止を後で実行していることに注意してください。
次のモーションの再生開始前に実行すると一瞬Tポーズをとるようになります。
修正結果はこちら
キー入力で移動させる
モーション切り替えの試作を元にして移動を仕込んでみます。
Three.jsでのオブジェクトの位置指定はObject3Dのpositionプロパティを設定します。
問題は、アバターの向きが変われば、前と右のベクトルが変わることです。
移動入力の際には、アバターの向きから見ての前後左右への移動を期待しますが、これはワールド座標系(scene基準の座標系)でのXZ軸とは必ずしも一致しません。
つまり、移動処理を実行する際には、現在のアバターの向きから見た右と前のベクトルを計算し、入力による前速度と右速度をかけた上で位置に反映する必要があります。
とりあえず前回のキーを押したらモーション変える処理は不要なので以下を削除します。
//何かキー押したらモーション切り替える
window.addEventListener("keydown",(e)=>{
console.log("keydown",vrm.current_animation);
if(vrm.current_animation=="walk"){
vrm.playMotion("idle");
}else{
vrm.playMotion("walk");
}
});
そこに以下のコードを入れて、キー入力を移動に反映します。
//移動入力対応
//アバターから見た前・右・上の単位ベクトル
const vec_forward = new THREE.Vector3();
const vec_right = new THREE.Vector3();
const vec_up = new THREE.Vector3(0,1,0);
const tmp_dir = new THREE.Vector3();
//アバターの移動速度
const move_speed = 3;//1秒で3m
const rotate_speed = 1;//1秒で1回転
//移動入力
var input_forward = 0;
var input_right = 0;
//回転入力
var input_rotate= 0;
const calcDirection = (vrm)=>{
//移動計算用に現在の単位ベクトルを計算する。
const dir = vrm.scene.getWorldDirection(tmp_dir);
//dirにはvrmの背後へのベクトルが入っている
dir.negate();//反転
vec_forward.copy(dir);
//右を計算したい場合はy軸で-90度回せばいい
dir.applyAxisAngle(vec_up,Math.PI*-0.5);
vec_right.copy(dir);
}
window.addEventListener("keydown",(e)=>{
//移動入力値更新
const code = e.code;
if(code=="KeyW"){
input_forward=1;
}else if(code=="KeyS"){
input_forward=-1;
}else if(code=="KeyA"){
input_right = -1;
}else if(code=="KeyD"){
input_right = 1;
}else if(code=="KeyQ"){
input_rotate=1;
}else if(code=="KeyE"){
input_rotate=-1;
}
});
window.addEventListener("keyup",(e)=>{
//移動入力値更新
const code = e.code;
if(code=="KeyW"){
input_forward=0;
}else if(code=="KeyS"){
input_forward=0;
}else if(code=="KeyA"){
input_right = 0;
}else if(code=="KeyD"){
input_right = 0;
}else if(code=="KeyQ"){
input_rotate=0;
}else if(code=="KeyE"){
input_rotate=0;
}
});
//位置更新処理の登録
const move_vec_forward = new THREE.Vector3();
const move_vec_right = new THREE.Vector3();
dic_ticks["proc_input"] = (dt) =>{
calcDirection(vrm);
//移動ベクトル計算
//前後入力分
move_vec_forward.copy(vec_forward);//前方単位ベクトルコピー
move_vec_forward.multiplyScalar(input_forward * move_speed*dt);//入力値*移動速度*フレーム時間で変位を得る
//左右入力分
move_vec_right.copy(vec_right);//右方単位ベクトルコピー
move_vec_right.multiplyScalar(input_right* move_speed*dt);//入力値*移動速度*フレーム時間で変位を得る
//位置更新
vrm.scene.position.add(move_vec_forward);
vrm.scene.position.add(move_vec_right);
//向き更新
vrm.scene.rotation.y += 2*Math.PI *input_rotate * rotate_speed * dt;
//モーション変更
//移動入力を見て現在のモーションと違う場合は更新する。
const is_move = (input_forward != 0 ) || (input_right != 0 ) || (input_rotate != 0);
if(is_move && vrm.current_animation=="idle"){
vrm.playMotion("walk");
}else if(!is_move && vrm.current_animation=="walk"){
vrm.playMotion("idle");
}
}
修正結果はこちら
問題がなければ、WSで前後移動 ADで左右移動 QEで方向転換するはずです。
カメラを追随させる。
上の例ではカメラが固定であるため、移動させ続けるとアバターが視界の外に出てしまいます。
そこでカメラをアバターに追随させることを考えます。
TPS視点と考えた場合、単純にアバターの後方にカメラを置いて移動に追随させればいいので、毎フレームカメラの位置と向きを更新することにします。
次のようなコードをscene.add(vrm.scene);の上に入れます。
//追尾カメラ処理
const camera_vec = new THREE.Vector3();
dic_ticks["proc_camera"] = (dt) =>{
calcDirection(vrm);
//カメラの位置はプレイヤーの足元基準で後方5m上3mとする
camera_vec.copy(vec_forward);
camera_vec.multiplyScalar(-5);//後方5m
camera_vec.add(vrm.scene.position);
camera_vec.y += 3;//3m上
camera.position.copy(camera_vec);
//プレイヤーの足元の1m上を見る
camera.lookAt(vrm.scene.position.x,vrm.scene.position.y+1,vrm.scene.position.z);
}
修正結果はこちら
これでプレイヤーの後ろをカメラが追随します。
マウスドラッグで視点を動かす
一応キー操作でアバターを動かすことは実現しましたが、視線を動かせないので少し不便です。
マウスドラッグの左右で旋回、上下で視線の対象を上下させることを考えます。
マウスドラッグはpointerdown/pointermove/pointerupイベントで取得することが出来るので
- pointerdownでドラッグモードに移行する
- pointermoveでドラッグ中だった場合 累積ドラッグ量を更新する。
- pointerup でドラッグモードを解除する。
- 描画更新時に累積ドラッグ量でアバターの旋回と視線の上下オフセットの更新を行い累積ドラッグ量はリセットする
という流れが考えつきます。
これを実装すると次のようになります。
自撮りモードを追加する
TPSだけだと自分を含めた記念撮影的なことが出来ません。
この手のカメラ制御は割とめんどくさい処理ですがThree.jsにはOrbitControlsというカメラ制御のアドオンがあるのでそれを使います。
こんな感じで実現できるでしょう。
- Cを押したら追尾カメラと自撮りモードを切り替える。
- 自撮りモードの場合は、カメラ更新関数で処理をスキップする。
これを実装すると次のようになります。
Cを押して自撮りモードになると、左ボタンドラッグで視点回転・右ボタンドラッグで平行移動・ホイールでズームできるようになります。
これで、自分のアバターを動かすことについては何とかなりそうです。
メタバースに使うことを考慮しての再構成
メタバース用に使うことを考えるとまだ不足している点があります。
- 他のプレイヤーを表示する必要がある。
メタバースの場合、自分以外のプレイヤーが動的に追加・削除されます。
プレイヤー管理が必要なので、アバター単位でクラスオブジェクトにまとめることにします。
また、アバターの移動は自分のアバターは入力操作ですが、他のアバターは受信データを反映する必要があります。
そこで自分のアバターの操作入力用メソッドと別に受信データを設定するメソッドを追加します。 - アバター変更
プレイヤー操作で任意のアバターに変更する必要があります。
VRMファイルをドロップすることで変更するようにします。
ファイルから読み込んだバイナリデータで設定することになるので
このデータをp2pでやり取りすればリモートへのアバター反映も可能になります。 - 他のプレイヤーが誰かわかる必要がある。
アバターの頭上に名札を表示することにします。 - 発言を表示したい。
アバターにメソッドを追加して引数を頭上に表示することにします。 - プレイヤーがモーションを追加したい。
アニメーションデータのファイルをドロップすることで追加できるようにします。
これもアバター同様にリモートに反映可能になります。
あと、描画関係と操作系の実装が散らかっていてわかりにくいのでクラスと関数にまとめます。
操作解説
操作 | 動作 |
---|---|
ASWDキー | 移動 |
QEキー | 旋回 |
Cキー | カメラモード切替 |
マウスドラッグ | カメラ制御 |
ファイルドロップ | VRMの場合アバター変更、FBXの場合モーション追加 |
Mキー | 所作uiの表示切替 |
名札クリック | 所作uiの表示切替 |
所作ボタン | 選択したモーションの再生 |
テキストボックス | 入力したテキストをアバターの頭上に表示 |
物理演算の導入
壁や階段といった地形の処理が欲しいので、物理演算エンジンを使って実装してみます。
Unityのように物理演算が統合されてるわけではないので、
移動入力 -> 速度決定 -> 物理演算 -> 位置確定 -> 描画に反映
という流れを自前で実装する必要があります。
物理演算を自前実装するのは大変ですが有り難いことにブラウザ上で使用可能なオープンソースの物理演算エンジンがいくつか存在しています。
今回は、cannon-esを使います。
上で書いた流れを実現するには、プレイヤーと地形の物理判定を物理演算エンジンに登録する必要があります。
これは画面ロード時に解決します。
プレイヤー間の物理干渉についてはp2pでは同期が非常に困難になるので対応しません。
ローカルプレイヤーと地形についてだけ物理演算を行い、その結果はプレイヤーの位置情報として他のプレイヤーに送信することになります。
移動地形的なものを同期させたい場合は、同じ時刻に同じ位置・速度になるようなアルゴリズムを作って地形のオブジェクトに適用する必要があります。
実装例
箱と斜面があります、ジャンプして乗ることが出来ます。
操作説明
操作 | 動作 |
---|---|
ASWDキー | 移動 |
QEキー | 旋回 |
スペース | ジャンプ |
Cキー | カメラモード切替 |
マウスドラッグ | カメラ制御 |
ファイルドロップ | VRMの場合アバター変更、FBXの場合モーション追加 |
Mキー | 所作uiの表示切替 |
名札クリック | 所作uiの表示切替 |
所作ボタン | 選択したモーションの再生 |
テキストボックス | 入力したテキストをアバターの頭上に表示 |
次回予告
次回からはThree.jsから一旦離れて、通信周りの基本を押さえます。
- Websocketによるチャットを実装する
までやる予定です。
Discussion