BLOG

ブログ

Three.jsとGSAPで、シェーダーを使わないお手軽パーティクルアニメーション

こんにちは!先月入社しました、駆け出しエンジニアの菱村です。

私はエンジニア未経験からの入社になりますが、
就職活動時に制作したポートフォリオサイトで使用した、Three.jsとGSAPのパーティクルアニメーションについて、簡単に紹介していきたいと思います。

想定している読者

  • Three.jsを使ってみたいけど、難しそうと感じている方
  • HTML・CSSの基本的な内容がわかっている
  • JSの基礎はある程度知っている

パーティクルアニメーションとは?

パーティクルとは粒子のことで、たくさんの粒子を動かして形などを表現していくアニメーションになります。
本記事では、画面をスクロールしていくのに連動して、箱の形から一度バラバラに散らばり、最後は球の形に変形していくアニメーションを作成していきます。

※完成イメージ図

完成イメージ

本来パーティクルアニメーションを実装する場合は、シェーダー(GLSL)で動きを処理するのが一般的なのですが、私がパーティクルアニメーションに挑戦したときは、就職活動を開始するまでの期間的にあまり時間が無かったので、シェーダーの学習は一旦あきらめてしまいました。
その代わりにパーティクルの数を少なくすることで、シェーダーを使わずにアニメーションを作成しています。

事前準備 HTML・CSS

今回用意したHTML・CSSは↓になります。

<div class="wrapper">
  <!-- ここに描画される -->
  <canvas id="canvas" class="canvas"></canvas>
    
  <div class="box"></div>
  <div class="box scrollAnime1"></div>
  <div class="box"></div>
  <div class="box"></div>
  <div class="box scrollAnime2"></div>
  <div class="box"></div>
</div>
.canvas {
  position: fixed;
  top: 0;
  left: 0;
  z-index: -1;
  width: 100% !important;
  height: 100vh !important;
}

.box {
  width: 100%;
  height: 100vh;
}

Three.jsの内容はcanvasに描画されます。
また、スクロールをしていく分の高さを出すために、divを複数用意しました。
クラスのscrollAnime1、scrollAnime2については、後ほどGSAPで使用します。

Three.jsの導入

今回はCDNで読み込んで使用します。
初期化処理やアニメーション部分などはfunction.jsに記述しています。

<script type="importmap">
    {
      "imports": {
        "three": "https://unpkg.com/three@0.152.2/build/three.module.js"
      }
    }
</script>
<script type="module" src="js/function.js"></script>

Three.jsの初期化処理

まずは、Three.jsのお作法的な部分の記述になります。

import * as THREE from "three";

// ページの読み込みを待ってから実行
window.addEventListener("DOMContentLoaded", init);

function init() {

  // 描画サイズ
  const width = window.innerWidth;
  const height = window.innerHeight;

  // 背景色
  const bgColor = 0x251D3A;

  // レンダラーを作成
  const renderer = new THREE.WebGLRenderer({
    canvas: document.querySelector("#canvas"),
    antialias: true,
    devicePixelRatio: window.devicePixelRatio,
  });
  renderer.setSize(width, height);
  renderer.setClearColor(bgColor);

  // シーンを作成
  const scene = new THREE.Scene();

  // 光源
  scene.add(new THREE.DirectionalLight(0xffffff, 3)); // 平行光源
  scene.add(new THREE.AmbientLight(0xeeeeee, 1)); // 環境光源  

  // カメラを作成
  const camera = new THREE.PerspectiveCamera(45, width / height); // 視野角, アスペクト比
  camera.position.set(0, 0, +1000); // カメラ位置のX座標, Y座標, Z座標



  // 毎フレーム時に実行されるループイベント
  animate();

  function animate() {
    renderer.render(scene, camera); // レンダリング
    requestAnimationFrame(animate);
  }

}

各設定項目はコメントに記載している通りなのですが、

3D空間を用意して、
光をあてて、
カメラを用意して、
カメラで撮っている部分を描画する、

という感じです。

ただこれだけだと3D空間があるだけなので、
次はお試しでオブジェクトを表示してみたいと思います。

球のオブジェクトを表示してみる

