# @yagejs/effects

Depends on `@yagejs/core` (peer), `@yagejs/renderer` (peer), `pixi.js` (peer), `pixi-filters`. Built-in visual-effect presets added via `.fx.addEffect` at any of the four scopes (component / layer / scene / screen). Each preset uses `defineEffect` to provide a named factory with typed options.

## Setup

No plugin install — just import a preset and call it like a factory:

```ts yage-context="entity,scene-enter"
import { hitFlash, bloom, crt, vignette } from "@yagejs/effects";
import {
  RendererKey,
  SceneRenderTreeKey,
  SpriteComponent,
} from "@yagejs/renderer";

const sprite = entity.get(SpriteComponent);
const tree = this.use(SceneRenderTreeKey);

sprite.fx.addEffect(hitFlash({ color: 0xffffff }));
tree.get("world").fx.addEffect(bloom({ threshold: 0.8 }));
tree.fx.addEffect(crt({}));
this.use(RendererKey).fx.addEffect(vignette({ alpha: 0.4 }));
```

Each preset returns the same `EffectHandle` shape (`remove`, `setEnabled`, `enabled`, `setIntensity(value)`, `fadeIn(duration)`, `fadeOut(duration)`) plus typed extras specific to the preset (e.g. `OutlineHandle.setThickness(n)`).

`import type { EffectHandle } from "@yagejs/effects"` names that shape — the type to annotate a field or a list that holds handles from several presets. Each preset's own handle type (`OutlineHandle`, `BloomHandle`) is exported from the same barrel and narrows it.

## Presets

| Preset                | Options shape                                                                                                              | Wraps                                  | Primary intensity                                  |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | -------------------------------------------------- |
| `hitFlash`            | `{ color?, duration?, peak? }`                                                                                             | built-in `ColorMatrixFilter`           | additive tint amount                               |
| `bloom`               | `{ threshold?, bloomScale?, brightness?, blur?, quality? }`                                                                | `pixi-filters` `AdvancedBloomFilter`   | `bloomScale`                                       |
| `outline`             | `{ thickness?, color?, alpha?, quality?, knockout? }`                                                                      | `pixi-filters` `OutlineFilter`         | `thickness`                                        |
| `dropShadow`          | `{ offset?, color?, alpha?, blur?, quality?, shadowOnly? }`                                                                | `pixi-filters` `DropShadowFilter`      | `alpha`                                            |
| `pixelate`            | `{ size? }`                                                                                                                | `pixi-filters` `PixelateFilter`        | `size` (clamped to ≥ 1)                            |
| `glow`                | `{ color?, distance?, outerStrength?, innerStrength?, alpha?, quality?, knockout? }`                                       | `pixi-filters` `GlowFilter`            | scales BOTH strengths together                     |
| `crt`                 | `{ curvature?, lineWidth?, lineContrast?, verticalLine?, noise?, vignetting?, vignettingAlpha? }`                          | `pixi-filters` `CRTFilter`             | filter `alpha` (whole effect; noise self-animates) |
| `chromaticAberration` | `{ separation? }`                                                                                                          | `pixi-filters` `RGBSplitFilter`        | symmetric `separation` (red −x, blue +x)           |
| `vignette`            | `{ radius?, alpha?, blur? }`                                                                                               | `CRTFilter` (with CRT features zeroed) | `vignettingAlpha`                                  |
| `colorGrade`          | `{ preset?, amount? }`                                                                                                     | built-in `ColorMatrixFilter`           | filter `alpha` (cross-fades to identity)           |
| `godRay`              | `{ angle?, gain?, lacunarity?, alpha? }`                                                                                   | `pixi-filters` `GodrayFilter`          | `gain` (rays scale 0 → full)                       |
| `shockwave`           | `{ speed?, amplitude?, wavelength?, brightness?, radius?, direction?, duration? }`                                         | `pixi-filters` `ShockwaveFilter`       | `amplitude × brightness` (zero until `trigger`)    |
| `motionBlur`          | `{ velocity?, kernelSize?, offset? }`                                                                                      | `pixi-filters` `MotionBlurFilter`      | configured `velocity` magnitude                    |
| `oldFilm`             | `{ sepia?, noise?, noiseSize?, scratch?, scratchDensity?, scratchWidth?, vignetting?, vignettingAlpha?, vignettingBlur? }` | `pixi-filters` `OldFilmFilter`         | filter `alpha` (whole effect; noise self-animates) |
| `bulgePinch`          | `{ strength?, radius?, center? }` (host-local coords)                                                                      | `pixi-filters` `BulgePinchFilter`      | configured `strength` (sign preserved)             |
| `halftone`            | `{ size?, amount?, angle? }`                                                                                               | custom WebGL+WGSL                      | `amount` (cross-fades back to source)              |
| `wave`                | `{ amplitude?, wavelength?, speed? }`                                                                                      | custom WebGL+WGSL                      | configured `amplitude`                             |
| `colorize`            | `{ color, strength? }`                                                                                                     | custom WebGL+WGSL                      | `strength` (cross-fades back to source)            |
| `glitch`              | `{ slices?, offset?, direction?, fillMode?, average?, minSize?, sampleSize?, red?, green?, blue?, seed? }`                 | `pixi-filters` `GlitchFilter`          | band displacement and RGB offsets                  |
| `zoomBlur`            | `{ strength?, center?, innerRadius?, radius?, expandFromCenter?, maxKernelSize? }`                                         | `pixi-filters` `ZoomBlurFilter`        | signed blur strength                               |
| `axisBlur`            | `{ strength?, axis?, perpendicularStrength?, quality?, kernelSize?, repeatEdgePixels? }`                                   | built-in `BlurFilter`                  | main and perpendicular strengths                   |
| `implosion`           | `{ center?, radius?, strength?, darkness?, swirl?, expandFromCenter? }`                                                    | custom WebGL+WGSL                      | inward pull, darkness, and swirl                   |
| `dissolve`            | `{ edgeColor?, edgeWidth?, noiseScale?, softness?, seed? }`                                                                | custom WebGL+WGSL                      | dissolve progress from intact to transparent       |

