[Astro] #195 POKE — VRM をクリックすると傾いて戻る(回転ばね・起き上がりこぼし・VRMA の上に重ねる)

[Astro] #195 POKE — VRM をクリックすると傾いて戻る(回転ばね・起き上がりこぼし・VRMA の上に重ねる)

はじめに

「POKE」は、VRM をクリックすると、押した場所と向きに合わせて体が傾き、揺れながら戻るアプリです。表情も、押した場所に合わせて変わります。VRMA(VRM のモーション)を再生しながら押すこともできるので、殴られたときの反応のようにも使えます。

  • 物理エンジンは使いません。回転の減衰ばねだけです
  • 皮膚の凹み(頂点を押し込む表現)は、やりません。骨の回転と表情だけです
  • 描画は three.js 0.160.0、VRM は @pixiv/three-vrm 3.2.0、モーションは @pixiv/three-vrm-animation 3.2.0 です

動画

RAGDOLL — スタートメニューから起動

1. 最初の案がうまくいかなかった理由

最初は、ボーンの位置を押した向きにずらして、ばねで戻していました。動きますが、戻り方が機械的でした。原因は次の 3 点です。

最初の案問題
骨の位置をずらす位置を動かすと、皮膚が引っ張られて、関節がちぎれたように見える
全部が同時に動く人は、体の中心が先に動いて、先端が遅れて振られる
ばねで戻るだけ起き上がりこぼしのような、ゆっくりした全体の揺れが無い

そこで、次の形に作り直しました。

作り直し内容
回転のばね骨の位置は動かさず、回転だけを動かす
関節ごとに速さを変える体全体はゆっくり、関節は速く揺れる。頭や腕が遅れて振られる
体全体の揺れ足元を支点にして、体を丸ごと傾ける
クリックで押す押したまま動かすのではなく、1 回のクリックで衝撃を入れる

2. 減衰ばねの積分

1 つの回転(3 次元の回転ベクトル th と、角速度 om)に、次の減衰振動を使います。

a = -ω0² θ - 2 ζ ω0 ω

ω0 = 2π f です。f が揺れの速さ、ζ が減衰です。半陰的オイラー法で積分します。

function integrate(s, w0, z, h) {
  s.om.x += (-w0 * w0 * s.th.x - 2 * z * w0 * s.om.x) * h;
  s.om.y += (-w0 * w0 * s.th.y - 2 * z * w0 * s.om.y) * h;
  s.om.z += (-w0 * w0 * s.th.z - 2 * z * w0 * s.om.z) * h;
  s.th.addScaledVector(s.om, h);
  const a = s.th.length();
  const mx = s.max || MAX_ANGLE;
  if (a > mx) s.th.multiplyScalar(mx / a);   // 角度の上限
}

step(dt) では、1 フレームを 1/480 秒以下の小さな刻みに分けて、積分します。フレームが落ちても、揺れが発散しないためです。

const n = Math.max(1, Math.ceil(dt / (1 / 480)));
const h = dt / n;
for (let i = 0; i < n; i++) {
  integrate(fig.body, 2 * Math.PI * f0, z, h);
  for (const j of Object.values(fig.joints)) integrate(j, 2 * Math.PI * f0 * j.f, Math.min(1, z * 1.6), h);
}

回転ベクトルをクォータニオンに直して、骨の回転に掛けます。

3. 体全体: 足元を支点に傾ける

体全体の傾きは、VRM を入れた holder(Group)を回して作ります。ただし、原点まわりに回すと、足が地面から浮きます。足元(腰の真下の地面)を支点にします。

fromRotVec(fig.body.th, holder.quaternion);
holder.position.copy(fig.pivot).sub(tl.copy(fig.pivot).applyQuaternion(holder.quaternion));

pivot は、読み込み時の腰の x・z と、地面の高さ 0 の点です。回してから、支点が元の位置に戻るように位置を引きます。

揺れの速さ(ROCK、初期 0.6 Hz)と減衰(DAMPING、初期 5%)は、左パネルのスライダーで変えられます。

4. 関節: 骨ごとに揺れの速さを変える

背骨・胸・上胸・首・頭・肩・上腕・前腕・太ももに、それぞれ回転のばねを持たせます。揺れの速さは、体全体の 1.9〜2.8 倍、減衰は 1.6 倍です。体より速く揺れるので、頭が遅れて振られます。

const JOINTS = {
  spine: { f: 1.9 }, chest: { f: 2.2 }, upperChest: { f: 2.5 }, neck: { f: 2.8 }, head: { f: 2.0 },
  leftShoulder: { f: 2.4 }, rightShoulder: { f: 2.4 },
  leftUpperArm: { f: 2.2 }, rightUpperArm: { f: 2.2 }, leftLowerArm: { f: 2.8 }, rightLowerArm: { f: 2.8 },
  leftUpperLeg: { f: 2.0 }, rightUpperLeg: { f: 2.0 },
};