3D空間にオブジェクトを生成するときは、
ジオメトリ(オブジェクトの形・大きさ)と、マテリアル(オブジェクトの質感・色)の2つの設定をしてあげます。

// -----  カメラを作成 の下に記述 ------

  // 生成するオブジェクトの設定
  const geometry = new THREE.SphereGeometry(100, 32, 32);
  const material = new THREE.MeshLambertMaterial({ color: 0xFFFFFF });

Three.jsの中には、いくつかの種類のジオメトリとマテリアルが用意されています。
どんなジオメトリ(マテリアル)が用意されていて、各引数の意味合いがどのようなものなのかは、公式リファレンスにサンプルも含めて説明がありますので、そちらをご参照ください。

右上のパラメータの値を変更すると、大きさや形などが変わるのを確認でき、
引数にどんな値を入れたらいいのかもわかりやすいです。

今回は、ジオメトリは球、マテリアルはややマットな質感のものを設定しました。
この2つの設定でオブジェクトを生成し、シーンに追加します。

  // オブジェクトをシーンに追加
  const sphere = new THREE.Mesh(geometry, material);
  scene.add(sphere);

これで3D空間に球のオブジェクトが表示されたと思います。

球をたくさん作る

↑で作成した球のオブジェクトはこのあとの工程では不要なため、
コメントアウトしておきます。

  // // 生成するオブジェクトの設定
  // const geometry = new THREE.SphereGeometry(100, 32, 32);
  // const material = new THREE.MeshLambertMaterial({ color: 0xFFFFFF });
  
  // // オブジェクトをシーンに追加
  // const sphere = new THREE.Mesh(geometry, material);
  // scene.add(sphere);

ここから、パーティクル用の球をたくさん生成しますが、やることは基本的に先程と変わりません。
たくさんのパーティクルを1つのまとまりとして操作したいので、グループを作成してシーンに追加してあげます。

// -----  コメントアウトしたコード の下に記述 ------

  // グループを作成
  const group = new THREE.Group();
  scene.add(group);

  // パーティクル用のオブジェクトの設定
  const geometry = new THREE.SphereGeometry(3, 32, 32);
  const material = new THREE.MeshLambertMaterial({ color: 0xFFFFFF });

先ほど球のオブジェクトを生成した時と同じように、ジオメトリとマテリアルを設定します。
(ジオメトリは、先程よりもサイズを小さくしました)

あとは、パーティクルの数だけ繰り返し生成して、グループに入れてあげます。

  // パーティクルの数
  const meshCount = 726;

  // パーティクルを生成
  for (let i = 0; i < meshCount; i++) {
    const mesh = new THREE.Mesh(geometry, material);

    // グループに格納する
    group.add(mesh);
  }

ただこれだけだと、全てのパーティクルが同じ場所にあり、1つに重なってしまっています。
このあとの工程で、箱型に並べていきます。

また、なぜパーティクルの数がこの数だったのかというと、
このあと箱型に並べるときの、箱オブジェクトの頂点の数に合わせています。
(頂点の数については、後でまた説明します)

パーティクルを箱型に並べる

パーティクルを箱型に並べていくために、まずは箱型ジオメトリの頂点情報を取得します。

// -----  パーティクル用のオブジェクトの設定 の下に記述 ------

  // Box型のジオメトリの頂点座標を取得する(最初の形)
  const firstGeometry = new THREE.BoxGeometry(200, 200, 200, 10, 10, 10);
  const firstMesh = new THREE.Mesh(firstGeometry, material);
  const firstPos = firstMesh.geometry.attributes.position;

実際に箱のオブジェクトを表示(シーンに追加)するわけではありませんが、
頂点情報を取得するために一度生成しています。
そのときのマテリアルの設定はなんでもよいです。
(今回は、パーティクルを生成するときのマテリアルを流用しました)

頂点の数について

頂点の数については、頂点情報を取得した変数のcountプロパティで確認することができます。

console.log(firstPos.count);

パーティクルを動かして変形させる、かつ、パーティクルの数が少ない場合は、「変形前の頂点の数」と「変形後の頂点の数」を合わせてあげたほうがキレイに見えると思います。

箱型の頂点座標にパーティクルを配置

