Three.jsでパーティクルアニメーションを作ってみよう

particle-1

カーソルの動きにあわせてたくさんのパーティクル(粒子)が動く、インタラクティブなパーティクルアニメーションでWebサイトを華やかにしてみませんか?

パーティクルアニメーションはドラマチックな印象を与えるだけでなく、ユーザーの遊び心も刺激してくれる優れものです。

今回は、Three.jsを使ってたくさんのパーティクルを描画し、シェーダーとオフスクリーンテクスチャを使ってマウスやタッチ入力に反応させる方法をご紹介します。

ジオメトリをインスタンス化する

パーティクルは、画像のピクセルに基づいて作られます。今回使用する画像のサイズは320×180、つまり57,600ピクセルです。

ただし、パーティクルごとにジオメトリを一つひとつ作る必要はありません。ひとつだけ作って、さまざまなパラメーターで57,600回レンダリングします。これがジオメトリのインスタンス化です。

InstancedBufferGeometryを使用してジオメトリを定義し、すべてのインスタンスにおいて変わらない属性にはBufferAttributeを使用し、インスタンスごとに変わる可能性のある属性(色、サイズなど)にはInstancedBufferAttributeを使用します。

今回作るパーティクルのジオメトリは、4つの頂点と2つの三角形で形成されたシンプルな四角形です。

particle-2

const geometry = new THREE.InstancedBufferGeometry();

// positions
const positions = new THREE.BufferAttribute(new Float32Array(4 * 3), 3);
positions.setXYZ(0, -0.5, 0.5, 0.0);
positions.setXYZ(1, 0.5, 0.5, 0.0);
positions.setXYZ(2, -0.5, -0.5, 0.0);
positions.setXYZ(3, 0.5, -0.5, 0.0);
geometry.addAttribute('position', positions);

// uvs
const uvs = new THREE.BufferAttribute(new Float32Array(4 * 2), 2);
uvs.setXYZ(0, 0.0, 0.0);
uvs.setXYZ(1, 1.0, 0.0);
uvs.setXYZ(2, 0.0, 1.0);
uvs.setXYZ(3, 1.0, 1.0);
geometry.addAttribute('uv', uvs);

// index
geometry.setIndex(new THREE.BufferAttribute(new Uint16Array([ 0, 2, 1, 2, 3, 1 ]), 1));

次に、画像のピクセルをループ処理して、インスタンス化した属性を割り当てます。positionはすでに使われているため、各インスタンスのポジションをストアするにあたってoffsetを使用します。オフセットは、画像の各ピクセルのx、yになります。また、後からアニメーションに使用するパーティクルインデックスとランダムなアングルも保存しておきましょう。

パーティクルのマテリアル

マテリアルは、カスタムシェーダーのparticle.vertとparticle.fragを含むRawShaderMaterialです。

ユニフォームは以下のとおりです。

  • uTime:経過時間、フレームごとに更新
  • uRandom:x、yの粒子を変位させるために使用されるランダムファクター
  • uDepth:z軸におけるパーティクルの最大振幅
  • uSize:パーティクルの基本サイズ
  • uTexture:画像テクスチャ
  • uTextureSize:テクスチャの大きさ
  • uTouch:テクスチャへのタッチ
const uniforms = {
	uTime: { value: 0 },
	uRandom: { value: 1.0 },
	uDepth: { value: 2.0 },
	uSize: { value: 0.0 },
	uTextureSize: { value: new THREE.Vector2(this.width, this.height) },
	uTexture: { value: this.texture },
	uTouch: { value: null }
};

const material = new THREE.RawShaderMaterial({
	uniforms,
	vertexShader: glslify(require('../../../shaders/particle.vert')),
	fragmentShader: glslify(require('../../../shaders/particle.frag')),
	depthTest: false,
	transparent: true
});

単純な頂点シェーダーは、offset属性に従ってパーティクルの位置を直接出力します。

またユニークさを加えるために、ランダムとノイズを使ってパーティクルを変位させましょう。パーティクルのサイズについても同様です。

// particle.vert

void main() {
	// displacement
	vec3 displaced = offset;
	// randomise
	displaced.xy += vec2(random(pindex) - 0.5, random(offset.x + pindex) - 0.5) * uRandom;
	float rndz = (random(pindex) + snoise_1_2(vec2(pindex * 0.1, uTime * 0.1)));
	displaced.z += rndz * (random(pindex) * 2.0 * uDepth);

	// particle size
	float psize = (snoise_1_2(vec2(uTime, pindex) * 0.5) + 2.0);
	psize *= max(grey, 0.2);
	psize *= uSize;

	// (...)
}

フラグメントシェーダーは元の画像からRGBカラーをサンプリングし、光度を基準にしてグレースケールに変換します(0.21 R + 0.72 G + 0.07 B)。

アルファチャンネルはUVの中心までの直線距離によって決定されるため、必然的に円になります。円の境界はsmoothstepを使ってぼかすことが可能です。

