[Next.js #44]  R3F + BlenderのCell Fractureと物理演算で描く「水晶の雨」

はじめに

YoutubeのBlender動画を見ていて、Boxジオメトリーを落下させて破壊するアニメーション処理があり、Three.js で同様の事が出来ないかと試行錯誤。

BoxGeometryをランダムな複数のオブジェクトに分割するのは、かなり難易度が高そうなので諦め、BlenderのアドオンCell Fractureを活用して分割し、それをThree.js で読み込むことで実装。

それに当たり、Nexit.jsらしく久しぶりにR3F(React Three Fiber)でやってみました。

流れとしては

  • Blenderの物理破壊アドオン「Cell Fracture」を使って綺麗な球体を分割
  • R3F上にインポート
  • リアルタイムの物理演算(Rapier)で砕け散

さらに、床には前回(#43)でも触れたノイズ関数の知識を活かして、エネルギーが揺らめくようなシェーダーを実装しています。

スクリーンショット:

動画(Youtube):

動画(PC):

1. Blenderでの下準備:Cell Fractureによるオブジェクト分割

まずは、R3F上で砕け散る「クリスタル本体」をBlenderで作成します。Blenderには標準で物理破壊シミュレーション用のアドオンが用意されているため、これを利用すると非常に簡単です。

Step 1: UV球(綺麗な球体)の作成

デフォルトの立方体は削除し、新しく球体を配置します。

  1. Blenderを開き、“Shift + A” > “メッシュ” > “UV球” を選択して配置します。
  2. そのままだと表面がカクカクしているので、球体を右クリックして “スムーズシェード” をかけ、表面を滑らかにしておきます。
  3. (任意)サイズが大きすぎる場合は、“S” キーで適度な大きさ(半径1m程度)にスケールを調整し、“Ctrl + A” > “スケール” で適用しておきましょう。

Step 2: Cell Fractureアドオンの有効化と適用

球体をボロノイ状(ランダムなガラスの破片のような形)に分割します。

  1. アドオンの有効化: 上部メニューの “編集” > “プリファレンス” > “アドオン” を開き、検索バーに「Cell」と入力します。 リストに出てくる “Object: Cell Fracture” のチェックボックスをオンにします。
  1. 分割の実行: 作成したUV球を選択した状態で、上部メニューの “オブジェクト” > “クイックエフェクト” > “Cell Fracture” をクリックします。
  1. 設定の調整: 設定ウィンドウが開きます。ブラウザでのリアルタイム物理演算(Rapier)は破片が多すぎると重くなるため、以下の設定がおすすめです。
    • Source Limit (破片の数): 30 〜 50 程度(多すぎるとブラウザがクラッシュする原因になります)。
    • Noise (ノイズ): 1.0 に設定すると、破片の断面が直線的ではなくいびつになり、より「自然なクリスタルの割れ方」になります。
  1. OK を押すと、自動的に計算が始まり、球体が複数の破片メッシュに分割されます。

  2. 分割が終わったら、元の「割れていないUV球」は不要になるので削除(または非表示に)しておきます。

  • “A” -> “CTRL + A” -> “全トランスフォーム”

  • “オブジェクト”  -> “原点設定” -> “原点をジオメトリへ移動”

Step 3: GLB形式でのエクスポート

分割された破片を、Three.js(R3F)で読み込める形式で書き出します。

  1. 分割されて生成されたすべての破片をドラッグして選択します(アウトライナーで破片のコレクションを選択すると確実です)。
  2. 上部メニューの “ファイル” > “エクスポート” > “glTF 2.0 (.glb/.gltf)” を選択します。
  3. エクスポート画面の右側にある設定パネルで、以下の項目を必ずチェックします。
    • 内容 > 選択したオブジェクト: チェックを入れる(余計なカメラやライトを含めないため)。
    • データ > メッシュ > モディファイアーを適用: チェックを入れる。
    • アニメーション > アニメーション: チェックを外す(今回はR3F側の物理エンジンで動かすため、Blender側のアニメーションデータは不要です)。
  4. ファイル名を “CellFractureBox.glb” (※コード側の読み込み名に合わせます)とし、プロジェクトの “public/models/” フォルダ内にエクスポートします。

これで、ブラウザで物理演算にかけるための「美しい破片の集まり」の準備が整いました。次はこのGLBファイルをR3Fに読み込みます。

2. R3FとRapierによる物理演算の実装

Blenderで作成した分割モデル(CellFractureBox.glb)をR3Fで読み込み、@react-three/rapier を使って物理演算を適用します。

パフォーマンスを安定させる工夫(状態のスワップ)

最初、すべての破片に個別の RigidBody を割り当てて空から降らせたところ、**「空中で破片同士が衝突して計算が追いつかず、不自然に空中に浮いてしまう」「ブラウザがクラッシュする」**という問題が発生しました。

そこで、落下中と衝突後で物理演算の状態を2つに分けるアプローチをとりました。

  1. 落下中(Solid): 1つのまとまった球体(colliders=“ball”)として扱い、軽く計算させる。
  2. 衝突後(Shattered): 地面に激突した瞬間に isShattered フラグを切り替え、個別の破片(colliders=“hull”)にスワップ。同時に applyImpulse(衝撃)を与えて弾け飛ばす。

これにより、ブラウザへの負荷を抑えつつ、ダイナミックな破壊表現が可能になりました。

実装コード(FallingSphere.tsx)

上記のスワップロジックを実装したコンポーネントです。

"use client";

import React, { useState, useRef, useMemo } from "react";
import { useGLTF } from "@react-three/drei";
import { RigidBody, RapierRigidBody } from "@react-three/rapier";
import * as THREE from "three";

export const FallingSphere = ({ position, scale = 1 }: { position: [number, number, number]; scale?: number }) => {
  const [isShattered, setIsShattered] = useState(false);
  const { nodes } = useGLTF("/models/CellFractureBox.glb");

  // GLBからメッシュ(破片)だけを抽出
  const fragments = useMemo(() => {
    return Object.values(nodes).filter((node) => node.type === "Mesh") as THREE.Mesh[];
  }, [nodes]);

  const handleShatter = () => {
    if (isShattered) return;
    setIsShattered(true); // 衝突時にフラグを切り替え
  };

  return (
    <group position={position}>
      {!isShattered ? (
        /* --- 1. 落下中:1つの物理ボディとして扱う --- */
        <RigidBody type="dynamic" colliders="ball" onCollisionEnter={handleShatter}>
          {fragments.map((mesh) => (
            <mesh key={mesh.uuid} geometry={mesh.geometry} scale={scale}>
              {/* マテリアル設定(次セクションで解説) */}
              <meshPhysicalMaterial transmission={1} thickness={scale} roughness={0.1} />
            </mesh>
          ))}
        </RigidBody>
      ) : (
        /* --- 2. 衝突後:個別の破片にバラす --- */
        fragments.map((mesh) => {
          // 初期位置をスケールに合わせて調整
          const initialPos = mesh.position.clone().multiplyScalar(scale);
          return (
            <RigidBody
              key={mesh.uuid}
              position={[initialPos.x, initialPos.y, initialPos.z]}
              rotation={[mesh.rotation.x, mesh.rotation.y, mesh.rotation.z]}
              scale={scale}
              type="dynamic"
              colliders="hull" // 凸包(より正確な当たり判定)
            >
              <mesh geometry={mesh.geometry}>
                <meshPhysicalMaterial
                  transmission={1.0}
                  thickness={0.5 * scale}
                  roughness={0.2}
                  emissive="#3366ff" // 砕けた瞬間に少し発光させる
                  emissiveIntensity={0.2}
                />
              </mesh>
            </RigidBody>
          );
        })
      )}
    </group>
  );
};

さらに安定させるための「お掃除」ロジック

今回の演出では、この水晶が「雨」のように次々と降り注ぎます。破片が画面内に溜まり続けると、あっという間に数千個の RigidBody が生成されて処理落ちしてしまいます。

これを防ぐため、生成元のコンポーネント(親要素)側で、**「生成から一定時間(例:15秒)経った球体データは配列から削除する」**というメモリ解放の仕組み(ガベージコレクション)を入れています。

// 親コンポーネントでの生成と削除のイメージ
const newSphere = { id: Date.now(), pos: [...] };
setSpheres((prev) => [...prev, newSphere]);

// 15秒後にその球体(と全破片)を削除してメモリを解放
setTimeout(() => {
  setSpheres((prev) => prev.filter((s) => s.id !== newSphere.id));
}, 15000);

このように「見えないところで消す」処理を徹底することで、長時間の演出でも60FPSを維持できるようになります。

3. マテリアルとライティングの追い込み

物理演算で動きを作っただけでは、ただの「グレーの石ころ」が落ちてくるだけです。これを「魔法のクリスタル」へと昇華させるため、Three.jsの高機能なマテリアルとポストプロセッシング(事後処理)を調整します。

透き通るガラスの質感(MeshPhysicalMaterial)

クリスタルのような透明・屈折を表現するには、標準の meshStandardMaterial ではなく、meshPhysicalMaterial を使用します。

<meshPhysicalMaterial
  transmission={1.0}        // 完全に透過させる
  thickness={1.0 * scale}   // 屈折のための「厚み」(スケールに連動させると自然)
  roughness={0.1}           // ほんの少しだけ粗くしてリアルな光の散乱を作る
  ior={1.5}                 // 屈折率(ガラスや水晶は1.5付近)
  envMapIntensity={2.0}     // 環境マップの反射強度
/>

特に thickness(厚み)の設定が重要で、これがないと単なる「薄いセロハン」のようになってしまい、宝石のような重量感のある屈折が生まれません。

必須の「環境マップ(Environment)」

透明なマテリアルを設定した際、**「オブジェクトが真っ黒になってしまう」**という問題によく直面します。これは、周囲に「反射・屈折させる対象(景色)」が存在しないためです。

これを解決するために、@react-three/drei の Environment コンポーネントを配置し、シーン全体を包み込む環境光と反射用の景色を提供します。

// page.tsx の Canvas 内に配置
import { Environment } from "@react-three/drei";

<Canvas>
  <color attach="background" args={["#050505"]} />
  {/* preset="city" で、都市の夜景のような複雑な光源を反射させる */}
  <Environment preset="city" />

  {/* ...物理演算やメッシュの配置... */}
</Canvas>

これを入れるだけで、クリスタルのエッジが周囲の光を拾い、一気にキラキラと輝き始めます。

砕ける瞬間の「発光」と Bloom効果

さらにドラマチックな演出として、「地面に激突して砕けた瞬間にだけ、破片が青く発光する」ギミックを入れます。

マテリアルの emissive(自発光)プロパティを、物理演算の isShattered フラグと連動させます。

<meshPhysicalMaterial
  // ... (透過設定など) ...
  emissive={isShattered ? "#3366ff" : "#000000"}
  emissiveIntensity={isShattered ? 0.3 : 0}
/>

そして、この発光を画面全体に「ぼわっ」と滲ませるために、@react-three/postprocessing の Bloom(ブルーム) 効果を追加します。

import { EffectComposer, Bloom } from "@react-three/postprocessing";

<Canvas>
  {/* ... */}
  <EffectComposer>
    <Bloom
      luminanceThreshold={0.8} // どのくらいの明るさから光らせるかの閾値
      mipmapBlur
      intensity={0.5}          // 光の強さ
      radius={0.3}             // 光の広がり具合
    />
  </EffectComposer>
</Canvas>

注意点: 破片の数が多い場合、Bloom の intensity が高すぎると、破片が重なった部分が激しく白飛びして視覚的なノイズになってしまいます。破片が多いデモでは、少し控えめな数値に設定するのが美しく見せるコツです。

4. 床のノイズシェーダーと鏡面反射

最後に、クリスタルの雨を受け止める「祭壇(床)」を作り込みます。単なる平面ではなく、反射と発光を組み合わせて魔法陣のような空間を演出します。

床は以下の3層構造になっています。

① 物理演算用の「見えない床」と当たり判定の罠

まず、破片を受け止めるための物理的な床を <RigidBody type="fixed"> で配置します。ここで一つ、Rapier特有の落とし穴がありました。

単に円形のメッシュ(circleGeometry)を配置しただけだと、物理エンジンが自動的に「円をすっぽり囲む四角い当たり判定(Cuboid)」を生成してしまい、**「円の外側に落ちた破片が、見えない空中の床に乗って止まってしまう」**というバグが発生しました。

これを防ぐため、colliders=“trimesh” を明示的に指定し、当たり判定をメッシュの形状(円形)に正確に沿わせています。

{/* 1層目:物理演算用の見えない床 */}
<RigidBody type="fixed" colliders="trimesh">
  <mesh rotation={[-Math.PI / 2, 0, 0]}>
    <circleGeometry args={[15, 64]} />
    <meshBasicMaterial transparent opacity={0} />
  </mesh>
</RigidBody>

② 破片を映し出す鏡面の床(MeshReflectorMaterial)

次に、視覚的なベースとなる「鏡の床」を作ります。@react-three/drei の MeshReflectorMaterial を使うと、驚くほど簡単にリアルタイムの反射を実装できます。

{/* 2層目:反射する床(ベース) */}
<mesh rotation={[-Math.PI / 2, 0, 0]}>
  <circleGeometry args={[15, 64]} />
  <MeshReflectorMaterial
    blur={[300, 100]}        // 反射のぼかし具合
    resolution={1024}       // 反射の解像度
    mixStrength={80}        // 反射の強さ
    color="#050505"         // ベースの色は暗くして反射を目立たせる
    mirror={1}
  />
</mesh>

これで、頭上から落ちてくるクリスタルが床に美しく映り込むようになります。

③ fBmノイズによる「エネルギーの奔流」シェーダー

最後に、鏡面の床の上にわずかに浮かせる形で、発光するノイズのレイヤーを重ねます。ここでは、前回(#43)の「Procedural Aurora」の記事でも扱った fBm(Fractal Brownian Motion) をGLSLで記述したカスタムシェーダーマテリアル(NoiseFloorMaterial)を使用しています。

{/* 3層目:ノイズを描画するシェーダーレイヤー */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.01, 0]}>
  <circleGeometry args={[15, 64]} />
  <noiseFloorMaterial
    ref={materialRef}
    transparent
    blending={THREE.AdditiveBlending} // 加算合成で光を重ねる
  />
</mesh>

シェーダーのポイント(GLSL): フラグメントシェーダー内で時間変数(uTime)を使ってノイズの座標を少しずつずらすことで、青白いオーラがゆっくりと呼吸するように蠢くアニメーションを作っています。

// FloorMaterial.ts のフラグメントシェーダーの一部
void main() {
  vec2 uv = vUv * 3.0;
  // uTimeを足してノイズをアニメーションさせる
  float n = fbm(uv + uTime * 0.1);

  // 中心から外側に向かって暗くグラデーションさせる
  float dist = distance(vUv, vec2(0.5));
  float alpha = smoothstep(0.5, 0.2, dist);

  vec3 highlightColor = vec3(0.3, 0.4, 0.8);
  vec3 finalColor = mix(uColor * 0.5, highlightColor, n * 1.2);

  // 加算合成用の出力
  gl_FragColor = vec4(finalColor, (n * 0.8 + 0.2) * alpha);
}

blending={THREE.AdditiveBlending}(加算合成)を指定しているため、下のレイヤー(鏡面反射)を消すことなく、ノイズの光だけが純粋に上乗せされ、非常に神秘的な空間が完成しました。

まとめ

Blenderでの事前割断(Cell Fracture)と、R3F上でのリアルタイム物理演算(Rapier)、そしてGLSLによるプロシージャルなノイズ表現(fBm)。これら複数の技術を組み合わせることで、ブラウザ上でも非常にシネマティックでリッチな「クリスタル・レイン」の表現が可能になりました。

特に、物理エンジンの負荷を減らすための「落下中と衝突後の状態スワップ」や「古いオブジェクトのクリーンアップ」といった最適化のアプローチは、今後さらに複雑なWebXRや3Dインタラクションを作っていく上でも強力な武器になりそうです。