[Astro] #193 Rapier と three.js で作る物理ラグドール「RAGDOLL」の実装(VRM 対応)

[Astro] #193 Rapier と three.js で作る物理ラグドール「RAGDOLL」の実装(VRM 対応)

はじめに

関節でつながった人形(ラグドール)を落として、マウスで掴んで、投げて遊ぶアプリ「RAGDOLL」を作りました。物理エンジンは Rapier(WASM)、描画は three.js です。

この記事は、その実装をまとめたものです。

  • 人形: 箱・カプセル・球の 11 部品と、関節 10 本(球関節 6、蝶番 4)
  • 操作: マウスで掴んで投げる、爆風、STIFFNESS(姿勢を戻す力)、TIME(スロー)
  • VRM: ボーンの位置から部品の大きさと位置を自動で決めて、物理の動きを VRM に書き込む
  • 場面: 坂、階段、低い壁、ボール、箱

スクリーンショット

SWF Reel

動画

人体モデルでやると心無い事をやってるように見えますが、モデル制作者様などへの悪意はないです。
あくまでこういう事も出来るという実験で動かしてるだけなのでご了承ください。

SWF Reel

構成

パス中身
src/pages/ragdoll.astro画面(左パネル + 3D ビュー)、importmap
public/tool/ragdoll/ragdoll-core.js人形の定義と操作。Rapier は引数で受け取り、three.js に依存しない(Node でも動く)
public/tool/ragdoll/ragdoll-vrm.jsVRM のボーン位置から、人形の仕様を作る。three.js にも Rapier にも依存しない
public/tool/ragdoll/ragdoll.js描画、マウス操作、ボタン、VRM の読み込みと姿勢の書き込み

物理の部分(core / vrm)を、描画から切り離しています。Node で、画面なしに、落下・関節のずれ・負荷を測れるからです。

ライブラリは、importmap で読み込みます。

<script type="importmap">
{ "imports": {
  "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
  "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/",
  "@pixiv/three-vrm": "https://unpkg.com/@pixiv/three-vrm@3.2.0/lib/three-vrm.module.js",
  "@dimforge/rapier3d-compat": "https://unpkg.com/@dimforge/rapier3d-compat@0.21.0/dist/rapier.mjs"
} }
</script>

Rapier は @dimforge/rapier3d-compat(0.21.0、Apache-2.0)です。WASM が文字列で埋め込まれた ESM 1 本(約 4.3MB)なので、別のファイルを置く必要がありません。初期化は、await RAPIER.init() です。

人形の定義

座標は、メートル、Y が上、+Z が正面です。股関節の高さを原点にして、部品の中心を決めます。

部品形中心(原点から)
pelvis箱 (0.16, 0.09, 0.10)(0, 0.05, 0)
torso箱 (0.20, 0.20, 0.11)(0, 0.35, 0)
head球 r=0.12(0, 0.67, 0)
上腕 ×2カプセル(半長 0.11、r=0.05)(±0.28, 0.34, 0)
前腕 ×2カプセル(半長 0.10、r=0.045)(±0.28, 0.035, 0)
太もも ×2カプセル(半長 0.17、r=0.07)(±0.09, -0.24, 0)
すね ×2カプセル(半長 0.16、r=0.055)(±0.09, -0.695, 0)

腕は、体の横に下ろした姿勢です。剛体の設定は、次のとおりです。

  • 密度 400、摩擦 0.9、反発 0.05
  • 減衰: 直線 0.05、回転 0.8
  • CCD を有効にする(速い部品が床をすり抜けないように)
const desc = R.RigidBodyDesc.dynamic()
  .setTranslation(x, y, z)
  .setLinearDamping(0.05).setAngularDamping(0.8)
  .setCcdEnabled(true);

関節

関節種類制限
腰(骨盤-胴)、首、肩 ×2、股 ×2球関節なし
肘 ×2蝶番(X 軸)-2.4〜0 rad(前へ曲がる)
膝 ×2蝶番(X 軸)0〜2.5 rad(後ろへ曲がる)

関節の位置は、2 つの部品の中心からの差として、それぞれの局所座標で渡します。

const a1 = { x: at[0] - A.localPos[0], y: at[1] - A.localPos[1], z: at[2] - A.localPos[2] };
const a2 = { x: at[0] - B.localPos[0], y: at[1] - B.localPos[1], z: at[2] - B.localPos[2] };
const data = R.JointData.revolute(a1, a2, { x: 1, y: 0, z: 0 });
const joint = world.createImpulseJoint(data, A.body, B.body, true);
joint.setContactsEnabled(false);   // つながった部品どうしはぶつからない

