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.
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.
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
withShaderare not reset byinto(). They apply inside the target and stay asfnleft 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.
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));coloris the draw's tint × alpha, premultiplied. Return premultiplied colour too (rgb already multiplied by alpha);source(uv) * colordoes the right thing.- A sprite's
flashis 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
withShaderhappen without the effect, with one console warning. sprites()insidewithShaderfalls 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:
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:
// 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