[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 です
POKE
lain-lab.com の、VRM をクリックして押すと、傾いて、起き上がりこぼしのように戻るブラウザアプリ。VRMA を再生しながら押せます。
lain-lab.com動画
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);
更新の順序
モーションは、骨の回転を毎フレーム上書きします。その上に揺れを足すので、順序が大切です。
- 前のフレームで保存した、揺れを足す前の姿勢(
base)を、骨に戻す mixer.update(dt): モーションが、骨を上書きする- その結果を
baseに保存する - 揺れの回転を、
premultiplyで掛ける - 体全体の傾き(
holder)、表情 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)。ほかのブラウザ・機種は未確認 |