インタラクション
R3F は 3D オブジェクトに直接イベントハンドラを書ける。中身は raycaster による ヒット判定で、HTML イベントと同じ感覚で onClick/onPointerOver が当たる。 DOM のバブリングと違うクセはあるが、慣れれば直感的。
イベントを書く
メッシュに onClick を付けるだけで、そのメッシュをクリックしたときに発火する。
カメラから見えない面(裏面)はクリック判定されないのが既定。
function ClickableBox() {
const [active, setActive] = useState(false)
return (
<mesh
onClick={() => setActive((v) => !v)}
scale={active ? 1.5 : 1}
>
<boxGeometry />
<meshStandardMaterial color={active ? "hotpink" : "#7dd3fc"} />
</mesh>
)
}
使えるイベント
| イベント | 発火条件 |
|---|---|
onClick | クリック(pointerdown→up が同じオブジェクト内) |
onContextMenu | 右クリック |
onDoubleClick | ダブルクリック |
onPointerOver | カーソルが乗った(HTML の mouseover 相当) |
onPointerOut | カーソルが離れた |
onPointerEnter / onPointerLeave | over/out のバブル無し版 |
onPointerMove | オブジェクト上で動いている間ずっと |
onPointerDown / onPointerUp | 押下 / 開放 |
onPointerMissed | そのオブジェクトを「外して」クリックされたとき |
onPointerCancel | ジェスチャがキャンセルされた |
onWheel | スクロール |
イベントオブジェクト
ハンドラの引数は ThreeEvent。Three.js の Intersection + 普通の DOM event を併せ持つ。
| プロパティ | 意味 |
|---|---|
e.object | 当たった一番手前の Object3D(孫まで遡る) |
e.eventObject | イベントが登録されたオブジェクト(バブル元) |
e.point | ワールド座標での当たり点(Vector3) |
e.distance | カメラからの距離 |
e.face | 当たった面(Face) |
e.uv | テクスチャ UV(Vector2) |
e.intersections | レイが当たった全候補(手前順) |
e.unprojectedPoint | NDC→ワールドに戻した点 |
e.ray | 飛ばしたレイ |
e.camera | レイを発射したカメラ |
e.delta | down→up までの距離(クリック判定の閾値) |
e.stopPropagation() | 親へのバブルを止める |
e.nativeEvent | 元の DOM PointerEvent |
カーソルを変える(hover の定石)
onPointerOver で document.body.style.cursor を変える。
ただしcleanup を忘れない。drei の useCursor が定石。
import { useCursor } from "@react-three/drei"
function Hoverable() {
const [hover, setHover] = useState(false)
useCursor(hover) // cleanup も込み
return (
<mesh
onPointerOver={(e) => { e.stopPropagation(); setHover(true) }}
onPointerOut={() => setHover(false)}
>
<sphereGeometry />
<meshStandardMaterial color={hover ? "yellow" : "white"} />
</mesh>
)
}
バブリングと stopPropagation
イベントは子から親へバブルする。group の中に複数の mesh があって、
各 mesh 個別と group 全体の両方にハンドラがあると、両方が発火する。止めたい時は e.stopPropagation()。
注意: hover のバブルで親の onPointerOut が連発
子から子へポインタが移動すると、親では「out → over」が立て続けに飛ぶことがある。
親で hover state を持つと点滅する。onPointerOver 内で stopPropagation するか、
onPointerEnter / onPointerLeave(バブルしない版)を使う。
選択解除の典型パターン
「3Dオブジェクト以外をクリックしたら選択解除する」は onPointerMissed。
Canvas に渡すか、個別 mesh に渡すかで挙動が違う。
{/* mesh 個別 — 自分以外をクリックされた時に発火 */}
function Selectable() {
const [selected, setSelected] = useState(false)
return (
<mesh
onClick={(e) => { e.stopPropagation(); setSelected(true) }}
onPointerMissed={() => setSelected(false)}
>
<boxGeometry />
<meshStandardMaterial color={selected ? "lime" : "gray"} />
</mesh>
)
}
{/* Canvas 全体 — どのオブジェクトにも当たらなかった時 */}
<Canvas onPointerMissed={() => deselectAll()}> ... </Canvas>
ドラッグ
onPointerDown でそのポインタを captureすると、そのまま外に出ても
onPointerMove が来続ける。これがドラッグの基本パターン。
function Drag() {
const ref = useRef<THREE.Mesh>(null!)
const [dragging, setDragging] = useState(false)
return (
<mesh
ref={ref}
onPointerDown={(e) => {
e.stopPropagation()
;(e.target as Element).setPointerCapture(e.pointerId)
setDragging(true)
}}
onPointerUp={(e) => {
;(e.target as Element).releasePointerCapture(e.pointerId)
setDragging(false)
}}
onPointerMove={(e) => {
if (!dragging) return
ref.current.position.x = e.point.x
ref.current.position.y = e.point.y
}}
>
<sphereGeometry args={[0.5, 32, 32]} />
<meshStandardMaterial color="aqua" />
</mesh>
)
}
@use-gesture/react と組み合わせ
@use-gesture/react + react-three/fiber + @react-spring/three はドラッグ・ピンチ・スワイプの最強コンボ。
import { useDrag } from "@use-gesture/react"
import { useSpring, animated } from "@react-spring/three"
function Draggable() {
const [{ pos }, api] = useSpring(() => ({ pos: [0, 0, 0] }))
const bind = useDrag(({ offset: [x, y] }) => {
api.start({ pos: [x / 100, -y / 100, 0] })
})
return (
<animated.mesh position={pos as any} {...(bind() as any)}>
<boxGeometry />
<meshStandardMaterial color="hotpink" />
</animated.mesh>
)
}
drei の <DragControls />
import { DragControls } from "@react-three/drei"
<DragControls>
<mesh> ... </mesh>
</DragControls>
raycaster をカスタマイズ
Canvas の raycaster prop で全体をいじれる:
<Canvas raycaster={{
near: 0.1,
far: 10,
params: {
Line: { threshold: 0.5 },
Points: { threshold: 0.05 },
},
}}> ... </Canvas>
イベント無効化
当たり判定を一切させたくないオブジェクトは raycast={() => null}。
パーティクルや HUD など、当たり判定不要なものに付けると毎フレームの raycast コストが減る。
<mesh raycast={() => null}> ... </mesh>
Layers
Three.js の Object3D.layers を使うと、レイキャスト対象を絞れる。
function PickableOnly() {
const ref = useRef<THREE.Mesh>(null!)
useEffect(() => { ref.current.layers.set(1) }, [])
return <mesh ref={ref}> ... </mesh>
}
<Canvas
raycaster={{ layers: new THREE.Layers() }}
onCreated={({ raycaster }) => raycaster.layers.set(1)}
>
...
</Canvas>
ポインタ位置を取る(カーソルに何かを追従)
state.pointer(Vector2、NDC -1..1)から拾える。
ワールド座標に変換するなら raycaster 経由。
function FollowMouse() {
const ref = useRef<THREE.Mesh>(null!)
const v = useRef(new THREE.Vector3()).current
useFrame((state) => {
v.set(state.pointer.x, state.pointer.y, 0.5).unproject(state.camera)
ref.current.position.lerp(v, 0.2)
})
return (
<mesh ref={ref}>
<sphereGeometry args={[0.05, 16, 16]} />
<meshBasicMaterial color="white" />
</mesh>
)
}
HTML を 3D 空間に置く(drei の Html)
ボタンやツールチップは 3D で実装するより HTML のほうが楽なことが多い。
drei の <Html /> がそれを portal してくれる。
import { Html } from "@react-three/drei"
<mesh position={[1, 1, 0]}>
<sphereGeometry args={[0.3]} />
<meshStandardMaterial color="orange" />
<Html distanceFactor={10} center>
<div style={{ background: "white", padding: 6 }}>クリックでズーム</div>
</Html>
</mesh>
occlude(隠れたら見えなくする)
<Html occlude="blending"> ... </Html>
{/* "raycast" / "blending" / true / false / refs */}
キーボード入力(drei の KeyboardControls)
import { KeyboardControls, useKeyboardControls } from "@react-three/drei"
const map = [
{ name: "forward", keys: ["ArrowUp", "w", "W"] },
{ name: "back", keys: ["ArrowDown", "s", "S"] },
{ name: "left", keys: ["ArrowLeft", "a", "A"] },
{ name: "right", keys: ["ArrowRight", "d", "D"] },
{ name: "jump", keys: ["Space"] },
]
<KeyboardControls map={map}>
<Canvas>
<Player />
</Canvas>
</KeyboardControls>
function Player() {
const ref = useRef<THREE.Mesh>(null!)
const [, get] = useKeyboardControls()
useFrame((_, dt) => {
const { forward, back, left, right } = get()
if (forward) ref.current.position.z -= 5 * dt
if (back) ref.current.position.z += 5 * dt
if (left) ref.current.position.x -= 5 * dt
if (right) ref.current.position.x += 5 * dt
})
return <mesh ref={ref}> ... </mesh>
}
InstancedMesh のヒット
InstancedMesh 上のクリックは e.instanceId で特定できる。
<instancedMesh args={[geom, mat, 1000]} onClick={(e) => {
console.log("instance:", e.instanceId)
e.stopPropagation()
}} />
パフォーマンス
- 当たり判定不要なオブジェクトに
raycast={() => null} - イベントを
groupでまとめて1箇所で受ける raycaster.near/farを絞る- 大量のメッシュは Layers で分けて、必要な layer だけ raycast
onPointerMoveは重い。本当に必要な時だけ付ける
R3F は毎フレーム raycast を回している(hover 状態の更新等のため)。 シーンに大量の interactive な mesh があると重い。パフォーマンス参照。