[Next.js #19] Next.js + R3F: 初心に帰るシャドウマップ実装とLevaでのパラメータ可視化

1. はじめに

React Three Fiber (R3F) のような高機能なライブラリを使っていると、複雑な処理が抽象化されているおかげで、ついその裏側にある仕組み=「魔法」に頼りがちになります。

しかし、いざ自分が思い描いた通りの演出をしようとした途端、その魔法が解けて泥沼にハマることがあります。

その代表格が 「Shadow(影)」 です。

今回は初心・基本に立ち返り、Next.js + R3F 環境において DirectionalLight のシャドウマップがどのように生成されているのかを再確認しました。GUIツールである Leva を活用し、普段は見えない「影の計算範囲」を可視化することで、影が切れたり縞模様が出たりする「あるある」な問題の解決策を探ります。

castShadow だけでは綺麗に出ない理由

多くのチュートリアルでは「<mesh castShadow /> をつければ影が出る」と教わります。確かに影は出ますが、デフォルト設定のままでは以下のような問題によく直面します。

  • 影がギザギザしている(解像度不足)
  • 影が途中でバツっと切れる(計算範囲の外)
  • 影に縞模様(アーティファクト)が乗る(バイアス調整不足)

これらはバグではなく、「シーンのスケールとライトの設定が合っていない」 ことが原因です。

シャドウマップは「ライト視点のカメラ」

技術的な話をすると、影の計算(シャドウマップ)とは、「ライトの位置からシーンを撮影し、そこから見えている部分以外を『影』とする」 処理のことです。 つまり、DirectionalLight には、我々が見ているメインカメラとは別に、影を計算するためだけの「もう一つのカメラ(正射影カメラ)」 が内蔵されているのです。

「ライトの視界」を可視化して制御する

影が切れるのは、この「ライト内蔵カメラ」の画角(Frustum / 視錐台)からオブジェクトがはみ出しているからです。 しかし、このカメラは通常目に見えません。そこで今回は、Levaを使ってこの「ライトの視界」をリアルタイムに可視化・操作できる環境を構築しました。数値を手探りで当てずっぽうに入力するのではなく、視覚的に影を制御するアプローチを解説します。

2. 実装環境と基本セットアップ

まずは、今回の検証に使用した技術スタックと、シーンの土台となるセットアップコードです。Next.js の App Router 環境で React Three Fiber (R3F) を動かしています。

Tech Stack

  • Framework: Next.js (App Router)
  • 3D Library: React Three Fiber (R3F), Three.js
  • Helpers: @react-three/drei
  • GUI: Leva (パラメータ調整用)

基本的な実装コード

/app/components/three-shadow-map/page.tsx として実装した、シーンの全体像は以下の通りです。 ここで重要なのは、Canvas 全体で影を有効化することと、影の品質を底上げするヘルパーの導入です。

"use client";

import { Canvas } from "@react-three/fiber";
import { OrbitControls, PerspectiveCamera, SoftShadows, Stats } from "@react-three/drei";
import { Leva } from "leva";
// ... 他のimport

export default function ExtendedShadowMapPage() {
  return (
    <div style={{ width: "100%", height: "100vh", background: "#111" }}>
      {/* 1. GUIパネル (Leva) を表示 */}
      <Leva collapsed={false} />

      {/* 2. Canvasで shadows を有効化 */}
      <Canvas shadows dpr={[1, 2]}>

        {/* 3. PCSS (Soft Shadows) の導入 */}
        <SoftShadows size={10} samples={10} focus={0.5} />

        <color attach="background" args={["#202025"]} />
        <PerspectiveCamera makeDefault position={[5, 5, 5]} fov={50} />
        <ambientLight intensity={0.4} />

        {/* 4. パラメータ制御用のカスタムライト */}
        <ControllableLight />

        <SceneObjects />

        <OrbitControls makeDefault />
        <Stats />
        <gridHelper args={[20, 20, 0x444444, 0x222222]} />
      </Canvas>
    </div>
  );
}

コードのポイント

このセットアップには、初心者が躓きやすいポイントがいくつか含まれています。

  1. <Canvas shadows> の有効化
  • これが全ての始まりです。個別のメッシュにいくら castShadow を設定しても、Canvas コンポーネント自体に shadows プロパティ(内部的には renderer.shadowMap.enabled = true)が渡されていないと、影の計算は一切行われません。
  1. SoftShadows の導入
  • @react-three/drei が提供するこのコンポーネントは、PCSS (Percentage-Closer Soft Shadows) という手法を簡単に実装できます。
  • 通常のシャドウマップは影の輪郭がパキッと(あるいはギザギザに)なりがちですが、これを入れるだけで光源のサイズに応じた「距離によってボケるリアルな影」が手に入ります。今回は size, samples, focus で調整しています。
  1. ControllableLight コンポーネント
  • ライトの設定を Canvas 内に直書きせず、別のコンポーネントに切り出しています。
  • これは、次章で解説する useControls (Leva) などのフックを使うためです(R3Fのフックは <Canvas> の子要素の中でしか動作しないため)。このコンポーネント内で、動的にシャドウパラメータを操作します。

3. 【デモ解説】Levaで理解する重要パラメータ

ここが今回の記事の核となる部分です。
影の品質をコントロールするために作成した ControllableLight コンポーネントを見てみましょう。
Levaの useControls を使って、ライトのパラメータをリアルタイムに操作できるようにしています。

function ControllableLight() {
  const lightRef = useRef<THREE.DirectionalLight>(null!);

  // GUI設定 (Leva)
  const { position, intensity, bias, mapSize, camSize, showHelper } = useControls("Directional Light", {
    position: { value: [5, 8, 5], step: 0.5 },
    intensity: { value: 1.5, min: 0, max: 10 },
    bias: { value: -0.001, min: -0.01, max: 0, step: 0.0001 },
    mapSize: { value: 2048, options: [1024, 2048, 4096] },
    camSize: { value: 10, min: 1, max: 50 }, // 影を計算する範囲
    showHelper: true,
  });

  // ライトの位置と向きを可視化
  useHelper(showHelper && lightRef, THREE.DirectionalLightHelper, 1);

  return (
    <directionalLight
      ref={lightRef}
      castShadow
      position={position}
      intensity={intensity}
      /* 重要なシャドウ設定 */
      shadow-bias={bias}
      shadow-mapSize-width={mapSize}
      shadow-mapSize-height={mapSize}
      /* シャドウカメラの範囲(視錐台)設定 */
      shadow-camera-left={-camSize}
      shadow-camera-right={camSize}
      shadow-camera-top={camSize}
      shadow-camera-bottom={-camSize}
    />
  );
}

このコードで操作している3つの重要なパラメータについて、それぞれ解説します。

mapSize (解像度): 影の精細さ

シャドウマップのテクスチャサイズです。Three.jsのデフォルトは 512x512 ですが、これだとオブジェクトの縁がギザギザ(ジャギー)になりがちです。

  • 2048x2048: 今回採用した値。デスクトップPC向けにはバランスが良い設定です。
  • 4096: 非常に滑らかになりますが、VRAM消費が増え、計算負荷も高くなります。
  • パフォーマンスとのトレードオフ: 特にモバイル端末では、解像度を上げすぎると描画負荷が跳ね上がります。dpr (Device Pixel Ratio) の設定や、ターゲットとするデバイスに応じて 1024 に落とすなどの判断が必要です。

bias (バイアス): アーティファクトとの戦い

影の計算において最もデリケートな数値です。Levaで -0.0001 単位で調整すると、以下の2つの現象の狭間にある「スイートスポット」が見えてきます。

  1. シャドウアクネ (Shadow Acne): バイアスが 0 に近いと発生します。自分自身の影が表面に干渉し、モアレのような縞模様が現れてしまいます。
  2. ピーターパンニング (Peter Panning): アクネを消そうとしてバイアスを強く(マイナス方向に大きく)しすぎると、今度は影が物体から分離して宙に浮いてしまいます(ピーターパンの影のように)。

今回は -0.001 あたりが最適解でした。この値はシーンのスケールによって変わるため、GUIで動かしながら探るのが正解への近道です。

camSize (Frustum / 視錐台): 一番のハマりポイント

「ライトは当たっているのに、影だけが出ない」というトラブルの原因の9割はこれです。

DirectionalLight は「無限遠からの平行光源」ですが、影を計算するカメラ(shadow.camera)は無限ではありません。 内部的には OrthographicCamera(正射影カメラ)が使われており、これは直方体のボックス(Box)のような形状をしています。

  • 仕組み: コード内の shadow-camera-leftright などで、このボックスのサイズを定義しています。
  • トラブル: オブジェクトがこのボックスの外にはみ出すと、影が計算されず、バツっと切れてしまいます。
  • Levaの役割: camSize を可視化しながら調整することで、「シーン全体を包み込みつつ、無駄に大きすぎない(解像度を無駄にしない)」最適なサイズを見つけることができます。

4. 実装の工夫:ヘルパーの活用

このセクションでは、「見えないものを見る」ための工夫について解説します。 多くの開発者は console.log で値を確認しますが、3D開発においては Visual Helper(可視化ツール) こそが最強のデバッガーです。

基本的なヘルパー: useHelper

React Three Fiber には @react-three/drei パッケージを通じて、Three.js の標準ヘルパーを簡単に使えるフック useHelper が用意されています。

import { useHelper } from "@react-three/drei";
import { DirectionalLightHelper } from "three";

// ... Inside component
const lightRef = useRef<THREE.DirectionalLight>(null!);
useHelper(lightRef, DirectionalLightHelper, 1, "yellow");

これだけで、ライトの位置と向きを示す黄色い線が表示されます。「あ、ライトが真上すぎて影が消えてたのか」といった単純なミスに即座に気付けるようになります。

一歩進んだ活用:シャドウカメラの可視化

しかし、DirectionalLightHelper だけでは「影が計算される範囲(Box)」までは見えません。そこで、さらに一歩踏み込んで Shadow Camera 自体を可視化すると、トラブルシューティングが劇的に楽になります。

以下のようなコンポーネントを追加することで、普段は見えない「影の計算ボックス」をシーン上に描画できます。

// ShadowCameraHelper.tsx
import { useHelper } from "@react-three/drei";
import { useEffect } from "react";
import * as THREE from "three";

export function ShadowCameraHelper({ lightRef }) {
  // ライトの内部にある shadow.camera を可視化
  useEffect(() => {
    if (lightRef.current?.shadow?.camera) {
      const helper = new THREE.CameraHelper(lightRef.current.shadow.camera);
      scene.add(helper);

      // クリーンアップ
      return () => {
        scene.remove(helper);
        helper.dispose();
      };
    }
  }, [lightRef, scene]); // 依存配列に注意

  return null;
}

これを導入すると、Leva で camSize を操作した瞬間に、画面上の黄色い枠線(Frustum)がリアルタイムに伸縮する 様子が見えるようになります。 「枠線の中にオブジェクトを入れる」という視覚的な操作が可能になるため、影が切れる問題の原因が一目瞭然になります。

5. まとめ

3D開発において、ライティングとシャドウの設定に「コピー&ペーストで済む万能な正解」は存在しません。なぜなら、最適なバイアス値やカメラ範囲は、配置するオブジェクトの大きさや距離といった 「シーンのスケール」 に完全に依存するからです。

コード上の数値を勘で書き換えてはブラウザをリロードする……という作業は、時間の無駄であるだけでなく、クリエイティブな試行錯誤の邪魔になります。

今回のように Leva を導入し、「視覚的にフィードバックを得ながらリアルタイムに調整できる環境」 を構築することで、ライティングのクオリティアップにかかる時間は劇的に短縮されます。「基本に立ち返る」とは、単にドキュメントを読み直すことだけでなく、こうした 「急がば回れ」のデバッグフロー を身につけることなのかもしれません。


これで記事の構成要素が出揃いましたね! 最後に、全体を通しての整合性チェックや、もし必要であればOGP画像(アイキャッチ)の生成プロンプトの作成などもお手伝いできますが、いかがいたしましょうか?

AIからの補足:

1. シャドウカメラを「正しく」可視化する

記事内で紹介した DirectionalLightHelper は光の向きを知るには便利ですが、影が計算される「範囲(Box)」までは表示してくれません。 そこで、「ライト内部のカメラ(shadow.camera)」を直接可視化するための専用コンポーネントを用意すると、デバッグ効率が飛躍的に向上します。

以下は、Leva で camSize を操作した瞬間に、黄色の枠線(視錐台)がリアルタイムに伸縮するのを確認するためのヘルパー実装例です。

import { useThree, useFrame } from "@react-three/fiber";
import { useEffect } from "react";
import * as THREE from "three";

type ShadowCameraHelperProps = {
  lightRef: React.MutableRefObject<THREE.DirectionalLight>;
  visible: boolean;
};

export function ShadowCameraHelper({ lightRef, visible }: ShadowCameraHelperProps) {
  const { scene } = useThree();

  useEffect(() => {
    if (!lightRef.current || !visible) return;

    // light.shadow.camera をターゲットにしたヘルパーを作成
    const cameraHelper = new THREE.CameraHelper(lightRef.current.shadow.camera);
    scene.add(cameraHelper);

    // クリーンアップ: コンポーネントがアンマウントされたり、visibleがfalseになったら削除
    return () => {
      scene.remove(cameraHelper);
      cameraHelper.dispose();
    };
  }, [lightRef, visible, scene]);

  useFrame(() => {
    // Levaで数値をいじった際、ヘルパーの枠線を即座に追従させるための更新処理
    if (lightRef.current?.shadow?.camera && visible) {
      lightRef.current.shadow.camera.updateProjectionMatrix();
      // ※CameraHelperは通常、対象のカメラが更新されれば自動で追従しますが、
      // 確実な同期のために明示的に update() を呼ぶ実装が必要なケースもあります。
    }
  });

  return null;
}

実装のワンポイント (玄人向け)

React Three Fiber には便利な useHelper フックがありますが、今回のようなケースではあえて THREE.CameraHelper を手動で scene.add するアプローチ を取ることがあります。

理由は、shadow.cameraDirectionalLight の内部で生成されるオブジェクトであり、React のレンダリングサイクルとは独立して Three.js 側で管理されているためです。 useHelper は Ref の変更検知に優れていますが、内部オブジェクトのプロパティ(この場合は camSize による射影行列の変更)への追従が遅れたり、期待通りに更新されない場合があります。

そのため、上記コードのように useEffect で明示的にヘルパーの生成・破棄を管理し、useFrame で更新をフックする方が、Leva と連携させた際の挙動が安定します。「動的なデバッグ環境」を作る際は、この「React のライフサイクルと Three.js の内部更新の橋渡し」を意識すると、より堅牢な実装になります。

2. near と far の設定忘れに注意

今回の ControllableLight コンポーネントでは、camSize (left, right, top, bottom) を使ってシャドウカメラの「幅と高さ」を制御しましたが、実は 「奥行き」 を決める shadow-camera-nearshadow-camera-far も非常に重要です。

DirectionalLight の影は「正射影(Orthographic)」なので、影の有効範囲は直方体(Box)になります。この直方体の「手前」と「奥」の設定を間違えると、以下のような問題が発生します。

  • far が短すぎる場合:

  • ライトから遠い地面やオブジェクトに影が落ちません。「途中から影が消える」現象の主犯です。

  • far が長すぎる(無駄に大きい)場合:

  • 「大は小を兼ねる」で far={10000} のように設定するのは NG です。

  • シャドウマップの「Z深度の分解能(Depth Precision)」が低下してしまうためです。0〜10000の範囲を限られたビット数で表現することになり、計算精度が荒くなります。

  • 結果として、バイアス調整が難しくなり、影がガタガタになったり、本来影になるべきでない場所に影が出る(アーティファクト)原因になります。

実装アドバイス

camSize と同様に、far も Leva で調整できるようにしておくと、「影が落ちるギリギリの長さ」 に設定を絞り込むことができ、影のクオリティを最大限に保てます。

// Levaの設定に追加
const { far } = useControls("Directional Light", {
  // ...他の設定
  far: { value: 50, min: 0, max: 200 }, // シーンの大きさに合わせて調整
});

return (
  <directionalLight
    // ...
    shadow-camera-near={0.5} // デフォルトで問題ないことが多いが、近くすぎるとクリッピングされる
    shadow-camera-far={far}  // ここを動的に!
  />
);

3. パフォーマンスと useFrame の落とし穴

今回のデモコードでは、シャドウカメラの変更を即座にヘルパーに反映させるために useFrame 内で updateProjectionMatrix() を呼んでいます。 しかし、これは毎秒60回(あるいはそれ以上)、常に行列計算を行っていることになるため、本番環境(Production)では無駄な負荷 となります。ライトが動かない静的なシーンであればなおさらです。

💡 実装アドバイス:更新は「必要な時だけ」

より実践的でパフォーマンスに配慮したコードにするなら、以下の2つのアプローチで最適化を図りましょう。

  1. ガード節を入れる(基本) ヘルパーが表示されていない時は、更新処理をスキップするようにします。これだけで、本番環境での不要な計算をカットできます。
useFrame(() => {
  // showHelperがfalseなら何もしない
  if (!showHelper || !lightRef.current) return;

  lightRef.current.shadow.camera.updateProjectionMatrix();
});
  1. 値が変わった時だけ更新する(推奨) useFrame(毎フレーム実行)をやめて、Reactの useEffect を使い、「Levaで値をいじった瞬間だけ」 更新するように変更します。これが最も効率的なアプローチです。
// camSize や mapSize が変更された時だけ実行される
useEffect(() => {
  if (lightRef.current) {
    lightRef.current.shadow.camera.updateProjectionMatrix();

    // ヘルパーも更新が必要な場合
    if (helperRef.current) helperRef.current.update();
  }
}, [camSize, mapSize]); // 依存配列に監視したい値を入れる

「動くものは useFrame、静的な設定変更は useEffect」と使い分ける癖をつけると、R3Fアプリケーションのパフォーマンスは格段に安定します。

4. 記事の締めくくりに使える「次のステップ」

記事の締めくくりとして、読者の好奇心を次へ繋げる「次のステップ」をまとめました。 「基本を押さえたからこそ、次はこれに挑戦してみよう」という、ステップアップを促すポジティブなエンディングになります。


次のステップ:さらにリッチな影の世界へ

今回はシャドウマップの「基本」に焦点を当てましたが、3D表現における「影」の手法はこれだけではありません。シーンの目的やパフォーマンスの要件に応じて、以下のようなテクニックを使い分けるのが脱・初心者の第一歩です。

  • Baked Shadows(焼き付けられた影)

  • 動かない壁や床であれば、計算負荷の高いリアルタイムシャドウを使う必要はありません。あらかじめ影をテクスチャとして書き込んでしまう「ベイク」手法を使えば、非常に高品質な影を負荷ゼロで表現できます。

  • Contact Shadows(コンタクトシャドウ)

  • 接地面のわずかな影を強調したいなら、@react-three/drei<ContactShadows /> が便利です。ライトの設定に頼らず、物体が地面に「置かれている感」を簡単に出せるため、プロダクトの展示用シーンなどで多用されます。

  • Cascaded Shadow Maps (CSM)

  • 広大なフィールドを作る場合、一つのシャドウカメラでは解像度が足りません。CSMは「カメラに近い場所は高精細に、遠くは低解像度に」と影の計算を分割する技術です。オープンワールドのような風景を作りたいなら、避けては通れない道です。

影は、3D空間の「実在感」を左右する最も重要な要素の一つです。今回のLevaによる可視化で得た感覚を武器に、ぜひ自分のプロジェクトに最適な「影」を探求してみてください。


おわりに

「基本に立ち返る」ことは、遠回りに見えて実は一番の近道です。 今回構築したデバッグ環境のように、「何が起きているかを目で見えるようにする」工夫を一つずつ積み重ねることで、複雑な3D開発もぐっと楽しく、コントロール可能なものになっていくはずです。