[Astro] #200 three-vrm の揺れものに、動く球をぶつける(髪・服に指で触れる反応と、3.5 の colliderMatrix)

[Astro] #200 three-vrm の揺れものに、動く球をぶつける(髪・服に指で触れる反応と、3.5 の colliderMatrix)

はじめに

TOP ページの VR マスコットに、ハンドトラッキングの素手で触れると、反応する機能を足しました。

  • 指先と手のひらが髪・服に触れると、揺れものが押されて動く
  • 頭に触れると、目を閉じる
  • 体に触れると、表情が変わる(既定は angry 50%)

作りは、WiredLead.tsx(手をつないで歩く機能と同じファイル)に、触れる処理を足したものです。他のファイルの変更は、表情の反映の 2 か所だけです。

使ったバージョンは、次のとおりです。

項目バージョン
three0.183.2
@pixiv/three-vrm3.5.5
@react-three/fiber9.6.1
@react-three/xr6.6.29

スクリーンショット

ハンドトラッキングモードで、髪や体に触れる事ができます。

lain-lab TOP の使い方 - VR メニュー(SYSTEM)

1. 全体の流れ

毎フレーム、useFrame の中で、次の順に処理します。

  1. 手の関節(指先 5 点と手のひら)の位置を、XR のフレームから読む
  2. その点に球の当たり判定を置く
  3. 球を、すべての揺れものの衝突相手に登録しておく(モデルが変わったときだけ)
  4. 頭・体との距離から、目を閉じる量と表情の量を決める

揺れものの計算は、vrm.update(delta) の中で行われます。球の位置は、その前のフレームで更新した値を使うため、1 フレーム遅れますが、見た目には影響しません。

2. 当たり判定の球

手の関節は、frame.getJointPose(joint, referenceSpace) で読みます。球にする関節と半径は、次のとおりです。

const TOUCH_JOINTS: [string, number][] = [
  ['thumb-tip', 0.035], ['index-finger-tip', 0.035], ['middle-finger-tip', 0.035],
  ['ring-finger-tip', 0.035], ['pinky-finger-tip', 0.035], ['middle-finger-metacarpal', 0.07],
];

指先は 3.5cm、手のひら(中指の付け根)は 7cm です。両手で 12 個になります。半径が小さいと、髪の関節に届かないことがあるため、最初の 1.2cm から広げました。

球は、VRMSpringBoneCollider に VRMSpringBoneColliderShapeSphere を渡して作ります。フレームごとに作り直さず、プールに溜めておいて、位置と半径だけを更新します。

while (tc.pool.length < pts.length)
  tc.pool.push(new VRMSpringBoneCollider(new VRMSpringBoneColliderShapeSphere({ radius: 0.012 })));

3. 揺れものへの登録

全部の揺れものに、同じ 1 つのグループ(colliders の配列を持つオブジェクト)を足します。グループの中身を入れ替えるだけで、全部の揺れものに球の増減が伝わります。

const group = { colliders: [] as VRMSpringBoneCollider[], name: 'lead-touch' };

const mgr = vrm.springBoneManager;
const joints = mgr.joints ?? mgr.springBones; // 版によって名前が違う
joints.forEach((j: any) => {
  if (!j.colliderGroups.includes(group)) j.colliderGroups.push(group);
});

登録はモデルが変わったときに、1 回だけ行います。毎フレームは、group.colliders = pool.slice(0, 点の数) で、球の数を合わせます。

使った VRM(VRM 0 形式)では、揺れものの関節は 224 本でした。

4. 球の位置の更新(3.2 と 3.5 の違い)

球の位置は、position に入れたあと、行列を更新します。

c.position.copy(pt.p);
c.updateWorldMatrix(true, false);

ここで updateMatrixWorld(true) ではなく、updateWorldMatrix(true, false) を使います。three-vrm の版で、衝突の計算に使う行列が違うためです。

three-vrm衝突に使う行列更新される呼び出し
3.2.0collider.matrixWorldupdateMatrixWorld() でも updateWorldMatrix() でも更新される
3.5.5collider.colliderMatrixupdateWorldMatrix() だけ。VRMSpringBoneCollider が、これを上書きして、colliderMatrix を更新している

3.5 で updateMatrixWorld() を呼ぶと、colliderMatrix が更新されず、球が原点(0, 0, 0)に置かれたままになります。球を画面に出しても、位置は合って見えるため、気づきにくい不具合です。updateWorldMatrix(true, false) は、3.2 でも同じように動くので、版を分けずに、これだけを使えます。

