R3Fでインタラクティブな3D|マウス追従とスクロール連動の作り方

約18分
R3Fでインタラクティブな3D|マウス追従とスクロール連動の作り方

みなさんこんにちは。フロントエンドエンジニアのしゅん(@shun_webdesign)です。

前回のReact Three Fiber入門で、シリーズでずっと使ってきた「回る箱」の世界をまるごとJSXに引っ越しました。ただ、あの時点では箱はただ回っているだけです。見ているだけの3Dでした。

今回は、そこに手を入れます。マウスに反応させて、スクロールに連動させる。Webサイトで3Dを使うとき、実際に必要になるのはだいたいこの2つです。

いつも通り、この記事のコードは全部手元で動かして確認しています(React 19.2.8 / @react-three/fiber 9.7.0 / @react-three/drei 10.7.8 / three 0.180.0)。実測した数字もそのまま載せます。

クリックやホバーは、JSXに書くだけ

素のThree.jsでクリック判定をやろうとすると、それなりの儀式が必要でした。

// 素のThree.js:自分でレイを飛ばす
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();

addEventListener("click", (e) => {
  pointer.x = (e.clientX / innerWidth) * 2 - 1;
  pointer.y = -(e.clientY / innerHeight) * 2 + 1;
  raycaster.setFromCamera(pointer, camera);
  const hits = raycaster.intersectObjects(scene.children, true);
  if (hits[0]?.object === cube) { /* 当たった */ }
});

R3Fだと、これが消えます。

<mesh
  onPointerOver={() => setHovered(true)}
  onPointerOut={() => setHovered(false)}
  onClick={() => setActive((v) => !v)}
>

<div>にイベントを付けるのと同じ書き方です。Raycasterもリスナー登録も、R3Fが裏で全部やっています。

大事なのは、ハンドラを書いたオブジェクトだけが判定の対象になるという点です。書いていないメッシュは最初から候補に入りません。実際に、床(ハンドラなし)の上をクリックしても「何も当たらなかった」扱いになり、<Canvas>onPointerMissedが発火しました。

<Canvas onPointerMissed={() => setSelected(null)}>

これは「背景をクリックしたら選択を解除する」みたいな挙動をそのまま書ける、地味に便利なやつです。

使えるハンドラは一通り揃っています。onPointerOver / onPointerOut / onPointerMove / onClick / onDoubleClick / onWheel あたりを押さえておけば、ほとんどの場面は足ります。

カーソルを指の形に変えたいときは、dreiのuseCursorが1行です。

import { useCursor } from "@react-three/drei";

const [hovered, setHovered] = useState(false);
useCursor(hovered); // hovered が true の間だけ cursor: pointer

ホバー状態をsetStateで持つのは問題ありません。毎フレーム走るのはuseFrameだけで、ホバーの出入りは1回ずつしか起きないからです。前回書いた「useFrameの中でsetStateしない」とは別の話なので、混ぜないでください。

3Dのイベントは、重なった全部に飛ぶ

ここがR3Fで一番ハマるところだと思っています。

DOMのクリックは、いちばん手前の要素に当たって、そこから親へ伝わっていきます。3Dは違います。レイはオブジェクトを貫通するので、手前の箱にも、その奥にある箱にも当たります。

半透明の板を手前に、不透明の板を奥に置いて、カーソルを重ねたときにonPointerOverが誰に飛ぶか実測しました。

発火した相手
そのままfrontback の両方
ハンドラ内で e.stopPropagation()front だけ

「手前の板にホバーしただけなのに、後ろのオブジェクトまで光る」の正体はこれです。DOMの感覚だと絶対に予想できません。

<mesh onPointerOver={(e) => { e.stopPropagation(); setHovered(true); }}>

手前のものだけに反応させたいなら、stopPropagation()を書く。 ホバー系は基本的に書いておいて損はないです。逆に「串刺しになった全部を光らせたい」みたいな演出なら、書かなければそのまま実現できます。

マウス追従はstate.pointerを毎フレーム読む

追従は、イベントではなくuseFrameの中でやります。useFrameに渡ってくるstateが、正規化済みのポインタ座標を持っているからです。

useFrame((state, delta) => {
  const { x, y } = state.pointer; // 画面左下 (-1,-1) 〜 右上 (1,1)
  ref.current.position.x = x * 1.2;
});

state.pointer画面中央が(0, 0)、端が±1の値です(旧名のstate.mouseも残っていますが、今はpointerが正式)。カーソルが画面右下にあるとき、実測で{ x: 0.78, y: -0.73 }でした。

そして、ここでも前回の原則がそのまま効きます。setStateは使わず、refを直接触る。 マウスの動きをstateに入れたら、動かすたびにReactが再レンダリングされます。

そのまま代入するとカクつくので、慣性をつける

上のコードだと、箱がカーソルにピタッと張り付いて、動きが機械的になります。実際の見た目としては「少し遅れて追いかけてくる」ほうが気持ちいいです。

