スクロール操作とカメラ
ビューポートの設定、カメラのFOV(Field of View: 視野角)とアスペクト比は、Three.jsで3Dコンテンツを表示する際に、特にスクロール操作やレスポンシブ対応を考慮する場合に、緊密に関連する重要な要素です
スクロールに合わせてオブジェクトを動かす場合
フレームごとに繰り返し実行する場合→animate内で
スクロールイベントが発生した時に実行する場合→イベントリスナーのコールバックで
カメラまたはオブジェクトのy軸を移動させます
*カメラを移動する場合はスクロールすると内容が上に移動するようにカメラは下げる必要があるのでマイナス
<!DOCTYPE html>
<html>
<head>
<title>Scroll-linked 3D Object</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
html{
background-color: black;
}
body {
margin: 0;
overflow: scroll;
height: 2000px; /* スクロール領域 */
}
/*3Dビューが常に見える状態を保つ場合はキャンバスを固定*/
canvas {
position: fixed;
top: 0;
left: 0;
outline: none;
}
</style>
</head>
<body>
<script>
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(35, window.innerWidth / window.innerHeight, 0.1, 100);
//alpha: trueで背景を透明に
const renderer = new THREE.WebGLRenderer({alpha: true,});
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
camera.position.z = 5;
//animate関数内ではなくイベントリスナーのコールバックで変更する場合
// window.addEventListener('scroll', function() {
// camera.position.y = window.scrollY / window.innerHeight * -3;
// cube.position.y = window.scrollY / window.innerHeight *3;
// });
function animate() {
requestAnimationFrame(animate);
camera.position.y = window.scrollY / window.innerHeight *-3; //スクロール量に応じてカメラのY座標を変更
// cube.position.y = window.scrollY / window.innerHeight *3; //スクロール量に応じてキューブのY座標を変更
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>
オブジェクト自体を動かすこともできますが
カメラを動かすと、シーン全体の視点が変わるのでより広範な動きや変化をユーザーに示すことができ、複数のオブジェクトに対する相対的な位置関係は維持されるため、個々のオブジェクトを動かすよりもコードがシンプルになると思います
備考:マウスやスクロールは、2D(平面)での動きを示します
一方、カメラは3D(立体)空間で動きます
この座標系の違いが、ユーザーが期待する直感的な動きとカメラの実際の動きとを一致させないことがあります
この違和感をなくすためには、座標を反転したり速度を調整するなどの補正が必要な場合があります
ビューポート、カメラのFOV/アスペクト比、とレスポンシブデザインの相互関係
- 垂直FOV(Field of View): デフォルトでカメラのFOVは垂直方向に設定されます
カメラが垂直方向にどれだけ広範囲を「見る」ことができるかを示します
ビューポートの上端と下端の間に形成される角度です - 垂直方向の「固定」: FOVが垂直であると、ウィンドウがリサイズされたときにも、オブジェクトはビューポートの垂直方向で相対的に固定された位置に留まります
*幅はアスペクト比に応じて異なります - FOVの大きさと視野: FOVが大きければ大きいほど、多くの内容がビューポートに表示されます
逆に、FOVが小さければ、ビューポート内で見える範囲が狭くなります
カメラのアスペクト比の調整: ウィンドウサイズが変更されたときに、カメラのアスペクト比(camera.aspect)を新しいウィンドウサイズに合わせて調整します
function handleResize() {
const newWidth = window.innerWidth;
const newHeight = window.innerHeight;
camera.aspect = newWidth / newHeight;
camera.updateProjectionMatrix();
renderer.setSize(newWidth, newHeight);
});
// 初期読み込みで呼び出し
handleResize();
// リサイズイベントで呼び出し
window.addEventListener('resize', handleResize);
2:ウィンドウのサイズ変更に柔軟に対応できる
スケールなどいろいろ調整することができます
例の、scaleRatioはウィンドウの幅が1000ピクセルで1になるように正規化しています
正規化したウィンドウの幅と1を比較します
aspectRatioはスマフォの様な縦長(0.6程度以下か)かどうかを比較します
オブジェクトでビューポートのサイズ等管理して、リサイズ時に呼び出す関数内で更新すると便利
const sizes = {
width: window.innerWidth,
height: window.innerHeight,
aspectRatio: window.innerWidth / window.innerHeight,
scaleRatio : Math.min(window.innerWidth / 1000, 1); // この比率は調整が必要です
};
function handleResize() {
// sizesオブジェクトの更新
sizes.width = window.innerWidth;
sizes.height = window.innerHeight;
sizes.aspectRatio = sizes.width / sizes.height;
sizes.scaleRatio = Math.min(sizes.width / 1000, 1);
// その他の例
camera.aspect = sizes.aspectRatio;
camera.updateProjectionMatrix();
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
}
handleResize();
window.addEventListener("resize", handleResize);
//利用例 スマフォのような縦長なら0.7
let meshScale = sizes.aspectRatio < 0.6 ? 0.7 : 1;
mesh.scale.set(meshScale, meshScale, meshScale);
垂直ビューポートに対応したオブジェクト配置のテクニック
*オブジェクト間の距離を変数にして保存してオブジェクト配置、後でスクロール操作と連動してカメラを動かすなどで便利に使える
// カメラの作成(FOVは35)
const camera = new THREE.PerspectiveCamera(35, window.innerWidth / window.innerHeight, 0.1, 100);
// オブジェクト間の距離変数
let objectsDistance = 2;
//この例ではmesh1, mesh2, mesh3がすでに定義されていると仮定して
//それぞれを各々異なる高さに配置する
// オブジェクトの配置(y軸上)
//y座標はオブジェクトを下に移動させるため負の値にする
mesh1.position.y = - objectsDistance * 0; // 最初のオブジェクト(位置0)
mesh2.position.y = - objectsDistance * 1; // 二番目のオブジェクト(位置-2)
mesh3.position.y = - objectsDistance * 2; // 三番目のオブジェクト(位置-4)
上記のobjectsDistancを使って3Dオブジェクトが各々異なる高さに配置
スクロールに合わせてカメラを移動させることで、スクロールによって順番にオブジェクトを表示することができます
let scrollY = window.scrollY
window.addEventListener("scroll", () => {
scrollY = window.scrollY;
});
//アニメーションループ内で
function animate() {
//scrollYはスクロールすると増加する
//通常はスクロールすると内容が上に移動するようにカメラは下げる必要があるのでマイナス
//スクロールの量に基づいて、ビューポートの高さとオブジェクト間の距離で正規化して、カメラのY位置を更新
camera.position.y = (-scrollY / window.innerHeight) * objectsDistance;
renderer.render(scene, camera);
}
注意:Y座標が2回更新される場合、2回目の更新で1回目が上書きされます
この問題を避けるために、カメラをGroupに入れてGroupに適用します
// Groupを作成
const cameraGroup = new THREE.Group();
// カメラをGroupに追加
cameraGroup.add(camera);
// シーンにGroupを追加
scene.add(cameraGroup);
let scrollY = window.scrollY
window.addEventListener("scroll", () => {
scrollY = window.scrollY;
});
//アニメーションループ内で
function animate() {
//1回目の更新
camera.position.y = (-scrollY / window.innerHeight) * objectsDistance;
//2回目の更新はグループに対して
cameraGroup.position.y = 値;
renderer.render(scene, camera);
}
シーン内全体に、objectsDistanceを利用して、粒子をランダムに配置する
const particlesCount = 2000
const positions = new Float32Array(particlesCount * 3)
for (let i = 0; i < particlesCount; i++) {
positions[i * 3 + 0] = (Math.random() - 0.5) * 10;
positions[i * 3 + 1] = objectsDistance * 0.5 -Math.random() * objectsDistance * オブジェクトの数;
positions[i * 3 + 2] = (Math.random() - 0.5) * 10;
}
const particlesGeometry = new THREE.BufferGeometry();
particlesGeometry.setAttribute(
"position",
new THREE.BufferAttribute(positions, 3)
);
// Material
const particlesMaterial = new THREE.PointsMaterial({
color: 0x00ff00,
sizeAttenuation: false,
size: 1
})
// Points
console.log(particlesMaterial);
const particles = new THREE.Points(particlesGeometry, particlesMaterial)
scene.add(particles)
対応するセクションに到達したときにアニメーションをトリガーする方法
*セクションの高さが全てwindow.innerHeightで均一であることが前提です
let currentSection = 0
window.addEventListener('scroll', () =>{
//スクロール位置がセクションの高さの半分を超えた時点でnewSectionが更新
const newSection = Math.round(window.scrollY / window.innerHeight)
// セクションの高さに基づいて、どのセクションが完全に表示されたかを計算する場合は
//const newSection = Math.floor((scrollY + window.innerHeight) / sizes.height);
if(newSection != currentSection){
currentSection = newSection
console.log('changed', currentSection)
}
})
パーティクルにテクスチャを配置
背景が透明のテクスチャ画像を使用しても、マテリアルにテクスチャ画像設定した場合
オブジェクトの描画順序やテクスチャのブレンディングモード、深度バッファの取り扱いなどの設定によっては、テクスチャが透明であっても重なった部分で不透明な背景のような表示がされることがあります