パーティクルを生成するタイミングで、座標情報を変更します。

  // パーティクルを生成
  for (let i = 0; i < meshCount; i++) {
    const mesh = new THREE.Mesh(geometry, material);

    // 【追加】最初の形になるように配置
    mesh.position.x = firstPos.getX(i);
    mesh.position.y = firstPos.getY(i);
    mesh.position.z = firstPos.getZ(i);

    // グループに格納する
    group.add(mesh);
  }

パーティクルそれぞれのX座標・Y座標・Z座標を箱の頂点座標に変更しています。
これで、パーティクルを箱型に並べることができました。

回転させてみる

それでは、パーティクルのグループを回転させてみたいと思います。
animateの中に、以下を記述すると、回転させることができます。

  // 毎フレーム時に実行されるループイベント
  animate();

  function animate() {
    // 【追加】グループを回転させる
    group.rotation.x += 0.002;
    group.rotation.y += 0.002;

    renderer.render(scene, camera); // レンダリング
    requestAnimationFrame(animate);
  }

奥行きに影をつけるフォグの設定

もう少し3Dの表現がリッチになるように、奥行きに影を付けていきます。
シーンにフォグ(霧)の設定をしてあげることで、カメラから離れるほど、霞んで見えるようになります。

// -----  パーティクルを生成 の下に記述 ------

  // フォグを設定
  scene.fog = new THREE.Fog(bgColor, 400, 1500); // 色, 開始距離, 終点距離;

フォグを設定することで、手前にあるパーティクルのほうがはっきりと見えるようになりました。

ここまでのコード全文

まだ途中ですが、一旦ここまでのコードを掲載します。

<body>
  <div class="wrapper">
    <!-- ここに描画される -->
    <canvas id="canvas" class="canvas"></canvas>
    
    <div class="box"></div>
    <div class="box scrollAnime1"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box scrollAnime2"></div>
    <div class="box"></div>
  </div>

  <script type="importmap">
      {
        "imports": {
          "three": "https://unpkg.com/three@0.152.2/build/three.module.js"
        }
      }
  </script>
  <script type="module" src="js/function.js"></script>
</body>
.canvas {
  position: fixed;
  top: 0;
  left: 0;
  z-index: -1;
  width: 100% !important;
  height: 100vh !important;
}

.box {
  width: 100%;
  height: 100vh;
}
import * as THREE from "three";

// ページの読み込みを待ってから実行
window.addEventListener("DOMContentLoaded", init);

function init() {

  // 描画サイズ
  const width = window.innerWidth;
  const height = window.innerHeight;

  // 背景色
  const bgColor = 0x251D3A;

  // レンダラーを作成
  const renderer = new THREE.WebGLRenderer({
    canvas: document.querySelector("#canvas"),
    antialias: true,
    devicePixelRatio: window.devicePixelRatio,
  });
  renderer.setSize(width, height);
  renderer.setClearColor(bgColor);

  // シーンを作成
  const scene = new THREE.Scene();

  // 光源
  scene.add(new THREE.DirectionalLight(0xffffff, 3)); // 平行光源
  scene.add(new THREE.AmbientLight(0xeeeeee, 1)); // 環境光源  

  // カメラを作成
  const camera = new THREE.PerspectiveCamera(45, width / height); // 視野角, アスペクト比
  camera.position.set(0, 0, +1000); // カメラ位置のX座標, Y座標, Z座標



  // // 生成するオブジェクトの設定
  // const geometry = new THREE.SphereGeometry(100, 32, 32);
  // const material = new THREE.MeshLambertMaterial({ color: 0xFFFFFF });
  
  // // オブジェクトをシーンに追加
  // const sphere = new THREE.Mesh(geometry, material);
  // scene.add(sphere);



  // グループを作成
  const group = new THREE.Group();
  scene.add(group);

  // パーティクル用のオブジェクトの設定
  const geometry = new THREE.SphereGeometry(3, 32, 32);
  const material = new THREE.MeshLambertMaterial({ color: 0xFFFFFF });

  // Box型のジオメトリの頂点座標を取得する(最初の形)
  const firstGeometry = new THREE.BoxGeometry(200, 200, 200, 10, 10, 10);
  const firstMesh = new THREE.Mesh(firstGeometry, material);
  const firstPos = firstMesh.geometry.attributes.position;

  // パーティクルの数
  const meshCount = 726;

  // パーティクルを生成
  for (let i = 0; i < meshCount; i++) {
    const mesh = new THREE.Mesh(geometry, material);

    // 最初の形になるように配置
    mesh.position.x = firstPos.getX(i);
    mesh.position.y = firstPos.getY(i);
    mesh.position.z = firstPos.getZ(i);

    // グループに格納する
    group.add(mesh);
  }


  // フォグを設定
  scene.fog = new THREE.Fog(bgColor, 400, 1500); // 色, 開始距離, 終点距離;



  // 毎フレーム時に実行されるループイベント
  animate();

  function animate() {
    // グループを回転させる
    group.rotation.x += 0.002; 
    group.rotation.y += 0.002; 

    renderer.render(scene, camera); // レンダリング
    requestAnimationFrame(animate);
  }

}

