VR particles and effects

Create pooled, instanced effects that read correctly in stereo and fit a headset frame budget.

By pavlin-dev

Use this skill

Three.js and TypeScript; headset support varies by WebXR browser.

Add the skill folder in Settings. The in-app library is coming in the next desktop update.

The skill

The source instructions, with their original wording. Read as Markdown.

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 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).