[JavaScript] Three.js WebXR:VRコントローラのレーザーポインタでUIボタンを押す(Raycaster入門)

はじめに

昨日の続きで、コントローラーでレーザーポインタでボタンをクリックする処理まで実装出来たので、メモを残しておきます。

追記:

先日、動画再生機能を実装しましたが、本日実装したレーザーポインタで再生制御できるパネルを設置し、雑ですがボタンを実装してみました。

VRでキャプチャした動画、音が出るのでボリュームに注意してください。

モデルは、「sketchfab」からお借りしてます。

余談ですが、色々構想を練ってるのですが、調べたところ実装次第ですが広大なフィールドを作る事も可能なようです。

やりたい事が多すぎて何から手を付けていいか悩んでますが、現状だと平面移動しかできないのでジャンプで

1. Three.js × WebXR でコントローラUIを作る

Three.js で WebXR(VR)を扱うとき、PC のマウス操作とは全く違う UI の作り方が必要になる。 VR空間は「空間そのものが UI」になるので、

  • コントローラの向き
  • 3D空間上での当たり判定
  • レーザーポインタを使ったクリック

こうした要素を組み合わせて初めて“押せるUI”が成立する。

ここでは 実際にあなたが昨日つかんだ要点を踏まえつつ、ステップ形式で必要な概念を整理する。


コントローラのレーザーポインタ

VR コントローラは「3D空間上の位置と向き」を持つ。 Three.js ではこれを renderer.xr.getController() で取得できる。

レーザーポインタは以下の2つを分けて考えると理解が早い:

  1. 見た目のレーザー(Line)

    • コントローラから伸びる赤い線
    • “UI操作している感”を与えるだけのオブジェクト
  2. 判定用のレイ(Raycaster)

    • Three.jsの Raycaster を使って 「この方向にビームを飛ばす」
    • 当たったオブジェクトを取得するための処理

見た目と判定は完全に別物ということだけ押さえておけばOK。


ボタン押下

VR UI ボタンは、基本的にこれで成立する:

  • 3D空間に Plane(板)を置く
  • そのメッシュに userData.onClick を付ける(コールバック)
  • レイが当たり、かつコントローラのトリガーが押されたら実行する

つまり“2条件成立で押される”仕組み。

Three.js は「UIコンポーネントの仕組み」を持っていないので、 こういう低レベルな部分から作ることになる。

VR UI を作る上で最初に理解するべき動きが一番シンプルにまとまっている。


基本レイキャスト

レイキャストの要点は次の3つ。

  1. origin(どこから飛ぶか) → VR コントローラのワールド座標

  2. direction(どっちへ飛ぶか) → コントローラの向き(-Z)

  3. 毎フレーム更新する必要がある → コントローラは動き続けるため、初期化では不十分

特に direction の算出は Three.js の “matrixWorld から回転だけを抜き出す” 方法が定番:

tempMatrix.extractRotation(controller.matrixWorld);
raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);

extractRotation() を使う理由は 移動やスケールを無視して、純粋に向きだけを反映させたい から。 この公式パターンを押さえておくと、VR の挙動が一気に安定する。


部品をつなげると “VR空間でクリックできるUI” になる

  • コントローラの位置と向きを読む
  • レーザーを表示する
  • Raycaster で当たりを取る
  • トリガー入力を拾って onClick を実行する

2. レーザーポインタの生成(Object3D + Line)

VRコントローラの“レーザーポインタ”は、Three.js だと Object3D に Line を生やすだけで作れる。 ただし、この時点で理解しておくべきポイントが 3 つある。


-Z 方向が前

Three.js の座標系では ローカルの -Z が「前方」。 これを知らないと、レーザーポインタの方向が逆を向いたり、横に飛んだりする。

VR コントローラからレーザーを出す場合、まずローカル座標で

new THREE.Vector3(0, 0, -1)

を前方ベクトルとして採用する。

Line の生成もこれに沿って作られる:

const geometry = new THREE.BufferGeometry().setFromPoints([
  new THREE.Vector3(0, 0, 0),
  new THREE.Vector3(0, 0, -1)
]);

この時点では まだローカル方向。 つまり、「コントローラが前"だと思っている"方向」。


controller.matrixWorld の話

