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.
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:
// 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
sizeAttenuationgive 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 fromrenderUpdate(dt).