Skip to content

Add batched per-glyph effects to BitmapText - #1725

Merged
obiot merged 2 commits into
melonjs:masterfrom
snowyukitty:feat/bitmap-text-glyph-effects
Oct 9, 2026
Merged

obiot merged 2 commits into
melonjs:masterfrom
snowyukitty:feat/bitmap-text-glyph-effects

Conversation

@snowyukitty

Copy link
Copy Markdown
Contributor

Description

Adds the offset/tint primitive requested in #1522: BitmapText.glyphEffect(out, ctx) modifies individual glyphs without creating a renderable for each character. The text example now uses one BitmapText for its wavy speaker name.

The callback receives reused output/context objects, with offsets reset to zero and tint reset to opaque white before each glyph. Effects preserve advances, kerning, wrapping, alignment and typewriter reveal. Animation time advances in update, and tint/opacity are restored even if the callback throws. The ordinary drawing path remains available when the callback is null.

text.glyphEffect = (out, ctx) => {
    out.offsetY = Math.sin(ctx.time * 0.008 + ctx.index * 0.6) * 6;
    out.tint.setColor(255, 128, 128);
};

This keeps effects independent of layout: measured bounds are unchanged, so offsets do not extend culling bounds. Canvas uses the existing tinted-image cache; the API documentation recommends a finite colour palette there. Presets, markup, scale and rotation are left for follow-up work.

Type of change

  • New feature

Checklist

  • I have read the Contributing Guide
  • My code follows the existing code style (pnpm lint passes)
  • I have tested my changes locally (pnpm test passes)
  • I have added tests that cover my changes
  • The build succeeds (pnpm build)

Validation

From the repository root, using Node 24, pnpm 10.32.1 and CI=1:

pnpm lint
pnpm biome check
pnpm build
pnpm test
pnpm -F @melonjs/planck-adapter test
pnpm -F @melonjs/matter-adapter test
pnpm -F @melonjs/debug-plugin test

Core: 7,686 passed / 11 skipped; Planck: 221 passed; Matter: 226 passed; debug plugin: 23 passed. The unmodified core baseline was 7,673 passed / 11 skipped. The 12 new Canvas/WebGL cases cover reused state, scaled/wrapped layout, visibility, missing glyphs, update timing, exception restoration and actual pixels. The WebGL pixel case also verifies that different glyph colours still produce one draw call. A separate test checks the exported callback types.

Validated in Linux Chromium; dedicated WebGPU pixel validation and Windows execution were not run.

Related issues

Closes #1522.

@obiot
obiot force-pushed the feat/bitmap-text-glyph-effects branch from c78abf2 to 5a25623 Compare October 9, 2026 09:42
Review follow-ups on top of @snowyukitty's work.

`text.glyphEffect = undefined` threw `TypeError: effect is not a function`
out of `draw()`, i.e. from inside the frame loop. The constructor already
normalized with `settings.glyphEffect || null`, but the setter stored whatever
it was given while the draw path tests `!== null`. `undefined` is both how an
unset option arrives and how a caller spells "turn it off", and the getter
also reported it, breaking its own documented `GlyphEffect|null` type. One
test per backend covers it, and reverting the fix reproduces the TypeError.

Dropped the `chars` cache. It was an array of characters per line, rebuilt in
`setText` and again in the setter, to supply `context.char` — but the draw
loop already holds the line, so `string.charAt(c)` is the same answer with no
extra field, no second maintenance site and no invariant to keep in sync.
`trimEnd()` only trims the tail, so the indices agree.

Documented the Canvas cost honestly. "Prefer a finite colour palette" read as
a performance hint; what actually happens is that `TextureCache#tint` keys a
cached, tinted copy of the whole font page by colour, unbounded for the life
of the renderer, so a colour driven by `ctx.time` allocates a page-sized
canvas every frame on that backend. Also noted that the renderable reports
itself dirty every frame while an effect is set.

The UI and text skill now covers the hook. Animating letters was the one
thing a reader would otherwise solve with the per-character renderable
workaround this feature exists to remove, and the skill's own triggers
already include "animate a label".

Credited the contributor in the changelog entry, as the house convention
asks for external contributions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
@obiot
obiot force-pushed the feat/bitmap-text-glyph-effects branch from 5a25623 to 1cbcbdd Compare October 9, 2026 10:16
@obiot

obiot commented Oct 9, 2026

Copy link
Copy Markdown
Member

Thanks @snowyukitty — this is a well-built change. The approach is right: packing the tint into the per-vertex colour means an animated line stays one draw call, and the gl.drawElements spy asserting exactly one call is the test that proves it rather than claiming it. I ran five mutations of my own against your spec (dropping the finally restore, letting the offset leak into the pen advance, freezing the effect time, skipping the per-glyph reset, ignoring the tint) and every one failed loudly, so the tests genuinely bite.

I rebased onto master (#1688 landed today and moved CHANGELOG.md, index.ts and bitmaptext.js) and pushed three follow-ups:

1. A falsy assignment crashed the frame loop. text.glyphEffect = undefined threw TypeError: effect is not a function out of draw(). The constructor already normalized with settings.glyphEffect || null, but the setter stored whatever it was given while the draw path tests !== null, and the getter then reported undefined, which its own @type {GlyphEffect|null} rules out. undefined is both how an unset option arrives and how a caller spells "turn it off", so the setter normalizes the same way now. One test per backend; reverting the fix reproduces the TypeError in six cases.

2. Dropped the chars cache. It was an array of characters per line, rebuilt in setText and again in the setter, purely to supply context.char — but the draw loop already holds string, so string.charAt(c) is the same answer (trimEnd() only trims the tail, so the indices agree). That removes a field, a second maintenance site, an invariant to keep in sync, and the effect branch inside the public setText. Your existing tests still pin context.char, so the swap is covered.

3. Said what Canvas actually costs. "Prefer a finite colour palette there" reads as a performance hint. What happens is that TextureCache#tint keys a tinted copy of the whole font page by colour in a plain Map, cleared only on renderer reset, so a colour driven by ctx.time allocates a page-sized canvas every frame on that backend. The JSDoc now says that, and notes that the renderable reports itself dirty every frame while an effect is set, since whether the callback reads ctx.time cannot be known.

I also added a glyphEffect section to the melonjs-ui-and-text skill. Animating letters is exactly what a reader would otherwise solve with the per-character renderable workaround this feature exists to delete, and that skill's triggers already include "animate a label".

One thing left open rather than decided: #1522 also floated presets (wave / shake / rainbow) and out.scale / out.rotation, with "shipping both is the usual resolution". This ships the primitive only. Merging it closes #1522, so if you want the presets or the scale/rotation fields, open a follow-up and they can build on exactly this hook.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

@obiot
obiot merged commit cc610bb into melonjs:master Oct 9, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Per-glyph effect hook for BitmapText (batched vertex offset + tint)

2 participants