[Astro] #200 three-vrm の揺れものに、動く球をぶつける(髪・服に指で触れる反応と、3.5 の colliderMatrix)
はじめに
TOP ページの VR マスコットに、ハンドトラッキングの素手で触れると、反応する機能を足しました。
- 指先と手のひらが髪・服に触れると、揺れものが押されて動く
- 頭に触れると、目を閉じる
- 体に触れると、表情が変わる(既定は angry 50%)
作りは、WiredLead.tsx(手をつないで歩く機能と同じファイル)に、触れる処理を足したものです。他のファイルの変更は、表情の反映の 2 か所だけです。
使ったバージョンは、次のとおりです。
| 項目 | バージョン |
|---|---|
| three | 0.183.2 |
| @pixiv/three-vrm | 3.5.5 |
| @react-three/fiber | 9.6.1 |
| @react-three/xr | 6.6.29 |
スクリーンショット
ハンドトラッキングモードで、髪や体に触れる事ができます。
1. 全体の流れ
毎フレーム、useFrame の中で、次の順に処理します。
- 手の関節(指先 5 点と手のひら)の位置を、XR のフレームから読む
- その点に球の当たり判定を置く
- 球を、すべての揺れものの衝突相手に登録しておく(モデルが変わったときだけ)
- 頭・体との距離から、目を閉じる量と表情の量を決める
揺れものの計算は、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.0 | collider.matrixWorld | updateMatrixWorld() でも updateWorldMatrix() でも更新される |
| 3.5.5 | collider.colliderMatrix | updateWorldMatrix() だけ。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.0 | updateMatrixWorld(true) | 約 2.2cm | 0 |
| 3.5.5 | updateMatrixWorld(true) | 0 | 0 |
| 3.5.5 | updateWorldMatrix(true, false) | 約 2.2cm | 0 |
VRM は、実際に使っているモデルです。このモデルは Draco 圧縮のメッシュを持ち、ヘッドレスの環境ではデコードに失敗したため、メッシュは空のジオメトリに差し替えました。揺れものの確認には、ボーンだけで足ります。
実機(Quest 3、ハンドトラッキング)では、髪が押されて動くこと、頭に触れると目を閉じること、体に触れると表情が変わることを確認しました。
8. 制約
| 項目 | 内容 |
|---|---|
| 対象 | ハンドトラッキングの素手。コントローラーには、球を付けていない |
| 髪の動き | 髪の設定(硬さ・抵抗)に依存する。使ったモデルの髪は硬めで、動きは大きくない |
| 触れ方 | 触れている間だけ反応する。なでる動き(手の速さ)は見ていない |
| 頭・体の範囲 | 定数で決めた球と線分。モデルの体型によって、調整が要る |
| VRM | 確認したのは VRM 0 形式の 1 体(揺れものの関節 224 本) |
関連
実装メモは、docs/lead.md にまとめています。