関節でつながった部品どうしは、setContactsEnabled(false) で衝突させません。つながった部品が、互いを押し返して暴れるのを防ぎます。

Rapier 0.21 で踏んだこと

蝶番の制限は、作ったあとに setLimits で渡す

JointData に角度の範囲を入れても、効きませんでした(膝が -2.0〜2.9 rad まで動きました)。関節を作ったあとに、setLimits を呼ぶと、設定どおりに止まります。

if (j.type === 'rev') joint.setLimits(j.limits[0], j.limits[1]);

球関節に、モーターのメソッドが無い

型定義(d.ts)には、球関節のモーターを設定するメソッドがあります。しかし、実行時の球関節は、汎用の関節として返り、configureMotorModel などが関数として存在しません(is not a function)。

「力の入り具合」(STIFFNESS)は、モーターを使わず、自前で実装しました。

STIFFNESS: 姿勢を戻すトルク

STIFFNESS は、球関節を、元の姿勢(まっすぐ)へ戻そうとする力です。0 で完全にぐにゃぐにゃ、100 で、強く戻ります。毎ステップ、親に対する子の回転のずれに比例したトルクを、親と子に逆向きにかけます。

  1. 親に対する子の回転を求める: qrel = inverse(qa) * qb(近い方の回りを取る)
  2. 角度 ang と、回転軸(親の座標系 → 世界座標)を求める
  3. PD 制御のトルクを求める
    • ω0 = 0.2 × STIFFNESS(rad/s。STIFFNESS 100 で 20 rad/s)
    • kp = ω0²、kd = 2 × 0.7 × ω0(減衰比 0.7)
    • τ = -kp × ang × 軸 - kd × (子の角速度 - 親の角速度)
  4. 2 つの部品の有効な慣性 I = ia·ib / (ia + ib)(principalInertia の最大値の調和平均)を掛けて、applyTorqueImpulse(τ · I · dt) を、子には正、親には逆向きに加える

最初は、質量に比例したトルクにしたところ、発散しました。慣性ベースに直して、ω0 を 20 rad/s 以下に抑えると(ω0·dt ≈ 0.17)、陽解法でも安定しました。

Node で測った結果です(落下後の、関節の姿勢のずれの平均)。

STIFFNESS02550100
平均のずれ(rad)2.121.780.950.55

STIFFNESS を上げても、人形は立ちません。関節が、元の姿勢へ戻ろうとするだけで、バランスは取りません。

固定刻みで回す

物理は、1/120 秒の固定刻みです。描画のフレームの時間をためて、足りた分だけ進めます。

acc += dt * timeScale;                 // TIME スライダーは、この倍率
while (acc >= DT && steps < MAX_STEPS) {
  for (const f of figures) applyStiffness(f.rd, DT);   // 姿勢を戻すトルクは、step の前
  world.step();
  acc -= DT; steps++;
}
if (steps === MAX_STEPS) acc = 0;      // 遅れすぎたら、取り戻さず捨てる

1 フレームの上限は 8 回です。重くなったときに、遅れを取り戻そうとして、さらに重くなるのを防ぎます。ソルバーの反復は 8(world.numSolverIterations = 8)です。

マウスで掴む

掴んだ点に、当たり判定のない kinematic body を作り、掴んだ部品の局所点との間に、ばね関節を張ります。

const kin = world.createRigidBody(
  RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(p.x, p.y, p.z));
const data = RAPIER.JointData.spring(0.0, 5000, 200,      // 自然長, 剛性, 減衰
  { x: 0, y: 0, z: 0 }, { x: local.x, y: local.y, z: local.z });
world.createImpulseJoint(data, kin, part.body, true);
  • 掴んだ点は、inverse(体の回転) で、その部品の局所座標に直します
  • kinematic body は、カメラに正対する面(掴んだ点を通る)の上で、マウスに追従させます(setNextKinematicTranslation)。y は 0.05 以上にして、床より下へ引かないようにします
  • 離すときは、kinematic body を消すだけです(付いている関節も消えます)。投げは、ばねで引かれていた速度が、そのまま残ります
  • 掴んでいる間は、OrbitControls を無効にします

ばねの強さは、Node の実験で決めました(剛性 5000、減衰 200)。手元と、掴んだ点の差が 7cm 以内で、関節のずれは 1mm でした。弱いと、人形が追いつかず、強いと、関節が伸びます。

掴む対象は、見えている部品の絵に対して、レイキャストで選びます。VRM のときは、見えない(visible = false の)物理の部品に対して当てます。Raycaster は、visible を見ないので、見えなくても当たります。

VRM を人形にする

