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 mobileDynamic 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