@vysmo/effects Library Canvas

Effects for the modern web.

30 WebGL2 filter primitives — blur, bloom, glow, color grade, sharpen, halftone, tilt-shift, scanlines, lens distortion, oil paint, wave, swirl, motion blur, VHS, datamosh, ASCII, dither, gradient map and more. One Runner, one source, one render call. Multi-pass effects auto-allocate HDR ping-pong targets.

  • 30 effects
  • ~9 KB gzipped (full)
  • 0 runtime deps
  • tree-shakable import what you ship
$ pnpm add @vysmo/effects View on GitHub

Install

Pick your package manager. Adds @vysmo/effects.

pnpm add @vysmo/effects npm install @vysmo/effects yarn add @vysmo/effects

Zero runtime dependencies, SSR-safe at module load. WebGL2 is required at render() time — the package itself imports cleanly in Node.

Quick start

Three steps from import to a rendered effect: build the runner, load a source, call render(). The runner is thin enough that this is genuinely all you need.

import { Runner, blur } from "@vysmo/effects";

// 1. A canvas to render into. The runner owns its WebGL2 context — one
//    runner per canvas. Reuse it across effect / param changes.
const canvas = document.querySelector<HTMLCanvasElement>("#stage")!;
const runner = new Runner({ canvas });

// 2. A source — image, video, canvas, or ImageBitmap.
const image = new Image();
image.crossOrigin = "anonymous";
image.src = "/photo.jpg";
await image.decode();

// 3. Render. Compiled programs are cached per effect, so re-rendering
//    the same effect with new params skips compilation.
runner.render(blur, {
  source: image,
  params: { radius: 16 },
});

The runner owns the WebGL2 context for the lifetime of the canvas. Reuse it across every effect / param change — recreating it per render is the most common perf footgun. Want a different effect? Swap blur for any other export. Want it animated? Drive params from requestAnimationFrame or @vysmo/animations.

Concepts

The mental model in six bullets. Skim if you're already comfortable with the playground.

Runner is one-per-canvas

A Runner owns a WebGL2 context bound to a single canvas. Cheap to instantiate, GPU-cheap to keep alive. Survives context loss via the standard onContextLost / onContextRestored callbacks. Always call runner.dispose() on unmount — otherwise you leak the context, the texture cache, and the program cache.

Frame loop is yours, not ours

runner.render() is idempotent: pass it new params and it draws. The library owns no animation loop — you drive params from requestAnimationFrame, an animation library, scroll progress, a timeline, anything. The same effect serves preview, render, and export.

Sources are anything browsers can draw

source accepts HTMLImageElement, HTMLCanvasElement, HTMLVideoElement, OffscreenCanvas, ImageBitmap, and ImageData. The texture cache treats decoded images and ImageBitmaps as immutable (upload once, reuse) and re-uploads canvases / videos / ImageData every frame because their pixels can change.

Multi-pass effects allocate FBOs lazily

blur, tiltShift, bloom, and glow declare passes > 1. The runner allocates two ping-pong framebuffers on first render, exposes uPass / uPassCount / getPrevious(uv) to the shader, and reuses the FBO pair across renders. bloom and glow opt into RGBA16F targets when the GPU supports EXT_color_buffer_float.

Effects compose by chaining renders

To stack effects, render the first to canvas, then render the second using the canvas itself as source. The texture cache re-uploads the canvas every render, so each call sees the previous frame's pixels. For tighter loops, write a single defineEffect that does the composition in one shader pass.

Programs are cached per effect

The first render() with a given Effect compiles its GLSL once; subsequent renders reuse the cached program. Switching effects pays the compile cost once; re-rendering the same effect with new params costs only uniform uploads.

API reference

Six things to know. Everything else is composition.

Runner

Owns a WebGL2 context bound to a single canvas. One per surface. Cheap to instantiate, GPU-cheap to keep alive. Survives context loss via onContextLost / onContextRestored. Caches compiled programs per Effect, so switching effects pays the compile cost once.

new Runner({
  canvas: HTMLCanvasElement | OffscreenCanvas,
  contextAttributes?: WebGLContextAttributes,
  onContextLost?: () => void,
  onContextRestored?: () => void,
})

runner.render(effect, args)

Renders one frame from source through effect. Idempotent — pass new params and re-render whenever you want a new output. Multi-pass effects allocate ping-pong FBOs on first use and reuse them across renders.

runner.render(effect, {
  source: HTMLImageElement | HTMLCanvasElement | HTMLVideoElement | ...,
  params?: Partial<typeof effect.defaults>,
})