昔ながらの書き方はlerpですが、これにはフレームレートに依存するという弱点があります。

// 60fpsと144fpsで速度が変わってしまう書き方
ref.current.position.x += (target - ref.current.position.x) * 0.1;

dreiが内部で使っているmaatheasingを使うと、deltaを渡すだけでこれが解決します。

import { easing } from "maath";

useFrame((state, delta) => {
  const { x, y } = state.pointer;
  // (対象, 目標値, 到達までの秒数っぽいパラメータ, delta)
  easing.damp3(ref.current.position, [x * 1.2, y * 0.6, 0], 0.25, delta);
  easing.dampE(ref.current.rotation, [-y * 0.4, x * 0.6, 0], 0.3, delta);
});

damp3はVector3用、dampEはEuler(回転)用、単一の数値ならdampです。第3引数が「追いつくまでの目安の秒数」で、大きくするほどぬるっと遅れて付いてきます。これだけで、いわゆる「マウス追従」の質感になります。

maathはdreiの依存に入っているので、dreiを使っていればimportできます(単体で入れるならnpm i maath)。

ポインタ座標をワールド座標に変換したいときはstate.viewportが使えます。カメラから見た可視領域のサイズが入っていて、この記事の設定(fov 75・カメラ距離3)では実測で 幅7.28 × 高さ4.85 でした。state.pointer.x * viewport.width / 2 で「画面端=シーンの端」にぴったり合わせられます。

スクロール連動は、2つの流派がある

ここが今回いちばん誤解の多いところです。R3Fのスクロール連動には方式が2つあり、どちらを選ぶかで書き方が全部変わります

