There's a category of visual work the browser is genuinely good at and yet most projects either skip or reinvent: putting a filter on top of an image or a video. Blur a hero photo. Glow on hover. Halftone for the print-design aesthetic. ASCII for the terminal vibe. A scanline VHS pass for nostalgia.
You can do these in WebGL, but the gap between "I want a bloom on this image" and "working ping-pong framebuffer pipeline with HDR float targets" is wide enough that most people give up and reach for a CSS filter: blur(8px) and live with it.
@vysmo/effects is the tour I would have wanted when I first looked at this. 30 effects, one API, ~9 KB for the whole thing. Multi-pass pipelines are handled for you.
Four lines
import { Runner, blur } from "@vysmo/effects";
const runner = new Runner({ canvas });
runner.render(blur, { source: image, params: { radius: 12 } });
That's a real, shippable image filter — GPU-accelerated, runs at 60 fps on a phone. The source can be an HTMLImageElement, a canvas, a video, an ImageBitmap, an OffscreenCanvas, or ImageData. The Runner uploads it once and caches the compiled shader program between renders, so if you change radius and re-render, you skip compilation entirely.
Swap blur for any of 29 others and you get a totally different look from the same five lines:
runner.render(halftone, { source: image, params: { dotSize: 4 } });
runner.render(scanlines, { source: image, params: { intensity: 0.7 } });
runner.render(ascii, { source: image, params: { charSize: 8 } });
runner.render(oilPaint, { source: image, params: { brushSize: 6 } });
runner.render(swirl, { source: image, params: { angle: 1.2 } });
Each of those is one line and one new aesthetic. The catalog at vysmo.com/effects has all 30 with a live playground — pick one, drop your own image, tune the params.
Where this gets technically interesting: multi-pass HDR
Most filter effects are a single fragment shader pass: sample the image, do some math, write the output. Easy. The Runner draws once per render() call.
But some effects can't be done that way. Bloom is the canonical example. To make highlights glow believably, you need:
- A bright-pass filter that isolates pixels above a luminance threshold
- Several passes of a separable Gaussian blur on those highlights (horizontal, then vertical — multiple radii)
- A composite pass that adds the blurred highlights back over the original
Four passes, minimum. And to do it correctly, those intermediate textures need to be HDR floating-point (
RGBA16F) — because once you've isolated highlights at 2× or 3× max brightness, an 8-bit-per-channel buffer clamps them to 1.0 and your bloom looks flat and ugly.
In @vysmo/effects, this is the API:
runner.render(bloom, {
source: image,
params: { intensity: 1.2, threshold: 0.7 },
});
Same shape as the blur example. One line. Internally the Runner allocates ping-pong RGBA16F framebuffers when the GPU supports them (every WebGL2 device built since 2017 does), runs the four-pass pipeline, and disposes the intermediate targets when you call runner.dispose(). You never see any of this.
The same architecture handles glow (similar multi-pass with a different bright-pass response curve), motion blur (multi-tap directional sampling), and datamosh (frame-history accumulation).
If you've ever tried to ship bloom from scratch with vanilla WebGL, you know how much code that hides.
The 30 effects, roughly grouped
Image cleanup and adjustment — blur, sharpen, color-grade, tint, dither, gradient-map. The everyday tools.
Print and editorial aesthetics — halftone, oil-paint, ascii. The looks designers reach for to make web feel like print.
Cinematic and lens — bloom, glow, tilt-shift, lens-distortion, motion-blur. The looks that make web feel like film.
Retro and degraded — scanlines, vhs, datamosh. The looks people use when nostalgia is the point.
Distortion and warp — wave, swirl. The looks for headers that want to feel alive.
Plus another dozen I'm not going to enumerate because that's what the catalog page is for.
A single-pass effect costs you a few hundred bytes in your bundle. A multi-pass effect costs you maybe a kilobyte. Import the two or three you actually use and your effects bundle is well under 2 KB.
Authoring your own
Same defineX pattern as transitions. Write a fragment shader that exports vec4 effect(vec2 uv), declare your params, done:
import { defineEffect, Runner } from "@vysmo/effects";
const sepia = defineEffect({
name: "sepia",
defaults: { strength: 0.8 },
glsl: `
uniform float uStrength;
vec4 effect(vec2 uv) {
vec4 src = getSource(uv);
float gray = dot(src.rgb, vec3(0.299, 0.587, 0.114));
vec3 sepia = vec3(gray * 1.2, gray * 1.0, gray * 0.8);
return vec4(mix(src.rgb, sepia, uStrength), src.a);
}
`,
});
runner.render(sepia, { source: image, params: { strength: 0.6 } });
The Runner allocates FBOs as needed, manages uniforms, and dispatches passes — you write the math. The defaults object also drives type inference, so params is fully typed in your editor without you writing Effect<{ strength: number }> anywhere.
How it composes
Two patterns that show up constantly in real projects.
Effects on video. Pass a <video> element as source and re-render every frame:
const video = document.querySelector<HTMLVideoElement>("video")!;
video.play();
function frame() {
runner.render(bloom, { source: video, params: { intensity: 0.8 } });
requestAnimationFrame(frame);
}
frame();
Effects driven by interaction. Hook the params object up to a slider, scroll progress, or mouse position. The Runner caches the compiled program, so re-rendering with different params is essentially free:
slider.addEventListener("input", (e) => {
runner.render(blur, {
source: image,
params: { radius: Number((e.target as HTMLInputElement).value) },
});
});
That's an interactive blur control in eight lines of code, with a draw call that costs less than half a millisecond on a modern phone.
Try it
pnpm add @vysmo/effects
Then four lines:
import { Runner, bloom } from "@vysmo/effects";
const runner = new Runner({ canvas });
const image = document.querySelector("img")!;
await image.decode();
runner.render(bloom, { source: image });
Open vysmo.com/effects for the catalog and the playground where you can drop your own image and try every effect at every parameter combination. Source and issue tracker at github.com/vysmodev/vysmo.
All Vysmo libraries are MIT, zero dependency, free forever. There's no commercial tier and no telemetry.

Top comments (0)