defineEffect

Author your own. Param types are inferred from defaults — never hand-type Effect<{...}>. Drop your effect into the same runner.render() call as the built-ins.

import { defineEffect } from "@vysmo/effects";

export const tint = defineEffect({
  name: "tint",
  defaults: { strength: 0.5, color: [1, 0.4, 0.8] as const },
  glsl: `
    uniform float uStrength;
    uniform vec3 uColor;
    vec4 effect(vec2 uv) {
      vec4 src = getSource(uv);
      return vec4(mix(src.rgb, uColor, uStrength), src.a);
    }
  `,
});

Standard uniforms & helpers

The runner injects these around your effect(uv) body. You don’t declare or sample textures yourself — call the helpers.

// Available in every fragment shader:
uniform vec2  uResolution;     // canvas size in pixels
uniform float uPass;           // current fragment pass index (multi-pass only)
uniform float uPassCount;      // total passes (multi-pass only)
vec4 getSource(vec2 uv);       // sample the source texture
vec4 getPrevious(vec2 uv);     // previous pass output (multi-pass only)

// Custom uniforms: declare them and the runner maps
// camelCase keys in 'defaults' → uPascalCase uniforms.

Naming conventions

Effect names are kebab-case in source; export identifiers are camelCase. Custom uniforms in GLSL are uPascalCase, mapped automatically from camelCase keys in defaults.

// Effect names are kebab-case in the source, exported as camelCase:
//   "chromatic-aberration" → import { chromaticAberration }
//   "tilt-shift"           → import { tiltShift }
// defaults.intensity → uniform uIntensity
// defaults.dotSize   → uniform uDotSize

Multi-pass & HDR

Set passes: N for separable filters or composite chains. Read the previous pass with getPrevious(uv); the runner exposes uPass and uPassCount automatically. Add hdr: true for intermediates that go outside [0,1] — bloom and glow do this for highlights.

defineEffect({
  name: "bloom",
  passes: 4,           // number of fragment-shader passes
  hdr: true,           // RGBA16F intermediate targets (where supported)
  defaults: { intensity: 1, threshold: 0.8, softness: 0.1, radius: 32 },
  glsl: /* ... */,
})

Catalog

All 30 built-in effects with their parameters, defaults, and accepted values. Pick an effect to inspect its prop table — same data the playground uses.

vignette

Radial corner darkening. Identity at intensity 0.

Prop
Type
Default
Values
intensity
number
0.6
0 – 1 · step 0.01
radius
number
0.5
0 – 1 · step 0.01
softness
number
0.4
0 – 1 · step 0.01
color
vec3
[0, 0, 0]
vec3 (read-only in playground)
grain

Procedural film-grain overlay. Identity at intensity 0.

Prop
Type
Default
Values
intensity
number
0.1
0 – 0.6 · step 0.005
size
number
1
1 – 8 · step 0.5
seed
number
0
0 – 64 · step 1
chromaticAberration

RGB channel offset along an axis. Identity at offset 0.

Prop
Type
Default
Values
offset
number
8
0 – 24 · step 0.5
direction
vec2
[1, 0]
vec2 (read-only in playground)
colorGrade

Brightness, contrast, saturation, hue. Pixel-pure identity at the defaults.

Prop
Type
Default
Values
brightness
number
0
-0.5 – 0.5 · step 0.01
contrast
number
1
0 – 2 · step 0.01
saturation
number
1
0 – 2 · step 0.01
hue
number
0
0 – 6.28 · step 0.01
pixelate

Mosaic / chunky-pixel quantisation. Identity at size ≤ 1.

Prop
Type
Default
Values
size
number
8
1 – 64 · step 0.5
sharpen

Unsharp-mask convolution. Identity at amount 0.

Prop
Type
Default
Values
amount
number
0.6
0 – 3 · step 0.01
radius
number
1
0.5 – 4 · step 0.1
threshold

Luma-based binary threshold with optional softness.

Prop
Type
Default
Values
cutoff
number
0.5
0 – 1 · step 0.01
softness
number
0
0 – 0.5 · step 0.005
lowColor
vec3
[0, 0, 0]
vec3 (read-only in playground)
highColor
vec3
[1, 1, 1]
vec3 (read-only in playground)
duotone

Luma-driven gradient mapping between two colours.

Prop
Type
Default
Values
intensity
number
1
0 – 1 · step 0.01
shadow
vec3
[0.13, 0.18, 0.55]
vec3 (read-only in playground)
highlight
vec3
[0.96, 0.78, 0.34]
vec3 (read-only in playground)
posterize

