Skip to content

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.

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 + down
flash.fadeOut(200); // returns a Process
flash.setEnabled(false); // toggle without removing
flash.remove(); // detach + destroy

The base handle is shared across every preset:

MethodDescription
remove()Detach + destroy. Idempotent.
setEnabled(on)Toggle the underlying filter’s enabled flag without removing.
enabledCurrent 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.

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

ScopeCleaned up whenFades pause with
ComponentThe owning component is destroyed (or its entity)The entity’s scene
LayerThe owning scene exitsThe owning scene
SceneThe owning scene exitsThe owning scene
ScreenRendererPlugin.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.

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";
PresetOne-lineDrives getIntensity
hitFlashDamage flash with trigger()additive tint amount
bloomSoft glow on bright pixelsbloom scale
outlineHard-edge outlinethickness
dropShadowDrop shadow with offset + blurshadow alpha
pixelateBlock pixelationpixel size (clamped to ≥1)
glowOuter + inner glow haloscales BOTH strengths together
crtScanlines, curvature, noisefilter alpha (whole effect)
chromaticAberrationRGB channel separationsymmetric separation
vignetteEdge darkeningvignette alpha
colorGradeSepia / grayscale / negative / night / warm / coolfilter alpha
godRayAnimated volumetric light rays (self-scheduling time)gain (rays scale 0 → full)
shockwaveOne-shot ripple at (x, y) via trigger()amplitude × brightness (parked off until trigger)
motionBlurDirectional smear along a velocity vectorvelocity magnitude
oldFilmSepia + grain + scratches + animated seedfilter alpha
bulgePinchLens distortion — sign-preserving (negative pinches, positive bulges)strength (sign preserved)
halftoneComic-print dot grid via custom WebGL+WGSL shader pairamount (cross-fades back to source)
waveSinusoidal x-displacement via custom shader; scene-time animatorconfigured amplitude
colorizeLuminance-to-colour recolour via custom WebGL+WGSL shader pair — the replace-style alternative to sprite.tint’s multiplystrength (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.

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 ratio
recolour.fadeOut(200); // strength → 0 cross-fades back to the source

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

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 preserved
bp.setCenter(0.5, 0.5);

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 its radius.
  • shockwave — the ring expands outward from center and 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 makes trigger(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.

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.

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:

  1. g.clear() first. Pixi Graphics commands accumulate; without clear(), every redraw() layers another shape on top of the previous one.
  2. Read live state, don’t snapshot it. JS closures capture variable bindings, not values. const w = panel.width followed by g.rect(0, 0, w, ...) will keep using the original w after redraw(). 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.

Effects + masks built from defineEffect / defineMask factories survive SnapshotService.saveSnapshot round-trips automatically:

  • Component scope — captured inside each visual component’s serialize() and restored in afterRestore.
  • Layer / Scene / Screen scope — the renderer registers a snapshot contributor with SnapshotService (key "renderer") at engine start, so RendererPlugin and SnapshotPlugin can 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 onEnter or afterRestore if 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.