All `duration` options and `fadeIn`/`fadeOut` arguments are in seconds (`hitFlash` default 0.12, `shockwave` default 1).

Color-grade presets: `"neutral"` (identity), `"sepia"`, `"grayscale"`, `"negative"`, `"night"`, `"warm"` (orange tint + brightness boost), `"cool"` (blue tint).

`motionBlur.kernelSize` must be finite, odd and ≥ 5. Finite invalid values are coerced up to the nearest valid kernel and a one-shot `console.warn` fires naming the requested + final value. `bulgePinch.strength` is signed: negative pinches, positive bulges. A fade scales the magnitude while preserving the sign, so a pinch fades flat → pinch, not flat → bulge → pinch. `bulgePinch.center` and `bulgePinch.radius` are in the effect host's local pixels; omit `center` to sit in the middle of the filtered region. `zoomBlur.strength` is also signed: positive values streak outward and negative values pull inward. `axisBlur` is symmetric around each source pixel; use `motionBlur` for a directional trailing smear.

`zoomBlur.expandFromCenter` grows a finite `radius` outward with intensity. A
negative, unlimited radius cannot expand. `implosion.expandFromCenter` applies
the same center-first progression to its pull, darkness, and swirl.

`glitch`, `zoomBlur`, `axisBlur`, `implosion`, `dissolve`, and `bulgePinch` reject a
non-finite or out-of-range number at the call that supplies it — options,
`setIntensity`, and the per-preset setters alike — and throw naming the input
and the constraint (`implosion: radius must be >= 1, got 0.`). A `NaN` written
into a filter uniform renders undefined output with nothing pointing back at
its source, so these throw instead of clamping. Range-bound inputs:
`implosion.radius` ≥ 1, `implosion.darkness` 0–1, `zoomBlur.innerRadius` ≥ 0,
`glitch.slices` and `glitch.sampleSize` integers ≥ 1, `axisBlur.quality` an
integer ≥ 1, `dissolve.edgeWidth` 0.001–0.5, `dissolve.noiseScale` ≥ 1,
`dissolve.softness` 0.001–0.25, `bulgePinch.radius` ≥ 0.