VRコントローラの姿勢は controller.matrixWorld に全て入っている。

  • 位置(translation)
  • 回転(rotation)
  • スケール(scale)

全部セットで保持されている。

ただし、レイの方向に必要なのは 回転だけ。

なので、Three.js の定番処理として:

tempMatrix.extractRotation(controller.matrixWorld);

こうして「回転部分だけを抽出」する。

その後、レイの origin を位置に合わせ、

raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);

ローカル座標で前方と定義した (0,0,-1) に回転だけを適用する:

raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);

これが “コントローラが向いている世界方向” に変換された前方ベクトルになる。

毎フレームこれを行うことで、コントローラの向きとレイが完全に一致する。


表示用レーザーと当たり判定は別物

初心者がよく混同する点だが、ここは完全に切り離されている。

表示用レーザー

  • Three.Line で可視化した線
  • 見た目しか担当しない
  • 当たり判定には関与しない
  • controller.add(line) でコントローラの子になる
  • ローカル空間で -Z 方向に伸びるだけ

当たり判定のレイ(Raycaster)

  • レーザーの“正体”
  • 見えない
  • raycaster.ray.origin/direction を自分で毎フレーム更新
  • intersectObjects() で衝突チェックする

両者は見た目とロジックをきっちり分離して作る。 この設計を理解していると、あとで UI ボタンや 3D オブジェクト操作を追加するときに混乱しない。

3. raycaster の方向を毎フレーム更新する必要性

WebXR の VR コントローラは、フレームごとに位置も向きも変化し続ける。 だから、レイキャスト(Raycaster)に設定する origin(発射位置) と direction(発射方向) を “初期化だけ” で指定しても意味がない。

ここを理解できていないと、次の症状が必ず起きる:

  • レーザーが当たっているように見えるのにボタンにヒットしない
  • コントローラを動かすとレイがズレ始める
  • トリガーを押しても onClick が発火しない
  • レイの方向がフリーズする

原因はすべて 「レイ方向を毎フレーム更新していない」 という一点に集約される。


コントローラは“生き物”

WebXR のデバイスは、60〜90fps で姿勢(position/rotation)が更新される。 つまり、行列 matrixWorld も毎フレーム変わる。

レイキャスト用の origin/direction は、その 生きた姿勢に合わせて更新する必要がある。


行列から “回転だけ” 抜き出す理由

tempMatrix.extractRotation(controller.matrixWorld);

matrixWorld は「位置・回転・スケールすべて入り」の 4×4 行列。 direction(前方ベクトル)を求める時に必要なのは 回転だけ。

もし extractRotation を使わずに matrixWorld をそのまま direction に適用すると:

  • スケールが影響してレイ方向が歪む
  • 平行移動の成分が混入して方向がズレる

という不具合が出る。

extractRotation() を使うことで、 純粋な「向き」だけを抽出できる。


origin(発射位置)はワールド座標から取る

raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);

ここは「コントローラの世界座標そのまま」。 レイのスタート地点になる。


direction(前方)はローカル -Z を回転して世界方向に変換

raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);

手順はこう:

  1. (0,0,-1) は“ローカルの前方向”
  2. extractRotation で取得した回転行列を適用
  3. “世界空間での前方向” に変換される

これで、 コントローラが向いている方向 = レイが飛ぶ方向 が常に一致する。


なぜ「毎フレーム」必要なのか?

VR コントローラは常に動くため、

  • フレーム A の姿勢で求めた origin/direction
  • フレーム B の姿勢で使う origin/direction

が一致していないと、 レイがポインタの向きとズレてしまう。

つまり

レイキャストは FPS と同じ頻度で更新し続ける設計であるべき

ということ。

レイキャストを「一回だけセットして使いまわす」という発想は、 VR では通用しない。


中核ポイント(この項目で覚えるべきこと)

  • VR コントローラは毎フレーム位置と向きが変わる
  • origin と direction も毎フレーム更新しないとズレる
  • 行列から回転だけ取り出す理由は「方向の歪み防止」

4. UIボタンの実装

Three.js には「ボタン」や「UIコンポーネント」という概念が存在しない。 だから VR UI を作る場合は、最小単位の 3D オブジェクトを使って自前で組み立てることになる。

