From a1623e592214ff790c333db3e530983a8877cf21 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 13 Sep 2026 09:05:56 +0800 Subject: [PATCH 01/17] Text: gradient fills no longer clip to the font box on Safari MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebKit rasterizes gradient-filled canvas text through a mask it sizes from the font's layout box rather than from the ink, so glyphs that rise above the declared ascent — routine for a display face — came out transparent under an intact stroke. Bake the fill flat and re-colour it through `source-in` instead, which runs every browser down the same path. Two defects fell out of the same bake while fixing it: - The glyphs were drawn `inkPadTop` lower without the gradient moving with them, so the fill sampled further along the ramp than the caller authored and by a different amount per browser. Translate into the padding instead, which keeps bake space equal to metrics space. - `actualBoundingBox*` describes the outline, but antialiasing and pixel snapping paint a full pixel past it, which sheared the top row off the stroke. Pad by one more pixel, and fall back to a share of the em where the metric is unavailable rather than to no padding at all. The canvas bucket spec measured its waste against the layout box alone, which ignored the ink padding the canvas also has to hold; it only passed while the padding happened not to cross a 32px boundary. Rebased on the padded box, plus the lower bound that would have caught the shearing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- packages/melonjs/src/renderable/text/text.js | 154 ++++++++++++------ .../src/renderable/text/textmetrics.js | 53 ++++-- .../melonjs/src/renderable/text/textstyle.js | 80 ++++++++- packages/melonjs/tests/text_bucket.spec.js | 20 ++- 4 files changed, 234 insertions(+), 73 deletions(-) diff --git a/packages/melonjs/src/renderable/text/text.js b/packages/melonjs/src/renderable/text/text.js index cdf153aa9..e7b80ef57 100644 --- a/packages/melonjs/src/renderable/text/text.js +++ b/packages/melonjs/src/renderable/text/text.js @@ -453,7 +453,7 @@ export default class Text extends Renderable { this.canvasTexture.context, this._text, this.pos.x - this.metrics.x, - this.pos.y - this.metrics.y + this.metrics.inkPadTop, + this.pos.y - this.metrics.y, ); // Invalidate LAST, so the renderer re-uploads the canvas at the size @@ -593,7 +593,7 @@ export default class Text extends Renderable { this.canvasTexture.context, this._text, this.pos.x - this.metrics.x, - this.pos.y - this.metrics.y + this.metrics.inkPadTop, + this.pos.y - this.metrics.y, ); // after the repaint, not before it — see `setText` this.canvasTexture.invalidate(renderer); @@ -620,65 +620,115 @@ export default class Text extends Renderable { * @internal */ _drawFont(context, text, x, y) { - setContextStyle(context, this); - - let remaining = this.visibleCharacters; - - // A gradient is re-anchored to EACH LINE, so every line of a multi-line - // label carries the same ramp — what you would get from one `Text` per - // line, which is how a HUD is usually built. + // The bake canvas carries `inkPadTop` of headroom above the layout box. + // Translating INTO that headroom — rather than adding it to every draw + // position — keeps bake space equal to METRICS space, which is the + // space a caller authored their gradient coordinates in. Add the pad to + // the positions instead and the glyphs slide down the ramp while the + // ramp stays put, so the fill changes colour with the padding, and the + // padding differs per browser. The translate is a whole number of + // pixels, so glyph rasterization is untouched. + context.save(); + context.translate(0, this.metrics.inkPadTop); + + const fill = setContextStyle(context, this); + + const fills = this.fillStyle.alpha > 0; + const strokes = this.lineWidth > 0 && this.strokeStyle.alpha > 0; + + // A gradient fill is painted THROUGH the glyphs rather than handed to + // `fillText`. // - // The canvas would otherwise spread one ramp across the whole block: - // a `CanvasGradient` lives in the current transform's space, so lines - // drawn further down sample further along it, and every line after the - // first comes out flat unless the caller happens to have authored the - // ramp over the exact block height. That is silent and easy to get - // wrong. Translating per line instead keeps the gradient with the text. + // WebKit rasterizes gradient-filled text by drawing the glyphs into a + // mask it sizes from the FONT's layout box, then filling that mask — + // so ink rising above the declared ascent, which display faces do + // routinely, never enters the mask and comes out fully transparent. + // The stroke is a flat colour and takes a different path, which leaves + // an outlined but hollow glyph. Filling flat and re-colouring through + // `source-in` puts the glyphs through the same rasterizer every browser + // uses for ordinary text, so all of them agree. // - // The trade is that a ramp spanning a whole two-line title is no longer - // expressible; compose that from one `Text` per line. - const perLine = - this.gradientPerLine === true && - this.fillGradient !== undefined && - text.length > 1; - const firstY = y; - - for (let i = 0; i < text.length; i++) { - let string = text[i].trimEnd(); - - // limit visible characters if needed - if (remaining !== -1) { - if (remaining <= 0) { - break; + // Measured on a plain canvas with no engine involved, 34px display + // face: with a FLAT fill both engines paint 7px above the top alignment + // point; with a GRADIENT, WebKit stops at 0 — precisely the ascent it + // reports for the face. Everything above that is absent, not faint. + // The mask path is `drawTextUnchecked` in WebCore's + // `CanvasRenderingContext2DBase`, under `USE(CG)`. + // + // Done unconditionally, not behind a browser check: one rasterization + // path is worth more than saving a `fillRect` on the engines that would + // have been fine either way. + // + // The trade is that fill and stroke now interleave per BLOCK rather + // than per line, observable only where lines overlap. + // + // DO NOT collapse this back into a single `fillText` with the gradient + // as `fillStyle`. The second pass reads as a redundant `fillRect` and + // is not one — it is the only reason the glyph tops survive on Safari. + const masked = fills && this.fillGradient !== undefined; + + /** + * Walk the visible lines, handing each to `paint`. + * @param {(line: string, x: number, y: number) => void} paint - per line + */ + const eachLine = (paint) => { + let remaining = this.visibleCharacters; + let lineY = y; + + for (let i = 0; i < text.length; i++) { + let string = text[i].trimEnd(); + + // limit visible characters if needed + if (remaining !== -1) { + if (remaining <= 0) { + break; + } + string = string.substring(0, remaining); + remaining -= string.length; } - string = string.substring(0, remaining); - remaining -= string.length; - } - // Shift the whole space down to this line rather than the draw - // position, so the gradient travels with it and each line is - // painted from the ramp's start. - if (perLine) { - context.save(); - context.translate(0, y - firstY); + paint(string, x, lineY); + // add leading space + lineY += this.metrics.lineHeight(); } - const lineY = perLine ? firstY : y; + }; - // draw the string - if (this.fillStyle.alpha > 0) { - context.fillText(string, x, lineY); - } - // stroke the text - if (this.lineWidth > 0 && this.strokeStyle.alpha > 0) { - context.strokeText(string, x, lineY); + if (fills) { + if (masked === true) { + // any opaque colour: only the alpha it leaves behind survives + context.fillStyle = "#000000"; } + eachLine((line, lineX, lineY) => { + context.fillText(line, lineX, lineY); + }); + } - if (perLine) { - context.restore(); - } - // add leading space - y += this.metrics.lineHeight(); + if (masked === true) { + // Re-colour the silhouette under the SAME transform the glyphs were + // drawn with, so the gradient travels with them. The rect only has + // to cover the canvas — a gradient's colours come from its own + // coordinates, not from the rect it is painted into. + const canvas = context.canvas; + context.globalCompositeOperation = "source-in"; + context.fillStyle = fill; + context.fillRect( + -1, + -this.metrics.inkPadTop - 1, + canvas.width + 2, + canvas.height + 2, + ); + // back before the stroke: the outline is a flat colour and must not + // be masked by the fill it sits on + context.globalCompositeOperation = "source-over"; } + + if (strokes) { + eachLine((line, lineX, lineY) => { + context.strokeText(line, lineX, lineY); + }); + } + + context.restore(); return this.metrics; } diff --git a/packages/melonjs/src/renderable/text/textmetrics.js b/packages/melonjs/src/renderable/text/textmetrics.js index 63fec6fd2..b222ce124 100644 --- a/packages/melonjs/src/renderable/text/textmetrics.js +++ b/packages/melonjs/src/renderable/text/textmetrics.js @@ -2,6 +2,28 @@ import { Bounds } from "../../physics/bounds.ts"; import Text from "./text.js"; import setContextStyle from "./textstyle.js"; +/** + * One pixel of slack on top of the reported ink extent. + * + * `actualBoundingBox*` describes the OUTLINE, and the rasterizer paints past + * it: antialiasing and pixel snapping both put ink outside what the metrics + * promise. Measured against two engines, the painted ink reached a full pixel + * beyond the reported bound — enough to shear the top row off a stroke. Over- + * padding costs nothing but invisible canvas headroom. + * @ignore + * @internal + */ +const INK_SLACK = 1; + +/** + * The share of the em to pad by when the ink extent cannot be measured at all. + * Covers the overshoot of the display faces measured here without depending on + * a metric the browser may not report. + * @ignore + * @internal + */ +const INK_GUESS = 0.25; + /** * a Text Metrics object that contains helper for text manipulation */ @@ -144,9 +166,10 @@ class TextMetrics extends Bounds { // screen position and only the clipped pixels are recovered. // // `actualBoundingBox*` is measured from the alignment point, so the - // values already account for whichever `textBaseline` is in force. A - // browser or font that cannot report them leaves the padding at zero, - // which is exactly today's behaviour. + // values already account for whichever `textBaseline` is in force. + // They describe the outline, though, not the pixels — see `INK_SLACK` — + // and where they are unavailable the padding falls back to `INK_GUESS` + // rather than to nothing. this.inkPadTop = 0; this.inkPadBottom = 0; if (!isBitmapText && strings.length > 0 && typeof context !== "undefined") { @@ -162,15 +185,21 @@ class TextMetrics extends Bounds { : context.measureText(strings[strings.length - 1].trimEnd()); const above = first.actualBoundingBoxAscent; const below = last.actualBoundingBoxDescent; - if (Number.isFinite(above) && Number.isFinite(below)) { - this.inkPadTop = Math.max(0, Math.ceil(above + stroke)); - // the last line starts one line box short of the bottom, so only - // what reaches past that needs room - this.inkPadBottom = Math.max( - 0, - Math.ceil(this.inkPadTop + below + stroke - this.lineHeight()), - ); - } + // A face that cannot report its ink falls back to a share of the em + // rather than to nothing: a guess that covers most faces clips less + // than a zero that covers none. + const measured = Number.isFinite(above) && Number.isFinite(below); + const top = measured ? above : style.fontSize * INK_GUESS; + const bottom = measured ? below : style.fontSize * INK_GUESS; + + this.inkPadTop = Math.max(0, Math.ceil(top + stroke) + INK_SLACK); + // the last line starts one line box short of the bottom, so only + // what reaches past that needs room + this.inkPadBottom = Math.max( + 0, + Math.ceil(this.inkPadTop + bottom + stroke - this.lineHeight()) + + INK_SLACK, + ); } this.width = Math.ceil(this.width); diff --git a/packages/melonjs/src/renderable/text/textstyle.js b/packages/melonjs/src/renderable/text/textstyle.js index 1e8f8ca97..667ce177e 100644 --- a/packages/melonjs/src/renderable/text/textstyle.js +++ b/packages/melonjs/src/renderable/text/textstyle.js @@ -1,18 +1,86 @@ +/** + * How far from vertical a linear gradient may lean and still be treated as a + * per-line ramp. A gradient running mostly across the label is one the caller + * meant to span it, not one to repeat. + * @ignore + * @internal + */ +const VERTICAL_TOLERANCE = 0.1; + +/** + * The fill style a bake should use: a flat colour, the gradient as authored, + * or — for a multi-line label under `gradientPerLine` — one gradient spanning + * the whole block with the caller's stops REPEATED once per line. + * + * Repeating the stops rather than re-anchoring the gradient per line keeps this + * to a single `fillStyle` and leaves the canvas transform alone. `fillText` is + * already where the browsers disagree most about rasterization; moving the + * transform underneath it during text drawing is not a place to add variables. + * @param {CanvasRenderingContext2D} context - the bake's context + * @param {object} style - the Text being drawn + * @returns {string|CanvasGradient} a value for `context.fillStyle` + * @ignore + * @internal + */ +function resolveFill(context, style) { + const gradient = style.fillGradient; + + if (gradient === undefined) { + return style.fillStyle.toRGBA(); + } + + const lines = style._text?.length ?? 1; + if ( + style.gradientPerLine !== true || + lines <= 1 || + gradient.type !== "linear" + ) { + return gradient.toCanvasGradient(context); + } + + const [x0, y0, x1, y1] = gradient.coords; + const span = y1 - y0; + // only a (near) vertical ramp repeats: a horizontal one has nothing to say + // about lines, and a diagonal one was authored for the whole block + if (span === 0 || Math.abs(x1 - x0) > Math.abs(span) * VERTICAL_TOLERANCE) { + return gradient.toCanvasGradient(context); + } + + const lineHeight = style.metrics.lineHeight(); + const blockHeight = lines * lineHeight; + if (blockHeight <= 0) { + return gradient.toCanvasGradient(context); + } + + const repeated = context.createLinearGradient(x0, 0, x1, blockHeight); + for (let line = 0; line < lines; line++) { + for (const stop of gradient.colorStops) { + const y = line * lineHeight + y0 + stop.offset * span; + repeated.addColorStop( + Math.min(1, Math.max(0, y / blockHeight)), + stop.color, + ); + } + } + return repeated; +} + /** * apply the current text style to the given context + * @param {CanvasRenderingContext2D} context - the bake's context + * @param {object} style - the Text being drawn + * @returns {string|CanvasGradient} the resolved fill, so a caller that repaints + * the glyphs through it does not have to resolve it a second time * @ignore * @internal */ export default function setContextStyle(context, style) { + const fill = resolveFill(context, style); context.font = style.font; - // a Gradient fills the glyphs in place of the flat colour — the same choice - // `Renderer#setColor` makes, resolved here against the bake's own context - context.fillStyle = - style.fillGradient !== undefined - ? style.fillGradient.toCanvasGradient(context) - : style.fillStyle.toRGBA(); + context.fillStyle = fill; context.strokeStyle = style.strokeStyle.toRGBA(); context.lineWidth = style.lineWidth; context.textAlign = style.textAlign; context.textBaseline = style.textBaseline; + return fill; } diff --git a/packages/melonjs/tests/text_bucket.spec.js b/packages/melonjs/tests/text_bucket.spec.js index 1083f7179..e31bd13bc 100644 --- a/packages/melonjs/tests/text_bucket.spec.js +++ b/packages/melonjs/tests/text_bucket.spec.js @@ -13,6 +13,11 @@ import { * only happens across bucket boundaries, and the canvas never shrinks * (the pre-existing grow-only rule). Replaces the power-of-two rounding, * whose waste was multiplicative instead of ≤31px per axis. + * + * Height is bucketed from the layout box PLUS the ink padding, not from the + * layout box alone — the padding is canvas the glyphs genuinely occupy, so + * measuring waste against `metrics.height` would call it waste and would go + * red the moment the padding crossed a bucket boundary. */ describe("Text — 32px canvas buckets", () => { // borrow the session's single shared renderer — specs must never boot @@ -43,6 +48,12 @@ describe("Text — 32px canvas buckets", () => { return Math.ceil(n / 32) * 32; }; + // everything the canvas has to hold: the layout box and the ink that + // escapes it top and bottom + const baked = (t) => { + return t.metrics.height + t.metrics.inkPadTop + t.metrics.inkPadBottom; + }; + it("the canvas lands exactly on the metric's 32px bucket (no power-of-two jumps)", (ctx) => { requireWebGL(ctx, renderer); const t = makeText("Hello World"); @@ -50,10 +61,10 @@ describe("Text — 32px canvas buckets", () => { expect(c.width % 32).toBe(0); expect(c.height % 32).toBe(0); expect(c.width).toBe(bucket(t.metrics.width)); - expect(c.height).toBe(bucket(t.metrics.height)); + expect(c.height).toBe(bucket(baked(t))); // waste is bounded additively — the whole point of the change expect(c.width - t.metrics.width).toBeLessThan(32); - expect(c.height - t.metrics.height).toBeLessThan(32); + expect(c.height - baked(t)).toBeLessThan(32); }); it("property sweep: every string's canvas is bucket-exact and minimal", (ctx) => { @@ -80,7 +91,10 @@ describe("Text — 32px canvas buckets", () => { expect(c.height % 32, str).toBe(0); expect(c.width, str).toBeGreaterThanOrEqual(Math.ceil(t.metrics.width)); expect(c.width - t.metrics.width, str).toBeLessThan(32); - expect(c.height - t.metrics.height, str).toBeLessThan(32); + // the canvas must FIT the padded box — one pixel short of this is + // the top row of a stroke sheared off + expect(c.height, str).toBeGreaterThanOrEqual(Math.ceil(baked(t))); + expect(c.height - baked(t), str).toBeLessThan(32); } }); From 8fad82955347f8fa4ec66254c4f14996a244a1f8 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 13 Sep 2026 10:25:20 +0800 Subject: [PATCH 02/17] NoiseTexture2d: type the Noise settings it forwards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The constructor hands its settings object to `new Noise(settings)` whole, and the JSDoc said so in prose, but declared only the texture's own keys — so the emitted type rejected `type`, `seed`, `octaves` and every other field setting. The class's own `@example` would not have type-checked. Type the parameter as an intersection with `NoiseSettings` instead of transcribing its fields, which would drift the next time a setting is added there. `colorRamp` was documented as a `Gradient` the file had no reference to, so it emitted as `any`; it now resolves. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- .../src/video/texture/noise_texture2d.js | 54 ++++++++++++------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/packages/melonjs/src/video/texture/noise_texture2d.js b/packages/melonjs/src/video/texture/noise_texture2d.js index f98ef7a5e..78d4ac4a7 100644 --- a/packages/melonjs/src/video/texture/noise_texture2d.js +++ b/packages/melonjs/src/video/texture/noise_texture2d.js @@ -12,6 +12,39 @@ const lerp = (a, b, t) => { return a + (b - a) * t; }; +/** + * What a {@link NoiseTexture2d} bakes WITH — its own settings, as opposed to + * the field's. + * @typedef {object} NoiseTexture2dBakeSettings + * @property {number} [width=256] - baked texture width in pixels + * @property {number} [height=256] - baked texture height in pixels + * @property {Noise} [noise] - an existing {@link Noise} to bake; when omitted + * one is built from the forwarded field settings + * @property {boolean} [seamless=false] - tile cleanly in both axes + * @property {number} [seamlessBlendSkirt=0.1] - edge blend band width as a + * fraction (0..1) of the smaller dimension, when `seamless` + * @property {boolean} [invert=false] - invert the noise value (`1 - v`) + * @property {boolean} [asNormalMap=false] - encode as a normal map + * @property {number} [bumpStrength=1] - normal steepness when `asNormalMap` + * @property {import("../gradient.js").Gradient} [colorRamp] - map the noise + * value to a color + * @property {boolean} [animated=false] - sample in 3D (`getNoise3d`) using an + * internal `time` as the third axis, advanced by {@link NoiseTexture2d#update} + * @property {number} [speed=1] - animation speed in noise z-units per second + * (only used while `animated`) + */ + +/** + * Everything the constructor takes. + * + * The settings object is handed to {@link Noise} WHOLE when no `noise` instance + * is given, so every field setting — `type`, `seed`, `frequency`/`scale`, + * `octaves`, `gain`/`persistence`, `lacunarity`, `fractalType`, the domain-warp + * settings — belongs here too. Spelling them out a second time is what would + * drift; the intersection cannot. + * @typedef {NoiseTexture2dBakeSettings & import("../../math/noise.ts").NoiseSettings} NoiseTexture2dSettings + */ + /** * A {@link Texture2d} that bakes a {@link Noise} field into a drawable canvas — * usable directly as a sprite image, a normal map, an image layer, or a custom @@ -54,25 +87,8 @@ const lerp = (a, b, t) => { */ class NoiseTexture2d extends Texture2d { /** - * @param {object} [settings] - configuration; any {@link Noise} setting - * (`type`, `seed`, `frequency`/`scale`, `octaves`, `gain`/`persistence`, - * `lacunarity`, `fractalType`, domain-warp settings) is forwarded when no - * `noise` instance is given. - * @param {number} [settings.width=256] - baked texture width in pixels - * @param {number} [settings.height=256] - baked texture height in pixels - * @param {Noise} [settings.noise] - an existing {@link Noise} to bake; when - * omitted one is built from the forwarded settings - * @param {boolean} [settings.seamless=false] - tile cleanly in both axes - * @param {number} [settings.seamlessBlendSkirt=0.1] - edge blend band width - * as a fraction (0..1) of the smaller dimension, when `seamless` - * @param {boolean} [settings.invert=false] - invert the noise value (`1 - v`) - * @param {boolean} [settings.asNormalMap=false] - encode as a normal map - * @param {number} [settings.bumpStrength=1] - normal steepness when `asNormalMap` - * @param {Gradient} [settings.colorRamp] - map the noise value to a color - * @param {boolean} [settings.animated=false] - sample in 3D (`getNoise3d`) - * using an internal `time` as the third axis, advanced by {@link NoiseTexture2d#update} - * @param {number} [settings.speed=1] - animation speed in noise z-units per - * second (only used while `animated`) + * @param {NoiseTexture2dSettings} [settings] - bake settings, plus any + * {@link NoiseSettings} for the field itself */ constructor(settings = {}) { super(); From 925d2a40bf50f6967e6a3a024219ccabcaf66e2a Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 13 Sep 2026 10:39:24 +0800 Subject: [PATCH 03/17] Text/BitmapText: correct the docs and show the features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two BitmapText settings were documented wrongly rather than thinly: - `size` is a scaling RATIO against the font's authored size, but read as a pixel size — the same word means pixels on `Text`, so anyone moving between the two classes asks for 24 and gets 24x. - `lineWidth` promised a stroke width. BitmapText has no stroke path and the constructor never reads the setting; the documentation was for a parameter that does not exist. Its `fillStyle` also pointed `@see` at `BitmapText.tint`, which is inherited from `Renderable` and did not resolve, and did so from inside the `@param` text where it would not have rendered as a link either way. Examples now cover what the classes are actually reached for: gradient fills and their bake-local coordinates, tinting a bitmap font (and that white is the absence of a tint rather than white text), rescaling by ratio, `measureText` for sizing a panel, and tweening `visibleRatio` for a length-independent typewriter reveal. The UI and text skill advertised BitmapText in its description and triggers and then never mentioned it in the body. It now carries the ratio trap, the absent stroke, tinting, and the paired binary/image load. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- .../skills/melonjs-ui-and-text/SKILL.md | 42 +++++++++++++++++++ .../melonjs/src/renderable/text/bitmaptext.js | 38 ++++++++++++++--- packages/melonjs/src/renderable/text/text.js | 36 +++++++++++++++- 3 files changed, 109 insertions(+), 7 deletions(-) diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index f6ad9f5bd..87225033b 100644 --- a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md +++ b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md @@ -54,6 +54,48 @@ an outline keeps its own colour without any luminance trickery — and it works Canvas2D, which a post effect does not. `fillStyle.alpha` still gates the fill, and the property still reads back as a `Color`. +## BitmapText: `size` is a RATIO, not pixels + +The trap when moving over from `Text`: + +```js +new Text(x, y, { font: "Arial", size: 24, text: "SCORE" }); // 24 pixels +new BitmapText(x, y, { font: "arial", size: 24, text: "SCORE" }); // 24 TIMES +``` + +`size` scales the font's authored size, so `1` is native and `2` is double — +`resize(scale)` and `set(textAlign, scale)` take the same ratio. Whole numbers +keep pixel art crisp; fractional ones resample the page image. + +It has **no stroke** — there is no `strokeStyle` or `lineWidth` here, which is +part of why it stays sharp. Colour comes from the tint instead: + +```js +const score = new BitmapText(8, 8, { + font: "arial", text: "1000", fillStyle: "#ffd700", +}); +score.fillStyle = "#ff4040"; // recolour at any time +score.fillStyle = new Color(255, 255, 255); // UNTINTED, not "white text" +``` + +`fillStyle` is `Renderable#tint` under another name: white is the *absence* of a +tint and every other colour tints away from it, so author the page in white to +keep every colour available to you. + +Load the descriptor as `binary` and its page as `image` under the **same name**. +Both BMFont flavours — text (`.fnt`) and XML — are auto-detected, so an `.xml` +descriptor loads as-is: + +```js +loader.preload([ + { name: "arial", type: "binary", src: "data/font/arial.fnt" }, + { name: "arial", type: "image", src: "data/font/arial.png" }, +]); +``` + +Reach for it over `Text` when the text is mostly static, has to stay crisp at +integer scales, or wants recolouring without a re-bake. + ## 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 ec969b313..31dc72a36 100644 --- a/packages/melonjs/src/renderable/text/bitmaptext.js +++ b/packages/melonjs/src/renderable/text/bitmaptext.js @@ -23,9 +23,8 @@ export default class BitmapText extends Renderable { * @param {object} settings - the text configuration * @param {string|Image} settings.font - a font name to identify the corresponding source image * @param {string} [settings.fontData=settings.font] - the bitmap font data corresponding name, or the bitmap font data itself (AngelCode BMFont, `.fnt` text or `.xml`) - * @param {number} [settings.size] - size a scaling ratio - * @param {Color|string} [settings.fillStyle] - a CSS color value used to tint the bitmapText (@see BitmapText.tint) - * @param {number} [settings.lineWidth=1] - line width, in pixels, when drawing stroke + * @param {number} [settings.size=1.0] - a scaling RATIO applied to the font's authored size, not a pixel size: `2` draws it at double. ({@link Text} takes pixels here; this one does not.) + * @param {Color|string} [settings.fillStyle] - a CSS color value used to tint the glyphs, see {@link Renderable#tint} * @param {string} [settings.textAlign="left"] - horizontal text alignment * @param {string} [settings.textBaseline="top"] - the text baseline * @param {number} [settings.lineHeight=1.0] - line spacing height @@ -170,7 +169,7 @@ export default class BitmapText extends Renderable { /** * change the font settings * @param {string} textAlign - ("left", "center", "right") - * @param {number} [scale] + * @param {number} [scale] - a scaling ratio, applied through {@link BitmapText#resize} when given * @returns {BitmapText} this object for chaining */ set(textAlign, scale) { @@ -320,10 +319,27 @@ export default class BitmapText extends Renderable { } /** - * defines the color used to tint the bitmap text + * defines the color used to tint the bitmap text. + * + * This is {@link Renderable#tint} under another name, so the same rule + * applies: white — `(255, 255, 255)` — is the absence of a tint, and any + * other colour tints away from there. A page image authored in white + * therefore keeps every colour available to it. * @public * @type {Color} * @see Renderable#tint + * @example + * // tint at construction... + * const score = new BitmapText(8, 8, { + * font: "arial", + * text: "1000", + * fillStyle: "#ffd700", // gold + * }); + * app.world.addChild(score); + * + * // ...or at any point after it, from a CSS string or a Color + * score.fillStyle = "#ff4040"; // flash red on damage + * score.fillStyle = new Color(255, 255, 255); // back to untinted */ get fillStyle() { return this.tint; @@ -339,8 +355,14 @@ export default class BitmapText extends Renderable { /** * change the font display size - * @param {number} scale - ratio + * @param {number} scale - a ratio against the font's authored size, NOT a + * pixel size: `1` is the page image at its native scale, `2` is double * @returns {BitmapText} this object for chaining + * @example + * // a bitmap font is pixel art — whole-number ratios stay crisp, and + * // fractional ones resample the page image + * title.resize(3); // three times its authored size + * title.set("center", 2); // align and rescale in one call */ resize(scale) { this.fontScale.set(scale, scale); @@ -358,6 +380,10 @@ export default class BitmapText extends Renderable { * measure the given text size in pixels * @param {string} [text] * @returns {TextMetrics} a TextMetrics object with two properties: `width` and `height`, defining the output dimensions + * @example + * // size a panel around a label, at the label's CURRENT scale + * const size = label.measureText(); + * panel.resize(size.width + 16, size.height + 16); */ measureText(text = this._text) { return this.metrics.measureText(text); diff --git a/packages/melonjs/src/renderable/text/text.js b/packages/melonjs/src/renderable/text/text.js index e7b80ef57..fbbe3ccac 100644 --- a/packages/melonjs/src/renderable/text/text.js +++ b/packages/melonjs/src/renderable/text/text.js @@ -87,6 +87,23 @@ export default class Text extends Renderable { * label.setOpacity(0.8); // per-object transparency * app.world.addChild(label); * @example + * // a gradient fill: `fillStyle` takes a Gradient as well as a colour. + * // Its coordinates are the label's OWN bake, so (0, 0) is the top-left of + * // the render box and the ramp below runs down exactly one line. + * const ramp = app.renderer.createLinearGradient(0, 0, 0, 32); + * ramp.addColorStop(0, "#fffdf0"); + * ramp.addColorStop(1, "#ffa71d"); + * + * app.world.addChild(new Text(8, 8, { + * font: "sans-serif", + * size: 32, + * fillStyle: ramp, // ramps the glyphs... + * strokeStyle: "#000000", // ...while the outline keeps its own colour + * lineWidth: 1, + * text: "GAME\nOVER", // every line restarts the ramp by default + * // gradientPerLine: false, // ...or span ONE ramp across both lines + * })); + * @example * // a web font (loaded via the fontface loader) is used by its family name * loader.preload( * [{ name: "kenpixel", type: "fontface", src: "data/font/kenvector.woff2" }], @@ -495,9 +512,18 @@ export default class Text extends Renderable { /** * the ratio of visible characters (0.0 to 1.0). - * Setting this automatically updates {@link visibleCharacters}. + * Setting this automatically updates {@link Text#visibleCharacters}. + * + * This is the one to tween: it is independent of how many characters the + * label holds, so a reveal takes the same time whatever the string is. * @public * @type {number} + * @default 1.0 + * @see Text#visibleCharacters + * @example + * // reveal over two seconds, regardless of length + * label.visibleRatio = 0; + * new Tween(label).to({ visibleRatio: 1.0 }, { duration: 2000 }).start(); */ get visibleRatio() { if (this._visibleCharacters === -1) { @@ -576,6 +602,14 @@ export default class Text extends Renderable { * measure the given text size in pixels * @param {string} [text] - the text to be measured * @returns {TextMetrics} a TextMetrics object defining the dimensions of the given piece of text + * @example + * // size a panel around a label + * const size = label.measureText(); + * panel.resize(size.width + 16, size.height + 16); + * + * // or measure a string the label does not currently hold, to reserve + * // room for the widest state a counter will reach + * const widest = label.measureText("00:00").width; */ measureText(text = this._text) { return this.metrics.measureText(text, this.canvasTexture.context); From 1fa2046600af4eff64d593c700d70ce70497d6ef Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 13 Sep 2026 13:22:29 +0800 Subject: [PATCH 04/17] Text: a label that moves now moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TextMetrics#x/y` is the box the bake is blitted at, derived from `pos` — but nothing else in `measureText` reads `pos`, so it was refreshed only when the string changed. Move a label without re-setting its text and the bake offset (`pos - metrics`) grew by the whole distance travelled while the canvas stayed the size it was: the glyphs slid off their own canvas and were clipped, and the blit still went to where the label used to be. Split that origin out as `TextMetrics#updateOrigin` and refresh it from the draw path. It reads `pos` and an already-measured width, and measures no glyphs, so a per-frame call costs nothing. The sub-pixel arithmetic is preserved rather than relocated: the canvas still floors onto a whole pixel, because a texture blitted at a fractional coordinate resamples and goes soft, and the fraction the floor drops still lands in the bake where the font rasterizer antialiases it. For a label that has not moved, `updateOrigin` recomputes what `measureText` already computed, from the same inputs — so a stationary label is untouched by construction. The new spec pins both halves: that the canvas lands on a whole pixel at fractional and negative positions, that a whole-pixel move leaves the glyph phase bit-identical while a sub-pixel move moves the phase and not the canvas, and that the origin follows a long move. Its draw-path case fails with `expected 10 to be 120` if the refresh is removed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- packages/melonjs/CHANGELOG.md | 2 + packages/melonjs/src/renderable/text/text.js | 12 ++ .../src/renderable/text/textmetrics.js | 34 ++- .../melonjs/tests/text-pixel-snapping.spec.js | 196 ++++++++++++++++++ 4 files changed, 237 insertions(+), 7 deletions(-) create mode 100644 packages/melonjs/tests/text-pixel-snapping.spec.js diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index ae1d8c555..f7f01dd64 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -19,6 +19,8 @@ - Mesh: a mesh with `normalize: false` and an explicit `scale` now sizes itself from its geometry when no `width`/`height` is given, the way a `Sprite` sizes itself from its frame. It reported a zero-size box at its position while drawing at full size, misleading frustum culling, pointer picking and the broadphase alike. `meshScale` is unchanged - Mesh: a `ShaderEffect` attached to a mesh drew the geometry unplaced and without the camera on WebGL, and was refused on WebGPU with a message naming the wrong reason. An effect is realized against the quad vertex contract, which declares neither `uModelMatrix` nor `uViewMatrix` ([#1658](https://github.com/melonjs/melonJS/issues/1658)) +- Text: moving a label now moves it. `metrics.x/y` — the box the bake is blitted at — is derived from `pos`, but nothing else in `measureText` is, so it refreshed only when the *string* changed. Reposition a label without re-setting its text and the bake offset had grown by the whole distance travelled while the canvas stayed the size it was: the glyphs slid off their own canvas and were clipped, while the blit still went to where the label used to be. The origin is refreshed from `pos` on every draw now, which reads two numbers and measures no glyphs. Sub-pixel placement is unchanged — the canvas still lands on a whole pixel, and the fraction the floor drops still goes into the bake where the rasterizer can antialias it rather than resampling the texture + ## [20.4.0] (melonJS 2) - _2026-09-09_ ### Added diff --git a/packages/melonjs/src/renderable/text/text.js b/packages/melonjs/src/renderable/text/text.js index fbbe3ccac..ec17425de 100644 --- a/packages/melonjs/src/renderable/text/text.js +++ b/packages/melonjs/src/renderable/text/text.js @@ -620,6 +620,18 @@ export default class Text extends Renderable { * @param {CanvasRenderer|WebGLRenderer} renderer - Reference to the destination renderer instance */ draw(renderer) { + // Re-anchor the box to where the label is NOW. + // + // `metrics.x/y` is derived from `pos`, but the rest of `measureText` is + // not, so it used to refresh only when the STRING changed. Move a label + // without re-setting its text and the bake offset (`pos - metrics`) grew + // by the whole distance moved while the canvas stayed the size it was — + // the glyphs slid off their own canvas and were clipped, and the blit + // still went to where the label used to be. Refreshing the origin is + // cheap: it reads `pos` and the already-measured width, and measures no + // glyphs. + this.metrics.updateOrigin(); + // re-render the canvas texture when dirty (e.g. visibleCharacters changed) if (this.isDirty) { this.canvasTexture.clear(); diff --git a/packages/melonjs/src/renderable/text/textmetrics.js b/packages/melonjs/src/renderable/text/textmetrics.js index b222ce124..f199f81e7 100644 --- a/packages/melonjs/src/renderable/text/textmetrics.js +++ b/packages/melonjs/src/renderable/text/textmetrics.js @@ -205,7 +205,33 @@ class TextMetrics extends Bounds { this.width = Math.ceil(this.width); this.height = Math.ceil(this.height); - // compute the bounding box position + this.updateOrigin(); + + if (typeof context !== "undefined") { + // restore the context + context.restore(); + } + + return this; + } + + /** + * Recompute the box's position from the ancestor's CURRENT `pos`. + * + * Split out of `measureText` because it depends on `pos` while everything + * else there depends only on the string and the font. A label that moves + * needs this refreshed — and nothing else — so the draw path can call it + * per frame without re-measuring a single glyph. + * + * The `Math.floor` is what puts the canvas on a whole pixel. The remainder + * it drops is not lost: `Text` bakes its glyphs at `pos - metrics`, so the + * sub-pixel part lands in the rasterization, where the font rasterizer can + * antialias it, instead of resampling the whole texture. + * @returns {TextMetrics} this instance for chaining + * @ignore + * @internal + */ + updateOrigin() { this.x = Math.floor( this.ancestor.textAlign === "right" ? this.ancestor.pos.x - this.width @@ -220,12 +246,6 @@ class TextMetrics extends Bounds { ? this.ancestor.pos.y - this.lineHeight() / 2 : this.ancestor.pos.y - this.lineHeight(), ); - - if (typeof context !== "undefined") { - // restore the context - context.restore(); - } - return this; } diff --git a/packages/melonjs/tests/text-pixel-snapping.spec.js b/packages/melonjs/tests/text-pixel-snapping.spec.js new file mode 100644 index 000000000..27a88d099 --- /dev/null +++ b/packages/melonjs/tests/text-pixel-snapping.spec.js @@ -0,0 +1,196 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { Text } from "../src/index.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, + requireWebGL, +} from "./helpers/webgl-context.js"; + +/** + * The two halves of a `Text`'s position, and the rule that keeps them apart. + * + * A label is a canvas blitted at `metrics.x/y` with its glyphs baked at + * `pos - metrics` INSIDE that canvas. `metrics` is floored, so the canvas + * always lands on a whole pixel — a texture blitted at a fractional coordinate + * resamples and goes soft — and the fraction the floor drops is handed to the + * bake instead, where the font rasterizer antialiases it properly. The two must + * always sum back to `pos`. + * + * `metrics.x/y` reads `pos`, but nothing else in `measureText` does, so it used + * to be refreshed only when the STRING changed. A label that moved kept the + * origin it was born with: the bake offset grew by the whole distance travelled + * while the canvas stayed its original size, so the glyphs slid off their own + * canvas and were clipped. `TextMetrics#updateOrigin` is the split-out part the + * draw path now refreshes every frame. + */ +describe("Text — pixel snapping and a label that moves", () => { + // borrow the session's shared renderer: every test here needs Text to be + // constructible, and the draw-path test needs something to draw into + let renderer; + + beforeAll(async () => { + renderer = await getWebGLRenderer(); + }); + + afterAll(() => { + releaseWebGLRenderer(); + }); + + const makeText = (x, y, settings = {}) => { + return new Text(x, y, { + font: "Arial", + size: 16, + fillStyle: "#ffffff", + text: "Hello", + ...settings, + }); + }; + + /** where the glyphs sit inside their canvas */ + const bakeOffset = (t) => { + return { x: t.pos.x - t.metrics.x, y: t.pos.y - t.metrics.y }; + }; + + it("lands the canvas on a whole pixel, whatever the position", (ctx) => { + requireWebGL(ctx, renderer); + for (const [x, y] of [ + [0, 0], + [10.5, 20.25], + [0.999, 0.001], + [-3.75, -8.5], + ]) { + const t = makeText(x, y); + t.metrics.updateOrigin(); + expect(Number.isInteger(t.metrics.x), `x at ${x}`).toBe(true); + expect(Number.isInteger(t.metrics.y), `y at ${y}`).toBe(true); + } + }); + + it("hands the dropped sub-pixel to the bake, not to the blit", (ctx) => { + requireWebGL(ctx, renderer); + const t = makeText(10.25, 20.75); + t.metrics.updateOrigin(); + + // default align/baseline is left/top, so the glyph origin is the box + // origin and the whole remainder is the fraction the floor dropped + const offset = bakeOffset(t); + expect(offset.x).toBeCloseTo(0.25, 10); + expect(offset.y).toBeCloseTo(0.75, 10); + + // and the two halves still sum back to where the caller put the label + expect(t.metrics.x + offset.x).toBeCloseTo(10.25, 10); + expect(t.metrics.y + offset.y).toBeCloseTo(20.75, 10); + }); + + it("keeps the glyph phase identical across a whole-pixel move", (ctx) => { + requireWebGL(ctx, renderer); + const t = makeText(10.25, 20.75); + t.metrics.updateOrigin(); + const before = bakeOffset(t); + const originX = t.metrics.x; + + t.pos.x += 40; + t.pos.y += 7; + t.metrics.updateOrigin(); + + // the canvas moved by exactly the distance travelled... + expect(t.metrics.x).toBe(originX + 40); + // ...and the glyphs did not move inside it at all, so the rasterized + // pixels are reusable rather than merely close + const after = bakeOffset(t); + expect(after.x).toBeCloseTo(before.x, 10); + expect(after.y).toBeCloseTo(before.y, 10); + }); + + it("absorbs a sub-pixel move into the phase, not the canvas", (ctx) => { + requireWebGL(ctx, renderer); + const t = makeText(10, 20); + t.metrics.updateOrigin(); + const originX = t.metrics.x; + + t.pos.x += 0.4; + t.metrics.updateOrigin(); + + // the canvas stays put — a 0.4px blit would resample the whole texture + expect(t.metrics.x).toBe(originX); + // the shift is carried by the bake instead + expect(bakeOffset(t).x).toBeCloseTo(0.4, 10); + }); + + it("follows a moved label instead of keeping its birth origin", (ctx) => { + requireWebGL(ctx, renderer); + const t = makeText(10, 10); + const born = t.metrics.x; + + // move it a long way WITHOUT re-setting the text — the case that used + // to slide the glyphs off their own canvas + t.pos.x = 400; + t.metrics.updateOrigin(); + + expect(t.metrics.x).toBe(born + 390); + // the bake offset must stay sub-pixel: it is what used to grow by the + // whole 390px and push the glyphs past the canvas edge + expect(Math.abs(bakeOffset(t).x)).toBeLessThan(1); + }); + + it("refreshes the origin from the DRAW path, not only from setText", (ctx) => { + requireWebGL(ctx, renderer); + const t = makeText(10, 10); + const born = t.metrics.x; + + // move it and draw, without touching the text. Every other test here + // calls `updateOrigin` itself, so this is the one that fails if the + // draw path stops asking for it — which is the whole fix. + t.pos.x = 120; + t.draw(renderer); + + expect(t.metrics.x).toBe(born + 110); + expect(Math.abs(t.pos.x - t.metrics.x)).toBeLessThan(1); + }); + + it("snaps under every textAlign", (ctx) => { + requireWebGL(ctx, renderer); + for (const textAlign of ["left", "center", "right"]) { + const t = makeText(30.5, 10, { textAlign }); + t.metrics.updateOrigin(); + + expect(Number.isInteger(t.metrics.x), textAlign).toBe(true); + + // the glyph origin differs per alignment, but the canvas is always + // within a pixel below the point the alignment asks for + const anchored = + textAlign === "right" + ? 30.5 - t.metrics.width + : textAlign === "center" + ? 30.5 - t.metrics.width / 2 + : 30.5; + expect(t.metrics.x, textAlign).toBe(Math.floor(anchored)); + } + }); + + it("snaps under every textBaseline", (ctx) => { + requireWebGL(ctx, renderer); + for (const textBaseline of [ + "top", + "hanging", + "middle", + "alphabetic", + "ideographic", + "bottom", + ]) { + const t = makeText(10, 30.5, { textBaseline }); + t.metrics.updateOrigin(); + + expect(Number.isInteger(t.metrics.y), textBaseline).toBe(true); + + const line = t.metrics.lineHeight(); + const anchored = + textBaseline === "top" || textBaseline === "hanging" + ? 30.5 + : textBaseline === "middle" + ? 30.5 - line / 2 + : 30.5 - line; + expect(t.metrics.y, textBaseline).toBe(Math.floor(anchored)); + } + }); +}); From 9cac0c2c9bba206dd4db52a2d17f0b70e9d7bddf Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 13 Sep 2026 13:22:48 +0800 Subject: [PATCH 05/17] Typings: stop rejecting the options the docs promise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every one of these was found by type-checking an example against the shipped declarations, and every one was documented behaviour the types denied. `Mesh` declared its 27 settings inline on the constructor, so `InstancedMesh` — which forwards them wholesale to `super` and says so in prose — rejected all of them. Extracted as `MeshSettings` and intersected, the way `NoiseTexture2d` now states its forwarded `Noise` settings: a subclass that passes the object along says so once, instead of restating a list that drifts. `setCurrentAnimation(name, { loop: true })` is the form the class's own example uses, and it did not compile: the parameter was typed against the NORMALIZED options that `parseAnimationOptions` returns, where `loop` and `speed` are mandatory. Added `AnimationOptionsInput` for what a caller may pass. `Container#setChildsProperty` took an `object`, refusing the booleans and strings most properties actually hold. `image`/`texture` refused an `HTMLCanvasElement` the renderer has always accepted. `Stage#update` and `#draw` were marked internal and stripped from the declarations, so a custom stage could not call `super.update(dt)` in TypeScript — while the skills, the examples and the docs all teach exactly that. They are the documented override points, so they are public now. One behaviour fix rides with these: `Sprite3d` builds its own settings for `Mesh` rather than forwarding yours, and `fog` was not among the keys it copied — so `fog: false`, the only way to keep a sun out of the haze, silently did nothing. The settings shapes are exported and documented, which also removes three pre-existing typedoc warnings: 157 down to 154. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- packages/melonjs/CHANGELOG.md | 3 + packages/melonjs/src/index.ts | 13 ++++ packages/melonjs/src/level/gltf/GLTFModel.js | 8 +-- packages/melonjs/src/renderable/animation.ts | 33 +++++++--- packages/melonjs/src/renderable/container.js | 3 +- .../melonjs/src/renderable/instanced_mesh.js | 19 ++++-- packages/melonjs/src/renderable/mesh.js | 65 +++++++++++-------- packages/melonjs/src/renderable/sprite3d.js | 6 +- packages/melonjs/src/state/stage.ts | 10 +-- 9 files changed, 109 insertions(+), 51 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index f7f01dd64..cb1094d4a 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -21,6 +21,9 @@ - Text: moving a label now moves it. `metrics.x/y` — the box the bake is blitted at — is derived from `pos`, but nothing else in `measureText` is, so it refreshed only when the *string* changed. Reposition a label without re-setting its text and the bake offset had grown by the whole distance travelled while the canvas stayed the size it was: the glyphs slid off their own canvas and were clipped, while the blit still went to where the label used to be. The origin is refreshed from `pos` on every draw now, which reads two numbers and measures no glyphs. Sub-pixel placement is unchanged — the canvas still lands on a whole pixel, and the fraction the floor drops still goes into the bake where the rasterizer can antialias it rather than resampling the texture +- `Sprite3d`: `fog` reaches the mesh. A sprite builds its own settings for `Mesh` rather than forwarding yours, and `fog` was not among the keys it copied — so `fog: false`, documented on `Mesh` and the only way to keep a sun or a moon out of the haze, silently did nothing. `transparent`, `castGroundShadow` and `fog` are now documented on `Sprite3d` itself +- Typings: options the documentation promises are no longer rejected by TypeScript. `NoiseTexture2d` forwards every `Noise` setting (`type`, `seed`, `octaves`, …) and said so in prose while declaring none of them, so its own documented example did not compile; `InstancedMesh` forwards every `Mesh` setting the same way; `setCurrentAnimation(name, { loop: true })` was typed against the *normalized* options, which make `loop` and `speed` mandatory; `Container#setChildsProperty` took `object`, refusing the booleans and strings most properties hold; `image`/`texture` refused an `HTMLCanvasElement` the renderer accepts; and `Stage#update`/`#draw` were marked internal and stripped, so a custom stage could not call `super.update(dt)` in TypeScript at all. The settings shapes are now named types — `MeshSettings`, `InstancedMeshSettings`, `NoiseTexture2dSettings`, `AnimationOptionsInput` — exported and documented, so a forwarding subclass states the intersection instead of restating a list that drifts + ## [20.4.0] (melonJS 2) - _2026-09-09_ ### Added diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts index f753c8877..0d0419866 100644 --- a/packages/melonjs/src/index.ts +++ b/packages/melonjs/src/index.ts @@ -166,10 +166,23 @@ export { default as BuiltinAdapter } from "./physics/builtin/builtin-adapter.ts" export { collision } from "./physics/collision.js"; export * as plugin from "./plugin/plugin.ts"; export { getPool } from "./pool.ts"; +export type { + AnimationOptions, + AnimationOptionsInput, +} from "./renderable/animation.ts"; +export type { + InstancedMeshOwnSettings, + InstancedMeshSettings, +} from "./renderable/instanced_mesh.js"; +export type { MeshSettings } from "./renderable/mesh.js"; export * as device from "./system/device.js"; export * as event from "./system/event.ts"; export * as utils from "./utils/utils.ts"; export * from "./version.ts"; +export type { + NoiseTexture2dBakeSettings, + NoiseTexture2dSettings, +} from "./video/texture/noise_texture2d.js"; export * as video from "./video/video.js"; // export all class definition export { diff --git a/packages/melonjs/src/level/gltf/GLTFModel.js b/packages/melonjs/src/level/gltf/GLTFModel.js index 0bb943194..3ceec0137 100644 --- a/packages/melonjs/src/level/gltf/GLTFModel.js +++ b/packages/melonjs/src/level/gltf/GLTFModel.js @@ -15,7 +15,7 @@ import { linearToSrgb8 } from "./srgb.js"; /** * additional import for TypeScript - * @import { AnimationOptions } from "../../renderable/animation.ts"; + * @import { AnimationOptionsInput } from "../../renderable/animation.ts"; */ // column-major identity, the root's parent transform when the model sits @@ -92,7 +92,7 @@ const _localScratch = new Array(16); */ export default class GLTFModel extends Container { /** - * @param {object} data - the parsed glTF descriptor (`{ graph, animations, bounds, ... }`) + * @param {import("../../loader/loader.js").GLTFData} data - the parsed glTF descriptor, as returned by {@link loader.getGLTF} * @param {object} [options] * @param {number} [options.scale=1] - pixels per glTF unit (uniform scene scale) * @param {boolean} [options.rightHanded=true] - glTF Y-up → engine Y-down via a rotation (no mirror) @@ -404,7 +404,7 @@ export default class GLTFModel extends Container { * another clip when this one ends, a `function` legacy completion callback * (return `false` to hold the final pose), or an options object. * @param {string} name - animation clip id (see {@link GLTFModel#getAnimationNames}) - * @param {string|Function|AnimationOptions} [options] - loop / chain / completion behavior + * @param {AnimationOptionsInput} [options] - loop / chain / completion behavior * @param {boolean} [preserveTime=false] - keep the current playback time instead of restarting at 0 * @returns {GLTFModel} this, for chaining * @example @@ -478,7 +478,7 @@ export default class GLTFModel extends Container { * (and start) it, or with no argument to resume after {@link GLTFModel#pause}. * Always clears the paused state. * @param {string} [name] - clip id to play; omit to just resume - * @param {string|Function|AnimationOptions} [options] - loop / chain / completion behavior (see {@link GLTFModel#setCurrentAnimation}) + * @param {AnimationOptionsInput} [options] - loop / chain / completion behavior (see {@link GLTFModel#setCurrentAnimation}) * @returns {GLTFModel} this, for chaining * @example * model.play("walk"); // switch to + play "walk" diff --git a/packages/melonjs/src/renderable/animation.ts b/packages/melonjs/src/renderable/animation.ts index 00060ad8e..6aeb0b46c 100644 --- a/packages/melonjs/src/renderable/animation.ts +++ b/packages/melonjs/src/renderable/animation.ts @@ -23,6 +23,30 @@ export interface AnimationOptions { legacyFn?: boolean; } +/** + * What a caller may PASS as the second argument of `setCurrentAnimation`. + * + * The loose counterpart of {@link AnimationOptions}, which is what those + * choices normalize TO. Every field here is optional, because the defaults are + * the point: `{ loop: true }` has to be a complete thing to say. Typing an + * argument as the normalized shape instead makes `loop` and `speed` mandatory + * and turns the shortest useful call into a type error. + * @category Animation + */ +export type AnimationOptionsInput = + | string + | (() => unknown) + | { + /** called when the animation completes a cycle */ + onComplete?: () => unknown; + /** name of an animation to switch to when this one finishes */ + next?: string; + /** loop forever (default) or play once and hold the last frame */ + loop?: boolean; + /** playback rate multiplier (1 = authored speed) */ + speed?: number; + }; + /** * Normalize the polymorphic second argument of `setCurrentAnimation` into a * uniform {@link AnimationOptions}. Accepts: @@ -36,14 +60,7 @@ export interface AnimationOptions { */ export function parseAnimationOptions( arg?: - | string - | (() => unknown) - | { - onComplete?: () => unknown; - next?: string; - loop?: boolean; - speed?: number; - } + | AnimationOptionsInput // `null` is passed by the internal animation-chain call // (`setCurrentAnimation(next, null, true)`), so it must be accepted. | null, diff --git a/packages/melonjs/src/renderable/container.js b/packages/melonjs/src/renderable/container.js index 01e866a3c..44234282a 100644 --- a/packages/melonjs/src/renderable/container.js +++ b/packages/melonjs/src/renderable/container.js @@ -959,7 +959,8 @@ export default class Container extends Renderable { * {@link Renderable#alpha} on the container instead: opacity cascades at * draw time and leaves every child's own value alone. * @param {string} prop - property name - * @param {object} value - property value + * @param {*} value - property value; whatever the named property holds, so a + * flag is a boolean and a colour is a string, not only an object * @param {boolean} [recursive=false] - recursively apply the value to child containers if true * @see Renderable#alpha */ diff --git a/packages/melonjs/src/renderable/instanced_mesh.js b/packages/melonjs/src/renderable/instanced_mesh.js index 6a95bc094..af69beeb6 100644 --- a/packages/melonjs/src/renderable/instanced_mesh.js +++ b/packages/melonjs/src/renderable/instanced_mesh.js @@ -101,14 +101,25 @@ const _dirtySpan = [0, 0]; * forest.visibleInstanceCount = 1200; * app.world.addChild(forest, 10); */ +/** + * Everything {@link InstancedMesh} takes: the instancing settings, plus every + * {@link MeshSettings} — they are handed to {@link Mesh} whole, so the type + * says so by intersection rather than by copying the list. + * @typedef {object} InstancedMeshOwnSettings + * @property {number} [instanceCount=0] - number of instances to pre-allocate. Instances start at the group origin (identity transform) until placed; `addInstance` grows past this. + * @property {boolean} [instanceColors=false] - give each instance its own colour (16 bytes per instance), multiplied into the mesh tint + * @property {boolean} [instanceData=false] - give each instance an opaque `vec4` (16 bytes per instance). Read as emissive by the built-in lit shading, or as anything at all by a custom mesh shader. + */ + +/** + * @typedef {InstancedMeshOwnSettings & import("./mesh.js").MeshSettings} InstancedMeshSettings + */ + export default class InstancedMesh extends Mesh { /** * @param {number} x - the x coordinate of the group origin * @param {number} y - the y coordinate of the group origin - * @param {object} settings - every {@link Mesh} setting, plus those below - * @param {number} [settings.instanceCount=0] - number of instances to pre-allocate. Instances start at the group origin (identity transform) until placed; `addInstance` grows past this. - * @param {boolean} [settings.instanceColors=false] - give each instance its own colour (16 bytes per instance), multiplied into the mesh tint - * @param {boolean} [settings.instanceData=false] - give each instance an opaque `vec4` (16 bytes per instance). Read as emissive by the built-in lit shading, or as anything at all by a custom mesh shader. + * @param {InstancedMeshSettings} settings - every {@link Mesh} setting, plus the instancing ones */ constructor(x, y, settings) { super(x, y, settings); diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index 8a580fe9b..796257ada 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -303,6 +303,42 @@ function buildTextureGroups( * vertex bake is the single anchoring mechanism. * @category Game Objects */ +/** + * Everything {@link Mesh} takes. + * + * Split out of the constructor's own `@param` list so a subclass that + * forwards these wholesale — {@link InstancedMesh} does — can say so in its + * type, instead of restating 27 entries that would then drift apart. + * @typedef {object} MeshSettings + * @property {string} [model] - name of a preloaded OBJ model (via loader.preload with type "obj"). Vertex normals come with it — authored `vn` when the file has them, generated from face geometry when it does not — so an OBJ model can be `lit`. + * @property {Float32Array|number[]} [vertices] - vertex positions as x,y,z triplets (alternative to `model`) + * @property {Float32Array|number[]} [uvs] - texture coordinates as u,v pairs (alternative to `model`) + * @property {Uint16Array|number[]} [indices] - triangle vertex indices (alternative to `model`) + * @property {HTMLImageElement|HTMLCanvasElement|TextureAtlas|string} [texture] - the texture to apply (image name, HTMLImageElement, or TextureAtlas). If omitted and settings.material is provided, the texture is resolved from the MTL material's map_Kd. Passing this pins ONE binding over the whole model, which on a multi-material model suppresses the per-material texture split — see {@link Mesh#textureGroups}. + * @property {string} [material] - name of a preloaded MTL material (via loader.preload with type "mtl"). When provided, the diffuse texture (map_Kd), tint color (Kd), and opacity (d) are automatically applied. On a multi-material model each material's own `map_Kd` is bound for its own slice of the geometry (#1573) and each `Kd` is baked per-vertex, so one `Mesh` renders the whole model. + * @property {number} width - display width in pixels. With normalization on (the default) the model is scaled to fit this size; with `normalize: false` this is the uniform pixels-per-unit scale applied to the raw geometry. With `normalize: false` and an explicit `scale`, an omitted `width`/`height` is derived from the GEOMETRY's own extent, the way a Sprite takes its size from its frame — a mesh that reports no extent misleads frustum culling, pointer picking and the physics broadphase alike. + * @property {number} [height] - display height in pixels (normalized models only; ignored when `normalize: false`) + * @property {boolean} [cullBackFaces=true] - enable backface culling + * @property {boolean} [normalize=true] - fit the source geometry into a `[-0.5, 0.5]` unit cube before scaling, so `width`/`height` behave like a Sprite. Set `false` to keep the geometry's real-world coordinates — required when several meshes share one coordinate space (e.g. nodes of an imported glTF scene) so their relative scale and layout are preserved. + * @property {number} [scale] - world-space scale (pixels per source unit) for the Camera3d path; defaults to `width`. Set this when `width`/`height` describe the renderable's world bounds (frustum culling) rather than the geometry scale — see {@link Mesh#meshScale}. + * @property {boolean} [rightHanded=false] - treat the source as right-handed (Y-up, e.g. glTF) under the `Camera3d` world path. The default Y-up→Y-down bridge negates Y only (a reflection, which mirrors the scene left/right); `true` negates Y **and** Z (a rotation) so chirality is preserved and the result matches the authoring tool. See {@link Mesh#rightHanded}. + * @property {string} [textureRepeat] - texture wrap mode (`"repeat"` / `"repeat-x"` / `"repeat-y"` / `"no-repeat"`) this mesh samples its texture with (per-mesh — it does not modify the shared texture, so other meshes/sprites using the same image are unaffected). Use `"repeat"` when the geometry's UVs fall outside the `[0, 1]` range and rely on the texture tiling (e.g. glTF assets, whose default sampler wrap is REPEAT) — otherwise the texture clamps to its edge texels and looks flat. Ignored for the white-pixel fallback. Note: REPEAT on a non-power-of-two texture requires WebGL 2. + * @property {string} [textureFilter] - texture magnification filter (`"nearest"` for crisp pixel-art upscaling, `"linear"` for smooth) applied to the resolved texture. Omit to keep the renderer's global `antiAlias` default. On the mesh path, linear filtering also samples a generated mip chain with trilinear minification and 4× anisotropy (distant geometry stops shimmering) — `"nearest"` opts out, keeping crisp pixel-art models on hard level-0 sampling. GPU backends only (ignored by the Canvas renderer). + * @property {number} [alphaCutoff=0] - alpha cutout threshold. Fragments whose final alpha is below this value are discarded (hard-edged cutout — foliage, fences, decals — with no blending or sorting). `0` disables the cutout. Set automatically by the glTF loader from a material's `alphaMode: "MASK"`. GPU mesh path only (WebGL and WebGPU; the Canvas renderer ignores it). + * @property {number[]|Float32Array} [emissive] - emissive (self-illumination) color `[r, g, b]` (0..1, may exceed 1 for HDR glow) added on top of the lit/unlit color so the surface glows regardless of scene lights (neon, lava, screens). Omit / all-zero for no emission. Set automatically by the glTF loader (`emissiveFactor`) and OBJ loader (MTL `Ke`). GPU mesh path only (WebGL and WebGPU; the Canvas renderer ignores it). + * @property {boolean} [lit=false] - shade this mesh with the scene's {@link Light3d} lights (the lit mesh pipeline) instead of rendering fullbright. Set automatically by the glTF importer when the scene carries a directional, point or spot light. With `lit` on and no lights present the batcher uploads a white ambient, so the result is indistinguishable from unlit. + * @property {Uint32Array|Color[]|number[]} [vertexColors] - per-vertex colour, one entry per vertex, multiplied into {@link Mesh#tint}. Either packed RGBA8 (`Uint32Array`, the form the batchers read — no conversion) or one {@link Color} per vertex. Omit for plain white. Lets a single mesh carry a gradient — fading a terrain toward the sky with distance, darkening a crease — which a per-object `tint` cannot express. An explicit value wins over the colours a multi-material OBJ bakes from its MTL. + * @property {number[]|Float32Array} [normals] - per-vertex normals for the lit path. An explicit value wins over the ones an OBJ or glTF source supplies; omit it and they are taken from the model, or generated from the geometry when the mesh is `lit`. Generated normals average per vertex where faces share vertices (smooth shading) and equal the face normal where they do not (flat shading) — the geometry decides, not a flag. + * @property {number[]|Float32Array} [specular] - specular color `[r, g, b]` (0..1) for the lit path. Set by the OBJ loader from MTL `Ks`, and derived from glTF metallic/roughness. + * @property {number} [shininess=0] - specular exponent for the lit path (MTL `Ns`). `0` for a fully diffuse surface. + * @property {string|TextureAtlas|HTMLImageElement} [alphaMap] - per-texel opacity map, sampled in addition to the diffuse texture (MTL `map_d`). + * @property {boolean} [castGroundShadow] - give this mesh a blob ground shadow, overriding the application's `castGroundShadow` setting in both directions. Omit to inherit. Needs a GPU backend and a `Camera3d`. + * @property {boolean} [transparent] - draw in the transparent pass (blended, back-to-front, no depth write). Omit and a mesh goes transparent whenever its draw alpha is fractional; `true` for soft-alpha textures; `false` to stay opaque however faded + * @property {boolean} [fog] - set `false` to exempt this mesh from the camera's distance fog ({@link Camera3d#setFog}); omit to fog whenever the camera does + * @property {number} [shadowGroundY] - world Y of the floor the shadow lands on. Omit and the blob sits at the object's own base at full strength; set it and the blob shrinks and fades as the object rises. Render space is Y-down, so the floor is a **greater** Y than the object above it. + * @property {number} [shadowOpacity=0.45] - opacity of the shadow directly beneath the object, before any height fade. + */ + /** * The width and height a mesh should report, filling in from the GEOMETRY what * the caller did not declare — the way a Sprite takes its size from its frame. @@ -374,34 +410,7 @@ export default class Mesh extends Renderable { /** * @param {number} x - the x screen position of the mesh object * @param {number} y - the y screen position of the mesh object - * @param {object} settings - Configuration parameters for the Mesh object - * @param {string} [settings.model] - name of a preloaded OBJ model (via loader.preload with type "obj"). Vertex normals come with it — authored `vn` when the file has them, generated from face geometry when it does not — so an OBJ model can be `lit`. - * @param {Float32Array|number[]} [settings.vertices] - vertex positions as x,y,z triplets (alternative to settings.model) - * @param {Float32Array|number[]} [settings.uvs] - texture coordinates as u,v pairs (alternative to settings.model) - * @param {Uint16Array|number[]} [settings.indices] - triangle vertex indices (alternative to settings.model) - * @param {HTMLImageElement|TextureAtlas|string} [settings.texture] - the texture to apply (image name, HTMLImageElement, or TextureAtlas). If omitted and settings.material is provided, the texture is resolved from the MTL material's map_Kd. Passing this pins ONE binding over the whole model, which on a multi-material model suppresses the per-material texture split — see {@link Mesh#textureGroups}. - * @param {string} [settings.material] - name of a preloaded MTL material (via loader.preload with type "mtl"). When provided, the diffuse texture (map_Kd), tint color (Kd), and opacity (d) are automatically applied. On a multi-material model each material's own `map_Kd` is bound for its own slice of the geometry (#1573) and each `Kd` is baked per-vertex, so one `Mesh` renders the whole model. - * @param {number} settings.width - display width in pixels. With normalization on (the default) the model is scaled to fit this size; with `normalize: false` this is the uniform pixels-per-unit scale applied to the raw geometry. With `normalize: false` and an explicit `scale`, an omitted `width`/`height` is derived from the GEOMETRY's own extent, the way a Sprite takes its size from its frame — a mesh that reports no extent misleads frustum culling, pointer picking and the physics broadphase alike. - * @param {number} [settings.height] - display height in pixels (normalized models only; ignored when `normalize: false`) - * @param {boolean} [settings.cullBackFaces=true] - enable backface culling - * @param {boolean} [settings.normalize=true] - fit the source geometry into a `[-0.5, 0.5]` unit cube before scaling, so `width`/`height` behave like a Sprite. Set `false` to keep the geometry's real-world coordinates — required when several meshes share one coordinate space (e.g. nodes of an imported glTF scene) so their relative scale and layout are preserved. - * @param {number} [settings.scale] - world-space scale (pixels per source unit) for the Camera3d path; defaults to `width`. Set this when `width`/`height` describe the renderable's world bounds (frustum culling) rather than the geometry scale — see {@link Mesh#meshScale}. - * @param {boolean} [settings.rightHanded=false] - treat the source as right-handed (Y-up, e.g. glTF) under the `Camera3d` world path. The default Y-up→Y-down bridge negates Y only (a reflection, which mirrors the scene left/right); `true` negates Y **and** Z (a rotation) so chirality is preserved and the result matches the authoring tool. See {@link Mesh#rightHanded}. - * @param {string} [settings.textureRepeat] - texture wrap mode (`"repeat"` / `"repeat-x"` / `"repeat-y"` / `"no-repeat"`) this mesh samples its texture with (per-mesh — it does not modify the shared texture, so other meshes/sprites using the same image are unaffected). Use `"repeat"` when the geometry's UVs fall outside the `[0, 1]` range and rely on the texture tiling (e.g. glTF assets, whose default sampler wrap is REPEAT) — otherwise the texture clamps to its edge texels and looks flat. Ignored for the white-pixel fallback. Note: REPEAT on a non-power-of-two texture requires WebGL 2. - * @param {string} [settings.textureFilter] - texture magnification filter (`"nearest"` for crisp pixel-art upscaling, `"linear"` for smooth) applied to the resolved texture. Omit to keep the renderer's global `antiAlias` default. On the mesh path, linear filtering also samples a generated mip chain with trilinear minification and 4× anisotropy (distant geometry stops shimmering) — `"nearest"` opts out, keeping crisp pixel-art models on hard level-0 sampling. GPU backends only (ignored by the Canvas renderer). - * @param {number} [settings.alphaCutoff=0] - alpha cutout threshold. Fragments whose final alpha is below this value are discarded (hard-edged cutout — foliage, fences, decals — with no blending or sorting). `0` disables the cutout. Set automatically by the glTF loader from a material's `alphaMode: "MASK"`. GPU mesh path only (WebGL and WebGPU; the Canvas renderer ignores it). - * @param {number[]|Float32Array} [settings.emissive] - emissive (self-illumination) color `[r, g, b]` (0..1, may exceed 1 for HDR glow) added on top of the lit/unlit color so the surface glows regardless of scene lights (neon, lava, screens). Omit / all-zero for no emission. Set automatically by the glTF loader (`emissiveFactor`) and OBJ loader (MTL `Ke`). GPU mesh path only (WebGL and WebGPU; the Canvas renderer ignores it). - * @param {boolean} [settings.lit=false] - shade this mesh with the scene's {@link Light3d} lights (the lit mesh pipeline) instead of rendering fullbright. Set automatically by the glTF importer when the scene carries a directional, point or spot light. With `lit` on and no lights present the batcher uploads a white ambient, so the result is indistinguishable from unlit. - * @param {Uint32Array|Color[]|number[]} [settings.vertexColors] - per-vertex colour, one entry per vertex, multiplied into {@link Mesh#tint}. Either packed RGBA8 (`Uint32Array`, the form the batchers read — no conversion) or one {@link Color} per vertex. Omit for plain white. Lets a single mesh carry a gradient — fading a terrain toward the sky with distance, darkening a crease — which a per-object `tint` cannot express. An explicit value wins over the colours a multi-material OBJ bakes from its MTL. - * @param {number[]|Float32Array} [settings.normals] - per-vertex normals for the lit path. An explicit value wins over the ones an OBJ or glTF source supplies; omit it and they are taken from the model, or generated from the geometry when the mesh is `lit`. Generated normals average per vertex where faces share vertices (smooth shading) and equal the face normal where they do not (flat shading) — the geometry decides, not a flag. - * @param {number[]|Float32Array} [settings.specular] - specular color `[r, g, b]` (0..1) for the lit path. Set by the OBJ loader from MTL `Ks`, and derived from glTF metallic/roughness. - * @param {number} [settings.shininess=0] - specular exponent for the lit path (MTL `Ns`). `0` for a fully diffuse surface. - * @param {string|TextureAtlas|HTMLImageElement} [settings.alphaMap] - per-texel opacity map, sampled in addition to the diffuse texture (MTL `map_d`). - * @param {boolean} [settings.castGroundShadow] - give this mesh a blob ground shadow, overriding the application's `castGroundShadow` setting in both directions. Omit to inherit. Needs a GPU backend and a `Camera3d`. - * @param {boolean} [settings.transparent] - draw in the transparent pass (blended, back-to-front, no depth write). Omit and a mesh goes transparent whenever its draw alpha is fractional; `true` for soft-alpha textures; `false` to stay opaque however faded - * @param {boolean} [settings.fog] - set `false` to exempt this mesh from the camera's distance fog ({@link Camera3d#setFog}); omit to fog whenever the camera does - * @param {number} [settings.shadowGroundY] - world Y of the floor the shadow lands on. Omit and the blob sits at the object's own base at full strength; set it and the blob shrinks and fades as the object rises. Render space is Y-down, so the floor is a **greater** Y than the object above it. - * @param {number} [settings.shadowOpacity=0.45] - opacity of the shadow directly beneath the object, before any height fade. + * @param {MeshSettings} settings - Configuration parameters for the Mesh object * @example * // create from OBJ + MTL (texture auto-resolved from material) * let mesh = new me.Mesh(0, 0, { diff --git a/packages/melonjs/src/renderable/sprite3d.js b/packages/melonjs/src/renderable/sprite3d.js index 901efc730..b59aed223 100644 --- a/packages/melonjs/src/renderable/sprite3d.js +++ b/packages/melonjs/src/renderable/sprite3d.js @@ -148,7 +148,7 @@ export default class Sprite3d extends Mesh { * @param {number} x - world x position * @param {number} y - world y position * @param {object} settings - configuration - * @param {HTMLImageElement|Texture2d|string} [settings.image] - the sprite texture (image name, image, or a {@link Texture2d} asset such as a {@link TextureAtlas}). Alias: `settings.texture`. + * @param {HTMLImageElement|HTMLCanvasElement|Texture2d|string} [settings.image] - the sprite texture (image name, image, or a {@link Texture2d} asset such as a {@link TextureAtlas}). Alias: `settings.texture`. * @param {number} [settings.width=settings.framewidth] - quad width in world units (pixels) * @param {number} [settings.height=settings.width] - quad height in world units * @param {number} [settings.framewidth] - width of a single frame within a spritesheet (enables frame animation) @@ -162,6 +162,9 @@ export default class Sprite3d extends Mesh { * @param {boolean} [settings.flipY=false] - mirror the sprite vertically (see {@link Sprite3d#flipY}) * @param {boolean} [settings.lit=false] - shade through the lit mesh batcher (see {@link Mesh}) * @param {number[]|Float32Array} [settings.emissive] - emissive color (see {@link Mesh}) + * @param {boolean} [settings.castGroundShadow] - give this sprite a blob ground shadow, overriding the application's `castGroundShadow` setting in both directions. Omit to inherit. Needs a GPU backend and a {@link Camera3d}. + * @param {boolean} [settings.fog] - set `false` to exempt this sprite from the camera's distance fog ({@link Camera3d#setFog}); omit to fog whenever the camera does. A sun or a moon wants this — everything else at that distance dissolves into the haze, and so would it. + * @param {boolean} [settings.transparent] - draw in the transparent pass (blended, back-to-front, no depth write) instead of the opaque one. Omit and the sprite goes transparent whenever its draw alpha is fractional; `true` for a soft-alpha sprite such as an additive glow; `false` to stay opaque however faded. * @param {number} [settings.alphaCutoff=0.5] - alpha cutout threshold (see {@link Mesh}). The mesh pass is opaque (no alpha blending), so this defaults to `0.5` to discard a sprite's transparent background (clean cutout silhouette, correct depth, no sorting). Set `0` for a fully-opaque quad, or tune the threshold. */ constructor(x, y, settings) { @@ -272,6 +275,7 @@ export default class Sprite3d extends Mesh { // flatten it to an explicit false, silently opting every sprite out // of a scene-wide default castGroundShadow: settings.castGroundShadow, + fog: settings.fog, // raw for the same reason as above: `undefined` means "decide from // the draw's alpha", and coercing it would pin every sprite opaque transparent: settings.transparent, diff --git a/packages/melonjs/src/state/stage.ts b/packages/melonjs/src/state/stage.ts index a986a9b4c..ed3f71cfd 100644 --- a/packages/melonjs/src/state/stage.ts +++ b/packages/melonjs/src/state/stage.ts @@ -294,9 +294,8 @@ export default class Stage { } /** - * update function - * @ignore - * @internal + * Update the stage. Override it to run your own per-frame logic, and call + * `super.update(dt)` so the world and cameras still advance. * @param dt - time since the last update in milliseconds. * @returns true if the stage needs to be redrawn */ @@ -322,8 +321,9 @@ export default class Stage { * Lights are rendered as part of the world tree (they're now first-class * Renderables) and the ambient overlay pass runs inside each Camera's * post-effect FBO bracket via {@link Stage#drawLighting}. - * @ignore - * @internal + * + * Override it to draw under or over the world, calling `super.draw(...)` + * where the world itself belongs in that order. * @param renderer - the renderer object to draw with * @param world - the world object to draw */ From 86f95ce8672c793db92c788948ba2c2dae28cc29 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 13 Sep 2026 13:23:08 +0800 Subject: [PATCH 06/17] Skills: the silent no-ops this week's type errors uncovered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of these is code that reads correctly and does nothing, which is the kind a skill is worth spending words on — nothing warns, and the scene looks plausible enough that the value gets tuned instead of the call. 3D: `Light3d` already documented that it takes options only, and its two ways to fail quietly did not. Extra positional arguments are dropped by JavaScript, so `new Light3d(0, 0, {…})` makes `options` the number `0` and a `type: "ambient"` light is a second directional one on defaults. And `direction`/`position` are `[x, y, z]` arrays read by index, so a `Vector3d` resolves to NaN and the light contributes nothing — while `color` on the same object does take a `Color`. Fixing the first exposes the second, because TypeScript stops checking a literal once the argument count is wrong. Renderables: a settings key the class never reads is ignored in silence — `blendMode` belongs to the renderer and to the renderable as a property, never to a settings literal. And `isRenderable` gates `updateBounds`, not drawing, so setting it false leaves the object on screen with stale bounds; `alpha` is what hides something. Camera: `worldToScreen` was in no skill at all, so pinning a label to a point in a 3D scene had no documented answer and invited a hand-rolled `Matrix3d` projection — which is exactly where the perspective divide and the behind-the-camera case get missed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- packages/melonjs/skills/melonjs-3d/SKILL.md | 20 +++++++++++ .../melonjs-camera-and-drawing/SKILL.md | 24 ++++++++++++++ .../skills/melonjs-renderables/SKILL.md | 33 +++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/packages/melonjs/skills/melonjs-3d/SKILL.md b/packages/melonjs/skills/melonjs-3d/SKILL.md index e339cd68f..8c6b55e6d 100644 --- a/packages/melonjs/skills/melonjs-3d/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d/SKILL.md @@ -83,6 +83,10 @@ class GameStage extends Stage { `camera.setClipPlanes(near, far)`. - **`camera.pos.set(x, y)` is 2-argument and zeroes z.** Use `camera.depth` — the documented z accessor — or assign `pos.x`/`pos.y` individually. +- **`worldToLocal` does not project.** It is a 2D camera's offset subtraction. + To pin a label or marker to a point in the scene use + `camera.worldToScreen(x, y, z)`, which applies the projection and returns + `null` behind the camera. See `melonjs-camera-and-drawing`. ## Depth sorting @@ -476,6 +480,22 @@ world.addChild(new Light3d({ type: "directional", direction: [0.3, 1, 0.2] })); world.addChild(new Light3d({ type: "ambient", intensity: 0.3 })); ``` +Both of that call's traps fail **silently**, and they compound: + +- `new Light3d(0, 0, {…})` — JavaScript drops the extra arguments, so `options` + becomes the number `0` and every setting in your literal is discarded. The + light still appears, on pure defaults: a `type: "ambient"` written this way is + a second DIRECTIONAL light, and the scene looks plausible enough that nobody + checks. +- `direction: new Vector3d(x, y, z)` — `direction` and `position` are `[x, y, z]` + **arrays**, read by index. A `Vector3d` has no `[0]`, so the light's direction + becomes `NaN` and it contributes nothing. (`color` is the odd one out: it does + take a `Color`, a CSS string or an `[r, g, b]` array.) + +Fixing the first exposes the second — TypeScript stops checking an object +literal once the argument count is already wrong — so a scene can go from +"looks fine" to "entirely black" in one apparently-correct edit. + Use **both halves**: with a key light but no ambient, the shadow side of a mesh goes black. With no `Light3d` in the world at all, a `lit: true` mesh falls back to a white ambient and renders fullbright — indistinguishable from unlit, which diff --git a/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md b/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md index 1574f9136..f7b09d447 100644 --- a/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md +++ b/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md @@ -78,6 +78,30 @@ whichever of world/screen matches the region — screen for a `floating` region, world otherwise. Use the screen pair for anything that *moves* the camera, or the drag feeds back on itself. +### In 3D: `Camera3d#worldToScreen` + +`worldToLocal` is a 2D camera's offset subtraction — it knows nothing about a +perspective projection. To pin a label, damage number or marker to a point in a +3D scene, project it: + +```js +const p = camera.worldToScreen(x, y, z); // view + projection, divide included +if (p !== null) { // null = at or BEHIND the camera + label.pos.set(p.x, p.y); +} +``` + +It returns canvas pixels with the origin top-left and **y down**, which is the +engine's own 2D draw space — so the result feeds a `floating` renderable or the +immediate-mode API directly, with no flip. + +**Always check for `null`.** A point at or behind the camera (clip `w <= 0`) +has no honest screen position, and projecting it anyway yields a mirrored +coordinate that puts your label on the wrong side of the screen. Skip it. + +Do not hand-roll this with `Matrix3d` — the perspective divide and the behind- +camera case are exactly what gets it wrong. + ## Secondary cameras Cameras are first-class — a minimap is a second camera with `autoResize = false` diff --git a/packages/melonjs/skills/melonjs-renderables/SKILL.md b/packages/melonjs/skills/melonjs-renderables/SKILL.md index 51bd53c4e..24ffc6922 100644 --- a/packages/melonjs/skills/melonjs-renderables/SKILL.md +++ b/packages/melonjs/skills/melonjs-renderables/SKILL.md @@ -184,6 +184,37 @@ v.set(10, 20, v.z); // keep it `pos` is an `ObservableVector3d`, whose `set(x = 0, y = 0, z = 0)` does the same — so `this.pos.set(x, y)` wipes the object's depth. +## 7. A settings key the class never reads is silently ignored + +A settings object is a plain literal: nothing rejects a key, so a name that +belongs somewhere else does nothing at all and says nothing about it. + +```js +// WRONG — only the RENDERER reads a `blendMode` setting, never a renderable +const glow = new Sprite3d(x, y, { image: "sun", blendMode: "additive" }); + +// right: it is a property +glow.blendMode = "additive"; +``` + +The same shape catches `transparent` on a `GLTFModel` (its parts carry it — +`model.setChildsProperty("transparent", true, true)`) and any `Mesh` setting +handed to a class that forwards only a curated subset. If a visual option +appears to do nothing, check whether the class actually reads it before +tuning the value. + +## 8. `isRenderable` does not hide anything + +It gates `updateBounds`, not drawing. Setting it `false` leaves the object on +screen and merely stops its bounds tracking, which is worse than doing nothing. + +```js +sprite.isRenderable = false; // WRONG — still drawn, bounds now stale +sprite.alpha = 0; // hides it +``` + +Use `alpha` for a blink, or remove the child for a long absence. + ## Update and draw ```js @@ -236,6 +267,8 @@ Hand-rolled equivalents miss the batching and the multi-backend support. | custom draw offset by half the size | centred `anchorPoint` default not zeroed | | spatial query never finds an object | `isKinematic` left `true` — not in the broadphase | | `z` unexpectedly 0 after a `set` | `Vector3d.set(x, y)` defaults `z` to 0 | +| a settings option appears to do nothing | the class never reads that key — see section 7 | +| `isRenderable = false` did not hide the object | it gates bounds, not drawing — use `alpha` | ## Related skills From 87e7308d34f6bacf2ec05c7ae4c3f293cba25709 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 13 Sep 2026 13:23:20 +0800 Subject: [PATCH 07/17] Examples: both 3D lights were running on defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `new Light3d(0, 0, {…})` and `new Light3d(0, 0, 0, {…})` pass the options object where no parameter accepts it — the constructor takes options alone — so JavaScript dropped it and every setting went with it. The key light's direction, colour and intensity were discarded, and a `type: "ambient"` fill was a second DIRECTIONAL light on defaults, which is why both scenes looked lit at all. `direction` is an `[x, y, z]` array read by index, not a `Vector3d`, so fixing only the argument count turns the light's direction into NaN and the scene black. Both had to move together. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- .../src/examples/afterBurner/ExampleAfterBurner.tsx | 7 +++---- .../examples/materialTextures/ExampleMaterialTextures.tsx | 6 +++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx b/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx index 71f585401..cf5a45af3 100644 --- a/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx +++ b/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx @@ -32,7 +32,6 @@ import { Light3d, loader, state, - Vector3d, video, } from "melonjs"; import { createExampleComponent } from "../utils"; @@ -161,15 +160,15 @@ const createGame = async () => { // A key light plus an ambient fill so the shadowed side stays // readable rather than black. app.world.addChild( - new Light3d(0, 0, 0, { + new Light3d({ type: "directional", - direction: new Vector3d(-0.35, -0.7, -0.6), + direction: [-0.35, -0.7, -0.6], color: new Color(255, 244, 226), intensity: 1.2, }), ); app.world.addChild( - new Light3d(0, 0, 0, { + new Light3d({ type: "ambient", color: new Color(128, 146, 178), intensity: 0.6, diff --git a/packages/examples/src/examples/materialTextures/ExampleMaterialTextures.tsx b/packages/examples/src/examples/materialTextures/ExampleMaterialTextures.tsx index daf18f2a6..bd57b5a97 100644 --- a/packages/examples/src/examples/materialTextures/ExampleMaterialTextures.tsx +++ b/packages/examples/src/examples/materialTextures/ExampleMaterialTextures.tsx @@ -125,15 +125,15 @@ function buildScene(app: Application) { // One directional key light plus a dim ambient fill. The key is what the // specular highlight rides: a highlight needs a direction to reflect, so // an ambient-only scene would show none however glossy the material. - const key = new Light3d(0, 0, { + const key = new Light3d({ type: "directional", - direction: new Vector3d(-0.45, -0.75, 0.5), + direction: [-0.45, -0.75, 0.5], color: "#fff6e8", intensity: 1.15, }); key.name = "key"; world.addChild(key); - world.addChild(new Light3d(0, 0, { type: "ambient", color: "#3b4870" })); + world.addChild(new Light3d({ type: "ambient", color: "#3b4870" })); // a floor for the shadows to land on const F = 900; From 228037b03ef5cf94dd0b7bf8c657d83e86f44bf8 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 13 Sep 2026 16:10:46 +0800 Subject: [PATCH 08/17] Changelog: trim the unreleased section to the house style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entries had drifted to 594 characters on average against 18.3.0's ~250, and read like incident reports — discovery order, why a bug went unnoticed, what was tried first. That is a record of how the work went, not what changed for someone using the engine. State the mechanism and the symptom, then stop. 15 entries, now 288 average. Released sections are untouched: each has a published GitHub release carrying a copy, so editing them would only make the two diverge. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- packages/melonjs/CHANGELOG.md | 37 ++++++++++++++++------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index cb1094d4a..22b951b25 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,26 +3,23 @@ ## [20.5.0] (melonJS 2) - _unreleased_ ### Added -- Mesh: a `ShaderEffect` can be hosted on a mesh — `addPostEffect` shades it on both backends. The body runs as a colour hook, so the mesh keeps its own placement, alpha cutout, lighting and fog and `apply()` transforms the result; `setUniform` and `setTime` reach it. `screen_uv`, `noise_uv`, `screen_texture`, a body's own samplers, WGSL `vColor` and `InstancedMesh` are unsupported and warn once ([#1658](https://github.com/melonjs/melonJS/issues/1658)) -- `GLTFModel` can be placed and moved: `pos`, `depth` and the transform helpers (`rotate`, `scale`) now drive the whole rig, as they do for any other renderable. Every root node used to hang from the identity, so a loaded character posed at the world origin and stayed there — the only way to frame it was to move the camera. The placement composes with the animated pose rather than replacing it, so a walk cycle plays wherever the model stands. It reaches the parts by being baked into each one's world position, so the container no longer translates the renderer for its children or folds its own `currentTransform` in: doing both would apply every move and every turn twice, drawing a model at x = -400 as though it were at -800 while its reported position stayed correct. A model left at the origin with no transform poses exactly as before -- Text: `fillStyle` takes a `Gradient` as well as a colour — the same object `Renderer#setColor` already accepts, built with `createLinearGradient`/`addColorStop` as on the canvas API. Coordinates are the label's own bake, and the ramp colours the fill only, since the stroke is a separate pass. A multi-line label restarts the ramp on every line, so it reads like one `Text` per line without the caller having to author the gradient over the block height; `gradientPerLine: false` spans a single ramp across the whole block instead, as a plain canvas does. Widening what the setting accepts does not widen what the property holds: `fillStyle` still reads back as the pooled `Color`, still gates the fill through its `alpha`, and a label given a plain colour takes the same path it always did - -- `save`: registered keys are reachable from TypeScript without a cast. The namespace was typed `Record`, and that index signature swallowed its own members — under `strict`, `save.add()` was `unknown` and **could not be called at all** (the documented `me.save.add({ score: 0, lives: 3 })` did not compile), while every key read back as `unknown`. It now has a real interface, and `add()` returns the namespace typed with the keys just registered, so `const store = save.add({ hiscore: 0 })` gives `store.hiscore` as a `number`. Chained calls accumulate. `add()` previously returned nothing, and dynamic access through `save.anything` is unchanged - -### Fixed -- Renderable: opacity now cascades. `alpha` is multiplied with the alpha already on the renderer rather than replacing it, so fading a `Container` fades everything inside it and the values compose — a child at 0.5 inside a parent at 0.5 draws at 0.25. It was assigned outright, so a child overwrote its parent's value and a half-faded container still drew a fully opaque subtree; `Container.setChildsProperty("alpha", …)` was the only way to fade a tree, and that assigns to the children rather than composing. The save/restore the cascade relies on was already in place — `RenderState` stacks the tint and every renderable is wrapped by `preDraw`/`postDraw` — so nothing leaks between siblings. **A nested renderable that was visibly opaque under a faded ancestor will now fade with it.** Fading a subtree is `container.alpha` — the renderer composes it as it walks the tree; alpha should not be assigned down a hierarchy by hand -- GLTFModel: a loaded model reported no bounds at all. A `Container` carries no dimensions of its own, so `getBounds()` handed back an empty box — and the broadphase files every item by it, so a model with a `Body` was sorted into a node unrelated to where it stood and silently collided with nothing. Its extent now comes from the glTF bounding box, measured once at load -- Mesh: `resize()`, `recalc()` and the `width`/`height` setters threw on every mesh. A `Mesh` repurposes two slots it inherits from `Polygon` — `normals` and `indices` — with typed arrays, and `Polygon#recalc` walked them expecting its own 2D collision geometry. The polygon's edge normals are now `edgeNormals`, and `recalc` leaves `indices` alone unless it owns it -- Text: glyph tops were shorn off on **Safari**. The cache texture was invalidated *before* the canvas was resized and repainted, so the renderer refreshed it against the dimensions the canvas had on the way in. WebGL re-specifies texture storage on every upload and healed it silently; a WebGPU texture is immutable-sized, so the copy kept the old height and the label sampled past its own content. The refresh now happens last, after the resize and the repaint -- Text: a glyph is no longer clipped along the top of its own bake. The offscreen canvas was sized from the nominal line box — `fontSize × lineHeight` — which says nothing about where the ink lands, so a display face whose glyphs rise above the em box, or any stroked label (half of `lineWidth` sits outside the outline, and no metric reports it), lost the overshoot. The bake is now padded by the ink's real extent, read from `actualBoundingBoxAscent`/`Descent`, and the blit shifts back by the same amount — so glyphs keep their exact screen position and the reported bounds are unchanged. A font that cannot report ink extents renders as before -- Renderer: `stroke()` and `fill()` threw `Invalid geometry` on a `Box3d` — the one body shape with no case in the dispatcher, which the debug panel's hitbox overlay hits on any body carrying one. It now draws the XY footprint `Box3d` already exposes through its own `getBounds()` -- Mesh: a mesh with `normalize: false` and an explicit `scale` now sizes itself from its geometry when no `width`/`height` is given, the way a `Sprite` sizes itself from its frame. It reported a zero-size box at its position while drawing at full size, misleading frustum culling, pointer picking and the broadphase alike. `meshScale` is unchanged -- Mesh: a `ShaderEffect` attached to a mesh drew the geometry unplaced and without the camera on WebGL, and was refused on WebGPU with a message naming the wrong reason. An effect is realized against the quad vertex contract, which declares neither `uModelMatrix` nor `uViewMatrix` ([#1658](https://github.com/melonjs/melonJS/issues/1658)) - -- Text: moving a label now moves it. `metrics.x/y` — the box the bake is blitted at — is derived from `pos`, but nothing else in `measureText` is, so it refreshed only when the *string* changed. Reposition a label without re-setting its text and the bake offset had grown by the whole distance travelled while the canvas stayed the size it was: the glyphs slid off their own canvas and were clipped, while the blit still went to where the label used to be. The origin is refreshed from `pos` on every draw now, which reads two numbers and measures no glyphs. Sub-pixel placement is unchanged — the canvas still lands on a whole pixel, and the fraction the floor drops still goes into the bake where the rasterizer can antialias it rather than resampling the texture - -- `Sprite3d`: `fog` reaches the mesh. A sprite builds its own settings for `Mesh` rather than forwarding yours, and `fog` was not among the keys it copied — so `fog: false`, documented on `Mesh` and the only way to keep a sun or a moon out of the haze, silently did nothing. `transparent`, `castGroundShadow` and `fog` are now documented on `Sprite3d` itself -- Typings: options the documentation promises are no longer rejected by TypeScript. `NoiseTexture2d` forwards every `Noise` setting (`type`, `seed`, `octaves`, …) and said so in prose while declaring none of them, so its own documented example did not compile; `InstancedMesh` forwards every `Mesh` setting the same way; `setCurrentAnimation(name, { loop: true })` was typed against the *normalized* options, which make `loop` and `speed` mandatory; `Container#setChildsProperty` took `object`, refusing the booleans and strings most properties hold; `image`/`texture` refused an `HTMLCanvasElement` the renderer accepts; and `Stage#update`/`#draw` were marked internal and stripped, so a custom stage could not call `super.update(dt)` in TypeScript at all. The settings shapes are now named types — `MeshSettings`, `InstancedMeshSettings`, `NoiseTexture2dSettings`, `AnimationOptionsInput` — exported and documented, so a forwarding subclass states the intersection instead of restating a list that drifts +- Mesh: a `ShaderEffect` can be hosted on a mesh — `addPostEffect` shades it on both backends as a colour hook, so the mesh keeps its own placement, alpha cutout, lighting and fog. `screen_uv`, `noise_uv`, `screen_texture`, a body's own samplers, WGSL `vColor` and `InstancedMesh` are unsupported and warn once ([#1658](https://github.com/melonjs/melonJS/issues/1658)) +- `GLTFModel` can be placed and moved: `pos`, `depth`, `rotate` and `scale` drive the whole rig as on any other renderable, composing with the animated pose rather than replacing it. Every root node used to hang from the identity, so a loaded model posed at the world origin and stayed there +- Text: `fillStyle` takes a `Gradient` as well as a colour, built with `createLinearGradient`/`addColorStop`. Coordinates are the label's own bake and the ramp colours the fill only, since the stroke is a separate pass. A multi-line label restarts the ramp on every line; `gradientPerLine: false` spans one ramp across the block instead +- `save`: registered keys are reachable from TypeScript without a cast. The namespace was typed `Record`, and that index signature swallowed its own members, so under `strict` `save.add()` was `unknown` and could not be called at all. `add()` now returns the namespace typed with the keys just registered, and chained calls accumulate + +### Fixed +- Renderable: opacity now cascades — `alpha` multiplies with the alpha already on the renderer instead of replacing it, so a child at 0.5 inside a parent at 0.5 draws at 0.25. **A nested renderable that was visibly opaque under a faded ancestor will now fade with it** +- GLTFModel: a loaded model reported no bounds at all, so a model with a `Body` was filed in the broadphase away from where it stood and collided with nothing. Its extent now comes from the glTF bounding box, measured once at load +- Mesh: `resize()`, `recalc()` and the `width`/`height` setters threw on every mesh — `Polygon#recalc` walked the `normals` and `indices` slots a `Mesh` repurposes for typed arrays. The polygon's edge normals are now `edgeNormals` +- Text: glyph tops were shorn off on **Safari**. The cache texture was invalidated before the canvas was resized and repainted, and a WebGPU texture is immutable-sized, so the copy kept the old height and the label sampled past its own content. The refresh now happens last +- Text: a glyph is no longer clipped along the top of its own bake. The offscreen canvas was sized from the nominal line box, which says nothing about where the ink lands, so a display face or any stroked label lost the overshoot. The bake is padded by the ink's real extent and the blit shifts back by the same amount, so glyphs keep their exact screen position +- Text: moving a label now moves it. The blit origin is derived from `pos` but was refreshed only when the text changed, so repositioning a label without re-setting it slid the glyphs off their own canvas and drew at the old spot. Sub-pixel placement is unchanged +- Renderer: `stroke()` and `fill()` threw `Invalid geometry` on a `Box3d`, which the debug panel's hitbox overlay hits on any body carrying one. It now draws the XY footprint +- Mesh: a mesh with `normalize: false` and an explicit `scale` now sizes itself from its geometry when no `width`/`height` is given. It reported a zero-size box while drawing at full size, misleading frustum culling, pointer picking and the broadphase alike +- Mesh: a `ShaderEffect` attached to a mesh drew the geometry unplaced and without the camera on WebGL, and was refused on WebGPU with a message naming the wrong reason ([#1658](https://github.com/melonjs/melonJS/issues/1658)) +- `Sprite3d`: `fog: false` reaches the mesh — a sprite builds its own settings for `Mesh` and did not copy it, so a sun could not be kept out of the haze. `transparent` and `castGroundShadow` are documented on `Sprite3d` too +- Typings: documented options no longer fail to compile — the `Noise` settings `NoiseTexture2d` forwards, the `Mesh` settings `InstancedMesh` forwards, `setCurrentAnimation(name, { loop: true })`, non-object values for `Container#setChildsProperty`, an `HTMLCanvasElement` as `image`/`texture`, and `super.update(dt)` in a custom `Stage`. Settings shapes are exported as `MeshSettings`, `InstancedMeshSettings`, `NoiseTexture2dSettings` and `AnimationOptionsInput` ## [20.4.0] (melonJS 2) - _2026-09-09_ From 7ebbc350532d8bf5be7b8a95024ea87a4905b2dc Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 13 Sep 2026 16:12:07 +0800 Subject: [PATCH 09/17] Examples: both 3D lights were running on defaults, lit from below MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `new Light3d(0, 0, {…})` and `new Light3d(0, 0, 0, {…})` pass the options object where no parameter accepts it — the constructor takes options alone — so JavaScript dropped it and every setting went with it. The key light's direction, colour and intensity were discarded, and a `type: "ambient"` fill was a second DIRECTIONAL light on defaults, which is why both scenes looked lit at all. `direction` is an `[x, y, z]` array read by index, not a `Vector3d`, so fixing only the argument count turns the direction into NaN and the scene black. With the options finally reaching the engine, the direction was wrong too: this is a Y-down space and `direction` is the way the light TRAVELS, so a sun overhead is +Y. Both scenes had a negative Y and lit their subjects from underneath — never visible before, because the value had never once been used. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- .../examples/src/examples/afterBurner/ExampleAfterBurner.tsx | 2 +- .../src/examples/materialTextures/ExampleMaterialTextures.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx b/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx index cf5a45af3..d0ba7de6e 100644 --- a/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx +++ b/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx @@ -162,7 +162,7 @@ const createGame = async () => { app.world.addChild( new Light3d({ type: "directional", - direction: [-0.35, -0.7, -0.6], + direction: [-0.35, 0.7, -0.6], color: new Color(255, 244, 226), intensity: 1.2, }), diff --git a/packages/examples/src/examples/materialTextures/ExampleMaterialTextures.tsx b/packages/examples/src/examples/materialTextures/ExampleMaterialTextures.tsx index bd57b5a97..15e179b9e 100644 --- a/packages/examples/src/examples/materialTextures/ExampleMaterialTextures.tsx +++ b/packages/examples/src/examples/materialTextures/ExampleMaterialTextures.tsx @@ -127,7 +127,7 @@ function buildScene(app: Application) { // an ambient-only scene would show none however glossy the material. const key = new Light3d({ type: "directional", - direction: [-0.45, -0.75, 0.5], + direction: [-0.45, 0.75, 0.5], color: "#fff6e8", intensity: 1.15, }); From 11da8006e690ebb1b4bffafe7d723baca1dabd4b Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 13 Sep 2026 16:17:19 +0800 Subject: [PATCH 10/17] Plinko: the score fly is a Text, not a Container around one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ScoreFly` wrapped its label in a `Container` and moved the container, because moving a `Text` did not move it — the wrapper kept the label pinned at its local origin, where its stale bake offset stayed valid. The engine fix makes the wrapper unnecessary, so the label is the renderable: one class, no child, and the tween drives it directly. `preDraw` pivots a renderable's transform around its own `pos`, so the pop-scale still scales in place. Its `pos.set(x, y)` also wiped the depth every frame — the 2-argument form defaults z to 0, and `depth` IS `pos.z`, so the fly sorted at 0 rather than the 200 its constructor asked for, despite a comment saying otherwise. Assigning x and y leaves it alone. `Text` reads `settings.bold` and `settings.italic` and documented neither, so passing them was a type error in eight places across the examples. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- .../plinko-planck/entities/scoreFly.ts | 61 ++++++++----------- packages/melonjs/src/renderable/text/text.js | 2 + 2 files changed, 29 insertions(+), 34 deletions(-) diff --git a/packages/examples/src/examples/plinko-planck/entities/scoreFly.ts b/packages/examples/src/examples/plinko-planck/entities/scoreFly.ts index 8d60c2ec2..218fcfc8b 100644 --- a/packages/examples/src/examples/plinko-planck/entities/scoreFly.ts +++ b/packages/examples/src/examples/plinko-planck/entities/scoreFly.ts @@ -14,16 +14,15 @@ * with the spark burst spawned alongside it (see `sparkBurst.ts`), * each landing reads as a deliberate event with clear cause and effect. * - * Implemented as a Container (not a custom Renderable) because the - * engine's draw walk relies on container.draw translating to its own - * pos before iterating children — without that translation, a Text - * child renders at its baked-in metrics offset relative to the parent, - * which for our purposes is the world origin. With Container as the - * Tween target, animating `pos.x` / `pos.y` moves the text correctly. + * It is a `Text` itself. It used to be a `Container` wrapping one, because + * moving a `Text` did not move it — the blit origin was refreshed only when + * the string changed, so a label that travelled slid off its own canvas. The + * wrapper held the label still at its local origin and moved the container + * instead. That is fixed in the engine, so the label is the renderable now. */ import type { Container as ContainerType } from "melonjs"; -import { Container, Text, Tween } from "melonjs"; +import { Text, Tween } from "melonjs"; /** Total flight duration (ms). Tuned so the trip feels deliberate but not slow. */ const FLY_MS = 800; @@ -33,7 +32,7 @@ const FLY_MS = 800; */ const ARC_HEIGHT = 80; -export class ScoreFly extends Container { +export class ScoreFly extends Text { /** Tween-driven 0 → 1 lerp factor. */ private t = 0; private readonly startX: number; @@ -41,7 +40,6 @@ export class ScoreFly extends Container { private readonly targetX: number; private readonly targetY: number; private readonly value: number; - private readonly label: Text; private readonly onLand: (value: number) => void; constructor( @@ -54,8 +52,15 @@ export class ScoreFly extends Container { fontSize: number, onLand: (value: number) => void, ) { - super(startX, startY, 1, 1); - this.anchorPoint.set(0, 0); + super(startX, startY, { + font: "Courier New", + size: fontSize, + fillStyle: color, + textAlign: "center", + textBaseline: "middle", + bold: true, + text: `+${value}`, + }); this.alwaysUpdate = true; // Float so the fly renders in screen space (matches the HUD's // coordinate frame — the play area uses no camera scroll, so @@ -71,20 +76,6 @@ export class ScoreFly extends Container { this.targetY = targetY; this.value = value; this.onLand = onLand; - - // Text is centred at this container's local (0, 0). Container.draw - // translates to `this.pos` before drawing children, so the Text - // appears at (this.pos.x, this.pos.y) in world space. - this.label = new Text(0, 0, { - font: "Courier New", - size: fontSize, - fillStyle: color, - textAlign: "center", - textBaseline: "middle", - bold: true, - text: `+${value}`, - }); - this.addChild(this.label); } override onActivateEvent(): void { @@ -123,27 +114,29 @@ export class ScoreFly extends Container { oneMinusT * oneMinusT * this.startY + 2 * oneMinusT * t * midY + t * t * this.targetY; - // Use `pos.set(x, y)` (2-arg form defaults z=0). The engine's - // `World._sortReverseZ` reads `pos.z` and crashes on undefined; - // stacking order is driven by `this.depth` (set in the ctor), - // not by stuffing a depth into `pos.z`. - this.pos.set(x, y); + // Assign x and y rather than `pos.set(x, y)`: the 2-argument form + // defaults z to 0, and `depth` IS `pos.z` — so setting the position + // that way wiped the depth given in the constructor on the very first + // frame, and the fly sorted at 0 instead of on top. + this.pos.x = x; + this.pos.y = y; // Scale: pop up briefly (1 → 1.3 in the first 20% of flight), // then settle back to 1.0 by 60%. Sells "ejected from the slot". // `currentTransform.scale` MULTIPLIES the current matrix — reset - // to identity each frame before applying the new scale. + // to identity each frame before applying the new scale. `preDraw` + // pivots the transform around `pos`, so this scales in place. const popPhase = Math.min(1, t / 0.2); const settlePhase = Math.max(0, Math.min(1, (t - 0.2) / 0.4)); const scale = 1 + popPhase * 0.3 - settlePhase * 0.3; - this.label.currentTransform.identity(); - this.label.currentTransform.scale(scale, scale); + this.currentTransform.identity(); + this.currentTransform.scale(scale, scale); // Alpha: full opacity until 75%, fade out in the final 25% so // the fly dissolves into the counter rather than abruptly // vanishing. const alpha = t < 0.75 ? 1 : 1 - (t - 0.75) / 0.25; - this.label.setOpacity(alpha); + this.setOpacity(alpha); super.update(dt); return true; } diff --git a/packages/melonjs/src/renderable/text/text.js b/packages/melonjs/src/renderable/text/text.js index ec17425de..aa5e0cc8d 100644 --- a/packages/melonjs/src/renderable/text/text.js +++ b/packages/melonjs/src/renderable/text/text.js @@ -69,6 +69,8 @@ export default class Text 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 pixels of a line before it wraps + * @param {boolean} [settings.bold=false] - render the face bold, as {@link Text#bold} does + * @param {boolean} [settings.italic=false] - render the face italic, as {@link Text#italic} does * @param {(string|string[])} [settings.text=""] - a string, or an array of strings * @example * // a styled, word-wrapped, multi-line label using a generic system font From 877001592d772e33ffbe003da07d08d0ec15fb4a Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 13 Sep 2026 16:20:31 +0800 Subject: [PATCH 11/17] Light3d: say which way `direction` points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `direction` is the direction the light TRAVELS, and render space is Y-down, so a sun overhead has a POSITIVE y. Neither the JSDoc nor the skill said so, and two examples shipped with a negative one — lighting their subjects from underneath, invisible for as long as the options were being discarded entirely. `position` gets the same note: a lamp above the floor has a smaller y than the floor. The skill gains the sign alongside the two silent failures already documented there, plus a symptom row, since all three hide behind each other — a wrong argument count stops TypeScript checking the literal, so the array and then the sign only surface one fix at a time. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- packages/melonjs/skills/melonjs-3d/SKILL.md | 20 +++++++++++++++++--- packages/melonjs/src/lighting/light3d.ts | 17 ++++++++++++++--- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/melonjs/skills/melonjs-3d/SKILL.md b/packages/melonjs/skills/melonjs-3d/SKILL.md index 8c6b55e6d..5e8afc16b 100644 --- a/packages/melonjs/skills/melonjs-3d/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d/SKILL.md @@ -491,10 +491,23 @@ Both of that call's traps fail **silently**, and they compound: **arrays**, read by index. A `Vector3d` has no `[0]`, so the light's direction becomes `NaN` and it contributes nothing. (`color` is the odd one out: it does take a `Color`, a CSS string or an `[r, g, b]` array.) +- **`direction` is where the light GOES, and this is a Y-down space** — so a sun + overhead travels *downward* and its Y is **positive**. Get the sign wrong and + the scene is lit from underneath: faces that should be in shade are bright, + the ground glows and the sky-facing surfaces go dark. -Fixing the first exposes the second — TypeScript stops checking an object -literal once the argument count is already wrong — so a scene can go from -"looks fine" to "entirely black" in one apparently-correct edit. + ```js + direction: [-0.35, 0.8, 0.45] // sun overhead, late afternoon + direction: [-0.35, -0.8, 0.45] // lit from below — almost never what you want + ``` + + `position` follows the same convention: a lamp above the floor has a + **smaller** y than the floor. + +They hide behind each other: TypeScript stops checking an object literal once +the argument count is already wrong, so fixing the call reveals the `Vector3d`, +and fixing that finally lets the sign show. A scene can go from "looks fine" to +"entirely black" to "lit from below" across three apparently-correct edits. Use **both halves**: with a key light but no ambient, the shadow side of a mesh goes black. With no `Light3d` in the world at all, a `lit: true` mesh falls back @@ -619,6 +632,7 @@ To branch rather than fail, read `app.renderer.supportsDepthBuffer` after | symptom | cause | |---|---| +| scene lit from underneath | `direction` Y sign — this is Y-down, so a sun overhead is **+Y** | | a `lit` mesh renders fullbright | it had no normals — supply them, or let the engine generate them | | a gradient across one mesh is impossible | `tint` is per object — use `vertexColors` / `setVertexColor` | | a mesh stays solid as you fade it out | meshes render opaque; only `alpha` 0 (hidden) and 1 differ | diff --git a/packages/melonjs/src/lighting/light3d.ts b/packages/melonjs/src/lighting/light3d.ts index 5793fe683..96e484bad 100644 --- a/packages/melonjs/src/lighting/light3d.ts +++ b/packages/melonjs/src/lighting/light3d.ts @@ -16,11 +16,22 @@ export interface Light3dOptions { */ type?: "directional" | "ambient" | "point" | "spot"; /** - * world-space direction the light travels along (directional lights, - * and the cone axis of spot lights). + * World-space direction the light TRAVELS ALONG — not the direction it + * comes from (directional lights, and the cone axis of spot lights). + * + * Render space is **Y-down**, so a sun overhead shining onto the scene + * travels *downward* and its Y is **positive**: `[-0.35, 0.8, 0.45]` is a + * late-afternoon sun. A negative Y lights everything from underneath, + * which reads instantly as wrong and is the usual mistake here. + * + * An `[x, y, z]` array, read by index — a `Vector3d` has no `[0]`, so + * passing one yields `NaN` and the light contributes nothing at all. */ direction?: [number, number, number]; - /** world-space position (point and spot lights). */ + /** + * World-space position (point and spot lights), as an `[x, y, z]` array. + * Y-down again: a lamp above the floor has a **smaller** y than the floor. + */ position?: [number, number, number]; /** * light color — a {@link Color}, a CSS color string, or an `[r, g, b]` From 8040adde5c5061bf88445b22eeab31f638feb50a Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 13 Sep 2026 16:24:18 +0800 Subject: [PATCH 12/17] Examples: drag-and-drop used the window timer, not the engine's `window.setTimeout` does not know the game is paused, so the drop colour reset its own second down while everything else was stopped. `timer.setTimeout` with `pauseable` set follows the engine clock. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- .../examples/dragAndDrop/ExampleDragAndDrop.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/examples/src/examples/dragAndDrop/ExampleDragAndDrop.tsx b/packages/examples/src/examples/dragAndDrop/ExampleDragAndDrop.tsx index 67f5e85db..5735a5cbd 100644 --- a/packages/examples/src/examples/dragAndDrop/ExampleDragAndDrop.tsx +++ b/packages/examples/src/examples/dragAndDrop/ExampleDragAndDrop.tsx @@ -10,6 +10,7 @@ import { DropTarget, game, Text, + timer, video, } from "melonjs"; import { createExampleComponent } from "../utils"; @@ -106,10 +107,16 @@ class DropTarget1 extends DropTarget { // indicate a succesful drop this.color = "green"; - // set the color back to red after a second - window.setTimeout(() => { - this.color = "red"; - }, 1000); + // set the color back to red after a second. `timer.setTimeout`, not the + // window one: the third argument makes it respect the engine's pause + // state, so a paused game does not quietly finish the countdown + timer.setTimeout( + () => { + this.color = "red"; + }, + 1000, + true, + ); } } From 7d320df335af26ef595be334d8b0d9f14178896e Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 13 Sep 2026 16:31:42 +0800 Subject: [PATCH 13/17] Audio: `delay` schedules a tone or noise burst ahead of time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sequencing a multi-part sound needed a `setTimeout` around the second call, because neither generator could be told to start later. A timer fires on the main thread, so a busy frame slips the note and the sequence loses time audibly — and the fault reads as sound design rather than scheduling. Both generators already hang every ramp, start and stop off a single `t0`, so this is one addition there. Optional and defaulting to 0, so a call that does not mention it schedules exactly where it always did; negative values clamp rather than scheduling in the past. The two examples that were sequencing by timer — plinko's fanfare and afterBurner's double-tap explosion — now use it. The specs assert what the option is for: that the gap between two starts is the delay, measured on the context's own clock, and that omitting it still starts at `currentTime`. Removing the offset turns the first red with `expected +0 to be close to 0.25`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- .../examples/src/examples/afterBurner/sfx.ts | 41 +++++------ .../src/examples/plinko-planck/audio.ts | 55 ++++++++------- packages/melonjs/CHANGELOG.md | 2 + .../melonjs/skills/melonjs-audio/SKILL.md | 17 +++++ packages/melonjs/src/audio/procedural.ts | 19 +++-- packages/melonjs/src/audio/types.ts | 22 ++++++ packages/melonjs/tests/audio.spec.js | 70 +++++++++++++++++++ 7 files changed, 175 insertions(+), 51 deletions(-) diff --git a/packages/examples/src/examples/afterBurner/sfx.ts b/packages/examples/src/examples/afterBurner/sfx.ts index 030066176..51f76faa8 100644 --- a/packages/examples/src/examples/afterBurner/sfx.ts +++ b/packages/examples/src/examples/afterBurner/sfx.ts @@ -119,26 +119,27 @@ export function playEnemyHit(pan = 0): void { filter: { type: "lowpass", frequency: 1600, Q: 0.5 }, filterSweep: 0.18, }); - // 5. secondary blast — real explosions double-tap - setTimeout(() => { - audio.noise({ - type: "white", - duration: 0.05, - gain: 0.4, - attack: 0.001, - pan, - filter: { type: "highpass", frequency: 1200, Q: 0.8 }, - }); - audio.tone({ - freq: 70, - duration: 0.32, - wave: "sine", - gain: 0.5, - attack: 0.002, - pitchSlide: 0.3, - pan, - }); - }, 80); + // 5. secondary blast — real explosions double-tap. `delay` puts it on + // the audio clock, so the 80ms gap holds through a frame spike. + audio.noise({ + type: "white", + duration: 0.05, + gain: 0.4, + attack: 0.001, + pan, + filter: { type: "highpass", frequency: 1200, Q: 0.8 }, + delay: 0.08, + }); + audio.tone({ + freq: 70, + duration: 0.32, + wave: "sine", + gain: 0.5, + attack: 0.002, + pitchSlide: 0.3, + pan, + delay: 0.08, + }); } /** diff --git a/packages/examples/src/examples/plinko-planck/audio.ts b/packages/examples/src/examples/plinko-planck/audio.ts index 0a9622da0..c58f7890a 100644 --- a/packages/examples/src/examples/plinko-planck/audio.ts +++ b/packages/examples/src/examples/plinko-planck/audio.ts @@ -216,34 +216,35 @@ export const playWin = (score: number, pan = 0): void => { pitchSlide: 1.03, }); - // 4) Brass note 2 — fifth + octave climb at t=110ms. - setTimeout(() => { - audio.tone({ - freq: [fifth, octave], - duration: 0.18, - gain: 0.32, - pan, - wave: "triangle", - }); - }, 110); + // 4) Brass note 2 — fifth + octave climb at t=110ms. `delay` schedules + // on the audio clock, so the fanfare keeps time through a frame spike + // that would visibly slip a `setTimeout`. + audio.tone({ + freq: [fifth, octave], + duration: 0.18, + gain: 0.32, + pan, + wave: "triangle", + delay: 0.11, + }); // 5) Resolution chord at t=230ms — sustained octave + fifth + // 2-octave triangle chord. This is the headline "DAAAAH". - setTimeout(() => { - audio.tone({ - freq: [octave, octave * 1.5, octave * 2], - duration: 0.75, - gain: 0.38, - pan, - wave: "triangle", - }); - // 6) Bell sparkle — sine high-octave stack on top of the - // chord swell for celebratory shimmer. - audio.tone({ - freq: [octave * 2, octave * 3, octave * 4], - duration: 0.55, - gain: 0.16, - pan, - }); - }, 230); + audio.tone({ + freq: [octave, octave * 1.5, octave * 2], + duration: 0.75, + gain: 0.38, + pan, + wave: "triangle", + delay: 0.23, + }); + // 6) Bell sparkle — sine high-octave stack on top of the + // chord swell for celebratory shimmer. + audio.tone({ + freq: [octave * 2, octave * 3, octave * 4], + duration: 0.55, + gain: 0.16, + pan, + delay: 0.23, + }); }; diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 22b951b25..703f103fa 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -8,6 +8,8 @@ - Text: `fillStyle` takes a `Gradient` as well as a colour, built with `createLinearGradient`/`addColorStop`. Coordinates are the label's own bake and the ramp colours the fill only, since the stroke is a separate pass. A multi-line label restarts the ramp on every line; `gradientPerLine: false` spans one ramp across the block instead - `save`: registered keys are reachable from TypeScript without a cast. The namespace was typed `Record`, and that index signature swallowed its own members, so under `strict` `save.add()` was `unknown` and could not be called at all. `add()` now returns the namespace typed with the keys just registered, and chained calls accumulate +- `audio.tone()` and `audio.noise()` take a `delay` in seconds, scheduled on the audio clock, so a multi-part sound — a stinger's second note, an explosion's double-tap — sequences without a `setTimeout`. A timer fires on the main thread and a busy frame slips the note; this is scheduled up front and is sample-accurate. Omitting it is unchanged + ### Fixed - Renderable: opacity now cascades — `alpha` multiplies with the alpha already on the renderer instead of replacing it, so a child at 0.5 inside a parent at 0.5 draws at 0.25. **A nested renderable that was visibly opaque under a faded ancestor will now fade with it** - GLTFModel: a loaded model reported no bounds at all, so a model with a `Body` was filed in the broadphase away from where it stood and collided with nothing. Its extent now comes from the glTF bounding box, measured once at load diff --git a/packages/melonjs/skills/melonjs-audio/SKILL.md b/packages/melonjs/skills/melonjs-audio/SKILL.md index f7ffcd484..2abca19fb 100644 --- a/packages/melonjs/skills/melonjs-audio/SKILL.md +++ b/packages/melonjs/skills/melonjs-audio/SKILL.md @@ -141,6 +141,23 @@ audio.noise({ duration: 0.5, filter: { type: "lowpass", frequency: 800 } }); `"white"`), `attack`, `pan`, `filter` and `filterSweep`. `gain` defaults to `0.1` on both. +### Sequencing: `delay`, not `setTimeout` + +Both take `delay` — seconds before the sound starts, scheduled on the **audio +clock**. That is what builds a multi-part sound: a stinger's second note, an +explosion's double-tap. + +```js +audio.tone({ freq: 392, duration: 0.12 }); +audio.tone({ freq: 587, duration: 0.18, delay: 0.11 }); +audio.tone({ freq: [784, 1176], duration: 0.75, delay: 0.23 }); +``` + +Do not reach for `setTimeout` here. A timer fires on the main thread, so a busy +frame slips the note late and the sequence audibly loses time — the fault is +easy to blame on the sound design rather than the scheduling. `delay` is +sample-accurate because the whole envelope is scheduled up front. + Both are **silent no-ops** when there is no WebAudio context — they bail on `getAudioContext() === null` rather than throwing. Neither needs the clip to be loaded; neither goes through the file-playback path at all. diff --git a/packages/melonjs/src/audio/procedural.ts b/packages/melonjs/src/audio/procedural.ts index 55c1ea256..9df778190 100644 --- a/packages/melonjs/src/audio/procedural.ts +++ b/packages/melonjs/src/audio/procedural.ts @@ -120,7 +120,7 @@ function _connectToOutput( * wants to show a "no audio" badge or fall back to a different * feedback channel. * @param opts - the {@link ToneOptions} (frequency, duration, - * envelope, pan, slide). See the interface for per-field defaults. + * envelope, pan, slide, delay). See the interface for per-field defaults. * @example * // simple UI click * me.audio.tone({ freq: 1200, duration: 0.08, pitchSlide: 0.5 }); @@ -128,6 +128,11 @@ function _connectToOutput( * me.audio.tone({ freq: [880, 1320], duration: 0.4, gain: 0.18, pan: 0.5 }); * // descending "thud" — square wave with a wide pitch drop * me.audio.tone({ freq: 200, duration: 0.15, wave: "square", pitchSlide: 0.25 }); + * // a three-part fanfare, sequenced on the AUDIO clock — `delay` keeps the + * // notes in time whatever the frame rate is doing, which `setTimeout` cannot + * me.audio.tone({ freq: 392, duration: 0.12 }); + * me.audio.tone({ freq: 587, duration: 0.18, delay: 0.11 }); + * me.audio.tone({ freq: [784, 1176], duration: 0.75, delay: 0.23 }); * @category Audio */ export function tone(opts: ToneOptions): void { @@ -142,6 +147,7 @@ export function tone(opts: ToneOptions): void { attack = 0.005, pan = 0, pitchSlide = 1, + delay = 0, } = opts; const freqs = Array.isArray(freq) ? freq : [freq]; @@ -153,7 +159,9 @@ export function tone(opts: ToneOptions): void { _resumeIfSuspended(ctx); const dur = Math.max(0.001, duration); - const t0 = ctx.currentTime; + // Everything below hangs off `t0`, so a delay is one addition here + // rather than a timer around the whole call. + const t0 = ctx.currentTime + Math.max(0, delay); const t1 = t0 + dur; const env = _buildGainEnvelope(ctx, t0, t1, attack, dur, gain); const panner = _connectToOutput(ctx, env, pan, t0); @@ -256,7 +264,7 @@ function fillNoiseBuffer( * explicitly disabled) this is a silent no-op: {@link getAudioContext} * returns `null` and nothing is scheduled. * @param opts - the {@link NoiseOptions} (duration, spectral colour, - * envelope, pan, optional filter + sweep). See the interface for + * envelope, pan, delay, optional filter + sweep). See the interface for * per-field defaults. * @example * // Explosion: brown rumble closing into a thud @@ -308,12 +316,15 @@ export function noise(opts: NoiseOptions): void { pan = 0, filter, filterSweep = 1, + delay = 0, } = opts; _resumeIfSuspended(ctx); const dur = Math.max(0.001, duration); - const t0 = ctx.currentTime; + // Everything below hangs off `t0`, so a delay is one addition here + // rather than a timer around the whole call. + const t0 = ctx.currentTime + Math.max(0, delay); const t1 = t0 + dur; const env = _buildGainEnvelope(ctx, t0, t1, attack, dur, gain); diff --git a/packages/melonjs/src/audio/types.ts b/packages/melonjs/src/audio/types.ts index bd6e2f32b..3ccbdb77b 100644 --- a/packages/melonjs/src/audio/types.ts +++ b/packages/melonjs/src/audio/types.ts @@ -213,6 +213,17 @@ export interface NoiseOptions { * Has no effect when `filter` is unset. */ filterSweep?: number; + /** + * Seconds to wait before the sound starts, scheduled on the audio + * clock rather than a timer. `0` (the default) plays immediately. + * + * This is what sequences a multi-part sound — a stinger's second + * note, an explosion's double-tap — and it is sample-accurate, which + * `setTimeout` is not: a timer fires on the main thread, so a busy + * frame slips the note audibly. Nothing is allocated until the sound + * actually starts. + */ + delay?: number; } /** @@ -250,6 +261,17 @@ export interface ToneOptions { * value < 1) or rising stings (value > 1). */ pitchSlide?: number; + /** + * Seconds to wait before the sound starts, scheduled on the audio + * clock rather than a timer. `0` (the default) plays immediately. + * + * This is what sequences a multi-part sound — a stinger's second + * note, an explosion's double-tap — and it is sample-accurate, which + * `setTimeout` is not: a timer fires on the main thread, so a busy + * frame slips the note audibly. Nothing is allocated until the sound + * actually starts. + */ + delay?: number; } /** diff --git a/packages/melonjs/tests/audio.spec.js b/packages/melonjs/tests/audio.spec.js index 74c705a9b..e2f3dedf9 100644 --- a/packages/melonjs/tests/audio.spec.js +++ b/packages/melonjs/tests/audio.spec.js @@ -197,6 +197,76 @@ describe("audio", () => { }).not.toThrow(); }); + it("delay schedules the start on the audio clock, not a timer", () => { + const ctx = audio.getAudioContext(); + if (ctx === null) { + // documented contract with no WebAudio: a silent no-op + expect(() => { + return audio.tone({ freq: 440, duration: 0.05, delay: 0.25 }); + }).not.toThrow(); + return; + } + + // capture what time each oscillator is actually told to start + const starts = []; + const create = ctx.createOscillator.bind(ctx); + ctx.createOscillator = () => { + const osc = create(); + const start = osc.start.bind(osc); + osc.start = (when) => { + starts.push(when); + return start(when); + }; + return osc; + }; + + try { + const before = ctx.currentTime; + audio.tone({ freq: 440, duration: 0.05 }); + audio.tone({ freq: 440, duration: 0.05, delay: 0.25 }); + expect(starts).toHaveLength(2); + // BACKWARD COMPATIBILITY: omitting `delay` still starts now. + // The option is additive — a call that does not mention it + // schedules exactly where it always did. + expect(starts[0] - before).toBeGreaterThanOrEqual(0); + expect(starts[0] - before).toBeLessThan(0.05); + // and the gap is the delay, measured on the context's own + // clock — the point of the option over `setTimeout` + expect(starts[1] - starts[0]).toBeCloseTo(0.25, 2); + } finally { + Reflect.deleteProperty(ctx, "createOscillator"); + } + }); + + it("a negative delay does not schedule in the past", () => { + const ctx = audio.getAudioContext(); + if (ctx === null) { + expect(() => { + return audio.noise({ duration: 0.05, delay: -5 }); + }).not.toThrow(); + return; + } + const starts = []; + const create = ctx.createBufferSource.bind(ctx); + ctx.createBufferSource = () => { + const src = create(); + const start = src.start.bind(src); + src.start = (when) => { + starts.push(when); + return start(when); + }; + return src; + }; + try { + const before = ctx.currentTime; + audio.noise({ duration: 0.05, delay: -5 }); + expect(starts).toHaveLength(1); + expect(starts[0]).toBeGreaterThanOrEqual(before); + } finally { + Reflect.deleteProperty(ctx, "createBufferSource"); + } + }); + it("tone clamps pan to [-1, 1]", () => { // Out-of-range pan should be clamped internally, no throw. expect(() => { From d7fb45fe1a97d283ea7a9c9cddf3316982e6ffb8 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 13 Sep 2026 16:34:28 +0800 Subject: [PATCH 14/17] Examples: drop the Camera3d (perspective) demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three sprites stacked along z, proving that per-sprite depth reaches the vertex stream and that the perspective matrix scales by it — a check written alongside the original Camera3d PRs rather than something to show anyone. The 3D tier has real examples now. Nothing else referenced it, and its monster sprite is borrowed from `shaderEffects/assets`, which four other examples still use. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- .../src/examples/camera3d/ExampleCamera3d.tsx | 226 ------------------ packages/examples/src/main.tsx | 16 +- 2 files changed, 8 insertions(+), 234 deletions(-) delete mode 100644 packages/examples/src/examples/camera3d/ExampleCamera3d.tsx diff --git a/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx b/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx deleted file mode 100644 index a3b1dffc7..000000000 --- a/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx +++ /dev/null @@ -1,226 +0,0 @@ -/** - * melonJS — Camera3d (perspective) minimal example. - * - * Three monster sprites stacked along the camera's forward axis at - * z = 200 / 400 / 600. Under perspective, the front one renders - * largest, the back one smallest — proving: - * - per-sprite depth flows from `sprite.depth` to the GPU vertex - * stream (PR A) - * - the Camera3d's perspective matrix scales sprites by their z - * (PR B) - * - painter-algorithm z-sorting puts the front sprite on top of - * the ones behind it (visible occlusion order) - * - * On-screen controls rotate the camera (yaw / pitch) and zoom in/out. - * Drag the canvas to orbit. Demonstrates the simplest opt-in path — - * the Application-level `cameraClass: Camera3d` setting. - * - * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. - * See `packages/examples/LICENSE.md` for full license + asset credits. - */ -import { DebugPanelPlugin } from "@melonjs/debug-plugin"; -import { - Application, - type Camera3d, - Camera3d as Camera3dClass, - input, - loader, - type Pointer, - plugin, - Sprite, - state, - video, -} from "melonjs"; -import monsterImg from "../shaderEffects/assets/monster.png"; -import { createExampleComponent } from "../utils"; - -const createGame = async () => { - // Stash teardown work assembled inside the loader callback so the - // outer `createGame` can return a single cleanup function from the - // async preload completion. - let pointerCleanup: (() => void) | null = null; - let domCleanup: (() => void) | null = null; - // NOTE: an `unmounted` guard around the preload callback would in - // principle fix the "user navigates away before preload finishes" - // race that Copilot flagged on review. It can't be added cleanly - // today — `examples/utils.tsx` runs `currentTeardown()` on every - // React useEffect cleanup (including StrictMode's dev double-mount - // cycle), but the same-example remount branch only reattaches the - // canvas without re-invoking `createGameFn`. An `unmounted` flag - // flipped in teardown therefore stays `true` across the StrictMode - // remount, the preload callback bails for the rest of the session, - // and the example never renders. Picks up cleanly once the - // utils.tsx remount path is fixed (separate review thread). - - // opt in to Camera3d at the Application level — every stage in this - // app gets a Camera3d as its default camera (the loader screen pins - // to Camera2d via its own constructor regardless). - const app = new Application(1024, 768, { - parent: "screen", - renderer: video.AUTO, - scale: "auto", - cameraClass: Camera3dClass, - }); - await app.init(); - - app.world.backgroundColor.parseCSS("#0a0a14"); - plugin.register(DebugPanelPlugin, "debugPanel"); - - loader.preload([{ name: "monster", type: "image", src: monsterImg }], () => { - // loader.preload internally transitions to state.LOADING (the - // DefaultLoadingScreen). Transition back to the default game - // stage so its Camera3d becomes the active viewport. - state.change(state.DEFAULT, true); - - // three monsters along the camera's forward axis at increasing - // depth. Same x, same y — only z differs. Perspective scales - // each one inversely to z. - const depths = [200, 400, 600]; - for (const z of depths) { - const sprite = new Sprite(0, 0, { image: "monster" }); - sprite.scale(0.5); - app.world.addChild(sprite); - // set depth AFTER addChild — Container.autoDepth (default - // true) would otherwise overwrite our intended z - sprite.depth = z; - } - - // the app's default camera is now a Camera3d (via cameraClass). - const camera = app.viewport as Camera3d; - - // orbit state: yaw / pitch / distance. Driven by drag + buttons. - let yaw = 0; - let pitch = 0; - let distance = 700; - - const updateCameraPos = () => { - // orbit around the middle sprite (z = 400). When yaw/pitch - // are 0, the camera sits at z = 400 - distance (behind the - // middle sprite) and looks at it. - const target = 400; - camera.pos.set( - Math.sin(yaw) * Math.cos(pitch) * -distance, - Math.sin(pitch) * distance, - target - Math.cos(yaw) * Math.cos(pitch) * distance, - ); - camera.lookAt(0, 0, target); - }; - updateCameraPos(); - - // drag-to-orbit. Pointer events are registered against the - // camera region; we release them on teardown so a same-tab - // navigation back to the example index doesn't leave listeners - // attached to a no-longer-mounted camera. - let dragging = false; - let lastX = 0; - let lastY = 0; - input.registerPointerEvent("pointerdown", camera, (ev: Pointer) => { - dragging = true; - lastX = ev.gameX; - lastY = ev.gameY; - }); - input.registerPointerEvent("pointerup", camera, () => { - dragging = false; - }); - input.registerPointerEvent("pointermove", camera, (ev: Pointer) => { - if (!dragging) { - return; - } - yaw += (ev.gameX - lastX) * 0.005; - pitch = Math.max( - -Math.PI / 2 + 0.1, - Math.min(Math.PI / 2 - 0.1, pitch - (ev.gameY - lastY) * 0.005), - ); - lastX = ev.gameX; - lastY = ev.gameY; - updateCameraPos(); - }); - pointerCleanup = () => { - input.releasePointerEvent("pointerdown", camera); - input.releasePointerEvent("pointerup", camera); - input.releasePointerEvent("pointermove", camera); - }; - - // on-screen HTML control panel — yaw / pitch / zoom / reset. - // HTML buttons live above the canvas; `#screen > *` already - // has `pointer-events: auto` (PR A's CSS fix) so they're - // clickable. - const panel = document.createElement("div"); - panel.style.cssText = - "position:absolute;top:60px;left:16px;display:grid;" + - "grid-template-columns:repeat(3,40px);grid-template-rows:repeat(4,40px);" + - "gap:4px;z-index:1000;font-family:sans-serif;"; - const mkButton = (label: string, gridArea: string, handler: () => void) => { - const b = document.createElement("button"); - b.textContent = label; - b.style.cssText = - "background:#1a1a1a;color:#e0e0e0;border:1px solid #444;" + - "border-radius:4px;cursor:pointer;font-size:18px;" + - `grid-area:${gridArea};`; - b.addEventListener("click", handler); - panel.appendChild(b); - }; - const YAW_STEP = 0.15; - const PITCH_STEP = 0.1; - const ZOOM_STEP = 60; - mkButton("▲", "1 / 2 / 2 / 3", () => { - pitch = Math.min(Math.PI / 2 - 0.1, pitch + PITCH_STEP); - updateCameraPos(); - }); - mkButton("◀", "2 / 1 / 3 / 2", () => { - yaw -= YAW_STEP; - updateCameraPos(); - }); - mkButton("●", "2 / 2 / 3 / 3", () => { - yaw = 0; - pitch = 0; - distance = 700; - updateCameraPos(); - }); - mkButton("▶", "2 / 3 / 3 / 4", () => { - yaw += YAW_STEP; - updateCameraPos(); - }); - mkButton("▼", "3 / 2 / 4 / 3", () => { - pitch = Math.max(-Math.PI / 2 + 0.1, pitch - PITCH_STEP); - updateCameraPos(); - }); - mkButton("−", "4 / 1 / 5 / 2", () => { - distance = Math.min(1500, distance + ZOOM_STEP); - updateCameraPos(); - }); - mkButton("+", "4 / 3 / 5 / 4", () => { - distance = Math.max(150, distance - ZOOM_STEP); - updateCameraPos(); - }); - - const hint = document.createElement("div"); - hint.textContent = "Drag or use controls"; - hint.style.cssText = - "position:absolute;top:240px;left:16px;color:#888;" + - "font-family:sans-serif;font-size:12px;z-index:1000;"; - - const parent = app.renderer.getCanvas().parentElement; - if (parent) { - parent.style.position = "relative"; - parent.appendChild(panel); - parent.appendChild(hint); - } - domCleanup = () => { - panel.remove(); - hint.remove(); - }; - }); - - // Returned to `createExampleComponent` so a same-tab navigation - // back to the example index doesn't leave the HTML control panel / - // hint overlay sitting on top of the index page (the canvas parent - // `#screen` persists across mounts), and doesn't leave stale - // pointer listeners attached to a dead camera reference. - return () => { - if (pointerCleanup) pointerCleanup(); - if (domCleanup) domCleanup(); - }; -}; - -export const ExampleCamera3d = createExampleComponent(createGame); diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx index 8aa1c2307..f1755987c 100644 --- a/packages/examples/src/main.tsx +++ b/packages/examples/src/main.tsx @@ -48,9 +48,9 @@ const ExampleAfterBurner = lazy(() => default: m.ExampleAfterBurner, })), ); -const ExampleCamera3d = lazy(() => - import("./examples/camera3d/ExampleCamera3d").then((m) => ({ - default: m.ExampleCamera3d, +const ExampleJungleRabbit = lazy(() => + import("./examples/jungleRabbit/ExampleJungleRabbit").then((m) => ({ + default: m.ExampleJungleRabbit, })), ); const ExampleClipping = lazy(() => @@ -318,12 +318,12 @@ const examples: { "Behind-the-plane arcade shooter on Camera3d + 3D Mesh models — arrows / WASD to fly, space to shoot.", }, { - component: , - label: "Camera3d (perspective)", - path: "camera-3d", - sourceDir: "camera3d", + component: , + label: "Jungle Rabbit", + path: "jungle-rabbit", + sourceDir: "jungleRabbit", description: - "Perspective camera orbiting three sprite billboards spaced along the z axis. Drag to orbit; closer sprites render larger.", + "Endless river run on Camera3d — authored glTF scenery through InstancedMesh, a rigged paddling boat, and a procedural valley. Arrows to steer, space to jump.", }, { component: , From 51f3091776d649306903d82fa9fac0bea71c3448 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 13 Sep 2026 16:44:52 +0800 Subject: [PATCH 15/17] Sprite3d: document the shadow settings it already forwards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `shadowGroundY` and `shadowOpacity` reach `Mesh` — the constructor copies both — but neither was in the `@param` list, so passing the floor height a scene knows and the engine cannot guess was a type error. The billboard example does exactly that. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- packages/melonjs/src/renderable/sprite3d.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/melonjs/src/renderable/sprite3d.js b/packages/melonjs/src/renderable/sprite3d.js index b59aed223..b5849fdc8 100644 --- a/packages/melonjs/src/renderable/sprite3d.js +++ b/packages/melonjs/src/renderable/sprite3d.js @@ -162,6 +162,8 @@ export default class Sprite3d extends Mesh { * @param {boolean} [settings.flipY=false] - mirror the sprite vertically (see {@link Sprite3d#flipY}) * @param {boolean} [settings.lit=false] - shade through the lit mesh batcher (see {@link Mesh}) * @param {number[]|Float32Array} [settings.emissive] - emissive color (see {@link Mesh}) + * @param {number} [settings.shadowGroundY] - world Y of the floor the blob shadow lands on. Omit and it falls back to the sprite's own base — which for a billboard moves with the camera, so a scene that knows where its floor is should say so. + * @param {number} [settings.shadowOpacity=0.45] - opacity of the shadow directly beneath the sprite, before any height fade * @param {boolean} [settings.castGroundShadow] - give this sprite a blob ground shadow, overriding the application's `castGroundShadow` setting in both directions. Omit to inherit. Needs a GPU backend and a {@link Camera3d}. * @param {boolean} [settings.fog] - set `false` to exempt this sprite from the camera's distance fog ({@link Camera3d#setFog}); omit to fog whenever the camera does. A sun or a moon wants this — everything else at that distance dissolves into the haze, and so would it. * @param {boolean} [settings.transparent] - draw in the transparent pass (blended, back-to-front, no depth write) instead of the opaque one. Omit and the sprite goes transparent whenever its draw alpha is fractional; `true` for a soft-alpha sprite such as an additive glow; `false` to stay opaque however faded. From 963c65705bd922e80fcb44408dc6fcf80a663c7c Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 13 Sep 2026 16:47:07 +0800 Subject: [PATCH 16/17] Changelog: drop a stray blank line inside Added Left by the audio entry's insertion. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- packages/melonjs/CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 703f103fa..f4e942c71 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -7,7 +7,6 @@ - `GLTFModel` can be placed and moved: `pos`, `depth`, `rotate` and `scale` drive the whole rig as on any other renderable, composing with the animated pose rather than replacing it. Every root node used to hang from the identity, so a loaded model posed at the world origin and stayed there - Text: `fillStyle` takes a `Gradient` as well as a colour, built with `createLinearGradient`/`addColorStop`. Coordinates are the label's own bake and the ramp colours the fill only, since the stroke is a separate pass. A multi-line label restarts the ramp on every line; `gradientPerLine: false` spans one ramp across the block instead - `save`: registered keys are reachable from TypeScript without a cast. The namespace was typed `Record`, and that index signature swallowed its own members, so under `strict` `save.add()` was `unknown` and could not be called at all. `add()` now returns the namespace typed with the keys just registered, and chained calls accumulate - - `audio.tone()` and `audio.noise()` take a `delay` in seconds, scheduled on the audio clock, so a multi-part sound — a stinger's second note, an explosion's double-tap — sequences without a `setTimeout`. A timer fires on the main thread and a busy frame slips the note; this is scheduled up front and is sample-accurate. Omitting it is unchanged ### Fixed From b44ec4d867d290ef00c0ae5549e23b9966f438cc Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 13 Sep 2026 16:53:19 +0800 Subject: [PATCH 17/17] Examples: the Jungle Rabbit gallery entry is not ready to ship MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It points at `./examples/jungleRabbit/`, which is deliberately held back until 20.5 publishes, so the entry alone breaks the examples build — the module cannot resolve. It was swept into the Camera3d removal by staging main.tsx whole. The entry goes back with the example it names. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t --- packages/examples/src/main.tsx | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx index f1755987c..cd7b12cc7 100644 --- a/packages/examples/src/main.tsx +++ b/packages/examples/src/main.tsx @@ -48,11 +48,6 @@ const ExampleAfterBurner = lazy(() => default: m.ExampleAfterBurner, })), ); -const ExampleJungleRabbit = lazy(() => - import("./examples/jungleRabbit/ExampleJungleRabbit").then((m) => ({ - default: m.ExampleJungleRabbit, - })), -); const ExampleClipping = lazy(() => import("./examples/clipping/ExampleClipping").then((m) => ({ default: m.ExampleClipping, @@ -317,14 +312,6 @@ const examples: { description: "Behind-the-plane arcade shooter on Camera3d + 3D Mesh models — arrows / WASD to fly, space to shoot.", }, - { - component: , - label: "Jungle Rabbit", - path: "jungle-rabbit", - sourceDir: "jungleRabbit", - description: - "Endless river run on Camera3d — authored glTF scenery through InstancedMesh, a rigged paddling boat, and a procedural valley. Arrows to steer, space to jump.", - }, { component: , label: "Clipping",