Skip to content

Filter and the Node.filters visual effects

Filter (from scenerystack/scenery) is the abstract base class behind Node's filters option — an array of post-processing effects applied to that Node's rendered subtree, the scenery equivalent of the CSS filter property. You rarely construct a Filter directly; instead you construct one of its named subtypes (Brightness, Contrast, Grayscale, Invert, Sepia, HueRotate, Saturate, or a custom ColorMatrixFilter) and assign an array of them to filters.

ts
import { Node, Circle, Grayscale, Brightness, Contrast } from 'scenerystack/scenery';

const icon = new Node( {
  children: [ new Circle( 30, { fill: 'crimson' } ) ]
} );

// Applied in array order: grayscale first, then brighten the result.
icon.filters = [ new Grayscale( 1 ), new Brightness( 1.2 ) ];

// A disabled-looking control, built from two filters composed together:
const disabledLookFilters = [ new Contrast( 0.6 ), new Brightness( 1.1 ) ];

The shared pattern

Every named filter is a small class that computes a fixed CSS filter string (e.g. grayscale(1)) and, where the effect can be expressed as a 4x5 color matrix (all of them except Invert), extends ColorMatrixFilter so the same math also works under SVG and Canvas rendering — not just DOM/CSS. Node.filters accepts an array so multiple effects can stack; they apply in array order.

All of these filters live under scenerystack/scenery. Most take a single amount constructor argument that behaves like the equivalent CSS filter function:

FilterConstructoramount meaning
Brightnessnew Brightness( amount )0 = black, 1 = normal, greater = brighter. Non-negative, required
Contrastnew Contrast( amount )0 = flat gray, 1 = normal, greater = higher contrast. Non-negative, required
Grayscalenew Grayscale( amount = 1 )0 = unchanged, 1 = fully gray-scale. Must be in [0, 1]
Invertnew Invert( amount = 1 )0 = unchanged, 1 = fully inverted. Must be in [0, 1]
Sepianew Sepia( amount = 1 )0 = unchanged, 1 = fully sepia-toned. Must be in [0, 1]
Saturatenew Saturate( amount )0 = fully desaturated (grayscale), 1 = normal, greater = over-saturated. Non-negative, required
HueRotatenew HueRotate( amount )Hue rotation in radians (converted to degrees internally for the CSS string). Non-negative, required

Brightness and Grayscale each expose a convenience static: Brightness.BLACKEN (new Brightness( 0 )) and Grayscale.FULL (new Grayscale( 1 )); Contrast.GRAY (new Contrast( 0 )) similarly turns content flat gray.

ColorMatrixFilter

ts
new ColorMatrixFilter(
  m00, m01, m02, m03, m04,
  m10, m11, m12, m13, m14,
  m20, m21, m22, m23, m24,
  m30, m31, m32, m33, m34
)

Every named filter above (except Invert) is implemented purely by calling super(...) with 20 fixed matrix values — ColorMatrixFilter multiplies this 4x5 matrix against each pixel's [r, g, b, a, 1] column to produce a new [r, g, b, a]. Writing a custom ColorMatrixFilter is possible but PhET's own guidance is to prefer the named subtypes wherever they cover the need: a custom matrix filter is SVG/Canvas-compatible but is not compatible with WebGL or DOM content, and forces everything under it into a single SVG or Canvas block, which can reduce rendering performance.

Filter (the abstract base)

You won't subclass Filter directly in ordinary sim code, but its interface is why every filter can be mixed freely: each subclass implements getCSSFilterString() (for DOM/Canvas), applySVGFilter( svgFilter, inName, resultName? ) (for SVG), and applyCanvasFilter( wrapper ) (the ImageData fallback path), plus compatibility checks (isDOMCompatible(), isSVGCompatible(), isCanvasCompatible(), isWebGLCompatible()) that scenery uses to decide which rendering strategy can honor a given filters array.

Filters apply to the whole subtree, and can be animated like any other option

Because filters is a plain Node option, it can be set/replaced at any time — e.g. swap in [ new Grayscale( fraction ) ] on every frame of a fade-to-grayscale transition by recomputing the array as fraction changes. There's no Filter Property API for interpolating an amount smoothly; you construct a fresh filter instance with the new amount.

Mixed compatibility can force a slower rendering path

Not every filter works under every renderer. If a Node's subtree can't be placed in a single SVG block or Canvas (e.g. because of a layerSplit or DOM content), some filters may fall back to a more expensive strategy, or in the case of custom ColorMatrixFilters under WebGL/DOM content, may not be supported at all. Prefer the DOM-compatible named filters (all of the ones listed above are DOM-compatible) when performance matters and the content underneath isn't guaranteed to be single-block SVG or Canvas.