`bloom.blur`, `dropShadow.blur`, and `outline.thickness` must be non-negative;
`glow.distance` must be at least 1 and `glow.quality` must be between 0 and 1.
Bloom and shadow quality must be integers at least 1. The dimensional options
and setters of these presets, `chromaticAberration`, and `motionBlur` reject
non-finite numbers.

The public handle controls an effect's strength three ways. `setIntensity(value)` sets the primary intensity immediately and clamps the value to 0–1. `fadeIn(seconds)` and `fadeOut(seconds)` tween that same value and return a `Process`. The per-preset `set*` setters that change a preset's "full" value (`bloom.setBloomScale`, `glow.setOuterStrength`, `outline.setThickness`, `dropShadow.setAlpha`, `vignette.setStrength`, `chromaticAberration.setSeparation`, `pixelate.setSize`, `glow.setInnerStrength`, `godRay.setGain`, `motionBlur.setVelocity`, `bulgePinch.setStrength`, `halftone.setAmount`, `wave.setAmplitude`, `colorize.setStrength`) rebase that ceiling while preserving the current intensity ratio. For example, `bloom.setIntensity(0.5)` displays half of the configured bloom scale, while `bloom.setBloomScale(2)` changes what full strength means. For a custom timed animation, pass a tween to `run`; the process is scoped to the effect and stops on `.remove()`.

## Scope rationale

Four presets work 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 travels between `center` and the host's bounds and is naturally clipped there, so a component-scoped shockwave on a small sprite looks like a tiny "bump" rather than a ring. Scene scope makes `trigger(heroX, heroY)` line up with the entity's transform.
- `implosion` — inward displacement samples beyond the output pixel. A small component host clips the warped image at its own bounds, while a layer or scene gives the distortion room around its center.

The `examples/src/effects-showcase/controls.ts` demo sets up `godRay`, `bulgePinch`, and `shockwave` at scene scope — copy that as the worked-out reference.

Scene scope and screen scope also post-process the UI. `@yagejs/ui` mounts its screen-space `"ui"` layer inside the scene's render tree, so `tree.fx.addEffect(...)` (scene scope) and a renderer-level effect (screen scope) both filter the HUD along with the world. Two ways to keep an effect off the HUD:

```ts yage-context="entity,scene-enter"
import { colorGrade } from "@yagejs/effects";
import { SceneRenderTreeKey, SpriteComponent } from "@yagejs/renderer";

const tree = this.use(SceneRenderTreeKey);

// 1. Name the layers it covers. One handle, one filter pass per listed
//    layer — three layers cost three fullscreen passes per frame.
const grade = tree.addLayerEffect(colorGrade({ preset: "night" }), [
  "background",
  "world",
  "props",
]);
grade.fadeOut(1); // fans out to all three

// 2. Lift one visual out of every layer- and scene-scope effect.
entity.add(
  new SpriteComponent({ texture: "cursor", renderAboveEffects: true }),
);
```

`renderAboveEffects` draws the visual after the scene's layers while its logical parent still drives position, alpha, visibility, and camera. It escapes layer and scene filters, `layer.setMask`, `tree.setMask`, and the `irisReveal` / `chessboard` transition masks — during those reveals the visual shows at once. A screen-scope effect still covers it. Hit testing follows the logical tree, so a UI element drawn under it still receives the pointer first. Toggle it at runtime with `sprite.renderAboveEffects = false`. On a `SortGroupComponent` the flag applies to the component's own render object; the group's container is not lifted. A layer declared with `isRenderGroup: true` is unsupported for lifted visuals (a Pixi restriction).