Per-channel colour quantisation. Identity at levels ≥ 256.

Prop
Type
Default
Values
levels
number
4
2 – 12 · step 1
edgeDetect

3×3 Sobel filter on luma. Identity at intensity 0.

Prop
Type
Default
Values
intensity
number
1
0 – 1 · step 0.01
radius
number
1
0.5 – 3 · step 0.1
color
vec3
[1, 1, 1]
vec3 (read-only in playground)
halftone

Print-style dot pattern; dot radius scales with luma.

Prop
Type
Default
Values
intensity
number
1
0 – 1 · step 0.01
dotSize
number
8
3 – 32 · step 0.5
angle
number
0.785
0 – 1.57 · step 0.01
inkColor
vec3
[0, 0, 0]
vec3 (read-only in playground)
paperColor
vec3
[1, 1, 1]
vec3 (read-only in playground)
scanlines

CRT-style horizontal banding. Identity at intensity 0.

Prop
Type
Default
Values
intensity
number
0.4
0 – 1 · step 0.01
density
number
2
1 – 16 · step 0.5
offset
number
0
0 – 6.28 · step 0.01
lensDistortion

Barrel (positive) / pincushion (negative). Identity at strength 0.

Prop
Type
Default
Values
strength
number
0.3
-1 – 1 · step 0.01
oilPaint

Simplified Kuwahara filter — painterly edge-preserving smoothing.

Prop
Type
Default
Values
radius
number
3
0 – 6 · step 0.5
wave

Sinusoidal UV displacement along an axis. Identity at amplitude 0.

Prop
Type
Default
Values
amplitude
number
8
0 – 32 · step 0.5
frequency
number
4
0 – 12 · step 0.1
axis
vec2
[0, 1]
vec2 (read-only in playground)
phase
number
0
0 – 6.28 · step 0.01
bulge

Radial pinch / bulge with quadratic falloff. Identity at strength 0.

Prop
Type
Default
Values
strength
number
0.5
-1 – 1 · step 0.01
centre
vec2
[0.5, 0.5]
vec2 (read-only in playground)
radius
number
0.5
0.05 – 1 · step 0.01
swirl

Angular UV rotation falling off with radial distance. Identity at angle 0.

Prop
Type
Default
Values
angle
number
1.5
-6.28 – 6.28 · step 0.05
centre
vec2
[0.5, 0.5]
vec2 (read-only in playground)
radius
number
0.5
0.05 – 1 · step 0.01
motionBlur

Directional 16-tap smear along a vector. Identity at distance 0.

Prop
Type
Default
Values
distance
number
16
0 – 64 · step 0.5
direction
vec2
[1, 0]
vec2 (read-only in playground)
radialBlur

Zoom-streak blur sampled along the centre→pixel ray.

Prop
Type
Default
Values
strength
number
0.1
0 – 1 · step 0.01
centre
vec2
[0.5, 0.5]
vec2 (read-only in playground)
rgbShift

Per-row hashed channel offsets. Glitchy sibling of chromatic aberration.

Prop
Type
Default
Values
intensity
number
0.5
0 – 1 · step 0.01
seed
number
0
0 – 100 · step 1
vhs

Combo: jitter + chromatic offset + scanlines + soft blur. One knob.

Prop
Type
Default
Values
intensity
number
0.6
0 – 1 · step 0.01
seed
number
0
0 – 100 · step 1
pixelSort

Cheap procedural fake — bright pixels stretched into horizontal bars.

Prop
Type
Default
Values
intensity
number
0.6
0 – 1 · step 0.01
threshold
number
0.5
0 – 1 · step 0.01
datamosh

Block-snapped noise warp mimicking codec corruption.

Prop
Type
Default
Values
intensity
number
0.5
0 – 1 · step 0.01
seed
number
0
0 – 100 · step 1
ascii

Quantise the source into a grid of glyphs picked by cell luminance — sparse to dense, source colour preserved as the ink.

Prop
Type
Default
Values
size
number
12
4 – 32 · step 1
intensity
number
1
0 – 1 · step 0.01
dither

Bayer-matrix ordered dither + per-channel quantisation.

Prop
Type
Default
Values
intensity
number
1
0 – 1 · step 0.01
levels
number
4
2 – 32 · step 1
gradientMap

Three-stop luma gradient — duotone with a midtone hinge.

