フック

R3F が提供する主要フックは 4つuseFrame / useThree / useLoader / useGraph。これと React 標準の useRef/useEffect/useState さえあれば 9 割の処理が書ける。すべて Canvas の中でしか動かないのが最重要ルール。

Canvas の外では使えない

R3F のフックは <Canvas> が提供するコンテキストに依存する。 Canvas ののコンポーネントから使うとエラー。 Canvas に渡すコンポーネント(<Canvas><Foo /></Canvas>Foo)の中なら OK。

useFrame

毎フレーム呼ばれるコールバックを登録する。これが Three.js の setAnimationLoop 相当。 コールバックは (state, delta, xrFrame) => void

回転させるだけ
function SpinningBox() {
  const ref = useRef<THREE.Mesh>(null!)
  useFrame((_, dt) => {
    ref.current.rotation.x += dt * 0.5
    ref.current.rotation.y += dt * 0.8
  })
  return (
    <mesh ref={ref}>
      <boxGeometry />
      <meshStandardMaterial color="#7dd3fc" />
    </mesh>
  )
}
useFrame の中で setState しない

useFrame60fpsで呼ばれる。中で setState すると毎フレーム React の再レンダーが走り、即座にパフォーマンスが死ぬ。
値を保持したいだけならuseRef に書くか、Three.js のオブジェクトを直接 mutate するのが正解。

state の中身

キー意味
glWebGLRenderer
sceneルート Scene
camera現在のカメラ
raycaster共有 Raycaster
clockTHREE.Clock。経過時間など
mouse / pointerNDC(-1..1)の現在ポインタ位置
size{ width, height, top, left } 物理サイズ
viewportワールド座標サイズ + DPR
eventsイベントマネージャ
set(partial)state の更新(カメラ差し替え等)
get()最新の state を同期取得(zustand 由来)
invalidate()1フレーム描画を要求(demand モード時)
advance(time, runGlobalEffects?)手動でフレームを進める
performance.regress()性能落ちを通知(drei の AdaptiveDpr 等が反応)

実行順を制御する(priority)

第2引数に number を渡すと、レンダリングの前後や、複数の useFrame の順序を制御できる。

「先にこれ、次にあれ」の制御
useFrame(updateA, 0)         // レンダー前
useFrame(updateB, 1)         // レンダー後(昇順なので C より先)
useFrame(updateC, 2)         // レンダー後
multi-pass の自前レンダリング
// 自動レンダーを止めて、自前で2回 render
useFrame(({ gl, scene, camera }) => {
  gl.autoClear = true
  gl.render(scene, camera)
  gl.autoClear = false
  gl.render(overlayScene, camera)
}, 1)

// priority < 0 にすると 1 回目の自動レンダーも止まる
useFrame((state) => { /* setup before */ }, -1)

パフォーマンス: ムダなアロケーションを避ける

毎フレーム new Vector3() しないこと。useRef で使い回すのが定石。

const tmp = useRef(new THREE.Vector3()).current
useFrame((state) => {
  tmp.set(state.mouse.x, state.mouse.y, 0).unproject(state.camera)
  // tmp を使って計算
})

// 複数必要なら useMemo にまとめる
const helpers = useMemo(() => ({
  tmp: new THREE.Vector3(),
  matrix: new THREE.Matrix4(),
  quat: new THREE.Quaternion(),
}), [])

条件付き有効化

useFrame 自体に条件を付けることはできないが、コールバック内で早期 returnするのが定石。

useFrame(() => {
  if (!enabled) return
  // 重い処理
})

useFrame をフックに切り出す

function useSpin(ref: RefObject<THREE.Object3D>, speed = 1) {
  useFrame((_, dt) => {
    if (ref.current) ref.current.rotation.y += dt * speed
  })
}

function Box() {
  const ref = useRef<THREE.Mesh>(null!)
  useSpin(ref, 0.5)
  return <mesh ref={ref}>...</mesh>
}

useThree

Canvas のグローバル状態にアクセスする。中身は useFramestate と同じ。 レンダー時に必要なら useThree、フレーム駆動なら useFrameと覚えるのが楽。

