Skip to content

Getting started ​

spryte draws 2D graphics on any canvas in immediate mode: each frame you call begin(), draw in paint order, then call end(). It uses WebGL2 and falls back to Canvas 2D on its own. This page covers everything a standalone setup needs.

Install ​

bash
npm install spryte

Create a renderer ​

ts
import { createRenderer } from "spryte";

const canvas = document.querySelector("canvas")!;
// smooth: false for pixel art
const draw = createRenderer(canvas, { smooth: false });
// instant on WebGL2; waits for the fallback otherwise
await draw.ready;
console.log(draw.kind); // "webgl2", or "2d" on the fallback

createRenderer(canvas, options?) takes three options:

OptionDefault
backend"auto""auto" uses WebGL2 and falls back to Canvas 2D. "webgl2" throws if WebGL2 is unavailable. "2d" always uses Canvas 2D.
smoothtrueDefault image filtering. Use false for pixel art. Each sprite can override it.
transparentfalseA see-through canvas. Clear with an alpha colour such as "#0000" to draw over the page or another canvas. Opaque is slightly cheaper for the compositor.

draw.ready and the fallback ​

The Canvas 2D backend is a separate chunk, so WebGL2 browsers never download it. On the fallback, frames draw nothing until that chunk arrives (a few milliseconds). await draw.ready waits for it. On WebGL2 it resolves immediately, and it rejects if the fallback fails to load.

Size the canvas ​

You draw in logical units you choose, and you own the canvas's backing size. After changing canvas.width or canvas.height, call draw.resize():

ts
function resize(): number {
  const dpr = devicePixelRatio || 1;
  const w = Math.round(canvas.clientWidth * dpr);
  const h = Math.round(canvas.clientHeight * dpr);
  if (canvas.width !== w || canvas.height !== h) {
    canvas.width = w;
    canvas.height = h;
    draw.resize(w, h);
  }
  return dpr;
}

The frame loop ​

ts
function frame(time: number) {
  const dpr = resize();
  const w = canvas.clientWidth;
  const h = canvas.clientHeight;
  // clear colour, logical width and height, backing pixels per unit
  draw.begin("#111", w, h, dpr);
  draw.rect(10, 10, 40, 20, "#cffb2d");
  draw.sprite(hero, w / 2, h / 2, { rotation: time / 1000 });
  draw.text(`${draw.kind}: ${draw.drawCalls} draw calls`, 8, 40, { font: "14px sans-serif" });
  draw.end();
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

begin(clear, width, height, scale) clears the canvas and sets the logical size. scale is backing pixels per logical unit, usually canvas.width / width (CSS scale × devicePixelRatio). It also sets the resolution text is rasterised at. begin() resets the transform stack, clips, global alpha, blend mode and shader, so each frame starts clean. end() submits the frame.

For a fixed resolution, draw at 320×180 logical units with scale = canvas.width / 320. Pixel art covers that setup.

Load images before drawing them ​

spryte draws HTMLImageElement, HTMLCanvasElement, OffscreenCanvas and ImageBitmap objects, plus its own render targets. Each is uploaded to the GPU the first time you draw it and cached per object. Make sure the pixels have loaded first:

ts
async function load(url: string): Promise<HTMLImageElement> {
  const img = new Image();
  img.src = url;
  await img.decode();
  return img;
}

const hero = await load("/hero.png");
// or: const hero = await createImageBitmap(await (await fetch("/hero.png")).blob());

If you change the pixels of a canvas you draw from, call draw.invalidate(thatCanvas) so spryte re-uploads it. spryte never owns your images: their textures are dropped by invalidate() or dispose().

Context loss ​

On phones, the browser can take the GPU away from a backgrounded tab. spryte handles it. While draw.contextLost is true, drawing is skipped. When the context is restored, GPU objects are recreated and images re-upload on first use, with nothing for you to do. The only thing that doesn't come back is the contents of render targets: redraw those each frame, or when contextLost turns false.

Clean up ​

ts
draw.dispose(); // frees every GPU object and listener

Using a disposed Draw throws. Disposing twice does nothing.

Diagnostics ​

  • draw.kind: "webgl2" or "2d".
  • draw.quads: quads submitted this frame.
  • draw.drawCalls: GPU draw calls this frame on WebGL2, one per quad on Canvas 2D.
  • draw.raw: { kind: "webgl2", gl } or { kind: "2d", ctx }. If you mix raw calls with draw calls, restore any state you change.

Next: Sprites