この問題を解決するためには、マテリアルのtransparent、alphaTest、depthTest、depthWrite、そしてblendingのようなプロパティを適切に設定することが必要です
- transparent
マテリアルが透明なテクスチャや透明度(opacity)を持つかどうかを示すフラグ
trueに設定すると、テクスチャやマテリアルの透明部分が透明として描画されます - alphaTest:
ピクセルの透明度がこの値より低い場合、そのピクセルは描画されません
透明な部分のギザギザや境界を除去するのに役立ちます
値は0から1の間で、0.001などの小さな値を設定すると、ほぼ透明なピクセルは描画されなくなります。 - depthTest:
オブジェクトの描画順序を決定するために、深度バッファを利用して現在のピクセルの深度と比較するかどうかを指定します
trueに設定すると、オブジェクトは他のオブジェクトの背後にある場合にのみ描画されます - depthWrite
描画時に深度バッファに情報を書き込むかどうかを指定します
falseに設定すると、そのマテリアルを持つオブジェクトは深度バッファに情報を書き込まず、他のオブジェクトの描画に影響を与えません - blending
ピクセルのブレンディングの方法を指定します
例えば、THREE.AdditiveBlendingは新しいピクセルの色を既存のピクセルの色に加算します
これにより、光のような効果や、色の飽和度を上げる効果が得られます
const particlesMaterial = new THREE.PointsMaterial({
sizeAttenuation: true,
size: 0.5,
alphaMap:particleTexture,
transparent: true,
alphaTest: 0.001,
depthWrite:false
})