全部取る

const state = useThree()
state.camera          // 現在のカメラ
state.gl              // WebGLRenderer
state.size            // { width, height, top, left }
state.viewport        // ワールド単位のサイズ

セレクタで購読する(再レンダー最小化)

useThree(state => ...) の形でその値が変わった時だけ再レンダーされる。 zustand を内部で使っている。

// size 全体ではなく width だけ購読
const width = useThree((s) => s.size.width)

// camera だけ購読(変わらないので実質一度だけ)
const camera = useThree((s) => s.camera)

// 複数値を組み合わせる
const aspect = useThree((s) => s.size.width / s.size.height)

イベントの外から state にアクセス: get()

useFrame 以外のイベントコールバック内で「最新の state を一発取りたい」場合。

const get = useThree((s) => s.get)

useEffect(() => {
  function onKey(e: KeyboardEvent) {
    if (e.key !== " ") return
    const { camera } = get()
    camera.position.z -= 1   // ジャンプ
  }
  window.addEventListener("keydown", onKey)
  return () => window.removeEventListener("keydown", onKey)
}, [get])

state を変える: set()

カメラを差し替える、events を変えるなど。

const set = useThree((s) => s.set)

set({ camera: newCamera })
set((prev) => ({ ...prev, frameloop: "demand" }))

サイズ変化に反応

function ResizeAwareCamera() {
  const camera = useThree(s => s.camera)
  const aspect = useThree(s => s.size.width / s.size.height)
  useEffect(() => {
    if (camera instanceof THREE.PerspectiveCamera) {
      camera.aspect = aspect
      camera.updateProjectionMatrix()
    }
  }, [camera, aspect])
  return null
}

外部から Canvas に触るには

Canvas の外では useThree が使えない。 Canvas の中に「ブリッジ」コンポーネントを置いて外側の状態と繋ぐパターンか、Canvas の onCreated で外部参照を注入する:

const apiRef = useRef<ThreeState | null>(null)

return (
  <>
    <Canvas onCreated={(state) => { apiRef.current = state }}>
      ...
    </Canvas>

    {/* 外側のボタン等から */}
    <button onClick={() => apiRef.current?.gl.setClearColor("red")}>赤</button>
  </>
)

または、Zustand のグローバル store を Canvas 内外で共有するのが綺麗。

useLoader

Three.js のローダー(TextureLoaderGLTFLoaderFontLoader 等)を Suspense と統合して呼ぶ。同じパスは内部でキャッシュされる。

テクスチャ読み込み
import { useLoader } from "@react-three/fiber"
import * as THREE from "three"

function Wall() {
  const tex = useLoader(THREE.TextureLoader, "/wall.jpg")
  return (
    <mesh>
      <planeGeometry args={[3, 3]} />
      <meshStandardMaterial map={tex} />
    </mesh>
  )
}

// Canvas 側で Suspense を入れる
<Canvas>
  <Suspense fallback={null}>
    <Wall />
  </Suspense>
</Canvas>

同時に複数読み込む

パスを配列で渡すと、対応する結果の配列が返る。

const [tex1, tex2, tex3] = useLoader(THREE.TextureLoader, [
  "/a.jpg", "/b.jpg", "/c.jpg",
])

ローダーを設定する(DRACO 付き GLTF など)

第3引数にextension functionを渡してローダーを設定。

import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader"
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader"

const gltf = useLoader(GLTFLoader, "/compressed.glb", (loader) => {
  const draco = new DRACOLoader().setDecoderPath("/draco/")
  loader.setDRACOLoader(draco)
})

preload でキャッシュ温め

useLoader.preload(loader, paths) で先読みできる。マウント前に裏で取りに行ける。 drei の useGLTF も同様の preload を持つ。

// アプリの起動直後にコール
useLoader.preload(THREE.TextureLoader, "/hero.jpg")
useGLTF.preload("/robot.glb")

キャッシュをクリア

import { useLoader } from "@react-three/fiber"

useLoader.clear(THREE.TextureLoader, "/wall.jpg")

エラーハンドリング

