Skip to content

Visual Effects

Web Engine Dev's VFX tools span three overlapping systems: the particle system (@web-engine-dev/particles) for CPU-driven particle effects, the VFX graph (@web-engine-dev/vfx) for GPU-driven high-volume effects, and the post-processing stack (integrated in @web-engine-dev/renderer) for screen-space effects.

Particle System

One-Shot Burst Effects

typescript
import { ParticleSystemResource, type ParticleEmitterConfig } from '@web-engine-dev/particles';

// Define an explosion emitter config
const explosionConfig: ParticleEmitterConfig = {
  maxParticles: 80,
  emission: {
    rateOverTime: 0, // 0 = burst only
    bursts: [{ time: 0, count: 80 }],
  },
  lifetime: { min: 0.4, max: 0.8 },
  startSpeed: { min: 100, max: 400 },
  startSize: { min: 4, max: 16 },
  startColor: {
    colorStops: [
      { time: 0, color: { r: 1, g: 0.55, b: 0, a: 1 } },
      { time: 1, color: { r: 1, g: 0.13, b: 0, a: 0 } },
    ],
  },
  gravityModifier: 2,
  renderer: { renderMode: 'billboard', blendMode: 'additive', sortMode: 'none' },
  // Texture is bound at render time via ParticleSystem.render(); see GPU renderer docs.
  looping: false,
};

function SpawnExplosionSystem(world: World): void {
  const particleSystem = world.getResource(ParticleSystemResource);
  for (const { position } of world.eventReader(ExplosionEvent).read()) {
    const emitter = particleSystem.createEmitter(explosionConfig);
    emitter.position = position;
    emitter.play();
  }
}

Looping Effects (Attached to Entity)

typescript
import { ParticleSystemResource, type ParticleEmitterConfig } from '@web-engine-dev/particles';

// Fire effect config
const fireConfig: ParticleEmitterConfig = {
  maxParticles: 200,
  emission: { rateOverTime: 60 },
  lifetime: { min: 0.3, max: 0.6 },
  startSpeed: { min: 30, max: 80 },
  startSize: { min: 8, max: 20 },
  shape: { type: 'cone', angle: 15, radius: 0.1 }, // emission cone
  startColor: {
    colorStops: [
      { time: 0, color: { r: 1, g: 0.87, b: 0, a: 1 } },
      { time: 1, color: { r: 1, g: 0.4, b: 0, a: 0 } },
    ],
  },
  noise: { strength: 20, frequency: 2 },
  renderer: { renderMode: 'billboard', blendMode: 'additive', sortMode: 'none' },
  // Texture is bound at render time via ParticleSystem.render(); see GPU renderer docs.
  looping: true,
};

// Create a fire emitter and follow the entity's transform each frame
function attachFireEmitter(world: World, torchEntity: Entity): ParticleEmitter {
  const particleSystem = world.getResource(ParticleSystemResource);
  const fireEmitter = particleSystem.createEmitter(fireConfig);
  fireEmitter.play();
  return fireEmitter;
}

// In an update system, sync emitter position to the torch entity
function FireEmitterUpdateSystem(world: World): void {
  const torchTransform = world.get(torchEntity, Transform);
  fireEmitter.position = torchTransform.position;
}

Particle Module System

Compose effects by stacking modules on a ParticleEmitterConfig:

typescript
import { type ParticleEmitterConfig } from '@web-engine-dev/particles';

const bloodSplashConfig: ParticleEmitterConfig = {
  maxParticles: 80,
  emission: { rateOverTime: 0, bursts: [{ time: 0, count: { min: 20, max: 40 } }] },
  lifetime: { min: 0.5, max: 1.5 },
  startSpeed: { min: 50, max: 250 },
  shape: { type: 'hemisphere', radius: 0.1 },
  velocityOverLifetime: {
    linear: { x: 0, y: -6, z: 0 }, // gravity-like deceleration
  },
  colorOverLifetime: {
    color: {
      colorStops: [
        { time: 0, color: { r: 0.8, g: 0, b: 0, a: 1 } },
        { time: 0.5, color: { r: 0.53, g: 0, b: 0, a: 0.8 } },
        { time: 1, color: { r: 0.27, g: 0, b: 0, a: 0 } },
      ],
    },
  },
  sizeOverLifetime: {
    size: {
      points: [
        { time: 0, value: 1 },
        { time: 1, value: 0.2 },
      ],
    },
  },
  collision: { enabled: true, type: 'planes', bounce: 0.2, lifetimeLoss: 0.1 },
  renderer: { renderMode: 'billboard', blendMode: 'alpha', sortMode: 'distance' },
  // Texture is bound at render time.
  looping: false,
};

VFX Graph (GPU-Driven)

For high-volume effects (thousands of particles), use the GPU-driven VFX graph:

typescript
import { VFXManagerResource, EffectGraph, createColorAdjustmentGraph } from '@web-engine-dev/vfx';

const vfx = world.getResource(VFXManagerResource);

// Build an effect graph programmatically
const auraEffect = createColorAdjustmentGraph();

// Register and play the graph via the VFX manager
vfx.effectGraphs.set('magic-aura', auraEffect);
auraEffect.play();

Post-Processing Stack

