[Astro] #203 スマホの VR ゴーグルで遊ぶ「だるまさんが転んだ」(注視ボタン・多体 VRM・BENCH・サバイバル)

[Astro] #203 スマホの VR ゴーグルで遊ぶ「だるまさんが転んだ」(注視ボタン・多体 VRM・BENCH・サバイバル)

はじめに

Quest を持っていない人でも遊べるように、スマホを Cardboard 型の簡易ゴーグルに入れて遊ぶ VR ゲームを作りました。最初の 1 本は、VRM がこちらへ歩いてくる「だるまさんが転んだ」です。見ている間は止まり、目を離すと動きます。

  • 画面に触れないので、操作は、画面中央の + を見つめて押す(注視ボタン)
  • 敵は VRM。character.json に書いたキャラが出る
  • ステージ 10 面(2 体から始めて、1 面ごとに 2 体増える)と、人数が増え続けるサバイバル
  • VRM を 1 体ずつ増やして FPS を測る BENCH
  • VRM を並べて、制作者と URL を見せる CREDIT 画面

使ったバージョンは、three 0.183.0、@pixiv/three-vrm 3.5.5 です。アプリは、Astro のサイトの public/tool/mobile/ に置いた、素の ES モジュールです。three と three-vrm は、esbuild で 1 ファイル(lib/three-vrm.js)にまとめています。

ファイル役割
common/cardboard.js左右 2 画面、向きの追従、注視ボタン、HUD(スマホ用ゴーグルアプリ共通)
daruma/daruma.jsゲーム本体
daruma/character.jsonキャラの一覧(モデル・サムネ・制作者・歩く速さ)

1. 注視ボタン

スマホ VR の操作は、画面中央に + を出して、頭を動かしてボタンに当て、一定時間見つめると押した扱いにする、という作りが定番です。

// カメラの子に置いた + の輪(z = -2)。見つめている間、輪が埋まる
const hit = raycaster.intersectObjects(buttons.filter(b => b.visible && b.interactive).map(b => b.mesh))[0];
// raycaster は、カメラの位置から、カメラの向きへ
  • ボタンは、ワールドに置いた板(PlaneGeometry に CanvasTexture)で、カメラの方を向けます
  • 判定は、カメラの中心から、向いている方向へ飛ばす光線です。画面の座標は使いません
  • 待ち時間は gaze.dwell です。最初は 5 秒にしましたが長すぎたので、既定を 1.5 秒にして、OPTION で 0.5〜5 秒に変えられます

押した直後に、続けて押さない

押した直後も、同じボタンを見つめたままだと、また押されます。次の 2 つの規則で防ぎます。

状況再び押せる条件
画面が切り替わった(新しいボタンが視線の下に出た)何もない所へ、一度、視線が外れる
同じ画面で押した同じボタンから、視線が外れる

最初は「どのボタンからでも、一度外れる」にしていたため、隣のボタンへ移っても、すぐ押せませんでした。切り替わりとそれ以外で、条件を分けています。

「−」「+」のように、押し続けて増減したいボタンには、repeat(秒)を付けます。押した後、見つめ続けている間は、その間隔で繰り返します。

正面(高さ 0°)には、何も置かない

ゴーグルをつけて、まっすぐ前を見たときに、見ている先のボタンが、勝手に押されないようにします。START は上へ +8°、OPTION などは下に置いています。

足元の RETURN

ゲーム中に、いつでもタイトルへ戻れるように、足元(真下)に円形のボタンを置きました。見下ろして見つめると、戻ります。

// flat: 床に寝かせて、回転は、カメラの上の向きに合わせる
createGazeButton({ label: 'RETURN', position: new THREE.Vector3(0, 0.06, 0), shape: 'disc', radius: 0.6, flat: true, opacity: 0.6, onSelect: () => toTitle() });

ゲーム中は、目の前に何も無い(敵の邪魔をしない)ため、操作パネルは出しません。下を向くという操作が、「戻る」の意味になります。

メニューを見失ったら、正面を作り直す

ゴーグルでは、向きがずれたまま、メニューが背中側に回ってしまうことがあります。メニューにいる間は、向いている方が、正面から 50° 以上ずれた状態が 1.5 秒続いたら、その向きを、新しい正面にします(autoRecenter)。CREDIT だけは、見回して読むため、切っています。

