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