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";
const canvas = document.querySelector<HTMLCanvasElement>("#stage")!;
const runner = new Runner({ canvas });
const image = new Image();
image.crossOrigin = "anonymous";
image.src = "/photo.jpg";
await image.decode();
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.
Cookbook
Recipes for the patterns the API doesn't make obvious. Every recipe is one snippet long; copy, paste, run.
Chain effects: blur → bloom
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";
const runner = new Runner({ canvas });
runner.render(blur, { source: image, params: { radius: 8 } });
runner.render(bloom, {
source: canvas,
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";
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.
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.
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.
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 } });
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 });
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";
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.
runner.render(bloom, {
source: image,
params: { intensity: 1.5 },
});