Visual Effects & Masks
YAGE wraps PixiJS filters and masks behind a consistent handle-based API
that works identically at four scopes — per-component, per-layer,
per-scene, per-screen. Pre-built effect presets ship in
@yagejs/effects. Custom shaders are still one rawFilter(...) away.
The handle pattern
Section titled “The handle pattern”Every fx.addEffect(...) call returns a typed EffectHandle you keep around
to control the effect later. Components, layers, scenes, and the renderer
all expose effects through the same .fx property:
import { hitFlash } from "@yagejs/effects";
const flash = sprite.fx.addEffect(hitFlash({ color: 0xffffff, duration: 100 }));flash.trigger(); // one-shot ramp up + downflash.fadeOut(200); // returns a Processflash.setEnabled(false); // toggle without removingflash.remove(); // detach + destroyThe base handle is shared across every preset:
| Method | Description |
|---|---|
remove() | Detach + destroy. Idempotent. |
setEnabled(on) | Toggle the underlying filter’s enabled flag without removing. |
enabled | Current enabled state (live). |
fadeIn(ms) | Tween primary intensity 0 → 1. Returns a Process. |
fadeOut(ms) | Tween primary intensity → 0. Returns a Process. |
run(p) | Schedule a Process scoped to this effect — pauses with the owning scene, time-scales with it, auto-cancels on remove(). Use for custom uniform tweens. |
Each preset extends the base with its own knobs — OutlineHandle.setThickness(n),
HitFlashHandle.trigger(), ColorGradeHandle.setPreset("sepia"), etc.
Four scopes
Section titled “Four scopes”// 1. Component scope — sprite / graphics / text / animatedSprite.sprite.fx.addEffect(hitFlash({ ... }));
// 2. Layer scope — every entity in this layer.this.use(SceneRenderTreeKey).get("world").fx.addEffect(bloom({ ... }));
// 3. Scene scope — the entire per-scene root.this.use(SceneRenderTreeKey).fx.addEffect(crt({ ... }));
// 4. Screen scope — cross-scene, on app.stage.this.use(RendererKey).fx.addEffect(vignette({ ... }));Pixi processes filters bottom-up the display tree, so an outer scope
sees the previous scope’s rasterized output. A screen-scope pixelate
will pixelate already-bloomed gameplay, not the raw pre-bloom pixels.
Layer / scene / screen filters operate on screen-space pixels post-camera-transform — a bloom radius is in screen pixels, not world units. Plan accordingly when zooming.
Lifecycle
Section titled “Lifecycle”| Scope | Cleaned up when | Fades pause with |
|---|---|---|
| Component | The owning component is destroyed (or its entity) | The entity’s scene |
| Layer | The owning scene exits | The owning scene |
| Scene | The owning scene exits | The owning scene |
| Screen | RendererPlugin.onDestroy (engine teardown) | Never — fades run in engine time |
You don’t have to manually remove() effects on scene exit; the renderer
tears down each scope’s EffectStack for you.
Hero presets
Section titled “Hero presets”Eighteen presets ship in @yagejs/effects, all save-aware and all using
the same handle protocol:
import { // Originals hitFlash, bloom, outline, dropShadow, pixelate, glow, crt, chromaticAberration, vignette, colorGrade, // Added in 0.5.x godRay, shockwave, motionBlur, oldFilm, bulgePinch, halftone, wave, // Added in 0.6.x colorize,} from "@yagejs/effects";| Preset | One-line | Drives getIntensity |
|---|---|---|
hitFlash | Damage flash with trigger() | additive tint amount |
bloom | Soft glow on bright pixels | bloom scale |
outline | Hard-edge outline | thickness |
dropShadow | Drop shadow with offset + blur | shadow alpha |
pixelate | Block pixelation | pixel size (clamped to ≥1) |
glow | Outer + inner glow halo | scales BOTH strengths together |
crt | Scanlines, curvature, noise | filter alpha (whole effect) |
chromaticAberration | RGB channel separation | symmetric separation |
vignette | Edge darkening | vignette alpha |
colorGrade | Sepia / grayscale / negative / night / warm / cool | filter alpha |
godRay | Animated volumetric light rays (self-scheduling time) | gain (rays scale 0 → full) |
shockwave | One-shot ripple at (x, y) via trigger() | amplitude × brightness (parked off until trigger) |
motionBlur | Directional smear along a velocity vector | velocity magnitude |
oldFilm | Sepia + grain + scratches + animated seed | filter alpha |
bulgePinch | Lens distortion — sign-preserving (negative pinches, positive bulges) | strength (sign preserved) |
halftone | Comic-print dot grid via custom WebGL+WGSL shader pair | amount (cross-fades back to source) |
wave | Sinusoidal x-displacement via custom shader; scene-time animator | configured amplitude |
colorize | Luminance-to-colour recolour via custom WebGL+WGSL shader pair — the replace-style alternative to sprite.tint’s multiply | strength (cross-fades back to source) |
setIntensity doubles as the “how strong is this effect right now” dial —
use it for fades AND for gameplay-driven scaling (HP-linked tinting,
heartbeat glow, breathing vignette). The set* setters that change a
preset’s “full” value (bloom.setBloomScale, glow.setOuterStrength,
outline.setThickness, godRay.setGain, motionBlur.setVelocity,
bulgePinch.setStrength, halftone.setAmount, wave.setAmplitude,
colorize.setStrength, etc.) preserve the current intensity ratio, so
adjusting the ceiling mid-pulse raises the pulse height instead of
snapping the visible effect back to 1.
Most presets wrap either a built-in ColorMatrixFilter or a pixi-filters
filter and expose typed extras specific to it. Three — halftone, wave,
and colorize — ship as custom WebGL + WGSL shader pairs with no
pixi-filters dep, which makes them the smallest reference implementations
to copy when authoring an effect from scratch.
Replace-style recolour (colorize)
Section titled “Replace-style recolour (colorize)”sprite.tint is the cheap built-in way to tint a sprite, but it
multiplies the source RGB by the tint colour. That works for
darken/desaturate/multiply-with-a-colour effects, but on saturated source
art it produces muddy mid-axis colours — a blue mushroom × a yellow tint
reads as olive, not yellow. colorize is the replace-style answer:
black stays black, white reaches the target colour, midtones blend
proportionally, source alpha is preserved unchanged.
import { colorize } from "@yagejs/effects";
const recolour = sprite.fx.addEffect(colorize({ color: 0xf2c14e }));recolour.setColor("#d94a4a"); // numbers or strings ("#…", "red", "rgb(…)")recolour.setStrength(0.6); // rebases full ceiling; preserves ratiorecolour.fadeOut(200); // strength → 0 cross-fades back to the sourceOutput per pixel is mix(sourceRGB, tintColor * L, strength) where L
is Rec. 601 luminance (0.299·R + 0.587·G + 0.114·B). Pick colorize
over sprite.tint whenever the source art has saturated colours and you
want the recolour to read as the target hue rather than the multiply of
the two.
Triggerable + animated presets
Section titled “Triggerable + animated presets”A few presets behave differently from the steady-state pattern:
// shockwave — parks at "off" until you call trigger(); re-trigger cancels// any in-flight ramp.const sw = scene.fx.addEffect(shockwave({ amplitude: 40 }));sw.trigger(heroX, heroY);
// godRay / oldFilm / wave self-advance their internal time uniform from// scene time, so they keep animating without any caller wiring. Pause// with the scene; time-scale with it.scene.fx.addEffect(godRay({ angle: 30, gain: 0.5 }));scene.fx.addEffect(oldFilm({ sepia: 0.4, noise: 0.4 }));layer.fx.addEffect(wave({ amplitude: 6, speed: 2 }));
// motionBlur — directional. Coerces invalid kernel sizes (must be odd// and ≥ 5) up to the nearest valid value with a one-shot warning.const mb = sprite.fx.addEffect(motionBlur({ velocity: { x: 30, y: 0 } }));mb.setVelocity(50, 12);
// bulgePinch.center is normalized 0..1 screen coords (resolution-independent).const bp = scene.fx.addEffect(bulgePinch({ strength: 1, radius: 200 }));bp.setStrength(-0.8); // flips bulge → pinch; intensity ratio preservedbp.setCenter(0.5, 0.5);Pick the right scope for each preset
Section titled “Pick the right scope for each preset”Three of the new presets read best at scene scope (or higher) rather than on a single component:
godRay— its alpha-aware fragment shader treats fully transparent host pixels as black, so on a per-component sprite the rays render against a black box. At scene scope the layer rasterizes alpha=1 across the visible area and the rays blend into the world as intended.bulgePinch— distortion samples outside the host’s bounding rect, so a sprite-scoped bulge clips at the sprite edges. Apply at scene/layer scope so the lens has room to bend pixels around itsradius.shockwave— the ring expands outward fromcenterand is naturally clipped at the host’s bounds, so a component-scoped shockwave on a small sprite reads as a tiny “bump” rather than a ring. Scene scope makestrigger(heroX, heroY)line up with the entity’s transform.
examples/src/effects-showcase.ts wires each of these at the recommended
scope — copy that as the worked-out reference.
Custom filters via rawFilter
Section titled “Custom filters via rawFilter”For one-off shaders or pixi-filters imports the engine doesn’t wrap, use
rawFilter:
import { BlurFilter } from "pixi.js";import { rawFilter } from "@yagejs/renderer";
const blur = new BlurFilter({ strength: 8 });const handle = sprite.fx.addEffect( rawFilter(blur, { intensity: { get: () => blur.strength, set: (v) => { blur.strength = v * 8; }, }, }),);handle.fadeIn(200);Without the intensity accessor, fadeIn / fadeOut no-op and warn once.
rawFilter entries are NOT serializable — the user-supplied Filter has
no string identity to record in a snapshot. Use defineEffect for any
effect you want save/load support on.
Custom presets via defineEffect
Section titled “Custom presets via defineEffect”import { defineEffect } from "@yagejs/renderer";import type { EffectHandle } from "@yagejs/renderer";import { ShockwaveFilter } from "pixi-filters";
interface ShockOptions { strength: number; }
export const shock = defineEffect<EffectHandle, ShockOptions>({ name: "myGame:shockwave", factory: (opts) => { const filter = new ShockwaveFilter({ amplitude: opts.strength }); return { filter, getIntensity: () => filter.amplitude / opts.strength, setIntensity: (v) => { filter.amplitude = opts.strength * v; }, }; },});The returned shock is callable like any preset: sprite.fx.addEffect(shock({ strength: 30 })).
Masks are exclusive (one per container) and use a parallel API:
import { rectMask, graphicsMask } from "@yagejs/renderer";
const handle = sprite.setMask(rectMask({ x: 0, y: 0, width: 200, height: 200 }));handle.setInverse(true);sprite.clearMask();
// Layer + scene scope share the same shape.tree.get("hud").setMask(graphicsMask((g) => { g.clear(); // pixi commands accumulate g.circle(layout.x, layout.y, 100).fill(0xffffff); // read live state, don't snapshot}));// After layout changes, call .redraw() to re-run the closure.Two gotchas inside a graphicsMask closure:
g.clear()first. PixiGraphicscommands accumulate; withoutclear(), everyredraw()layers another shape on top of the previous one.- Read live state, don’t snapshot it. JS closures capture variable
bindings, not values.
const w = panel.widthfollowed byg.rect(0, 0, w, ...)will keep using the originalwafterredraw(). Reference the live source (panel.width,this.layout.width, a getter) inside the closure so each call picks up the current value.
Three factories ship today: rectMask (savable), spriteMask (caller owns
the sprite — not savable), graphicsMask (custom drawn — closure can’t be
saved). For savable custom shapes, register one with defineMask.
Save / load
Section titled “Save / load”Effects + masks built from defineEffect / defineMask factories survive
SnapshotService.saveSnapshot round-trips automatically:
- Component scope — captured inside each visual component’s
serialize()and restored inafterRestore. - Layer / Scene / Screen scope — the renderer registers a snapshot
contributor with
SnapshotService(key"renderer") at engine start, soRendererPluginandSnapshotPlugincan be registered in any order. - In-flight fades are not preserved — only steady-state intensity +
enabled. Trigger any fade-in animations from your scene’s
onEnterorafterRestoreif you want them to play again on load. - Unsavable entries (
rawFilter,spriteMask,graphicsMask) skip the snapshot with a one-shot warning.
@yagejs/save is an optional peer dep — without it, component-scope
effects still serialize, but the layer/scene/screen contributor doesn’t
register.