[Astro #53] React Three Fiberで魔女のVRMを箒に乗せて自動周回・浮遊アニメーションを実装
1. はじめに
前回はVRMアバターに魔法詠唱のエフェクトやアニメーションを組み込みましたが、今回はその延長戦です。せっかく魔女のモデルを空間に迎えたのなら、やはり「箒での飛行」は欠かせない要素ですよね。
今回のゴールは、VRMアバターを箒に乗せ、React Three Fiber (R3F) で構築したサイバー空間の上空を、指定した楕円軌道で自動飛行させることです。
単にその場でフワフワと浮くだけではなく、軌道のカーブに合わせてしっかりと進行方向へ機首(箒の先)を向けながら、滑らかに風を切って飛ぶような躍動感のあるアニメーションを目指しました。
まずは、完成形の状態をご覧ください。
薄暗いPROTOCOL.LAINの空間を、魔女が悠々とパトロールしているかのようなSFチックで美しい絵面を作ることができました。
この記事では、複数の装飾品を制御するステート管理の基礎から、三角関数(Math.sin / Math.cos)を使った軌道計算、そして Math.atan2 を活用した少し数学的な旋回アプローチまで、実装の手順をステップバイステップで解説していきます。
YouTube:
Witch's Orbit Flight 🧹 Floating Webspace VRM Animation! #shorts
I implemented a smooth broom-flight orbit animation using Three.js and VRM in my custom web space (PROTOCOL.LAIN)! This video showcases both the PC desktop v...
www.youtube.com💻 Web (Desktop) View
VR View
2. Phase 1:ポーズ固定と「その場での浮遊感」の演出
移動ルートを計算する前に、まずは「箒に座ったポーズでその場に固定し、フワフワと浮遊させる」という基礎部分の構築からスタートしました。
このフェーズでは、単なる上下運動だけでなく、3Dならではの「空気の抵抗や不安定さ」を表現するためのアプローチと、それを支えるクリーンなシステム設計がポイントになります。
三角関数による「フワフワ感」と不安定さの演出
ブラウザの描画ループごとに実行される useFrame を利用し、経過時間(elapsedTime)をサイン波・コサイン波に投入して位置と回転を制御します。
上下の単純な浮遊であれば、Y軸に対して Math.sin(time) を適用するだけで実現できますが、それだけだと「機械的な上下移動」に見えてしまい、風に乗って浮いているリアルな質感が損なわれてしまいます。
そこで、以下のようにY軸の上下動に加え、ピッチ(X軸の回転)とロール(Z軸の回転)に対して、それぞれ周期や振幅の異なる波を組み合わせて微小な揺れを演出しました。
useFrame((state) => {
if (stateRef.current.isRidingBroom) {
const time = state.clock.getElapsedTime();
// 1. Y軸(上下)にフワフワとした浮遊感を与える
groupRef.current.position.y = 0.2 + Math.sin(time * 2.0) * 0.1;
// 2. ピッチとロールをわずかに揺らし、箒特有の「不安定さ」を表現
groupRef.current.rotation.z = Math.sin(time * 1.5) * 0.03; // 左右のロール
groupRef.current.rotation.x = Math.cos(time * 1.2) * 0.02; // 前後のピッチ
}
});
周期(time に掛ける倍率)や振幅(全体の計算結果に掛ける倍率)を意図的にズラすことで、規則性を感じさせない、自然で有機的な「風の抵抗を受けて漂っている空気感」を作ることができます。
ステート管理による複数アクセサリのクリーンな切り替え
これまでは、拳銃などの単一の武器アタッチを想定していたため、表示・非表示の切り替えは単なる showWeapon: true / false というフラグ(真偽値)で制御していました。
しかし、今回新しく「箒(Broom_01)」が加わったことで、「拳銃を表示している時は箒を隠し、箒に乗っている時は拳銃を隠す」という相互排他的な制御が必要になりました。フラグのまま拡張しようとすると、showGun や showBroom といった変数が乱立し、条件分岐がスパゲッティ化する原因になります。
そこで、単なるON/OFFから「現在どのアクティブIDのアイテムを表示すべきか」を文字列(または null)で管理するIDベースの切り替えシステムへと設計を変更しました。
1. 状態管理(stateRef)のアップデート
const stateRef = useRef({
// showWeapon: false, // 廃止
activeItemId: null as string | null, // 現在表示中のアクセサリIDを保持
isRidingBroom: false,
// ...略
});
2. アニメーション側からの動的な呼び出し
銃撃トリガーが引かれた際や、箒の飛行モード(b キーなど)がONになった際に、明示的にターゲットとなるIDを流し込みます。
// 銃撃時
stateRef.current.activeItemId = 'handgun_01';
// 箒乗車時
stateRef.current.activeItemId = 'Broom_01';
// 着地・アイドル帰還時
stateRef.current.activeItemId = null; // すべて非表示
3. アクセサリ(WiredAccessory)側の自律的な判定
描画を担うアクセサリコンポーネント側では、毎フレーム「現在アクティブなIDが、自分自身のID(item.id)と一致しているか」をチェックし、自身の visible を動的に切り替えます。
useFrame(() => {
if (groupRef.current && stateRef?.current) {
// 自身のIDとアクティブIDが一致している場合のみ可視化
const isActive = stateRef.current.activeItemId === item.id;
groupRef.current.visible = isActive;
// 拳銃固有のマズルフラッシュなど、特定アイテム専用の処理はここに内包する
if (item.id === 'handgun_01' && isActive) {
// ライトの同期処理など
}
}
});
この設計変更により、アタッチ関係のボーン指定(rightHand から hips への変更など)をJSON定義に任せたまま、コードを汚さずにどれだけでも新しいアイテムを安全に追加・切り替えられる拡張性を確保できました。
3. Phase 2:三角関数による「楕円軌道の自動周回」
その場での自然なフワフワ感が完成したら、次はいよいよ空間を飛び回らせましょう。
3D空間上で滑らかな円軌道や楕円軌道を描くための最強の武器、それが三角関数(Math.sin と Math.cos)です。
X座標とZ座標へのアプローチ
Three.jsの空間では、Y軸が上下(高さ)を表すため、地上を這うような水平の移動ルートを作るには X軸(左右) と Z軸(前後) を計算します。
経過時間(time)を利用して角度(angle)を作り、そこに半径を掛けることで、アバターの座標を毎フレーム更新していきます。
// 楕円軌道のパラメータ設計
const orbitSpeed = 0.4; // 周回する速度(数字を大きくすると高速飛行)
const radiusX = 1.0; // 楕円の横半径
const radiusZ = 0.5; // 楕円の縦半径
const angle = time * orbitSpeed;
// 楕円の位置計算(X, Z)
groupRef.current.position.x = Math.cos(angle) * radiusX;
groupRef.current.position.z = Math.sin(angle) * radiusZ;
たったこれだけの数行を追加するだけで、魔女は指定した半径の楕円軌道上を永遠に滑らかに周回し始めます。
空間に合わせた半径(radius)の調整のコツ
ここで重要になるのが、radiusX と radiusZ の数値調整です。
頭の中のイメージだけで数値を大きく(例えば 5.0 などに)設定してしまうと、カメラの位置やステージのスケール感と合わず、「アバターが画面外の遥か彼方へ飛び去ってしまい、虚無の空間を見つめることになる」という3D開発あるあるの現象が起きます。
実装時のコツとしては、まずは今回のコードのように radiusX = 1.0、radiusZ = 0.5 といった「確実にカメラの視野内に収まる小さな楕円」からスタートすることです。そこから、ブラウザで実際の飛行ルートを眺めながら、ステージの円形デッキのラインにピタッと沿うように数値を微調整していくのが最も確実で美しいアプローチになります。
これで美しい軌道を描いて飛ぶようになりましたが……実際に動かしてみると、ある「致命的な違和感」に気がつくはずです。
アバターが常に一点(正面)を向いたまま、真横や斜めにスライドして移動する「カニ歩き飛行」になってしまうのです。次はこの問題を解決し、飛行アニメーションに命を吹き込む最大のハイライト「自動旋回ロジック」に入ります。
4. Phase 3(最重要ポイント):進行方向への自動旋回と数学的アプローチ
先ほどの Phase 2 で美しい楕円を描いて飛ぶようにはなりましたが、実際に動かしてみると「致命的な違和感」に直面します。
それは、アバターが常に一点(画面の正面)を向いたまま、真横や斜めにスライドして移動する「カニ歩き(スライド移動)」になってしまうという問題です。これでは魔女が箒に乗って風を切っているようには見えません。飛行アニメーションに命を吹き込むためには、カーブに合わせて「機首(箒の先)を常に進行方向へ向ける」処理が必要になります。
軌道の微分ベクトルと Math.atan2 による接線方向の算出
進行方向、つまり「カーブの接線方向」を割り出すために、少しだけ数学的なアプローチを取り入れます。 現在の位置を計算する式が と であるならば、その「変化量(速度ベクトル)」は微分を用いて求めることができます。
具体的には、進行方向のベクトル dx と dz を以下のように算出します。
// 速度ベクトル(微分)を計算して、カーブの接線方向を割り出す
const dx = -Math.sin(angle) * radiusX;
const dz = Math.cos(angle) * radiusZ;
この dx と dz がわかれば、JavaScriptに標準搭載されている非常に強力な数学関数 Math.atan2() の出番です。Math.atan2(dx, dz) を使うことで、指定したXとZのベクトルが成す「角度(ラジアン)」を一発で弾き出すことができます。
モデルの「正面」に合わせた90度(Math.PI / 2)のオフセット
算出した角度をアバターのY軸回転(rotation.y)に代入すれば完璧……と思いきや、ここにもう一つの3D開発特有の罠が潜んでいます。それは、「3Dモデルが元々持っている『正面』の定義と、プログラムが算出した角度の基準軸がズレている」という現象です。
そのまま代入すると、進行方向に対して真横を向いたまま(結局カニ歩きで)飛んでしまうことがあります。これを補正するため、計算結果に直接 90度(Math.PI / 2)のオフセット を加算して向きを合わせます。
// 計算された進行方向の角度に、ラジアン単位で90度(Math.PI / 2)を足して向きを補正する
groupRef.current.rotation.y = Math.atan2(dx, dz) + Math.PI / 2;
Tips: もし3Dモデルのエクスポート仕様などにより、逆方向(後ろ向き)に飛んでしまう場合は、プラスではなくマイナス(
- Math.PI / 2)を適用することで正しい向きに調整できます。
この数行のロジックを組み込むだけで、アバターと箒の先端がしっかりと進行方向を捉え、滑らかに旋回するようになります。プログラム、3Dモデル、そして数学的アプローチが完全に噛み合い、画面の「説得力」が跳ね上がる最大のハイライトです。