Prop
Type
Default
Values
intensity
number
1
0 – 1 · step 0.01
shadow
vec3
[0.05, 0.05, 0.15]
vec3 (read-only in playground)
midtone
vec3
[0.65, 0.18, 0.55]
vec3 (read-only in playground)
highlight
vec3
[1, 0.85, 0.45]
vec3 (read-only in playground)
blur

Separable Gaussian blur. Two passes, identity at radius 0.

Prop
Type
Default
Values
radius
number
16
0 – 32 · step 0.5
tiltShift

Selective separable blur with a rotatable in-focus band. Identity at blurRadius 0.

Prop
Type
Default
Values
focus
vec2
[0.5, 0.5]
vec2 (read-only in playground)
angle
number
0
0 – 3.14 · step 0.01
focusWidth
number
0.25
0.05 – 0.8 · step 0.01
blurRadius
number
16
0 – 32 · step 0.5
bloom

Bright-highlight halo via 4-pass HDR ping-pong. Additive composite.

Prop
Type
Default
Values
intensity
number
1
0 – 2 · step 0.01
threshold
number
0.8
0 – 1 · step 0.01
softness
number
0.1
0 – 0.5 · step 0.01
radius
number
32
1 – 64 · step 1
glow

Wider, softer, screen-blended sibling of bloom. Tintable halo.

Prop
Type
Default
Values
intensity
number
0.7
0 – 2 · step 0.01
threshold
number
0.3
0 – 1 · step 0.01
softness
number
0.2
0 – 0.5 · step 0.01
radius
number
48
1 – 96 · step 1
tint
vec3
[1, 1, 1]
vec3 (read-only in playground)

Cookbook

Recipes for the patterns the API doesn't make obvious. Every recipe is one snippet long; copy, paste, run.

Chain effects: blurbloom

The cheapest way to stack effects is to render the first to canvas, then render the second using the canvas itself as the source. The texture cache re-uploads the canvas every render, so each call sees the previous frame's pixels. Cheap, idiomatic, composable.

import { Runner, blur, bloom } from "@vysmo/effects";

// Two effects, one canvas. The runner draws to canvas directly each
// render(); the second call reads the FIRST render's output by passing
// the canvas itself as the source. The texture cache re-uploads the
// canvas every render so each call sees the previous frame's pixels.
const runner = new Runner({ canvas });

runner.render(blur, { source: image, params: { radius: 8 } });
runner.render(bloom, {
  source: canvas,            // ← read the blurred output
  params: { intensity: 1.2, threshold: 0.6 },
});

Animate a param per frame

Every numeric param can be animated by re-rendering at a new value. The runner caches the compiled program per effect, so only the uniform upload runs on subsequent frames — animation is just a onUpdate callback.

import { Runner, glow } from "@vysmo/effects";
import { animate } from "@vysmo/animations";

// runner.render() is idempotent — animate any param by re-rendering on
// each onUpdate. The runner caches the compiled program per effect, so
// only uniform uploads run on subsequent frames.
const runner = new Runner({ canvas });

animate({
  from: 0, to: 1.6, duration: 1200,
  onUpdate: (intensity) => runner.render(glow, {
    source: image,
    params: { intensity, threshold: 0.4, radius: 64 },
  }),
});

Use a video as a source

source accepts any HTMLVideoElement; the texture cache re-uploads the current frame on each render() so the effect stays in sync with the video's playhead. Drive the loop from requestAnimationFrame while the video plays.

// Any HTMLVideoElement is a valid source. The texture cache treats
// videos as mutable, so each render() re-uploads the current frame.
const video = document.querySelector<HTMLVideoElement>("video")!;
video.play();

const runner = new Runner({ canvas });

function tick() {
  runner.render(vhs, {
    source: video,
    params: { intensity: 0.6, seed: performance.now() % 100 },
  });
  requestAnimationFrame(tick);
}
tick();

Source from another canvas

Any HTMLCanvasElement or OffscreenCanvas is a valid source. Hand-draw with the 2D API, offload to a worker via OffscreenCanvas, or generate a procedural texture — the runner re-uploads on each render.

// Source another canvas — your own custom 2D drawing, an OffscreenCanvas
// from a worker, a pre-rendered procedural texture. The runner re-uploads
// every render() so any updates flow through.
const drawingCtx = drawingCanvas.getContext("2d")!;
drawingCtx.fillRect(40, 40, 80, 80);

const runner = new Runner({ canvas });
runner.render(halftone, {
  source: drawingCanvas,
  params: { intensity: 1, dotSize: 12 },
});

Multiple canvases on one page

