three.js製パーティクルシステムの実装

コーダーの小林です。最近はもっぱらWebGLとCSS3 Transformをいじっています。

今回はWebGLのライブラリであるthree.jsを使用したパーティクルシステムの実装について解説します。 パーティクルシステムについてはWikipediaに項目があるので、そちらを参照していただくと、このあとの解説が理解しやすいかと思います。WebではFlash全盛の頃からよく取り入れられている手法として認知されています。

完成形のデモは以下です。

See the Pen three.js study - THREE.Points step3 by yoichi kobayashi (@ykob) on CodePen.

Pointsを使ってパーティクルシステムを生成する

three.jsにはあらかじめパーティクルシステムのためのクラスが用意されています。Pointsがそれです。ちなみにこのクラス、過去のバージョンではParticleSystemあるいはPointCloudという名前だった時期があるので、別の古い解説記事ではこれら別の名前で記載されていることがあります。ややこしいですね。

three.jsを少しでも触ったことがある方ならご存知かと思いますが、PointsはMeshと同様3Dオブジェクトを生成するためのクラスで、引数にはgeometry(形状)とmaterial(材質)を指定します。geometryには頂点に関わる情報が、materialには色などの塗りに関わる情報が含まれていると考えると理解しやすいでしょうか。

Pointsに使われる初歩的なgeometryはGeometry、materialはPointsMaterialですが、これらからPointsを生成すると以下のようになります。

See the Pen three.js study - THREE.Points step1 by yoichi kobayashi (@ykob) on CodePen.

完成形と比べるとだいぶ地味ですね。WebGLに慣れていない方のなかには、これがどうデザインに応用できるのかがわからないという方もいるかもしれません。もっとこだわった見た目や動きを持つパーティクルシステムを生成するには、geometryとmaterialを別のものに変える必要があります。

BufferGeometryとShaderMaterialで表現の幅を広げる

BufferGeometryとShaderMaterialを使用すると、通常であればthree.jsが行ってくれる素のWebGLの処理をある程度自作する必要がある代わりに、独自のシェーダを読み込むことができるので表現の幅を大きく広げることができます。

1つ前のデモのgeometryとmaterialを差し替え、最低限の属性と値をセットした場合のデモが以下です。

See the Pen three.js study - THREE.Points step2 by yoichi kobayashi (@ykob) on CodePen.

各粒子の位置座標は変わらないのに、1つ1つの粒子のサイズが均一になってしまいました。これは「3D空間上のカメラ(視点)の位置から各粒子の距離を計算して、遠いものほど小さく描画する」という処理をShaderMaterialが行っていないことが原因です。このような細かい計算から自前で行っていかなければいけないので最初は億劫かもしれませんが、慣れればそれほど苦でなくなるはずです。

ここからシェーダに渡す属性やその値、シェーダ内での計算を加え、肉付けしていきます。以下より、完成形のデモで行っている処理について1つずつ解説していきます。

頂点ごとの位置座標と色の属性を設定する

頂点属性はまずBufferGeometryを生成し、そのインスタンスにaddAttributeしていくことで追加ができます。
以下のコードではBufferGeometryの生成から、30万回の繰り返し処理のなかで一頂点あたりの座標(position)と色(color)を設定するまでを行っています。

const geometry = new THREE.BufferGeometry();
const vertices_base = [];
const colors_base = [];
for (let i = 0; i < 300000 ; i ++) {
  const x = Math.floor(Math.random() * 1000 - 500);
  const y = Math.floor(Math.random() * 1000 - 500);
  const z = Math.floor(Math.random() * 1000 - 500);
  vertices_base.push(x, y, z);
  const h = Math.random() * 0.2;
  const s = 0.2 + Math.random() * 0.2;
  const v = 0.8 + Math.random() * 0.2;
  colors_base.push(h, s, v);
}
const vertices = new Float32Array(vertices_base);
geometry.addAttribute('position', new THREE.BufferAttribute(vertices, 3));
const colors = new Float32Array(colors_base);
geometry.addAttribute('color', new THREE.BufferAttribute(colors, 3));

途中、Float32Arrayというクラスが出てきていますが、見慣れない方もいるかもしれません。 これは型付配列を指しており、JavaScriptからWebGLに値を渡す際に必須となるものです。この要素の詳細な解説は外部のページにいくつも存在しているので、ここでは割愛します。

※色はrgbではなくhsvの値にしていますがこれは演出上都合が良いからで、フラグメントシェーダ上でrgbに変換してから描画しています。

全頂点共通の属性と、使用するシェーダを設定する

