DEV Community

TommyDee
TommyDee

Posted on

Meet @vysmo/effects — 30 WebGL2 filter effects in one render() call

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.

WebGL Effects

Four lines

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

const runner = new Runner({ canvas });
runner.render(blur, { source: image, params: { radius: 12 } });
Enter fullscreen mode Exit fullscreen mode

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 } });
Enter fullscreen mode Exit fullscreen mode

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:

  1. A bright-pass filter that isolates pixels above a luminance threshold
  2. Several passes of a separable Gaussian blur on those highlights (horizontal, then vertical — multiple radii)
  3. 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 },
});
Enter fullscreen mode Exit fullscreen mode

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 adjustmentblur, sharpen, color-grade, tint, dither, gradient-map. The everyday tools.

Print and editorial aestheticshalftone, oil-paint, ascii. The looks designers reach for to make web feel like print.

Cinematic and lensbloom, glow, tilt-shift, lens-distortion, motion-blur. The looks that make web feel like film.

Retro and degradedscanlines, vhs, datamosh. The looks people use when nostalgia is the point.

Distortion and warpwave, 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 } });
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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) },
  });
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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)