その中でいちばんシンプルで拡張しやすい形が、

  • Mesh(Plane)を1枚置く
  • userData.onClick を仕込む
  • Raycaster とトリガー入力で押されたことにする

という構成。

実際、あなたが組んだ構造は完全にこの“最低限の正解形”。


Mesh + PlaneGeometry

UI ボタンの土台はこれだけで作れる。

const button = new THREE.Mesh(
  new THREE.PlaneGeometry(0.3, 0.1),
  new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide })
);

Plane は薄い板として扱われ、VR空間上の「ボタン」の役を担う。

  • サイズを 0.3 × 0.1 にすることでボタンらしい形に
  • Plane は軽いので VR 向き
  • DoubleSide にすると裏側からでも認識される(UI は正面固定なら片面でOK)

配置も普通の 3D オブジェクトと変わらない。

button.position.set(0, 1.5, -1);

目の前に出しておくと触りやすい。


userData.onClick

Three.js の Mesh にはイベントシステムがない。 その代わりに userData を使って、自分でコールバックを持たせるのが王道。

button.userData.onClick = () => {
  console.log('ボタン押された!');
  button.material.color.set(0x00ff00);
  setTimeout(() => button.material.color.set(0xffffff), 150);
};

userData は Three.js 標準で用意されている “何入れてもいいメタデータ” なので、 VR UI の実装でほぼ全員が活用している。

ここに “押されたらやる処理” を書いておくことで、 Raycaster と inputSource の双方と自然に連携が取れる。

この構造だと後で以下が自由に増やせる:

  • hover の色変化
  • アニメーション
  • SE
  • メニュー遷移
  • 他の UI 部品(Slider / Toggle / List など)

すべて Mesh レベルで追加できる。


Three.js で UI を作るときの最低限の構成(完成形)

UIボタンとして必要な要素は次の3つだけで成立する。

  1. ボタンの Mesh
  2. onClick コールバック
  3. レイキャストで衝突判定 → トリガー押下で実行

あなたのコードはこの 3 ステップを全部満たしている。


要は「Three.js の VR UI は、Mesh にイベントを載せて Raycaster で当てる」

この構造を理解して書けている時点で、 Three.js × WebXR の UI 実装の入り口はすでに突破している。

5. inputSources から trigger を読む

WebXR の VR コントローラは、DOMイベント(click など)では反応しない。 代わりに、WebXR が提供する XRInputSource を通して 生の入力情報 を取得する仕組みになっている。

ここを正しく理解できるかどうかで、 「VR 空間で UI を押せるか」が決まる。

あなたが動かすことに成功した理由は、この部分を正しく押さえたからだ。


inputSources とは何か

frame.session.inputSources は、 現在接続されているすべての VR 入力デバイスを列挙する。

例:

  • 右手コントローラ
  • 左手コントローラ
  • ハンドトラッキング
  • マウントされたリモコン系デバイス

ここを毎フレーム loop の中で回すことで、 デバイスの姿勢と入力をリアルタイムで取得できる。


trigger(トリガー)は gamepad API と同じ構造

WebXR の inputSource は gamepad プロパティを持つ。 これは Gamepad API と互換のボタン配列を提供する。

トリガー(人差し指のボタン)は、 Meta Quest などの多くの VR デバイスで buttons[0] に入る。

const triggerPressed = inputSource.gamepad.buttons[0]?.pressed;

この一行が、 「UI が押されたかどうか」 を判断するための最も重要なブール値になる。


これがなぜ重要なのか

VR 空間では「UI を押すこと」は以下の2条件が揃って初めて成立する。

  1. Raycaster がオブジェクトにヒットしている
  2. トリガーボタンが押されている

例:

if (hits.length > 0 && triggerPressed) {
  const hit = hits[0].object;
  hit.userData.onClick?.();
}

このひとつの if 文で UI クリックの仕組みが完成する。


さらに重要な注意点

コントローラが “handedness” を持っている

左右を識別するのに必要。

if (inputSource.handedness === 'right')

これで右手だけを UI 操作に使う、といった制御ができる。

ボタン番号はデバイスによって違うことがある

Quest では

  • buttons[0] → trigger
  • buttons[1] → grip が定番だが、

Vive / Pico / 他デバイスでは違うこともある。