このあとは、スクロールすると箱型→バラバラに(ランダムな座標)→球型の順番で変形していくようにしていきます。
そのため、必要なのは「ランダムな座標」と「球の頂点座標」です。

ランダムな座標・球の頂点座標

箱型の頂点と同じ数のランダムな座標を、配列の形で用意します。
ランダムな値の幅については、小さすぎるとそんなに散らばらず、大きすぎるとカメラの範囲内に収まらないということがあるので、お好みで調整してください。

球の頂点座標の取得方法は、箱型のときと同じです。

// -----  フォグを設定 の下に記述 ------

  // ランダムな頂点座標を入れるための配列
  const randomPos = [];

  // ランダムな頂点座標を配列に格納
  for (let i = 0; i < meshCount; i++) {
    const x = (Math.random() - 0.5) * 800;
    const y = (Math.random() - 0.5) * 800;
    const z = (Math.random() - 0.5) * 800;

    const randomPosObj = { x: x, y: y, z: z };

    randomPos.push(randomPosObj);
  }

  // 球型の頂点座標を取得する(2番目の形)
  const secondGeometry = new THREE.SphereGeometry(160, 32, 21);
  const secondMesh = new THREE.Mesh(secondGeometry, material);
  const secondPos = secondMesh.geometry.attributes.position;

GSAPの導入

スクロールと連動してアニメーションさせたいので、GSAPのScrollTriggerを使います。
今回はCDNでfunction.jsの直前に読み込んでいます。

  <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/ScrollTrigger.min.js"></script>

アニメーションの準備

スクロールに合わせて、値を0〜1で変動させるパラメータを用意します。
箱型→ランダムに変化するときのものと、ランダム→球に変化するときの2つを用意して、それぞれを変化させるScrollTriggerの設定をします。

// -----  球型の頂点座標を取得する の下に記述 ------

  // アニメーション用のパラメータを用意
  const animationParam1 = { value: 0, }; // 最初の形 → ランダム
  const animationParam2 = { value: 0, }; // ランダム → 2番目の形

  // gsapの設定
  gsap.to(animationParam1, {
    value: 1.0,
    scrollTrigger: {
      trigger: ".scrollAnime1",
      start: "top center",    // trigger要素のどの部分、画面のどの部分
      end: "bottom top",
      scrub: 0.7,
    },
  });

  gsap.to(animationParam2, {
    value: 1.0,
    scrollTrigger: {
      trigger: ".scrollAnime2",
      start: "top center",    // trigger要素のどの部分、画面のどの部分
      end: "bottom top",
      scrub: 0.7,
    },
  });

scrollAnime1、scrollAnime2のクラスが付いているdivのトップが、画面の中央に来た時にパラメータの変化が始まるようにしています。

パーティクル動かすアニメーション

