[Next.js #18] R3Fで「無限の水平線」を作る — Ocean & Volumetric Clouds

1. はじめに:海を置き、空を彫る

毎日の Next.js + React Three Fiber (R3F) 学習用実装連載、今日のテーマは 「海と空」 です。

昨日は物理演算や配列操作といったロジックの塊である「ルービックキューブ」と格闘していましたが、今日は打って変わってシェーダーと数学の世界へ飛び込みます。 今朝学習したばかりの「Noise 入門」の理論 —— 特にボリューメトリック・レンダリング —— を、記憶が熱いうちに Next.js 上で実装してみようという試みです。

「書き割り」の世界からの脱却

Web 3D において、空を表現する最も簡単な方法は「スカイボックス(Skybox)」です。全天球画像を背景に貼り付ければ、一瞬でリアルな空が手に入ります。あるいは、カメラの方を常に剥く「ビルボード(板ポリゴン)」に雲の画像を貼る手法も一般的です。

しかし、それでは 「雲の中」 には入れません。 カメラが近づけば画像は荒くなり、通り抜ければただの薄っぺらい板であることが露見してしまいます。

そこで今回は、「計算によって生成された海」 と 「体積(Volume)を持った雲」 の実装に挑みます。 テクスチャ(画像)を貼るのではなく、何もない空間に数学的な関数で「密度」と「波」を定義し、そこにあるかのように描画するのです。

技術スタック

今回は以下の2つの技術を R3F のエコシステム(@react-three/fiber, @react-three/drei)上で統合します。

  1. Three.js 公式 Water (Water2): 環境マップの反射(Reflection)、屈折、そして法線マップによる複雑なうねりを備えた、ハイエンドな海面表現。これを R3F で使えるように拡張します。
  2. Volumetric Clouds (Raymarching): 3D Noise(FBM)とレイマーチング(光線進行法)を用いた、厚みのある雲。Beer’s Law(光の減衰)を計算することで、雲の「底」が暗くなるリアルな陰影を表現します。

さらに、これらのパラメータ(雲の量、密度、風の流れなど)を Leva と連携させ、ブラウザ上でリアルタイムに「空をデザインできる」環境を構築します。

2. 海の実装:Official Water を R3F で使う

Three.js には、反射・屈折・波のうねりを表現できる非常に高機能なシェーダー Water.js(Water2)が公式のアドオンとして用意されています。 しかし、これは標準の Three.js クラスであり、React Three Fiber (R3F) のコンポーネントとしては直接提供されていません。

そこで R3F の extend 関数を使い、Three.js のクラスを JSX のタグとして扱えるように変換(Catalogへの登録)を行います。

app/components/Water.tsx

"use client";

import React, { useRef, useMemo } from "react";
import { extend, useFrame, useLoader } from "@react-three/fiber";
import * as THREE from "three";
import { Water } from "three/addons/objects/Water.js";

// 1. three.js のクラスを R3F コンポーネントとして登録
// これにより <water /> タグが使用可能になります
extend({ Water });

export default function Ocean() {
  const ref = useRef<any>();

  // 2. 波の模様(法線マップ)を読み込む
  const waterNormals = useLoader(
    THREE.TextureLoader,
    "https://raw.githubusercontent.com/mrdoob/three.js/master/examples/textures/waternormals.jpg"
  );

  // テクスチャを繰り返す設定(無限の広がりを表現するために必須)
  waterNormals.wrapS = waterNormals.wrapT = THREE.RepeatWrapping;

  // 3. 設定値のメモ化
  const config = useMemo(
    () => ({
      textureWidth: 512,
      textureHeight: 512,
      waterNormals,
      sunDirection: new THREE.Vector3(),
      sunColor: 0xffffff,
      waterColor: 0x001e0f, // 深い海の色
      distortionScale: 3.7, // 波の歪み具合
      fog: true,
    }),
    [waterNormals]
  );

  // 海面のジオメトリ(巨大な平面)
  const geom = useMemo(() => new THREE.PlaneGeometry(10000, 10000), []);

  useFrame((state, delta) => {
    if (ref.current) {
      // 4. uniform 'time' を更新して波を動かす
      // 公式の Water シェーダーは time uniform を持っています
      ref.current.material.uniforms["time"].value += delta * 0.5;
    }
  });

  // args は new Water(geom, config) の引数として渡されます
  return <water ref={ref} args={[geom, config]} rotation-x={-Math.PI / 2} />;
}

実装のポイント

  • extend({ Water }): 命令的な Three.js のクラスを、宣言的な React コンポーネントの世界に持ち込むための重要なステップです。これにより、キャメルケースのクラス名 Water が、小文字の JSX タグ <water /> として利用可能になります。
  • args={[geom, config]}: R3F では args プロパティに配列を渡すと、そのコンポーネントのインスタンス化時(new Water(...))の引数として展開されます。ここでは第一引数にジオメトリ、第二引数にシェーダーの設定オブジェクトを渡しています。
  • 巨大な平面: PlaneGeometry(10000, 10000) を水平(rotation-x={-Math.PI / 2})に配置することで、視界の限界まで続く水平線を作り出しています。カメラの位置によっては、さらに大きくする必要がある場合もあります。

3. 空の実装:Volumetric Clouds (Raymarching)

今回の核となるパートです。「Noise 入門 #09」で学んだ理論をコードに落とし込みます。 従来の「絵(テクスチャ)」を描く手法ではなく、「巨大な箱(Box)」の中に密度関数を定義し、光線を飛ばして(レイマーチング)描画するという、物理シミュレーションに近いアプローチをとります。

シェーダーの設計思想

このシェーダーは、何もない空間に「雲」という実体を作り出すために、以下の3つの数学的概念を組み合わせています。

  • 3D FBM (Fractal Brownian Motion): 空間内の座標 を入力すると、その地点の「密度」を返す関数。これが雲の「形」になります。
  • Raymarching (Accumulation): カメラからの視線(Ray)に沿って少しずつ進み、各地点の密度を足し合わせていく(積分する)ことで、厚みのある雲を表現します。
  • Beer’s Law (Optical Depth): 「密度が高いほど光は透過しない」という物理法則。これを計算に入れることで、雲の底が暗くなり、巨大な積乱雲のような陰影が生まれます。

app/projects/three-sea-and-sky/page.tsx (Shader部分)

const CloudMaterial = {
  uniforms: {
    uTime: { value: 0 },
    uThreshold: { value: 0.45 }, // 雲の切れ間(Levaで制御)
    uScale: { value: 0.05 },     // 雲の細かさ(Levaで制御)
    uSunPosition: { value: new THREE.Vector3(100, 20, 100) },
  },
  vertexShader: `
    varying vec3 vWorldPosition;
    void main() {
      // ワールド座標を Fragment Shader に渡す
      vec4 worldPosition = modelMatrix * vec4(position, 1.0);
      vWorldPosition = worldPosition.xyz;
      gl_Position = projectionMatrix * viewMatrix * worldPosition;
    }
  `,
  fragmentShader: `
    varying vec3 vWorldPosition;
    uniform float uTime;
    uniform float uThreshold;
    uniform float uScale;
    uniform vec3 uSunPosition;

    // --- Noise & FBM Functions ---
    // 乱数生成器
    float hash(float n) { return fract(sin(n) * 43758.5453123); }

    // 3D Noise Function: 空間の密度を決定する基礎
    float noise(vec3 x) {
      vec3 p = floor(x);
      vec3 f = fract(x);
      f = f * f * (3.0 - 2.0 * f);
      float n = p.x + p.y * 57.0 + 113.0 * p.z;
      return mix(mix(mix(hash(n + 0.0), hash(n + 1.0), f.x),
                     mix(hash(n + 57.0), hash(n + 58.0), f.x), f.y),
                 mix(mix(hash(n + 113.0), hash(n + 114.0), f.x),
                     mix(hash(n + 170.0), hash(n + 171.0), f.x), f.y), f.z);
    }

    // Fractal Brownian Motion (3 Octaves): 複雑なディテールを作る
    float fbm(vec3 p) {
      float f = 0.0;
      f += 0.5000 * noise(p); p = p * 2.02;
      f += 0.2500 * noise(p); p = p * 2.03;
      f += 0.1250 * noise(p);
      return f;
    }

    void main() {
      vec3 rayOrigin = cameraPosition;
      vec3 rayDirection = normalize(vWorldPosition - cameraPosition);

      float depth = 0.0;
      float opacity = 0.0;
      vec3 color = vec3(0.0);

      // --- Raymarching Loop ---
      // 視線方向に最大50歩進んでサンプリングする
      for(int i = 0; i < 50; i++) {
        vec3 p = rayOrigin + rayDirection * depth;

        // 1. 座標に風(uTime)とスケール(uScale)を適用
        // 時間経過で座標をずらすことで、雲が流れる表現になる
        vec3 windPos = p * uScale + vec3(uTime * 0.1, 0.0, uTime * 0.05);
        float d = fbm(windPos);

        // 2. 密度関数の調整 (閾値で雲の形を削る)
        // uThreshold 未満の薄いノイズを切り捨てて「雲の塊」だけを残す
        d = smoothstep(uThreshold, uThreshold + 0.2, d);

        // 3. 箱の端をフェードアウト (Soft Edges)
        // 箱の境界でパツッと切れないように、端に行くほど透明にする
        float edgeFade = smoothstep(500.0, 300.0, length(p.xz)) * smoothstep(25.0, 5.0, abs(p.y - 40.0));
        d *= edgeFade;

        // 4. 蓄積 (Accumulation)
        if (d > 0.01) {
          float transmittance = exp(-d * 0.8); // Beer's Law (濃いほど暗くなる)
          opacity += d * 0.15;

          // 密度に応じて色を混ぜる(薄いところは青っぽく、濃いところは白く)
          color += mix(vec3(0.5, 0.6, 0.7), vec3(1.0), d) * transmittance;
        }

        // Early Exit: 不透明度が1.0を超えたら奥は見えないので計算打ち切り
        if (opacity >= 1.0) break;

        depth += 2.0; // Step Size: 一歩の大きさ
      }

      gl_FragColor = vec4(color, opacity);
    }
  `,
};

4. 統合と制御(Leva)

最後に、作成した「海」と「空」を同じ Canvas に配置し、Leva を使ってパラメータをリアルタイム制御できるように統合します。

ここで重要なのが、シェーダーの uniforms の更新方法です。 React の State(Leva の値)が変わるたびにコンポーネント全体を再レンダリング(Re-render)させてしまうと、パフォーマンスが悪化し、映像がカクつく原因になります。 そこで useFrame フックを使い、React のレンダリングサイクルとは独立して、毎フレーム直接 GPU(シェーダー)に値を流し込むアプローチを取ります。

app/projects/three-sea-and-sky/page.tsx (Main)

// ... imports ...

function VolumetricClouds({ cloudThreshold, cloudScale }) {
  const meshRef = useRef<THREE.Mesh>(null!);

  useFrame((state) => {
    if (meshRef.current) {
      const mat = meshRef.current.material as THREE.ShaderMaterial;

      // 時間の更新(風を吹かせる)
      mat.uniforms.uTime.value = state.clock.getElapsedTime();

      // Levaの値をシェーダーへ直接転送
      // これにより React の再レンダリングを発生させずに数値を反映できる
      if (mat.uniforms.uThreshold) mat.uniforms.uThreshold.value = cloudThreshold;
      if (mat.uniforms.uScale) mat.uniforms.uScale.value = cloudScale;
    }
  });

  return (
    <mesh ref={meshRef} position={[0, 40, 0]}>
      {/* 雲の描画領域となる巨大なボックス */}
      {/* 視錐台カリングで消えないよう、十分な大きさが必要 */}
      <boxGeometry args={[1000, 50, 1000]} />

      {/* カメラが中に入っても描画されるよう BackSide を指定 */}
      <shaderMaterial args={[CloudMaterial]} transparent side={THREE.BackSide} />
    </mesh>
  );
}

export default function SeaAndSkyPage() {
  // Leva UI の設定: 雲の量と細かさを調整
  const { cloudThreshold, cloudScale } = useControls("Cloud Settings", {
    cloudThreshold: { value: 0.45, min: 0.2, max: 0.8, step: 0.01 },
    cloudScale: { value: 0.05, min: 0.01, max: 0.2, step: 0.01 },
  });

  return (
    <div style={{ width: "100vw", height: "100vh", background: "#000" }}>
      <Canvas camera={{ position: [0, 15, 60], fov: 60 }}>
        {/* カメラ操作とパフォーマンス計測 */}
        <OrbitControls makeDefault />
        <Stats />

        {/* 環境光と空の基本色 */}
        <Sky sunPosition={[100, 20, 100]} />
        <ambientLight intensity={0.5} />

        {/* テクスチャ読み込み待ちのための Suspense */}
        {/* Waterコンポーネント内で useLoader を使っているため必須 */}
        <Suspense fallback={null}>
          <Water />
        </Suspense>

        <VolumetricClouds cloudThreshold={cloudThreshold} cloudScale={cloudScale} />
      </Canvas>
    </div>
  );
}

実装のポイント

  • useFrame による直接更新: cloudThreshold などの Props が変化しても、Mesh 自体を再生成せず、ref を通じて uniforms.value だけを書き換えています。これにより、スライダーを激しく動かしても滑らかな描画が維持されます。
  • <Suspense> の配置: Water コンポーネント内部で非同期にテクスチャ(法線マップ)を読み込んでいるため、読み込み完了まで待機するための <Suspense> ラッパーが必要です。これを忘れるとランタイムエラーになります。
  • THREE.BackSide: 雲のボックスの中に入って「雲海」を飛び回る体験を可能にするため、ポリゴンの裏面も描画するように設定しています。

5. まとめ

今回の実装を通して、ブラウザ上に「無限の水平線」が出現しました。

  • Official Water: 法線マップと反射計算による、波のうねりを感じるリアルな質感。
  • Raymarching Clouds: 「中身(Volume)」があり、光を通し、中に入って飛ぶことができる雲。
  • Interaction: Leva を通じた、雲の量や流れる速度の即時反映。

単に綺麗な背景画像(Skybox)を貼るのではなく、数学と物理シミュレーションを用いて環境そのものをゼロから構築する。 これこそが、Procedural Nature(手続き型自然生成) の第一歩であり、醍醐味です。

次回は、この静止した世界に「時間」の概念を導入し、夕焼けから夜へと空の色と光が移り変わるシステム に挑戦してみましょう。


理論とコードの答え合わせ

この記事を読んだ後、今日実装したシェーダーコードのどの部分が、「Noise 入門」で学んだどの理論に対応しているかを振り返ってみてください。

  • fbm(windPos) 「形の生成(3D Noise)」
  • smoothstep 「密度の調整(雲の輪郭)」
  • exp(-d) 「光の計算(Beer’s Law / 吸収)」

この3つの数式が頭の中で映像とリンクしたとき、ノイズ学習の効果は最大化されます。