[Astro] #197 LEAD — VRM の手を引っ張って歩かせる(2 ボーン IK・追従歩行・VR のコントローラーとハンドトラッキング)
はじめに
「LEAD」は、VRM の手をつかんで引っ張ると、手が届く間は腕が伸びて、届かなくなったら、引っ張った分だけ歩いてついてくるアプリです。
- 引っ張る向きを向いて、少し傾いて歩く
- 左右どちらの手でも、つかめる。つかむと、指を握る
- 視線は、OFF / 手 / カメラの 3 通りを切り替える
- VR(Quest 3)では、コントローラー、またはハンドトラッキングで、手をつないで歩ける
- 描画は three.js 0.160.0、VRM は @pixiv/three-vrm 3.2.0、モーションは @pixiv/three-vrm-animation 3.2.0 です
LEAD
VRM の手を引っ張って、歩かせるブラウザアプリ。VR 対応。
lain-lab.comファイルは、次の 2 つです。
| ファイル | 役割 |
|---|---|
lead.astro | ページ、ヘルプ、importmap |
public/tool/lead/lead.js | 本体(読み込み、IK、歩行、VR) |
public/tool/lead/hand/ | ハンドトラッキングの手のモデル(left.glb、right.glb) |
スクリーンショット
起動画面
手を繋いで歩く
動画
PC
WebXR/VR(レーザーポインタ)
WebXR/VR(ハンドトラッキング)
1. 全体の流れ
毎フレーム、次の順に処理します。
- つかんでいる手の目標位置
Tを決める(マウス、またはコントローラー、または手首) - 肩から
Tまでの距離から、歩く速さを決める - 向き・位置・傾きを更新する
- 歩きモーションの重みと再生速度を決める
- ミキサーを進める
- 腕の IK、指の握り、頭の向きを、モーションの上に重ねる
IK などの上書きは、ミキサーを進めたあとに行います。順序が逆だと、モーションに上書きされます。
2. 腕の 2 ボーン IK
上腕・前腕の 2 ボーンの解析解(余弦定理)です。肩 S、目標 T の距離 d を、腕の長さ R = l1 + l2 の 98% までに制限します。完全に伸ばすと、肘の向きが不安定になるためです。
const d = clamp(_dir.length(), 0.05, R * REACH_MARGIN);
const cosA = clamp((l1*l1 + d*d - l2*l2) / (2*l1*d), -1, 1);
const sinA = Math.sqrt(1 - cosA * cosA);
// 肘 = 肩 + 方向 × l1·cosA + 極ベクトル × l1·sinA
_elb.copy(_S).addScaledVector(_dir, l1*cosA).addScaledVector(_pole, l1*sinA);
aim(up, lo, _elb); // 上腕を、肘へ向ける
aim(lo, hand, _T); // 前腕を、手首の目標へ向ける
肘が曲がる向き(極ベクトル)は、「下・後ろ・その腕の外側」の方向を、肩から目標への向きに直交する成分だけ取り出して作ります。左右で外側の符号を反転します。
aim(bone, child, target) は、ボーンから子ボーンへの向きを、target へ向ける回転を、ワールド空間で求めて、親のワールド回転で割り、ローカル回転に直します。
_q.setFromUnitVectors(_b.normalize(), _c.normalize()); // 現在の向き → 目標の向き
bone.getWorldQuaternion(_q2).premultiply(_q);
bone.parent.getWorldQuaternion(_q);
bone.quaternion.copy(_q.invert().multiply(_q2));
IK の重み(0〜1)は、つかんだときに約 0.1 秒で 1 へ、離したときに 0 へ、補間します。重みが 1 未満のときは、ミキサーが出した回転と slerp で混ぜます。
モーションが動かさないボーンは、ミキサーが回転を戻さないので、毎フレーム、休めの姿勢へ戻してから IK をかけます。これを忘れると、前のフレームの結果が残ります。
腕を下ろす処理(T ポーズから 75% 下げる)は、POKE と同じものです。
3. 歩かせる(追従)
手の届く距離を超えた分を、歩く速さに変えます。
const dy = st.T.y - _S.y, sl = R * P.slack / 100;
excess = Math.max(0, dist - Math.sqrt(Math.max(0, sl*sl - dy*dy)));
vT = Math.min(excess * gain, vmax);
distは、肩からTまでの水平距離です- 腕の長さ
slは 3 次元の距離なので、肩と手の高さが違えば、水平に届く距離は短くなります。sqrt(sl² − dy²)で補正します。水平だけで測ると、手が目標に届かないまま、歩き出しませんでした slack(SLACK)が 85% なら、腕が 85% 伸びたところから、歩き出します
実際の速さは、目標の速さへ、時定数 約 0.12 秒で近づけます。
向きは、引く方向へ、上限つきで回します(TURN、rad/秒)。進む向きは、体の向きではなく、引く方向です。向きが追いつくまでは、横歩きになります。
傾きは、進む向きと UP の外積を軸にして、速さ × LEAN の角度を、slerp で追従させます。足元が支点です。
4. 歩きモーション
VRMA の歩きを、速さに合わせて再生します。
- 重み: 速さが 0.4 m/秒のとき 1。止まるとき 0 へ
- 再生速度:
速さ ÷ モーションが進む速さ(下限 0.3)
モーションが進む速さは、次のように求めます。
- 一時的なミキサーで、最初と最後の腰の位置の差を測る
- 移動のあるモーションなら、その実測値を使う
- 腰の移動が小さい(その場で歩く)モーションなら、STRIDE の値(初期 1.2 m/秒)を使う
モーションに腰の移動がある場合は、移動分を直線近似して取り除きます(揺れは残す)。そうしないと、体の位置と、モーションの移動が二重に効きます。
T ポーズのキーと、末尾の外れたキーは、POKE と同じ処理(trimTposeKeys、trimOutlierEnds)で除去します。
5. 指の握り
つかんでいる間、指のボーンを曲げます。握りの重みは、約 0.12 秒で 1 へ。
f.nn.quaternion.multiply(_q.setFromAxisAngle(Z, -a.sign * f.ang * a.grip));
- 軸は、各指のボーンの局所 Z
- 曲げる角度: 人差し〜小指 根元 1.0・中 1.2・先 0.7 rad、親指 0.5 rad
- 符号は、右手 −、左手 +(
a.signが右 +1 / 左 −1) - モーションが指を動かすなら、その上に足す。動かさないなら、休めの姿勢から足す
6. 視線
OFF / HAND / CAMERA を切り替えます。
- HAND: つかんでいる間だけ、その手を見る
- CAMERA: いつでも、カメラを見る(VR では、自分の頭)
頭のボーンに、ワールド空間の差分回転をかけます。体の正面から、横 ±1.0 rad、縦 −0.5〜+0.3 rad まで。重みは、最大 0.7 です。目は、vrm.lookAt.target を見る先に動かします。
7. マウスでつかむ
- 手の位置に、半径 0.1m の玉(深度テストなし)を置き、クリックで取る
- つかんだ手の高さの水平面と、レイの交点を
Tにする。つかんだ位置と手の位置の差を覚えて、ずれを消す OrbitControlsは、つかんでいる間だけ止める。pointerdownはキャプチャ段階で受けて、玉に当たったときだけ、伝播を止める
8. VR(コントローラー)
renderer.xr.enabled = true、参照空間は local-floor。カメラとコントローラーは、rig(Group)の子にして、スティックで rig を動かします。rig は、VRM の正面(0, 0, 2.2)に置きます。
近くの手をつかむ
- グリップ空間(
getControllerGrip)を、手とみなす - VRM の手の玉が 0.2m 以内にあるとき、
squeezestartでつかむ - つかんだ点を、グリップの座標系で覚えておき、毎フレーム世界座標へ戻して
Tにする。手首をひねると、つかんだ点も一緒に動く
遠くの手をつかむ(レーザー)
selectstartで、コントローラーの向きのレイを飛ばし、先の玉をつかむ- 離すのは、つかんだときと同じ種類のイベントのみ(グリップで握って持っているとき、トリガーを離しても離れない)
そのほか
- 同時に持てるのは 1 本
- スティック(
gamepad.axes[2]、[3])で、頭の向きの水平方向へ、1.5 m/秒で動く - B / Y ボタン(
buttons[5])で、VRM も自分も、最初の位置へ戻す - 触覚フィードバック(
hapticActuators[0].pulse)を、つかんだ瞬間に出す - Quest のブラウザで VR に入るには、HTTPS が必要
9. VR(ハンドトラッキング)
VR HANDS ボタンで、CONTROLLER / TRACKING を切り替えます(localStorage に保存)。TRACKING のときだけ、セッション開始時に hand-tracking を optionalFeatures に加えます。機能はセッション開始時にしか決められないので、切り替えは次の VR から効きます。
navigator.xr.requestSession('immersive-vr', {
optionalFeatures: P.hands === 'on' ? ['local-floor', 'hand-tracking'] : ['local-floor'],
});
- 入力が手かどうかは、コントローラーの
connectedイベントのdata.handで判定する - 手のときは、
selectstart(つまみ)でvrGrabHandを呼ぶ - つまんだ点 = 人差し指の先
index-finger-tipと親指の先thumb-tipの中点。0.2m 以内に玉があれば、その玉。なければ、手のレーザーの先の玉 - つかんだ点は、手首
wristの座標系で覚える。毎フレーム、手首のmatrixWorldでTに戻す - 離すのは
selectend
コントローラーを持っているときは、Quest は手を追跡しません。コントローラーを置くと、手に切り替わります。
手のモデル
XRHandModelFactory の mesh プロファイルで、皮膚つきの手を表示します。
import { XRHandModelFactory } from 'three/addons/webxr/XRHandModelFactory.js';
const handFactory = new XRHandModelFactory();
handFactory.setPath('/tool/lead/hand/');
hand.add(handFactory.createHandModel(hand, 'mesh'));
- 既定では、外部の CDN からモデルを読み込みます。自前で配るため、
@webxr-input-profiles/assetsのgeneric-hand/left.glb・right.glb(各約 94KB)を、public/tool/lead/hand/に置きました - 手が玉を隠さないよう、スキンメッシュの材質を複製して、
opacityを 0.6 にします - モデルが読み込めなかったときのため、関節に白い小さな球を付けておき、モデルが読めたら隠します
10. 間違いの記録
- 指が、逆に曲がった: 最初に決めた符号が逆で、指が手の甲側へ反りました。数値の確認だけでは、気づけませんでした。実機で見て分かり、符号を反転しました
- 歩きモーションが進むと思い込んだ:
walk.vrmaは、その場で歩くモーション(腰の移動 1.6cm)でした。進む速さを実測値だけにしていたため、速さが下限に張り付きました。STRIDE を足しました - 届く距離を、水平だけで測った: 肩と手の高さが違うと、腕が伸びきらないうちに、目標に届かなくなりました。3 次元の腕の長さから、水平の届く距離を出す形に直しました
- 視線が不自然だった: 常に手を見る形は、不自然でした。OFF を初期値にして、切り替えにしました
11. ヘッドレスでの確認
Chromium(SwiftShader)で、次を確認しました。
- 読み込み、手の玉が出る
- 引っ張る(プログラムで指定): 3 秒ほどで 1m 近く進み、引く方向へ向く。離すと、速さ・歩き・IK が 0 へ戻る
- 左右の手、握りと視線の重みの立ち上がりと戻り
- VR: 疑似のコントローラーの位置を動かして、近づけて握る、動かすと VRM がついてくる、レーザーでつかむ、種類の違うボタンでは離れない、遠いとつかめない、二重につかまない
- ハンドトラッキング: 疑似の関節を動かして、つまむ、動かすと VRM がついてくる、離す、遠いと何もつかまない
XR のコントローラーのオブジェクトは matrixAutoUpdate = false なので、疑似の位置を入れるテストでは、これを true にする必要があります。
実機(Quest 3)で、コントローラーでのつかみ・スティック移動・B / Y ボタン、ハンドトラッキングのつまみ・手のモデルの表示を確認しました。
12. 制約
| 項目 | 内容 |
|---|---|
| 同時に持つ手 | 1 本 |
| 歩く向き | 引いた方向。向きが追いつくまでは横歩き |
| 歩きモーション | 1 本。走りとの切り替えは無い。速く引くと、足が追いつかない |
| 引っ張る手 | 身体を持たない(マウス、または VR の手・コントローラー) |
| ハンドトラッキング | Quest の設定でオン、コントローラーを置く必要がある。HTTPS が必要 |
| VRM | 確認したのは、AvatarSample_A(VRM 0.x)と自作の VRM(lain05)。指のボーンが無いモデルは、握る動きが出ない |
関連
実装メモは、docs/lead.md にまとめています。