Skip to content

Render targets and shaders ​

A render target is an offscreen image you draw into, then draw like any image. A shader is a custom fragment effect for everything drawn inside a scope. Together they cover post-processing, glow, cached static layers, minimaps, trails, outlines, palette swaps and CRT effects.

ts
const scene = draw.createTarget(320, 180);
const crt = draw.createShader(`
  uniform float time;
  vec4 effect(vec4 color, vec2 uv) {
    float scan = 0.85 + 0.15 * sin(uv.y * 900.0);
    return source(uv) * color * vec4(vec3(scan), 1.0);
  }
`);

function frame(t: number) {
  draw.begin("#000", 320, 180, scale);
  draw.into(scene, () => drawWorld(), "#102030");
  crt.set("time", t / 1000);
  draw.withShader(crt, () => draw.sprite(scene, 0, 0, { origin: [0, 0] }));
  draw.end();
}

Render targets ​

Method
createTarget(width, height)A new target, sized in whole pixels.
into(target, fn, clear?)Run fn with drawing going into target, then switch back.
disposeTarget(target)Free its GPU memory. Drawing it afterwards recreates it, blank.

Inside into(), units are the target's pixels from its top-left. The transform, clips and global alpha start fresh, and the outer ones are restored afterwards, even if fn throws. into() nests, so you can draw a target into another.

The clear argument fills the target first:

  • Omitted: transparent.
  • A colour: that colour.
  • null: keep what's there, so new drawing goes on top. That's how trails and motion blur accumulate.
ts
const trails = draw.createTarget(320, 180);

draw.into(trails, () => {
  draw.rect(0, 0, 320, 180, "#0000000f"); // fade the old frame a little
  draw.sprite(comet, comet.x, comet.y);
}, null);
draw.sprite(trails, 0, 0, { origin: [0, 0] });

What carries in, and what throws ​

  • The blend mode and an active withShader are not reset by into(). They apply inside the target and stay as fn left them. begin() resets both each frame.
  • Drawing a target into itself throws. So does disposing a target while you're drawing into it, or making one larger than the GPU's MAX_TEXTURE_SIZE.
  • Targets work on both backends.
  • After a WebGL context loss, target contents are blank. Check draw.contextLost, or redraw targets each frame.

Shaders ​

You write a function effect(color, uv) that returns the colour of one pixel. source(uv) samples the image being drawn.

ts
const wave = draw.createShader(`
  uniform float time;
  vec4 effect(vec4 color, vec2 uv) {
    uv.x += sin(uv.y * 40.0 + time * 6.0) * 0.01;
    return source(uv) * color;
  }
`);

wave.set("time", t);
draw.withShader(wave, () => draw.sprite(hero, x, y));
  • color is the draw's tint × alpha, premultiplied. Return premultiplied colour too (rgb already multiplied by alpha); source(uv) * color does the right thing.
  • A sprite's flash is applied after your effect.
  • set(name, value) takes a float, or [x, y], [x, y, z] or [x, y, z, w]. It affects only draws made after it, so you can change a uniform between two draws in the same scope.
  • withShader(shader, fn) restores the previous shader afterwards, and nests.
  • Shaders compile on first use. Errors throw with the driver's message, and line numbers refer to your source. A shader that fails to compile leaves the renderer as it was, and using it again throws again.

Caveats ​

  • Shaders are WebGL2 only. On the Canvas 2D fallback, draws inside withShader happen without the effect, with one console warning.
  • sprites() inside withShader falls back to one quad per copy: correct, but without the bulk speed-up.

Recipe: full-screen post-processing ​

Draw the scene into a target the size of your logical view, then draw the target through a shader:

ts
const scene = draw.createTarget(320, 180);
const vignette = draw.createShader(`
  uniform float strength;
  vec4 effect(vec4 color, vec2 uv) {
    float d = distance(uv, vec2(0.5));
    return source(uv) * color * vec4(vec3(1.0 - d * strength), 1.0);
  }
`);
vignette.set("strength", 0.8);

function frame() {
  draw.begin("#000", 320, 180, canvas.width / 320);
  draw.into(scene, () => drawWorld(), "#102030");
  draw.withShader(vignette, () => draw.sprite(scene, 0, 0, { origin: [0, 0] }));
  drawHud(); // after the effect, so the HUD stays sharp
  draw.end();
}

A target is sized in pixels, so a 320×180 target holds the scene at 320×180 resolution, whatever the canvas's backing size.

Recipe: glow ​

Draw the bright things into a small target, then add it back on top, scaled up and blended additively:

ts
// half resolution: cheap, and naturally soft
const glow = draw.createTarget(160, 90);

draw.into(glow, () => {
  draw.scale(0.5);
  draw.sprites(spark, sparks, live, { tint: "#ffd36b" });
});

drawWorld();
draw.blend("add");
draw.sprite(glow, 0, 0, { origin: [0, 0], scaleX: 2, scaleY: 2, smooth: true });
draw.blend("normal");

smooth: true on the upscale softens the half-resolution glow without a blur pass.

Verified output ​

spryte's browser tests run on a real GPU. They check that drawing into a target and then drawing the target 1:1 gives the same pixels as drawing directly.

Next: Pixel art