[JavaScript] Three.jsでパーティクルが消えない本当の原因と正しい後始末

はじめに

本日、花火のアニメーションを実装していた際、粒子のパーティクルを作成し上空で拡散して広がった後、メッシュを削除してもて消えないバグに直面。

AIに聞いても解決できなかったので、丸ごとTHREE.Group()を削除する強硬手段に出ようとしましたが、コードを読んるとバグに気づき修正できたので、再発防止も兼ねて備忘録メモとして残しておきます。

AIにコードは何度か見せてるのですが、それでも気づかないようで、AIも万能ではないし、相変わらず、人間側にコードを読むスキルが求められるようです。

1. 症状

  • 粒子が消えない
  • 不透明な状態では気づかないが、透明にすると上空に残る
  • update や dispose を呼んでも表示が消えない
  • scene.remove(mesh)group.remove(mesh) を試しても改善しない
  • 粒子数を減らしても症状は変わらない

2. 最初に疑ったこと(全部ハズレ)

最初は Three.js の描画や親子関係の問題を疑った。

  • scene.remove(mesh) しても消えない
  • group.remove(mesh) しても消えない
  • material.dispose() / geometry.dispose() も効かない
  • state = 'dead' で update を止めても残る
  • renderer.renderLists.dispose() まで試した

それでも、透明にした粒子が 上空に居座り続ける

ここまで来ると 「Three.js のバグでは?」 「粒子数が多すぎる?」 と考え始める。


3. 問題のコード(見た目は正しい)

問題の核心は、この一行だった。

this.particles = this.particles.filter((p) => p.life > 0);

JavaScript 的には自然で、ロジックも綺麗に見える。

  • 寿命が残っている粒子だけ残す
  • 寿命が尽きた粒子は配列から消す

一見、何も問題が無さそうに見える。


4. なぜこれがバグになるのか

filter がやっているのは 配列の更新だけ

Three.js 的に見ると、こうなる。

  1. 粒子の meshscene / group に add 済み
  2. filter によって 配列から参照だけが消える
  3. mesh 自体は scene に残り続ける
  4. update も dispose も二度と呼ばれない
  5. 結果、上空に「ゴースト粒子」が残る

つまり、

配列から消えた = Scene から消えた ではない


5. C言語っぽい挙動になる理由

感覚的には、C言語でこう書いたのと同じ。

Particle* p = malloc(sizeof(Particle));
array[i] = NULL; // ポインタだけ消した
// free(p) をしていない
  • 参照は消えた
  • 実体は残った
  • もう触れない
  • 片付け不能

Three.js は GC と手動解放が混ざった世界なので、 この罠にハマりやすい。


6. 正しい順序(重要)

Three.js で安全なのは、必ずこの順序。

Scene から remove
material / geometry を dispose
最後に配列から削除

7. 正しいコード例

this.particles = this.particles.filter((p) => {
  p.mesh.visible = true;
  p.velocity.y -= gravity * delta;
  p.mesh.position.addScaledVector(p.velocity, delta);
  p.life -= delta;

  if (p.life <= 0) {
    if (p.mesh.parent) p.mesh.parent.remove(p.mesh);
    p.mesh.material.dispose();
    p.mesh.geometry.dispose();
    return false;
  }
  return true;
});

これで、

  • 見た目も消える
  • Scene にも残らない
  • GPU リソースも解放される

8. 花火エフェクトなら別解もある

粒子を個別に消さず、

  • 全粒子の寿命が尽きたら
  • Firework ごと dispose

という設計でも成立する。

if (this.particles.every(p => p.life <= 0)) {
  this.dispose(); // group を丸ごと remove
}

短命エフェクトでは、この方が安全な場合も多い。


9. まとめとして残したい一文

Three.js では
配列管理と Scene 管理は別物。
Scene に add した Mesh は、
remove してから参照を切れ。


このまま出しても十分記事になる内容。
忘れた頃に読んだ自分も、たぶん助かる。