// particle.frag

void main() {
	// pixel color
	vec4 colA = texture2D(uTexture, puv);

	// greyscale
	float grey = colA.r * 0.21 + colA.g * 0.71 + colA.b * 0.07;
	vec4 colB = vec4(grey, grey, grey, 1.0);

	// circle
	float border = 0.3;
	float radius = 0.5;
	float dist = radius - distance(uv, vec2(0.5));
	float t = smoothstep(0.0, border, dist);

	// final color
	color = colB;
	color.a = t;

	// (...)
}

最適化とパフォーマンスの向上

今回は、明るさに応じてパーティクルの大きさを設定するため、暗いパーティクルはほとんど見えません。これは最適化の余地があることを意味します。

画像のピクセルをループ処理する際に、暗すぎるピクセルを破棄することにより、パーティクルの数が減ってパフォーマンスが向上します。

particle-3

InstancedBufferGeometryを作る前に、最適化をはじめましょう。

仮のキャンバスを作り画像を描画して、色の配列[R, G, B, A, R, G, B … ]を取得するためにgetlmageData()を呼び出します。次にしきい値(16進数#22または10進数34)を定義し、赤のチャンネルに対してテストします。必ずしも赤である必要はなく、青や緑、または3つの平均を使うことも可能です。

// discard pixels darker than threshold #22
if (discard) {
	numVisible = 0;
	threshold = 34;

	const img = this.texture.image;
	const canvas = document.createElement('canvas');
	const ctx = canvas.getContext('2d');

	canvas.width = this.width;
	canvas.height = this.height;
	ctx.scale(1, -1); // flip y
	ctx.drawImage(img, 0, 0, this.width, this.height * -1);

	const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
	originalColors = Float32Array.from(imgData.data);

	for (let i = 0; i < this.numPoints; i++) {
		if (originalColors[i * 4 + 0] > threshold) numVisible++;
	}
}

また、しきい値を考慮するために、offset、angle、pindexを定義するループも更新しましょう。

for (let i = 0, j = 0; i < this.numPoints; i++) {
	if (originalColors[i * 4 + 0] <= threshold) continue;

	offsets[j * 3 + 0] = i % this.width;
	offsets[j * 3 + 1] = Math.floor(i / this.width);

	indices[j] = i;

	angles[j] = Math.random() * Math.PI;

	j++;
}

パーティクルアニメーションをインタラクティブに

おさえておきたいポイント

パーティクルをインタラクティブにする方法はたくさんあります。たとえば、各パーティクルにvelocity属性を設定し、カーソルの接近度に基づいてすべてのフレームで更新するという方法があります。この方法は古典的で扱いやすいですが、何万ものパーティクルをループしなければいけない今回のような場合は、処理がすこし重すぎるかもしれません。

シェーダーを使うのも効率的です。カーソルの位置をユニフォームとして渡し、カーソルからの距離に基づいてパーティクルを移動させられます。この方法だと高速ですが、パーティクルのイーズイン・イーズアウトはできません。

今回採用した方法

今回選んだのは、カーソルの位置をテクスチャ上で描画する方法です。

この方法を採用するメリットは、カーソルの位置の履歴を保持して軌跡を作れるという点です。また軌跡の半径にイージング関数を適用し、スムーズに伸縮させることも可能。シェーダー内ですべてが完結し、すべてのパーティクルが同時に処理されるのです

particle-4

カーソルの位置を取得するために、今回はRaycasterとシンプルなPlaneBufferGeometryをメインジオメトリと同じサイズで使用します。表示されないため目には見えませんが、とてもインタラクティブです。

またこの例からもわかるように、Three.jsのインタラクティビティ自体に注目すべき価値があります。

カーソルと平面のあいだに交差する点がある場合、交差する点のデータ内のUV座標を使ってカーソルの位置を取得できます。そしてその位置が配列(軌跡)にストアされ、オフスクリーンキャンバスに描画されるという仕組みです。キャンバスは、ユニフォームuTouchを介してテクスチャとしてシェーダーに渡されます。

頂点シェーダーでは、パーティクルはタッチしたテクスチャの、ピクセルの明るさに基づいて移動します。

// particle.vert

void main() {
	// (...)

	// touch
	float t = texture2D(uTouch, puv).r;
	displaced.z += t * 20.0 * rndz;
	displaced.x += cos(angle) * t * 20.0 * rndz;
	displaced.y += sin(angle) * t * 20.0 * rndz;

	// (...)
}

まとめ

particle-5

今回のチュートリアルはお楽しみいただけたでしょうか?

ぜひこの例を参考にして、ユニークなパーティクルアニメーションを作ってみてください!

(原文:Bruno Imbrizi 翻訳:Asuka Nakajima)

 

あわせて読みたい!▼

SHARE

RELATED

  • お問い合わせ
  • お問い合わせ
  • お問い合わせ