Skip to content

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).

ts
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 ​

  1. createPhaserContext() makes a WebGL2 context and returns { canvas, context } to spread into the game config, with type: 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.
  2. spryteExtern(extern, render) sets the Extern's render. Each frame, in Phaser's draw order, your render(draw) runs with a Draw whose transform is already Phaser's: the camera (scroll, zoom, rotation) and any Container the Extern sits in. So coordinates are the ones Phaser uses, and draw.sprites takes the same typed arrays a Phaser emitter would have needed objects for.
  3. 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() and extern.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.
  • Stencil masks. A Stencil game object before the Extern masks spryte's drawing like any Phaser object's.
  • Depth. setDepth on the Extern puts spryte's drawing anywhere in the display list. Use several Externs for several depths; each has its own render.

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 or ImageBitmap. For an image Phaser loaded, use this.textures.get(key).getSourceImage().
  • Units are Phaser's pixels. render is called with begin(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.createShader and withShader all run in the Extern's slot. Create targets and shaders once, outside render, and reuse them.
  • Phaser 3 has an Extern with 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.