メニューをクリックしたら3Dビューのカメラが動くサイト – Blender✕Three.js

ホテルや住宅のウェブサイトで、写真や図がたくさんあっても、いまいち間取りが分からないことってありますよね。空間は立体で伝えたほうが伝わりやすいと思うのです。ということで、わがオフィスを立体化してブラウザで見られるようにしようというのが今回の自由研究です。

使用技術

  • モデリング:Blender
  • フロントエンド:Next.js
  • ライブラリ:Three.js、GSAP
  • デプロイ:ロリポップ(Next.jsで静的サイトをビルドして設置)

実装したいこと

実際のオフィスには「写真」「エンジニア」「事務」の用途で使っている3つの空間があり、これを再現します。それぞれの空間にモニターを設置し、ナビゲーションをクリックすることで、各エリアにフォーカスして詳細情報を見られるようにします。

モデリング

今回は過去にBlenderでモデリングしたアセットを利用します。ただ、200万ポリゴンもあるモデルなので、めちゃくちゃ重い。

まずは軽量化。上記のようにレンダリングする場合は木の角を滑らかにするためにベベルをかけたりしますが、ブラウザ用に直角にするなどしてポリゴン数を削減します。また、ソファや椅子のクッションもポリゴンを多く消費するので他のモデルに変更。頂点数を6,540にまで削減できました。

テクスチャのベイク

元のモデルでは木だけでも6種類、そのほかいくつものマテリアルとテクスチャを使用しています。これではきっと重くなってしまうので、マテリアルをひとまとめにします。また、できる限り動作を軽くするために、ライトもベイクしてしまいます。

目立つところだけ手作業でシームを設定して面積を広くとり、あとはスマートUV展開でざっくり。

すべてのマテリアルとライトを2048x2048pxのテクスチャ1つにまとめました。

カメラの設定

Three.jsでもカメラは設定できるのですが、調整が大変そうだったので、Blenderで設定したカメラを読み込む方法を採用します。今回は3つのモニターに向けて3つのカメラを設置。ここまでできたら、データにカメラを含めてglbでエクスポート。

カメラの切り替えを実装

ここからはNext.js✕Three.jsで実装。まずはナビゲーションにonClickイベントを設定して、クリックしたらhandleMenuClickでカメラを設定する。メニューの後ろにあるthree.jsを触ってしまうのを防止するために、event.stopPropagationも設定

export default function Navbar({}) {

  const handleMenuClick = (camera) => {
    switchCamera(camera);
  };

  return (
    <nav className={styles.navbar}>
      <ul>
        <li onClick={(event) => { event.stopPropagation(); handleMenuClick('main'); }}>HOME</li>
        <li onClick={(event) => { event.stopPropagation(); handleMenuClick(0); }}>PHOTOGRAPHY</li>
        <li onClick={(event) => { event.stopPropagation(); handleMenuClick(1); }}>TRAVEL</li>
        <li onClick={(event) => { event.stopPropagation(); handleMenuClick(2); }}>ENGINEERING</li>
      </ul>
    </nav>
  );
}

クリックしたらswitchCameraが呼び出されて、glbで設定したカメラの順番に応じたカメラに切り替わる。画面ロード時に初期で表示されるメインカメラだけはThree.jsで設定してmainCameraとしています。

また、画面が横長の場合と縦長の場合で画角を変更して、被写体全体が写るように調整しています。そのほか下記コード外でGSAPを使ってカメラ切り替え時にスムーズにカメラがつながるように補間も実装しています。

const camerasRef = { current: [] };
const mainCameraRef = { current: null };
const originalFov = 90;

// カメラを切り替え
export const switchCamera = (cameraIndex) => {
  if (cameraIndex === 'main') {
    targetCamera = mainCamera;
    mainCamera.fov = originalFov;
  } else if (camerasRef.current[cameraIndex]) {
    targetCamera = camerasRef.current[cameraIndex];
  }
  
  // 画面サイズに基づいてfovを調整
  const aspectRatio = window.innerWidth / window.innerHeight;
  if (aspectRatio > 1) {
    targetCamera.fov = 60; // 横長の場合
  } else {
    targetCamera.fov = 100; // 縦長の場合
  }
};