実務では inputSource.profiles を使ってデバイス識別するが、 最初の実装では Quest 寄りの 0 番トリガーで問題ない。


UI 操作が成功した“理由”

あなたの実装が成功した理由は 1 行に集約される。

const triggerPressed = inputSource.gamepad.buttons[0]?.pressed;

これで “押した瞬間” を正しく検出できたから、 Raycaster のヒットと結びついた。

初学者が最初に詰まる場所なので、ここを突破したのはかなり大きい。

6. レイキャストで UI をクリックさせる

ここが Three.js × WebXR UI の核心部分。 あなたはすでに動かしていて、実装として正しい流れを掴めている。

レイキャストで UI を押すには、 「レイが当たっている」 + 「トリガーが押されている」 の2条件が揃えば十分。

Three.js と WebXR の組み合わせでは、 この “組み合わせ” を自前で作る必要がある。


毎フレーム:レイキャストを実行する

コントローラの向きと raycaster.ray が毎フレーム更新されるので、 UIボタンとの衝突判定もループ内で行う。

const hits = config.raycaster.intersectObjects(config.uiButtons, false);

intersectObjects() の戻り値には「当たった順に」結果が入る。

  • 一番手前 → hits[0]
  • 奥にあるほど hits[1], hits[2]…

UI ボタンは普通は平面なので hits[0] があれば十分。


トリガー押下と組み合わせる

レイキャスト単体では「どこに当たったか」がわかるだけ。 UIイベントとして成立させるには、入力デバイスの押下状態が必要。

昨日あなたが成功させた実装の本質がこれ。

if (hits.length > 0 && triggerPressed) {
  const hit = hits[0].object;
  if (hit.userData.onClick) {
    hit.userData.onClick();
  }
}

この if 文が “VRのクリック” の正体。

  • Raycaster → “カーソル位置”
  • triggerPressed → “左クリック”

こういう感覚で理解すれば迷わなくなる。


userData に onClick を仕込んでおく利点

UIボタンの実装でやったように Mesh に対して:

button.userData.onClick = () => { ... };

を用意しておくことで、 レイキャストとコントローラ操作のロジックから UI 処理を完全に分離できる。

これにより:

  • UI ボタンの数が増えても管理が簡単
  • hover 処理(選択状態)を追加しやすい
  • 押下時のアニメーション・色変更も使い回せる
  • UI を prefab 的に扱える

実際、Three.js VR UI のベストプラクティスがまさにこれ。


UI ボタンは「オブジェクト」→「レイ」→「入力」の三層で成り立つ

  1. ボタン(Mesh)  形だけ、特別なロジックなし
  2. レイキャスト(Raycaster)  当たり判定の仕組み
  3. 入力(inputSource.gamepad)  “押す”ことを決める要素

この三層構造を理解できていると、 UI の拡張(スライダー、チェックボックス、VRウィンドウなど)が簡単になる。

詰まりポイント

この4つだけ押さえておけば、Three.js × WebXR の UI は一気に安定する。


初期化時に intersectObjects を呼んでも何も起きない

Raycaster は「現在のコントローラ位置と向き」で判定する仕組み。 初期化時は controller.matrixWorld がまだ更新されていないため、 レイを飛ばしてもヒットは絶対に返らない。 intersectObjects はアニメーションループの中で呼ぶのが前提。


raycaster の方向は毎フレーム更新必須

VR コントローラは常に姿勢が変わる。 方向を一度だけセットすると、 レイがコントローラとズレたままになる。


// controller の world 行列を最新化
config.controller.updateMatrixWorld(true);

tempMatrix.extractRotation(controller.matrixWorld);
raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);

この 3 行はフレームごとに必須。


trigger が undefined のままだと onClick が発動しない

WebXR の入力は DOMイベントじゃなく、 inputSource.gamepad.buttons に入っている。

const triggerPressed = inputSource.gamepad.buttons[0]?.pressed;

この値を取らない限り、 押下イベントが存在しないのと同じになる。


VRコントローラの前方向は必ず -Z

Three.js のローカル座標系では -Z が「前」。 レイキャストで使う方向ベクトルは常にこれ。

new THREE.Vector3(0, 0, -1);

ここを逆にすると UI に当たらない。