React Three Fiber入門|ReactでThree.jsを書くと、何が変わるのか

約21分
React Three Fiber入門|ReactでThree.jsを書くと、何が変わるのか

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

前回の3Dモデル(GLTF)を読み込む方法で、Three.jsの基礎編は一区切りつきました。世界を作って、光を置いて、好きなモデルを並べられるところまで来ています。

ここから先は「何で書くか」の話です。ReactでWebを作っている人なら、一度は名前を見たことがあるはず。React Three Fiber(R3F)です。

今回は、シリーズでずっと使ってきた base.js の世界をまるごとJSXに書き直しながら、R3Fが何をしてくれているのかを見ていきます。新しい3Dの概念はひとつも出てきません。同じものを、別の書き方で書くだけです。

R3Fは「Three.jsのラッパー」ではない

まずここを勘違いすると、あとが全部ズレます。

R3Fは、Three.jsを使いやすくパッケージしたライブラリ……ではありません。Reactのレンダラーです。

React本体は「UIをどう組み立てるか」だけを知っていて、実際に何を出力するかは知りません。それを担当するのがレンダラーです。

レンダラー出力するもの
react-domDOM要素(<div><button>
react-nativeネイティブのビュー
@react-three/fiberThree.jsのオブジェクト(Mesh・Light・Material)

つまり、react-dom<div> を見て document.createElement("div") を呼ぶのとまったく同じ理屈で、R3Fは <mesh> を見て new THREE.Mesh() を呼びます。

ここが大事なところで、R3FにはThree.jsの機能が一切含まれていません。Three.js側に新しいクラスが増えたら、R3Fを更新しなくてもその日から使えます。ラッパーだったらこうはいきません。

そして当然、これまで学んだ知識はそのまま持ち込めます。MeshStandardMaterialroughnessが何かを知っていることは、R3Fでも100%そのまま役に立ちます。

バージョンの組み合わせだけ最初に確認する

R3Fで最初につまずくのは、たいてい3Dではなくここです。R3FはReactのメジャーバージョンと組で動きます。

ReactReact Three Fiberdrei(補助ライブラリ)
18v8v9系
19v9(現行)v10系

Reactを19に上げないままR3F v9を入れると動きません。逆も同じです。既存プロジェクトに後乗せするときは、まずpackage.jsonのReactを見てください。

npm install three @react-three/fiber @react-three/drei

この記事のコードは React 19.2.8 / @react-three/fiber 9.7.0 / @react-three/drei 10.7.8 / three 0.180.0 で実際に動かして確認しています。

v8から上げる場合の主な破壊的変更は、React 19必須になったことに加えて、StrictModeが親から正しく継承されるようになった点です。これまで<Canvas>の外側のStrictModeはCanvas内に効いていなかったので、v9に上げた途端に「初期化が2回走ることを前提にしていなかったコード」が壊れて見えることがあります。バグが増えたわけではなく、隠れていたバグが表に出たというやつです。詳細は公式のv9移行ガイドにまとまっています。

同じ世界を、JSXで書き直す

では本題です。シリーズで使ってきたbase.jsは、こういうものでした。

// これまでの書き方(抜粋)
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x15171c);

const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 1000);
camera.position.set(0, 1, 3);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);

const ambient = new THREE.AmbientLight(0xffffff, 1.5);
const directional = new THREE.DirectionalLight(0xffffff, 2);
directional.position.set(2, 3, 4);
directional.castShadow = true;
scene.add(ambient, directional);

これがR3Fだと、こうなります。

import { Canvas } from "@react-three/fiber";

export default function App() {
  return (
    <Canvas shadows camera={{ position: [0, 1, 3], fov: 75 }}>
      <color attach="background" args={["#15171c"]} />

      <ambientLight intensity={1.5} />
      <directionalLight position={[2, 3, 4]} intensity={2} castShadow />

      <Cube />
    </Canvas>
  );
}

Scene・Camera・Rendererを作るコードが消えました。 <Canvas>がその3つを丸ごと引き受けて、さらにcanvas要素の追加とリサイズ対応まで面倒を見ています。base.jsで自分で書いていた土台が、まるごとタグ1つになったかたちです。

base.jsの各行がどこへ行ったのかを並べると、こうなります。

base.jsでやっていたことR3Fでの居場所
Scene・Camera・Renderer生成<Canvas> が内包
renderer.setSize / リサイズ追従<Canvas> が自動
renderer.shadowMap.enabled<Canvas shadows>
scene.add(mesh)JSXの入れ子構造そのもの
setAnimationLoopuseFrame フック
OrbitControls・環境マップdrei のコンポーネント(後述)

scene.add()が消えているのがポイントです。R3FではJSXの親子関係がそのままシーングラフの親子関係になります。追加も削除も、Reactが条件分岐でタグを出し入れすれば勝手にやってくれる。ここがいちばん体感の変わるところです。

