spryte inside a Phaser game
A Phaser 4 game can keep everything it has and hand its heaviest drawing (bullets, particles, swarms) to spryte. spryte draws inside Phaser's frame, at the depth you choose, on the same WebGL2 context: no overlay canvas, no second copy of textures, and Phaser objects can pass in front of what spryte draws. Needs spryte 0.4 or later and Phaser 4 (tested with 4.2.1).
import Phaser from "phaser";
import { createPhaserContext, spryteExtern } from "spryte/phaser";
class Main extends Phaser.Scene {
create() {
// Phaser draws the background at depth 0 and the HUD at depth 2...
this.add.image(400, 225, "background").setDepth(0);
this.add.text(12, 12, "score 0").setDepth(2);
// ...and spryte draws the bullets between them.
const extern = this.add.extern().setDepth(1);
spryteExtern(extern, (draw) => {
draw.sprites(bullet, positions, count);
});
}
}
new Phaser.Game({
type: Phaser.WEBGL,
...createPhaserContext(), // a WebGL2 context: Phaser makes WebGL1 on its own
width: 800,
height: 450,
scene: Main,
});How it fits together
createPhaserContext()makes a WebGL2 context and returns{ canvas, context }to spread into the game config, withtype: Phaser.WEBGL. Phaser creates a WebGL1 context by default; spryte needs WebGL2, and Phaser runs on one. Its second argument sets the context's attributes, which default to Phaser's own (opaque, antialiased, premultiplied, with a stencil buffer). Pass your own canvas as the first argument if the game already has one.spryteExtern(extern, render)sets the Extern'srender. Each frame, in Phaser's draw order, yourrender(draw)runs with aDrawwhose transform is already Phaser's: the camera (scroll, zoom, rotation) and any Container the Extern sits in. So coordinates are the ones Phaser uses, anddraw.spritestakes the same typed arrays a Phaser emitter would have needed objects for.- spryte restores nothing Phaser didn't already expect. Phaser resets its own state after any Extern, and spryte draws under Phaser's drawing context: into its framebuffer, under its scissor and stencil.
The Draw exists from the first frame on. spryteExtern returns { draw, dispose }; dispose runs for you when the Extern is destroyed, so a scene shutdown frees spryte's GPU objects.
What still applies
Because spryte draws into Phaser's drawing context under Phaser's state, Phaser's features work on it:
- Filters.
extern.enableFilters()andextern.filters.internal.addBlur(...)filter what spryte drew. Camera filters include it too. - Camera viewports. With several cameras (a minimap, split screen), spryte draws inside each camera's viewport, with that camera's transform.
Stencilmasks. AStencilgame object before the Extern masks spryte's drawing like any Phaser object's.- Depth.
setDepthon the Extern puts spryte's drawing anywhere in the display list. Use several Externs for several depths; each has its ownrender.
What doesn't carry over: the Extern's own alpha, tint and blendMode properties, which Phaser documents as unused by Externs. Set alpha, tint and blend in your draw calls instead.
Why the shader shim
Phaser's shaders are written in GLSL ES 1.00. On a WebGL2 context, three Phaser 4.2.1 features fail to compile and stop the game rendering: the Gradient game object, noise objects with noiseNormalMap, and the smoothPixelArt render setting. They use screen-space derivatives (dFdx), which in ES 1.00 come from the OES_standard_derivatives extension, and WebGL2 doesn't offer that extension because ES 3.00 has derivatives built in.
createPhaserContext() installs shimShaders(gl), which translates every ES 1.00 shader the context compiles into ES 3.00, so those features render as they do on WebGL1 (checked pixel for pixel in spryte's tests). Your own ES 1.00 shaders go through it as well; shaders that already declare #version 300 es pass through untouched. The translation renames identifiers that became keywords in ES 3.00 (sample, smooth, texture and a few more), in uniform and attribute lookups too. If you'd rather make the context yourself, call shimShaders(gl) on it before new Phaser.Game.
Tips
- Keep bulk state in typed arrays.
draw.sprites(frame, positions, count, options, { scale, rotation, color })uploads them as they are: no per-bullet JavaScript, no objects to pool. Phaser's emitters do more out of the box (emit zones, gravity, lifespans), so with spryte that behaviour is a few lines of your own simulation. See Bulk sprites and particles. - Textures are yours. spryte draws any
HTMLImageElement, canvas orImageBitmap. For an image Phaser loaded, usethis.textures.get(key).getSourceImage(). - Units are Phaser's pixels.
renderis called withbegin(null, width, height, 1)already done for the drawing context's size, so text rasterises at one pixel per unit, like Phaser's own. - Render targets and shaders work inside.
draw.into,draw.createShaderandwithShaderall run in the Extern's slot. Create targets and shaders once, outsiderender, and reuse them. - Phaser 3 has an
Externwith a different callback and state handling. It isn't supported.
Measuring it
In 2dbench's particles scene (every property changing, 50k sprites, RTX 5060 laptop, 5 rounds), spryte's draw.sprites inside a Phaser Extern drew the frame in 1.4 ms against 7.1 ms for Phaser's SpriteGPULayer, and draw.sprite per sprite in 6.9 ms against 17.8 ms for Image. spryte on its own canvas took 1.3 ms, so being inside Phaser costs 0.1 ms, within the measurement's noise. The numbers, with the raw data, are in spryte inside Phaser. To measure your own game, time a frame with and without the Extern drawing, or move one effect over and compare.
See also: Migrating from Phaser for moving the whole renderer, Bulk sprites and particles, API reference.