Vol.8
粒子は Points + Shader、雷はオブジェクトプール — 千鳥で見つけた満杯プールの無駄
ParticleEmitter: GPU に渡すのは 4 属性だけ
位置・速度・寿命は CPU 側の TypedArray でシミュレーションし、GPU には position / aSize / aAlpha / aColor だけを BufferAttribute として渡します。点のサイズは頂点シェーダで aSize * uPointScale(400)/ 奥行き として計算し、丸い点のテクスチャ(64px の放射グラデーション)は全エミッタで共有します。実際に出す粒子数は「基本数 × 品質の倍率」を Vol.7 の上限で切ります。
1attribute float aSize;2attribute float aAlpha;3attribute vec3 aColor;4uniform float uPointScale; // 4005void main() {6 vec4 mv = modelViewMatrix * vec4(position, 1.0);7 gl_Position = projectionMatrix * mv;8 gl_PointSize = aSize * uPointScale / max(-mv.z, 0.02);9}10 11// CPU 側: 毎フレーム書き換えたら needsUpdate だけ立てる12geometry.getAttribute('position').needsUpdate = true;13geometry.getAttribute('aAlpha').needsUpdate = true;千鳥: 光る球から「不規則な雷の塊」へ
千鳥は 2026-09-22 に作り直しました。それまでの見た目は「光る球」で、コミットには「irregular lightning mass, not a glowing sphere」と書いています。雷は主雷(major arc)・細かい放電(micro arc)・破片(debris)・筋(streak)の 4 種類のプールで構成し、それぞれ上限数の配列を最初に確保して使い回します。
同じ日に、外側の大きな雷を「常に 6〜9 本出し続ける」方式から、時間ベースの短いアクセント区間にだけ最大 3 本程度出す方式に変えています。中心は常にパチパチと鳴り続け、外側の大きな雷はときどき走るアクセントになるので、見た目の情報量が整理されると同時に、同時に存在する雷の数も減りました。アクセントのタイミングは dt で決めているので FPS に依存しません。
ハマった点: 満杯のプールを毎フレーム数え直していた
雷の生成は「生成レート × dt をアキュムレータに足し、1 を超えた分だけ生成する」方式です。修正前は、ループの中で毎回 reduce でプール全体の稼働数を数え、空きを find で探していました。プールが満杯だと生成は失敗しますが、アキュムレータには毎フレーム rate × dt が足され続け、そのたびに reduce と find がプール全体を走査していました。見た目には何も起きないのに、雷が最も多い(=プールが満杯の)瞬間ほど CPU を無駄に使う構造です。
1majorSpawnAccumulator += majorRate * dt;2while (majorSpawnAccumulator >= 1) {3 majorSpawnAccumulator -= 1;4 const activeCount = majorArcs.reduce((n, a) => n + (a.active ? 1 : 0), 0); // 毎回全走査5 if (activeCount < majorMaxActive) {6 spawnMajorArc(majorBias, majorLengthScale); // 内部で majorArcs.find((a) => !a.active)7 }8}1// 空きスロットは free-list で O(1)。稼働数はカウンタで持つ2const majorFree = Array.from({ length: cfg.majorArcPoolSize }, (_, i) => i);3const freeIndex = majorFree.pop();4if (freeIndex === undefined) return false;5 6// アキュムレータに上限。満杯なら 1 未満に丸めて、満杯中の空振りループを止める7majorSpawnAccumulator = Math.min(majorSpawnAccumulator + majorRate * dt, Math.max(1, majorMaxActive));8while (majorSpawnAccumulator >= 1 && majorActiveCount < majorMaxActive) {9 majorSpawnAccumulator -= 1;10 if (!spawnMajorArc(/* ... */)) break;11}12if (majorActiveCount >= majorMaxActive) {13 majorSpawnAccumulator = Math.min(majorSpawnAccumulator, 0.999);14}アキュムレータを 0.999 で止めるのには、もう一つ理由があります。上限が無いと、満杯の間に溜まった分が空きが出た瞬間にまとめて吐き出され、雷が一斉に湧く不自然な見た目になるからです。
実装から得た知見
- 形が同じ大量の点は Points + シェーダ、1 本ずつ形と寿命が違うものはオブジェクトプール。全部を 1 つの仕組みに寄せようとしない。
- プールが満杯のときに何が起きるかを必ず確認する。生成に失敗するだけで見た目は正常なので、無駄な走査は気づかれにくい。
- 見た目の整理とコスト削減は同時に起きることがある。外側の雷を「常時」から「アクセント」に変えたら、情報量も同時存在数も減った。