spryte inside Phaser: 5x faster particles, no rewrite
A Phaser 4 game can now keep Phaser and hand its heaviest drawing to spryte. One Extern game object gives spryte a slot in Phaser's display list, on the same WebGL2 context, so bullets and particles draw at the depth you choose, under Phaser's cameras, filters and masks. In 2dbench's particles scene at 50k sprites, spryte inside Phaser drew the frame in 1.4 ms against 7.1 ms for Phaser's SpriteGPULayer (5.1x), and held 400,000 particles at 60 fps against 100,000. It costs 0.1 ms over spryte on its own canvas. It's in spryte 0.4 as spryte/phaser; the guide is spryte inside a Phaser game.
Results
RTX 5060 laptop (Ryzen 7 260), Chrome 155, AC power, 5 interleaved rounds per scene; median frame time from simulation to GPU completion, with every library drawing the same pixels (2dbench's parity gate). Phaser 4.2.1, PixiJS 8.22.0 as the parity reference. Raw data: results/community/2026-10-22-rtx-5060-laptop-phaser-spryte-*.json.
Particles, every property changing (position, rotation, scale, tint, alpha):
| 50k sprites | Frame | Most at 60 fps |
|---|---|---|
Phaser + spryte draw.sprites (one call) | 1.4 ms | 400,000 |
spryte draw.sprites on its own canvas | 1.3 ms | 400,000 |
Phaser SpriteGPULayer | 7.1 ms | 100,000 |
Phaser + spryte draw.sprite (one call per sprite) | 6.9 ms | 100,000 |
Phaser Image | 17.8 ms | 20,000 |
PixiJS Sprite (reference) | 21.0 ms | 20,000 |
Bunny, position only:
| 50k sprites | Frame | Most at 60 fps |
|---|---|---|
Phaser + spryte draw.sprites | 0.8 ms | 1,600,000 |
Phaser SpriteGPULayer | 5.6 ms | 100,000 |
Phaser Blitter | 7.2 ms | 100,000 |
Phaser + spryte draw.sprite | 4.8 ms | 100,000 |
Phaser Image | 11.8 ms | 50,000 |
At 200k the bulk gap holds: 5.7 ms against 25.0 ms in particles (4.4x), 2.6 ms against 21.1 ms in bunny (8.1x). Frames under 2 ms are low precision (why), so read the 0.1 ms between spryte inside Phaser and spryte alone as "the same". The "most at 60 fps" counts come from a ladder whose steps are up to 2x apart, so they're lower bounds. These are this run's numbers; the spryte vs Phaser post has its own run, with the full set of scenes.
The code
import Phaser from "phaser";
import { createPhaserContext, spryteExtern } from "spryte/phaser";
class Main extends Phaser.Scene {
create() {
this.add.image(400, 225, "background").setDepth(0);
this.add.text(12, 12, "score 0").setDepth(2);
// spryte draws at depth 1: over the background, under the HUD.
spryteExtern(this.add.extern().setDepth(1), (draw) => {
draw.sprites(bullet, positions, count, {}, { scale, rotation, color });
});
}
}
new Phaser.Game({ type: Phaser.WEBGL, ...createPhaserContext(), width: 800, height: 450, scene: Main });createPhaserContext() gives Phaser a WebGL2 context (it makes WebGL1 on its own). spryteExtern runs your render(draw) in the Extern's slot each frame, with Phaser's camera already applied, so coordinates are Phaser's. Bullets, particles and swarms stay in typed arrays and go to the GPU in one call; everything else in the game stays as it was.
Why one context matters
The obvious way to add a second renderer is a transparent canvas on top. That's what our migration guide suggested as a way to measure, and it works for measuring. For a game it doesn't: two GPU contexts, two copies of every shared texture, an overlay that must track Phaser's scale manager, and spryte always on top, so nothing it draws can pass behind a Phaser sprite.
Phaser's Extern exists for this. Before it calls you, Phaser flushes its batch and unbinds its textures; after, it reapplies its own cached GL state. Inside the slot, spryte binds what it needs, draws into Phaser's drawing context (the canvas, or the framebuffer a filter is rendering into) and leaves. The test that matters is a pixel diff: everything Phaser draws after the Extern, with nearest-filtered textures, tints, additive blending, Graphics, Text and a canvas texture re-uploaded every frame, has to come out identical to a run where the Extern draws nothing. It does, in Chrome, Firefox and WebKit.
Three things we had to fix
Building it turned up three problems, none of them in the obvious place.
- Phaser 4.2.1 breaks on a WebGL2 context. Phaser supports being handed a WebGL2 context, and most of it runs fine. But its
Gradientgame object, noise objects withnoiseNormalMap, and thesmoothPixelArtrender setting fail to compile and stop the game rendering. Their shaders are GLSL ES 1.00 and use screen-space derivatives, which in ES 1.00 come from theOES_standard_derivativesextension. WebGL2 doesn't expose that extension, because ES 3.00 has derivatives built in.createPhaserContextinstalls a shim that translates every ES 1.00 shader to ES 3.00 as it's compiled; those features then render pixel for pixel as on WebGL1. Reproduces on all three engines, so it's Phaser, not a browser. - Sampler objects override the host's filtering. spryte sets texture filtering with WebGL2 sampler objects, which take precedence over the per-texture filter parameters that WebGL1-era code uses. Phaser never binds samplers, so after spryte drew, Phaser's nearest-filtered pixel art came out blurred: 30,000 changed pixels in a 640x360 test frame. spryte now unbinds its samplers when it hands the context back.
- Scissor and stencil belong to the host. Our first plan reset the scissor at the start of spryte's frame. With two cameras on screen (a minimap, split screen), that drew over the whole canvas: 60,800 pixels outside the viewports. And Phaser's stencil masks need the stencil test state of the drawing context, not whatever was left by the
Stencilobject that wrote the mask. The helper applies Phaser's fullDrawingContextstate before spryte draws, and spryte leaves scissor and stencil as it finds them, intersecting its own clips with the host's scissor box. Viewports,Stencilmasks and filters all pass pixel checks now.
A fourth one was smaller: Phaser's calcMatrix can carry skew (a rotated child in a non-uniformly scaled Container), which translate, rotate and scale can't express. spryte 0.4 adds draw.transform(a, b, c, d, e, f) and the helper uses it; the approximation left 134 wrong pixels in a test frame, the exact matrix none.
What this doesn't show
One laptop GPU in one browser version, with Playwright's Firefox and WebKit builds for the correctness checks, not Safari's own GPU path. Phaser 3 has an Extern with a different callback and state handling and isn't supported. The run used spryte 0.4's code from the debuff repository before its npm release, which is why the results files record it as 0.3.0. And the scenes draw only sprites: a frame that's mostly Phaser's own objects gains in proportion to what you hand over.
Try it on your own game: npm install spryte, then spryte inside a Phaser game. If your Phaser setup in 2dbench can be made faster, send a PR; results are rerun and published.