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: 0.1 }));flash.trigger(); // one-shot ramp up + downflash.setIntensity(0.5); // set half strength immediatelyflash.fadeOut(0.2); // 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). |
setIntensity(value) | Set primary intensity immediately. Values are clamped to 0–1. |
fadeIn(seconds) | Tween primary intensity 0 → 1. Returns a Process. |
fadeOut(seconds) | 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.
The common setIntensity method changes the current 0–1 strength. A preset
setter such as setThickness or setBloomScale changes what full strength
means while preserving the current intensity ratio.
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 | Primary intensity |
|---|---|---|
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) |
The handle controls an effect’s strength at runtime. setIntensity(value)
sets the primary intensity immediately and clamps it to 0–1.
fadeIn(seconds) and fadeOut(seconds) tween the same value. 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.) rebase that ceiling
while preserving the current intensity ratio, so adjusting it mid-pulse
raises the pulse height instead of snapping the visible effect back to 1.
For gameplay-driven strength (HP-linked tinting) or looping animation
(heartbeat glow, breathing vignette), drive the preset’s set* setter
from a scheduled tween —
vignette.run(Tween.custom((v) => vignette.setStrength(v), from, to, seconds))
— which is scoped to the effect and auto-cancels on remove().
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(0.2); // 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/main.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(0.2);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.
Blend modes
Section titled “Blend modes”Every visual component takes a blendMode option deciding how its pixels
combine with what is already drawn beneath it. It is also a live accessor,
so it can change at runtime.
import { SpriteComponent } from "@yagejs/renderer";
// A muzzle flash that brightens whatever it lands on.entity.add(new SpriteComponent({ texture: "flash", blendMode: "add" }));
// Later, back to plain compositing.sprite.blendMode = "normal";"normal", "add", "multiply", "screen", "erase", "min", "max",
"none" and the -npm variants run directly on the GPU and work with no
setup. The photoshop-style rest — "darken", "lighten", "overlay",
"color-dodge", "soft-light" and the rest — are drawn with filters that
PixiJS only registers when you ask for them. Add one import to your entry
file:
import "pixi.js/advanced-blend-modes";Without it, PixiJS logs a warning naming the mode and draws the object normally.
serialize() records blendMode only when it is something other than
"normal", and fromSnapshot restores it.
Offscreen render targets
Section titled “Offscreen render targets”createRenderTarget draws a container into a texture your game owns, on a
schedule you control. Reach for it when several objects need to composite
against each other before they reach the screen, or when redrawing expensive
static content every frame is wasteful.
import { Container, Graphics } from "pixi.js";import { RendererKey, SpriteComponent, registerTexture } from "@yagejs/renderer";
const renderer = this.context.resolve(RendererKey);
// The offscreen content. Not part of the scene render tree.const buffer = new Container();const darkness = new Graphics() .rect(0, 0, 1280, 720) .fill({ color: 0x05060a, alpha: 0.85 });const lamp = new Graphics().circle(400, 300, 120).fill({ color: 0xffffff });lamp.blendMode = "erase"; // only reaches `darkness`, because both live in the bufferbuffer.addChild(darkness, lamp);
const target = renderer.createRenderTarget(buffer, { width: 1280, height: 720, resolutionScale: 0.5, // a quarter of the texels — invisible on a soft gradient});
// Show the texture like any other.registerTexture("lighting", target.texture);overlay.add(new SpriteComponent({ texture: "lighting", layer: "overlay" }));A registered key outlives the target it came from. destroy() frees the GPU
texture but leaves the key resolving to it, so tear the two down together:
onExit(): void { unregisterTexture("lighting"); target.destroy();}The buffer starts out needing a draw and is redrawn only when you ask:
// In a component's update:lamp.position.set(player.x, player.y);target.invalidate(); // the content changedtarget.renderIfNeeded(); // draws, and returns true because it didFor content that changes rarely — a baked background, a fog-of-war map
revealed on discovery — call invalidate() from the event that changes it
and leave renderIfNeeded() in the update. For content that moves every
frame, call render() directly and use resolutionScale to keep the cost
down.
Where things land inside the buffer
Section titled “Where things land inside the buffer”Content is drawn in the source container’s own coordinate space. A child
at local (100, 50) lands at texture pixel (100, 50). Ancestor transforms
never reach the buffer, so neither the camera nor the responsive fit scale
moves or resizes what you draw. Two consequences:
- To follow the camera, move the children yourself. Run the world point
through
camera.worldToScreen(x, y)and place the child at the result — readingcamera.positionalone drifts as soon as zoom, rotation, or shake are in play. Or size the buffer torenderer.visibleVirtualRectand place content in that rectangle. - The source’s own transform still applies. Setting
source.positionshifts everything inside the texture. Leave the source untransformed unless you want that.
Rules worth knowing
Section titled “Rules worth knowing”- Keep the source out of the scene render tree. PixiJS promotes a rendered container to a render group, changing how it batches wherever it is parented, and buffer content is normally shown through the texture rather than drawn twice.
- A hidden source draws nothing. PixiJS skips a container whose
visibleisfalse. The pending draw is kept — and set, if you forced one — so the buffer catches up the first time the source is shown. - A destroyed source throws. Once the source container is destroyed the
buffer can never draw again, so
render()andrenderIfNeeded()raise a named error instead of leaving a permanently stale texture. Destroy the target alongside its source. resolutionScalecosts sharpness, not layout. Only the texel count changes. The exception is rounding: PixiJS stores whole texels, so a size that lands between them is rounded up and the measured size grows to match. Good for gradients and glows, bad for text and pixel art.resize()keeps the density you asked for. Omit the third argument and the buffer re-derives its resolution from the renderer’s current one using the configuredresolutionScale; pass one to replace that scale.destroy()frees the GPU memory. Call it when the buffer’s owner goes away — nothing reclaims it automatically.- WebGPU is unverified. PixiJS tries WebGL first unless you pass
pixi: { preference: "webgpu" }, and blend behaviour inside a render target has only been checked on WebGL.
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.