その時確認したこと WebXR API Emulatorを導入して 公式デモのページにアクセスして 鏡を表示してVRボタンを押す。 WebXRタブでハンドコントローラを操作してもアバターの手は動かない。
このため以前twitterで使えてる人います?と質問して無反応だったのであきらめていたのだが 実は一応動いていることが分かった。
確認手順 WebXR API Emulatorを導入しておく ブラウザの開発ツールで setup-verse.jsの const res = await VerseThree.start( の行にブレークポイントをおいてリロードして ステップ実行して次の行に移動してからコンソールで window.the_player=res.player; the_player._setupIK(); the_player._avatar.setIKMode(true); を実行する。 そこでステップ実行を解除。 その状態で鏡を表示してVRボタンを押す。 WebXR API Emulatorでのハンドコントローラの移動操作で手が動くことがわかる。 2窓で確認したところ手の動きはリモートでも反映されている。
謎1. VRボタンを押したときに公式デモでthree.js版だけエラーが出てaframe版は出ない。 gitにあるverse-threeやthree-avatarのexampleでもエラーになる。
謎2. エミュレータでないVRデバイス使ってる場合は、普通に動くのかもしれない。
1.ライブラリとしてはちゃんと動きます。 ドキュメントに書いている動作についての不具合は起きていません。
2.プレイヤー位置が問題になるゲームを作るのには向きません。 位置は自己申告な設計になっています。 p2pの仕様上これは解決は難しいと思われます。
3.カスタマイズはソースを読んて試行錯誤する気があればそんなに難しくはない。 機能の割にはコンパクトなライブラリだと思います。
4.一般公開するのはやはり敷居が高い コンテンツデータの二次配付問題をクリアできる方法は筆者には見つけることはできませんでした。 ログイン機能つけて信頼できる相手にしかアカウント発行しないという方針にせざるをえないので一般公開は難しい気がします。
5.どういう用途が考えられるか 教育用ではないかと考えます。 ソースが見えるのは教育用としてはメリットといえます。 学内からしかアクセスできないなら二次配付問題もある程度クリアできます。
6. unity/ueどっちも使ってないのは長期的には安心かもしれない。 verse-coreがソース非公開ではありますが、仕様自体はverse-three側の実装でわかります。
7. 仕様についての疑問
ログイン機能がない理由
現状の仕様では他のユーザーに対して一時的なミュートはできてもブロックは不可能です。
迷惑ユーザーを排除できません。
エントランスサーバ接続時にログインがあれば、マッチング時の判定でセッションIDからのブロックも可能なはずですし、悪質ならBANも可能です。
これについては、
1.課金と結びつかない限りは捨て垢の作成は容易なので意味が薄い
2.永続的なユーザーをエントランスサーバが持つ場合、セキュリティ要件が高くなりますが、無料公開なのでそこまでコストはかけない。
あたりかと考えます。
もし仮に有料でログインを提供するとしても、ワールドがあるサーバ側に認証情報が漏れない必要があります。
| できること | 補足 |
|---|---|
| 3Dモデルを配置 | コード追加かhtml記述必要 |
| オブジェクトをクリックしたら何かする 例) 動画とか音声を再生する 例) リンクを開く |
コード追加必要だが公式に説明あり |
| ボイスチャット | |
| テキストチャット | コード追加必要だが公式に説明あり |
| できること | 補足 |
|---|---|
| 所作モーションを追加する | テキストチャットで内容を通知して、受信時にotherPersonのアバターを操作する |
| ジャンプする。 |
three-avatarの移動制御の実装をオーバーライドして、物理演算ライブラリで制御する。 ジャンプ操作した際に、プレイヤーに上ベクトルを追加する。 |
| 走らせる |
three-avatarのAutoWalkerの実装をオーバーライドして、移動速度でモーションを切り替える。 プレイヤー操作でmoveControllerの移動速度設定を変更できるようにする。 |
| できること | 補足 |
|---|---|
| プレイやーが画像や動画や3Dモデルをアップロードして他の人に見せる |
アップロード用のwebapiでサーバ上にファイルをアップロード。 テキストチャットで、ファイルの情報と配置位置を通知、受信した側では、そのファイルを適切な方法で表示する。 |
| できること | 補足 |
|---|---|
| 特定のプレイヤーにだけチャットを送る | websocket経由でpushする。 |
| できないこと | 補足 |
|---|---|
| プレイヤーの位置をゲームの判定に使う | 自分の位置は、jsで任意に設定可能でそのまま他のプレイヤーにも伝搬する。 状態を管理する上位存在がないのでそれを嘘と判定できない。 |
| 必要なもの | 説明 |
|---|---|
| サーバ環境 |
実際に自分でワールド作って試すにはssl対応のサーバ必須 興味があってもすぐ試せる人は限られる。 |
| 知識 |
簡単に扱えるとはいうもののそれなりの知識は必要 html javascript three.js gltf vrm 確実につなげたい場合は、webrtcの知識も必要。 |
| 利点 |
利用の敷居が低い 専用のアプリや機材が不要 外部サービスへの依存度が低い エントランスサーバがソース公開されたので自前で建てられる。 stun/turnサーバはcoturn等で自前で建てられる。 |
|---|---|
| 欠点 |
ユーザー認証なし 迷惑ユーザーのブロック機能なし 画像・動画・3Dモデルといったメディアファイルをウェブに配置するので2次配付問題がある。 無料で利用を許可している素材も二次配付は禁止していることが多い。 |
| 観光施設の簡易リモート体験 | 施設のモデルの中を歩き回って、動画などを見ることができるようにする。。 |
|---|---|
| 店舗紹介 | 店のモデルの中を歩き回って、商品モデルから購入ページにアクセスできるようにする。 |
| 利用者間トラブル |
見知らぬ人間とのテキストチャットやボイスチャットはトラブルの原因になりうる。 認証なしで匿名利用可能なので、厄介ユーザーがルームに張り付いてハラスメント行為を行う可能性がある。 |
|---|
| 外部サービスに依存してない |
photonもunityもueもvroidhubも使わずに実現している。 エントランスサーバがソース公開されたので、 仮にアップランドがエントランスサーバの運用を止めても 自分で動かせば構築したverseengineのシステムの運用は継続可能なので無駄にならない。 |
||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| 自作ネットコードのテストに使える |
versecoreはブラックボックスだがthree-avatarはソースが見えているので 自分で書いたネットコードの画面表示の実装に使うことができる。 具体的な最初の流れとしては 1. ネットで見つけることができるwebrtcのサンプルを参考にしてチャットシステムを作る
クライアント動作
例えば、モーションの名前を通知して、アバターに対応モーションを再生させれば アニメーション同期ができる。 ゲームを作りたい場合は、ホストなしだと状態をクライアント側で自己申告し放題なので、 ルームホストを作ってそちらで部屋の状態変数を管理する。 |
| 1.サーバ |
ドメイン名必須 ssl必須 学生毎のウェブ公開用ディレクトリとアップロード権 |
|---|---|
| 2.生徒用端末 |
ブラウザ上でVerseEngineを動かせる程度の性能がいる。 比較的最近のタブレット等であれば問題ないがソースの編集を行うのでキーボード必須 サーバへのアップロード用のソフトも必要。 |
| 3.指導要領 |
VerseEngineの基本操作の説明 アップロードのやり方 ファイルを破損させた場合などの初期化手順 課題設問と評価基準 |
| 放物線運動 |
このサンプルでは、運動方程式に基づく放物線運動を扱っています。 青い箱をクリックすると、箱からボールが射出されます。 右上のuiの、elevation_angleで仰角、directionで水平方向角度、powerで初速を指定します。 アルゴリズムを記述している部分のファイル |
| 楕円運動 |
このサンプルでは、万有引力に基づく楕円運動を扱っています。 青い箱をクリックすると、柱の回りに球が生成され、初速を与えられます。 柱と球には引力があるので、一定の範囲の初速の場合は楕円運動をします。 右上のuiの、velocityで初速を指定します。 初期値は、円軌道になるように調整しています。 アルゴリズムを記述している部分のファイル |
| 楕円運動 ui拡張版 |
このサンプルでは、万有引力に基づく楕円運動を扱っています。 青い箱をクリックすると、青い円錐の位置に球が生成され、初速を与えられます。 緑の球と生成された球には引力があるので、一定の範囲の初速の場合は楕円運動をします。 右上のuiの、velocityで初速を指定します。 初期値は、円軌道になるように調整しています。 観察しやすいように以下の機能を追加しています。 Fキー : マウスドラッグでカメラを移動できるモードに切り替えます。もう一回押すと解除されます。 Tキー : 円錐の位置を変更するモードになります。 Rキー : 円錐の向きを変更するモードになります。 アルゴリズムを記述している部分のファイル |
| ローレンツ力その1 |
このサンプルでは、ローレンツ力に基づく運動を扱っています。 垂直方向に2本の電流が流れている状態で、電荷を射出します。 初期状態では同じ大きさで向きが反対で、ちょうど中間を通るように射出するので均衡状態にあるため真っすぐ進みます。 電流の大きさを変えたり、射出位置を変えたりして均衡が崩れるとローレンツ力で曲線を描きます。 アルゴリズムを記述している部分のファイル |
| ローレンツ力その2 |
このサンプルでは、ローレンツ力に基づく運動を扱っています。 その1とほぼ同じですが、初期状態で射出位置が2本の電流の中間で方向が電流と平行になっています。 初期状態では均衡状態にあるので真っすぐ飛んでいきます。 電流の大きさを変えて不均衡にすると、さまざまな軌跡を描きます。 アルゴリズムを記述している部分のファイル |
| シンボル名 | 意味 |
|---|---|
| param_define |
画面右上に表示されるパラメータの項目をjson形式で定義します。 default:初期値 min:設定下限 max:設定上限 step:設定刻み |
| start_by_local_user |
青い箱をクリックしたときに実行される関数です。 この関数の中で、位置や変数の初期値を設定して連想配列として返します。 param_config変数で、右上に表示されているパラメータの現在値を参照できます。 |
| start |
処理開始時に呼ばれる関数です。 ここで、表示するオブジェクトや、それを制御する変数を定義します。 クラスメンバとして定義するので、this.変数名=...という感じに記述します。 引数paramは、start_by_local_userが返した連想配列です。 この関数の中で、位置や変数の初期値を設定します。 ※start_by_local_userと分かれているのは、この関数はRPCとしてリモートプレイヤー側でも実行されるため。 |
| tick |
時間経過で行われる処理を記述する関数です。 引数dtは前回の呼び出しからの秒単位の経過時間です。 この関数の中で、物理法則に基づくオブジェクトの位置や変数の更新を記述します。 処理を打ち切る場合は戻り値でfalseを返します。そうでない場合はtrueを返します。 |
| destroy |
tickでfalseを返した場合に実行される関数です。 startで生成したオブジェクトの破棄をここで記述します。 |
| 関数名 | createCube(position,rotation,size,color) | ||||||||
|---|---|---|---|---|---|---|---|---|---|
| 概要 | 箱を生成する | ||||||||
| 引数 |
|
||||||||
| 戻り値 |
箱のオブジェクト。 箱を破棄するときはdestroyメソッドを呼ぶ。 Three.jsのMeshクラスなので詳細はそちらを参照。 |
| 関数名 | createSphere(radius,position,color) | ||||||
|---|---|---|---|---|---|---|---|
| 概要 | 球を生成する | ||||||
| 引数 |
|
||||||
| 戻り値 |
球のオブジェクト。 球を破棄するときはdestroyメソッドを呼ぶ。 Three.jsのMeshクラスなので詳細はそちらを参照。 |
| クラス名 | trail | ||||||
|---|---|---|---|---|---|---|---|
| 概要 | 軌跡を表示する | ||||||
| メソッド |
|
なぜ必要なのか? ユーザーIDとパスワードを使った認証は、 1. どうやってパスワードを渡すか? 2. 突破されやすい という問題があります。
公開鍵認証の場合は 1. パスワードを伝える必要はない 2. 突破するのは困難 という特徴があります。
なぜパスワードを伝える必要がないか? >公開鍵のurlと、メッセージ原文と、秘密鍵で署名したシグネチャがあれば、秘密鍵の所有者であるかどうかを判定可能だからです。
なぜ突破するのは困難なのか? >これについては詳しく無いのですが 1 暗号方式そのもの強度 2 パスワードそのものを送らない というあたりと考えられます。
単純化して考えると、 ID = 公開鍵のurl パスワード = 原文とシグネチャ と考えていいかもしれません。 公開鍵のurlは完全にユニークなので、ユーザー登録なしで動く認証とも言えます。
ただしこれが正しく機能するには以下の前提がありまます 1.公開鍵のurlは信頼のおけるサイトのものである。 2.秘密鍵がちゃんと本人だけが管理出来る体制にある。 3.アプリケーションサーバ側が信頼できる。
知らない相手が、知らないサイトの公開鍵のurlと原文とシグネチャを送信してきたとして、 署名の照合に成功したからといって、相手が信頼のおける人物であることは保証されません。 単に、秘密鍵の所有者であることを意味しているだけです。 認証以前に、公開鍵のurlにアクセスしたらフィッシングサイトに飛ばされる可能性すらあります。
したがって、安全に扱おうと考えるとアプリケーションサーバ側で 1.登録済みの安全リストにあるサイト以外の公開鍵urlは処理しない 2.登録してほしい利用者は、申請を行い、アプリケーションサーバの管理者による安全性の確認後に安全リストに追加する。 といった手順が必要ではないかと思われます。
考えられる使いどころはどこなのか? ユーザー登録でも、メールと電話の2ファクター認証等で、本人証明は可能ではありますが、個人情報流出のリスクがあります。 サービス提供側がよほどの信用がある企業/団体などでもない限り、ユーザー登録に個人情報は入力したくないものです。 公開鍵認証であれば、サービス提供側が利用してほしい個人または団体に、個人情報をやり取りせずにアクセス権を与えることができます。
const set_hdri=(url) =>{
new RGBELoader().load(url, function (texture) {
texture.mapping = THREE.EquirectangularReflectionMapping;
scene.background = texture;
});
}
set_hdri("hdri画像のurl");
const sky = new Sky();
sky.name = "sky";
sky.scale.setScalar(450000);
scene.add(sky);
staticize(sky);
影を表示するには
renderer:
renderer.shadowMap.enabled = true;
の設定が必要。
light:
light.castShadow = true;
の設定が必要
影を落とすタイプの光源(SpotLight/PointLight/DirectionalLight)でないと無効
mesh:
影を落とす側
mesh.castShadow = true;
の設定が必要
影を受ける側(地面など)
mesh.receiveShadow = true;
の設定が必要
glbの場合は複数のメッシュを子階層に含むので
glbobject.traverse((object) => {
if(object.isMesh) {
object.castShadow = true;
}
})
PlayerやOtherPersonのアバターの場合は、子要素にvrmを持っているので
avatar.vrm.scene.traverse((obj) => {
if (obj.isMesh) {
obj.castShadow = true;
}
});
指定したアニメーションを再生させたり位置を変えたりするにはPlayerクラスのインスタンスに対して操作する必要がある。 Playerクラスのインスタンスのインスタンスを変数に積むには setup-verse.jsの終わりのほうのコード const res = await VerseThree.start( この呼び出しの後で window.the_player = res.player; とか書けば the_playerに積まれる。
const ANIMATION_MAP = {
idle: "./asset/animation/idle.fbx",
walk: "./asset/animation/walk.fbx",
motion: "./asset/animation/motion.fbx",//追加分
};
const setup_avatar = (avatar) =>{
avatar.playClipOneShot=(motion)=>{
avatar._mixer.stopAllAction();
const clip = avatar._getClip(motion,false);
avatar._activeAction = avatar._mixer.clipAction(clip);
avatar._activeAction.setLoop(THREE.LoopOnce)
avatar._activeAction.clampWhenFinished = true
avatar._activeAction.play();
};
};
これはTREE.Object3D型なのでそのルールで制御できる。 the_player._object3D.parent.position で位置を設定・取得できる the_player._object3D.parent.rotation で向きを設定・取得できる
これはTREE.Object3D型なのでそのルールで制御できる。 the_player._object3D.parent.position で位置を設定・取得できる the_player._object3D.parent.rotation で向きを設定・取得できる
ライブラリを書き換えたくない筆者は、以下の方法を使っている。
置き換え用クラスの定義
AutoWalkerはAvatarExtensionの派生だが実は派生しなくてもsetupとtickがあれば動作はする。
class AutoWalker_ex {
setup(avatar){
//AutoWalkerの実装を参考にして初期化コードを書く
},
tick(dt) {
//AutoWalkerの実装を参考にして移動によるアニメーション切り替えコードを書く
}
}
プレイヤー生成時とアバター変更時にAutoWalkerを差し替える。
new_avatarという変数にアバターが積まれているとして
const aw = new AutoWalker_ex();
aw.setup(new_avatar);
new_avatar._extensions[1]= aw;
注意:._extensions[1]は、現在のthree-avatarの実装依存なので、添え字の1は変わる可能性がある。
実装例
この実装は動画にあげた試作で実際に使ってるコード
最適解である保証はない
以下のアニメーションを登録してる前提
walk 前歩行
walk_back 後歩行
walk_right 右歩行
walk_left 左歩行
walk_turn_right 右旋回
walk_turn_left 左旋回
setupの引数 move_actions は移動可能なアニメーション名の辞書を渡している。
これは椅子に座っている場合などの対策
class AutoWalker_ex {
setup(avatar,move_actions) {
this._isWalking = false;
this._walkCheckTimer = 0;
this._lastPosition = new THREE.Vector3();
this.__avatar = avatar;
this._avatar = new WeakRef(avatar);
this._object3D = avatar.object3D;
this._tmpVec = new THREE.Vector3();
this._plev_motion = "idle";
this._move_actions = move_actions;
this._tmpDir = new THREE.Vector3();
this._tmpFwd = new THREE.Vector3();
this._tmpNewPos = new THREE.Vector3();
let dir = this._object3D.getWorldDirection(this._tmpDir);
let angle = Math.atan2(dir.x,dir.z);
this._last_angle = angle;
}
tick(dt) {
const object3D = this._object3D;
if (!object3D?.visible) {
return;
}
//回転チェック
let roll = 0;
if(object3D){
let dir = object3D.getWorldDirection(this._tmpDir);
let angle = Math.atan2(dir.x,dir.z);
let diff = angle - this._last_angle;
if(diff < -2){
roll = 1;
}else{
if(diff > 0.01 ){
roll = 1;
}else if(diff < -0.01){
roll = -1;
}
}
this._last_angle = angle;
}
//移動以外のmotion中は無視
let in_move =this.__avatar._activeAction._clip.name in this._move_actions;
if(!in_move){
return;
}
const newPos = object3D.getWorldPosition(this._tmpVec);
if (!this._lastPosition) {
this._lastPosition = new THREE.Vector3().copy(newPos);
} else if (
Math.abs(this._lastPosition.x - newPos.x) > 0.05 ||
Math.abs(this._lastPosition.z - newPos.z) > 0.05 ||
roll >0 || roll <0
) {
this._tmpNewPos.copy(newPos);
let dir = this._tmpNewPos.sub(this._lastPosition);
let new_motion = "idle";
if(dir.length()>0){
//console.log(dir.normalize());//これはまあまあ信用できる移動ベクトルの数字
//unityにおけるforwardに相当する値のとり方
var fwd = object3D.getWorldDirection( this._tmpFwd );
//console.log(fwd);
let angle = fwd.angleTo(dir.normalize());//forwardから移動ベクトルへの角度
//上のangleだと左右取れないので外積で判定する
const cross = fwd.clone().cross(dir.normalize()).normalize();
//console.log(angle);
if(angle < 0.5){
//console.log("backward");
new_motion = "walk_back";
}else if(angle > 2.7){
//console.log("forward");
new_motion = "walk";
}else if(cross.y > 0.7){
//console.log("right");
new_motion = "walk_right";
}else if(cross.y < -0.7){
//console.log("left");
new_motion = "walk_left";
}
//console.log("move angle=",angle,cross);
}else{
if(roll>0){//left
new_motion = "walk_turn_left";
}else if(roll <0 ){//right
new_motion = "walk_turn_right";
}
}
this._lastPosition.copy(newPos);
if(new_motion != "idle" && this._prev_motion != new_motion){
//if (!this._isWalking) {
this._isWalking = true;
//this._avatar?.deref()?.playClip("walk");
this._avatar?.deref()?.playClip(new_motion);
this._prev_motion = new_motion;
//console.log("start walk");
}
this._walkCheckTimer = 0;
} else {
if(this._prev_motion != "idle"){
//if (this._isWalking ) {
this._walkCheckTimer += dt;
if (this._walkCheckTimer > 0.3) {
this._isWalking = false;
this._avatar?.deref()?.playClip("idle");
this._prev_motion = "idle";
//console.log("stop walk");
}
}
}
}
}
椅子 メッシュ 脚1 メッシュ 脚2 メッシュ 脚3 メッシュ 脚4 メッシュ 底板 メッシュ 背もたれ
let new_arr = [];
let org_arr = the_adapter.getInteractableObjects();
for( let el of org_arr){
if(el === newObj){
}else{
new_arr.push(el);
}
}
new_arr.push(newObj)
//the_adapter._interactableObjects = () => new_arr;
//2023-07-29 訂正 障害物と違いgetInteractableObjectsそのものを上書きしないと反映されない
the_adapter.getInteractableObjects = () => new_arr;
実装例:
追加する障害物をnewObj、DefaultEnvAdapterのインスタンスをthe_adapterとする。
newObjはすでにシーンに追加済みとする。
let new_arr = [];
let org_arr = the_adapter.getCollisionObjects();
for( let el of org_arr){
if(el === newObj){
}else{
new_arr.push(el);
}
}
new_arr.push(newObj)
the_adapter._getCollisionObjects = () => new_arr;
const collisionBoxes = [...the_adapter._getCollisionObjects(), ...the_adapter._getTeleportTargetObjects()].map(
(o) => new THREE.Box3().setFromObject(o)
);
the_adapter._getCollisionBoxes = () => collisionBoxes;
注意点: ローカルにしか反映されない。 他のプレイヤー視点に反映するにはアニメーション同様にsetTextDataを介してリモート側でも実行する必要がある。 またその方法では、障害物生成後に後から入室したプレイヤーには存在しない障害物になる。 これを解決するには、サーバ側で部屋の状態を記録しておいて、入室者にその情報を渡して同期させる仕組みが必要になる。 つまり静的なページだけでは実現不可能。
例:
ticksは各オブジェクトの更新関数が積まれた配列とする。
camera1,camera2をcameramodeで切り替えるものとする。
const clock = new THREE.Clock();
const animate = () => {
renderer.render(scene, that._freeCamera);
const dt = clock.getDelta();
ticks.forEach((f) => f(dt));
//描画更新は明示的に呼び出される
if(cameramode==2){
renderer.render(scene, camera2);
}else{
renderer.render(scene, camera1);
}
};
renderer.setAnimationLoop(animate);
if(otherPerson.sessionID in dic_mute_persons){
return;
}
ページ構成の例:
ページ1
uri /verse/entrance
部屋名とパスワードを入力すると、VerseEngineを動かすページに遷移する。
遷移先は
/verse/room/部屋名とパスワードから作られるハッシュ値
ハッシュ値の計算方法の例(php)
$roomkey = hash('sha256', $roomname.$roompw);
ページ2
uri /verse/room/$roomkey
VerseEngineを動かすページ
$roomkey部分は、部屋名とパスワードから作られるハッシュ値
注意点: インポートマップは、クライアント側から見たurlを基準としている。 したがって、もし /verse/setup-verse.js を利用したい場合は "setup-verse": "../../setup-verse.js", のように記述する必要がある。 リソース類についても同様で アニメーションを /verse/asset/animation に配置している場合 setup-verse.js のANIMATION_MAPでは idle: "../../asset/animation/idle.fbx", のように記述する必要がある。
仮にhtmlの配置が /verse/tps.html で、関連ファイルの配置は公式サンプル同様だとしてアクセスするurlが /verse/room/部屋コード という形式だとする。 (部屋コードを十分に長くして予測不可能な文字列にすれば鍵部屋に近いことができる。) この場合 /verse/.htaccess に記述するべき内容は以下のものとなる。Deny from all RewriteEngine on RewriteRule ^room/setup.js setup.js [L] RewriteRule ^room/setup-verse.js setup-verse.js [L] RewriteRule ^room/world.js world.js [L] RewriteRule ^room/asset/(.*)$ asset/$1 [L] RewriteRule ^room/(.*)$ tps.html?path=$1 [L] 注意点1 asset以下でない場所にファイルを追加した場合、RewriteRuleの記述を追加する必要がある。 注意点2 サーバが.htaccessでrewriteを使える設定になっている必要がある。
検索しても出てこないであろう情報のメモ:
1.vesrse-session-idをライブラリとしてビルドする設定
Cargo.tomlに以下の行を入れる。
[lib]
crate-type = ["cdylib"]
(筆者は[dependencies]の上に書いている)
2.lib.rsに追加記述する呼び出し用関数の実装例
ffiの場合
use std::os::raw::c_char;
use std::ffi::CStr;
#[no_mangle]
pub extern "C" fn verify(_session_id: *const c_char, _signature: *const c_char, _data: *const c_char) -> bool{
unsafe {
let __session_id: &CStr = CStr::from_ptr(_session_id);
let session_id: &str = __session_id.to_str().unwrap();
let __signature: &CStr = CStr::from_ptr(_signature);
let signature: &str = __signature.to_str().unwrap();
let __data: &CStr = CStr::from_ptr(_data);
let data: &str = __data.to_str().unwrap();
let Ok(sid) = session_id.parse::() else {
return false;
};
let Ok(ss) = signature.parse::() else {
return false;
};
sid.verify(vec![data.as_bytes()], &ss).is_ok()
}
}
napiの場合
#[macro_use]
extern crate napi_derive;
#[napi]
pub fn verse_session_id_verify(session_id: String, signature: String, data: String) -> bool{
let Ok(sid) = session_id.parse::() else {
return false;
};
let Ok(ss) = signature.parse::() else {
return false;
};
sid.verify(vec![data.as_bytes()], &ss).is_ok()
}
3.ffiで呼び出す場合の引数定義
$ffi = FFI::cdef('bool verify(const char*,const char*, const char*);', './libverse_session_id.so');
最後の引数は生成したダイナミックリンクライブラリの場所
例:
チャット欄に@danceと入力して送信すると踊る動作をしてリモートでも反映させる。
実装手順:
踊るアニメーションのファイルを用意する。
dancing.fbxとする。
これをasset/animationに配置する。
setup-verse.jsのANIMATION_MAPの定義に
dancing: "./asset/animation/dancing.fbx",
を追加する。
これでavatarのplayClipメソッドで呼び出し可能になる。
text.htmlの65行目あたりの
"verse-three": "../dist/esm/index.8c729faa6eb58c05.min.js",
これを
"verse-three": "https://cdn.jsdelivr.net/npm/@verseengine/verse-three@1.0.7/dist/esm/index.min.js",
に書き換える。
書き換えないと自前サーバだとインポートマップを解決できない。
ここはindex.htmlだと下の設定なのでおそらく修正漏れ。
text.htmlの168行あたりの送信フォームのイベントハンドラ
ここの
const data = Object.fromEntries(new FormData(e.target).entries());
これを
let data = Object.fromEntries(new FormData(e.target).entries());
if(data.message=="@dancing"){
player._avatar.playClip("dance");//ローカルで踊らせる
data = {nickname:data.nickname,message:"",action:"dancing",ts:Date.now()};//リモートへの要求
}
に置き換える。
text.htmlの200行あたりにある、addTextDataChangedListenerのイベントハンドラ
const data = JSON.parse(textData)?.textMessage;
この下に以下のコード追加
if(data.action){
otherPerson._avatar.playClip(data.action);
return;
}
テスト方法:
上記の修正実行後、2窓でtext.htmlにアクセスする。
2窓目が1窓目のキャラクターを見れるように位置調整する。
1窓目でテキスト入力欄に@dancingと入力して送信する。
2窓目で踊れば成功
step1. tps.html をエディタで開く
step2.
const main = () => {
の上に以下追加
//npc用追加モジュール
import { VRMLoaderPlugin } from '@pixiv/three-vrm';
import {
createAvatarIK,
isAnimationDataLoaded,
preLoadAnimationData,
createAvatar
} from "verse-three";
//npcローダ
const makeNpcAvatar = async (
url,
animationMap,
renderer,
isLowSpecMode
) => {
if (!isAnimationDataLoaded()) {
await preLoadAnimationData(animationMap);
}
let resp = await fetch(url);
const avatarData = new Uint8Array(await resp.arrayBuffer());
let _avatar = await createAvatar(
avatarData,
renderer,
false,
{
}
);
_avatar._object3D.name = "npc_avatar";
return _avatar;
};
//npc行動クラス
class NpcBehaviour{
constructor(avatar,scene,player,collisionObjects,adapter,ticks){
this._object3D = new THREE.Object3D();
this._look = new THREE.Object3D();
this._avatar = avatar;
this._disposed = false;
this._tmpVec = new THREE.Vector3();
this._target_pos = new THREE.Vector3()
this._target_rot = new THREE.Quaternion();;
this._move_speed=1;
this._rotate_speed = 1;
this._in_move = false;
this._player = player;
this._collisionObjects = collisionObjects;
this._object3D.add(this._avatar._object3D);
//vrmに直接onSelectUpをつけると超重くなるので別途付ける
let mat = new THREE.MeshBasicMaterial({color: 0x6699FF});
mat.transparent = true;
mat.opacity = 0;
this._selectable = new THREE.Mesh(
new THREE.BoxGeometry( 0.5, 2, 0.5 ),
mat
);
this._selectable.position.set(0,1,0);
this._object3D.add(this._selectable);
//プレイヤー生成後に作るのでinteratableを更新する必要がある
let new_arr = [];
let org_arr = adapter.getInteractableObjects();
for( let el of org_arr){
if(el === this._selectable){
}else{
new_arr.push(el);
}
}
new_arr.push(this._selectable)
adapter.getInteractableObjects = () => new_arr;
scene.add(this._object3D);
//2023-07-08 npc
let that = this;
ticks.push(function(dt){
that.tick(dt);
});
}
setPosition(pos_x, pos_y, pos_z, angle) {
var axis = new THREE.Vector3(0,1,0).normalize();
this._target_rot.setFromAxisAngle(axis,angle);
this._target_pos.set(pos_x,pos_y,pos_z);
this._object3D.position.copy(this._target_pos);
this._object3D.quaternion.copy(this._target_rot);
}
follow(){
let pp = this._player._avatar._object3D.getWorldPosition(new THREE.Vector3());
this.moveTo(pp.x,pp.y,pp.z);
}
moveTo(pos_x, pos_y, pos_z) {
this._in_move = true;
var axis = new THREE.Vector3(0,1,0).normalize();
pos_y = this._object3D.position.y;
this._target_pos.set(pos_x,pos_y,pos_z);
this._look.position.copy(this._object3D.position);
this._look.quaternion.copy(this._object3D.quaternion);
this._look.lookAt(this._target_pos);
this._target_rot.copy(this._look.quaternion);
}
tick(deltaTime) {
if (this._disposed) {
return;
}
this._elapsedTime += deltaTime;
this._avatar.tick(deltaTime);
if(!this._in_move){
return;
}
this._tmpVec.copy(this._target_pos);
this._tmpVec.sub(this._object3D.position);
//なぜかこれやらないと向きがおかしい
let dx = this._tmpVec.x;
let dz = this._tmpVec.z * -1;
if(this._tmpVec.length() < 0.01){
this._in_move= false;
return;
}
//2023-07-09 地形による停止判定
var ray_dir = new THREE.Vector3();
ray_dir.copy(this._tmpVec);
ray_dir.normalize();
var pos = this._avatar._object3D.getWorldPosition(new THREE.Vector3());
pos.y = pos.y+0.2;
const ray = new THREE.Raycaster(pos, ray_dir, 0.1,0.5);
const intersects = ray.intersectObjects(this._collisionObjects);
if(intersects.length>0){
this._in_move= false;
return;
}
this._tmpVec.normalize().multiplyScalar(this._move_speed * deltaTime);
this._object3D.position.add(this._tmpVec);
this._look.position.copy(this._object3D.position);
this._look.quaternion.copy(this._object3D.quaternion);
this._look.lookAt(this._look.position.x + dx,this._look.position.y,this._look.position.z + dz);
this._object3D.quaternion.slerp(this._look.quaternion.invert(),1*deltaTime);
}
_move(dt) {
}
dispose() {
try {
this._disposed = true;
this._avatar.dispose();
this._object3D.removeFromParent();
} catch (ex) {
console.warn("unexpected error", "npc.dispose", ex);
}
}
}
step3.
let _tick = () => {};
const ctx = setupScene((dt) => {
_tick(dt);
}, true);
上記コードを以下のように書き換える
//描画関数を複数対応
let _ticks = [];
const ctx = setupScene((dt) => {
for(let f of _ticks){
f(dt);
}
}, true);
step4.
).then(({ tick, player, adapter }) => {
の下の
_tick = tick;
をコメントアウトして以下のコードを追加
_ticks.push(tick);//渡された描画関数を積む
step5.
setupToggleFPS(adapter, player);
の下に以下のコード追加
//onSelectDown有効化
adapter.onSelectDown = (el, _point) => {
if(el.onSelectDown){
el.onSelectDown(_point);
}
};
//npc登録
let make_npc = async function(pos,npc_url,ANIMATION_MAP){
let npc_avatar = await makeNpcAvatar(
npc_url,
ANIMATION_MAP,
renderer,
false
);
let npc = new NpcBehaviour(npc_avatar,scene,player,collisionObjects,adapter,_ticks);
npc._object3D.name = "npc";
npc._object3D.position.set(pos.x,pos.y,pos.z);
npc._selectable.onSelectDown = function(){
console.log("npc clicked");
//npcとプレイヤーの向きに距離-1移動させる
let dist = npc._object3D.position.distanceTo(npc._player._object3D.parent.position);
if(dist < 1){
return;
}
let p = new THREE.Vector3();
p.copy(npc._player._object3D.parent.position);
p.sub(npc._object3D.position);
p.normalize();
p.multiplyScalar (dist-1);
p.add(npc._object3D.position);
npc.moveTo(p.x,p.y,p.z);
};
};
make_npc(
{x:5,y:0,z:5},//配置位置
"./asset/avatar/f0.vrm",//表示モデル
{
idle: "asset/animation/idle.fbx",
walk: "asset/animation/walk.fbx"
}//プレイヤーの場合と同じアニメーションマップを指定する。プレイヤー側でロード済なら使われない。
);
確認: 1.ブラウザでtps.htmlにアクセスする。 2.カメラアイコンでTPSに切り替える。 3.視界を回すとどこかに浴衣の女性のnpcがいる。 4.npcをクリックする 5.プレイヤーのそばまで歩いてくる。 6.近くなったら止まる。 7.プレイヤーを移動させて離す。 8.npcをクリックする。 9.プレイヤーのそばまで歩いてくる。
注意点: この実装では他のプレイヤーの空間のnpcには影響を及ぼさない。 もし、他のプレイヤーの空間でも動かしたい場合はsetTextData経由で動作を反映させる実装が必要。 その場合でも、だれの空間での位置が正解か?はp2pの性質上定義不可能なので、最後に要求された座標に向かうという動作になる。 本来はnpcの状態はサーバで管理されるべきである。
| 物理演算なし版 | 左スティック:移動、右スティック:カメラ操作、 L:向き固定、 LT:カメラの向きをキャラに合わせる |
| 物理演算なし版 キーボード・タッチ移動対応 |
左スティック:移動、右スティック:カメラ操作、 L:向き固定、 LT:カメラの向きをキャラに合わせる コントローラ無の場合、X:向き固定 、Z:カメラの向きをキャラに合わせる、 |
| 物理演算あり版 | 左スティック:移動、右スティック:カメラ操作、A:ジャンプ、 L:向き固定、 LT:カメラの向きをキャラに合わせる |
| 物理演算あり版 キーボード・タッチ移動対応 |
左スティック:移動、右スティック:カメラ操作、A:ジャンプ、 L:向き固定、 LT:カメラの向きをキャラに合わせる コントローラ無の場合、X:向き固定 、Z:カメラの向きをキャラに合わせる、スペース:ジャンプする |
| 物理演算あり版 キーボード・タッチ移動対応、タブレット用にカメラとジャンプのアイコン追加 |
左スティック:移動、右スティック:カメラ操作、A:ジャンプ、 L:向き固定、 LT:カメラの向きをキャラに合わせる コントローラ無の場合、X:向き固定 、Z:カメラの向きをキャラに合わせる、スペース:ジャンプする 目アイコン(頭上)のドラッグでカメラ制御 ジャンプアイコン(足元)のドラッグでジャンプ軌跡を表示して離すとジャンプ。 |
| 物理演算あり版 キーボード・タッチ移動対応、タブレット用にカメラとジャンプのアイコン追加。カメラアイコンにダブルクリック時の処理追加 |
左スティック:移動、右スティック:カメラ操作、A:ジャンプ、 L:向き固定、 LT:カメラの向きをキャラに合わせる コントローラ無の場合、X:向き固定 、Z:カメラの向きをキャラに合わせる、スペース:ジャンプする 目アイコン(頭上)のドラッグでカメラ制御 目アイコン(頭上)のダブルクリックでカメラの向きをキャラに合わせる。 ジャンプアイコン(足元)のドラッグでジャンプ軌跡を表示して離すとジャンプ。 |
document.querySelector("#toggle-fps").addEventListener(
"click",
() => {
isFirstPerson = !isFirstPerson;
f();
document.activeElement.blur();//フォーカスを外すコードを追加
},
false,
);
そもそもTHREE.JSには組み込みの物理演算がない。 THREE.JSで使われる代表的な物理演算ライブラリの Cannon.jsやAmmo.jsもVerseEngineでは使われていない。
ではどうやって地面の上を歩かせたり階段を上らせたりしてるのか? VerseThree.DefaultEnvAdapterのコンストラクタに渡している collisionBoxes、collisionObjects、teleportTargetObjects この辺の情報を使ってverse-threeの実装の中で移動に対する制限をかけている。
挙動としては以下のように見える。 1.移動先に有効な床が存在しない場合に移動を禁止している。 2.現在位置(XZ)に複数の床がある場合、一番高い床に合わせてプレイヤーのY座標を移動させる。 3.上の判定対象となる床はプレイヤーからの高低差制限がある。(0.5m程度?)
このため、プレイヤーのインスタンス(仮にplayerとする)に対して player._object3D.parent.position.y=10; のように高い空中位置を指定すると、空中に浮いたまま移動不可能な状態になる。
重力に沿って落ちる動作を実装したい場合、 1.プレイヤーに速度ベクトルを保持させる。 2.毎フレーム速度ベクトルをプレイヤー位置に加算して位置を更新する。 3.毎フレームプレイヤーが接地しているかチェックする。 プレイヤーの位置とcollisionBoxesで判定する。 プレイヤーが接地していない場合は毎フレーム速度ベクトルを下に加速する。 接地している場合は速度ベクトルをゼロにする。 といった実装が必要になる。
この実装では、速度ベクトルとして上成分を含むものを指定するとジャンプ動作になる。 Twitter(現在X)にあげた動画はこのようにしてジャンプを実現している。
VerseEngineでは地形を斜めに配置しても正しく判定されない。 サンプル(2023/09/04現時点)ではscaleを操作した場合も正しく判定されない。
前回で触れたように地形判定はcollisionBoxesが使われているがこれはBox3の配列である。 このBox3はcollisionObjectsとteleportTargetObjectsに含まれるオブジェクトを引数としてBox3.setFromObjectで作っている。 setFromObjectはオブジェクトを含む最小の直方体のBox3を返す。 Box3には角度情報はないので、常に0度である。
つまり、地形オブジェクトを斜めに配置しても見た目通りの判定にはならない。 表示上は何もないところに地形判定が発生する。
また、サンプルで書かれているcollisionBoxesを生成するコードはscaleを考慮していないので、scaleを操作して大きくしている場合、表示されているのに判定に無い地形が発生する。
これを考慮する場合は以下のような実装になる。
const collisionBoxes = [...adapter._getCollisionObjects(), ...adapter._getTeleportTargetObjects()].map(
(o) =>{
if(o instanceof THREE.Mesh){
o.geometry.computeBoundingBox();
o.updateMatrixWorld();
return new THREE.Box3().copy(o.geometry.boundingBox).applyMatrix4( o.matrixWorld )
}else{
return new THREE.Box3().setFromObject(o);
}
}
);
| 動く床 | これは時間による同期をしてないのでリモートと位置がそろいません |
| 動く床 同期版 |
これは時間による同期をしているので、時計があっていればリモートの人と一緒に乗れます。 押せる箱については同期していないので、リモート側と位置が合いません。 |
| 回転拘束 | プレイヤーの物理演算に回転制限を入れて、滑り落ちた際の不自然な動作を抑制しています。 |
| 動く床 同期改良 |
開始時だけ同期だと、だんだんずれてくるので移動の1ループ毎に位置リセットとインターバル調整をいれています。 これでも、ウィンドウやタブが非アクティブになっているとずれますが1ループでリセットされます。 |
| 動く床 Autowalker制御 |
移動床や坂で、移動入力が無いのに足が動いてしまう場合の対策。 処理の性質上、ローカルプレイヤー以外(入力値を直接取れない)はそのままです。 |
| 分類 | 概要 | メソッド |
|---|---|---|
| インスタンス管理 | インスタンスの生成と破棄 | new(entrance_server_url, player, other_person_factory, options) free() |
| デバッグ | デバッグ情報取得 | getDebugDistance() getDebugStatus() |
| 署名 | プレイヤーの送信データの署名など | getSessionID() sign(datq) signString(data) verify(session_id, signature, data) verifyString(session_id, signature, data) |
| ボイスチャット | ボイスチャット関連設定 | setMicAudioConstraints(audio_constraints) isSpeakerOn() isMicOn() micOff() micOn() |
| 引数名 | 内容 | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| entrance_server_url | verseengine公式が提供するエントランスサーバのurl | ||||||||||||
| player | verse-threeのPlayerクラスのインスタンス | ||||||||||||
| other_person_factory | verse-threeのOtherPersonFactoryクラスのインスタンス | ||||||||||||
| options |
|
||||||||||||
| ファイル名 | 説明 |
| readme.txt | 使用方法と注意書き。 |
| index.html |
クライアント画面 カスタマイズ部分は大体ここに記述している。 |
| httpd.js |
ローカル動作用の簡易httpサーバ nodeで動かす 自前でhttpサーバ動かす場合は不要 |
| full-mesh-signaling.js |
sdp(経路情報)をクライアント間で交換するための シグナリングサーバ nodeで動かす |
| p2p-client.js |
webrtc接続用のクライアント側ライブラリ クライアント側のシグナリング処理 peerの管理を行う |
| 開始行番号 | 71 | ||||||||||
| 開始コード | const url = "ws://127.0.0.1:14514"; | ||||||||||
| 説明 | シグナリングサーバのurl |
| 開始行番号 | 82 | ||||||||||
| 開始コード | const callbacks={ | ||||||||||
| 説明 |
接続イベントに対するイベントハンドラ定義
|
| 開始行番号 | 130 | ||||||||||
| 開始コード | const channels={ | ||||||||||
| 説明 |
webrtcのデータチャネルに対するイベントハンドラ定義 データチャネルは他のプレイヤーとの接続毎に存在している。
|
| 開始行番号 | 470 |
| 開始コード | window.tick_others= (dt) =>{ |
| 説明 |
毎フレームの更新処理。 プレイヤーの位置送信や、受信した他のプレイヤーの位置の反映など |
| 概要 |
webrtcの通信処理の基本部分。 webソケットを使ったシグナリングサーバで接続を行い、箱の同期をしている。 |
| ソース | local-webrtc.zip |
| 動画 | verse-webrtc.mp4 |
| 備考 | シグナリングの際には、ページのurlをルーム名としてルーム単位でのマッチングをしている。 |
| 概要 | verseengineに使われているthree-avatarを利用してNPCを生成しプレイヤーを追尾させている。 |
| デモページ | three-avatarを使ったnpc表示の試作 |
| 動画 | |
| 備考 | npcの動作をwebrtcで受信した他のプレイヤーの入力で行うと、マルチプレイ動作を実現できる。 |
| 概要 | 1と2を組み合わせて同期を実現し、アバターアップロード機能を追加して簡易メタバースを実現している。 |
| ソース | local-avatar.zip |
| 動画 | local_avatar_change.mp4 |
| 備考 | アバターのデータはwebrtcのデータチャネルで渡している。 |
| 概要 | 3に画像アップロード機能とテキストチャットを付けた。 |
| ソース | tinyverse.zip |
| 動画 | tinyverse.mp4 |
| 備考 |
| 概要 | アップロード対象を拡張し、3Dモデルやモーションに対応した。 |
| ソース | tinyverse2.zip |
| 動画 | tinyverse3.mp4 |
| 備考 |
アップロードしたオブジェクトの長押しで編集メニューが出る。 モーションをアップロードした場合、リモートのプレイヤーもモーションを使用できる。 |
| 概要 | webソケットを使ったシグナリングサーバではなく、hostページを使って手動でシグナリングを解決する機能を追加した。 |
| ソース | tinyverse3.zip |
| 動画 | tinyverse5.mp4 |
| 備考 |
手動なのはホストとクライアント間の接続。 クライアントとクライアント間はホストとの接続を使って自動シグナリングである。 |
| 概要 | 手動シグナリング版のクライアントでstun/turnサーバを指定する機能を追加した。 |
| ソース | tinyverse4.zip |
| 動画 | tinyverse6.mp4 |
| 備考 |
デフォルト設定では、googleのstunサーバだけ指定されている。 それではつながらない場合に、coturn等で自前でturnサーバを立てて、その設定を指定すればつながる可能性がある。 ただし、経路上のルータの設定によってはturnサーバを使ってもつながらない場合はある。 |
| 概要 | ssl対応版のhtmlをソースに追加。 |
| ソース | tinyverse5.zip |
| 動画 | tinyverse7.mp4 |
| 備考 |
作った理由は、ボイスチャットの確認のため。 現在のブラウザはセキュリティ上の理由でssl環境でない場合はjs側からマイクを取得できない。 ついでに、ダイスロール機能も試作している。 |
| 概要 |
手動シグナリングの場合、そのままだとページ移動をすると接続が失われて再度手動シグナリングをする必要がある。 その問題の回避方法の試作。 |
| ソース | tinyverse6.zip |
| 動画 | tinyverse8.mp4 |
| 備考 |
iframeを利用して、親側でホストとの接続を行い、iframe側にクライアント画面を置く。 これによりiframeが移動してもホストとの接続は維持されるので手動シグナリングはやらなくて済む。 |