Effects

An Effect is a full-screen fragment shader created with effect(gpu, source). Its pipeline compiles lazily on first use; call await effect.compile(target) during load if you want to pre-warm it. See Compilation for the full pre-warm flow. Every draw fills the whole target — you only write the fragment.

Effects chain through targets: render one effect into an offscreen Target, then bind that target as a texture input of the next effect with set().

The uv varying that effect(gpu) injects is top-origin: (0, 0) is the top-left corner and v grows downward — the same convention as WebGPU texture coordinates, @builtin(position), and target.read(). Sampling any texture with this uv needs no flip: a pass that samples src at uv reproduces the image exactly. If you are porting a WebGL or Shadertoy shader that assumes v grows upward, invert once at the boundary (1.0 - uv.y) and keep everything else flip-free.

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const sceneSource = `
  @fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
    return vec4f(uv, 1.0, 1.0);
  }
`;
 
// Post-processing: reads the scene texture and inverts its colors.
const postSource = `
  @group(0) @binding(0) var src: texture_2d<f32>;
  @group(0) @binding(1) var samp: sampler;
 
  @fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
    let base = textureSampleLevel(src, samp, uv, 0.0);
    return vec4f(1.0 - base.rgb, 1.0);
  }
`;
 
const scene = target(gpu, { size: [1280, 720] });
 
const sceneEffect = effect(gpu, sceneSource);
const post = effect(gpu, postSource);
post.set({
  src: scene,
  samp: sampler(gpu, { minFilter: 'linear', magFilter: 'linear' }),
}); // the offscreen result becomes the post input
 
sceneEffect.draw(scene); // render the scene offscreen
post.draw(canvasSurface); // invert it onto the canvas

Reach for textureLoad only when you need exact texels or an unfilterable format — for ordinary sampling, a filtering sampler is simpler and faster.

post.set(...) exposes the offscreen result and filtering sampler to WGSL as bindings named src and samp. Each one-shot draw() encodes and submits its own work immediately, in call order.

Updating bindings

You can update bindings at any time by using .set.

set() writes immediately — there is no change detection, so every call is a real GPU write. Match your calls to how often values actually change: constants once at creation, size- and resolution-class uniforms at init and on resize, and per-frame calls only for genuinely dynamic values like time or pointer input. Rebinding the same resources is free — bind groups are cached by resource identity — so this rule is purely about avoiding redundant writes.

One more rule keeps multi-pass frames predictable: a frame records into a single command buffer, and set() writes land before any of it executes — so re-recording the same effect with mutated uniforms makes every pass read the final values. When two passes need different values (a horizontal and a vertical blur, say), create two effects; they are cheap, and each owns its uniforms.

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const pulseSource = `
  struct Params { time: f32, width: f32, height: f32 }
  @group(0) @binding(0) var<uniform> params: Params;
 
  @fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
    let glow = sin(params.time) * 0.5 + 0.5;
    return vec4f(uv.x, uv.y, glow, 1.0);
  }
`;
 
const pulse = effect(gpu, pulseSource, {
  // initial uniform defaults
  set: {
    params: {
      time: 0,
      width: canvasSurface.size[0],
      height: canvasSurface.size[1]
    }
  },
});
 
// update uniforms before drawing
pulse.set({
  params: {
    time: clock(gpu).time,
  },
});
 
pulse.draw(canvasSurface);

You should also only update uniforms when they need to change, for example, react to canvas size changes:

TypeScript
1
2
3
const unsubscribe = canvasSurface.onResize(({ width, height }) => {
  pulse.set({ params: { width, height } }); // partial update: time keeps its value
});

onResize() fires the callback once immediately with the current size, then again on every resize. It returns an unsubscribe function — call it when you tear the effect down.

API in this chapter