5. 表情への反映

頭に触れる・体に触れる判定は、次のとおりです。

  • 頭: 頭のボーンの上 7cm を中心にした、半径 14cm の球
  • 体: 腰のボーンから首のボーンまでの線分から、17cm 以内
  • 点の半径の半分を、距離から引く
  • 頭の判定が、体より優先
  • 値は、0.2 秒の時定数で、目標に近づける

どちらも、拡大率(vrm.scene.scale.x)を掛けた距離で判定します。結果は、stateRef に書きます。

st.touchBlink = tc.wHead;                     // 目を閉じる量 0〜1
st.touchExprName = TOUCH_BODY_EXPR;           // 'angry' など
st.touchExprValue = tc.wBody * TOUCH_BODY_AMOUNT;

表情は、自律動作のフック(useWiredAutonomous.ts)が毎フレーム書き戻すため、そこで値を足します。blink は、自動の瞬きとの大きいほうを使います。

const presets = ['happy', 'sad', 'angry', 'relaxed', 'surprised', 'blinkLeft', 'blinkRight'];
presets.forEach((name) => {
  const currentVal = manager.getValue(name) || 0;
  let target = (name === s.expressionType) ? s.expressionTarget : 0;
  if (name === s.touchExprName) target = Math.max(target, s.touchExprValue || 0);
  if (Math.abs(currentVal - target) > 0.001) {
    manager.setValue(name, THREE.MathUtils.lerp(currentVal, target, s.expressionSpeed));
  }
});

// vrm.update(delta) のあと
manager.setValue('blink', Math.max(s.blinkValue, s.touchBlink || 0));

target の計算は、forEach の中に書く必要があります。外に書くと、name が window.name を指すため、エラーにならず、何も起きません。

体に触れたときの表情は、WiredLead.tsx の冒頭の 2 つの定数で変えます。

const TOUCH_BODY_EXPR = 'angry';   // 'sad' や 'relaxed' など
const TOUCH_BODY_AMOUNT = 0.5;

happy は、表情が不自然だったため、使っていません。

6. 間違いの記録

  • three-vrm の版を確かめなかった: 手元の検証環境(3.2.0)で動いたことを根拠に進めて、実際のプロジェクト(3.5.5)で動かない原因に、数往復かかりました。npm ls @pixiv/three-vrm で、先に確認します
  • 登録は成功していた: 診断の数値(球のグループを持つ関節の数 224/224、管理側が認識している、先端が球の中に入っている)は、すべて正常でした。登録・距離の計算は、原因ではありませんでした
  • 読んだだけでは分からなかった: 3.2.0 のソースは、matrixWorld を使う形でした。3.5.5 を入れて、ヘッドレスで再現して、初めて colliderMatrix に気づきました
  • 表情の処理を、ループの外に書いた: 前節の target の位置です

7. ヘッドレスでの確認

Chromium で、揺れものの関節 90 本(頭の近く)の位置の動きを、球ありとなしで比べました。球は、髪の関節の位置から 2cm ずらして置いています。

three-vrm球の更新球あり球なし
3.2.0updateMatrixWorld(true)約 2.2cm0
3.5.5updateMatrixWorld(true)00
3.5.5updateWorldMatrix(true, false)約 2.2cm0

VRM は、実際に使っているモデルです。このモデルは Draco 圧縮のメッシュを持ち、ヘッドレスの環境ではデコードに失敗したため、メッシュは空のジオメトリに差し替えました。揺れものの確認には、ボーンだけで足ります。

実機(Quest 3、ハンドトラッキング)では、髪が押されて動くこと、頭に触れると目を閉じること、体に触れると表情が変わることを確認しました。

8. 制約

項目内容
対象ハンドトラッキングの素手。コントローラーには、球を付けていない
髪の動き髪の設定(硬さ・抵抗)に依存する。使ったモデルの髪は硬めで、動きは大きくない
触れ方触れている間だけ反応する。なでる動き(手の速さ)は見ていない
頭・体の範囲定数で決めた球と線分。モデルの体型によって、調整が要る
VRM確認したのは VRM 0 形式の 1 体(揺れものの関節 224 本)

関連

実装メモは、docs/lead.md にまとめています。

  • 手をつないで歩く: LEAD
  • 手を引っ張って歩かせる実装: #197 LEAD