はじめに
Three.jsには、便利なUIは用意されていない為、自力で実装する必要があります。
今までは、動画操作や、ゲームのキャラクター操作用のボタンのみ実装しましたが、今回は、レーザーポインタのドラッグでテキストをスクロールするUIを自力で実装してみたので、その備忘録メモです。

Three.js WebXR(VR) UI環境について
Three.js の WebXR(VR)環境で、 テキストを表示できるUIを作ろうとすると、意外と選択肢がない。
HTML をオーバーレイする方法はあるが、
- VR 内で完結しない
- レーザーポインタとの整合が取りにくい
- 3D空間の一部として扱えない
という問題がある。
そこで今回は、
CanvasTexture を使って 3D空間内にテキストUIを配置し、 レーザーポインタでスクロールできるUIを自作した。
DOM や既存 UI ライブラリは使わず、 Three.js と WebXR の仕組みだけで完結させている。
やりたかったこと
- 右モニタにゲーム説明テキストを表示
- テキストは縦に長く、スクロール可能
- VRコントローラのレーザーでドラッグ操作
- 既存のボタンUIと共存させる
Three.js の examples には UIらしいスクロール実装はほぼ無いため、 イベント設計から描画まで、すべて自前で実装した。
基本構成
UI 全体の設計は、 「描画」「入力」「状態管理」を明確に分離する方針で組んだ。
DOM や HTML イベントは一切使わず、 WebXR の入力と Three.js の Raycaster をそのまま使っている。
役割は次のように分けている。
-
Canvas テキストを描画するだけのバッファ。 入力イベントは一切扱わない。
-
CanvasTexture Canvas を Three.js の Texture として扱うための橋渡し。
needsUpdateを立てることで描画内容を反映する。 -
Plane Mesh モニタ表面に配置する UI 面。 Raycaster の当たり判定対象になる。
-
Raycaster VR コントローラのレーザーから、 どの UI Mesh に当たっているかを判定する。
-
Controller 側ロジック トリガーの 押下 / 押し続け / 解放 をフレーム単位で管理する。
-
GameBox クラス スクロール量やドラッグ状態など、 UI の状態を一元管理する。
この構成にすることで、
- UI の描画方法を後から変えられる
- 入力デバイスが増えても影響範囲が限定される
- ボタン UI とスクロール UI を共存させやすい
というメリットがあった。
スクロールの考え方
スクロール実装で一番重要なのは、 「何を動かすか」を最初に決めること。
今回の構成では、 Mesh も Texture も動かさない。
動かすのは、Canvas に描かれる 中身だけ。
ポイントは次の3つだけ。
-
スクロールは Canvas の中身を動かす Three.js 側にスクロールの概念は持たせない。
-
座標を直接いじらず、
ctx.translate()で 座標系そのものを動かす。 -
scrollYは UI の状態としてクラスに持たせる。 入力や描画から独立させる。
ctx.save();
ctx.translate(0, -this.scrollY);
drawContents(ctx);
ctx.restore();
この形にしておくと、
- レイアウトの座標は常に論理座標のまま書ける
- スクロール方向や感度の調整が簡単
- 入力方式(マウス / VR / キー)を後から差し替えられる
という利点がある。
逆に、
描画時の座標に scrollY を直接足し引きすると、
スクロール処理とレイアウトが密結合になり、
後からの調整が難しくなる。
Canvas を 「UIの表示窓」として扱うことで、 3D 空間上でも素直なスクロールが実装できた。
ドラッグスクロールの実装
ドラッグ操作は
Mesh や Canvas に直接イベントを持たせず、
Raycaster と userData を使ったコールバック方式で実装した。
Three.js では DOM のようなイベント伝播は存在しないため、 「どの Mesh に当たっているか」を Raycaster で判定し、 その結果を UI 側に渡す形になる。
UI Mesh 側
スクロール対象の Mesh に、
UI 用のコールバックを userData として持たせる。
textPlane.userData = {
type: 'scrollView',
onPointerDown: (hit) => {
this.onPointerDown(hit.point);
},
onPointerMove: (hit) => {
this.onPointerMove(hit.point);
},
onPointerUp: () => {
this.onPointerUp();
},
};
ここで userData は、
- DOM の
addEventListenerに相当する役割 - UI の種類(
type)と振る舞いをまとめたもの
として使っている。
Controller 側では、
Raycaster のヒット結果に応じて
onPointerDown / Move / Up を呼び分けるだけにしている。
スクロール処理(GameBox 側)
スクロール量の計算は、 ワールド座標を一度 UI Mesh のローカル座標に変換して行う。
onPointerMove(worldPoint) {
if (this.dragStartY == null) return;
const local = this.textPlane.worldToLocal(worldPoint.clone());
const deltaY = (local.y - this.dragStartY) * 0.1;
this.scrollY = this.startScrollY + deltaY * (this.contentH - this.viewH);
this.clampScroll();
}
ポイントは次の通り。
-
0.1はスクロール感度の調整用係数 レーザー移動量と実際のスクロール量のバランスを取る。 -
ローカル座標を使うことで、 Mesh の向きや配置を意識せずにドラッグ量を扱える。
-
上下方向が直感と逆だったため、 符号を調整して自然な動きに合わせた。
-
上下端は
clampScroll()で制限し、 スクロールしすぎないようにしている。
この形にすることで、 ボタン UI とスクロール UI を同じ Raycaster 上で共存させつつ、 自然なドラッグ操作が実現できた。
ハマった点
実装自体はシンプルだが、 いくつか Three.js ならではの落とし穴があった。
-
userDataにコールバックを設定し忘れると、完全に無反応 Raycaster は当たっているのに何も起きない、という状態になる。 エラーも出ないため、最初は原因に気づきにくい。 -
Canvas にスクロールバーは出ない CanvasTexture は単なる画像バッファなので、 HTML のようなスクロールバーは存在しない。 必要であれば、描画も入力もすべて自作になる。
-
Canvas のサイズを明示しないと挙動が分かりにくい
width/heightを設定しない場合、 デフォルトサイズのまま描画されてしまい、 スクロール量や感度の調整が難しくなる。 -
クリック UI とドラッグ UI の分岐は必須 同じ Raycaster 上で扱うため、 「クリック用」と「ドラッグ用」の UI を 明示的に分けて処理しないと誤動作する。
これらはサンプルコードだけを追っていると見落としやすく、 実際に UI を組んで初めて気づく点だった。
得られたもの
今回の実装を通して、次のことがはっきりした。
-
Three.js でも UI は普通に作れる 特別なライブラリがなくても、 構成を整理すれば十分実用的な UI が組める。
-
DOM を捨てると、設計はゲームエンジン寄りになる イベント伝播やスクロールといった仕組みを 自分で定義する必要がある分、 UI の責務が明確になる。
-
Raycaster は UI イベントシステムそのもの 「どこに当たっているか」を基点に考えると、 クリックやドラッグは自然に表現できる。
-
CanvasTexture は「2Dアプリを3Dに埋め込む」ための強力な手段 既存の 2D 描画ロジックをそのまま活かしつつ、 3D 空間の一部として扱える。
Three.js の UI はサンプルが少なく手探りになりがちだが、 一度構造が見えると、 ゲームエンジンの UI と同じ感覚で組めるようになる。
💬 コメント