アニメーションパラメータの値に連動して、パーティクルそれぞれの座標を変化させるように、animateに追記します。

  // 毎フレーム時に実行されるループイベント
  animate();

  function animate() {
    // 【追加】アニメーションパラメータの変動に合わせて、パーティクルの位置を変化させる
    if (animationParam1.value <= 1 && animationParam2.value == 0) {
      // 最初の形 → ランダム
      for (let i = 0; i < meshCount; i++) {
        // バラバラに
        group.children[i].position.x = firstPos.getX(i) + randomPos[i].x * animationParam1.value;
        group.children[i].position.y = firstPos.getY(i) + randomPos[i].y * animationParam1.value;
        group.children[i].position.z = firstPos.getZ(i) + randomPos[i].z * animationParam1.value;
      }

    } else if (animationParam2.value <= 1) {
      // ランダム → 2番目の形
      for (let i = 0; i < meshCount; i++) {
        // ↑の変化の最終地点
        const x = firstPos.getX(i) + randomPos[i].x;
        const y = firstPos.getY(i) + randomPos[i].y;
        const z = firstPos.getZ(i) + randomPos[i].z;

        // 2番目の形 との差分
        const dX = secondPos.getX(i) - x;
        const dY = secondPos.getY(i) - y;
        const dZ = secondPos.getZ(i) - z;

        // 2番目の形 に変形
        group.children[i].position.x = x + dX * animationParam2.value;
        group.children[i].position.y = y + dY * animationParam2.value;
        group.children[i].position.z = z + dZ * animationParam2.value;
      }
    }

    // グループを回転させる
    group.rotation.x += 0.002; 
    group.rotation.y += 0.002; 

    renderer.render(scene, camera); // レンダリング
    requestAnimationFrame(animate);
  }

これで今回のパーティクルアニメーションは完成です!

コード全文

<body>
  <div class="wrapper">
    <!-- ここに描画される -->
    <canvas id="canvas" class="canvas"></canvas>
    
    <div class="box"></div>
    <div class="box scrollAnime1"></div>
    <div class="box"></div>
    <div class="box"></div>
    <div class="box scrollAnime2"></div>
    <div class="box"></div>
  </div>

  <script type="importmap">
      {
        "imports": {
          "three": "https://unpkg.com/three@0.152.2/build/three.module.js"
        }
      }
  </script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/ScrollTrigger.min.js"></script>
  <script type="module" src="js/function.js"></script>
</body>
.canvas {
  position: fixed;
  top: 0;
  left: 0;
  z-index: -1;
  width: 100% !important;
  height: 100vh !important;
}

.box {
  width: 100%;
  height: 100vh;
}
import * as THREE from "three";

// ページの読み込みを待ってから実行
window.addEventListener("DOMContentLoaded", init);

