Skip to content

Antriel/phaser4-msdf-text

Repository files navigation

phaser4-msdf-text

npm license

MSDF (Multi-channel Signed Distance Field) font rendering for Phaser 4.

Live examples →

  • 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

Install

npm install phaser4-msdf-text

Phaser 4 is a peer dependency — install it alongside if you haven't already:

npm install phaser@^4.1.0

Setup

Register 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) from callbacks.postBoot — it registers the batch handler with the renderer directly.

Use

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 },
});

Examples

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

API

Text properties

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

Word wrap

text.setMaxWidth(400);             // Wrap to fit 400px (0 disables)
// Or:  text.maxWidth = 400;
text.wordWrapCharCode = 32;        // Default: space. Use 45 for hyphen, etc.

Outline (shader-based, no extra draw calls)

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 fill

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

Shadow (extra pass, still batched)

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 only

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

Per-glyph display callback

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:

  • transformx, y, scaleX, scaleY, rotation (about the glyph centre; scaleX/scaleY = 0 hides 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 index and charCode.

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.

Persistent per-glyph state (manual mode)

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.

Loading details

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 LINEAR filtering works correctly. If you opt into NEAREST (e.g. for a pixel-art project), MSDF edges will alias badly — use a bitmap font in that case.

Generating MSDF fonts

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

Requirements

  • Phaser 4.1+ (peer dependency)
  • WebGL with the OES_standard_derivatives extension (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.

License

MIT. Inspired by the MIT-licensed Ceramic Engine MSDF implementation.

References

About

MSDF font rendering for Phaser 4

Topics

Resources

License

Stars

10 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors