Three.js備忘録(5)

目次
  1. レスポンシブ
  2. スクロール操作とカメラ
  3. 3Dシーン内の位置に対応する
  4. パーティクルにテクスチャを配置
  5. GSAPを利用する

レスポンシブ

ビューポートの設定、カメラのFOV(Field of View: 視野角)とアスペクト比は、Three.jsで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);

3Dモデルをウィンドウのサイズ変更に柔軟に対応
ウィンドウの幅でスケールを調整
X軸の位置は縦長か横長かで調整
例の、scaleRatioはウィンドウの幅が600ピクセルで1になるように正規化しています
正規化したウィンドウの幅と1を比較します

const MAX_SCALE_WIDTH = 600  // この比率は調整が必要です
const sizes = {
  width: window.innerWidth,
  height: window.innerHeight,
  aspectRatio: window.innerWidth / window.innerHeight,
  scaleRatio : Math.min(window.innerWidth / MAX_SCALE_WIDTH, 1);
};

//モデルのレスポンシブ対応用関数
function updateModel(
  model,
  scaleRatio,
  aspectRatio,
  scaleValues,
  positionXValues = null
) {
  if (!model) return;
  let scale = scaleRatio < 1 ? scaleValues[0] : scaleValues[1]; //モデルのスケール
  model.scale.set(scale, scale, scale);
  if (positionXValues) {
    let xpos = aspectRatio < 1 ? positionXValues[0] : positionXValues[1]; //x軸の位置
    model.position.x = xpos;
  }
}

// updateModel関数をロード時に使用する例
gltfLoader.load("/model/model.glb", (gltf) => {
  model = gltf.scene;
  updateModel(
       model,
       sizes.scaleRatio,
       sizes.aspectRatio,
       [1.2, 1.7], //モデルのスケール
       [0.5, 0.75]  //x軸の位置
     );
  scene.add(model);
});


function handleResize() {
  // sizesオブジェクトの更新
  sizes.width = window.innerWidth;
  sizes.height = window.innerHeight;
  sizes.aspectRatio = sizes.width / sizes.height;
  sizes.scaleRatio = Math.min(sizes.width / MAX_SCALE_WIDTH, 1); 
 // その他の例
  camera.aspect = sizes.aspectRatio;
  camera.updateProjectionMatrix();
  renderer.setSize(sizes.width, sizes.height);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  //updateModel関数を実行
  updateModel(reindeer,sizes.scaleRatio,sizes.aspectRatio,[1.2, 1.7],[0.5, 0.75]);
}

handleResize();
window.addEventListener("resize", handleResize);

スクロール操作とカメラ

備考:そもそも、CSSの部分

  • position: absolute;を使用することで、スクロールに合わせてcanvas要素やその中のモデルが自然に上下に移動するようになる
    ただし、bodyや他の親要素に対して位置が決定されるため、微妙なズレが生じることがあり横スクロールバーが発生することがある
    *横スクロールバーを防ぐためにoverflow-x: hidden;をbodyやhtmlに設定する
  • position: fixed;を使用することで要素はビューポートに対して固定され、スクロールしても常に同じ位置に留まります

position: fixedでスクロールに合わせてオブジェクトを上下に動かす場合
フレームごとに繰り返し実行する場合→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>

カメラを動かすと、シーン全体の視点が変わるのでより広範な動きや変化をユーザーに示すことができ、複数のオブジェクトに対する相対的な位置関係は維持されるため、個々のオブジェクトを動かすよりもコードがシンプルになると思います

備考:スクロール時に取得する座標系のY軸の動きとモデルの座標系
スクロール動作と3Dモデルの動作を連動させる場合、座標系の違いに注意する必要があります
特にY軸の動きは直感に反することがあります
スクロールでは、Y軸の正方向は下向きですが、Three.jsの3D空間では上向きです
モデルを上に動かしたい場合は、scrollYの値を減算する必要があります
逆に下に動かしたい場合は加算します。
canvasの位置がfixedかスクロール位置に依存するかで、スクロール値の影響が変わります
canvasがfixedの場合、スクロール値を加算すると直感的な動きになります

垂直ビューポートに対応したオブジェクト配置のテクニック
オブジェクト間の距離を変数にして保存してオブジェクト配置、後でスクロール操作と連動してカメラを動かすなどで便利に使える

// カメラの作成(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)
    }
})

3Dシーン内の位置に対応する

HTML要素の配置

  • project メソッド
    project メソッドは、3D空間の位置(Vector3)をカメラの視点から見た2Dスクリーン座標に変換するために使用されます
    camera オブジェクトを引数として取り、カメラのプロジェクション行列を使用して座標変換を行います
  • 3D座標系:Three.js のシーン内のオブジェクトは3D座標(x, y, z)で位置が定義されます
    スクリーン座標は:[-1, 1]の範囲で表され、これを画面のピクセル座標に変換します
  • screenPosition.x と screenPosition.y は [-1, 1] の範囲に収まるようになります
    これを画面のピクセル座標に変換するために、画面の幅と高さを掛けて調整
    *screenPosition.z は視点からの深度情報を持ちますが、通常のスクリーン表示には使用しません
//例としてpointPositionに.pointクラスの要素を配置
//これをターゲットのモデルのpositionを取得してモデルに追従するようにしたりできる
const pointPosition = new THREE.Vector3(0, 0, 0),
const element = document.querySelector('.point')

// フレームが更新される関数内で実装する
// ポイントの位置を更新する関数
function updatePointPosition(){
   // 元の3D位置を変更しないようにクローンを作成
    const screenPosition = pointPosition.clone();

  // 3D位置を2Dスクリーン座標に投影する
  // プロジェクション行列を使用して、3D空間の座標を2Dスクリーン空間の座標に変換
    screenPosition.project(camera);

  // スクリーン上のピクセル位置を計算する
  // Three.jsのスクリーン座標は[-1, 1]の範囲で表されるため、これを画面の幅と高さを掛けることでピクセル座標に変換
  // screenPosition.x * sizes.width * 0.5で、スクリーン座標の範囲をピクセル単位に変換
  //+ (sizes.width / 2)ウィンドウの中心に調整します。これにより、スクリーンの中央が原点(0,0)になる
  //- (pointElement.offsetWidth / 2)要素の幅の半分を引くことで、要素の中心がモデルの位置に合う
    const translateX = (screenPosition.x * sizes.width * 0.5) + (sizes.width / 2) - (pointElement.offsetWidth / 2);
  //Y座標も変換しますが、CSSの座標系では正の値が下方向に移動するため、符号を反転
   const translateY = -(screenPosition.y * sizes.height * 0.5) + (sizes.height / 2) - (pointElement.offsetHeight / 2);

   // HTML要素に変換後の座標を適用する
   // 上で計算したピクセル単位のXおよびY座標を、HTML要素のtransformプロパティに適用します。
   // これにより、3Dシーン内のポイントと対応するHTML要素がスクリーン上の正しい位置に表示されます。
    element.style.transform = `translateX(${translateX}px) translateY(${translateY}px)`;
};

const tick = () => {
    // ポイントの位置を更新
    updatePointPosition();
    // シーンのレンダリング
    renderer.render(scene, camera);
    // 次のフレームをリクエスト
    requestAnimationFrame(tick);
};

モデルにイベントを設定する
*Raycasterについて:https://koro-koro.com/threejs-no3/#chapter-0

//マウスおよびタッチイベントを考慮したカーソル変更
.pointer {
    cursor: pointer;
}
//RaycasterとVector2の設定
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();

function updatePointer(event) {
//マウスイベントとタッチイベントのどちらかを取得
    const clientX = event.clientX || event.changedTouches[0].clientX;
    const clientY = event.clientY || event.changedTouches[0].clientY;

    mouse.x = (clientX / window.innerWidth) * 2 - 1;
    mouse.y = -(clientY / window.innerHeight) * 2 + 1;

    raycaster.setFromCamera(mouse, camera);
    const intersects = raycaster.intersectObjects(scene.children, true);

    document.body.classList.toggle('pointer', intersects.length > 0);
}

function onPointerClick(event) {
  updatePointer(event); // ホバーの更新も行う
  const intersects = raycaster.intersectObjects(scene.children, true);
  if (intersects.length > 0) {
    //クリックに対応した処理
  }
}

window.addEventListener('mousemove', updatePointer);
window.addEventListener('touchmove', updatePointer);
window.addEventListener('click', onPointerClick);
window.addEventListener('touchend', onPointerClick);

パーティクルにテクスチャを配置

背景が透明のテクスチャ画像を使用しても、マテリアルにテクスチャ画像設定した場合
オブジェクトの描画順序やテクスチャのブレンディングモード、深度バッファの取り扱いなどの設定によっては、テクスチャが透明であっても重なった部分で不透明な背景のような表示がされることがあります

この問題を解決するためには、マテリアルの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();

まとめ