箱の人形は、部品の寸法を、固定で持っています。VRM は、モデルごとに体の大きさが違うので、ボーンの位置から、部品の大きさと位置を自動で決めます。

1. 仕様を作る(ragdoll-vrm.js)

休め姿勢(T ポーズ)の、生ボーンの世界座標を取ります。

for (const name of Object.keys(vrm.humanoid.humanBones)) {
  vrm.humanoid.getRawBoneNode(name).getWorldPosition(v);
  P[name] = [v.x, v.y, v.z];
}

specFromBones(P) は、これを受け取って、部品と関節の仕様を返します。必要なボーンは 16 本(hips、spine、neck、head、腕 3 × 2、脚 3 × 2)で、欠けているときは、エラーにします。

  • 原点: 左右の upperLeg の中点(股関節)
  • 倍率 k = 頭ボーンの高さ / 1.45(箱の人形の頭の高さに対する倍率)
  • 骨盤: 箱。下は股関節の少し下、上は spine の高さ。幅は、脚の間隔 + 0.07k
  • 胴: 箱。spine から neck まで。幅は、肩幅の半分 × 0.72
  • 頭: 球。半径は、頭ボーンの高さ × 0.08。中心は、頭ボーンの少し上
  • 腕・脚: ボーンからボーンまでのカプセル(半径は、上腕 0.05k、前腕 0.045k、太もも 0.07k、すね 0.055k)
  • すねの先端: 球が地面(y=0)に触れる高さにそろえる
  • 関節: 腰・首・肩・股は球関節。肘は Y 軸、膝は X 軸の蝶番。肘の向きは、上腕の X 座標の符号で決める(+X 側は -2.4〜0、-X 側は 0〜2.4)

2. 斜めの手足は、コライダーを回す

T ポーズの腕は、横(X 方向)に伸びています。体を回すと、関節の軸や、休め姿勢の基準が、部品ごとにずれます。そこで、体の休め姿勢の回転は、全部の部品で恒等にして、カプセルの向きは、コライダーだけを回して表します。

if (def.colQ) cd.setRotation({ x: q[0], y: q[1], z: q[2], w: q[3] });

colQ は、(0,1,0) を、ボーンの方向へ回すクォータニオンです(qFromYTo)。こうすると、蝶番の軸を、世界座標のまま、2 つの部品に同じ値で渡せます。そして、部品の回転が、そのまま、そのボーンの「休め姿勢からの回転」になります。

3. 正規化ボーンへ書き込む

VRM のボーンは、モデルごとに、休め姿勢の向きが違います。three-vrm の正規化ヒューマノイドは、休め姿勢の向きが全部同じなので、そちらへ書きます。

ボーンごとに、従う部品を決めます(ownerOf)。

部品従うボーン
pelviships
torsospine, chest, upperChest, neck, 左右の shoulder
headhead, jaw, eyes
armX_upupperArm
armX_lolowerArm, hand, 指すべて
legX_upupperLeg
legX_lolowerLeg, foot, toes

部品が無いボーンは、親の回転にそのまま従います。

毎フレーム、親が先になる順(階層の深さ順)に、次を計算します。

// R0: 正規化ルートの世界回転(VRM 1.0 は恒等、VRM 0.x は Y 軸 180°)
e.wq.set(q.x, q.y, q.z, q.w).multiply(R0);        // 世界回転 = 部品の回転 × R0
tq.copy(parentWq).invert();
e.node.quaternion.copy(tq).multiply(e.wq);         // ローカル回転 = 親の世界回転の逆 × 自分

hips の位置だけ、書き込みます。

  • 骨盤の中心 + 回転した「中心から hips ボーンまでの差」を、ルートのローカル座標へ直して position に入れます
  • ほかのボーンの位置は、階層の長さから決まります。関節の位置は、ボーンの位置から作ってあるので、書かなくても、物理の関節と同じ位置になります

最後に、vrm.update(dt) を呼びます(髪・服の揺れ、表情、ノード制約)。dt は、1/30 秒を上限にします。

4. 置き直したときは、揺れ物をリセットする

DROP や RESET で、人形を高い所に置き直すと、髪や服が、遠くへ飛んだ位置から、一気に引き戻されます。置き直した直後のフレームだけ、次を呼びます。

vrm.humanoid.update();
vrm.scene.updateMatrixWorld(true);
vrm.springBoneManager.reset();

5. VRM 0.x

VRM 0.x は、-Z が正面なので、VRMUtils.rotateVRM0(vrm) で、+Z へ向けます。このとき、正規化ルートの世界回転が、Y 軸 180° になるので、上の R0 を掛けます。R0 を、読み込み時に getWorldQuaternion で取っておけば、VRM 1.0 と 0.x を同じ式で扱えます。

