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も、いざ触ってみると、簡単なアニメーションだけならそこまでハードルは高くないのかなと感じました。
このアニメーション技術のおかげで、私のエンジニア人生の道が開けたと言っても過言ではありません。(面接時に、とても評価していただき嬉しかったです)
この記事が誰かの役に立ってくれたら嬉しいです。
最後まで読んでいただきありがとうございました!