One Runner per canvas. They share no GPU state — different effects, different sources, different params, all drawing in parallel. The browser caps WebGL contexts per page (~16 in Chrome), so dispose runners whose canvases unmount.

// One Runner per canvas. They share no GPU state — different effects,
// different sources, different params, all drawing in parallel. The
// browser caps WebGL contexts per page (~16 in Chrome) so dispose any
// you don't need anymore.
const heroRunner   = new Runner({ canvas: heroCanvas });
const detailRunner = new Runner({ canvas: detailCanvas });

heroRunner.render(bloom,    { source: heroImg,    params: { intensity: 1.2 } });
detailRunner.render(duotone, { source: detailImg, params: { intensity: 1 } });

// Always dispose runners whose canvases unmount.
heroRunner.dispose();
detailRunner.dispose();

Drive params from scroll

@vysmo/scroll ships createScrollEffect that subscribes a Runner to a scroll progress observer. Pass an effect plus a (progress) => params mapper and you get a scroll-driven blur, glow, or any other effect — same rAF-throttled observer the rest of the library uses.

import { Runner, blur } from "@vysmo/effects";
import { createScrollEffect } from "@vysmo/scroll";

const runner = new Runner({ canvas });

// As #hero scrolls past, blur ramps from 0 → 24px. Same hooks work for
// any effect — pass an effect + a function that maps progress to params.
createScrollEffect({
  section: document.querySelector("#hero")!,
  runner,
  effect: blur,
  source: image,
  params: (p) => ({ radius: p * 24 }),
});

Author a custom effect with defineEffect

The 30 built-ins cover the common ground; for the unique-to-your- product effect, defineEffect gives you the same shader shell with a typed defaults object. Param types are inferred — never hand-type Effect<{...}>. Custom uniforms map automatically: defaults.warmth → uniform uWarmth.

import { defineEffect, Runner } from "@vysmo/effects";

// Sepia + warm shadows in one shader pass — beats two render() calls
// on cost. Custom uniforms map automatically: defaults.warmth → uWarmth.
export const sepiaWarm = defineEffect({
  name: "sepia-warm",
  defaults: {
    strength: 1,
    warmth: 0.25,
  },
  glsl: `
uniform float uStrength;
uniform float uWarmth;

vec4 effect(vec2 uv) {
  vec4 src = getSource(uv);
  vec3 sepia = vec3(
    dot(src.rgb, vec3(0.393, 0.769, 0.189)),
    dot(src.rgb, vec3(0.349, 0.686, 0.168)),
    dot(src.rgb, vec3(0.272, 0.534, 0.131))
  );
  // Push shadows toward warm orange in proportion to uWarmth.
  float lift = (1.0 - dot(sepia, vec3(0.299, 0.587, 0.114))) * uWarmth;
  sepia += vec3(lift * 0.2, lift * 0.1, 0.0);
  return vec4(mix(src.rgb, sepia, uStrength), src.a);
}`,
});

new Runner({ canvas }).render(sepiaWarm, {
  source: image,
  params: { strength: 0.8, warmth: 0.4 },
});

Override only what you change

params is a Partial of effect.defaults. Anything you don't override falls back, so as you tweak you only ever spell out the dial that moved — the snippet stays compact.

// Override only what you change. Defaults fill in the rest, so the
// snippet stays compact as you tweak.
runner.render(bloom, {
  source: image,
  params: { intensity: 1.5 }, // threshold/softness/radius come from bloom.defaults
});

FAQ & gotchas

The handful of things that catch people the first time they ship a WebGL effect.

Can I use this without a canvas — apply effects to a CSS element?

No. Effects render to a WebGL2RenderingContext; there's no DOM-only fallback. If you need filters on a DOM element, the CSS filter property covers most of what blur / brightness / saturate / hue-rotate do — at the cost of less control and worse performance for moving content.

Does it work with video sources?

Yes. Pass an HTMLVideoElement as source. The texture cache re-uploads on each render(), so drive it from a requestAnimationFrame loop while the video plays.

Is bloom's HDR mode required?

On GPUs that support EXT_color_buffer_float, yes — highlights above 1.0 survive the bright-pass blur before the additive composite. On older devices the runner silently falls back to RGBA8; the result is dimmer but doesn't break.

Why doesn't defineEffect let me declare uniforms?

They're inferred from defaults. The runner walks your defaults object, maps each key to a camel-cased uniform (strengthuStrength), and sets the uniform on every render. Removing manual declarations is the whole point of the defineX pattern.