function init() {

  // 描画サイズ
  const width = window.innerWidth;
  const height = window.innerHeight;

  // 背景色
  const bgColor = 0x251D3A;

  // レンダラーを作成
  const renderer = new THREE.WebGLRenderer({
    canvas: document.querySelector("#canvas"),
    antialias: true,
    devicePixelRatio: window.devicePixelRatio,
  });
  renderer.setSize(width, height);
  renderer.setClearColor(bgColor);

  // シーンを作成
  const scene = new THREE.Scene();

  // 光源
  scene.add(new THREE.DirectionalLight(0xffffff, 3)); // 平行光源
  scene.add(new THREE.AmbientLight(0xeeeeee, 1)); // 環境光源  

  // カメラを作成
  const camera = new THREE.PerspectiveCamera(45, width / height); // 視野角, アスペクト比
  camera.position.set(0, 0, +1000); // カメラ位置のX座標, Y座標, Z座標



  // // 生成するオブジェクトの設定
  // const geometry = new THREE.SphereGeometry(100, 32, 32);
  // const material = new THREE.MeshLambertMaterial({ color: 0xFFFFFF });
  
  // // オブジェクトをシーンに追加
  // const sphere = new THREE.Mesh(geometry, material);
  // scene.add(sphere);



  // グループを作成
  const group = new THREE.Group();
  scene.add(group);

  // パーティクル用のオブジェクトの設定
  const geometry = new THREE.SphereGeometry(3, 32, 32);
  const material = new THREE.MeshLambertMaterial({ color: 0xFFFFFF });

  // Box型のジオメトリの頂点座標を取得する(最初の形)
  const firstGeometry = new THREE.BoxGeometry(200, 200, 200, 10, 10, 10);
  const firstMesh = new THREE.Mesh(firstGeometry, material);
  const firstPos = firstMesh.geometry.attributes.position;

  // パーティクルの数
  const meshCount = 726;

  // パーティクルを生成
  for (let i = 0; i < meshCount; i++) {
    const mesh = new THREE.Mesh(geometry, material);

    // 最初の形になるように配置
    mesh.position.x = firstPos.getX(i);
    mesh.position.y = firstPos.getY(i);
    mesh.position.z = firstPos.getZ(i);

    // グループに格納する
    group.add(mesh);
  }


  // フォグを設定
  scene.fog = new THREE.Fog(bgColor, 400, 1500); // 色, 開始距離, 終点距離;



  // ランダムな頂点座標を入れるための配列
  const randomPos = [];

  // ランダムな頂点座標を配列に格納
  for (let i = 0; i < meshCount; i++) {
    const x = (Math.random() - 0.5) * 800;
    const y = (Math.random() - 0.5) * 800;
    const z = (Math.random() - 0.5) * 800;

    const randomPosObj = { x: x, y: y, z: z };

    randomPos.push(randomPosObj);
  }

  // 球型の頂点座標を取得する(2番目の形)
  const secondGeometry = new THREE.SphereGeometry(160, 32, 21);
  const secondMesh = new THREE.Mesh(secondGeometry, material);
  const secondPos = secondMesh.geometry.attributes.position;



  // アニメーション用のパラメータを用意
  const animationParam1 = { value: 0, }; // 最初の形 → ランダム
  const animationParam2 = { value: 0, }; // ランダム → 2番目の形

  // gsapの設定
  gsap.to(animationParam1, {
    value: 1.0,
    scrollTrigger: {
      trigger: ".scrollAnime1",
      start: "top center",    // trigger要素のどの部分、画面のどの部分
      end: "bottom top",
      scrub: 0.7,
    },
  });

  gsap.to(animationParam2, {
    value: 1.0,
    scrollTrigger: {
      trigger: ".scrollAnime2",
      start: "top center",    // trigger要素のどの部分、画面のどの部分
      end: "bottom top",
      scrub: 0.7,
    },
  });



  // 毎フレーム時に実行されるループイベント
  animate();

  function animate() {
    // アニメーションパラメータの変動に合わせて、パーティクルの位置を変化させる
    if (animationParam1.value <= 1 && animationParam2.value == 0) {
      // 最初の形 → ランダム
      for (let i = 0; i < meshCount; i++) {
        // バラバラに
        group.children[i].position.x = firstPos.getX(i) + randomPos[i].x * animationParam1.value;
        group.children[i].position.y = firstPos.getY(i) + randomPos[i].y * animationParam1.value;
        group.children[i].position.z = firstPos.getZ(i) + randomPos[i].z * animationParam1.value;
      }

    } else if (animationParam2.value <= 1) {
      // ランダム → 2番目の形
      for (let i = 0; i < meshCount; i++) {
        // ↑の変化の最終地点
        const x = firstPos.getX(i) + randomPos[i].x;
        const y = firstPos.getY(i) + randomPos[i].y;
        const z = firstPos.getZ(i) + randomPos[i].z;

        // 2番目の形 との差分
        const dX = secondPos.getX(i) - x;
        const dY = secondPos.getY(i) - y;
        const dZ = secondPos.getZ(i) - z;

        // 2番目の形 に変形
        group.children[i].position.x = x + dX * animationParam2.value;
        group.children[i].position.y = y + dY * animationParam2.value;
        group.children[i].position.z = z + dZ * animationParam2.value;
      }
    }


    // グループを回転させる
    group.rotation.x += 0.002; 
    group.rotation.y += 0.002; 

    renderer.render(scene, camera); // レンダリング
    requestAnimationFrame(animate);
  }

}

おわりに

私のポートフォリオのコンセプトは、「やってみたいをカタチに」というもので、次々とカタチになっていくような表現をしたいと思い、パーティクルアニメーションに挑戦しました。
挑戦する前は難しそうに感じていたThree.jsも、いざ触ってみると、簡単なアニメーションだけならそこまでハードルは高くないのかなと感じました。

このアニメーション技術のおかげで、私のエンジニア人生の道が開けたと言っても過言ではありません。(面接時に、とても評価していただき嬉しかったです)

この記事が誰かの役に立ってくれたら嬉しいです。
最後まで読んでいただきありがとうございました!