### What a layer-scope filter's input frame covers

A layer- or scene-scope filter runs over the filtered container's own content
bounds, which Pixi computes from what the container holds. Nothing sets
`filterArea`, so the frame is not the viewport and not the play rect: it tracks
the content, and it moves and resizes with the camera and with the letterbox
bars the responsive fit produces.

Two consequences for choosing a scope:

- **A sparse layer** gets a small frame that changes size as its contents move.
  A vignette or a full-screen colour grade on a layer holding three sprites
  covers those three sprites and their gaps, not the screen. A wider scope
  unions more content into the frame — `app.stage`, the screen-scope host, has
  no `filterArea` either — so it helps only as far as the content it adds
  reaches. The reliable answer is a layer that fills the viewport.
- **A layer that fills the viewport** gets a frame close to the visible area,
  which is what a full-screen look needs. Its frame includes child effect
  padding. See the unit reference below for sizes that follow the host scale.

## Units and padding

Most blur and halo sizes use **host-local pixels**. They follow the effect host's
scale, including camera zoom and responsive fit, on every render. Do not multiply
these options by the canvas-to-virtual-size ratio yourself.

| Preset                    | Options in host-local pixels                                      |
| ------------------------- | ----------------------------------------------------------------- |
| `bloom`                   | `blur`                                                            |
| `outline`                 | `thickness`                                                       |
| `dropShadow`              | `offset`, `blur`                                                  |
| `glow`                    | `distance`                                                        |
| `chromaticAberration`     | `separation`                                                      |
| `motionBlur`              | `velocity`, `offset`                                              |
| `axisBlur`                | `strength`, `perpendicularStrength`                               |
| `bulgePinch`, `implosion` | `center`, `radius`                                                |
| `zoomBlur`                | `center`, `innerRadius`, `radius`                                 |
| `shockwave`               | trigger coordinates, `speed`, `amplitude`, `wavelength`, `radius` |
| `glitch`                  | `offset`, `red`, `green`, `blue`                                  |
| `dissolve`                | `noiseScale`                                                      |

Scalar lengths use the mean of the host's two axis scales when those scales
differ. Axis blur strengths, channel separation, and vector components instead
use the corresponding axis scale magnitude. Vector
screen directions do not rotate with the host. Shockwave speed is measured in
local pixels per second and uses the host’s x-axis scale. `motionBlur.kernelSize` and
quality options count samples or passes; they are not lengths.

`pixelate.size`, `crt.lineWidth`, `halftone.size`, and `wave.amplitude` /
`wave.wavelength` remain in post-transform logical pixels. Renderer resolution
controls raster density separately from these units.

The seven blur and halo presets at the top of the table reserve padding from
their current sampling reach. Padding updates with scale and intensity before
the filter's input is allocated. A parent effect also includes its children's
effect padding, so a scene or layer filter does not crop a sprite's halo at the
sprite's original bounds. Consecutive filters add their padding.

Filter input buffers include these margins. Ordinary `getBounds()` queries
and local layout bounds continue to measure content.
Larger blur radii and more blur passes can require larger intermediate textures.
Explicit masks, `filterArea`, viewport clipping, and render-target size limits
still constrain output. `axisBlur({ repeatEdgePixels: true })` deliberately
keeps edge pixels and does not expand its input. With `rawFilter`, set the
underlying filter's `padding` to cover its shader's sampling reach; parent
effects preserve that declared margin.

## Per-preset handle extras