JSXがThree.jsに変換される、3つのルール

<mesh><boxGeometry>は、Reactが知っているタグではありません。R3Fが変換しています。ルールは3つだけです。

ルール1|小文字のタグ = THREEのクラス

タグ名の頭を大文字にすると、THREEのクラス名になります。

<mesh />              // → new THREE.Mesh()
<boxGeometry />       // → new THREE.BoxGeometry()
<directionalLight />  // → new THREE.DirectionalLight()

一覧表を暗記する必要はありません。 Three.jsのドキュメントでクラスを見つけたら、頭を小文字にすればそれがタグ名です。

ルール2|args = コンストラクタの引数

newに渡していた引数は、argsに配列で渡します。

<boxGeometry args={[1, 1, 1]} />        // → new THREE.BoxGeometry(1, 1, 1)
<color args={["#15171c"]} />            // → new THREE.Color("#15171c")

ひとつ注意があります。argsが変わるとインスタンスが作り直されます。 コンストラクタ引数は後から変えられないので、R3Fは黙ってnewし直すわけです。

実際に確かめると分かりやすくて、colorを変えたときはmaterialもgeometryも同じインスタンスのままuuidが変わらない)色だけが差し替わるのに、args[1,1,1]から[2,1,1]に変えた瞬間、geometryは別物に作り直されます

つまり、毎フレーム変わる値をうっかりargsに入れると、そのたびにインスタンスが作り直されて重くなります。動かしたい値は次のルール3側に置いてください。

ルール3|その他のprops = インスタンスのプロパティ

残りのpropsは、そのままプロパティに代入されます。

<mesh position={[0, 1, 3]} />   // → mesh.position.set(0, 1, 3)
<mesh rotation-x={-Math.PI / 2} />  // → mesh.rotation.x = -Math.PI / 2
<meshStandardMaterial color="#4a90d9" roughness={0.3} metalness={0.6} />

rotation-xのようにハイフンで潜れるのが地味に便利で、これはピアス記法と呼ばれます。material-colorのように、子のさらに奥のプロパティまで一発で触れます。

そしてattach。geometryとmaterialは<mesh>の子に置けば自動で正しい場所に入りますが、それ以外は行き先を明示します。

<color attach="background" args={["#15171c"]} />  // → scene.background = ...

これは「親のbackgroundプロパティに入れてくれ」という指示です。<fog attach="fog">なども同じ形になります。

アニメーションはuseFrame

setAnimationLoopに相当するのがuseFrameです。ここが素のThree.jsとの一番の違いなので、少し丁寧にいきます。

import { useRef } from "react";
import { useFrame } from "@react-three/fiber";

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

  useFrame((state, delta) => {
    ref.current.rotation.y += delta * 0.5; // deltaが渡ってくる
  });

  return (
    <mesh ref={ref} castShadow>
      <boxGeometry />
      <meshStandardMaterial color="#4a90d9" roughness={0.3} metalness={0.6} />
    </mesh>
  );
}

第2引数のdeltaは前フレームからの経過秒です。A4のAnimationMixerで自分で差分を計算していたやつが、最初から渡ってきます。

そして絶対に押さえてほしい原則がこれです。

useFrameの中でsetStateを呼ばない。refを直接触る。

毎フレームsetStateすると、毎フレームReactの再レンダリングが走ります。60fpsで再レンダリング60回。React側が確実に音を上げます。

R3Fでは、Reactの再レンダリングと描画ループは完全に別物です。Reactは「シーンに何を置くか」の担当で、「毎フレーム値をどう動かすか」はref経由でThree.jsのオブジェクトを直接殴る。この住み分けができれば、R3Fは素のThree.jsと同じ速度で動きます。

初めて触ったときの僕は、ここを分かっておらず「Reactで書くと重い」と思い込んでいました。重かったのは書き方のほうでした。

dreiを入れると、書く量が激減する

@react-three/dreiは、R3Fのよく使う部品を詰め合わせた公式のヘルパー集です。base.jsの残りの部分は、ほぼこれで片付きます。

import { Canvas } from "@react-three/fiber";
import { OrbitControls, Environment } from "@react-three/drei";

<Canvas shadows camera={{ position: [0, 1, 3], fov: 75 }}>
  <color attach="background" args={["#15171c"]} />

  <ambientLight intensity={1.5} />
  <directionalLight position={[2, 3, 4]} intensity={2} castShadow />
  <Environment preset="apartment" />   {/* 環境マップ */}

  <Cube />

  {/* 影を受ける床 */}
  <mesh rotation-x={-Math.PI / 2} position-y={-1} receiveShadow>
    <planeGeometry args={[20, 20]} />
    <meshStandardMaterial color="#2a2d34" roughness={1} />
  </mesh>

  <OrbitControls enableDamping />
</Canvas>

PMREMGeneratorOrbitControlsの初期化も消えました。これでbase.jscreateWorld()は完全に置き換わったことになります。

