From 351681a7c44ea9ae6fbbe9ced53116c11fb34769 Mon Sep 17 00:00:00 2001 From: snowyukitty <270071858+snowyukitty@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:07:24 +0900 Subject: [PATCH 1/2] Add batched per-glyph effects to BitmapText --- packages/examples/src/examples/text/text.ts | 53 +-- packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/src/index.ts | 5 + .../melonjs/src/renderable/text/bitmaptext.js | 138 +++++++- .../src/renderable/text/glypheffect.ts | 32 ++ .../tests/bitmaptext-glyph-effect.spec.js | 320 ++++++++++++++++++ packages/melonjs/tests/glyph-effect.spec.ts | 14 + 7 files changed, 517 insertions(+), 46 deletions(-) create mode 100644 packages/melonjs/src/renderable/text/glypheffect.ts create mode 100644 packages/melonjs/tests/bitmaptext-glyph-effect.spec.js create mode 100644 packages/melonjs/tests/glyph-effect.spec.ts diff --git a/packages/examples/src/examples/text/text.ts b/packages/examples/src/examples/text/text.ts index 73204cc6a..487616ff2 100644 --- a/packages/examples/src/examples/text/text.ts +++ b/packages/examples/src/examples/text/text.ts @@ -19,7 +19,6 @@ import { type Application, BitmapText, ColorLayer, - Container, NineSliceSprite, Renderable, type Renderer, @@ -93,18 +92,8 @@ class ContinueArrow extends Renderable { } } -/** - * RPG-style "wavy text": a bitmap-font string whose glyphs bob on a sine wave. - * Each character is its own BitmapText (BitmapText draws a string in one batch, - * with no per-glyph hook) so it can be offset independently every frame. - */ -class WavyText extends Container { - private glyphs: BitmapText[] = []; - private elapsed = 0; - private amplitude: number; - private speed: number; - private phaseStep: number; - +/** RPG-style wavy text, kept in one batched BitmapText renderable. */ +class WavyText extends BitmapText { constructor( x: number, y: number, @@ -114,38 +103,14 @@ class WavyText extends Container { speed = 0.009, phaseStep = 0.7, ) { - super(x, y); - this.amplitude = amplitude; - this.speed = speed; - this.phaseStep = phaseStep; - this.alwaysUpdate = true; - - // lay glyphs out left-to-right by measured advance - const measurer = new BitmapText(0, 0, settings); - let cx = 0; - for (const ch of text) { - const glyph = new BitmapText(cx, 0, { ...settings, text: ch }); - this.glyphs.push(glyph); - this.addChild(glyph); - measurer.setText(ch === " " ? "M" : ch); // spaces measure to 0 otherwise - cx += measurer.measureText().width; - } - - // Fixed, non-empty bounds so the camera never culls the group. Deriving - // bounds from the (continuously moving) child glyphs is what let MELONA - // occasionally vanish; a stable box covering the text + wave is robust. - this.width = cx; - this.height = (Number(settings.size) || 1) * 16 + this.amplitude * 2; - } - - override update(dt: number) { - this.elapsed += dt; - this.glyphs.forEach((glyph, i) => { - glyph.pos.y = - Math.sin(this.elapsed * this.speed + i * this.phaseStep) * - this.amplitude; + super(x, y, { + ...settings, + text, + glyphEffect: (out, ctx) => { + out.offsetY = + Math.sin(ctx.time * speed + ctx.index * phaseStep) * amplitude; + }, }); - return super.update(dt); } } diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 569dbf507..67635c573 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,6 +3,7 @@ ## [20.9.0] (melonJS 2) - _unreleased_ ### Added +- **`BitmapText#glyphEffect`**: offset and tint individual glyphs in one renderable for wave, shake and colour effects, with reused callback objects and the existing GPU batch. Layout and typewriter reveal keep their normal behaviour; the text example uses the hook for its wavy speaker name ([#1522](https://github.com/melonjs/melonJS/issues/1522)) - **`RenderTarget#toImageData()`**: read back a render target's pixels as a promise. The portable readback, and the one backend-agnostic code should use: `toBlob()`, `toDataURL()` and `toImageBitmap()` are all built on it - **`Texture2d#isAtlas`**: whether a texture carries named regions addressable with `getRegion()`. `false` on every texture but a `TextureAtlas`, so a game holding a `Texture2d` of unknown kind can ask without a type test - **Spatial audio placed in world coordinates**: `audio.play(name, { follow: renderable })` tracks a sound to a renderable every frame, `{ at: { x, y } }` pins one to a fixed world point, and `{ stopWithTarget: true }` ends it when that renderable is destroyed. The numbers are world pixels with y measured down, the same ones already in `pos`, so a game converts nothing by hand. `audio.unfollow(id)` detaches a sound and leaves it playing where it is diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts index 0cd9444c3..cc5c638c5 100644 --- a/packages/melonjs/src/index.ts +++ b/packages/melonjs/src/index.ts @@ -338,6 +338,11 @@ DOMContentLoaded(() => { } }); +export type { + GlyphEffect, + GlyphEffectContext, + GlyphEffectOutput, +} from "./renderable/text/glypheffect.ts"; export type { EasingFunction } from "./tweens/easing.ts"; export type { InterpolationFunction } from "./tweens/interpolation.ts"; export type { Topology } from "./video/gpu/topology.ts"; diff --git a/packages/melonjs/src/renderable/text/bitmaptext.js b/packages/melonjs/src/renderable/text/bitmaptext.js index a10d1b072..c5d86dea9 100644 --- a/packages/melonjs/src/renderable/text/bitmaptext.js +++ b/packages/melonjs/src/renderable/text/bitmaptext.js @@ -13,6 +13,7 @@ import TextMetrics from "./textmetrics.js"; * @import WebGLRenderer from "../../video/webgl/webgl_renderer.js"; * @import {Bounds} from "../../physics/bounds.ts"; * @import Renderer from "../../video/renderer.js"; + * @import {GlyphEffect, GlyphEffectContext, GlyphEffectOutput} from "./glypheffect.ts"; */ /** * a bitmap font object. @@ -38,6 +39,7 @@ export default class BitmapText extends Renderable { * @param {number} [settings.lineHeight=1.0] - line spacing height * @param {string|Vector2d|{x:number,y:number}} [settings.anchorPoint={x:0.0, y:0.0}] - anchor point to draw the text at. Also accepts the named presets `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`. * @param {number} [settings.wordWrapWidth] - the maximum length in CSS pixel for a single segment of text + * @param {GlyphEffect|null} [settings.glyphEffect=null] - a per-glyph offset and tint callback * @param {(string|string[])} [settings.text] - a string, or an array of strings * @example * // Load the BMFont descriptor as a "binary" asset and its page as an "image". @@ -170,8 +172,20 @@ export default class BitmapText extends Renderable { this.resize(settings.size); } - // set the text + /** @private @type {GlyphEffect|null} */ + this._glyphEffect = null; + /** @private */ + this._glyphEffectTime = 0; + /** + * Lazily allocated; plain bitmap text needs no effect scratch objects. + * @private + * @type {{out: GlyphEffectOutput, context: GlyphEffectContext, tint: Color, chars: string[][]}|undefined} + */ + this._glyphEffectState = undefined; + + // set the text before preparing the effect's character cache this.setText(settings.text); + this.glyphEffect = settings.glyphEffect || null; } /** @@ -213,6 +227,12 @@ export default class BitmapText extends Renderable { this._text = this.metrics.wordWrap(this._text, this.wordWrapWidth); } + if (this._glyphEffect !== null) { + this._glyphEffectState.chars = this._text.map((line) => { + return line.split(""); + }); + } + // measure text dimensions (cached for updateBounds) this.metrics.measureText(this._text); this.updateBounds(); @@ -220,6 +240,53 @@ export default class BitmapText extends Renderable { return this; } + /** + * A per-glyph offset and multiplicative tint callback, or `null` to disable. + * The output and context are reused: do not retain them. Offsets start at + * zero and tint at opaque white on each call. Time advances through update, + * so drawing through multiple cameras does not advance the animation. + * Effects change drawing only: metrics, wrapping and bounds stay unchanged. + * Keep the measured text in view: effects do not extend the culling bounds. + * Canvas uses its existing tinted-image cache; prefer a finite colour palette + * there. WebGL/WebGPU retain batching. + * @type {GlyphEffect|null} + * @example + * text.glyphEffect = (out, ctx) => { + * out.offsetY = Math.sin(ctx.time * 0.008 + ctx.index * 0.6) * 6; + * out.tint.setColor(255, 128, 128); + * }; + */ + get glyphEffect() { + return this._glyphEffect; + } + + set glyphEffect(effect) { + if (this._glyphEffect !== effect) { + this._glyphEffect = effect; + if (effect !== null) { + this._glyphEffectState ??= { + out: { offsetX: 0, offsetY: 0, tint: new Color(255, 255, 255) }, + context: { index: 0, char: "", code: 0, time: 0, x: 0, y: 0 }, + tint: new Color(255, 255, 255), + chars: [], + }; + this._glyphEffectState.chars = this._text.map((line) => { + return line.split(""); + }); + } + this.isDirty = true; + } + } + + /** @inheritdoc */ + update(dt) { + if (this._glyphEffect !== null) { + this._glyphEffectTime += dt; + return true; + } + return super.update(dt); + } + /** * the number of characters to display (use -1 to show all). * Useful for typewriter effects combined with Tween. @@ -469,7 +536,17 @@ export default class BitmapText extends Renderable { const scaleY = this.fontScale.y; // draw it - if (glyphWidth !== 0 && glyphHeight !== 0) { + if (this._glyphEffect !== null) { + this._drawGlyphEffect( + renderer, + glyph, + charCount, + ch, + this._glyphEffectState.chars[i][c], + x + glyph.xoffset * scaleX, + y + glyph.yoffset * scaleY, + ); + } else if (glyphWidth !== 0 && glyphHeight !== 0) { // some browser throw an exception when drawing a 0 width or height image renderer.drawImage( this.fontImage, @@ -500,12 +577,69 @@ export default class BitmapText extends Renderable { } } + /** + * Draw a glyph without changing the pen advance or renderer state. + * @private + * @param {Renderer} renderer + * @param {import("./glyph.ts").default} glyph + * @param {number} index + * @param {number} code + * @param {string} char + * @param {number} x + * @param {number} y + */ + _drawGlyphEffect(renderer, glyph, index, code, char, x, y) { + const { out, context, tint } = this._glyphEffectState; + out.offsetX = out.offsetY = 0; + out.tint.setFloat(1, 1, 1, 1); + context.index = index; + context.char = char; + context.code = code; + context.time = this._glyphEffectTime; + context.x = x; + context.y = y; + tint.copy(renderer.currentTint); + const alpha = renderer.getGlobalAlpha(); + try { + const effect = this._glyphEffect; + effect(out, context); + const base = tint.toArray(); + const color = out.tint.toArray(); + renderer.currentTint.setFloat( + base[0] * color[0], + base[1] * color[1], + base[2] * color[2], + base[3], + ); + // Global alpha is shared by Canvas and both GPU backends. + renderer.setGlobalAlpha(alpha * color[3]); + if (glyph.width !== 0 && glyph.height !== 0) { + renderer.drawImage( + this.fontImage, + glyph.x, + glyph.y, + glyph.width, + glyph.height, + x + out.offsetX, + y + out.offsetY, + glyph.width * this.fontScale.x, + glyph.height * this.fontScale.y, + ); + } + } finally { + renderer.currentTint.copy(tint); + renderer.setGlobalAlpha(alpha); + } + } + /** * Destroy function * @ignore * @internal */ destroy() { + this._glyphEffect = null; + this._glyphEffectState = undefined; vector2dPool.release(this.fontScale); this.fontScale = undefined; bitmapTextDataPool.release(this.fontData); diff --git a/packages/melonjs/src/renderable/text/glypheffect.ts b/packages/melonjs/src/renderable/text/glypheffect.ts new file mode 100644 index 000000000..400ea6724 --- /dev/null +++ b/packages/melonjs/src/renderable/text/glypheffect.ts @@ -0,0 +1,32 @@ +import type { Color } from "../../math/color.ts"; + +/** Mutable per-glyph output, reset before each call. Do not retain it. */ +export interface GlyphEffectOutput { + /** Horizontal offset in drawing pixels, after font scaling. */ + offsetX: number; + /** Vertical offset in drawing pixels, after font scaling. */ + offsetY: number; + /** Multiplicative tint; reset to opaque white before each call. */ + readonly tint: Color; +} + +/** Reused per-glyph context. Read its values during the callback only. */ +export interface GlyphEffectContext { + /** UTF-16 character index across drawn lines, excluding line breaks. */ + index: number; + /** The UTF-16 character represented by this glyph. */ + char: string; + /** The BMFont character code. */ + code: number; + /** Elapsed update time in milliseconds while the effect is enabled. */ + time: number; + /** Unmodified glyph destination coordinates, after alignment and scaling. */ + x: number; + y: number; +} + +/** Modify the reused output to offset or tint a BitmapText glyph. */ +export type GlyphEffect = ( + out: GlyphEffectOutput, + context: GlyphEffectContext, +) => void; diff --git a/packages/melonjs/tests/bitmaptext-glyph-effect.spec.js b/packages/melonjs/tests/bitmaptext-glyph-effect.spec.js new file mode 100644 index 000000000..4820110b2 --- /dev/null +++ b/packages/melonjs/tests/bitmaptext-glyph-effect.spec.js @@ -0,0 +1,320 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; +import { + Application, + BitmapText, + boot, + video, + WebGLRenderer, +} from "../src/index.js"; + +const SIZE = 64; +const data = `info face="effect-test" size=4 padding=0,0,0,0 spacing=0,0 +common lineHeight=6 base=4 scaleW=4 scaleH=4 pages=1 packed=0 +page id=0 file="effect-test.png" +chars count=4 +char id=32 x=0 y=0 width=0 height=0 xoffset=0 yoffset=0 xadvance=6 page=0 chnl=15 +char id=65 x=0 y=0 width=4 height=4 xoffset=0 yoffset=0 xadvance=6 page=0 chnl=15 +char id=66 x=0 y=0 width=4 height=4 xoffset=0 yoffset=0 xadvance=6 page=0 chnl=15 +char id=67 x=0 y=0 width=4 height=4 xoffset=0 yoffset=0 xadvance=6 page=0 chnl=15 +kernings count=1 +kerning first=65 second=66 amount=-1`; + +for (const backend of [video.CANVAS, video.WEBGL]) { + describe(`BitmapText glyph effects (${backend === video.CANVAS ? "Canvas" : "WebGL"})`, () => { + let app; + let renderer; + let image; + const texts = []; + beforeAll(async () => { + boot(); + app = new Application(SIZE, SIZE, { + parent: "screen", + renderer: backend, + antiAlias: false, + failIfMajorPerformanceCaveat: false, + transparent: true, + backgroundColor: "rgba(0, 0, 0, 0)", + }); + await app.init(); + renderer = app.renderer; + if (backend === video.WEBGL) { + expect(renderer).toBeInstanceOf(WebGLRenderer); + } + image = document.createElement("canvas"); + image.width = image.height = 4; + const ctx = image.getContext("2d"); + ctx.fillStyle = "white"; + ctx.fillRect(0, 0, 4, 4); + }); + afterEach(() => { + vi.restoreAllMocks(); + // Direct draw probes also queue GPU quads; drain before the next scene. + renderer.flush(); + for (const text of texts.splice(0)) { + if (text.ancestor) { + app.world.removeChildNow(text); + } else { + text.destroy(); + } + } + renderer.clearTint(); + renderer.setGlobalAlpha(1); + }); + afterAll(() => { + return app.destroy(); + }); + const makeText = (settings = {}) => { + const text = new BitmapText(8, 8, { + font: image, + fontData: data, + text: "ABC", + ...settings, + }); + texts.push(text); + return text; + }; + const pixel = (x, y) => { + if (backend === video.CANVAS) { + return Array.from(renderer.getContext().getImageData(x, y, 1, 1).data); + } + const out = new Uint8Array(4); + const gl = renderer.gl; + gl.readPixels(x, SIZE - 1 - y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, out); + // WebGL framebuffer RGB is premultiplied; Canvas readback is straight. + return [out[0], out[1], out[2]] + .map((v) => { + return out[3] ? Math.round((v * 255) / out[3]) : 0; + }) + .concat(out[3]); + }; + + it("keeps the ordinary path and layout identical with an identity effect", () => { + const text = makeText({ + text: "AB C\nABC", + size: 2, + textAlign: "center", + textBaseline: "middle", + wordWrapWidth: 18, + }); + const draw = vi.spyOn(renderer, "drawImage"); + text.draw(renderer); + const plain = draw.mock.calls.map((call) => { + return call.slice(); + }); + const bounds = { + x: text.getBounds().x, + y: text.getBounds().y, + width: text.getBounds().width, + height: text.getBounds().height, + }; + draw.mockClear(); + text.glyphEffect = () => {}; + text.draw(renderer); + expect(draw.mock.calls).toEqual(plain); + expect({ + x: text.getBounds().x, + y: text.getBounds().y, + width: text.getBounds().width, + height: text.getBounds().height, + }).toEqual(bounds); + text.glyphEffect = null; + draw.mockClear(); + text.draw(renderer); + expect(draw.mock.calls).toEqual(plain); + }); + + it("reuses and resets output/context, preserving spaces, reveal and pen advance", () => { + const text = makeText({ text: "A BC\nABC" }); + const contexts = [], + outputs = [], + snapshots = []; + text.glyphEffect = (out, ctx) => { + contexts.push(ctx); + outputs.push(out); + snapshots.push({ + ...ctx, + offsetX: out.offsetX, + offsetY: out.offsetY, + tint: Array.from(out.tint.toArray()), + }); + if (ctx.index === 0) { + out.offsetX = 5; + out.offsetY = 3; + out.tint.setColor(255, 0, 0, 0.5); + } + }; + text.visibleCharacters = 5; + text.update(20); + const draw = vi.spyOn(renderer, "drawImage"); + text.draw(renderer); + expect( + snapshots.map((ctx) => { + return ctx.char; + }), + ).toEqual(["A", " ", "B", "C", "A"]); + expect( + snapshots.map((ctx) => { + return ctx.index; + }), + ).toEqual([0, 1, 2, 3, 4]); + expect( + snapshots.every((ctx) => { + return ( + ctx.time === 20 && + ctx.offsetX === 0 && + ctx.offsetY === 0 && + ctx.tint.every((v) => { + return v === 1; + }) + ); + }), + ).toBe(true); + expect( + contexts.every((ctx) => { + return ctx === contexts[0]; + }), + ).toBe(true); + expect( + outputs.every((out) => { + return out === outputs[0]; + }), + ).toBe(true); + expect( + draw.mock.calls.map((call) => { + return call.slice(5, 7); + }), + ).toEqual([ + [13, 11], + [20, 8], + [26, 8], + [8, 12], + ]); + text.setText("CBA"); + snapshots.length = 0; + text.draw(renderer); + expect( + snapshots.map((ctx) => { + return ctx.char; + }), + ).toEqual(["C", "B", "A"]); + }); + + it("skips missing and hidden glyphs without renumbering later characters", () => { + const seen = []; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const text = makeText({ + text: "A?B", + glyphEffect: (_out, ctx) => { + return seen.push([ctx.index, ctx.code]); + }, + }); + text.draw(renderer); + expect(seen).toEqual([ + [0, 65], + [2, 66], + ]); + expect(warn).toHaveBeenCalledTimes(1); + seen.length = 0; + text.visibleCharacters = 0; + text.draw(renderer); + expect(seen).toEqual([]); + }); + + it("advances time on updates, dirties the scene without changing layout, and redraws after removal", () => { + const text = makeText(); + text.isDirty = false; + expect(text.update(16)).toBe(false); + const times = []; + text.glyphEffect = (_out, ctx) => { + return times.push(ctx.time); + }; + text.isDirty = false; + expect(text.update(16)).toBe(true); + expect(text.isDirty).toBe(false); + text.draw(renderer); + text.draw(renderer); + expect( + times.every((time) => { + return time === 16; + }), + ).toBe(true); + text.glyphEffect = null; + expect(text.update(16)).toBe(true); + text.isDirty = false; + expect(text.update(16)).toBe(false); + }); + + it("restores renderer tint and alpha when the callback throws or reveal stops", () => { + const text = makeText(); + renderer.currentTint.setFloat(0.5, 0.25, 1, 0.75); + renderer.setGlobalAlpha(0.5); + const original = Array.from(renderer.currentTint.toArray()); + text.glyphEffect = (out) => { + out.tint.setColor(255, 0, 0, 0.25); + }; + text.visibleCharacters = 1; + text.draw(renderer); + expect(Array.from(renderer.currentTint.toArray())).toEqual(original); + expect(renderer.getGlobalAlpha()).toBe(0.5); + text.glyphEffect = () => { + throw new Error("effect failed"); + }; + expect(() => { + return text.draw(renderer); + }).toThrow("effect failed"); + expect(Array.from(renderer.currentTint.toArray())).toEqual(original); + expect(renderer.getGlobalAlpha()).toBe(0.5); + }); + + it("renders offset/tinted pixels through the world, keeps the next glyph untouched and batches GPU colours", () => { + const text = makeText({ + fillStyle: "#80ffff", + glyphEffect: (out, ctx) => { + if (ctx.index === 0) { + out.offsetY = 8; + out.tint.setColor(255, 0, 0, 0.5); + } + if (ctx.index === 1) { + out.tint.setColor(0, 255, 0); + } + }, + }); + text.setOpacity(0.5); + app.world.addChild(text); + renderer.backgroundColor.setFloat(0, 0, 0, 0); + renderer.clearRect(0, 0, SIZE, SIZE); + const draw = + backend === video.WEBGL + ? vi.spyOn(renderer.gl, "drawElements") + : undefined; + app.world.update(16); + app.repaint(); + app.draw(); + const red = pixel(9, 17), + green = pixel(15, 9), + third = pixel(20, 9); + expect(red[0]).toBeGreaterThan(100); + expect(red[1]).toBe(0); + expect(red[2]).toBe(0); + expect(red[3]).toBeGreaterThanOrEqual(62); + expect(red[3]).toBeLessThanOrEqual(65); + expect(green[1]).toBe(255); + expect(green[0]).toBe(0); + expect(green[3]).toBeGreaterThanOrEqual(126); + expect(green[3]).toBeLessThanOrEqual(129); + expect(third[0]).toBeGreaterThan(120); + expect(third[1]).toBe(255); + if (draw) { + expect(draw).toHaveBeenCalledTimes(1); + } + }); + }); +} diff --git a/packages/melonjs/tests/glyph-effect.spec.ts b/packages/melonjs/tests/glyph-effect.spec.ts new file mode 100644 index 000000000..f2bc76e8c --- /dev/null +++ b/packages/melonjs/tests/glyph-effect.spec.ts @@ -0,0 +1,14 @@ +import { expectTypeOf, it } from "vitest"; +import type { + BitmapText, + GlyphEffect, + GlyphEffectContext, + GlyphEffectOutput, +} from "../src/index.js"; + +it("exports the callback types used by the public BitmapText API", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().parameter(0).toEqualTypeOf(); + expectTypeOf().parameter(1).toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); +}); From 1cbcbddd11cf244e93a14254645e3897ecd5609f Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Fri, 9 Oct 2026 17:42:10 +0800 Subject: [PATCH 2/2] BitmapText: normalize a falsy glyphEffect, and document what it costs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- packages/melonjs/CHANGELOG.md | 2 +- .../skills/melonjs-ui-and-text/SKILL.md | 45 ++++++++++++++++++- .../melonjs/src/renderable/text/bitmaptext.js | 42 +++++++++-------- .../tests/bitmaptext-glyph-effect.spec.js | 25 +++++++++++ 4 files changed, 93 insertions(+), 21 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 67635c573..d84636bca 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,7 +3,7 @@ ## [20.9.0] (melonJS 2) - _unreleased_ ### Added -- **`BitmapText#glyphEffect`**: offset and tint individual glyphs in one renderable for wave, shake and colour effects, with reused callback objects and the existing GPU batch. Layout and typewriter reveal keep their normal behaviour; the text example uses the hook for its wavy speaker name ([#1522](https://github.com/melonjs/melonJS/issues/1522)) +- **`BitmapText#glyphEffect`**: offset and tint individual glyphs in one renderable for wave, shake and colour effects, with reused callback objects and the existing GPU batch. Layout and typewriter reveal keep their normal behaviour; the text example uses the hook for its wavy speaker name ([#1522](https://github.com/melonjs/melonJS/issues/1522), thanks @snowyukitty) - **`RenderTarget#toImageData()`**: read back a render target's pixels as a promise. The portable readback, and the one backend-agnostic code should use: `toBlob()`, `toDataURL()` and `toImageBitmap()` are all built on it - **`Texture2d#isAtlas`**: whether a texture carries named regions addressable with `getRegion()`. `false` on every texture but a `TextureAtlas`, so a game holding a `Texture2d` of unknown kind can ask without a type test - **Spatial audio placed in world coordinates**: `audio.play(name, { follow: renderable })` tracks a sound to a renderable every frame, `{ at: { x, y } }` pins one to a fixed world point, and `{ stopWithTarget: true }` ends it when that renderable is destroyed. The numbers are world pixels with y measured down, the same ones already in `pos`, so a game converts nothing by hand. `audio.unfollow(id)` detaches a sound and leaves it playing where it is diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index 88700e7f9..12faf72d7 100644 --- a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md +++ b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md @@ -1,6 +1,6 @@ --- name: melonjs-ui-and-text -description: "Use this skill for HUDs, buttons, menus, dialogue panels, progress bars and on-screen text in melonJS. Covers UIBaseElement/UISpriteElement/UITextButton, ProgressBar, Draggable and DropTarget, the floating screen-space container pattern, which of two overlapping panels gets the pointer, Text and BitmapText, web font loading, and NineSliceSprite panels. Triggers on: UI, HUD, button, UIBaseElement, UISpriteElement, UITextButton, ProgressBar, progress bar, health bar, gauge, Draggable, DropTarget, menu, dialogue, overlapping panels, moveToTop, onOver, onClick, Tween, easing, animate a label, Text, BitmapText, fillStyle, fillGradient, gradient text, tint, font, fontface, wordWrapWidth, NineSliceSprite, score display, floating." +description: "Use this skill for HUDs, buttons, menus, dialogue panels, progress bars and on-screen text in melonJS. Covers UIBaseElement/UISpriteElement/UITextButton, ProgressBar, Draggable and DropTarget, the floating screen-space container pattern, which of two overlapping panels gets the pointer, Text and BitmapText, web font loading, and NineSliceSprite panels. Triggers on: UI, HUD, button, UIBaseElement, UISpriteElement, UITextButton, ProgressBar, progress bar, health bar, gauge, Draggable, DropTarget, menu, dialogue, overlapping panels, moveToTop, onOver, onClick, Tween, easing, animate a label, wavy text, per-glyph effect, glyphEffect, shake text, rainbow text, Text, BitmapText, fillStyle, fillGradient, gradient text, tint, font, fontface, wordWrapWidth, NineSliceSprite, score display, floating." license: MIT --- @@ -125,6 +125,49 @@ label from one class to the other: | stroke | `strokeStyle` + `lineWidth` | none | | `size` | pixels | a RATIO of the authored size | +### Per-glyph wave, shake and colour: `glyphEffect` + +Animating individual letters does **not** mean one renderable per character. +`BitmapText#glyphEffect` offsets and tints each glyph inside the batch the +label already draws, so an animated 100-character line stays one draw call and +costs no extra objects: + +```js +label.glyphEffect = (out, ctx) => { + out.offsetY = Math.sin(ctx.time * 0.008 + ctx.index * 0.6) * 6; // wave + out.tint.setColor(255, 128, 128); // per glyph +}; +label.glyphEffect = null; // back to the plain path +``` + +`out` and `ctx` are **reused**: read `ctx` during the call and write `out`, but +never keep either. `out.offsetX` / `offsetY` start at zero and `out.tint` at +opaque white on every glyph, so an effect that only touches some glyphs leaves +the rest alone. `ctx` carries `index` (across lines, line breaks excluded), +`char`, `code`, `x`, `y` and `time`. + +Four things that catch people: + +- **`time` advances in `update`, not in `draw`.** A label drawn through two + cameras animates once, not twice. It is the milliseconds ACCUMULATED across + updates while an effect was set, and nothing resets it, so swapping the + callback continues the same clock rather than starting a new one. +- **Effects move pixels, not layout.** Metrics, wrapping, alignment, + `visibleCharacters` and the bounds all ignore the offsets, so the pen advance + and kerning are exactly as without one. The flip side is that **offsets do + not extend the culling bounds**: a big wave near the screen edge can clip, so + keep the measured text in view. +- **Canvas pays per distinct colour.** WebGL and WebGPU carry the tint in the + per-vertex colour, so batching survives. The Canvas renderer instead caches a + tinted copy of the whole font page per colour, and that cache is unbounded + for the renderer's life, so a colour driven by `ctx.time` allocates a + page-sized canvas every frame there. On Canvas, keep the palette finite or + animate only the offsets. +- **`tint` is multiplicative**, like `fillStyle` above: white leaves the glyph + alone, and `out.tint` multiplies whatever the label's own `fillStyle` is. + +`Text` has no equivalent, since it rasterises the whole string into one texture. + ## The HUD pattern A HUD is a `floating` container at a high z, built once and re-added: diff --git a/packages/melonjs/src/renderable/text/bitmaptext.js b/packages/melonjs/src/renderable/text/bitmaptext.js index c5d86dea9..0087d4e9b 100644 --- a/packages/melonjs/src/renderable/text/bitmaptext.js +++ b/packages/melonjs/src/renderable/text/bitmaptext.js @@ -179,13 +179,13 @@ export default class BitmapText extends Renderable { /** * Lazily allocated; plain bitmap text needs no effect scratch objects. * @private - * @type {{out: GlyphEffectOutput, context: GlyphEffectContext, tint: Color, chars: string[][]}|undefined} + * @type {{out: GlyphEffectOutput, context: GlyphEffectContext, tint: Color}|undefined} */ this._glyphEffectState = undefined; - // set the text before preparing the effect's character cache + // set the text this.setText(settings.text); - this.glyphEffect = settings.glyphEffect || null; + this.glyphEffect = settings.glyphEffect; } /** @@ -227,12 +227,6 @@ export default class BitmapText extends Renderable { this._text = this.metrics.wordWrap(this._text, this.wordWrapWidth); } - if (this._glyphEffect !== null) { - this._glyphEffectState.chars = this._text.map((line) => { - return line.split(""); - }); - } - // measure text dimensions (cached for updateBounds) this.metrics.measureText(this._text); this.updateBounds(); @@ -247,8 +241,15 @@ export default class BitmapText extends Renderable { * so drawing through multiple cameras does not advance the animation. * Effects change drawing only: metrics, wrapping and bounds stay unchanged. * Keep the measured text in view: effects do not extend the culling bounds. - * Canvas uses its existing tinted-image cache; prefer a finite colour palette - * there. WebGL/WebGPU retain batching. + * WebGL and WebGPU retain batching: the tint rides the per-vertex colour, + * so a whole animated line is still one draw call. Canvas realizes each + * DISTINCT tint as a cached, tinted copy of the entire font page, and that + * cache is unbounded for the life of the renderer, so a colour driven by + * `ctx.time` costs a font-page canvas per frame there. On Canvas, keep the + * palette finite or vary only the offsets. + * + * While an effect is set the renderable reports itself as changed every + * frame, since whether the callback reads `ctx.time` cannot be known. * @type {GlyphEffect|null} * @example * text.glyphEffect = (out, ctx) => { @@ -261,18 +262,21 @@ export default class BitmapText extends Renderable { } set glyphEffect(effect) { - if (this._glyphEffect !== effect) { - this._glyphEffect = effect; - if (effect !== null) { + // Normalized, as the constructor's `settings.glyphEffect || null` + // already was. `undefined` is how an unset option arrives and how a + // caller spells "turn it off", and storing it left the draw path's + // `!== null` test true with nothing callable behind it: a + // `TypeError: effect is not a function` out of `draw()`, i.e. from + // inside the frame loop. + const next = effect || null; + if (this._glyphEffect !== next) { + this._glyphEffect = next; + if (next !== null) { this._glyphEffectState ??= { out: { offsetX: 0, offsetY: 0, tint: new Color(255, 255, 255) }, context: { index: 0, char: "", code: 0, time: 0, x: 0, y: 0 }, tint: new Color(255, 255, 255), - chars: [], }; - this._glyphEffectState.chars = this._text.map((line) => { - return line.split(""); - }); } this.isDirty = true; } @@ -542,7 +546,7 @@ export default class BitmapText extends Renderable { glyph, charCount, ch, - this._glyphEffectState.chars[i][c], + string.charAt(c), x + glyph.xoffset * scaleX, y + glyph.yoffset * scaleY, ); diff --git a/packages/melonjs/tests/bitmaptext-glyph-effect.spec.js b/packages/melonjs/tests/bitmaptext-glyph-effect.spec.js index 4820110b2..906134540 100644 --- a/packages/melonjs/tests/bitmaptext-glyph-effect.spec.js +++ b/packages/melonjs/tests/bitmaptext-glyph-effect.spec.js @@ -274,6 +274,31 @@ for (const backend of [video.CANVAS, video.WEBGL]) { expect(renderer.getGlobalAlpha()).toBe(0.5); }); + it("REGRESSION: a falsy assignment disables the effect, it does not throw", () => { + // `undefined` is how an unset option arrives and how a caller + // spells "turn it off". The constructor normalized it with + // `|| null`; the setter stored it, which left the draw path's + // `!== null` test true with nothing callable behind it, and the + // `TypeError` came out of `draw()`, from inside the frame loop. + const text = makeText(); + text.glyphEffect = (out) => { + out.offsetY = 3; + }; + expect(typeof text.glyphEffect).toEqual("function"); + + text.glyphEffect = undefined; + // reads back as the documented `GlyphEffect|null`, not `undefined` + expect(text.glyphEffect).toBe(null); + expect(() => { + text.draw(renderer); + }).not.toThrow(); + + // and the ordinary path really is back: no per-glyph callback runs + const draw = vi.spyOn(renderer, "drawImage"); + text.draw(renderer); + expect(draw).toHaveBeenCalledTimes(3); + }); + it("renders offset/tinted pixels through the world, keeps the next glyph untouched and batches GPU colours", () => { const text = makeText({ fillStyle: "#80ffff",