WebGL & Three.js

Three.jsのカメラをスクロールで動かす|GSAPで位置と注視点を連動させる

Three.jsのカメラ位置と注視点をGSAP ScrollTriggerで同時に補間する方法。固定canvasとHTML本文の実行例で、逆スクロール、画角調整、reduced-motion、後片付けまで確認します。

この記事の目次
  1. 先に「立つ場所」と「見る先」の3地点を決める
  2. 4ファイルで、固定canvasとスクロール本文を作る
  3. package.json
  4. index.html
  5. style.css
  6. main.js
  7. 位置と注視点のTweenは、同じ開始位置へ重ねる
  8. リサイズと「動きを減らす」を、違う処理として扱う
  9. 動かないときは、座標・描画・別のカメラ操作を順に見る

スクロールに合わせてcamera.positionを変えたのに、見せたいモデルが画面の端へ逃げてしまう。カメラが「どこにいるか」は変わっても、「どこを見るか」が同じままだと、そんな動きになります。

Three.jsのスクロール連動カメラは、GSAPでカメラ位置と注視点を同時に補間し、その更新後にcamera.lookAt(target)とrenderer.render()を呼ぶと組み立てられます。lookAtという関数自体をアニメーションさせるのではなく、渡す座標を少しずつ変えます。

ここでは、固定したcanvasの手前を通常のHTML本文が流れる、3場面のページを作ります。Three.js 0.186.0、GSAP 3.15.0、Vite 7.3.6で確認した例です。スクロールを戻すと、カメラも同じ経路を逆にたどります。

先に「立つ場所」と「見る先」の3地点を決める

今回は3つのワイヤーフレームの箱を並べます。箱は回転させず、カメラだけが正面、右側、左上へ移動します。見せ方が変わる原因をカメラに絞るためです。

ページの進捗 camera.position 注視点target 見せ方
0 (0, 0, 8) (0, 0, 0) 正面
0.5 (4, 1, 6) (0.6, 0, 0) 右側へ回り込む
1 (-3, 2, 5) (-0.6, 0, 0) 左上から見る

カメラとtargetは別の値です。今回のtargetは座標を持てればよいので、THREE.Vector3を使います。見えないObject3Dを置く方法もありますが、位置だけを補間する例では必須ではありません。

なお、lookAtに渡す点はワールド座標です。カメラや注視対象を親Groupへ入れて変換する場合は、ローカル座標をそのまま渡さないようにします。Three.js r186のlookAt実装・説明にも、ワールド座標と、非均一な拡大縮小を持つ親への制限が示されています。この例のカメラは親Groupへ入れていません。

スポンサーリンク

4ファイルで、固定canvasとスクロール本文を作る

空の検証用フォルダーへ、次のpackage.json、index.html、style.css、main.jsを保存します。既存サイトのpackage.jsonをそのまま置き換える用途ではありません。

package.json

{
  "name": "three-scroll-camera-example",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite --host 127.0.0.1 --port 5220",
    "build": "vite build"
  },
  "dependencies": {
    "three": "0.186.0",
    "gsap": "3.15.0"
  },
  "devDependencies": {
    "vite": "7.3.6",
    "@playwright/test": "1.61.1"
  }
}

インストール後、表示されたローカルURLを開きます。

npm install
npm run dev

index.html

<!doctype html>
<html lang="ja">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>スクロールで視点を変える</title></head>
<body>
  <canvas id="scene" aria-hidden="true"></canvas>
  <main id="story">
    <section><div class="card"><p>01 / FRONT</p><h1>3つの箱を正面から見る</h1><p>下へスクロールすると、右側へ回り込みます。</p></div></section>
    <section><div class="card"><p>02 / RIGHT</p><h2>位置と注視点を一緒に変える</h2><p>右へ移動しながら、視線を少し右へ向けます。</p></div></section>
    <section><div class="card"><p>03 / LEFT</p><h2>最後は左上から見る</h2><p>上へ戻ると、同じ経路を逆にたどります。</p></div></section>
  </main>
  <p id="fallback" hidden>3D表示を利用できません。説明は本文で読めます。</p>
  <script type="module" src="/main.js"></script>
</body>
</html>

説明はcanvasへ描かず、HTMLに残しています。WebGLが使えない場合でも、3場面の内容は読めます。canvasのaria-hiddenは、この例では3Dが装飾的な補助表示であるためです。製品の形状などを3Dだけで説明するページでは、対応する説明や静止画も用意してください。

style.css

