Skip to content

Bulk sprites ​

draw.sprites(frame, positions, count?, options?, each?) draws one frame at many positions in a single call. On WebGL2, a reused Float32Array is uploaded to the GPU as is, with no JavaScript work per copy. Use it for particles, bullets, swarms and starfields.

ts
const MAX = 20_000;
const pos = new Float32Array(MAX * 2); // x0, y0, x1, y1, ...
const vel = new Float32Array(MAX * 2);
let live = 0;

function update(dt: number) {
  for (let i = 0; i < live * 2; i++) pos[i] += vel[i] * dt;
}

function render() {
  draw.sprites(spark, pos, live, { tint: "#ffd36b", alpha: 0.9 });
}

Arguments ​

  • frame: an image or Frame, as for draw.sprite().
  • positions: x, y pairs. Pass the same Float32Array every frame.
  • count: how many copies to draw. Omit it to draw every pair in positions.
  • options: the same SpriteDrawOptions as sprite() (origin, scaleX, scaleY, rotation, flipX, flipY, alpha, tint, flash, smooth), applied to every copy.
  • each: optional per-copy arrays, below.

Per-copy values with each ​

Sparks that shrink, debris that spins, embers that fade: pass per-copy arrays as the fifth argument.

ts
const scale = new Float32Array(MAX);
const rotation = new Float32Array(MAX);
const color = new Uint32Array(MAX); // 0xRRGGBBAA

function spawn(i: number, x: number, y: number) {
  pos[i * 2] = x;
  pos[i * 2 + 1] = y;
  scale[i] = 1;
  rotation[i] = Math.random() * Math.PI * 2;
  color[i] = 0xffd36bff;
}

function fade(i: number, lifeLeft: number) {
  scale[i] = lifeLeft;
  color[i] = (0xffd36b00 | Math.round(255 * lifeLeft)) >>> 0;
}

draw.sprites(spark, pos, live, {}, { scale, rotation, color });
  • scale (Float32Array) multiplies the shared scaleX and scaleY.
  • rotation (Float32Array, radians) adds to the shared rotation.
  • color (Uint32Array of 0xRRGGBBAA) multiplies the shared tint and alpha. Keep the >>> 0 when you build a value, so it stays unsigned.
  • Pass only the arrays you need. They're uploaded as is too.

Why it's fast ​

A draw.sprite() loop does a little JavaScript per sprite: reading options, transform maths, writing an instance. With sprites() on WebGL2, spryte hands your Float32Array (and any each arrays) straight to the GPU, which expands each copy. Your own update loop is the only per-particle work. Other array types for positions work, but are copied into a staging array first.

The output is pixel-identical to a sprite() loop with the same values, including pixel snapping: with smooth off, unrotated copies land on whole pixels. spryte checks this on a real GPU in its browser tests, so you can switch an effect from a loop to sprites() without it looking different.

On spryte's own benchmark (desktop Chrome, RTX 5060 laptop, 3 rounds), 200,000 particles with per-particle scale, rotation, tint and alpha all changing took 7.7 ms a frame, against 28.9 ms for PixiJS 8's ParticleContainer. Method and caveats are on the benchmarks page.

Limits ​

  • One frame per call. For a few hundred sprites from many images, a draw.sprite() loop is simpler.
  • Inside withShader, sprites() falls back to one quad per copy. It's still correct, but without the bulk speed-up.
  • On the Canvas 2D fallback, each copy is drawn as its own quad.
  • count is clamped to the shortest array you pass (positions pairs, scale, rotation, color), so a short array never reads garbage. A NaN or negative count draws nothing.
  • One draw call that breaks the batch. On WebGL2, sprites() is its own draw call, so calling it once per particle defeats the point. Call it once per layer.

Draw order ​

sprites() draws after everything before it and before everything after it. Put each layer (smoke behind, sparks in front) in its own call, in the order you want:

ts
draw.sprites(smoke, smokePos, smokeLive, { alpha: 0.5 });
draw.sprite(player, px, py);
draw.blend("add");
draw.sprites(spark, sparkPos, sparkLive, { tint: "#ffd36b" }, { color: sparkColor });
draw.blend("normal");

Next: Shapes and text