```ts yage-context="entity,scene-enter"
import { Transform } from "@yagejs/core";
import {
  axisBlur,
  bloom,
  bulgePinch,
  chromaticAberration,
  colorGrade,
  colorize,
  crt,
  dissolve,
  dropShadow,
  glitch,
  glow,
  godRay,
  halftone,
  hitFlash,
  implosion,
  motionBlur,
  oldFilm,
  outline,
  pixelate,
  shockwave,
  vignette,
  wave,
  zoomBlur,
} from "@yagejs/effects";
import {
  RendererKey,
  SceneRenderTreeKey,
  SpriteComponent,
} from "@yagejs/renderer";

const sprite = entity.get(SpriteComponent);
const tree = this.use(SceneRenderTreeKey);
const layer = tree.get("world");
const renderer = this.use(RendererKey);
const playerPosition = entity.get(Transform).position;
const { x: heroX, y: heroY } = playerPosition;
const [drainX, drainY] = [640, 200];
const [nextX, nextY] = [heroX + 32, heroY];

const flash = sprite.fx.addEffect(hitFlash({ color: 0xffffff }));
flash.trigger(); // one-shot ramp up + down
flash.setColor(0xff0000);

const out = sprite.fx.addEffect(outline({ thickness: 3 }));
out.setThickness(5);
out.setColor(0x00ff00);

const bloomH = layer.fx.addEffect(bloom({}));
bloomH.setThreshold(0.6);
bloomH.setBloomScale(2);

const drop = sprite.fx.addEffect(dropShadow({ offset: { x: 4, y: 4 } }));
drop.setOffset(8, 8);
drop.setColor(0x222222);
drop.setAlpha(0.7);

const px = layer.fx.addEffect(pixelate({ size: 8 }));
px.setSize(12);

const g = sprite.fx.addEffect(glow({ outerStrength: 2 }));
g.setOuterStrength(4);
g.setInnerStrength(1);
g.setColor(0xff8800);

tree.fx.addEffect(crt({})); // noise self-animates; no caller setup

const ca = layer.fx.addEffect(chromaticAberration({ separation: 4 }));
ca.setSeparation(8);

const vig = renderer.fx.addEffect(vignette({ alpha: 0.5 }));
vig.setStrength(0.8);

const grade = tree.fx.addEffect(colorGrade({ preset: "neutral" }));
grade.setPreset("sepia");

const ray = tree.fx.addEffect(godRay({ angle: 30, gain: 0.5 }));
ray.setAngle(45); // tweak ray angle in degrees
ray.setGain(0.8); // rebases full strength; preserves intensity ratio

const sw = tree.fx.addEffect(shockwave({ speed: 600, amplitude: 40 }));
sw.trigger(heroX, heroY); // ALL pixel-valued inputs (center, amplitude,
// wavelength, radius, speed) are in the filter
// target's local space — virtual px for
// scene/layer scope, sprite-local for
// component scope. The wrapper rescales them
// to input-texture px every frame from the
// target's live worldTransform, so resize /
// camera zoom / scope changes preserve both
// the trigger point AND the visual ring shape
// / travel speed at any size.
// Re-trigger cancels any in-flight ramp.

const vortex = tree.fx.addEffect(
  shockwave({ direction: "in", speed: 400, duration: 1.5 }),
);
vortex.trigger(drainX, drainY); // direction: "in" starts the ring
// speed × duration = 600 host-local px out
// and contracts it onto the trigger point.
// A configured `radius` hides the ring until
// it reaches that radius, then strengthens as
// it closes in. Default is "out".

const mb = sprite.fx.addEffect(motionBlur({ velocity: { x: 30, y: 0 } }));
mb.setVelocity(50, 12); // rebases full vector; preserves intensity ratio

tree.fx.addEffect(oldFilm({ sepia: 0.4, noise: 0.4 }));
// noise self-animates; only the base
// EffectHandle surface is exposed

const bp = tree.fx.addEffect(
  bulgePinch({ strength: 1, radius: 200, center: { x: 640, y: 360 } }),
);
bp.setStrength(-0.8); // flips bulge → pinch; intensity ratio preserved
bp.setCenter(heroX, heroY); // host-local coordinates
bp.useHostCenter(); // back to the middle of the filtered region
bp.setRadius(300); // distortion radius in host-local pixels

const ht = layer.fx.addEffect(halftone({ size: 6, angle: Math.PI / 4 }));
ht.setSize(10);
ht.setAngle(0);
ht.setAmount(0.7); // rebases full ceiling; preserves intensity ratio

const wv = layer.fx.addEffect(wave({ amplitude: 6, wavelength: 40 }));
wv.setAmplitude(12); // rebases full amplitude; preserves intensity ratio
wv.setWavelength(60); // clamped to ≥ 1
wv.setSpeed(2); // cycles/second; advances `uTime` from scene time

// Recolour a sprite without the multiply-tint trap — black stays black,
// white reaches the target colour, midtones blend proportionally, and
// source alpha is preserved unchanged.
const recolour = sprite.fx.addEffect(colorize({ color: 0xf2c14e }));
recolour.setColor(0xd94a4a); // accepts numbers or strings ("#d94a4a", "red")
recolour.setStrength(0.6); // rebases full ceiling; preserves intensity ratio
recolour.fadeOut(0.2); // strength → 0 cross-fades back to the source (seconds)
const glitchH = sprite.fx.addEffect(glitch({ slices: 8, offset: 24 }));
glitchH.refresh(42); // deterministic replacement pattern
glitchH.setOffset(36);

const zoom = layer.fx.addEffect(
  zoomBlur({ center: playerPosition, strength: 0.15 }),
);
zoom.setCenter(nextX, nextY); // host-local coordinates
zoom.setStrength(-0.12); // negative pulls inward

const axis = sprite.fx.addEffect(
  axisBlur({ axis: "horizontal", strength: 12 }),
);
axis.setAxis("vertical");
axis.setPerpendicularStrength(2);

const hole = layer.fx.addEffect(
  implosion({ center: playerPosition, radius: 180 }),
);
hole.setDarkness(1);
hole.setSwirl(0.6);

const vanish = sprite.fx.addEffect(
  dissolve({ edgeColor: 0x67e8f9, noiseScale: 10 }),
);
vanish.setIntensity(0.5); // half of the noise field is transparent
vanish.setSeed(7);
```