押す向きと回転軸

押す向きは、レイの向きを水平にしたものです。回転軸は、上向きとの外積です。

const d = new THREE.Vector3(dir.x, 0, dir.z).normalize();
const axis = new THREE.Vector3().crossVectors(UP, d);   // この軸の正の回転で、上にある部分が d の向きへ倒れる

正面から頭を押すと、押す向きはキャラクターの後ろ向きなので、頭が後ろへ倒れます。

押した骨ごとの力の配り方

押された骨に一番近い骨(20 本の人体ボーンのうち)を選び、その骨ごとに、体全体と各関節へ配る重みを変えます(weightsFor)。

if (name === 'head') return { body: 0.5, spine: 0.1, chest: 0.15, upperChest: 0.3, neck: 1.0, head: 1.4 };

ぶら下がっている腕・脚は、符号が逆です。

押した場所腕・脚の重み動き
胴正慣性で、遅れて揺れる
腕・脚そのもの負押した方向へ振れる

高さとひねり

高い所を押すほど、体全体が大きく傾きます(lever = 押した高さ / 1.3、0.3〜1.2)。また、中心(腰)からずれた所を押すと、ずれと押す向きの外積の鉛直成分で、背骨・胸・首・頭が少しひねられます。

5. クリック判定

pointerdown と pointerup の間で、動きが 6px 以内、時間が 600ms 以内なら、クリックとします。それ以外は、OrbitControls の視点の回転です。

const hit = pick(e.clientX, e.clientY);          // Raycaster で VRM のメッシュに当てる
if (hit) poke(hit.point, ray.ray.direction, power / 5);

スキン付きメッシュのレイキャストは重い

最初は、マウスを動かすたびに pick を呼んで、カーソルの形を変えていました。ところが、クリックした後に FPS が 30 から 12 に落ちました。スキン付きメッシュ(SkinnedMesh)のレイキャストは、当たり判定のたびに頂点をスキニング計算し直すので、重いためです。サンプルの VRM で測ると、1 回あたり約 110ms でした。

対処は、ホバー判定をやめて、判定をクリックのときだけにしたことです。

6. 表情

押した部位に合わせて、表情を出します。強さは 0.45 + 0.4 × strength(最大 1)で、時定数 0.9 秒で消えます。

部位表情
頭・首surprised(無ければ oh)
胴・肩・腰angry
腕・手happy
脚・足sad

VRM がその表情を持っていなければ、変えません。

VRMA が表情を動かしているときは、モーション側の値と、押したときの値の大きい方を出します。押したときの表情が消えたら、表情には触らず、モーションに任せます。EXPRESSION を OFF にすると、どちらの表情も止めます(まばたきだけ残します)。

7. VRMA の上に揺れを重ねる

読み込み

@pixiv/three-vrm-animation の VRMAnimationLoaderPlugin で .vrma を読み、createVRMAnimationClip で、今の VRM 用のクリップにします。

const loader = new GLTFLoader();
loader.register((p) => new VRMAnimationLoaderPlugin(p));
loader.parse(buf, '', (g) => { const anim = g.userData.vrmAnimations[0]; /* ... */ });

const clip = createVRMAnimationClip(anim, vrm);
const mixer = new THREE.AnimationMixer(vrm.scene);
mixer.clipAction(clip).play();

createVRMAnimationClip は、VRMLookAtQuaternionProxy が無いと警告を出して、自動で作ります。先に自分で付けておくと、警告が出ません。

const proxy = new VRMLookAtQuaternionProxy(vrm.lookAt);
proxy.name = 'VRMLookAtQuaternionProxy';
vrm.scene.add(proxy);

更新の順序

モーションは、骨の回転を毎フレーム上書きします。その上に揺れを足すので、順序が大切です。

  1. 前のフレームで保存した、揺れを足す前の姿勢(base)を、骨に戻す
  2. mixer.update(dt): モーションが、骨を上書きする
  3. その結果を base に保存する
  4. 揺れの回転を、premultiply で掛ける
  5. 体全体の傾き(holder)、表情
  6. vrm.update(dt): 正規化ボーンから、実際の骨へ反映
for (const j of Object.values(fig.joints)) if (j.base) j.nn.quaternion.copy(j.base);
fig.mixer.update(dt);
for (const j of Object.values(fig.joints)) {
  fromRotVec(j.th, qa);
  j.base.copy(j.nn.quaternion);
  j.nn.quaternion.premultiply(qa);
}

1 と 3 を入れないと、モーションが動かさない骨(たとえば、右腕だけのモーションの、頭)では、揺れが毎フレーム積み上がって、傾いたまま戻らなくなります。

モーションの切り替え

古いモーションを止めてから新しいモーションを始めると、間の 1 フレームが、素の姿勢(T ポーズ)になります。止めずに、0.3 秒で混ぜながら切り替えます。

