Vol.8
Particles via Points + shaders, lightning via object pools — the full-pool waste we found in Chidori
ParticleEmitter: only four attributes go to the GPU
Position, velocity, and lifetime are simulated in CPU TypedArrays; the GPU only receives position / aSize / aAlpha / aColor as buffer attributes. Point size is aSize * uPointScale (400) / depth in the vertex shader, and one round-dot texture (a 64px radial gradient) is shared by every emitter. The live count is base count × quality multiplier, capped by the Vol.7 table.
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;Chidori: from a glowing ball to an irregular mass of lightning
Chidori was rebuilt on 2026-09-22. The old look was a glowing ball; the commit says “irregular lightning mass, not a glowing sphere”. The lightning is built from four pools — major arcs, micro arcs, debris, streaks — each preallocated to its cap and reused.
The same day, the big outer bolts went from “always maintain 6–9” to “up to about 3, only during short time-based accent windows”. The core crackles constantly while the big outer bolts become an occasional accent — the look gets cleaner and fewer bolts exist at once. Accent timing is dt-driven, so it is FPS-independent.
Where we got stuck: rescanning full pools every frame
Bolt spawning adds rate × dt to an accumulator and spawns one per whole unit. Before the fix, each loop iteration counted active bolts with reduce over the whole pool and searched for a free slot with find. With a full pool spawning fails, but the accumulator kept gaining rate × dt every frame, and every unit triggered another reduce and find across the pool. Nothing visible happened, yet the moments with the most lightning — a full pool — were exactly when CPU was wasted most.
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}Clamping the accumulator at 0.999 has a second purpose: without it, whatever accumulated while full would burst out the instant slots free up, and bolts would all appear at once.
What building it taught us
- Masses of identical points go to Points + shaders; things that each have their own shape and lifetime go to object pools. Do not force one mechanism onto everything.
- Always check what happens when the pool is full. Spawns just fail and the visuals look fine, so wasted scans go unnoticed.
- Visual cleanup and cost cuts can be the same change. Moving outer bolts from constant to accent reduced both visual noise and concurrent count.