スライドショーの実装

各モニターに表示される画像はテクスチャとして読み込み、2500ms間隔で切り替わるように設定、というのをGithub Copilotが書いてくれました。もっと簡略化したいけど、1分でこれが実装できるなんて便利すぎる。

本当はフェードしたりスライドしたりするアニメーションをGSAPを使って実装したかったのですが、うまくいかなかったため、また後日研究します。

const loader = new GLTFLoader();
    loader.load('/studio.glb', (gltf) => {
      scene.add(gltf.scene);
      camerasRef.current = gltf.cameras;

      // テクスチャをロードして適用
      const textureLoader = new THREE.TextureLoader();
      const textureLaptop = [
        textureLoader.load('screen/travel__slide01.jpg'),
        textureLoader.load('screen/travel__slide02.jpg'),
        textureLoader.load('screen/travel__slide03.jpg'),
      ];
      const textureDesktop = [
        textureLoader.load('screen/tech__slide01.jpg'),
        textureLoader.load('screen/tech__slide02.jpg'),
        textureLoader.load('screen/tech__slide03.jpg'),
      ];
      const textureTV = [
        textureLoader.load('screen/photo__slide01.jpg'),
        textureLoader.load('screen/photo__slide02.jpg'),
        textureLoader.load('screen/photo__slide03.jpg'),
      ];

      let currentTextureIndex = 0;
      const textureChangeInterval = 2500;

      function updateTextures() {
        currentTextureIndex = (currentTextureIndex + 1) % 3;

        gltf.scene.traverse((child) => {
          if (child.name === 'screen-tv') {
            child.material.map = textureTV[currentTextureIndex];
            child.material.needsUpdate = true;
          } else if (child.name === 'screen-laptop') {
            child.material.map = textureLaptop[currentTextureIndex];
            child.material.needsUpdate = true;
          } else if (child.name === 'screen-desktop') {
            child.material.map = textureDesktop[currentTextureIndex];
            child.material.needsUpdate = true;
          }
        });
      }

      setInterval(updateTextures, textureChangeInterval);

      // 初期テクスチャ設定
      // 各テクスチャにリンク先のデータももたせる
      gltf.scene.traverse((child) => {
        if (child.name === 'screen-tv') {
          child.material.map = textureTV[currentTextureIndex];
          child.material.needsUpdate = true;
          child.userData = { URL: 'https://abc.com' };
        } else if (child.name === 'screen-laptop') {
          child.material.map = textureLaptop[currentTextureIndex];
          child.material.needsUpdate = true;
          child.userData = { URL: 'https://xyz.jp' };
        } else if (child.name === 'screen-desktop') {
          child.material.map = textureDesktop[currentTextureIndex];
          child.material.needsUpdate = true;
          child.userData = { URL: 'https://omg.net' };
        }
        
        // クリックイベントしたらリンクを開く
        child.onClick = (event) => {
          if (child.userData.URL) {
            console.log(`Click over: ${child.name}`);
            window.open(child.userData.URL, '_blank');
          }
        };
      
        // ホバーイベントを追加
        child.onHover = () => {
          // ポインターを変更
          if (child.name === 'screen-tv' || child.name === 'screen-laptop' || child.name === 'screen-desktop') {
            document.body.style.cursor = 'pointer';
            // スムーズに明るくする
            gsap.to(child.material.emissive, { r: 0.27, g: 0.27, b: 0.27, duration: 0.5 });
            document.querySelector(`.${styles.visitButton}`).classList.add(styles.hover);
          }
        };
        
        // ホバーアウトイベントを追加
        child.onMouseOut = () => {
          // ポインターを元に戻す
          if (child.name === 'screen-tv' || child.name === 'screen-laptop' || child.name === 'screen-desktop') {
            document.body.style.cursor = 'default';
            // スムーズに元の色に戻す
            gsap.to(child.material.emissive, { r: 0, g: 0, b: 0, duration: 0.5 });
            document.querySelector(`.${styles.visitButton}`).classList.remove(styles.hover);
          }
        };

      });
    });

今回はここまで。