MSDF (Multi-channel Signed Distance Field) font rendering for Phaser 4.
- Crisp text at any scale (no pixelation when zooming, single texture per font)
- Batched rendering — 1–2 draw calls per text object, regardless of length
- Shader-based outlines — sharp, or rounded corners on MTSDF atlases (no extra draw calls)
- Drop shadows — hard, or soft/glow on MTSDF atlases (extra pass, batched)
- Per-character display callbacks (wave, rainbow, jiggle, rotate, scale, …)
- Word wrapping with configurable wrap character
npm install phaser4-msdf-textPhaser 4 is a peer dependency — install it alongside if you haven't already:
npm install phaser@^4.1.0Register the global plugin in your Phaser game config. The plugin wires up the
BatchHandlerMSDF render node, the font cache, and verifies the required
OES_standard_derivatives extension — no separate renderNodes entry needed.
import * as Phaser from 'phaser';
import { MSDFPlugin } from 'phaser4-msdf-text';
new Phaser.Game({
type: Phaser.WEBGL,
width: 800,
height: 600,
plugins: {
global: [
{ key: 'MSDFPlugin', plugin: MSDFPlugin, start: true },
],
},
scene: [MyScene],
});If you prefer not to wire it up in the game config, call
installMSDFPlugin(game)fromcallbacks.postBoot— it registers the batch handler with the renderer directly.
Load fonts via the standard Phaser loader, then create text via the
add.msdfText factory. Rendering goes through a custom BatchHandler
(extending Phaser.Renderer.WebGL.RenderNodes.BatchHandler), so a page full
of text typically renders in 1–2 draw calls.
class MyScene extends Phaser.Scene {
preload() {
// Loads <key>.png and <key>.json by default, or pass explicit URLs.
this.load.msdfFont('arial', 'assets/fonts/Arial.png', 'assets/fonts/Arial.json');
}
create() {
const text = this.add.msdfText(400, 300, 'arial', 'Hello, MSDF!', 48);
text.setColor(0xffffff);
text.setCenterAlign();
text.setOrigin(0.5);
}
}Or via the creator API:
const text = this.make.msdfText({
x: 400, y: 300,
font: 'arial',
text: 'Hello, MSDF!',
fontSize: 48,
color: 0xffffff,
align: 'center', // 'left' (default), 'center' or 'right'
// Optional effects — same fields as setOutline / setShadow:
outline: { width: 1.5, color: 0x000000, rounded: true },
shadow: { offsetX: 4, offsetY: 4, alpha: 0.5, softness: 6 },
});Live, interactive demos — each link opens that example directly:
| Example | What it shows |
|---|---|
| Crisp at Any Scale | MSDF vs. bitmap fonts under extreme zoom — no pixelation |
| Outline | Sharp and rounded shader outlines |
| Glow & Drop Shadow | Hard shadows, soft shadows, and glow |
| Animated Effects | Per-character display callbacks — wave, rainbow, jiggle |
| Text Layout | Alignment, word wrap, and line spacing |
| Performance | Draw-call count under a heavy text load |
| Game UI Showcase | A mock game HUD — score counter, combo meter, damage numbers |
| RPG Loot Cards | Procedural item cards — mixed fonts, rarity-keyed outline & glow, crisp through every tilt |
Each example's source is in examples/scenes/.
// Chainable setters (Phaser-idiomatic)
text.setText('New content');
text.setFontSize(64);
text.setColor(0xff8800); // packed 0xRRGGBB
text.setColor('#ff8800'); // hex string or 'rgb(255, 136, 0)'
text.setColor({ r: 255, g: 136, b: 0 }); // object (0-255), optional `a`
text.setColor(0xff8800, 0.5); // optional alpha (0-1) overrides color's alpha
text.setCenterAlign(); // also setLeftAlign() / setRightAlign()
text.setLineSpacing(10);
// Or use property accessors directly
text.text = 'New content';
text.fontSize = 64;
text.align = 'center'; // 'left' (default), 'center' or 'right'
text.lineSpacing = 10;
text.width; // rendered width in local space (read-only)
text.height; // rendered height in local space (read-only)
text.getTextBounds(); // { width, height, lines: { count, lengths, shortest, longest } }align is the string union 'left' | 'center' | 'right' (exported as
MSDFAlign); setLeftAlign() / setCenterAlign() / setRightAlign() are
chainable shortcuts.
text.setMaxWidth(400); // Wrap to fit 400px (0 disables)
// Or: text.maxWidth = 400;
text.wordWrapCharCode = 32; // Default: space. Use 45 for hyphen, etc.text.setOutline(1.5, 0x000000, 1.0); // width (distance-field units), color, alpha
text.setOutline(1.5, 0x000000, 1.0, true); // rounded outer corners (MTSDF atlas only)
text.setOutline(3, 0x000000, 1.0, false, true); // layered — thick outline, no neighbour overlap
text.clearOutline();
text.hasOutline(); // boolean
// setOutline is a convenience wrapper — the fields can be set or tweened directly:
text.outlineWidth = 2; // distance-field units
text.outlineColor = 0x000000; // packed 0xRRGGBB
text.outlineAlpha = 1;
text.outlineRounded = true; // MTSDF atlas only
text.outlineLayered = true; // separate silhouette pass under the fillPractical outline widths are roughly 0.5–3.0. The shader can only represent
outlines up to about distanceRange / 2 distance-field units — beyond that,
the texture's distance field is saturated and the outline stops growing,
showing flat edges around the glyph's atlas cell instead. If you need thicker
outlines, regenerate the atlas with a larger -pxrange in msdf-atlas-gen
(and matching glyph padding) rather than pushing the width higher at runtime.
rounded rounds the outline's outer corners using the atlas's true
signed distance field. It requires an MTSDF atlas (generated with
-type mtsdf; see FONTS.md). On a plain MSDF font it is ignored
with a one-time console warning and the outline stays sharp. The letterforms
themselves stay crisp either way — only the outline edge rounds.
layered fixes the one drawback of the default single-pass outline: because the
outline is computed per glyph, a thick one can spill over and cover the previous
glyph. With layered, every glyph's outline silhouette is drawn first and every
glyph's fill goes on top, so neighbouring outlines never cover a glyph's face.
The cost is a second set of glyph quads (≈2× the outline's fragment work, still
within the same 1–2 draw calls), and because the outline now sits under the
fill rather than being composited with it in one pass, partially transparent
text shows the outline faintly through the fill. Leave it off (the default)
unless the outline is actually thick enough to overlap. Works on plain MSDF and
MTSDF alike, and combines with rounded and the drop shadow.
text.setShadow(4, 4, 0x000000, 0.5); // x, y, color, alpha
text.setShadow(4, 4, 0x000000, 0.5, 6); // soft shadow, 6-unit blur (MTSDF atlas only)
text.setShadow(0, 0, 0x33ccff, 0.8, 8); // zero offset + softness reads as a glow
text.clearShadow();
text.hasShadow();
// setShadow is a convenience wrapper — the fields can be set or tweened directly:
text.shadowX = 4;
text.shadowY = 4;
text.shadowColor = 0x000000; // packed 0xRRGGBB
text.shadowAlpha = 0.5;
text.shadowSoftness = 6; // distance-field units, MTSDF atlas onlysoftness is the shadow blur in distance-field units (0 = hard edge,
the default) — the same units as outlineWidth, so the blur scales with the
text at any size. Any value above 0 produces a soft shadow and requires an
MTSDF atlas; on a plain MSDF font it is ignored with a one-time console
warning. The maximum usable blur is the atlas distanceRange — for softer
shadows than that, regenerate with a larger -pxrange.
text.setDisplayCallback((glyphs, text) => {
const t = text.scene.time.now;
for (let i = 0; i < glyphs.length; i++) {
glyphs[i].y += Math.sin(i * 0.5 + t * 0.003) * 15;
}
});
text.clearDisplayCallback();The callback runs once per frame (not once per glyph) with the full array
of per-glyph state and the text object. Each glyphs[i] is seeded with the
text's effective position, colour, alpha, outline and shadow before you get it
— mutate it in place. The return value is ignored, and the same array is reused
every frame.
Each glyph exposes:
- transform —
x,y,scaleX,scaleY,rotation(about the glyph centre;scaleX/scaleY = 0hides it).setScale(v)sets both axes;setScale(x, y)sets them independently (squash/stretch). fill— the glyph face:{ color: Corners, alpha: Corners }.shadow—{ color, alpha, x, y }, controlled independently of the fill (only drawn if the text has a drop shadow).outline—{ color, alpha }(only drawn if the text has an outline).- read-only
indexandcharCode.
Corners is { topLeft, topRight, bottomLeft, bottomRight }. Colour is plain
0xRRGGBB and alpha is a separate 0–1 float — set them independently, with no
bit-packing. Scalar helpers cover the common "all four corners the same" case:
g.setScale(1.2, 0.8); // squash/stretch about the centre
g.setFillColor(0xff0000); // recolour the face, alpha untouched
g.setFillAlpha(0.5); // fade the face, colour untouched
g.setShadowColor(0x000033); g.setShadowAlpha(0.4);
g.setOutlineColor(0xffd200); g.setOutlineAlpha(1);Reach into the Corners objects directly for a gradient:
g.fill.color.topLeft = g.fill.color.topRight = 0xff5da8;
g.fill.color.bottomLeft = g.fill.color.bottomRight = 0x5db8ff;Outline width and shadow softness stay per-object (set via setOutline
/ setShadow); outline and shadow colour, alpha and offset are per-glyph.
For per-glyph effects that don't change every frame — a fixed gradient,
highlighted spans, a static rainbow — use editGlyphs() instead of a callback.
It hands you the same array, seeded once, and the text stops re-seeding it, so
your edits persist with zero per-frame cost:
const glyphs = text.editGlyphs();
glyphs[0].setFillColor(0xff4040);
glyphs[1].setFillColor(0x40ff40);The array is rebuilt and re-seeded whenever the glyph set changes (setText,
setFont, word-wrap), which clears your edits and emits a 'glyphsreset'
event so you can re-apply them:
text.on('glyphsreset', () => { /* re-apply per-glyph colours */ });Call text.resetGlyphs() to re-seed to the current defaults on demand.
this.load.msdfFont(key, textureURL?, fontDataURL?, textureXhrSettings?, fontDataXhrSettings?)
Defaults to <key>.png / <key>.json if URLs are omitted, following Phaser's
bitmapFont convention. Also accepts a config object or array of configs.
this.load.msdfFont({
key: 'arial',
textureURL: 'assets/fonts/Arial.png',
fontDataURL: 'assets/fonts/Arial.json',
});Fonts land in this.cache.custom.msdfFont as parsed MSDFFont instances —
add.msdfText looks them up by key automatically, but you can pull the
MSDFFont directly if you need to inspect glyph metrics or measure text.
Texture filtering: MSDF rendering relies on linear interpolation across the distance field. Phaser's default
LINEARfiltering works correctly. If you opt intoNEAREST(e.g. for a pixel-art project), MSDF edges will alias badly — use a bitmap font in that case.
See FONTS.md for the msdf-atlas-gen workflow. In short:
msdf-atlas-gen -font MyFont.ttf -type msdf -size 42 -pxrange 4 \
-format png -imageout MyFont.png -json MyFont.json- Phaser 4.1+ (peer dependency)
- WebGL with the
OES_standard_derivativesextension (universally available on WebGL 1.0; Phaser 4 fetches it during renderer init)
The plugin throws a clear error during init() if the extension is missing.
MIT. Inspired by the MIT-licensed Ceramic Engine MSDF implementation.
- msdf-atlas-gen — font generator
- msdfgen — original MSDF research
- Phaser 4