⚠️ <Environment preset="...">は、プリセットのHDRI画像をCDNから取得します。オフラインで動かしたい・外部依存を切りたい場合はfilesにローカルのHDRIを指定してください。

GLTFの読み込みも、dreiだと1行です。

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

function Model() {
  const { scene } = useGLTF("/robot.glb");
  return <primitive object={scene} />;
}

// 使う側は Suspense で包む
<Suspense fallback={null}>
  <Model />
</Suspense>

<primitive>は「既にあるThree.jsオブジェクトをそのままシーンに置く」ためのタグで、ローダーで作ったものを扱うときの定番です。A4で書いたloader.load()のコールバックが、まるごと消えたのが分かると思います。

<Suspense>は「なくても動く」が、無いと全部消える

useGLTFは読み込みが終わるまでサスペンドします。ここで多くの入門記事が「<Suspense>で囲むのは必須」と書いていますが、実際には囲まなくてもクラッシュしません<Canvas>が内部にSuspenseを持っているからです。

ただし、その内部fallbackの中身がなかなか強烈で、Canvasコンポーネント自身をサスペンドさせる作りになっています。つまり内側で誰かが読み込み待ちに入ると、その間シーンごと消えます

手元で試したのがこれです。同じシーン(回る箱+読み込み中のモデル)で、<Suspense>の有無だけを変えました。

読み込み中に見えるもの
<Suspense>で包まない真っ白。箱すら出ない(シーン全体が待たされる)
<Suspense fallback={null}>で包む箱は出たまま。モデル部分だけが待つ

「モデルを足したら画面が真っ白になった」の正体はだいたいこれです。落ちているのではなく、シーン全体が読み込みを待っている

なので、正しい理解はこうなります。

<Suspense>は必須ではない。だが「どこまでを待たせるか」の境界線なので、置かないと境界がCanvas全体になる。

読み込みに時間がかかるものほど、狭く囲む。これだけ覚えておけば大丈夫です。

で、R3Fにすべきなのか?

正直に書きます。全部R3Fにしたほうがいい、とは思っていません。

R3Fが効くとき素のThree.jsで十分なとき
すでにReact / Next.jsで作っている静的サイトの単発演出だけ3Dにしたい
UIの状態と3Dが連動する(ボタンで色を変える等)描画がずっと自走していればいい
3Dのパーツを再利用・量産したい1シーンで完結する作品
dreiのエコシステムに乗りたいシェーダー中心の実験

判断軸はシンプルで、「そのページにReactがもういるか」です。いるなら乗せたほうが圧倒的に楽。いないなら、Reactを連れてくるコストのほうが大きいことがほとんどです。

僕自身、Next.jsでポートフォリオを作ったときはR3Fを選びましたが、Xに上げている単発の作品は今も素のThree.jsで書いています。道具は使い分けるものです。

よくあるつまずき

症状原因対処
Hooks can only be used within the Canvas component!useFrameuseThree<Canvas>の外で呼んでいる中身をコンポーネントに切り出して<Canvas>の子にする
何も起きない(エラーすら出ない)親要素に高さがなく、<Canvas>のサイズが0親にheightを与える(100vhなど)。サイズ0だとWebGLの初期化自体が走らない
影が落ちないshadowsを付け忘れ<Canvas shadows>castShadow / receiveShadow
モデルを足したら画面が真っ白読み込み待ちをCanvas全体が受けているuseGLTFを使う側を<Suspense>で狭く包む
Next.jsでwindow is not definedSSR時にThree.jsが動くdynamic(() => import(...), { ssr: false })
動きがカクつく・重いuseFrame内でsetStaterefを直接更新する
そもそもビルドが通らないReact / R3F / drei のバージョン不一致上のバージョン表を確認

まとめ

  • R3FはReactのレンダラーreact-domがDOMを吐くように、Three.jsのオブジェクトを吐く。Three.jsの知識はそのまま使える
  • React 19 → R3F v9 → drei v10 の組で使う。ここのズレが初手の事故原因
  • 変換ルールは3つ。小文字タグ=クラス / args=コンストラクタ引数 / その他props=プロパティrotation-xのピアス記法つき)
  • scene.add()は書かない。JSXの入れ子がそのままシーングラフ
  • ループはuseFrame中でsetStateせずrefを直接触る——これだけ守れば速度は落ちない
  • dreiの<OrbitControls><Environment>useGLTFで、土台のコードはほぼ消える
  • <Suspense>は必須ではないが、「どこまでを待たせるか」の境界線。置かないと境界がシーン全体になる(=読み込み中に真っ白)

シリーズでずっと育ててきた「回る箱」の世界が、そのままJSXに引っ越せました。中身の3Dは何ひとつ変わっていない——これがR3Fの一番の安心材料だと思います。


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

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

👉 Amazonで見る »


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

しゅん

しゅん

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

← 記事一覧に戻る