はじめに
本記事は、過去記事 #08 で作った 「メビウス帯+MMDウォーカー+Inspector」 の続編です。
#08 の実装では、ループ継ぎ目付近で 姿勢が突然 180° フリップして上下反転する(破綻する) 瞬間が残っていました。
今回はこの問題を、前フレームとの連続性を基準にクォータニオン姿勢を選択する ことで安定化します。 さらに、WebXR のカメラ追従(位置追従) を追加し、VRで“乗れる”状態まで拡張します。
参照リンク
動画(Youtube):
メビウス帯VRライド化|継ぎ目フリップ修正(Babylon.js×MMD)#Shorts
メビウス帯ウォーカーの継ぎ目で起きる上下反転(ロールフリップ)を、前フレーム基準のクォータニオン選択で安定化。さらにWebXRで“乗れる”チェイス視点にして、VRでも確認できるようにしました。▼実装内容- メビウス帯:中心線+TNBフレーム+半ひねりで生成- 継ぎ目の上下反転:姿勢A/B(ロール180°別解)を ...
https://youtube.com/shorts/nN3_ktfieM8?feature=share動画(PC):
動画(VR):
1. 今回やること
- ループ継ぎ目付近で発生する 上下反転(ロールフリップ) を抑える
- トラックフレーム(TNB)+メビウスの半ひねり から姿勢(Right/Up/Forward)を構築する
- 等価な2姿勢(ロール180°違い) を毎フレーム生成し、前フレームに近い方(Quaternion dot 最大) を採用する
- WebXR では カメラリグを「位置のみ」追従 させ、HMDの首回転(視線)を殺さない
- VR視点は 頭部視点ではなくチェイス視点(後方オフセット) にして、帯が見える構図にする
- 酔い対策として、追従を Lerp(追従係数) で緩める(ガチ追従にしない)
2. 継ぎ目で上下反転していた原因
メビウス帯の進行では、ある地点で「上方向(Up)」の取り方が不安定になりやすい。理由は、姿勢を作る基底 right/up/forward に “等価な別解” が存在するため。
-
right / up / forwardの3軸(直交基底)から回転(Quaternion)を組む -
ただし right と up を両方反転しても、forward(進行方向)は同じのまま
right' = -rightup' = -upforward' = forward
-
この2つは「前に進む向き」は同じだが、ロールが180°違う別解になる
-
継ぎ目付近で数値誤差や符号判定(例:
dot(up, surfaceNormal))がわずかに変わると、 解A → 解B に“飛ぶ”ことがある -
結果として、見た目は ロールが180°フリップして 上下反転して見える
ポイントは、「メビウスだから必然的に破綻する」ではなく、“等価な2姿勢のどちらを選ぶか”を連続フレームで固定していないと、継ぎ目や条件分岐の境界でジャンプが起きる。
3. 修正方針:2つの候補姿勢から「前フレームに近い方」を選ぶ
今回のキモは、等価な2姿勢(ロール180°違い)を毎フレーム作り、連続性が高い方を採用すること。
3.1 姿勢候補 A/B を作る
トラックから得た基底 right / up / forward で回転を組む。
- 候補A:
(right, up, forward) - 候補B:
(-right, -up, forward)(ロール180°の別解)
どちらも forward は同じなので「進行方向は同じ」に見えるが、ロールだけが反転している。
3.2 選び方:前フレームとの近さで決める
前フレームの姿勢 prevQ と、候補 qA / qB の Quaternion dot を比較する。
- Quaternion は
qと-qが 同一回転(符号反転の同値性) → そのため距離比較はabs(dot)を使う abs(dot(prevQ, q))が 大きいほど回転が近い → 大きい方(=連続性が高い方)を採用
これで継ぎ目付近で「別解に飛ぶ」確率を潰せる。
3.3 補間(ガタつき対策)
採用した姿勢 q をそのまま適用すると、まだ微小な揺れが見えることがある。
そこで軽く Slerp して滑らかにする。
q = Slerp(prevQ, q, smooth)smoothはdtに応じて 0〜1 にクランプ(例:smooth = min(1, dt*10))
3.4 位置オフセットにも「採用した right/up」を使う(重要)
姿勢だけ A/B を切り替えるのに、位置のオフセット(laneOffset / footLift)を元の right/up のままにすると、視点・モデル位置が一瞬ズレる。
-
採用した
chosenRight / chosenUpを、そのまま- 横ずれ(laneOffset)
- 持ち上げ(footLift) に使うことで、姿勢と位置が同じ解に揃い破綻が消える
4. 実装:継ぎ目フリップ対策コード(抜粋)
// ---- 追加:前フレーム姿勢 ----
let prevQ: Quaternion | null = null;
// forward/right/up を作った後…
// 姿勢候補A(right/up/forward で回転行列→Quaternion)
const mA = Matrix.FromValues(
right.x, right.y, right.z, 0,
up.x, up.y, up.z, 0,
forward.x, forward.y, forward.z, 0,
0,0,0,1
);
const qA = Quaternion.FromRotationMatrix(mA);
// 姿勢候補B(ロール180°別解:right/up を同時反転)
const rightB = right.scale(-1);
const upB = up.scale(-1);
const mB = Matrix.FromValues(
rightB.x, rightB.y, rightB.z, 0,
upB.x, upB.y, upB.z, 0,
forward.x, forward.y, forward.z, 0,
0,0,0,1
);
const qB = Quaternion.FromRotationMatrix(mB);
// どっちが前フレームに近いか選ぶ
let q = qA;
let chosenRight = right;
let chosenUp = up;
if (prevQ) {
// Quaternion は q と -q が同一回転なので abs(dot) で比較する
// abs(dot) が大きいほど “前フレームに近い”
const dA = Math.abs(Quaternion.Dot(prevQ, qA));
const dB = Math.abs(Quaternion.Dot(prevQ, qB));
if (dB > dA) {
q = qB;
chosenRight = rightB;
chosenUp = upB;
}
// ガタつき抑制(補間)
const smooth = Math.min(1.0, dt * 10.0);
q = Quaternion.Slerp(prevQ, q, smooth);
}
prevQ = q.clone();
// 位置オフセットも “採用した基底” でやる(重要)
const halfW = sampleWidth(track, u);
const lateral = chosenRight.scale(params.laneOffset * halfW * 0.6);
const lift = chosenUp.scale(params.footLift);
walkerRoot.position.copyFrom(f.p.add(lateral).add(lift));
walkerRoot.rotationQuaternion = q;
注意:
Matrix.FromValues(...)は行列の並べ方で混乱しやすいので、「この3軸を回転行列の基底として入れている」という意図を明記しておくと安全(Babylonの行/列の話に踏み込む必要はない)。
5. WebXR対応:VRで“乗る”ための考え方
5.1 何に追従させるか(座席ノード)
WebXR の内部構造は環境差があるので、rigParent → parent → camera 自体 の順で拾うのが安全。
const xrCam = xr.baseExperience.camera;
const getSeatNode = (): TransformNode | null => {
const anyCam = xrCam as any;
// 環境によってある/ない
if (anyCam.rigParent) return anyCam.rigParent as TransformNode;
// v8系で parent がリグになるケースが多い
const parent = xrCam.parent;
if (parent && parent instanceof TransformNode) return parent;
// 最終手段:カメラ自体(位置変更が効かない環境もあるが試す価値はある)
return xrCam as any as TransformNode;
};
let seatNode: TransformNode | null = null;
xr.baseExperience.onStateChangedObservable.add((state) => {
if (state === WebXRState.IN_XR) {
seatNode = getSeatNode();
// 念のため:Quaternion運用に寄せる(rotation と混在させない)
if (seatNode && !seatNode.rotationQuaternion) {
seatNode.rotationQuaternion = Quaternion.Identity();
}
} else {
seatNode = null;
}
});
5.2 “首の回転”を殺さない(重要)
やる:位置だけ追従 やらない:座席(リグ)の回転上書き
- 回転を上書きすると、HMD の head tracking(首の回転)が実質無効になる
- だから
seatNode.rotationQuaternion = ...は 基本的に触らない
補足:例外は「レールに完全固定されたローラーコースター」を作りたいケース。ただし酔いやすい。
6. VR視点を見やすくする(チェイス視点+酔い対策)
6.1 目的:帯を見せつつ、head tracking を守る
- 頭部視点(first person)にすると、帯そのものが視界から消えやすい
- そこで チェイス視点(後方オフセット)にして、進行方向の景色を確保する
- 酔い対策として、位置追従は Lerpで遅らせる(“ゴム紐”みたいに追従)
if (xr.baseExperience.state === WebXRState.IN_XR && seatNode) {
// walkerRoot は mobius mesh の子なので worldMatrix から基底を取る
const wm = walkerRoot.getWorldMatrix();
const rotM = wm.getRotationMatrix();
const wp = walkerRoot.getAbsolutePosition();
const fwd = Vector3.TransformNormal(Vector3.Forward(), rotM).normalize();
const upv = Vector3.TransformNormal(Vector3.Up(), rotM).normalize();
const rgt = Vector3.TransformNormal(Vector3.Right(), rotM).normalize();
// 視点オフセット(チューニング対象)
const eyeUp = 1.45;
const eyeForward = -1.6; // 後方:帯が見える
const eyeRight = 0.0;
const seatPos =
wp.add(upv.scale(eyeUp))
.add(fwd.scale(eyeForward))
.add(rgt.scale(eyeRight));
// 酔い対策:位置追従を緩める(0.1〜0.4 目安)
const follow = 0.2;
seatNode.position = Vector3.Lerp(seatNode.position, seatPos, follow);
// ★回転は触らない(HMD の head tracking をそのまま残す)
}
6.2 正直に書くべき難点(トレードオフ)
- 回転追従を切っているため、進行方向へ“身体ごと向く”感覚は弱い
- ただし今回は 快適性(酔いにくさ)と head tracking を優先した
6.3 発展:Yawだけ追従したい(末尾の課題にする)
ここは「次回の拡張」として1段落で十分。
- seatNode の上に
yawPivot(TransformNode)を挟む yawPivot.rotation.yだけを walker の yaw に寄せる- pitch/roll は HMD に任せる(=触らない)
実装のコツ:yaw抽出は「forward をXZに投影して atan2」で取るのが安定(ロール混入を避ける)。
7. 動画
- Before / After 継ぎ目付近でロールが 180°フリップして上下反転する瞬間 → 修正後は 同区間を通過しても姿勢が連続する、を同じアングルで比較。
修正前:
修正後:
- VR
8. まとめ(今回得られた知見)
- メビウス帯/閉曲線のフレーム生成では、(right, up, forward) と (-right, -up, forward) のように 見かけ上同じ向きに見える“等価姿勢の別解” が必ず出る(=ロールの符号が不安定になりやすい)。
- ループ継ぎ目の破綻は「数学が間違っている」というより、どちらの解を採用するかの規約が無いことが原因になりがち。 前フレーム prevQ を基準に abs(dot) で近い方を選ぶだけで、連続性が一気に安定する。
- 姿勢を選んだら、laneOffset / footLift のような 位置オフセットも同じ基底(chosenRight / chosenUp)で揃えるのが重要。ここがズレると「向きは滑らかだが足元が滑る」破綻が出る。
- WebXR の追従は、リグ(seat)の回転を上書きすると head tracking が死ぬ。 まずは 位置だけ追従(必要なら Lerp で遅らせる)を基本形にするのが安全。
- VR体験として見せるなら、位置追従+遅延(Lerp)+チェイス視点が強い。 “乗ってる感”のために回転追従を入れたくなるが、快適性とのトレードなので、発展課題として Yawだけ追従するピボット構成に逃がすのが設計的に綺麗。
9. VR体験を“作品”に寄せる追加ネタ
- Yawだけ追従(head tracking は生かす)
- seatYawPivot(TransformNode)を挟んで「体の向き=Yaw」だけ追従、HMDのPitch/Rollはそのまま。
- 酔いにくく、進行方向の“乗ってる感”が出る。
- 速度ベースFOV/ブラー無しの“酔い軽減”
- 速度が上がるほど follow を下げる(追従を鈍らせる)。
- さらに「急カーブ(曲率)」で追従を弱めると効く(視線が振り回されない)。
- ガードレール/路面ラインの追加(没入感が急に上がる)
- 帯の左右端に細いチューブ(リボンの縁)を生成。
- 視覚的な速度感・形状理解が上がる。VRで特に効く。
メビウス/フレームの“数学・安定化”ネタ(技術記事向き)
- “最小回転”での基底更新(Parallel Transport の強化)
- いまのPTでも良いけど、継ぎ目の holonomy を「均等に配る」以外に、 曲率が高い区間に多めに配る などの改善余地がある。
- 曲率κ・ねじれτの可視化(デバッグ可視化)
- サンプル点ごとに「曲率(曲がり具合)」を計算して色付け。
- 反転しやすい区間の特定に直結するし、記事映えする。
- “レーン”を複数にして隊列ウォーカーへ接続
- laneOffset を -1/0/+1 の3レーンにして複数キャラ化。
- そのまま“隊列+VR乗車”のデモに繋がる。
演出/動画映え(短時間で効果)
- 帯に発光グラデ(進行uで emissive を流す)
- 進行位置uを元に emissive を波打たせるだけで「走ってる」感が出る。
- カメラモード切替(PC/VR共通)
- Head / Chase / Side / Free の4モードをキー or GUIで切替。
- 記事内に“おすすめ設定”を置ける。
💬 コメント