`dissolve` requires `edgeWidth` from 0.001 to 0.5, `noiseScale` of at
least 1, `softness` from 0.001 to 0.25, and a finite `seed`.

## Fade behavior

Every preset's `fadeIn` / `fadeOut` tweens its primary intensity (column 4 in the table above). For `crt` and `colorGrade` that primary intensity is the filter's overall `alpha`, so fades touch the whole effect rather than a single uniform; for `glow` it scales outer + inner halos in lockstep.

If you need to drive a non-primary uniform (or any custom fade shape), schedule it via `handle.run(p)` — the process is bound to the effect's lifetime and auto-cancels on `.remove()`:

```ts yage-context="entity"
import { Tween } from "@yagejs/core";
import { bloom } from "@yagejs/effects";
import { SpriteComponent } from "@yagejs/renderer";

const sprite = entity.get(SpriteComponent);
const h = sprite.fx.addEffect(bloom({ bloomScale: 1.5 }));
h.run(Tween.custom((v) => h.setThreshold(v), 1, 0, 0.5)); // pauses with scene, ends with effect
```

For work that should outlive a single effect (e.g. a global animator), schedule directly on the matching scope's queue and manage cancellation yourself:

```ts yage-context="entity"
import { ProcessComponent, ProcessSystemKey, Tween } from "@yagejs/core";

const pc =
  entity.tryGet(ProcessComponent) ?? entity.add(new ProcessComponent());
const pulse = (v: number) => {
  // drive several effects from one value
};
pc.run(Tween.custom(pulse, 0, 1, 2)); // entity-scoped, NOT bound to any one effect
```

## Save state

Effects and their processes are runtime resources. Save the durable game fact
that selects an effect, then add it again during normal component or scene
setup after load.
