インタラクション

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 / onPointerLeaveover/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.unprojectedPointNDC→ワールドに戻した点
e.ray飛ばしたレイ
e.cameraレイを発射したカメラ
e.deltadown→up までの距離(クリック判定の閾値)
e.stopPropagation()親へのバブルを止める
e.nativeEvent元の DOM PointerEvent

カーソルを変える(hover の定石)

onPointerOverdocument.body.style.cursor を変える。 ただしcleanup を忘れない。drei の useCursor が定石。

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オブジェクト以外をクリックしたら選択解除する」は onPointerMissedCanvas に渡すか、個別 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.pointerVector2、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 が走る

R3F は毎フレーム raycast を回している(hover 状態の更新等のため)。 シーンに大量の interactive な mesh があると重い。パフォーマンス参照。