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.
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 orFrame, as fordraw.sprite().positions: x, y pairs. Pass the sameFloat32Arrayevery frame.count: how many copies to draw. Omit it to draw every pair inpositions.options: the sameSpriteDrawOptionsassprite()(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.
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 sharedscaleXandscaleY.rotation(Float32Array, radians) adds to the sharedrotation.color(Uint32Arrayof0xRRGGBBAA) multiplies the sharedtintandalpha. Keep the>>> 0when 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.
countis clamped to the shortest array you pass (positionspairs,scale,rotation,color), so a short array never reads garbage. ANaNor 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:
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