2. 最初のタップは、本物のタップが要る

iOS は、次の 3 つに、画面のタップを必要とします。

  • 向きのセンサー(DeviceOrientationEvent.requestPermission())
  • フルスクリーン
  • 画面を消さない(wakeLock)

アプリを開いたあと、最初に 1 回だけ、画面の START ボタンを指で押します。その後は、ゴーグルに入れて、注視ボタンだけで操作します。効果音(Web Audio)も、この最初のタップで、解禁します。

3. 見られているか

「見られている間は止まる」の判定です。体を包む球(中心の高さ 0.85m、半径 0.8m)が、見えている範囲に、少しでもかかっていれば、見られている、とします。

  • 縦は、カメラの画角
  • 横は、片目の広さ(左右 2 画面なので、画面の横幅の半分で、縦横比を出す)
  • 球の大きさぶん、余裕を見る(asin(半径 / 距離))

動き始めと止まり方は、急に切り替えず、指数で寄せます。

e.speed += (target - e.speed) * (1 - Math.exp(-dt / (target === 0 ? stopTau : startTau)));

stopTau は 0.16 秒、startTau は 0.25 秒です。急に止めると、不自然に見えました。

歩くアニメーションは walk.vrma(1 つ)を、全員で使います。再生の速さを 速度 / strideSpeed にして、足が滑らないようにしています。VRM 0.x のモデルは、VRMUtils.rotateVRM0 で、前の向きをそろえます。

4. ステージ

項目値
ステージ数10
人数2 体から始めて、1 面ごとに +2(10 面で 20 体)
制限時間1 面 30 秒(耐えきるとクリア)
お手つき3 回で終了。敵が 0.9m に近づくと、1 回
出る場所半径 10〜13m。いま向いている方から 100° 以上離れた所。4 回に 1 回は、向きを問わない
敵どうしの間隔出す位置は 1.4m 以上、歩いている間も、0.7m より近くならないように離す

幸運として、視界に敵が何体か同時に入ることがあります。最高記録は localStorage に残し、タイトルの下に出します。10 面をクリアすると、ALL CLEAR が出て、タイトルへ戻ります。

つかまった後は、その 1 体だけを、また遠くへ出します。画面は、赤くフラッシュします。

5. モデルの重さとメモリ

最初は、手元の VRM(RPG 用)を敵に使いました。iPhone で BENCH(後述)を回すと、3 体で、タブごと落ちました。Safari は、メモリが足りなくなると、タブを強制的に閉じます。

モデルテクスチャのメモリ(概算)三角形
重いキャラ(PNG 28 枚、2048² を含む)約 142MB多い
軽いキャラ(akuma: PNG 1024² と 512²)約 7MB約 5.4 千

メモリは、画像のファイルサイズではなく、展開したピクセル数で決まります。GPU に載るときは、基本的に、幅 × 高さ × 4 バイト(ミップマップで約 1.33 倍)です。重いものは、1 体で 100MB を超えます。

そこで、スマホで動かすのは、軽いモデルだけにしました。

character.json

{
  "id": "akuma",
  "name": "akuma",
  "vrm": "/models/akuma.vrm",
  "thumb": "/models/akuma.webp",
  "mobile": true,
  "credit": { "author": "teru65", "url": "https://hub.vroid.com/users/11277533", "license": "FREE", "note": "" },
  "daruma": { "walkSpeed": 0.9 }
}
  • mobile: false のモデルは、スマホ用の一覧から外します
  • daruma.walkSpeed で、キャラごとに歩く速さを変えられます
  • credit は、CREDIT 画面にそのまま出ます
  • 読めなかったときは、軽い 1 体だけの予備の一覧で動き、タイトルに警告を出します。最初は、予備の一覧に重いモデルを残していたため、JSON の記述ミスに気づかないまま、重いモデルが出ました

6. BENCH

VRM を 1 体ずつ増やして、FPS を測るモードです。タイトルの BENCH から入ります。

