three.jsのPointsでパーティクルシステム

以前の記事でthree.jsのInstanceMeshでパーティクルシステムを作る方法について書きました。
[three\.jsのInstanceMeshでパーティクルシステム \- Qiita](https://qiita.com/aa_debdeb/items/92762847d61dbfa801a4)

今回は、three.jsの[Points](https://threejs.org/docs/index.html?q=points#api/en/objects/Points)でパーティクルシステムを作りたいと思います。
![screenshot.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61569/7c295ab3-729f-f9b9-d7ee-4f2b28ec93a1.gif)
three.jsのバージョンはr135です。

ソースコード全文とデモは以下から確認できます。
[aadebdeb/ThreePointsParticleSystemExample: Example of Three\.js Points Particle System](https://github.com/aadebdeb/ThreePointsParticleSystemExample)

パーティクルシステムのベースは次のようになります。`THREE.Points`に使用するジオメトリの配列をパーティクルの最大数`capacity`の分だけ作成しておきます。`update`での更新ごとにそのジオメトリに生存しているパーティクルの情報だけを先頭から詰めて、最後に`BufferGeometry.setDrawRange`で生存しているパーティクルだけを描画するようにしています。`PointsParticleSystem`ではパーティクルの生成と寿命の管理だけをして、挙動はサブクラスで`createParticle`と`updateParticle`を実装することで決めるようにして汎用性を高めています。

```js
class PointsParticleSystem {
  constructor(
    material, // マテリアル
    capacity, // パーティクルの最大数
    {
      birthRate = 100, // 1秒あたりに生成するパーティクル数
      lifeExpectancy = 1.0, // パーティクルの寿命(秒)
      lifeVariance = 0.0, // パーティクルの寿命の分散 [0, 1]
      useColor = false, // パーティクルごとに色を設定するか
    } = {})
{
    this._capacity = capacity;
    this.birthRate = birthRate;
    this.lifeExpectancy = lifeExpectancy;
    this.lifeVariance = lifeVariance;
    this._useColor = useColor;

    this._geometry = new THREE.BufferGeometry();
    this._geometry.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(3 * capacity), 3));
    this._geometry.setAttribute('pScale', new THREE.Float32BufferAttribute(new Float32Array(capacity), 1));
    if (useColor) {
      this._geometry.setAttribute('color', new THREE.Float32BufferAttribute(new Float32Array(3 * capacity), 3));
    }
    this.points = new THREE.Points(this._geometry, material);

    // パーティクルの状態を管理する配列
    this._ages = new Array(this._capacity);
    this._lifes = new Array(this._capacity);
    this._alives = new Array(this._capacity);
    this._positions = new Array(this._capacity);
    this._scales = new Array(this._capacity);
    this._colors = useColor ? new Array(this._capacity) : null;
    for (let i = 0; i < this._capacity; ++i) {
      this._ages[i] = 0.0;
      this._lifes[i] = 0.0;
      this._alives[i] = false;
      this._positions[i] = new THREE.Vector3();
      this._scales[i] = 0.0;
      if (useColor) {
        this._colors[i] = new THREE.Color();
      }
    }
  }

  get capacity() {
    return this._capacity;
  }

  get useColor() {
    return this._useColor;
  }

  update(deltaSeconds) {
    let aliveCount = 0;
    let birthNum = this.birthRate * deltaSeconds;

    const output = {
      position: new THREE.Vector3(),
      scale: 0.0,
      color: this._useColor ? new THREE.Color() : null,
    };

    for (let i = 0; i < this._capacity; ++i) {
      if (this._alives[i]) {
        const age = this._ages[i];
        const life = this._lifes[i];
        const currentAge = age + deltaSeconds;
        if (currentAge <= life) {
          // まだ寿命に達してないパーティクルは更新する
          this._ages[i] = currentAge;

          this.updateParticle(output, {
            index: i,
            age: currentAge,
            life: life,
            position: this._positions[i],
            scale: this._scales[i],
            color: this._useColor ? this._colors[i] : null,
            useColor: this._useColor,
            deltaSeconds: deltaSeconds,
          });

          this._copyOutput(i, aliveCount, output);
          aliveCount += 1;
        } else {
          // 寿命に達したパーティクルは廃棄する
          this._alives[i] = false;
        }
      } else if (birthNum >= 1.0 || (birthNum > 0 && Math.random() <= birthNum)) {
        // このフレームで十分に新規パーティクルを生成していない場合はパーティクルを生成する
        birthNum -= 1.0;

        this._alives[i] = true;
        this._ages[i] = 0.0;
        this._lifes[i] = this.lifeExpectancy * (1.0 + (Math.random() * 2.0 - 1.0) * this.lifeVariance);

        this.createParticle(output, {
          index: i,
          useColor: this._useColor,
        });

        this._copyOutput(i, aliveCount, output);
        aliveCount += 1;
      }
    }

    // Pointsの配列を更新する
    this._geometry.setDrawRange(0, aliveCount);
    this._geometry.attributes.position.needsUpdate = true;
    this._geometry.attributes.pScale.needsUpdate = true;
    if (this._useColor) {
      this._geometry.attributes.color.needsUpdate = true;
    }
  }

  _copyOutput(index, aliveCount, output) {
    this._positions[index].copy(output.position);
    this._scales[index] = output.scale;
    this._geometry.attributes.position.array[aliveCount * 3] = output.position.x;
    this._geometry.attributes.position.array[aliveCount * 3 + 1] = output.position.y;
    this._geometry.attributes.position.array[aliveCount * 3 + 2] = output.position.z;
    this._geometry.attributes.pScale.array[aliveCount] = output.scale;
    if (this._useColor) {
      this._colors[index].copy(output.color);
      this._geometry.attributes.color.array[aliveCount * 3] = output.color.r;
      this._geometry.attributes.color.array[aliveCount * 3 + 1] = output.color.g;
      this._geometry.attributes.color.array[aliveCount * 3 + 2] = output.color.b;  
    }
  }

  // パーティクルの初期化はサブクラスで実装する
  createParticle(output, parameters) {
    console.error('This method must be implemented in subclass.');
  }

  // パーティクルの更新はサブクラスで実装する
  updateParticle(output, parameters) {
    console.error('This method must be implemented in subclass.');
  }
}
```

`Points`は`PointsMaterial`を組み合わせて使うことが多いですが、`PointsMaterial`は頂点ごとにスケールを設定することができません。以下のように`PointsMaterial`を拡張することで頂点ごとのスケールに対応させます。拡張方法は[この記事](https://biwanoie.tokyo/o2o1j/)を参考にしました。

```js
class ParticlePointsMaterial extends THREE.PointsMaterial {
  constructor(options) {
    super(options);
    this.onBeforeCompile = this._onBeforeCompile;
  }

  _onBeforeCompile(shader) {
    shader.vertexShader = shader.vertexShader.replace(
      '#include <color_pars_vertex>',
      `
      #include <color_pars_vertex>
      attribute float pScale;
      `,
    );

    shader.vertexShader = shader.vertexShader.replace(
      'gl_PointSize = size;',
      `
      gl_PointSize = size * pScale;
      `
    );
  }
}
```

`PointsParticleSystem`を継承したクラスを作ってみます。ここでは上に載せたような3次元グリッドに順々にパーティクルを生成するようなパーティクルシステムを作っています。`createParticle`と`updateParticle`でそれぞれ`output`引数にパーティクルの位置やスケール、色を設定します。

```js
// 単位球内に一様分布する乱数を生成する関数
function randomInSphere() {
  const cosTheta = -2.0 * Math.random() + 1.0;
  const sinTheta = Math.sqrt(1.0 - cosTheta * cosTheta);
  const phi = 2.0 * Math.PI * Math.random();
  const radius = Math.pow(Math.random(), 1.0 / 3.0);
  return new THREE.Vector3(radius * sinTheta * Math.cos(phi), radius * sinTheta * Math.sin(phi), radius * cosTheta);
}

class GridPointsParticleSystem extends PointsParticleSystem {
  constructor(material, capacity, options) {
    super(material, capacity, options);
    this._idCount = 0;

    this._gridSize = 40;
    this._gridSizeHalf = 0.5 * this._gridSize;
    this._gridDivision = 20;
    this._gridDivision2 = this._gridDivision * this._gridDivision;
    this._gridSpacing = this._gridSize / this._gridDivision;

    this._velocities = new Array(capacity);
    for (let i = 0; i < capacity; ++i) {
      this._velocities[i] = new THREE.Vector3();
    }

    this._velocity = new THREE.Vector3();
  }

  createParticle(output, parameters) {
    const id = this._idCount++;

    const x = id % this._gridDivision;
    const y = Math.floor(id / this._gridDivision2 % this._gridDivision);
    const z = Math.floor(id % this._gridDivision2 / this._gridDivision);

    output.position.set(
      x * this._gridSpacing - this._gridSizeHalf,
      y * this._gridSpacing - this._gridSizeHalf,
      z * this._gridSpacing - this._gridSizeHalf,
    );
    output.scale = 0.0;
    if (this.useColor) {
      output.color.setRGB(x / (this._gridSize - 1), y / (this._gridSize - 1), z / (this._gridSize - 1));
    }

    this._velocities[parameters.index].copy(randomInSphere());
  }

  updateParticle(output, parameters) {
    this._velocity.copy(this._velocities[parameters.index]);
    this._velocity.multiplyScalar(2.0 * parameters.deltaSeconds);
    output.position.copy(parameters.position);
    output.position.add(this._velocity);
    output.scale = Math.sin(Math.PI * parameters.age / parameters.life);
    if (this.useColor) {
      output.color.copy(parameters.color);
    }
  }
}
```

作成したパーティクルシステムは次のように使います。

```js
const material = new ParticlePointsMaterial({
  size: 2.0,
  vertexColors: true,
});
const particleSystem = new GridPointsParticleSystem(material, 10000, {
  birthRate: 500,
  lifeExpectancy: 3.0,
  lifeVariance: 0.5,
  useColor: true,
});
scene.add(particleSystem.points);

const clock = new THREE.Clock();
// 以下、レンダリングループ内
const deltaSeconds = Math.min(0.1, clock.getDelta());
particleSystem.update(deltaSeconds);
```