ローダー失敗時は Suspense とエラーバウンダリでキャッチ:

<ErrorBoundary fallback={<Text>読み込み失敗</Text>}>
  <Suspense fallback={<Loading />}>
    <Model />
  </Suspense>
</ErrorBoundary>

useGraph

GLTF などで読み込んだシーンを名前で引ける形に展開するヘルパー。 { nodes, materials } が返る。

import { useGraph } from "@react-three/fiber"
import { useGLTF } from "@react-three/drei"

function Robot() {
  const { scene } = useGLTF("/robot.glb")
  const { nodes, materials } = useGraph(scene)

  return (
    <group>
      <mesh
        geometry={(nodes.Body as THREE.Mesh).geometry}
        material={materials.MetalGray}
      />
      <mesh
        geometry={(nodes.Head as THREE.Mesh).geometry}
        material={materials.SkinTone}
      />
    </group>
  )
}

とはいえ、実用では gltfjsxGLTF から JSX を自動生成するのが一番楽。生成されたコードはそのまま貼って使える。

その他のフック・ユーティリティ

drei の頻出フック

useFrame と useEffect の使い分け

やりたいこと使うもの
毎フレーム動かすuseFrame
マウント時に1回だけセットアップuseEffect with empty deps
props 変化に反応useEffect with deps
state 変化を読みたいだけuseThree セレクタ
外部の DOM イベントに反応useEffect + addEventListener
非同期にロードして待ちたいuseLoader + Suspense

カスタムフックのパターン

マウス追従

function useFollowMouse(strength = 1) {
  const ref = useRef<THREE.Object3D>(null!)
  const v = useRef(new THREE.Vector3()).current
  useFrame((state) => {
    if (!ref.current) return
    v.set(state.pointer.x, state.pointer.y, 0.5).unproject(state.camera)
    ref.current.position.lerp(v, 0.1 * strength)
  })
  return ref
}

function Cursor() {
  const ref = useFollowMouse()
  return (
    <mesh ref={ref}>
      <sphereGeometry args={[0.05]} />
      <meshBasicMaterial color="white" />
    </mesh>
  )
}

サイズに応じてカメラ調整

function useResponsiveCamera(targetWidth = 5) {
  const camera = useThree(s => s.camera) as THREE.PerspectiveCamera
  const aspect = useThree(s => s.size.width / s.size.height)

  useEffect(() => {
    const desiredFov = 2 * Math.atan((targetWidth / 2) / camera.position.z) * (180 / Math.PI)
    camera.fov = desiredFov
    camera.aspect = aspect
    camera.updateProjectionMatrix()
  }, [camera, aspect, targetWidth])
}

キーボード入力

function useKey(key: string) {
  const pressed = useRef(false)
  useEffect(() => {
    const down = (e: KeyboardEvent) => { if (e.key === key) pressed.current = true }
    const up   = (e: KeyboardEvent) => { if (e.key === key) pressed.current = false }
    window.addEventListener("keydown", down)
    window.addEventListener("keyup", up)
    return () => {
      window.removeEventListener("keydown", down)
      window.removeEventListener("keyup", up)
    }
  }, [key])
  return pressed
}

function Player() {
  const ref = useRef<THREE.Mesh>(null!)
  const w = useKey("w"), s = useKey("s"), a = useKey("a"), d = useKey("d")
  useFrame((_, dt) => {
    if (!ref.current) return
    if (w.current) ref.current.position.z -= 5 * dt
    if (s.current) ref.current.position.z += 5 * dt
    if (a.current) ref.current.position.x -= 5 * dt
    if (d.current) ref.current.position.x += 5 * dt
  })
  return <mesh ref={ref}> ... </mesh>
}

まとめ: どのフックを使うか

やりたいこと使うフック
毎フレーム動かすuseFrame
カメラ・レンダラ・シーンに触るuseThree
テクスチャ・GLTF・フォントを読むuseLoader または drei の useGLTF / useTexture
GLTF を name で引きたいuseGraph
外部の状態と連動普通の zustand / Redux / Context
マウント時の初期化useEffect