Post-processing effects run on the final rendered frame:

typescript
// NOTE: PostProcessingStack is not yet a standalone exported class.
// Post-process settings are configured via the renderer device at setup time
// using PostProcessSettings from @web-engine-dev/renderer.
// The example below shows the intended API structure for reference.

const pp = world.getResource(PostProcessingStack); // TODO: verify descriptor

// Configure the post-processing pipeline
pp.configure([
  {
    type: 'bloom',
    threshold: 0.9,
    intensity: 0.4,
    scatter: 0.7,
    tint: [1, 0.95, 0.8],
  },
  {
    type: 'colorGrading',
    temperature: 0, // -100 (cool) to +100 (warm)
    tint: 0,
    saturation: 1.1,
    contrast: 1.05,
    gamma: [1, 1, 1],
    liftGamma: 0.95, // shadow lightness
    lut: 'luts/cinematic.png', // optional color LUT
  },
  {
    type: 'vignette',
    intensity: 0.4,
    smoothness: 0.4,
    color: [0, 0, 0],
  },
  {
    type: 'filmGrain',
    intensity: 0.05,
    luminanceContribution: 0.8,
  },
]);

// Enable/disable effects at runtime
pp.setEnabled('bloom', false); // disable for performance on mobile

Dynamic Post-Processing (Combat Feedback)

typescript
function CombatVFXSystem(world: World): void {
  const pp = world.getResource(PostProcessingStack); // TODO: verify resource descriptor
  const tween = world.getResource(TweenManager); // TODO: TweenManagerResource not found; verify

  for (const event of world.eventReader(PlayerDamagedEvent).read()) {
    // Red flash on damage
    tween
      .to(pp.getEffect('colorGrading'), 'saturation', 0, 0.05)
      .then(() => tween.to(pp.getEffect('colorGrading'), 'saturation', 1.1, 0.3));

    // Vignette pulse
    tween
      .to(pp.getEffect('vignette'), 'intensity', 0.8, 0.05)
      .then(() => tween.to(pp.getEffect('vignette'), 'intensity', 0.4, 0.4));
  }

  // Low health: desaturate and pulse vignette
  const health = world.getResource(PlayerHealthResource);
  if (health.normalized < 0.25) {
    const pulse = Math.sin(Date.now() * 0.003) * 0.5 + 0.5;
    pp.setParam('vignette', 'intensity', 0.5 + pulse * 0.3);
    pp.setParam('colorGrading', 'saturation', 0.3 + pulse * 0.2);
  }
}

Screen Effects

Quick screen-space effects via the renderer:

typescript
import { ScreenEffectsResource } from '@web-engine-dev/vfx';

const fx = world.getResource(ScreenEffectsResource);

// Flash (hit feedback)
fx.flash({ color: { r: 1, g: 0, b: 0, a: 0.3 } });

// Screen shake (explosion)
fx.shake({ intensity: 12, duration: 0.3 });

// Radial blur (speed boost) — intensity: 0–1
fx.setRadialBlur({ intensity: 0.3, center: { x: 0.5, y: 0.5 } });

// Vignette (low-health tension)
fx.setVignette({ intensity: 0.5, smoothness: 0.4 });

// Chromatic aberration (glitch)
fx.setChromaticAberration({
  intensity: 0.02,
  redOffset: { x: -0.01, y: 0 },
  blueOffset: { x: 0.01, y: 0 },
});

Hit Effects & Decals

typescript
import { DecalSystemResource, ParticleSystemResource } from '@web-engine-dev/vfx';

// Spawn a bullet hole decal on a surface
function BulletImpactSystem(world: World): void {
  const decals = world.getResource(DecalSystemResource);
  const particleSystem = world.getResource(ParticleSystemResource);

  for (const hit of world.eventReader(BulletHitEvent).read()) {
    // Spawn particles
    const impactEmitter = particleSystem.createEmitter(bulletImpactConfig);
    impactEmitter.position = hit.position;
    impactEmitter.play();

    // Place a permanent decal on the surface
    // spawn(position, normal, partialConfig)
    decals.spawn(hit.position, hit.normal, {
      lifetime: 30, // seconds before fade-out
    });
  }
}

Tweens for Visual Feedback

Use the @web-engine-dev/tween package for smooth animated feedback:

typescript
import { TweenManager } from '@web-engine-dev/tween';

const tween = world.getResource(TweenManager); // TODO: TweenManagerResource not found; verify resource descriptor

// Scale pop on item pickup
tween.sequence(pickupIconEntity, [
  { property: 'scale', target: { x: 1.5, y: 1.5 }, duration: 0.1, easing: 'easeOut' },
  { property: 'scale', target: { x: 1.0, y: 1.0 }, duration: 0.2, easing: 'easeIn' },
]);

// Damage number float and fade
tween.to(damageNumberEntity, {
  position: { y: '-40' }, // relative offset
  alpha: 0,
  duration: 1.0,
  easing: 'easeOut',
  onComplete: () => world.commands().despawn(damageNumberEntity),
});

Next Steps

  • Animation, trigger effects from animation frame events
  • Audio, sync particle effects with sound
  • Performance, particle culling and LOD

Proprietary software. All rights reserved.