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.
import { init, effect, sampler, surface, target } from "vgpu";
const gpu = await init();
const canvas = document.querySelector("canvas")!;
const canvasSurface = surface(gpu, canvas);
// ---cut---
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 canvasReach 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.
import { clock, init, effect, surface } from "vgpu";
const gpu = await init();
const canvas = document.querySelector("canvas")!;
const canvasSurface = surface(gpu, canvas);
// ---cut---
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:
import { clock, init, effect, surface } from "vgpu";
const gpu = await init();
const canvas = document.querySelector("canvas")!;
const canvasSurface = surface(gpu, canvas);
const pulse = effect(gpu, `
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 {
return vec4f(uv, sin(params.time) * 0.5 + 0.5, 1.0);
}
`, { set: { params: { time: 0, width: canvasSurface.size[0], height: canvasSurface.size[1] } } });
// ---cut---
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.