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)。実測した数字もそのまま載せます。
Table of Contents
クリックやホバーは、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しない」とは別の話なので、混ぜないでください。
ここがR3Fで一番ハマるところだと思っています。
DOMのクリックは、いちばん手前の要素に当たって、そこから親へ伝わっていきます。3Dは違います。レイはオブジェクトを貫通するので、手前の箱にも、その奥にある箱にも当たります。
半透明の板を手前に、不透明の板を奥に置いて、カーソルを重ねたときにonPointerOverが誰に飛ぶか実測しました。
| 発火した相手 | |
|---|---|
| そのまま | front と back の両方 |
ハンドラ内で 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が内部で使っているmaathのeasingを使うと、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().offset | useFrameの中で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は使いません。
実際に動かして、スクロール位置を変えながら撮ったのがこれです。

②の落とし穴: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 |
demand+invalidate()を呼ぶ | +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の差はけっこう大きいです。マウス追従をひとつ入れるだけで、サイトの手触りが変わります。
- 実際にR3Fでサイトを1本作った記録は → Next.jsとReact Three Fiberのポートフォリオを公開するまでの裏側
- 3Dの土台そのものの話に戻るなら → React Three Fiber入門
- 全体のロードマップは → Three.js入門ガイド
📕 もっと手を動かして体系的に学びたい人へ
この記事のような「仕組みを理解しながら小さく動かす」を積み重ねて、作品づくりまで導くKindle本を書きました。
『Three.jsでつくる、小さなWebGL表現 ── はじめての”作品づくり”ガイド』
環境づくりから、モデルや質感を組み合わせて自分の作品にまとめ上げるところまで順を追って解説しています。
僕もXでThree.jsの作品や知見を発信しているので、よかったら@shun_webdesignを覗いてもらえると嬉しいです。それでは、よいThree.jsライフを🌊