fig.action = fig.mixer.clipAction(clip);
fig.action.reset().play();
if (old) { fig.action.fadeIn(0.3); old.fadeOut(0.3); /* 0.4 秒後に old.stop() */ }

モーションを外す(NONE)ときは、全ヒューマノイドボーンを、読み込み直後の回転に戻します。

8. T ポーズが、ときどき見える問題

VMD から変換した VRMA を、放置して再生していると、16.7 秒に 1 回、0.1 秒ほど T ポーズのような姿勢が見えました。

原因

そのファイルを直接解析しました(GLB の JSON と、アニメーションのバッファを読む)。

  • 508 コマ、16.73 秒
  • 0 秒目のコマだけが、初期ポーズです。左上腕が 0.53 rad、手が 0 rad
  • 1 コマ後(0.033 秒)で、左上腕が 1.25 rad、手が 0.44 rad へ跳ぶ
  • 最後のコマは、2 コマ目とほぼ同じ姿勢なので、継ぎ目は滑らか

つまり、ループが一周するたびに、初期ポーズの 1 コマが出ていました。VMD の 0 フレーム目の初期ポーズが、そのまま入ったものです。腕が 30° ほどしか下りていない姿勢なので、T ポーズに見えます。

対処 1: 読み込み時に、端の外れ値のコマを捨てる

人体の回転トラックの、隣のコマとの角度差の合計を、コマごとに求めます。先頭か末尾のコマの値が、中央値の 6 倍 + 0.3 rad を超えたら、そのコマを全トラックから捨てます。

const S = new Array(n).fill(0);                 // S[i] = i-1 コマ目との角度差の合計
for (let i = 1; i < n; i++) for (const t of quats) S[i] += angle(t, i - 1, i);
const med = S.slice(1).sort((a, b) => a - b)[n >> 1];
const lim = med * 6 + 0.3;
if (S[1] > lim) drop.add(0);
if (S[n - 1] > lim) drop.add(n - 1);

全トラックのコマの数がそろっているときだけ、働きます。実物のファイルで、腕が上がるコマが 3 回から 0 回になりました。

対処 2: ファイル自体を直す

GLB を直接書き換えて、全アクセサの先頭の 1 コマを削除しました。時刻のアクセサは、全トラックで共有されているので、1 回だけ、先頭を削除して、残りの時刻を 1 コマぶんずらします。値のアクセサは、トラックごとに、先頭の要素を削除します。バイナリを前へ詰めて、count と min / max を更新します。

試した対処で、原因ではなかったもの

対処結果
0 秒目の全ボーンが、回転なし(T ポーズ)のコマを探して捨てる実物は、完全な T ポーズではなく、腕が 30° ほど上がった姿勢だったので、反応しなかった
腕と脚の、ほぼ全部が回転なしのコマを、直前の姿勢に置き換える同じ理由で反応しなかった
クリックの揺れで、腕が開くのを抑える(腕の上限 0.45 rad)放置でも出るので、原因ではなかった。ただし、害は無いので残した

原因の特定には、「全部が回転なし」と決めつけずに、実際のファイルの各コマの値を読むのが確実です。

9. Draco 圧縮の VRM

Draco 圧縮された VRM を読むと、No DRACOLoader instance provided. で止まります。GLTFLoader に DRACOLoader を付けます。

const draco = new DRACOLoader();
draco.setDecoderConfig({ type: 'js' });
draco.setDecoderPath('/tool/draco/');
loader.setDRACOLoader(draco);

type: 'js' にすると、draco_decoder.js だけで動きます。wasm 版(draco_wasm_wrapper.js と draco_decoder.wasm)を使う場合は、ラッパーのファイルも、同じフォルダに必要です。

10. ヘッドレスでの確認方法

実機が無い環境でも、Playwright の Chromium(SwiftShader、2〜3 fps)で、動きを数値で確認しました。

  • 描画のループを待たず、step(1/60) を直接呼んで、時間を進める
  • 外部ネットワークを遮断したページで、importmap を、ローカルのパッケージに向ける
  • 押す → 骨の最大角度、10 秒後の残りの揺れ(ほぼ 0 に戻ること)を測る
  • 手作りの歩きモーション(腕・脚・背骨・腰の回転を、30fps で 3 秒)に、ランダムなクリックと、ばらつきのある dt(1/60・1/30・0.1 秒)を入れて、4000 フレーム回す
  • 骨の回転から、「腕が T ポーズ付近になったコマ」を数える

描画の速さ(FPS)は、この方法では測れません。実機で確認しました。

11. 制約

項目内容
凹みしない(骨と表情だけ)
VR未対応
モーションの腰の移動傾きの支点は、読み込み時の腰の位置。腰が大きく動くモーションでは、支点がずれる
動作確認Windows の Chrome(lain05.vrm、walk.vrma、Draco 圧縮の VRM)。ほかのブラウザ・機種は未確認