---
name: vfx-and-particles
description: Use when adding particle effects or visual FX to a WebXR game — impact bursts, sparks, pickups, trails, muzzle flashes. Provides a pooled, instanced particle system that billboards correctly in stereo, reads well at VR scale, and stays within the per-eye frame budget. Excludes full-screen flashes (use vr-polish-checklist for why those are unsafe in VR).
---

# VFX and particles — impact you can see in stereo, cheap enough for VR

Particles sell impact: a burst on every hit, sparks on a ricochet, a trail behind a thrown object. In VR there are three constraints flat games ignore. Particles are viewed from any angle and at arm's length, so flat quads must **billboard** toward each eye or they vanish edge-on. Everything renders **twice**, so naïve per-particle meshes blow the budget — use **instancing or `Points`**. And **full-screen flashes are off-limits** in VR (they fill the player's whole field of view and can trigger discomfort) — keep effects local to the world.

**Core principle:** pool + instance, billboard for stereo, keep it in the world (never full-screen).

## When to use

- Impact bursts, sparks, pickups, trails, muzzle flashes, ambient motes.
- Symptoms: particles disappear when viewed edge-on, frame rate drops when many spawn, GC stutter from per-frame allocation.

**What this is NOT for:** screen-space flashes, vignette tints, post-processing — see `vr-polish-checklist` (full-screen effects cause discomfort in VR; use local emissive flashes and haptics instead).

## Use Points for sparks/dust, InstancedMesh for chunky bits

`THREE.Points` is the cheapest many-particle option and its material billboards (camera-facing) for free — ideal for sparks, dust, smoke motes. `InstancedMesh` is for 3D debris (cubes, shards) where you want real geometry.

### Pooled Points burst

Pre-allocate one buffer and recycle — never create/destroy systems per effect.

```ts
import * as THREE from 'three';

const MAX = 256;
const positions = new Float32Array(MAX * 3);
const velocities = new Float32Array(MAX * 3);
const life = new Float32Array(MAX);        // seconds remaining; 0 = dead
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));

const sparks = new THREE.Points(
  geom,
  // sizeAttenuation makes near sparks bigger — correct depth cue at VR scale.
  new THREE.PointsMaterial({ color: 0xffcc66, size: 0.03, sizeAttenuation: true, transparent: true, depthWrite: false }),
);
sparks.frustumCulled = false; // particles move outside the original bounds
scene.add(sparks);

let cursor = 0;
const _v = new THREE.Vector3();
function burst(at: THREE.Vector3, count = 24, speed = 2) {
  for (let i = 0; i < count; i++) {
    const p = cursor; cursor = (cursor + 1) % MAX;
    positions[p * 3] = at.x; positions[p * 3 + 1] = at.y; positions[p * 3 + 2] = at.z;
    _v.set(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5).normalize().multiplyScalar(speed * (0.5 + Math.random()));
    velocities[p * 3] = _v.x; velocities[p * 3 + 1] = _v.y; velocities[p * 3 + 2] = _v.z;
    life[p] = 0.5 + Math.random() * 0.3;
  }
}

// Call from renderUpdate(dt) — see game-loop.
function updateSparks(dt: number) {
  for (let i = 0; i < MAX; i++) {
    if (life[i] <= 0) continue;
    life[i] -= dt;
    velocities[i * 3 + 1] -= 4 * dt;            // gravity
    positions[i * 3] += velocities[i * 3] * dt;
    positions[i * 3 + 1] += velocities[i * 3 + 1] * dt;
    positions[i * 3 + 2] += velocities[i * 3 + 2] * dt;
    if (life[i] <= 0) positions[i * 3 + 1] = -9999; // park dead particles out of view
  }
  geom.attributes.position.needsUpdate = true;
}
```

Trigger `burst(hitPoint)` on impacts/pickups; the system is reused for the whole game.

### Billboarding 3D debris

`Points` always face the camera. If you use textured quads (`PlaneGeometry`) for things like smoke puffs, make them face each eye — otherwise they're invisible side-on:

```ts
// Per frame, orient quads toward the active (XR) camera.
const cam = renderer.xr.getCamera();
for (const quad of smokeQuads) quad.quaternion.copy(cam.quaternion);
```

`renderer.xr.getCamera()` gives the stereo rig orientation, so a single billboard quaternion is correct enough for both eyes at arm's length.

## Trails

Emit one particle per frame from a moving object's tail position with a short life and fast fade — reuse the same pooled system. Don't allocate a new mesh per trail segment.

## Domain principles honored here

- **Performance is comfort** — pooling + `Points`/instancing keeps particle cost flat across both eyes; no per-frame allocation, no GC stutter.
- **Diegetic** — effects live in the world at the impact point, never as a screen overlay.
- **1:1 scale** — particle sizes (cm) and `sizeAttenuation` give correct depth cues at real-world distances.

## Common mistakes

| Mistake | Symptom | Fix |
| ------- | ------- | --- |
| New geometry/material per effect | GC stutter, frame drops | pool a fixed buffer, recycle |
| One `Mesh` per particle | Draw-call explosion (×2 eyes) | `Points` or `InstancedMesh` |
| Flat quads not billboarded | Particles vanish edge-on | `Points`, or copy `xr.getCamera()` quaternion |
| `sizeAttenuation: false` | No depth cue; wrong scale in VR | enable attenuation, size in meters |
| Full-screen flash for impact | Discomfort, fills whole FOV | local emissive flash + haptics (`vr-polish-checklist`) |
| `frustumCulled` left on | Particles pop out when origin leaves view | set `frustumCulled = false` |

## Verify

- [ ] **On-device:** an impact burst is visible from any angle and at arm's length (billboards correctly).
- [ ] Near particles look bigger than far ones (`sizeAttenuation`).
- [ ] Spawning many bursts doesn't drop the frame rate (pooled, instanced).
- [ ] No GC hitching during sustained effects (no per-frame allocation).
- [ ] No full-screen flash is used for impact feedback.

## References

- `vr-polish-checklist` — which feedback is VR-safe; why full-screen flashes are out.
- `vr-performance-budget` — keeping particle cost within the per-eye budget.
- `game-loop` — drive particle updates from `renderUpdate(dt)`.