① ScrollControls(drei)② 固定Canvas × ページスクロール
スクロールするものCanvasの中に作られた専用の要素普通のHTML(window
3D側の読み方useScroll().offsetuseFrameの中でscrollY
HTMLの扱い<Scroll html>で3Dと一緒に流す普通に書くだけ
向いている用途3Dが主役のLP・作品ページ既存サイトの背景を3Dにする
既存のCSS/SEOとの相性独自スクロールなので癖があるそのまま

① ScrollControls:Canvasにスクロールごと預ける

import { ScrollControls, useScroll, Scroll } from "@react-three/drei";

function Cube() {
  const ref = useRef();
  const scroll = useScroll();

  useFrame(() => {
    ref.current.rotation.y = scroll.offset * Math.PI * 2; // offset は 0〜1
    // 「30%〜70%の区間だけ0→1」も1行で取れる
    const fade = scroll.range(0.3, 0.4);
  });
  // ...
}

<Canvas>
  <ScrollControls pages={3} damping={0.2}>
    <Cube />
    <Scroll html>
      <h1>スクロールすると箱が動く</h1>
    </Scroll>
  </ScrollControls>
</Canvas>

<ScrollControls>は、スクロール用のDOM要素をCanvasの中に作って、その中身をスクロールさせます。pages={3}は「画面3枚ぶんスクロールできる」という意味です。高さではありません。

実測すると、画面600pxのビューポートに対して生成された要素はscrollHeight 2400px(=画面4枚ぶん)で、実際にスクロールできる量はちょうど1800px=画面3枚ぶんでした(pagesを1・2・3と変えても、常に「画面 pages 枚ぶんスクロールできる」で一致)。pagesは高さではなくスクロール量、と覚えておくと寸法の計算がズレません。

useScroll()が返すoffsetは0〜1。range(開始, 長さ)は「この区間に入ってから抜けるまでを0→1で返す」ヘルパーで、セクションごとの演出を書くときに効きます。useScroll<ScrollControls>の内側でしか使えません。 ただし、外で呼んでもその場では怒られません。返ってくるのはnullで、scroll.offsetを読んだ瞬間に Cannot read properties of null (reading 'offset') で落ちます。エラーメッセージからは原因が分からないので、これだけ覚えておくと5分節約できます。

② 固定Canvas × ページスクロール

既存サイトに3D背景を足すなら、こっちです。CanvasをCSSでposition: fixedにして、その上に普通のHTMLを流します。

useFrame((state, delta) => {
  const max = document.body.scrollHeight - innerHeight;
  const t = max > 0 ? scrollY / max : 0;  // 0〜1
  easing.damp(ref.current.rotation, "y", t * Math.PI * 2, 0.25, delta);
});

scrollイベントのリスナーすら要りません。useFrameは毎フレーム走るので、その中でscrollYを読めばいいだけです。ここでもsetStateは使いません。

実際に動かして、スクロール位置を変えながら撮ったのがこれです。

固定したCanvasの上にHTMLを流したページ。スクロール率0%・35%・70%の3枚を並べると、スクロールに合わせて箱が回転し、上に移動している

②の落とし穴:HTMLを重ねるとクリックできなくなる

この方式には、必ず踏む罠があります。Canvasの上にHTMLを重ねた瞬間、3Dのポインタイベントが死にます。 カーソルの下にいるのはHTMLであってcanvasではないので、当然といえば当然です。

R3Fには、これ用の逃げ道が用意されています。

<Canvas
  eventSource={document.body}  // イベントを body で拾う
  eventPrefix="client"         // 座標を clientX/Y から計算する
>

同じページで、この2行の有無だけを比べた実測がこれです。カーソルは<h1>の真上に置いています。

ホバーが効くか
既定(canvasで拾う)効かない(箱の色が変わらない)
eventSource + eventPrefix効く

eventPrefix="client"をセットで書くのを忘れると、座標の基準がズレて当たり判定が微妙に狂います。2行セットで覚えてください。

動いていないときは、描かない

インタラクションが主役のページは、frameloop="demand"が効きます。

<Canvas frameloop="demand">

これを付けると、何か変化があったときだけ描画されるようになります。手元でuseFrameの実行回数を数えた結果が、なかなか極端でした。

描画されたフレーム数
既定(always)で3秒放置134
demandで3秒放置1
demand+マウスを動かす+1
demandinvalidate()を呼ぶ+1

つまりdemandは「ポインタが動いた」「Reactが再レンダリングした」「自分でinvalidate()を呼んだ」のいずれかがあったときだけ1枚描きます。ずっと自走するアニメーションが無いページなら、GPUがほぼ休みます。ノートPCのファンが回らないのはこれです。

const invalidate = useThree((s) => s.invalidate);
// 何か動かしたあとに
invalidate();

ただし慣性(damp)とは相性が悪いです。dampは「毎フレーム少しずつ目標に近づく」処理なので、demandだとマウスが止まった瞬間に描画も止まり、途中で固まります。慣性を効かせるならalwaysのままにするか、動いている間だけinvalidate()を呼び続けるかの二択です。

よくあるつまずき

症状原因対処
クリックしても何も起きないハンドラを書いていないメッシュは判定対象外反応させたいメッシュに直接ハンドラを書く
奥のオブジェクトまで反応するレイは貫通する(DOMのバブリングとは別)ハンドラの先頭でe.stopPropagation()
HTMLを重ねたらホバーが効かないイベントをcanvasで拾っているeventSource={document.body}eventPrefix="client"
マウス追従がカクつく・重いuseFrameの中でsetStateしているrefを直接更新する
追従の速さがPCによって違うlerpの固定係数を使っているeasing.damp3(..., delta)にする
Cannot read properties of null (reading 'offset')<ScrollControls>の外でuseScrollを呼んでいる(戻り値がnull使う側を<ScrollControls>の子に切り出す
demandにしたら動きが固まる慣性の途中で描画が止まっているalwaysに戻すかinvalidate()を回す
ドラッグすると視点まで回るOrbitControlsと操作が競合makeDefaultを付けて、掴んでいる間enabled={false}

まとめ

  • イベントはJSXにハンドラを書くだけ。Raycasterは書かない。書いたメッシュだけが判定対象になり、外れたクリックはonPointerMissedに飛ぶ
  • 3Dのレイは貫通する。 重なったオブジェクト全部に飛ぶので、手前だけに反応させたいならe.stopPropagation()
  • マウス追従はuseFrameの中でstate.pointer(-1〜1)を読む。setStateせずrefを直接。慣性はeasing.damp3(..., delta)でフレームレート非依存に
  • スクロールは2流派。3Dが主役ならScrollControls + useScroll().offset、既存サイトの背景なら固定Canvas + scrollY
  • 固定Canvas方式でHTMLを重ねるなら、eventSource + eventPrefixの2行を忘れない
  • 常時アニメが無いページはframeloop="demand"。3秒放置で134フレーム → 1フレームまで落ちる(ただし慣性とは相性が悪い)

これで、シリーズの「回る箱」はついにこちらの操作に反応するようになりました。見るだけの3Dと、触れる3Dの差はけっこう大きいです。マウス追従をひとつ入れるだけで、サイトの手触りが変わります。


📕 もっと手を動かして体系的に学びたい人へ

この記事のような「仕組みを理解しながら小さく動かす」を積み重ねて、作品づくりまで導くKindle本を書きました。
『Three.jsでつくる、小さなWebGL表現 ── はじめての”作品づくり”ガイド』
環境づくりから、モデルや質感を組み合わせて自分の作品にまとめ上げるところまで順を追って解説しています。

👉 Amazonで見る »


僕もXでThree.jsの作品や知見を発信しているので、よかったら@shun_webdesignを覗いてもらえると嬉しいです。それでは、よいThree.jsライフを🌊

しゅん

しゅん

フロントエンドエンジニア / Webデザイナー。 アメリカ カリフォルニア州生まれ。 音楽、映画、芸術を中心としたサブカルが大好き。 お仕事のご相談は下記リンクのポートフォリオの連絡先からお願いします。

← 記事一覧に戻る