全頂点共通の属性をuniformと呼びます。three.jsではShaderMaterialクラスにuniformを設定します。
ShaderMaterialにはそれ以外に、そのmaterialを指定する3Dオブジェクトの描画に使用するvertex shaderとfragment shaderも指定する必要があります。

const material = new THREE.ShaderMaterial({
  uniforms: {
    time: {
      type: 'f',
      value: 0.0
    },
    size: {
      type: 'f',
      value: 32.0
    },
    texture: {
      type: 't',
      value: createTexture()
    }
  },
  vertexShader: document.getElementById('vs').textContent,
  fragmentShader: document.getElementById('fs').textContent,
  transparent: true,
  depthWrite: false,
  blending: THREE.AdditiveBlending
});

ShaderMaterialにもattributeを指定できるようですが、BufferGeometryで既に指定しているのでこちらでは省略します。公式ドキュメントでもShaderMaterial内でのattributeの指定は推奨されていないようです。

今回uniformには、時間の経過を表すtime、パーティクルシステムの粒子のサイズを表すsize、粒子に貼り付けるテクスチャ画像を表すtextureの3種類を用意しました。テクスチャの値に記載しているcreateTexture関数は独自のもので、グラデーションがかかった粒子の画像をcanvas要素を生成しthree.jsのtextureクラスに組み込んで、そのtexture要素を返すようになっています。

※transparent、depthWrite、blendingはmaterial系クラス共通のプロパティで、今回の主題には直接関係しないためこれも省略します。

さきほど作成したBufferGeometryと、今回作成したShaderMaterialを組み合わせて、パーティクルシステム用のクラスPointsを以下のように生成します。

const points = new THREE.Points(geometry, material);

シェーダを記述する

vertex shader

attribute vec3 color;

uniform float time;
uniform float size;

varying vec4 vMvPosition;
varying vec3 vColor;

void main() {
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
  vMvPosition = mvPosition;
  vColor = color;

  gl_PointSize = (size + (sin(radians(time * 2.0)) * 10.0 - 10.0)) * (100.0 / length(mvPosition.xyz));
  gl_Position = projectionMatrix * mvPosition;
}

ここでは、

  • JavaScriptから渡されたattribute、uniformのうち、使用するものを宣言。
  • fragment shaderに渡したい値の変数をvaryingで宣言。
  • 渡された頂点の位置座標をmodelViewMatrixで変換。(mvPosition)
  • 頂点のサイズ(gl_PointSize)を、「(基本サイズ + 拡大/縮小アニメのための補正値) * 遠近感を出すための補正値」の式で算出。
  • 最終的に画面に描画される頂点の位置(gl_Position)を算出。

という処理を行っています。

今回のデモで渡した属性はpositionとcolorの2つですが、ここでattribute vec3 position;を省略しているのは、position値はthree.jsが予め準備してくれているため自前で宣言すると重複となりエラーを吐いてしまうためです。

fragment shader

uniform sampler2D texture;

varying vec4 vMvPosition;
varying vec3 vColor;

vec3 hsv2rgb(vec3 c){
  vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
  vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
  return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}

void main() {
  vec3 custom_color = hsv2rgb(vColor);
  float opacity = 200.0 / length(vMvPosition.xyz);

  gl_FragColor = vec4(custom_color, opacity) * texture2D(texture, gl_PointCoord);
}

ここでは、

  • hsv値をrgb値に変換。
  • カメラ位置と頂点の距離に応じてopacity値を補正。
  • rgb値、opacity値、textureを使用して、塗り(gl_FragColor)を算出。

という処理を行っています。

ここまで書いて、最初に示したデモの完成形ができあがります。

three.jsに頼っていても、シェーダの書き方は覚えなければならない

three.jsはWebGLの初期化処理、バッファの準備、座標変換など多くの処理を賄ってくれるので、素のWebGLと比較すると遥かに簡単に3Dレンダリングを行うことができます。しかし今回のデモで示したとおり、少しでも凝った演出を行おうとすると、予め用意されたgeometryやmaterialを使用するだけでは足りず、どうしても独自にシェーダを書く必要が出てきます。

パーティクルシステムは最低限頂点の位置を設定すれば描画ができるので、線やポリゴンを描くよりも必要とする要素が少なく、シェーダを覚えるための練習台としてはもってこいの題材です。

今回紹介したBufferGeometryとShaderMaterialはthree.js独自の要素ですが、素のWebGLにおけるバッファやprogramなどといったJavaScriptで予め用意すべきものが似ているので、素のWebGLの感覚も掴みやすくなるのではないでしょうか。今後WebGLに手を出してみたいが、どこから手を付けていいかわからないという人にはおすすめです。

スマホアプリ制作、Web制作、UIコンサルなどのご依頼はこちら

お問い合わせ