[Astro] #194 RAGDOLL に VR 対応とガラス表現を足す(WebXR・transmission・レーザーで掴む)
はじめに
#193 で作った物理ラグドール「RAGDOLL」に、次の 2 つを足しました。
[Astro] #193 Rapier と three.js で作る物理ラグドール「RAGDOLL」の実装(VRM 対応) // PROTOCOL.LAIN
lain-lab.com の RAGDOLL(Rapier + three.js の物理ラグドール)の実装ガイド。Rapier 0.21 の球関節にモーターが無い問題と自前の PD トルク、蝶番の setLimits、固定刻み、kinematic body とばね関節で掴む仕組み、VRM のボーンから部品を作って正規化ボーンへ書き込む手順を解説。
lain-lab.com- ガラス: 人形・VRM・ボール・箱を、ガラス(背後が屈折して透ける材質)にする GLASS ボタン
- VR: コントローラーのレーザーで人形を掴んで動かす。VR 内のパネルで、DROP・BLAST などを操作する
物理(ragdoll-core.js / ragdoll-vrm.js)は、描画に依存しないので、変更していません。
変えたのは、ragdoll.js の描画と入力、ragdoll.astro のボタンとヘルプだけです。
[Web Tool] RAGDOLL — 人形を落として、掴んで、投げる。VRM も人形にできる物理あそび // PROTOCOL.LAIN
lain-lab.com の RAGDOLL(Rapier + three.js の物理ラグドール)の使い方ガイド。人形を掴んで投げる操作、DROP・BLAST・ADD・RESET、STIFFNESS と TIME、ボール・箱、VRM の読み込み、BODIES の見方、動かないときの見分け方、よくある質問を解説。
lain-lab.comスクリーンショット
動画(GIF)
1. ガラス
材質
MeshPhysicalMaterial の transmission を使います。
const glassEnv = new THREE.PMREMGenerator(renderer).fromScene(new RoomEnvironment(), 0.04).texture;
const GLASS_TINT = 0xd8ecff; // 全部同じ薄い青
function glassMat() {
return new THREE.MeshPhysicalMaterial({
color: 0xffffff, transmission: 1, thickness: 0.3, ior: 1.45, roughness: 0.03, metalness: 0,
attenuationColor: new THREE.Color(GLASS_TINT), attenuationDistance: 1.2,
envMap: glassEnv, envMapIntensity: 2.0, specularIntensity: 1,
});
}
| 項目 | 値 | 役割 |
|---|---|---|
transmission | 1 | 背後を透かす(屈折して見える) |
thickness | 0.3 | 屈折の量 |
ior | 1.45 | 屈折率(ガラス) |
roughness | 0.03 | ほぼ鏡面 |
attenuationColor / attenuationDistance | 薄い青 / 1.2 | 厚みに応じた色づき |
envMap | RoomEnvironment | ガラスの映り込み |
環境マップは、scene.environment にせず、ガラスの材質にだけ渡しています。scene.environment にすると、床や階段など、ほかの材質の見た目も変わるからです。
three.js 0.160 には、色収差の dispersion がありません。使うなら、three.js の更新が必要です(r163 以降のはずです。未確認)。
背景が暗いと、黒く見える
transmission は、背後の描画結果を屈折させて見せる材質です。背後が暗い床と暗い背景だけだと、透けて見えるものが無く、黒い金属のように見えます。
そのため、GLASS が ON の間だけ、床とグリッドを変えます。
floorMat.color.set(glass ? 0x8a8f9a : 0x1a1a1a);
grid.material.color.set(glass ? 0x000000 : 0xffffff);
grid.material.opacity = glass ? 0.2 : 1; grid.material.transparent = glass;
色は一色にする
最初は、部位ごとに attenuationColor を変えていました(胴は緑、腕は青、脚はオレンジ)。半透明の色付きの人形に見えて、ガラスらしくなかったので、全部位を同じ薄い青にしました。
箱の人形・小道具の切り替え
ON / OFF で、メッシュの材質を差し替えます。ON のあとに追加した人形・小道具も、ガラスで作ります。
const mat = ghost || !glass ? solidMat(part, ghost) : glassMat(); // makeMesh
mesh.material = glass ? propGlass[kind] : propMats[kind]; // addProp
VRM の材質の差し替え
VRM は、全メッシュの材質を、ガラス材質 1 つに差し替えます。元の材質は、メッシュの userData.origMat に保存して、OFF で戻します。
function setVrmGlass(vrm, on) {
if (on && !vrmGlassMat) vrmGlassMat = glassMat();
vrm.scene.traverse((o) => {
if (!o.isMesh) return;
if (on) { if (!o.userData.origMat) { o.userData.origMat = o.material; o.material = vrmGlassMat; } }
else if (o.userData.origMat) { o.material = o.userData.origMat; delete o.userData.origMat; }
});
}
- GLASS が ON のまま読み込んだ VRM も、
prepVrmの最後でsetVrmGlass(vrm, true)を呼んでガラスにします - 複数マテリアルのメッシュ(
materialが配列)は、ガラス中は単一の材質になり、OFF で配列に戻ります - 髪と体のように、透過するメッシュ同士は、互いを屈折して見せません。透過のパスで描かれるのは、不透明なものだけです(three.js の仕様のはずです。未確認)
2. VR
構成
| 要素 | 内容 |
|---|---|
| 入口 | navigator.xr.isSessionSupported('immersive-vr') が true のときだけ、◇ ENTER VR を出す |
| rig | カメラ・コントローラー・パネルの親になる Group。立ち位置 (0, 0, 2.2) |
| 掴む | コントローラーの -Z 方向のレイで部品に当てて、トリガーの間、ばねで掴む |
| パネル | canvas に描いて板に貼る。レイで押す |
| 上限 | VR では VRM は 3 体まで(PC は 6 体) |
セッションの開始
renderer.xr.enabled = true;
renderer.xr.setReferenceSpaceType('local-floor');
async function enterVr() {
const s = await navigator.xr.requestSession('immersive-vr', { optionalFeatures: ['local-floor'] });
await renderer.xr.setSession(s);
}
renderer.xr.addEventListener('sessionstart', onVrStart);
renderer.xr.addEventListener('sessionend', onVrEnd);
描画ループは、requestAnimationFrame から renderer.setAnimationLoop(frame) に変えます。XR のセッション中は、こちらでないとフレームが回りません。
onVrStart でやることです。
- カメラの位置と向きを保存して、
rig.add(camera)、rig.positionを(0, 0, 2.2)にする - OrbitControls を止める(VR 中は
controls.update()も呼ばない) - パネルを出す
- GLASS が ON なら、OFF にする(透過は描画が重いため)
- VRM が 4 体以上なら、4 体目以降を消す
onVrEnd では、掴みを全部離して、カメラの位置と向きを戻し、OrbitControls を有効にします。
コントローラーは matrixAutoUpdate が false
renderer.xr.getController(i) が返すオブジェクトは、matrixAutoUpdate = false です。位置は、毎フレーム XR の姿勢から matrix に入ります。
positionやquaternionを直接動かしても、updateMatrix()を呼ばないと反映されません。後述のヘッドレステストで、これに当たりました- 実機では、three.js が
matrixを更新します。コントローラーをrigの子にしておくと、rigの移動に追従します
掴む仕組みを共通化する
PC のマウスと VR のコントローラーで、掴む部分(kinematic body とばね関節)は同じです。startGrab の中身を、makeGrab(part, point) に切り出しました。
function makeGrab(part, p) {
const body = part.body;
const t = body.translation(), q = body.rotation();
const inv = new THREE.Quaternion(q.x, q.y, q.z, q.w).invert();
const local = new THREE.Vector3(p.x - t.x, p.y - t.y, p.z - t.z).applyQuaternion(inv);
const kin = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(p.x, p.y, p.z));
const data = RAPIER.JointData.spring(0.0, GRAB_K, GRAB_C, { x: 0, y: 0, z: 0 }, { x: local.x, y: local.y, z: local.z });
const joint = world.createImpulseJoint(data, kin, body, true);
body.wakeUp();
return { kin, joint, part };
}
ばねの強さ(5000 / 200)は、PC と同じです。
レーザーで掴む
トリガー(selectstart)で、コントローラーのレイを部品に当てます。
function handRay(h) {
h.c.updateMatrixWorld(true);
vrM.identity().extractRotation(h.c.matrixWorld);
vrO.setFromMatrixPosition(h.c.matrixWorld);
vrD.set(0, 0, -1).applyMatrix4(vrM).normalize();
vrRay.set(vrO, vrD);
vrRay.far = 15;
return vrRay;
}
当たった点を、コントローラーの座標系で覚えます。
h.grab = makeGrab(mh.object.userData.part, mh.point);
h.local.copy(mh.point);
h.c.worldToLocal(h.local);
毎フレーム、それを世界座標に戻して、kinematic body の目標にします。手首をひねると、掴んだ部品も、てこのように一緒に振れます。
vrTmp.copy(h.local).applyMatrix4(h.c.matrixWorld);
h.grab.kin.setNextKinematicTranslation({ x: vrTmp.x, y: Math.max(0.05, vrTmp.y), z: vrTmp.z });
- 床より下(y < 0.05)には引きません(PC と同じ)
- 両手で、別々の部品を掴めます(手ごとに
grabを持ちます) - 掴んでいる間、レーザーの長さは、手と掴んだ点の距離にします
- VRM の部品は、見えない当たり判定のメッシュです。
Raycasterは、visible = falseのメッシュにも当たるので、そのまま使えます
VR 内のパネル
DROP、BLAST、ADD、RESET、BALL、CRATE、CLEAR PROPS、GLASS、EXIT VR のボタンを、canvas(512 × 704)に描いて、CanvasTexture として板(0.6 × 0.825 m)に貼ります。
const panel = new THREE.Mesh(new THREE.PlaneGeometry(0.6, 0.825),
new THREE.MeshBasicMaterial({ map: ptex, toneMapped: false }));
panel.position.set(-0.8, 1.25, -0.7); // rig 基準。左前
panel.rotation.set(-0.2, Math.atan2(0.8, 0.7), 0, 'YXZ'); // プレイヤーの方へ向ける
ボタンの当たり判定は、レイと板の交点の UV から、canvas の座標に直して、ボタンの矩形と比べます。
const hit = r.intersectObject(panel, false)[0];
const x = hit.uv.x * PW, y = (1 - hit.uv.y) * PH;
const b = PBTN.find((q) => x >= q.x && x <= q.x + q.w && y >= q.y && y <= q.y + q.h);
- レイが、パネルと部品の両方に当たるときは、近い方を取ります(
ph.dist < mh.distance) - 線を向けたボタンは、ハイライトします。押すと 0.25 秒光ります
- 再描画は、ハイライトが変わったとき、ボタンを押したとき、0.5 秒ごと(Figures / Bodies / FPS の更新)だけです
- ボタンの動作は、PC のボタンと同じ関数を呼びます(
dropAll、doBlast、doReset、addFigure、addProp、clearProps、setGlass)。そのため、PC のボタンの処理を関数に切り出しました
STIFFNESS と TIME は、パネルに置いていません。PC で決めた値を、そのまま使います。
VRM の上限
PC の上限は 6 体です。VR は、過去の実験の経験から 3 体にしました。addFigure の上限を、VR 中は 3 に切り替えます。
if (figures.length >= (vrSession ? MAX_VRM_VR : MAX_VRM)) return;
3. 動作確認の方法
ヘッドレスで確認した範囲
ヘッドレス Chromium では、VR のセッションを作れません。そのため、次の方法で操作の流れを確認しました。
onVrStart()を直接呼ぶ(セッションは、end()だけを持つ偽物を入れる)- コントローラーの
positionを動かして、updateMatrix()を呼び、lookAtで人形に向ける(-Zが向くように、rotateY(Math.PI)を足す) vrSelectStartを呼び、grabが入ることを確認する- コントローラーを 1 m 持ち上げて、胴の高さが上がることを確認する(0.16 m → 1.16 m)
vrSelectEndで離す- パネルのボタンの中心の世界座標を計算して、そこへレイを向け、
vrSelectStartで押す(CRATE で、箱が 1 つ増える) - パネルの canvas を PNG にして、ボタンの重なりを確認する
session.end()で、パネルとカメラが元に戻ることを確認する
この確認で見つけた不具合は、2 つです。
- パネルの最下段(EXIT VR)が、canvas の外にはみ出していた(行の数え間違い)
- コントローラーの
updateMatrix()を呼ばずに位置を動かしていた(テスト側の誤り)
実機で確認したこと
実機の VR で、次を確認しました。
- ENTER VR で入れる
- VRM を 3 体出しても、問題なく動く
- GLASS を ON にしても、動く
- レーザーで人形を掴んで、動かせる
FPS の数値は、測っていません。
まとめ
| 項目 | 内容 |
|---|---|
| ガラス | MeshPhysicalMaterial の transmission。環境マップは材質だけに渡す。暗い背景では、床とグリッドを変える |
| VRM のガラス | 全メッシュの材質を差し替えて、元の材質を userData.origMat に保存する |
| VR の入口 | isSessionSupported が true のときだけ、ボタンを出す |
| 描画ループ | renderer.setAnimationLoop |
| 掴む | レイで当てて、makeGrab(PC と共通)。点をコントローラー座標で覚える |
| パネル | canvas テクスチャ、UV からボタンを判定 |
| 上限 | VR の VRM は 3 体 |
関連
[Astro] #193 Rapier と three.js で作る物理ラグドール「RAGDOLL」の実装(VRM 対応)
Rapier 0.21 の球関節にモーターが無い問題と自前の PD トルク、掴む仕組み、VRM のボーンから部品を作って正規化ボーンへ書き込む手順を解説。
lain-lab.com