項目値
追加1 体ずつ。前に向けた 160° の扇に、8 体ごとに 1 列奥へ並べる
測定追加後 0.6 秒は数えず(読み込み直後の引っかかり)、その後 3 秒の平均
終了上限(100 体)か、20fps 未満
モデルcharacter.json の全員を順番に混ぜる
  • 見えていない体も描いて、負荷が、体数どおりに出るようにしています(frustumCulled = false)
  • 測るたびに、結果を localStorage へ書いておきます。途中でタブが落ちても、「N 体目を足す所で止まりました」と、次回タイトルに出ます
  • 結果の表示は、10 体ごとと最低 FPS だけです。全部並べると、字が小さくなりました

iPhone 13 Pro の実測(全キャラ混在)

体数FPS
〜5059〜60
5552
6050
6546
7042
8039
9034
9531
10026

落ちずに、100 体まで完走しました。60fps を保てるのは 50 体までで、そこから体数に比例して、なだらかに下がります。限界は、メモリではなく描画です。

最初の見積もり(「スマホは数体まで」)と、軽いモデルでの実測は、大きく違いました。重いモデルでは 3 体で落ち、軽いモデルでは 100 体が動きました。

7. CREDIT 画面

character.json の全員を前に並べて、頭の上の札に、名前・制作者・URL・ライセンスを出します。見せるだけの画面です。足元の RETURN で戻ります。

  • 前を中心に、半径 3.0m の円に、間隔 1.3m で並べる。1 周(約 14 体)埋まったら、半径を 2.2m 足して、奥の列へ
  • 奥の列ほど、足元に置くワイヤーフレームの箱を高くして、前の列の陰にならないようにする(0.25m + 列ごとに 0.4m)
  • 札は、隣より広いので、1 つおきに高さをずらす
  • 全員、その場でゆっくり足踏みする(歩きのアニメーションを、半分の速さで)
const yaw = (k - (cnt - 1) / 2) * (CREDIT.spacing / R);   // 前を中心に、左右へ
const box = makeBox();                                      // EdgesGeometry の線 + ごく薄い面
box.scale.set(CREDIT.boxW, boxH, CREDIT.boxW); box.position.set(x, boxH / 2, z);
vrm.scene.position.set(x, boxH, z);

ダンボール箱の見た目も試しましたが、目立ちすぎたので、ワイヤーフレームにしました。ゴーグルでは、URL を押して開けないため、表示するだけです。

8. サバイバル

人数が増え続けて、何秒生き残れるかを競う、番外編のモードです。

項目値
最初2 体
増え方3 秒ごとに 1 体
上限OPTION の「大群の上限」(10〜100、既定 50)
終了お手つき 3 回
記録生き残った秒数と、そのときの体数
  • 読み込みは、遊びながら、先に 4 体ぶん読み進めます。間に合わなければ、増えるのが、次のフレームに遅れるだけです
  • 終わってタイトルに戻るときに、大勢ぶんは捨てて、メモリを空けます
  • 上限を OPTION に出したのは、端末ごとに、BENCH の結果が違うためです

9. 床を細かく割る

PC のソフトウェア描画(SwiftShader)で確認していたとき、2 枚の大きな三角形でできた床が、足元を隠しました。深度の計算が、大きな三角形では、ずれることがあります。床は、PlaneGeometry(120, 120, 40, 40) と、細かく割っています。

10. 間違いの記録

  • スマホの限界を、数体と見積もった: 重いモデルで落ちた結果から、決めつけていました。軽いモデルでは、100 体まで動きました
  • 予備の一覧に、重いモデルを残した: JSON の記述ミスで、character.json が読めず、予備の一覧が使われました。軽い 1 体だけにして、読めないときは画面に警告を出します
  • BENCH の表示で、5 体ごとに並べた: 100 体では、行が長すぎて、右端が見切れました。10 体ごと・1 行 4 つに直しました
  • headless の FPS: 開発中の自動テストの FPS は、10 に張り付きます。FPS の確認は、実機だけで行います

11. 制約

項目内容
10 面の通し実機で、10 面まで通して遊んだ確認は、まだ
効果音お手つきの 1 つだけ。BGM は無い
URLCREDIT の URL は、ゴーグルの中では押せない
端末実測は、iPhone 13 Pro のみ。古い機種は、上限を下げる(OPTION)
走る・休む敵は、歩くだけ(character.json に、走る速さや、休むキャラを足す案)

関連