Three.js + WebXR で CanvasTexture を使ったスクロールUIを自作する

はじめに

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 と同じ感覚で組めるようになる。