Anime AR Stock

Vol.8

Particles via Points + shaders, lightning via object pools — the full-pool waste we found in Chidori

  • particles
  • WebGL
  • object pool
React treenever 1 node per particle
CPU simulationTypedArray
BufferAttributepos / size / color
THREE.PointsShaderMaterial
Fig. 8. Simulation stays in CPU TypedArrays. The GPU only gets position, size, color, alpha.

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.

particles/ParticleEmitter.ts
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.

Before
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}
After
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.