[Babylon.js #13] メビウス帯の継ぎ目バグ修正(上下反転)+WebXRローラーコースター化

はじめに

本記事は、過去記事 #08 で作った 「メビウス帯+MMDウォーカー+Inspector」 の続編です。

#08 の実装では、ループ継ぎ目付近で 姿勢が突然 180° フリップして上下反転する(破綻する) 瞬間が残っていました。

今回はこの問題を、前フレームとの連続性を基準にクォータニオン姿勢を選択する ことで安定化します。 さらに、WebXR のカメラ追従(位置追従) を追加し、VRで“乗れる”状態まで拡張します。

参照リンク

動画(Youtube):

動画(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' = -right
    • up' = -up
    • forward' = 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)
  • smoothdt に応じて 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体験を“作品”に寄せる追加ネタ

  1. Yawだけ追従(head tracking は生かす)
  • seatYawPivot(TransformNode)を挟んで「体の向き=Yaw」だけ追従、HMDのPitch/Rollはそのまま。
  • 酔いにくく、進行方向の“乗ってる感”が出る。
  1. 速度ベースFOV/ブラー無しの“酔い軽減”
  • 速度が上がるほど follow を下げる(追従を鈍らせる)。
  • さらに「急カーブ(曲率)」で追従を弱めると効く(視線が振り回されない)。
  1. ガードレール/路面ラインの追加(没入感が急に上がる)
  • 帯の左右端に細いチューブ(リボンの縁)を生成。
  • 視覚的な速度感・形状理解が上がる。VRで特に効く。

メビウス/フレームの“数学・安定化”ネタ(技術記事向き)

  1. “最小回転”での基底更新(Parallel Transport の強化)
  • いまのPTでも良いけど、継ぎ目の holonomy を「均等に配る」以外に、 曲率が高い区間に多めに配る などの改善余地がある。
  1. 曲率κ・ねじれτの可視化(デバッグ可視化)
  • サンプル点ごとに「曲率(曲がり具合)」を計算して色付け。
  • 反転しやすい区間の特定に直結するし、記事映えする。
  1. “レーン”を複数にして隊列ウォーカーへ接続
  • laneOffset を -1/0/+1 の3レーンにして複数キャラ化。
  • そのまま“隊列+VR乗車”のデモに繋がる。

演出/動画映え(短時間で効果)

  1. 帯に発光グラデ(進行uで emissive を流す)
  • 進行位置uを元に emissive を波打たせるだけで「走ってる」感が出る。
  1. カメラモード切替(PC/VR共通)
  • Head / Chase / Side / Free の4モードをキー or GUIで切替。
  • 記事内に“おすすめ設定”を置ける。