* { box-sizing: border-box; }
body { margin: 0; background: #101a2a; color: #17233b; font: 17px/1.8 sans-serif; }
#scene { position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; }
#story { position: relative; }
section { min-height: 100svh; padding: 5vw; display: flex; align-items: end; }
.card { max-width: 28rem; padding: 1.2rem 1.5rem; border-radius: 1rem; background: #fffffff2; }
h1, h2 { font-size: clamp(1.2rem, 3vw, 1.7rem); line-height: 1.5; }
#fallback { position: fixed; top: 0; left: 0; right: 0; padding: .5rem 1rem; background: #fff; }
@media (min-width: 900px) { section { align-items: center; } }
@media (prefers-reduced-motion: reduce) { section { min-height: 70svh; } }

canvasはfixedで画面に残り、sectionは通常のページスクロールで移動します。canvasのpointer-eventsをnoneにしているため、上にある文章の選択やリンク操作を妨げません。3Dオブジェクトのクリック操作を追加するなら、この役割分担も見直します。

main.js

import * as THREE from 'three';
import gsap from 'gsap';
import ScrollTrigger from 'gsap/ScrollTrigger';
import './style.css';

gsap.registerPlugin(ScrollTrigger);

function createStory() {
  const canvas = document.querySelector('#scene');
  let renderer;
  try {
    renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
  } catch {
    canvas.hidden = true;
    document.querySelector('#fallback').hidden = false;
    return null;
  }
  const scene = new THREE.Scene();
  scene.background = new THREE.Color('#101a2a');
  const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
  const target = new THREE.Vector3();
  const geometry = new THREE.BoxGeometry(1, 1, 1);
  const materials = ['#7dbbff', '#ffb47d', '#bca3ff'].map(
    color => new THREE.MeshBasicMaterial({ color, wireframe: true })
  );
  materials.forEach((material, index) => {
    const mesh = new THREE.Mesh(geometry, material);
    mesh.position.x = (index - 1) * 1.8;
    scene.add(mesh);
  });

  let disposed = false;
  let renders = 0;
  function render() {
    if (disposed) return;
    camera.lookAt(target);
    renderer.render(scene, camera);
    renders++;
  }
  function resize() {
    if (disposed) return;
    const width = canvas.clientWidth;
    const height = canvas.clientHeight;
    if (!width || !height) return;
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    renderer.setSize(width, height, false);
    camera.aspect = width / height;
    camera.fov = THREE.MathUtils.radToDeg(
      2 * Math.atan(Math.tan(THREE.MathUtils.degToRad(45) / 2) / Math.min(camera.aspect, 1))
    );
    camera.updateProjectionMatrix();
    render();
  }
  const media = gsap.matchMedia();
  media.add({ all: '(min-width: 0px)', reduce: '(prefers-reduced-motion: reduce)' }, context => {
    camera.position.set(0, 0, 8);
    target.set(0, 0, 0);
    resize();
    if (context.conditions.reduce) return;

    const timeline = gsap.timeline({
      defaults: { duration: 1, ease: 'none' },
      onUpdate: render,
      scrollTrigger: {
        id: 'camera-story',
        trigger: '#story',
        start: 'top top',
        end: 'bottom bottom',
        scrub: 0.4,
      },
    });
    timeline
      .to(camera.position, { x: 4, y: 1, z: 6 }, 0)
      .to(target, { x: 0.6, y: 0, z: 0 }, 0)
      .to(camera.position, { x: -3, y: 2, z: 5 }, 1)
      .to(target, { x: -0.6, y: 0, z: 0 }, 1);
    timeline.scrollTrigger.refresh();
  });
  window.addEventListener('resize', resize);

  return {
    snapshot() {
      const trigger = ScrollTrigger.getById('camera-story');
      return {
        camera: camera.position.toArray(), target: target.toArray(),
        aspect: camera.aspect, pixelRatio: renderer.getPixelRatio(),
        progress: trigger?.animation.progress() ?? null,
        triggers: trigger ? 1 : 0, renders, disposed,
        drawCalls: renderer.info.render.calls,
      };
    },
    destroy() {
      if (disposed) return;
      disposed = true;
      media.revert();
      window.removeEventListener('resize', resize);
      geometry.dispose();
      materials.forEach(material => material.dispose());
      renderer.dispose();
      canvas.remove();
    },
  };
}

export const story = createStory();
if (import.meta.hot) import.meta.hot.dispose(() => story?.destroy());

story.snapshot()は座標や描画回数を確認するための窓口です。開発者ツールのコンソールでは、(await import(‘/main.js’)).story.snapshot()で現在値を見られます。画面を破棄する場合は同じモジュールのstory.destroy()を呼びます。Viteの更新時にも古い処理を片付けるようにしています。

位置と注視点のTweenは、同じ開始位置へ重ねる

timelineの最初の2つのtoは、末尾の位置引数がどちらも0です。そのため、カメラ位置と注視点が同じ区間で動きます。次の2つはどちらも1から始まり、後半の区間になります。

位置引数を省くとTweenが順番に追加され、「先にカメラだけ移動し、そのあと見る先が変わる」という別の演出になります。今回の目的は、視線も一緒に移しながら回り込むことなので、同時に開始しています。

durationは各区間で1ずつ、全体で2です。scrubがあると、この2秒が実時間で必ず2秒かかるという意味にはなりません。前半・後半がスクロール範囲の半分ずつを使います。start: ‘top top’からend: ‘bottom bottom’までが対象範囲です。

このHTMLでは通常時の各sectionが最低1画面分あります。本文が長くなってsectionが伸びると、全体のスクロール距離も変わります。区間を各見出しの到着位置に厳密にそろえたい場合は、本文の実寸に合わせて区間比率やTriggerを設計し直します。この例はページ全体を前半・後半へ二分する構成です。

scrub: 0.4はアニメーションの追従をなめらかにする指定です。描画をScrollTrigger側のスクロール通知だけに置くと、スクロール停止後に追いつく途中の値を描き逃すことがあります。そのため、ここではtimelineのonUpdateで、両方の座標が更新されたあとの視線と描画を更新します。scrubの基礎や数値の意味は、ScrollTriggerのscrubの使い方で確認できます。

リサイズと「動きを減らす」を、違う処理として扱う

リサイズ時は、canvasの表示サイズに合わせてrendererの描画サイズとcamera.aspectを更新し、updateProjectionMatrix()を呼びます。aspectだけを変えても、投影行列の更新を忘れると見え方に反映されません。

縦長画面で横に並ぶ箱が切れにくいよう、この例では横幅が狭いときに縦の画角を広げています。横長なら縦画角45度、縦長なら横画角45度を保つ計算です。すべての3Dモデルが必ず収まる仕組みではないため、モデルの大きさや経路を変えたら端で見切れないか確認します。

描画密度はdevicePixelRatioの上限を2にしています。高密度画面で描画ピクセルが増えすぎるのを抑えるためです。これだけで滑らかさを保証するものではありません。PerspectiveCameraの公式実装・説明でも、aspectと投影行列の役割を確認できます。

prefers-reduced-motionがreduceなら、カメラを正面の固定位置へ戻し、ScrollTriggerを作りません。本文はそのまま残し、CSS側では長いスクロール区間も少し短くしています。gsap.matchMediaの公式説明に沿って、設定が変わると既存のTweenやTriggerが戻され、条件を評価し直す構成です。

画像の読み込みなどで本文の高さが後から変わったときは、画角の更新とは別にScrollTrigger.refresh()で範囲を再計算します。毎フレーム呼ぶ必要はありません。必要なレイアウト変更が終わった時点で実行します。

動かないときは、座標・描画・別のカメラ操作を順に見る

起きていること 確認する箇所
カメラ座標が変わらない スクロール距離、Triggerのstart/end、reduced-motionの設定
座標は変わるのに画面が同じ 値の更新後にrenderer.renderを呼んでいるか
モデルが画面の端へ逃げる targetを補間し、毎回lookAtで向きを更新しているか
視点が跳ねたり元へ戻ったりする OrbitControlsなども同じカメラを書き換えていないか
画面幅を変えると伸びる aspect、描画サイズ、updateProjectionMatrixの更新

OrbitControlsのマウス操作を足すときは、スクロール演出中もcontrols.update()が同じカメラを動かしていないか確認します。どちらが視点を決める区間かを分け、引き継ぐ際はカメラ位置とcontrols.targetをそろえます。単に2つの制御を並べるだけでは、意図しない上書きが起きます。

今回の箱は時間だけでは動かないので、描画は座標更新時とリサイズ時に行っています。自動回転、動画テクスチャ、AnimationMixerなどを追加したら、それらの時間更新と描画が別途必要です。常時動くシーンに、そのまま「スクロールが止まったら描画も止める」を当てはめないようにします。

検証では、ChromeのソフトウェアWebGLで幅390/1440の両方について、中間点・終点・逆方向の座標、リサイズ、DPRの上限、reduced-motionの途中切替、破棄後の停止を確認しました。WebGLを利用できない条件でもHTML本文が残ることを確認しています。実機GPUのfpsやバッテリー消費を測った結果ではありません。

Viteのビルドは通りましたが、この単一ファイル構成では圧縮前のJSが約631kBとなり、チャンクサイズの警告が出ます。実際のページへ組み込む際は、まず3Dが読者に必要な説明を助けるかを確認し、初期読込の計測、必要に応じた遅延読込、モデルや描画密度の調整を行います。

最初は表の3地点を変えずに動かし、カメラ位置だけ、次に注視点だけを少し変更して比べてみてください。どちらの値が構図を変えているか分かると、スクロール量に合わせた見せ方を調整しやすくなります。

スポンサーリンク