読み込み

  • ファイルは、GLTFLoader.parse に ArrayBuffer を渡して読みます(サーバーへは送りません)
  • DROP は、VRM を読み直さず、物理の体だけ作り直します
  • ADD は、1 体ごとに、ファイルを読み直します(最大 6 体)。three-vrm のインスタンスは、複製できないためです
  • 起動時に、ragdoll.astro 先頭の DEFAULT_VRM(/models/lain05.vrm)を、自動で読みます。無ければ、箱の人形のままです

場面と小道具

場面(buildStage)は、次のとおりです。描画は、buildStage が返す寸法から作るので、ページ側に寸法を持ちません。

  • 床: 60m 四方
  • 坂: 奥(-Z)が高い、0.38 rad の板
  • 階段: 右(+X)に 6 段(1 段 0.2m、奥行き 0.7m)
  • 低い壁: 左(-X)

小道具(buildProp)は、動く剛体です。

  • ボール: 半径 0.3m、密度 150、反発 0.55、摩擦 0.6
  • 箱: 一辺 0.5m、密度 250、反発 0.1、摩擦 0.8
  • 人形の上 4.5m から落とします。最大 12 個で、超えると古いものから消します
  • 人形と同じ仕組みで、掴んで投げられます

爆風(blastBodies)は、中心からの距離に応じた衝撃を、外向き + 少し上向きに加えます。

const k = power * body.mass() / (0.6 + d * 0.5);
body.applyImpulse({ x: dx * k, y: dy * k, z: dz * k }, true);

加えて、ランダムなトルク(各軸 ±0.4 × 質量)を与えます。小道具は、4m 以内のものだけが対象です。

床の外へ落ちたもの(重心の y が -15 より下)は、人形は落とし直し、小道具は消します。

検証

Node(Rapier 単体)

  • 落下: NaN なし、関節のずれ 1.1cm 以下
  • 蝶番の制限: 肘・膝とも、設定値どおり
  • BLAST(power 4 / 8 / 16 / 30): 最大速度 9.7 / 17.9 / 19.4 / 35.2 m/s、安定
  • ボーンから作った人形(疑似の T ポーズ)の落下: 関節のずれ 0.1mm、NaN なし
  • 負荷(人形の数と、1 ステップの時間):
人形の数151020
ms/step0.110.470.992.24

ヘッドレス Chromium

  • 箱の人形: 落下、掴んで持ち上げ、離す、ADD、BLAST、STIFFNESS、DROP、F1 / Esc、RESET
  • VRM: pixiv/three-vrm の VRM1_Constraint_Twist_Sample.vrm(VRM 1.0)と、AvatarSample_A.vrm(VRM 0.x)。休め姿勢で正面(+Z)を向き、腕・胴の部品が VRM の形に重なる。落下、DROP、ADD、BLAST、BOX への切り替え、RESET。胴を掴んで持ち上げると、髪が揺れる
  • 小道具: ボール・箱の追加、ボールを掴む、BLAST、上限 12 個、落下ガード、CLEAR

実機(Windows の Chrome)

  • 箱の人形、VRM(外部の VRM を含む)、場面、小道具が動く
  • 実測の例: VRM 3 体(35 ボディ)で、60 fps、0.27 ms/step

未確認

  • Quest、スマートフォン、Safari、Firefox
  • デフォルメの強い体型(大きな頭、短い手足)の VRM(部品の大きさは、比率で決めているので、合わないことがある。BODIES で、物理の部品を見て確かめられる)
  • 小道具や人形が、さらに多いときの FPS
  • ボーンが欠けた VRM(16 本が無いときは、エラーにして読み込まない)

制限

  • 首、手首、足首、指は、曲がりません(親の回転に従います)
  • 髪・服は、物理の対象ではありません(VRM 自身の揺れで動くので、スカートが体にめり込むことがあります)
  • A ポーズの VRM 0.x では、肘の蝶番の軸(Y)が、腕の向きに対して、少しずれます
  • GLB(VRM 以外のボーン付きモデル)は、未対応です

まとめ

  • Rapier の球関節は、JS では汎用関節で返り、モーターが使えません。姿勢を戻す力は、自前の PD トルクにしました。
  • 蝶番の制限は、setLimits で渡します。
  • 掴む仕組みは、kinematic body + ばね関節です。離すと消すだけで、投げになります。
  • VRM は、ボーンの位置から部品を作り、斜めの手足は、コライダーを回して表します。物理の回転 × R0 を、正規化ボーンへ書きます。
  • 物理(core / vrm)を、描画から切り離すと、Node で、画面なしに測れます。