ランダムな色を持つパーティクルを作成する場合
各頂点(パーティクル)の色を設定する THREE.BufferAttribute を使用します
// ランダムな色の設定
const colors = new Float32Array(particlesCount * 3); // 3 channels per color (r, g, b)
for (let i = 0; i < colors.length; i += 3) {
const color = new THREE.Color(Math.random(), Math.random(), Math.random());
colors[i] = color.r;
colors[i + 1] = color.g;
colors[i + 2] = color.b;
}
particlesGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const particlesMaterial = new THREE.PointsMaterial({
sizeAttenuation: true,
size: 0.5,
alphaMap:particleTexture,
transparent: true,
alphaTest: 0.001,
depthWrite:false,
vertexColors: true, // これを設定して、各頂点の色を使用するようにします
blending: THREE.AdditiveBlending, // 色の混合方法の設定
})
GSAPを利用する
GSAP(GreenSock Animation Platform)は、アニメーションを制作するためのJavaScriptライブラリです
- Three.jsで作成された3Dオブジェクトのプロパティ(例:位置、回転)は、GSAPで簡単にアニメーション化できます
- GSAPのTimeline機能をThree.jsのアップデートループと統合することが可能です
これにより、複数のアニメーションを連鎖させたり、特定のタイミングで開始・停止させたりすることが容易になります - ユーザーのマウスクリックやキーボード入力など、外部のイベントに基づいてアニメーションを開始する場合も、GSAPは非常に有用です
- GSAPのEasing関数を使って、独自の動きを持つ複雑なアニメーションも手軽に作成できます
画面をクリックしてキューブをアニメーションする例です
const container = document.getElementById('canvas');
const containerWidth = container.offsetWidth;
const containerHeight = container.offsetHeight;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, containerWidth / containerHeight, 0.1, 100);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(containerWidth, containerHeight);
document.getElementById('canvas').appendChild(renderer.domElement);
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshNormalMaterial();
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
camera.position.z = 3;
// GSAPのTimelineを作成
const tl = gsap.timeline({ paused: true });
// キューブを移動させるアニメーションをTimelineに追加(バウンスイージング適用)
tl.to(cube.position, { duration: 2, x: 2, ease: "bounce.out" });
// キューブを回転させるアニメーションをTimelineに追加(ゴムバンドイージング適用)
tl.to(cube.rotation, { duration: 2, x: Math.PI * 2, y: Math.PI * 2, ease: "elastic.out(1, 0.3)" });
// キャンパスをクリックしたらアニメーションのポーズとリスタートを切り替え
renderer.domElement.addEventListener('click', () => {
if (tl.isActive()) {
tl.pause(); // アニメーションが動いていたらポーズ
} else {
tl.restart(); // アニメーションが止まっていたらリスタート
}
});
// レンダリング
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
animate();
まとめ
