[Astro] #194 RAGDOLL に VR 対応とガラス表現を足す(WebXR・transmission・レーザーで掴む)

[Astro] #194 RAGDOLL に VR 対応とガラス表現を足す(WebXR・transmission・レーザーで掴む)

はじめに

#193 で作った物理ラグドール「RAGDOLL」に、次の 2 つを足しました。

  • ガラス: 人形・VRM・ボール・箱を、ガラス(背後が屈折して透ける材質)にする GLASS ボタン
  • VR: コントローラーのレーザーで人形を掴んで動かす。VR 内のパネルで、DROP・BLAST などを操作する

物理(ragdoll-core.js / ragdoll-vrm.js)は、描画に依存しないので、変更していません。

変えたのは、ragdoll.js の描画と入力、ragdoll.astro のボタンとヘルプだけです。

スクリーンショット

RAGDOLL WebXR RAGDOLL WebXR

動画(GIF)

RAGDOLL WebXR

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,
  });
}
項目値役割
transmission1背後を透かす(屈折して見える)
thickness0.3屈折の量
ior1.45屈折率(ガラス)
roughness0.03ほぼ鏡面
attenuationColor / attenuationDistance薄い青 / 1.2厚みに応じた色づき
envMapRoomEnvironmentガラスの映り込み

環境マップは、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 のセッションを作れません。そのため、次の方法で操作の流れを確認しました。

  1. onVrStart() を直接呼ぶ(セッションは、end() だけを持つ偽物を入れる)
  2. コントローラーの position を動かして、updateMatrix() を呼び、lookAt で人形に向ける(-Z が向くように、rotateY(Math.PI) を足す)
  3. vrSelectStart を呼び、grab が入ることを確認する
  4. コントローラーを 1 m 持ち上げて、胴の高さが上がることを確認する(0.16 m → 1.16 m)
  5. vrSelectEnd で離す
  6. パネルのボタンの中心の世界座標を計算して、そこへレイを向け、vrSelectStart で押す(CRATE で、箱が 1 つ増える)
  7. パネルの canvas を PNG にして、ボタンの重なりを確認する
  8. 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 体

関連