From 56d70a1f1649a5f7e913edcbc9327f2c59f208bd Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 29 Aug 2026 08:29:42 +0800 Subject: [PATCH 1/5] docs: write down the changelog convention, and apply it to 20.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 20.2.0 section had drifted a long way from how this changelog is written. Measured against the rest of the file, its entries averaged 890 characters where 18.3.0 runs about 250, and it opened straight into a bullet list with no `**Highlights:**` paragraph. Worse than the length, five of its eleven `Fixed` entries did not belong there at all: - two duplicated their own `Performance` entries, describing the same change twice - three documented bugs that never reached a release — one said outright that no released version was affected, another was latent until a same-cycle change exposed it, and the third was about the test suite A user reading those learns nothing; they record how the work went, not what changed for them. CONTRIBUTING.md now states the convention: `Subsystem: what changed` in one or two sentences, the mechanism and the symptom without the discovery narrative, nothing that only ever existed inside an unreleased cycle, `Changed` reserved for user-facing API changes, and measurements quoted no more precisely than they were taken. 20.2.0 is rewritten to match: 11 entries averaging 393 characters, a Highlights opener, and issue references in the bare `(#1234)` form the older sections use. Released sections are left alone. They are published — each has a GitHub release carrying a copy — so editing them here would only make the two diverge. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- CONTRIBUTING.md | 35 +++++++++++++++++++++++++++++++++++ packages/melonjs/CHANGELOG.md | 31 +++++++++++++------------------ 2 files changed, 48 insertions(+), 18 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 43f669dbdb..91ea4134d9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -116,6 +116,41 @@ melonJS uses [ESLint](https://eslint.org) and [Biome](https://biomejs.dev) to en - Follow the existing patterns in the codebase - If you fix a bug, consider adding a test to prevent it from recurring +## Changelog entries + +User-facing changes go in `packages/melonjs/CHANGELOG.md`, under the current +`_unreleased_` heading. Entries read `Subsystem: what changed`, in one or two +sentences: + +```markdown +- Renderer: `setLineDash()` and `getLineDash()` — set dash patterns for stroke + operations, matching the Canvas 2D API. Works on both Canvas and WebGL. +- Path2D: fix `quadraticCurveTo()` using a reference to `startPoint` instead of + capturing coordinates — `lineTo()` mutates it on each call, deforming the + curve as it was tessellated. +``` + +Say what changed and what it means for someone using the engine. A bug entry +needs the mechanism and the symptom; it does not need how it was found, why it +went unnoticed, or what else was tried. + +**Only write down what affects a released version.** A bug introduced and fixed +within the same unreleased cycle never reached anyone, so it does not belong in +the changelog — nor do changes to the test suite or other internals. + +Some other rules worth knowing: + +- The sections are `Added`, `Performance`, `Fixed`, `Changed` and + `Deprecated`. `Changed` is for **user-facing API changes only** — a game + looking different because of something under `Added` is not a change. +- Anything that alters existing behaviour must say so plainly, with what to do + about it. +- A feature release opens with a short `**Highlights:**` paragraph. +- Quote measurements only as precisely as they were measured, and say what + hardware produced them. +- Credit external contributors with a bare handle at the end: `(thanks @user)`. +- Examples are not part of the engine and do not appear here. + ## Submitting changes 1. Fork the repository and create a new branch from `master` diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 6ffc684643..f9a93da62d 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -2,29 +2,24 @@ ## [20.2.0] (melonJS 2) - _unreleased_ +**Highlights:** all thirteen blend modes the engine names now work on all three renderers, closing the last gap where the Canvas fallback was the most capable backend for blending. Particles gain a reference space, so an emitter can leave a trail instead of dragging its cloud along. Two measured wins: the particle update loop drops ~37%, and WebGPU quad submission ~42%, taking that backend from slower than WebGL 2 to marginally faster. + ### Added -- **`ParticleEmitter` can measure its particles from somewhere other than itself**, through the new `referenceSpace` setting: `"local"` (the default, unchanged), `"world"`, or any `Container`. A particle stores a position, and this decides what that position is relative to. Until now it was always the emitter, so a moving emitter dragged its entire cloud along with it — correct for a flame or an aura, and impossible to opt out of for smoke, exhaust, sparks or footstep dust, where the effect should be emitted and then abandoned. With `"world"` the position names a place in the level instead, so only newly emitted particles appear at the emitter's new location and the rest stay put; passing a `Container` measures from that, for a frame of reference that is neither (snow drifting inside a moving carriage). `"world"` resolves to the emitter's parent container rather than the root, so a level that moves carries its own trails with it. Changing it at runtime — by assigning the property or through `reset()` — re-bases the particles already alive, so the cloud does not jump. An emitter using a non-local space is treated as always visible while it has live particles, since a trail would otherwise disappear the instant the emitter that made it scrolled off-screen (the particles themselves are still culled individually) (thanks @Vareniel) -- **`Renderable.getWorldTransform(out)`** — the matrix form of the existing `getAbsolutePosition()`, which sums positions up the ancestor chain and so cannot represent the rotation, scale or flip accumulated along the way. Returns the transform mapping a renderable's local space into world space, writing into a caller-supplied `Matrix3d` and storing nothing on the renderable. Note it answers a slightly different question than `getAbsolutePosition()`: it is the frame a renderable's content is drawn *in*, which for a `Container` includes its own position (it offsets its children) and for a leaf does not, since a leaf places itself from `pos` inside its own `draw()` -- **The six remaining CSS blend modes now work on both GPU backends** ([#1318](https://github.com/melonjs/melonJS/issues/1318)): `overlay`, `hard-light`, `color-dodge`, `color-burn`, `soft-light` and `difference`. All thirteen modes the engine names are now supported by all three renderers, so the Canvas fallback is no longer the most capable backend for blending. These six cannot be expressed as `src * sfactor + dst * dfactor` — each needs a per-pixel branch, a division or a `sqrt` on the *destination* — and neither WebGL 2 nor WebGPU can read the destination in a fragment shader, so each draw captures the destination, renders to an offscreen target and composites through a shader carrying both a GLSL and a WGSL body. Nothing changes in how you use them: set `sprite.blendMode = "overlay"` or call `renderer.setBlendMode("overlay")` as before, on any renderable — sprites, text, image layers, particles, Tiled layers — or on a direct shape fill. `setBlendMode` now reports these six as applied rather than falling back, so the capability probe pattern (comparing the return value against the request) reports them supported. Two things to know before reaching for them: each blended draw costs **one destination capture and one composite**, which is what per-draw blending against the live framebuffer requires without framebuffer-fetch hardware — right for accents (a glow, a light overlay, a coloured wash), expensive for hundreds of blended objects and unsuitable for something like a whole tilemap layer in `overlay`. And **3D meshes** (`drawMesh`) do not support them, falling back to `"normal"` with a one-time console warning rather than silently, because the offscreen's separate depth buffer would break subsequent depth testing +- Particles: `referenceSpace` setting — measure a particle's position from the emitter (`"local"`, the default), from the container the emitter sits in (`"world"`), or from any `Container`. `"world"` leaves particles where they were emitted, so a moving emitter draws a trail rather than carrying its cloud. Changing it at runtime re-bases live particles so nothing jumps (thanks @Vareniel) +- Renderable: `getWorldTransform(out)` — the matrix form of `getAbsolutePosition()`, which sums positions up the ancestor chain and so cannot express the rotation, scale or flip accumulated along it. Writes into a caller-supplied `Matrix3d` and stores nothing. Returns the frame a renderable's content is drawn *in*: for a `Container` that includes its own position, for a leaf it does not +- Renderer: the six remaining CSS blend modes — `overlay`, `hard-light`, `color-dodge`, `color-burn`, `soft-light` and `difference` — now work on WebGL 2 and WebGPU (#1318), where they previously fell back to `"normal"` and rendered unblended. Each blended draw captures the destination and composites through a shader, so it costs one capture and one composite per draw: right for accents, expensive for hundreds of blended objects. 3D meshes (`drawMesh`) fall back to `"normal"` with a one-time warning ### Performance -- **particle bounds are computed lazily** ([#1607](https://github.com/melonjs/melonJS/pull/1607)): a particle invalidates its bounds when it moves and recomputes once on read, instead of twice per frame from a transform that had not been rebuilt yet. About **37%** off the particle update loop and **33%** more particles inside a 16.7 ms budget, holding on CPU-throttled hardware as well as fast. -- **WebGPU quad submission** ([#1608](https://github.com/melonjs/melonJS/pull/1608)): the per-quad segment-slot lookup no longer builds and hashes a string cache key for every quad, only when the texture, filter or wrap actually changes. About **42%** off WebGPU draw submission at 20 000 quads (10.15 to 5.93 ms), taking the backend from noticeably slower than WebGL 2 to marginally faster. Applies to all 2D drawing on WebGPU, not just particles. +- Particles: bounds are computed lazily — invalidated when a particle moves, recomputed once on read, rather than twice per frame from a transform that had not been rebuilt yet. About **37%** off the particle update loop and **33%** more particles inside a 16.7 ms budget, on throttled hardware as well as fast (#1607) +- WebGPU: the per-quad segment-slot lookup no longer builds and hashes a string cache key for every quad, only when the texture, filter or wrap changes. About **42%** off draw submission at 20 000 quads, 10.15 to **5.93 ms**, taking the backend from slower than WebGL 2 to marginally faster. Applies to all 2D drawing, not just particles (#1608) ### Fixed -- **Particles recomputed their bounds twice per frame, from a stale transform.** `Renderable` refreshes bounds from a callback fired on every `pos` assignment, which is right for a scene object and wrong for a particle: `Particle.update` writes `pos.x` and `pos.y` separately, so the callback fired twice per particle per frame — and both runs happened before `currentTransform` was rebuilt, deriving bounds from the previous frame's matrix and then discarding the result. `accurateBounds: true`, which exists to buy an accurate hitbox, added a third pass on top. A particle now invalidates its bounds on write and recomputes once on read, so a particle nothing looks at costs nothing. Roughly a third off the particle update loop, measured on both fast and CPU-throttled hardware. `ParticleEmitterSettings.accurateBounds` is deprecated as a result: it existed to trade hitbox accuracy for speed, and there is no longer a trade to make. It is still accepted and now has no effect -- **An abandoned WebGPU frame could leave a batcher holding views into destroyed textures.** `abandonFrame()` drops the command buffer unsubmitted and then frees every texture retired during that frame, on the reasoning that the draws referencing them died with the buffer. But the batchers were not reset, so their segment entries kept `GPUTextureView`s into textures that had just been destroyed, and the next frame's bind group could be composed over freed resources. Reached whenever a frame is abandoned after a texture is replaced or unloaded mid-frame, a stage switch freeing the previous scene's assets being the ordinary case. Every registered batcher is now reset before the retired textures are freed, which also drops the dead frame's queued vertices rather than replaying them into the next one -- **WebGPU submitted quads about 64% slower than WebGL 2.** `WebGPUQuadBatcher.segmentSlotFor` runs once per quad to resolve which multi-texture slot the quad batches against, and it built a template-literal cache key every time, then hashed that string into the slot table. Consecutive quads in a batch almost always share a texture (a sprite sheet, a font atlas, an emitter's particle image), so the answer was identical for long runs while the cost was not: at 20,000 particles that was 20,000 string allocations and 20,000 string-keyed lookups per frame. The resolved slot is now remembered and short-circuited, keyed on the same three components the string is built from (view, filter, wrap) so a filter or wrap change on the same texture still re-resolves, and placed after the residency check so a texture whose source changed underneath, a video frame or an animated canvas, is still revalidated and re-uploaded. Profiling put the entire gap here, not in the GPU API: at 20,000 quads the WebGPU calls themselves (`writeBuffer`, `submit`, `createCommandEncoder`) accounted for 0.07ms of a 9.8ms draw, with no bind group or buffer created per frame. Measured 10.15ms to 5.71ms at 20,000 quads, taking WebGPU from noticeably slower than WebGL 2 to marginally faster. Affects all 2D drawing on the backend, not only particles - -- **Particles drifted past the position they simulated.** `Particle` bakes its position into `currentTransform`, but left `autoTransform` at its default `true`, so `preDraw` conjugated the matrix as `T(p)·C·T(-p)`. Conjugating a matrix that already contains its own pivot is not the no-op it is for a pure translation: the net translation came out as `t + (I - s·R)·p`, putting the drawn centre at `(2 - s)·p`. Since `minEndScale` defaults to 0, `s` fades 1 to 0 over a particle's life, so a particle ended up drawn at roughly twice the displacement it had actually simulated. This was invisible for two decades because `p` is a particle's offset from its own emitter, usually a few pixels; it became untenable with `referenceSpace`, where `p` can be a position in the level and a motionless particle visibly flies across the screen as it fades. `autoTransform` is now off and the transform is applied directly. **This changes how existing effects look**: particles reach roughly half as far by the end of their life, matching the speed and lifetime they were configured with. Effects tuned against the old behaviour will need their `speed` or `maxLife` raised to compensate. Particle bounds now also land on the drawn position rather than lagging it, which makes edge-of-viewport culling and debug hitboxes correct - -- **Pointer events missed every non-floating renderable once the world was offset** ([#1605](https://github.com/melonjs/melonJS/pull/1605)). `Camera2d.localToWorld` subtracts `world.pos`, so a pointer's `gameWorldX/Y` are level-local, while a non-floating renderable's bounds are absolute and include that offset. With the world at the origin the two spaces coincide and nothing is wrong — move it, as a game does to centre a level, and hit detection stopped firing entirely for those regions. Not a coordinate drift: the handler was never called. Floating regions are indexed in level-local space and keep the original path, so a screen-pinned HUD is unaffected either way (thanks @Vareniel) -- **`ParticleEmitter.blendMode` did nothing.** An emitter draws no pixels of its own — each particle is a renderable carrying its own blend mode, copied from `settings.blendMode` when it is born — so assigning `emitter.blendMode`, which is what every other renderable takes and the obvious thing to write, reached nothing at all. Particles kept rendering `"normal"` and it read as particles not supporting blend modes. The emitter now fans a changed mode out on its next update: to `settings.blendMode` so particles emitted afterwards inherit it, and to the particles already alive so the switch is visible immediately rather than fading in over a particle lifetime. Detected with one string compare per emitter per frame rather than an accessor on `Renderable`, which every renderable in the scene would have paid for on every `preDraw` -- **`darken` and `lighten` were wrong for any translucent source.** Fixed-function `MIN`/`MAX` compute `min(src, dst)` and nothing else, so there was nowhere to put the `(1 - srcAlpha) * dst` term source-over contributes after the blend — the backdrop's share simply vanished. At 60% opacity `darken` came out 84/255 off the W3C result, and a white `lighten` glow over a light backdrop rendered *completely invisible* rather than brightening it. Both now composite through the same shader path as the other advanced modes and are exact at any alpha. They cost a capture and a composite per draw where they were previously free, which is the price of being correct; `multiply`, `screen` and `exclusion` stay on the fixed-function path, where measurement confirms they are already exact (the "approximate for a translucent source" comments they carried were wrong) -- **Test harness: a renderer that threw during construction reported as a skip, not a failure.** `getWebGLRenderer` caught every error and treated it as "this machine has no WebGL", so engine breakage turned the whole WebGL suite green-by-skipping. `webgl_available.spec.js` was the backstop, but it only runs when the full suite does — anyone running a subset lost the signal entirely, which is how the program-cache bug above stayed invisible to 47 spec files. The helper now classifies: a genuinely missing GL stack still skips, anything else fails loudly and carries the original error. Not shipped code, but it is why two real bugs in this release were found by looking at a screenshot rather than by the suite -- **Writing a shader uniform corrupted any batch that was still pending.** `GLShader.setUniform` binds its program to write the value (and linking a program leaves it bound), but `GLShader` is constructed with a bare `gl` and cannot reach the renderer's program cache — so the cache went on naming the batcher's program while GL had the effect's. Every batcher checks that cache before re-issuing `useProgram`, skipped the rebind as redundant, and drew the queued geometry through the wrong program: sprites turned solid black or vanished, silently, because the attribute layouts overlap. Reachable from ordinary code — `effect.setUniform()` or `setTime()` called between two draws is enough, with no blend mode involved. The cache now lives on the GL context, which is the one object both sides hold, so it cannot drift out of step. Found while building the new blend-modes example, where a sprite rendered as a black rectangle -- **A batcher could bind without adopting its own shader.** `Batcher.bind()` asked the renderer's program cache whether to call `useShader` — but that answers "what does GL have bound", not "has this batcher taken up its shader yet", and `useShader` is also what assigns `currentShader`, which `setProjection` and the uniform paths dereference. The two questions coincided only because the cache could be stale; making it truthful (above) pulled them apart and the renderer threw on construction. Latent until then, and the reason this and the fix above ship together -- **`CanvasRenderTarget.invalidate()` re-entered the renderer's batcher dispatch**, which the GPU backends use to bracket a draw for an advanced blend mode. Refreshing a texture therefore looked like a scene draw and opened a bracket of its own around the *invalidation*, so the following draw composited twice and blended the scene against itself. Only reachable with one of the six new modes active, so no released version is affected, but it is a real re-entrancy hole in the same class as the guards already covering `toFrameTexture` and `blitEffect` +- Particles: fix the drawn position drifting past the simulated one. `pos` was baked into `currentTransform` while `autoTransform` stayed `true`, so `preDraw` conjugated a matrix that already held its own pivot and put the drawn centre at `(2 − s)·p`. Invisible while `p` was a few pixels from an emitter, fatal once `referenceSpace` lets `p` be a level coordinate. **This changes how existing effects look** — particles reach roughly half as far by the end of their life; raise `speed` or `maxLife` to compensate. `accurateBounds` is deprecated as a result, since there is no longer a trade to make +- Input: fix pointer events missing every non-floating renderable once the world was offset (#1605). `Camera2d.localToWorld` subtracts `world.pos`, so pointer coordinates are level-local while a non-floating renderable's bounds are absolute — at the origin the two coincide, but move the world to centre a level and hit detection stopped firing entirely. Floating regions are unaffected (thanks @Vareniel) +- Particles: fix `ParticleEmitter.blendMode` having no effect. Each particle copies the mode when it is born, so assigning it on the emitter reached nothing; it now applies to live particles and, through `settings.blendMode`, to later ones +- Renderer: fix `darken` and `lighten` being wrong for any translucent source. Fixed-function `MIN`/`MAX` computes `min(src, dst)` and nothing else, leaving nowhere for the `(1 - srcAlpha) * dst` term source-over contributes, so the backdrop's share vanished — 84/255 off the W3C result at 60% opacity, and a white `lighten` glow over a light backdrop rendered invisible. Both now composite through the shader path and are exact at any alpha +- WebGL: fix `GLShader.setUniform` corrupting a batch still pending. It binds its own program to write the value but cannot reach the renderer's program cache, so batchers checked that cache, skipped the rebind as redundant, and drew queued geometry through the wrong program — sprites turned black or vanished. Reachable from ordinary code: `effect.setUniform()` or `setTime()` between two draws is enough +- WebGPU: fix an abandoned frame leaving batchers holding views into destroyed textures. `abandonFrame()` frees every texture retired during the frame but did not reset the batchers, whose segment entries still referenced them, so the next frame could compose a bind group over freed resources ## [20.1.1] (melonJS 2) - _2026-08-25_ From cd9f843c680e960b25e9d79ccd85826ec60265dc Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 29 Aug 2026 08:33:59 +0800 Subject: [PATCH 2/5] docs(changelog): apply the entry convention to the released sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entries across the 19.3 to 20.1 range had drifted well past the house style, peaking at 20.1.0's three entries averaging 1166 characters. Trimmed to the one or two sentences CONTRIBUTING now describes, keeping the mechanism and the symptom and dropping the discovery narrative. version entries before after 20.1.1 7 538 277 20.1.0 3 1166 458 20.0.0 51 423 352 19.8.0 26 525 354 19.7.1 3 539 244 19.7.0 39 525 274 19.5.0 51 636 382 19.4.0 12 511 268 19.3.0 21 647 305 19.4.0, 20.1.0 and 20.1.1 were rewritten by hand, since they also needed restructuring into `Subsystem: what changed`. The rest already had that shape and only needed shortening. No entry was removed and no fact dropped beyond the narrative — these are published notes, so the record stays intact. Verified afterwards: nothing cut mid-clause, and one entry that split at "(e.g." was repaired by hand. Note the published GitHub releases still carry the original text, so the two now differ. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/CHANGELOG.md | 310 +++++++++++++++++----------------- 1 file changed, 154 insertions(+), 156 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index f9a93da62d..5b4210c14e 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -24,24 +24,22 @@ ## [20.1.1] (melonJS 2) - _2026-08-25_ ### Fixed -- **A font whose filename contained a space failed to preload.** The `fontface` parser wrapped a bare path as `url()` with no quotes, and an unquoted CSS `url()` token may not contain whitespace, so `data/fnt/Super Bouncer.ttf` produced a descriptor the browser refuses to parse: `font.load()` rejected with a `SyntaxError` before any request was made, surfacing as `Failed loading resource`. The descriptor is now quoted, which also covers parentheses and commas. Only `fontface` was affected: every other asset type hands its path to the browser, which percent-encodes it and issues a real request. The parser also no longer writes its wrapped value back onto the caller's asset descriptor, which is routinely a module-level manifest reused across scenes -- **A collision handler that removed an object crashed the physics step.** `Detector.collisions()` dispatches `onCollision` / `onCollisionStart` in the middle of processing a pair, then reads `objX.body.*`, which `Renderable.destroy()` sets to `undefined`. A handler calling `removeChildNow()` therefore threw out of `world.update()`, at four distinct sites depending on the spelling: removing itself, removing itself *and* returning `false` (the documented opt-out, so the likeliest form), removing the other object, and the same through `onCollisionStart`. "Remove it on pickup or on hit" is the commonest thing a collision handler does; the deferred `world.removeChild()` was always safe, `removeChildNow()` was not. `BuiltinAdapter.step` needed the same guard, since it clears `body.force` after the handlers have run -- **A `GLTFModel` animation callback that removed its own model crashed the frame.** `update()` fires `onended` and the completion callback, then poses the hierarchy unconditionally, writing into part meshes the callback may have destroyed. The 3D sibling of the sprite bug below, needing its own guards because `GLTFModel` re-implements the animation-callback contract rather than sharing it -- **`timer.updateTimers()` skipped a timer whenever another one fired.** It iterated `timers` while `clearTimer()` spliced that same array, so the entry after a fired one-shot was skipped for that tick: two `setTimeout`s due on the same frame ran only the first, the second arriving a frame late. Silent, no error -- **`Container.update()` updated a child twice** when another child's `update()` removed a sibling at a lower index, shifting the current child into the slot the reverse walk was about to read. Double-stepped that child's animation, physics and timers for a frame. Self-removal was always safe and still is -- **`Renderable.parentApp` threw for a renderable in a detached tree** instead of returning `undefined` as its own documentation promises, so removing a mesh from a container that was never added to a world threw -- **An animation callback that removed its own sprite crashed the frame.** `FrameAnimation.update()` hands control to user code in the middle of its frame loop, at `onended` and at the completion callback behind `resetAnim`, and then keeps stepping. `removeChildNow()` destroys the sprite and `FrameAnimation.destroy()` empties `anim`, so the loop resumed into a map the callback had just cleared and threw `Cannot read properties of undefined (reading 'frames')` out of `getAnimationFrameObjectByIndex`. A death animation that removed its own sprite therefore took the whole frame down. Both callback windows now check the animation is still resolvable before continuing, and the `resetAnim` return value is resolved before it is acted on rather than only before the next read. Updating an already-destroyed sprite is a no-op instead of a throw. A callback that merely switches animation with `setCurrentAnimation()` is unaffected, which is the point of the check - +- Loader: fix a font whose filename contains a space failing to preload. The `fontface` parser wrapped a bare path as `url()` with no quotes, and an unquoted CSS `url()` token may not contain whitespace, so `font.load()` rejected with a `SyntaxError` before any request was made. The descriptor is now quoted, which also covers parentheses and commas. Only `fontface` was affected +- Physics: fix a collision handler that removes an object crashing the physics step. `Detector.collisions()` dispatches `onCollision` / `onCollisionStart` mid-pair, then reads `objX.body.*`, which `Renderable.destroy()` clears — so a handler calling `removeChildNow()` threw out of `world.update()`. The deferred `world.removeChild()` was always safe. `BuiltinAdapter.step` needed the same guard +- glTF: fix a `GLTFModel` animation callback that removes its own model crashing the frame. `update()` fires `onended` and the completion callback, then poses the hierarchy unconditionally, writing into part meshes the callback may have destroyed +- Sprite: fix an animation callback that removes its own sprite crashing the frame. `FrameAnimation.update()` hands control to user code mid-loop, at `onended` and at the completion callback, then continues stepping +- Timer: fix `updateTimers()` skipping a timer whenever another one fired. It iterated `timers` while `clearTimer()` spliced the same array, so the entry after a fired one-shot was skipped for that tick — two `setTimeout`s due on the same frame ran only the first. Silent, no error +- Container: fix `update()` stepping a child twice when another child's `update()` removed a sibling at a lower index, shifting the current child into the slot the reverse walk was about to read. Double-stepped that child's animation, physics and timers for a frame. Self-removal was always safe +- Renderable: fix `parentApp` throwing for a renderable in a detached tree instead of returning `undefined` as documented ## [20.1.0] (melonJS 2) - _2026-08-24_ ### Added -- **Shape-level collision events** ([#1596](https://github.com/melonjs/melonJS/issues/1596)): `onShapeCollisionStart`, `onShapeCollisionActive` and `onShapeCollisionEnd` report **every** overlapping shape pair between two bodies, where `onCollision` and the `onCollision*` lifecycle report only the single contact chosen for physical resolution. A body colliding through several shapes at once could previously surface just one of them, and a solid contact masked a simultaneous trigger contact entirely. Each contact carries both shapes, both indices into `body.shapes`, the trigger status and the SAT data, receiver-symmetric so `shapeA` / `indexShapeA` are always your own. **Physical resolution is untouched**: the same pair is chosen for push-out, with the same solid-over-trigger preference from #1590. **Opt-in**: the detector enumerates shape pairs only when an object declares one of these handlers, so an application that declares none performs exactly the narrowphase work it did before, which is asserted directly rather than assumed. Contact identity is a stable id stamped on the shape, not its array position, because `removeShape()` re-indexes every surviving shape and a position-based identity would end and restart unrelated contacts. Supported by all three physics backends: the builtin detector, `@melonjs/planck-adapter` (Box2D reports one contact per fixture pair, and the adapter builds one fixture per shape) and `@melonjs/matter-adapter` (one matter part per shape, and matter's pairs are already per-part), so on both adapters the enumeration is what the engine reports natively rather than something layered on top +- Physics: shape-level collision events (#1596) — `onShapeCollisionStart`, `onShapeCollisionActive` and `onShapeCollisionEnd` report every overlapping shape pair between two bodies, where `onCollision` and its lifecycle report only the single contact chosen for physical resolution. Each contact carries both shapes, both indices into `body.shapes`, the trigger status and the SAT data, receiver-symmetric so `shapeA` is always your own. Opt-in: shape pairs are enumerated only when an object declares one of these handlers. Physical resolution is unchanged. Supported by all three physics backends ### Fixed -- **The collision lifecycle only ever fired for ONE pair in the whole world** ([#1596](https://github.com/melonjs/melonJS/issues/1596)): `createGUID()` returned `index || GUID_index`, and `index` defaults to `1`, so it returned the literal string `"-1"` on every call and the incrementing counter was computed then discarded. Every renderable added to a container therefore shared one GUID. GUID's only consumer is collision pair identity, so `Detector`'s pair key collapsed every pair onto a single entry: with two pairs colliding anywhere at once, the second was treated as already-seen that frame and its `onCollisionStart` / `onCollisionActive` / `onCollisionEnd` never fired. Measured on two independent pairs, only the first was reported. `onCollision` was unaffected, which is how this survived unnoticed: the legacy handler is dispatched outside the first-visit guard. The counter is now advanced and actually used when no id is supplied. The fix is deliberately minimal and the string **format is unchanged**: a caller that supplies an id still gets `-`, so a Tiled object's GUID stays readable as its map id, and `resetGUID(levelId, nextobjectid)` still seeds the counter past every authored id so the two can never meet. Two renderables sharing an *explicit* `id` still share a GUID, exactly as before -- `FadeEffect.destroy()` and `MaskEffect.destroy()` threw "Instance is already in pool" when called twice. Neither guarded its pooled `tween`, `color` or mask shape before releasing them, so a second call re-released the same instances. This is reachable without doing anything unusual: `removePostEffect()` destroys the effect it removes, so a caller that also destroys it explicitly gets a throw from the pool rather than from anything in their own code. Both are now guarded and cleared, matching the `Body.destroy()` fix in 20.0.0 - +- Core: fix `createGUID()` returning the literal string `"-1"` on every call, so every renderable shared one GUID (#1596). GUID is collision pair identity, so `Detector`'s pair key collapsed every pair onto a single entry: with two pairs colliding at once, the second was treated as already-seen and its `onCollisionStart` / `onCollisionActive` / `onCollisionEnd` never fired. `onCollision` was unaffected, which is how it went unnoticed. The string format is unchanged +- Post effects: fix `FadeEffect.destroy()` and `MaskEffect.destroy()` throwing "Instance is already in pool" when called twice. Neither guarded its pooled `tween`, `color` or mask shape before releasing them. Reachable without doing anything unusual, since `removePostEffect()` destroys the effect it removes ## [20.0.0] (melonJS 2) - _2026-08-21_ @@ -50,39 +48,39 @@ **Upgrading:** starting a game is now two steps: construct the `Application`, then `await app.init()`. `video.init()` and friends are removed. See *Changed (breaking)*. ### Added -- **WebGPU renderer** ([#1184](https://github.com/melonjs/melonJS/issues/1184)): the backend `video.AUTO` tries first on browsers that support it, also selectable as `renderer: video.WEBGPU` or via the `#webgpu` URI fragment. Covers the full 2D contract (sprites, text and particles with multi-texture batching, shapes and Path2D, all blend modes, patterns, scissor clipping, stencil masks, GPU tilemaps, 2D lights and normal maps, frame captures, gradient fills, compressed textures) and the complete 3D tier (`drawMesh`, retained geometry under `Camera3d`, glTF scenes, animated models, `Sprite3d` billboards, split-screen viewports). `antiAlias: true` maps to 4× MSAA. Built on the backend-neutral vertex formats of [#1551](https://github.com/melonjs/melonJS/issues/1551) consumed into pipeline layouts ([#1492](https://github.com/melonjs/melonJS/issues/1492)), with frame globals in a bind-group-0 uniform buffer ([#1555](https://github.com/melonjs/melonJS/issues/1555)). See the reworked **Hello WebGPU** example -- **Mesh instancing** ([#1508](https://github.com/melonjs/melonJS/issues/1508)): `InstancedMesh` draws one geometry many times in a **single call**: a 100 000-tree forest is one 52-vertex geometry plus a compact record each, at 60 fps on both GPU backends. It extends `Mesh`, so every existing setting works unchanged; it adds optional per-instance `instanceColors` and `instanceData` slots (nobody pays for a slot they did not declare) and `visibleInstanceCount` as a one-integer LOD knob. Moving the whole group re-uploads nothing. Requires a GPU backend (`renderer.supportsInstancing`); Canvas falls back to drawing each instance. See the new **Instanced Forest** example +- **WebGPU renderer** ([#1184](https://github.com/melonjs/melonJS/issues/1184)): the backend `video.AUTO` tries first on browsers that support it, also selectable as `renderer: video.WEBGPU` or via the `#webgpu` URI fragment. Covers the full 2D contract (sprites, text and particles with multi-texture batching, shapes and Path2D, all blend modes, patterns, scissor clipping, stencil masks, GPU tilemaps, 2D lights and normal maps, frame captures, gradient fills, compressed textures) and the complete 3D tier (`drawMesh`, retained geometry under `Camera3d`, glTF scenes, animated models, `Sprite3d` billboards, split-screen viewports). +- **Mesh instancing** ([#1508](https://github.com/melonjs/melonJS/issues/1508)): `InstancedMesh` draws one geometry many times in a **single call**: a 100 000-tree forest is one 52-vertex geometry plus a compact record each, at 60 fps on both GPU backends. It extends `Mesh`, so every existing setting works unchanged; it adds optional per-instance `instanceColors` and `instanceData` slots (nobody pays for a slot they did not declare) and `visibleInstanceCount` as a one-integer LOD knob. - **glTF `EXT_mesh_gpu_instancing`** ([#1508](https://github.com/melonjs/melonJS/issues/1508)): a node carrying per-instance `TRANSLATION` / `ROTATION` / `SCALE` accessors loads as an `InstancedMesh`, with no user code -- **Ground shadows for 3D objects** ([#1515](https://github.com/melonjs/melonJS/issues/1515)): `castGroundShadow: true` gives a `Mesh`, `Sprite3d` or whole `InstancedMesh` scatter a soft blob shadow, so 2.5D characters and props stop reading as floating. Set `shadowGroundY` (the floor's world Y) and the blob shrinks and fades with height, which is what reads as a jump; `shadowOpacity` defaults to `0.45`. The blob is an ellipse sized from the caster's footprint and turned by its rotation, not a disc. Costs one extra draw per object, and **one for an entire instanced scatter**, whatever its size. On by default and overridable per object, per glTF scene, or application-wide; GPU backends only. Shown in the **Per-material Textures**, **Billboard Sprites** and **Instanced Forest** examples -- **Shader effects on WebGPU, with dual-language bodies**: `ShaderEffect` and all 18 built-in effects run on both GPU backends. A body stays a GLSL string, or becomes one per language: `new ShaderEffect(renderer, { glsl, wgsl })`, with shared uniform names so one `setUniform` serves both. With no matching body the effect warns once and stays disabled while the scene keeps rendering. Shader assets take the same shape. **Existing GLSL-only effects and assets are untouched** +- **Ground shadows for 3D objects** ([#1515](https://github.com/melonjs/melonJS/issues/1515)): `castGroundShadow: true` gives a `Mesh`, `Sprite3d` or whole `InstancedMesh` scatter a soft blob shadow, so 2.5D characters and props stop reading as floating. Set `shadowGroundY` (the floor's world Y) and the blob shrinks and fades with height, which is what reads as a jump; `shadowOpacity` defaults to `0.45`. +- **Shader effects on WebGPU, with dual-language bodies**: `ShaderEffect` and all 18 built-in effects run on both GPU backends. A body stays a GLSL string, or becomes one per language: `new ShaderEffect(renderer, { glsl, wgsl })`, with shared uniform names so one `setUniform` serves both. - **Custom mesh shaders on both backends, and `GLShader` is dual-language**: `new GLShader(gl, { vertex, fragment, wgsl })` carries a GLSL pair and/or a WGSL module, with `isWebGL` / `isWebGPU` reporting which exist, so one `mesh.shader = loader.getShader(...)` serves both. A shader with no realization for the active backend is inert with one warning, never fatal - **3D collision: the `Box3d` body shape** ([#1476](https://github.com/melonjs/melonJS/issues/1476)): a body can now be pushed back along **Z**, which no planar shape could express. Adds an AABB narrowphase, `ResponseObject.overlapZ` / `overlapNZ`, `velZ` / `forceZ` / `frictionZ` / `maxVelZ` on `Body`, and an exact ray-vs-AABB test for `raycast3d`. **The 2D path is unchanged**: Z arrives as scalars rather than widening `overlapV` / `body.vel`, and planar shapes mixed with a `Box3d` are treated as unbounded along Z -- **Per-shape collision settings** ([#1590](https://github.com/melonjs/melonJS/issues/1590)): each shape on a body may carry its own `collisionType` / `collisionMask`, plus `isActive` (default `true`; `false` removes it from collision entirely) and `isTrigger` (default `false`; still collides and fires events, but skips position correction). So one body can have a footprint that only hits terrain and a hurtbox that only reacts to attacks, instead of being split in two. Unset fields inherit the body's, so **a shape that sets nothing behaves exactly as before**, and a shape can only narrow what its body allows. Handlers read `response.indexShapeA` / `indexShapeB` to tell which shape was hit. Honoured by the builtin and planck adapters; matter filters per body and ignores them +- **Per-shape collision settings** ([#1590](https://github.com/melonjs/melonJS/issues/1590)): each shape on a body may carry its own `collisionType` / `collisionMask`, plus `isActive` (default `true`; `false` removes it from collision entirely) and `isTrigger` (default `false`; still collides and fires events, but skips position correction). So one body can have a footprint that only hits terrain and a hurtbox that only reacts to attacks, instead of being split in two. - **Point and spot 3D lights** ([#1536](https://github.com/melonjs/melonJS/issues/1536)): `Light3d` gains `"point"` and `"spot"` on both GPU backends, with `position`, `range` and cone angles, all mutable at runtime. glTF `KHR_lights_punctual` lamps now instantiate too, carrying their authored names so `world.getChildByName("Sun")` finds them; `level.load(name, { lightIntensityScale })` preserves relative authored intensities. The light block grows to 12 floats per light, so custom shaders reading `Light3dBlock` need the new layout -- **Up to 32 lights** ([#1552](https://github.com/melonjs/melonJS/issues/1552)): `MAX_LIGHTS` rises from 8, for both lit sprites and lit meshes, by moving light data out of GLSL uniform arrays into a `std140` uniform buffer. A static rig still costs **zero** GL calls per frame. This raises capacity, not shading cost. The four lit shaders move to GLSL ES 3.00 as a consequence; **user shaders are unaffected** and stay ES 1.00 -- **`maxTextures` setting, and the batch limit follows the device** ([#1585](https://github.com/melonjs/melonJS/issues/1585)): the WebGL texture pool was hardcoded to 16, the WebGL 2 spec *floor* and about half what current hardware reports; it now uses the device's own limit, capped at 32. `maxTextures: "auto" | number` overrides it, clamped to what the device has and read at initialization only. Lit sprites gain twice over: normal maps now draw from the shared pool instead of permanently reserving half of it +- **Up to 32 lights** ([#1552](https://github.com/melonjs/melonJS/issues/1552)): `MAX_LIGHTS` rises from 8, for both lit sprites and lit meshes, by moving light data out of GLSL uniform arrays into a `std140` uniform buffer. A static rig still costs **zero** GL calls per frame. +- **`maxTextures` setting, and the batch limit follows the device** ([#1585](https://github.com/melonjs/melonJS/issues/1585)): the WebGL texture pool was hardcoded to 16, the WebGL 2 spec *floor* and about half what current hardware reports; it now uses the device's own limit, capped at 32. `maxTextures: "auto" | number` overrides it, clamped to what the device has and read at initialization only. - **`antiAlias: true` survives post effects** ([#1556](https://github.com/melonjs/melonJS/issues/1556)): adding any effect used to silently disable MSAA, since the scene rasterized into a single-sampled capture target. Capture targets are now multisampled themselves (up to 4×) on both GPU backends, so edges stay smooth under an effect chain - **Mesh textures sample generated mipmaps**: full mip chains, trilinear minification and 4× anisotropic filtering on both GPU backends, so distant and grazing-angle geometry stops shimmering. 2D is untouched (sprite samplers clamp to the base level), and `textureFilter: "nearest"` opts a mesh out -- **MTL specular and per-texel opacity** ([#1575](https://github.com/melonjs/melonJS/issues/1575)): `Ks` + `Ns` give the lit mesh path a Blinn-Phong highlight where every material previously read as chalk, exposed as `mesh.specular` / `mesh.shininess` and gated on the exponent, so a material declaring only a colour stays matte. `map_d` drives `alphaCutoff` per *texel* through `mesh.alphaMap`, which is what foliage, fences and decals need. MTL's `Pr` / `Pm` and glTF's `pbrMetallicRoughness` map onto the same terms, so either format shades identically. This is an approximation onto a stylized model, **not** PBR. A material using neither renders identically to before. Shown in the reworked **Per-material Textures** example -- **Per-material diffuse textures on multi-material models** ([#1573](https://github.com/melonjs/melonJS/issues/1573)): a multi-material OBJ bound the first material's `map_Kd` for the whole model, so a crate with wood sides and a steel lid rendered all wood. Each material now resolves its own texture, reduced to the fewest index ranges and exposed as `mesh.textureGroups`. A model needing no split issues exactly the one draw call it always did -- **OBJ vertex normals** ([#1572](https://github.com/melonjs/melonJS/issues/1572)): the parser read `vn` and discarded it, so `lit: true` OBJs shaded against a fallback while the same model from glTF lit correctly. Authored normals now reach the mesh (vertices split where normals differ, so hard edges stay hard), and a file supplying none gets smooth generated ones. Smoothing groups (`s`) are still ignored, so supply `vn` for precise control +- **MTL specular and per-texel opacity** ([#1575](https://github.com/melonjs/melonJS/issues/1575)): `Ks` + `Ns` give the lit mesh path a Blinn-Phong highlight where every material previously read as chalk, exposed as `mesh.specular` / `mesh.shininess` and gated on the exponent, so a material declaring only a colour stays matte. `map_d` drives `alphaCutoff` per *texel* through `mesh.alphaMap`, which is what foliage, fences and decals need. +- **Per-material diffuse textures on multi-material models** ([#1573](https://github.com/melonjs/melonJS/issues/1573)): a multi-material OBJ bound the first material's `map_Kd` for the whole model, so a crate with wood sides and a steel lid rendered all wood. Each material now resolves its own texture, reduced to the fewest index ranges and exposed as `mesh.textureGroups`. +- **OBJ vertex normals** ([#1572](https://github.com/melonjs/melonJS/issues/1572)): the parser read `vn` and discarded it, so `lit: true` OBJs shaded against a fallback while the same model from glTF lit correctly. Authored normals now reach the mesh (vertices split where normals differ, so hard edges stay hard), and a file supplying none gets smooth generated ones. - **`Mesh.needsUpdate`** ([#1507](https://github.com/melonjs/melonJS/issues/1507)): signal that geometry was edited in place so the GPU copy refreshes on the next draw. Moving, rotating, scaling or re-tinting needs no signal - **Backend-neutral vertex formats and topologies** ([#1551](https://github.com/melonjs/melonJS/issues/1551)): declare an attribute with one `format` token (`"float32x3"`) instead of a `size` + `type` + `normalized` triple, and a draw mode with a topology name (`"triangle-list"`). `Batcher.addAttribute` accepts three forms and `Batcher.mode` either vocabulary; **the GL-enum form is supported indefinitely**, so custom batchers need no changes -- **`exclusion` blend mode on both GPU backends** ([#1318](https://github.com/melonjs/melonJS/issues/1318)): the one CSS advanced mode needing no shader: `a + b - 2ab` falls straight out of fixed-function blending. Exact for an opaque source, approximate for a translucent one, the caveat `screen` already carries. The other six still need shaders +- **`exclusion` blend mode on both GPU backends** ([#1318](https://github.com/melonjs/melonJS/issues/1318)): the one CSS advanced mode needing no shader: `a + b - 2ab` falls straight out of fixed-function blending. Exact for an opaque source, approximate for a translucent one, the caveat `screen` already carries. - **A `"none"` blend mode on both GPU backends**: disables blending outright; WebGL now honors it instead of silently falling back to `"normal"`. `setBlendEnabled`, `enableScissor` and `clearRenderTarget` are implemented on WebGPU too, along with custom batcher overrides, `settings.blendMode`, `GPUVendor` and `failIfMajorPerformanceCaveat` - **Gradient and Text textures stopped power-of-two rounding** ([#1554](https://github.com/melonjs/melonJS/issues/1554)): gradients bake into a fixed 256×256 shared target and stretch to size (capping gradient memory at 256 KB), and text canvases round to 32-pixel buckets, so worst-case waste drops from 2× per axis to 31 px. Both make re-bakes same-size updates ### Changed (breaking) -- **Starting a game is now two steps: construct the `Application`, then `await app.init()`**: and `init()` is **mandatory**. It is asynchronous because a WebGPU device cannot be acquired synchronously, but still resolves without suspending on Canvas and WebGL. Renderer failures surface as a rejection rather than a constructor throw: +- **Starting a game is now two steps: construct the `Application`, then `await app.init()`**: and `init()` is **mandatory**. It is asynchronous because a WebGPU device cannot be acquired synchronously, but still resolves without suspending on Canvas and WebGL. ```js // before // after video.init(640, 480, { parent: "screen" }); const app = new Application(640, 480, { parent: "screen" }); await app.init(); ``` **This applies to code already using `new Application(...)` on 19.x too**: constructing no longer builds the renderer or appends the canvas, so add `await app.init()` or nothing displays. The pre-created bootstrap application and the `legacy` setting are removed; `game` now names the most recently **initialized** `Application`, and is `undefined` until the first `init()` resolves. `app.destroy()` is now **terminal** (construct a new instance instead), and a repeated `init()` is a warned no-op. Custom renderers gained an argument-less async `init()` hook that `Application#init` awaits -- **`video.init()`, `video.renderer`, `video.createCanvas()` and `video.getParent()` are removed**: deprecated since 18.3.0/19.7.0. Use `app.init()`, `app.renderer`, `app.renderer.createCanvas()` and `app.getParentElement()`. Note `video.init()` returned `false` on failure where `app.init()` rejects, so `catch` it if you relied on the boolean -- **`video.AUTO` (the default) now tries WebGPU first, falling back to WebGL 2, and ultimately to Canvas** so an existing game using `AUTO` starts on WebGPU where available, rendering the entire feature set identically to WebGL (all 44 examples verified side-by-side). `await app.init()` still always resolves under AUTO. Pin `renderer: video.WEBGL` (or `#webgl`) to stay on WebGL -- **The WebGL renderer is now WebGL 2 only** ([#1509](https://github.com/melonjs/melonJS/issues/1509)): the WebGL 1 fallback is removed. `video.AUTO` falls back to Canvas on WebGL-1-only devices; `video.WEBGL` throws there. **User shaders need no changes**: GLSL ES 1.00 compiles unchanged on WebGL 2. With it: +- **`video.init()`, `video.renderer`, `video.createCanvas()` and `video.getParent()` are removed**: deprecated since 18.3.0/19.7.0. Use `app.init()`, `app.renderer`, `app.renderer.createCanvas()` and `app.getParentElement()`. +- **`video.AUTO` (the default) now tries WebGPU first, falling back to WebGL 2, and ultimately to Canvas** so an existing game using `AUTO` starts on WebGPU where available, rendering the entire feature set identically to WebGL (all 44 examples verified side-by-side). `await app.init()` still always resolves under AUTO. +- **The WebGL renderer is now WebGL 2 only** ([#1509](https://github.com/melonjs/melonJS/issues/1509)): the WebGL 1 fallback is removed. `video.AUTO` falls back to Canvas on WebGL-1-only devices; `video.WEBGL` throws there. **User shaders need no changes**: GLSL ES 1.00 compiles unchanged on WebGL 2. - `preferWebGL1` and the `#webgl1` flag are removed (`#webgl` / `#webgl2` are synonyms) - `device.isWebGLSupported()` now probes for WebGL **2**, agreeing with what renderer construction requests - `renderer.type` is always `"WebGL2"`; `renderer.WebGLVersion` is deprecated (always `2`) @@ -90,34 +88,34 @@ - WebGL is selected only on devices passing `failIfMajorPerformanceCaveat` (which melonJS leaves enabled, unlike the WebGL default), so a software rasterizer gets Canvas under `AUTO` and throws under `video.WEBGL`. Set it `false` to accept such a context - behavior corrections on ex-WebGL-1 configs: `repeat` genuinely tiles non-power-of-two textures (was clamp + warning), `"darken"` / `"lighten"` use true MIN/MAX (were downgraded to `"normal"`), and `createPattern()` accepts non-power-of-two sources (threw before) - **`Batcher` is now the backend-neutral base class; the WebGL base batcher is renamed `WebGLBatcher`**: custom WebGL batchers change one word (extend `WebGLBatcher`); constructor, settings and methods are unchanged. `WebGPUBatcher` / `WebGPUQuadBatcher` / `WebGPUPrimitiveBatcher` are exported, and `renderer.addBatcher()` now rejects a wrong-backend batcher up front instead of failing mid-draw -- **each `Batcher` owns an immutable Vertex Array Object** built at init ([#1509](https://github.com/melonjs/melonJS/issues/1509)): attribute layout is frozen once built and `unbind()` no longer disables attribute arrays. Custom batchers need no changes, but a custom shader hosted by a built-in batcher must declare that batcher's attributes first, in layout order (a warning fires on mismatch). `GLShader.setVertexAttributes` is no longer called by the engine -- **mesh geometry is now supplied to shaders in model space** ([#1507](https://github.com/melonjs/melonJS/issues/1507)): placement moved into the `uModelMatrix` / `uViewMatrix` / `uTint` uniforms. **This affects custom shaders used on a `Mesh` only**; sprite and post-effect shaders are unaffected. Such a shader must position with `uProjectionMatrix * uViewMatrix * uModelMatrix * vec4(aVertex, 1.0)` and tint with `uTint`. The engine warns when it declares none of them. `getBounds3d()` / `toPolygon()` now compute from the model matrix and are correct before the first draw +- **each `Batcher` owns an immutable Vertex Array Object** built at init ([#1509](https://github.com/melonjs/melonJS/issues/1509)): attribute layout is frozen once built and `unbind()` no longer disables attribute arrays. Custom batchers need no changes, but a custom shader hosted by a built-in batcher must declare that batcher's attributes first, in layout order (a warning fires on mismatch). +- **mesh geometry is now supplied to shaders in model space** ([#1507](https://github.com/melonjs/melonJS/issues/1507)): placement moved into the `uModelMatrix` / `uViewMatrix` / `uTint` uniforms. **This affects custom shaders used on a `Mesh` only**; sprite and post-effect shaders are unaffected. Such a shader must position with `uProjectionMatrix * uViewMatrix * uModelMatrix * vec4(aVertex, 1.0)` and tint with `uTint`. - **renderer capability flags replace backend type checks**: read `Renderer.shaderLanguage` and `Renderer.supportsDepthBuffer` instead of testing `renderer.gl` or `instanceof WebGLRenderer`, which answer "no" for any future backend that is in fact capable - an attribute declared without an explicit `offset` is now packed after the previous one instead of defaulting to byte 0: layouts omitting offsets were overlapping every attribute at 0 and reading the wrong data ([#1551](https://github.com/melonjs/melonJS/issues/1551)) - a `Batcher` whose vertex stride is not a multiple of 4 bytes now throws at construction, instead of building with a fractional vertex size and silently discarding every write ([#1551](https://github.com/melonjs/melonJS/issues/1551)) ### Deprecated -- `Application.updateAverageDelta` is renamed **`lastUpdateDelta`**: it holds the cost of the most recent logic step and has never been an average (the smoothing was removed in 2015). The old name keeps working as an alias, scheduled for removal in 21.0.0. Note it differs from `updateDelta`, the *simulated* time one step advances +- `Application.updateAverageDelta` is renamed **`lastUpdateDelta`**: it holds the cost of the most recent logic step and has never been an average (the smoothing was removed in 2015). The old name keeps working as an alias, scheduled for removal in 21.0.0. ### Fixed - `Application.destroy()` leaked the WebGL context: each destroyed application left a live one behind, and browsers force-lose the oldest past ~16, so a page that builds and tears down several eventually stalls an unrelated `getContext`. Now released through `WEBGL_lose_context` - destroyed renderers stayed subscribed to global events forever: `WebGLRenderer`, `CanvasRenderer`, the root `Container` and `World` all subscribed with inline anonymous handlers that `off()` cannot remove, pinning the renderer, its batchers and its GPU objects against garbage collection. All are now per-instance fields that `destroy()` unregisters -- the 3D broadphase silently dropped collisions between bodies at different depths: `Octree.retrieve()` descended only into the query item's own octant, but its consumers (SAT collision, pointer picking, the 2D raycast, `queryAABB`) all decide overlap in the XY plane. Measured on a 300-body scene, 12 of 20 genuinely overlapping pairs were never surfaced. `retrieve()` is now depth-blind. Note this removes the incidental "distant parallax drops out of collision for free" behaviour, so exclude parallax deliberately with `isKinematic` or `collisionType` / `collisionMask` -- an entire 2.5D gameplay plane sat unpartitioned at the root of the octree: items sitting *exactly* on a midpoint were kept at the parent level, and the root's midpoint is `(0, 0, 0)`: the default `pos` of every renderable. 200 bodies on a `z = 0` plane all stayed at the root, degrading the broadphase to a linear scan for exactly the busiest layer. Classification is now exact on all three axes. Invisible to 2D games, which use a `QuadTree` +- the 3D broadphase silently dropped collisions between bodies at different depths: `Octree.retrieve()` descended only into the query item's own octant, but its consumers (SAT collision, pointer picking, the 2D raycast, `queryAABB`) all decide overlap in the XY plane. Measured on a 300-body scene, 12 of 20 genuinely overlapping pairs were never surfaced. +- an entire 2.5D gameplay plane sat unpartitioned at the root of the octree: items sitting *exactly* on a midpoint were kept at the parent level, and the root's midpoint is `(0, 0, 0)`: the default `pos` of every renderable. 200 bodies on a `z = 0` plane all stayed at the root, degrading the broadphase to a linear scan for exactly the busiest layer. Classification is now exact on all three axes. - a multi-shape sensor body was still pushed out of collisions ([#1591](https://github.com/melonjs/melonJS/issues/1591)): `body.isSensor` worked with one shape and silently stopped once a second was added, because the extra pass that resolves shape-junction overlaps checked only `isStatic`. Affects any compound trigger volume or multi-part hitbox - `Body.destroy()` threw for a body with an `Ellipse` collider (no release branch, so it fell through to the legacy pool), and threw again when called twice (re-releasing `undefined` fields). Both aborted teardown partway, leaving the body holding a recycled `Bounds` that surfaced later as an unrelated broadphase crash -- a texture-cache overflow re-created and re-uploaded every texture once per draw ([#1585](https://github.com/melonjs/melonJS/issues/1585)): past the batching limit a scene did not re-*bind* its textures, it re-*built* them, regenerating each mip chain and leaving the displaced handles to GC. The GL handle was reachable only through a batcher's per-unit array, so dropping a unit assignment destroyed the texture. Residency is now renderer-owned and keyed by source, with wrap/filter on WebGL 2 sampler objects so one upload serves a source drawn at several repeat modes. Measured under *Performance* above +- a texture-cache overflow re-created and re-uploaded every texture once per draw ([#1585](https://github.com/melonjs/melonJS/issues/1585)): past the batching limit a scene did not re-*bind* its textures, it re-*built* them, regenerating each mip chain and leaving the displaced handles to GC. The GL handle was reachable only through a batcher's per-unit array, so dropping a unit assignment destroyed the texture. - a `ShaderEffect` extra sampler could silently corrupt normal-map lighting ([#1585](https://github.com/melonjs/melonJS/issues/1585)): `setTexture` claims units from the top and `LitQuadBatcher` reserved a fixed upper range, so an effect claiming before the first lit sprite aliased with it: the normal map sampled the effect's texture, with no error or warning -- a scene containing only meshes stopped clearing its depth buffer after the first frame and its geometry disappeared: the depth clear and lit-mesh light upload ran from `MeshBatcher.bind()`, a per-*transition* hook, so a scene with nothing else to draw bound once and never again. The same silence froze `Light3d` at its first-frame values. Regression from [#1468](https://github.com/melonjs/melonJS/issues/1468) -- a mesh marked `lit` with no usable normals rendered solid black (normalizing a zero-length normal yields NaN). It now degrades to **unlit** on both GPU backends: wrong, but recognisably the model. This makes the failure legible, it does not make a `Camera2d` mesh light ([#1576](https://github.com/melonjs/melonJS/issues/1576) remains open) +- a scene containing only meshes stopped clearing its depth buffer after the first frame and its geometry disappeared: the depth clear and lit-mesh light upload ran from `MeshBatcher.bind()`, a per-*transition* hook, so a scene with nothing else to draw bound once and never again. The same silence froze `Light3d` at its first-frame values. +- a mesh marked `lit` with no usable normals rendered solid black (normalizing a zero-length normal yields NaN). It now degrades to **unlit** on both GPU backends: wrong, but recognisably the model. - `DropShadowEffect` rendered its shadow mirrored (up instead of down) when chained with other effects on WebGL: the pooled path composites through bottom-up capture FBOs, inverting directional UV arithmetic. Effect bodies can now declare a `uUVYDir` uniform that the renderer feeds per draw path; `DropShadowEffect` and `ShineEffect` use it, so a positive `offsetY` means down everywhere - untextured glTF materials rendered washed out: `baseColorFactor` is linear per spec but was scaled by 255 straight into an 8-bit sRGB tint, so an authored mid-green arrived at double value. Now encoded through the sRGB transfer function, which also clamps the domain (an out-of-range factor used to NaN the whole tint) - an indexed (palette) PNG in a glTF asset rendered greyscale on Safari: the glTF parser decoded to an `HTMLImageElement` where the rest of the loader produces an `ImageBitmap`, and WebKit uploads raw palette indices for PNG `colorType 3`. glTF images now decode to an `ImageBitmap` - WebGPU reported `add` and `lighter` as unsupported blend modes: `setBlendMode` returns the mode applied, which callers compare against their request, but WebGPU returned the canonical `"additive"` for both aliases. Rendering was always identical; only the report was wrong ### Performance -- **retained-mode mesh rendering** ([#1507](https://github.com/melonjs/melonJS/issues/1507)): geometry uploads once and re-draws from the GPU, so steady-state frames issue **zero** vertex uploads and **zero** per-vertex CPU transforms however much a mesh moves or re-tints. Against 19.9.1, 64 meshes of 5 000 vertices went from 3.80 to **0.04 ms/frame** (95×) and from 128 draw calls to 64. Small meshes gain far less, since only per-call overhead was ever at stake. -- **texture batching past the multi-texture limit** ([#1585](https://github.com/melonjs/melonJS/issues/1585)): an overflow now costs a flush and some re-binds, where it used to re-create every texture once per draw. Against 19.9.1 at 512 quads/frame, one texture past the limit went from 4.71 to **0.06 ms/frame** (76×), and texture uploads from 1 626 per frame to none. Below the limit the new path is marginally slower (0.052 to 0.062 ms), the residual being the per-source lookup that buys the rest. +- **retained-mode mesh rendering** ([#1507](https://github.com/melonjs/melonJS/issues/1507)): geometry uploads once and re-draws from the GPU, so steady-state frames issue **zero** vertex uploads and **zero** per-vertex CPU transforms however much a mesh moves or re-tints. Against 19.9.1, 64 meshes of 5 000 vertices went from 3.80 to **0.04 ms/frame** (95×) and from 128 draw calls to 64. +- **texture batching past the multi-texture limit** ([#1585](https://github.com/melonjs/melonJS/issues/1585)): an overflow now costs a flush and some re-binds, where it used to re-create every texture once per draw. Against 19.9.1 at 512 quads/frame, one texture past the limit went from 4.71 to **0.06 ms/frame** (76×), and texture uploads from 1 626 per frame to none. - **immutable texture storage** ([#1556](https://github.com/melonjs/melonJS/issues/1556)): textures allocate with `texStorage2D` and update through `texSubImage2D`, so a same-size update (a ticking `Text`, a `Gradient` re-bake, a video frame) is a data copy rather than a fresh texture object each time. With the [#1554](https://github.com/melonjs/melonJS/issues/1554) size buckets, dynamic text and gradients allocate **nothing at all** in steady state. - **Vertex Array Objects for every batcher** ([#1509](https://github.com/melonjs/melonJS/issues/1509)): attribute layout is specified once at init, so steady-state frames issue **zero** attribute-specification calls. It also makes attribute-state leaks between batchers impossible by construction. - **the cost of `antiAlias: true` under post effects** ([#1556](https://github.com/melonjs/melonJS/issues/1556)): a 4× capture target adds roughly 28 bytes per pixel on WebGL (~55 MB at 1080p) and ~16 on WebGPU (~32 MB), plus one resolve blit per effect bracket. Only capture targets pay it; with `antiAlias: false` (the default) no multisampled storage exists at all. @@ -194,91 +192,91 @@ **Highlights:** glTF / GLB scene loading lands — author a 3D scene in Blender (or any DCC tool), export a `.glb`, and load it like a Tiled map with `level.load(...)`. Animated models play back through the same `setCurrentAnimation` / `play` / `pause` / `stop` API as a 2D `Sprite`. Scene meshes are lit by the authored sun, and 3D meshes can now report a real bounding box. And `Sprite3d` brings the 2.5D workflow — billboarded, frame-animated cut-out sprites that face a `Camera3d` (the Paper Mario look), sharing one `FrameAnimation` engine with the 2D `Sprite`. ### Added -- **`loader.preload()` / `loader.load()` are now `await`-able** — `preload(assets)` returns a `Promise` that resolves once every asset has loaded (and rejects on failure), so you can `await loader.preload(assets)` instead of nesting an `onload` callback. `load(asset)` called **without** callbacks likewise returns a Promise for a one-off dynamic load. Both are fully back-compat: the callback forms (and `loader.onload` / `LOADER_PROGRESS` / `LOADER_ERROR` events) are unchanged, and `load(asset, onload, onerror)` still returns the resource count. (`preload` was already promise-based internally — it just didn't hand the promise back.) -- **glTF / GLB scene loader (Tier 1)** — preload a `.glb`/`.gltf` and it auto-registers with the `level` director, so `level.load(name, { scale, rightHanded, onLoaded })` instantiates every mesh node as a `Mesh` in one call, exactly like a Tiled map. Parses the node graph, mesh primitives (`POSITION` / `NORMAL` / `TEXCOORD_0` / `COLOR_0` / indices), materials (`pbrMetallicRoughness.baseColorTexture` + `baseColorFactor`), perspective cameras, scene bounds, `KHR_lights_punctual` lights, and node animations. `loader.getGLTF(name)` returns the raw `{ nodes, cameras, lights, bounds, graph, animations }` descriptor for custom framing/instantiation. View under a `Camera3d`. New **glTF Scene** example (Kenney Platformer Kit, CC0). -- **glTF node animation + `GLTFModel`** — assets that define animation channels load as a single rig-driven `GLTFModel` that keeps the node **hierarchy** intact (a parent transform carries its children — rotate a character's `torso` and its `arm`/`head` follow). Each frame the active clip is sampled (translation/scale `LERP`, rotation `SLERP`, plus `STEP`; `CUBICSPLINE` keyframe values) and the rig is re-posed. This is rigid node/TRS animation (no vertex skinning) — walk/idle/sprint characters, spinning pickups, doors, lifts. The animation API mirrors `Sprite`: `setCurrentAnimation(name, { loop, speed, onComplete, next })`, `isCurrentAnimation`, `getAnimationNames`, `animationspeed` (a playback multiplier), `play` / `pause` / `stop`. Retrieve the model after loading with `world.getChildByName(assetName)[0]`. New **glTF Animated Model** example (Kenney Blocky Characters, CC0). +- **`loader.preload()` / `loader.load()` are now `await`-able** — `preload(assets)` returns a `Promise` that resolves once every asset has loaded (and rejects on failure), so you can `await loader.preload(assets)` instead of nesting an `onload` callback. `load(asset)` called **without** callbacks likewise returns a Promise for a one-off dynamic load. +- **glTF / GLB scene loader (Tier 1)** — preload a `.glb`/`.gltf` and it auto-registers with the `level` director, so `level.load(name, { scale, rightHanded, onLoaded })` instantiates every mesh node as a `Mesh` in one call, exactly like a Tiled map. Parses the node graph, mesh primitives (`POSITION` / `NORMAL` / `TEXCOORD_0` / `COLOR_0` / indices), materials (`pbrMetallicRoughness.baseColorTexture` + `baseColorFactor`), perspective cameras, scene bounds, `KHR_lights_punctual` lights, and node animations. +- **glTF node animation + `GLTFModel`** — assets that define animation channels load as a single rig-driven `GLTFModel` that keeps the node **hierarchy** intact (a parent transform carries its children — rotate a character's `torso` and its `arm`/`head` follow). Each frame the active clip is sampled (translation/scale `LERP`, rotation `SLERP`, plus `STEP`; `CUBICSPLINE` keyframe values) and the rig is re-posed. - **Aligned 2D + 3D animation API** — `Sprite.setCurrentAnimation(name, options)` now also accepts an options object `{ loop, speed, onComplete, next }` (the existing string / callback / no-arg forms are unchanged), plus a `speed` playback multiplier, and new `getAnimationNames()`. Both `Sprite` and `GLTFModel` gained `play(name?, options?)` (switch-and-play, or resume), chainable `pause()`, and `stop()` (reset to the first frame / bind pose) so 2D and 3D animation share one vocabulary. -- **External glTF resources** — the loader resolves external `.bin` buffers and image `uri`s relative to the asset URL (via `fetchData`, honoring the loader's crossOrigin / nocache settings), so a `.glb`/`.gltf` that references a separate texture file (e.g. Kenney's `Textures/foo.png`) loads as-shipped without repackaging. Self-contained GLBs (embedded buffers + data-URI / bufferView images) are unaffected. -- **OBJ/MTL textures auto-load** (#1505) — `preloadMTL` now loads each material's `map_Kd` texture automatically, resolved relative to the `.mtl` file, so an OBJ model's textures "come for free" like a glTF scene's. Preloading the model + material is enough — no separate per-texture preload entry needed (the explicit `texture:` still wins, and the legacy preload-it-yourself flow keeps working). A missing texture is warned and skipped (the mesh falls back to the white pixel) rather than aborting the load. -- **`Mesh` `textureRepeat` setting** — texture wrap mode (`"repeat"` / `"repeat-x"` / `"repeat-y"` / `"no-repeat"`) applied to the resolved texture, for geometry whose UVs fall outside `[0, 1]` and rely on the texture tiling. The glTF loader sets it from each material's sampler `wrapS` / `wrapT` (defaulting to REPEAT, the glTF spec default) — without it such assets sampled flat edge texels and looked untextured. Never applied to the shared white-pixel fallback. -- **`Mesh` `textureFilter` setting + glTF sampler filtering** — texture magnification filter (`"nearest"` / `"linear"`) applied to the resolved texture (WebGL). The glTF loader reads it from each material's sampler `magFilter`, so **pixel-art-textured 3D models render crisp** instead of blurred by the global antiAlias default. Omitted → keeps the engine default. +- **External glTF resources** — the loader resolves external `.bin` buffers and image `uri`s relative to the asset URL (via `fetchData`, honoring the loader's crossOrigin / nocache settings), so a `.glb`/`.gltf` that references a separate texture file (e.g. Kenney's `Textures/foo.png`) loads as-shipped without repackaging. +- **OBJ/MTL textures auto-load** (#1505) — `preloadMTL` now loads each material's `map_Kd` texture automatically, resolved relative to the `.mtl` file, so an OBJ model's textures "come for free" like a glTF scene's. Preloading the model + material is enough — no separate per-texture preload entry needed (the explicit `texture:` still wins, and the legacy preload-it-yourself flow keeps working). +- **`Mesh` `textureRepeat` setting** — texture wrap mode (`"repeat"` / `"repeat-x"` / `"repeat-y"` / `"no-repeat"`) applied to the resolved texture, for geometry whose UVs fall outside `[0, 1]` and rely on the texture tiling. The glTF loader sets it from each material's sampler `wrapS` / `wrapT` (defaulting to REPEAT, the glTF spec default) — without it such assets sampled flat edge texels and looked untextured. +- **`Mesh` `textureFilter` setting + glTF sampler filtering** — texture magnification filter (`"nearest"` / `"linear"`) applied to the resolved texture (WebGL). The glTF loader reads it from each material's sampler `magFilter`, so **pixel-art-textured 3D models render crisp** instead of blurred by the global antiAlias default. - **glTF material color** — `baseColorFactor` is applied as the mesh tint, so a solid-colored *untextured* material renders its color (previously it fell back to white). **Vertex colors** (`COLOR_0`, float or normalized byte/short, VEC3/VEC4) are read into per-vertex colors — untextured vertex-colored meshes (MagicaVoxel exports, vertex-painted models) render correctly. Factor, vertex color, and texture compose (`factor × vertexColor × texel`), and work under lighting. -- **3D mesh lighting** — `Light3d`, a manipulable light managed exactly like `Light2d`: it's a world `Renderable`, so `app.world.addChild(new Light3d({ direction, color, intensity }))` adds it and the active stage auto-tracks it (remove it from the world to turn it off — no global, no separate lighting object). Types: `"directional"` (a sun, half-Lambert diffuse) and `"ambient"` (a flat fill). Loading a glTF scene adds its authored `KHR_lights_punctual` directional lights (plus a soft ambient fill) automatically, so meshes are lit by the same sun set up in the authoring tool. Fields are mutable, so a light can be animated at runtime (e.g. a day/night cycle rotating `direction`). Meshes opt in via `mesh.lit` and render through a dedicated `LitMeshBatcher`; standalone unlit meshes keep the lean path and pay nothing for lighting. Directional + ambient this release (point/spot are parsed but not yet shaded). -- **glTF `KHR_materials_unlit`** — materials flagged with the extension bake their own lighting and are rendered fullbright (not shaded again), even in a lit scene. A very common stylized workflow (baked lighting in the texture); honoring it avoids double-lighting. Applied per primitive. -- **`Mesh` `alphaCutoff` setting + glTF alpha cutout** — a hard alpha cutout: fragments whose final alpha falls below the threshold are `discard`ed in the mesh shader, for crisp foliage / fences / chain-link / decals with no blending or back-to-front sorting. The glTF loader sets it from a material's `alphaMode: "MASK"` (using `alphaCutoff`, default `0.5`); `OPAQUE` / `BLEND` materials are unaffected. `0` (the default) disables the cutout. WebGL mesh path only. -- **`Mesh` `emissive` setting + glTF/OBJ emissive** — a self-illumination color added on top of the lit/unlit result so a surface glows regardless of scene lights (neon, lava, screens, glowing eyes). The glTF loader sets it from a material's `emissiveFactor` (scaled by `KHR_materials_emissive_strength` for HDR glow); the OBJ loader from an MTL's `Ke`. Composes with diffuse, so a black-diffuse + emissive material glows on a dark mesh. Omitted / all-zero → no emission (the lean path). WebGL mesh path only; emissive *textures* (`emissiveTexture` / `map_Ke`) are not yet supported. +- **3D mesh lighting** — `Light3d`, a manipulable light managed exactly like `Light2d`: it's a world `Renderable`, so `app.world.addChild(new Light3d({ direction, color, intensity }))` adds it and the active stage auto-tracks it (remove it from the world to turn it off — no global, no separate lighting object). Types: `"directional"` (a sun, half-Lambert diffuse) and `"ambient"` (a flat fill). +- **glTF `KHR_materials_unlit`** — materials flagged with the extension bake their own lighting and are rendered fullbright (not shaded again), even in a lit scene. A very common stylized workflow (baked lighting in the texture); honoring it avoids double-lighting. +- **`Mesh` `alphaCutoff` setting + glTF alpha cutout** — a hard alpha cutout: fragments whose final alpha falls below the threshold are `discard`ed in the mesh shader, for crisp foliage / fences / chain-link / decals with no blending or back-to-front sorting. The glTF loader sets it from a material's `alphaMode: "MASK"` (using `alphaCutoff`, default `0.5`); `OPAQUE` / `BLEND` materials are unaffected. +- **`Mesh` `emissive` setting + glTF/OBJ emissive** — a self-illumination color added on top of the lit/unlit result so a surface glows regardless of scene lights (neon, lava, screens, glowing eyes). The glTF loader sets it from a material's `emissiveFactor` (scaled by `KHR_materials_emissive_strength` for HDR glow); the OBJ loader from an MTL's `Ke`. - **`Mesh.getBounds3d()`** — the mesh's world-space `AABB3d` (the 3D analog of `getBounds()`, which only describes a flat 2D box). Powers the debug-plugin's new 3D bounding-box wireframe overlay. - **`Camera3d.worldToScreen(world, out?)`** — project a world point to screen-pixel coordinates (perspective divide included); returns `null` for points behind the camera. Useful for HUD elements pinned to 3D objects, picking, and debug overlays. - **`AABB3d`** now exported, with `AABB3d.fromVertices(src, count, matrix?)` to build a box from a flat vertex buffer (delegates to the new `transformedBounds`). -- **`textureFilter` application setting — texture filtering decoupled from `antiAlias`** (WebGL). `antiAlias` conflated two things: polygon-edge MSAA *and* texture sampling smoothness. The new `textureFilter` setting (`"auto"` / `"nearest"` / `"linear"`, default `"auto"`) separates the texture half, so you can pick them independently — smooth textures with no MSAA, or crisp pixel-art textures *with* MSAA edges. `"auto"` follows `antiAlias` (unchanged behavior); a `Mesh`'s own `textureFilter` still overrides per-mesh. Runtime `renderer.setTextureFilter(mode)` + backend-neutral `renderer.getDefaultTextureFilter()` (the 2D Canvas renderer has no per-texture filtering and ignores it). Default `"auto"` → byte-identical to previous behavior. +- **`textureFilter` application setting — texture filtering decoupled from `antiAlias`** (WebGL). `antiAlias` conflated two things: polygon-edge MSAA *and* texture sampling smoothness. - **Mesh `lit` / `normals` settings** and per-vertex world-space normal projection for the Camera3d lighting path. - **`Renderable.applyAnchorTransform`** (default `true`) — new flag controlling whether `preDraw` applies the `anchorPoint` offset to the renderer transform. Defaults to the existing behavior; `Mesh` sets it `false` on the `Camera3d` world-space path (a 3D mesh is positioned by its transform and has no anchor box, so the normalized offset must not leak into the shared mesh view matrix). - **`Mesh` supports meshes with more than 65,535 vertices** — a `Uint32Array` index buffer is preserved as-is instead of being coerced to `Uint16Array`, so high-poly meshes (e.g. large glTF nodes) no longer have their indices silently truncated. -- **`Sprite3d` — sprites in a 3D scene** — the 3D counterpart of `Sprite`: a textured quad rendered under a `Camera3d`, for the 2.5D workflow (characters, pickups, foliage, signs, particles). Its headline feature is **billboarding** via `billboard: false` (fixed orientation), `true` / `"cylindrical"` (faces the camera but stays upright — the 2.5D default), or `"spherical"` (faces the camera on all axes). As a thin `Mesh` subclass it rides the same world-space pipeline (depth testing, frustum culling) and material features (`lit`, `emissive`, `alphaCutoff`). **Frame animation** works through the exact same API as `Sprite` (`framewidth`/`frameheight` spritesheets or a packed `TextureAtlas`; `addAnimation` / `setCurrentAnimation` / `play` / `pause` / `stop`), mapping the current frame onto the quad — including packer **rotated** and **trimmed** atlas regions, at full parity with the 2D `Sprite`. Since the mesh pass is opaque (no alpha blending), `alphaCutoff` defaults to `0.5` so a sprite's transparent background cuts out cleanly (correct depth, no sorting); pass `alphaCutoff: 0` for a fully-opaque quad. `flipX()` / `flipY()` (and `flipX`/`flipY` settings) mirror the sprite — handy for facing a character left/right — and work across all billboard modes and rotated/trimmed atlas regions. Billboarding follows the same up/forward convention as a loaded glTF scene, so the two compose without flipping. New **Billboard Sprites** example (no external assets). Billboarding has no effect under a 2D `Camera2d` (use `Sprite`). -- **`FrameAnimation` — shared frame-animation engine** — the sprite-sheet animation logic (definitions, frame timing, looping, chaining) is now a standalone engine driving both `Sprite` (2D) and `Sprite3d` (3D) through one implementation, so the two never diverge. Each host applies the resolved frame to its own geometry (`Sprite` swaps its sub-texture / anchor; `Sprite3d` remaps UVs). The public `Sprite` API (`anim`, `current`, `animationspeed`, `animationpause`, every animation method) is unchanged — now surfaced as accessors onto the engine. +- **`Sprite3d` — sprites in a 3D scene** — the 3D counterpart of `Sprite`: a textured quad rendered under a `Camera3d`, for the 2.5D workflow (characters, pickups, foliage, signs, particles). Its headline feature is **billboarding** via `billboard: false` (fixed orientation), `true` / `"cylindrical"` (faces the camera but stays upright — the 2.5D default), or `"spherical"` (faces the camera on all axes). +- **`FrameAnimation` — shared frame-animation engine** — the sprite-sheet animation logic (definitions, frame timing, looping, chaining) is now a standalone engine driving both `Sprite` (2D) and `Sprite3d` (3D) through one implementation, so the two never diverge. Each host applies the resolved frame to its own geometry (`Sprite` swaps its sub-texture / anchor; `Sprite3d` remaps UVs). ### Fixed -- **glTF/3D meshes rendered at the wrong position under `Camera3d`** — props appeared sunk into / overlapping the surfaces they rested on, even though their parsed placement was numerically identical to the authoring tool. `Renderable.preDraw` was baking each mesh's normalized anchor-point offset (`width/2`, `height/2`) into the shared mesh batcher view matrix; since scene meshes size their bounds box per node, every mesh shifted by a different amount and lost their relative placement. The world-space mesh path now opts out of the anchor offset (see `applyAnchorTransform`), so meshes land exactly where the authoring tool put them. -- **`Camera3d` culled sizeless grouping containers (and their whole subtree)** — `Camera3d.isVisible` derived a bounding-sphere radius of `√(w²+h²)/2` from the object's bounds. A container with no intrinsic size has infinite/cleared bounds, making the radius `NaN`; `intersectsSphere(_, NaN)` is `false`, so the container was reported invisible and its children were never updated *or* drawn. Such a container can't be frustum-culled meaningfully and is now always visible (children are culled individually), matching `Camera2d`. This is what kept a nested `GLTFModel` rig from rendering under a 3D camera. +- **glTF/3D meshes rendered at the wrong position under `Camera3d`** — props appeared sunk into / overlapping the surfaces they rested on, even though their parsed placement was numerically identical to the authoring tool. `Renderable.preDraw` was baking each mesh's normalized anchor-point offset (`width/2`, `height/2`) into the shared mesh batcher view matrix; since scene meshes size their bounds box per node, every mesh shifted by a different amount and lost their relative placement. +- **`Camera3d` culled sizeless grouping containers (and their whole subtree)** — `Camera3d.isVisible` derived a bounding-sphere radius of `√(w²+h²)/2` from the object's bounds. A container with no intrinsic size has infinite/cleared bounds, making the radius `NaN`; `intersectsSphere(_, NaN)` is `false`, so the container was reported invisible and its children were never updated *or* drawn. ### Performance - **Allocation-free glTF animation pose path** — sampling and re-posing an animated `GLTFModel` each frame allocates nothing: matrix composition and multiplication write in place (`composeTRSInto` / `multiplyMatrixInto`) into preallocated per-node world buffers, and the keyframe sampler reuses scratch vectors. No per-frame GC churn even for dense, many-node rigs. -- **Allocation-free mesh batching** — `MeshBatcher.addMesh` dedup'd vertices per chunk with a `Map` that was `clear()`ed every chunk; V8 drops a `Map`'s backing table on `clear()`, so re-filling it reallocated as it grew — producing garbage proportional to vertex count (megabytes/sec of GC churn on a dense scene). Replaced with a "versioned" typed-array remap (a per-chunk stamp invalidates all entries in O(1), arrays grow once and are reused), so a re-drawn static mesh now allocates nothing per frame. Measured ~30% performance improvement and ~7× less garbage on a dense (~158k-vertex) scene. Benefits all 3D mesh rendering. +- **Allocation-free mesh batching** — `MeshBatcher.addMesh` dedup'd vertices per chunk with a `Map` that was `clear()`ed every chunk; V8 drops a `Map`'s backing table on `clear()`, so re-filling it reallocated as it grew — producing garbage proportional to vertex count (megabytes/sec of GC churn on a dense scene). Replaced with a "versioned" typed-array remap (a per-chunk stamp invalidates all entries in O(1), arrays grow once and are reused), so a re-drawn static mesh now allocates nothing per frame. -## [19.7.1] (melonJS 2) - _2026-06-14_ +## [19.7.1] (melonJS 2) - _2026-06-11_ ### Fixed -- WebGL context restore left `blendFunc`/`blendEquation` at driver defaults when the cached blend mode matched the requested one — `setBlendMode()`'s state cache survived the context loss and short-circuited the re-apply in the `webglcontextrestored` handler. Scenes drawing exclusively with one blend mode (e.g. all-PMA "normal") never triggered a cache miss afterwards, so transparent texels rendered opaque black until a blend-mode change. The restore handler now invalidates the cache before re-applying. (Found via the spine-plugin context-loss verification — the 19.6 hardening scenes all happened to change blend modes post-restore, masking the desync.) -- `UITextButton` crashed with a TypeError (`viewport.isDefault` on `undefined`) on every draw — its `draw()` override dropped the `viewport` argument when chaining to `Container.draw()` (#1499, regression introduced with multi-camera support in #1310). `Container.draw()` now also honors its documented-optional `viewport` parameter, so legacy subclass overrides that chain up with `super.draw(renderer)` keep working (treated as the default camera; the floating-child projection swap is skipped) -- "gpuTilemap is enabled but the active renderer is not WebGL 2" warning was emitted at every `Application` init whenever the gpuTilemap setting (default `true`) couldn't be honored — including apps that never load a TMX layer (e.g. the spine demo under Canvas). The warning is now deferred to the first `TMXLayer` that actually hits the no-WebGL2 fallback, latched once per session so multi-layer maps don't spam the console. Apps without any tilemap stay quiet. +- WebGL: fix context restore leaving `blendFunc` / `blendEquation` at driver defaults when the cached blend mode matched the requested one. `setBlendMode()`'s state cache survived the context loss and short-circuited the re-apply in the `webglcontextrestored` handler +- UI: fix `UITextButton` crashing with a TypeError (`viewport.isDefault` on `undefined`) on every draw (#1499). Its `draw()` override dropped the `viewport` argument when chaining to `Container.draw()`, a regression from the multi-camera support in #1310 +- Renderer: fix the "gpuTilemap is enabled but the active renderer is not WebGL 2" warning firing at every `Application` init whenever the setting could not be honoured, including apps that never load a TMX layer ## [19.7.0] (melonJS 2) - _2026-06-06_ **Highlights:** `Camera3d` perspective camera lands. Every batched shader now carries per-sprite depth as `vec3 aVertex`, unlocking 3D-projected sprites and meshes. Backward compatible with existing 2D code. ### Added -- **`Camera3d`** — perspective camera extending `Camera2d` with `fov`, `aspect`, `pitch`, `yaw`, `followOffset`, `lookAhead`. Drop-in for `Stage.cameras`. Opt in via `new Application(w, h, { cameraClass: Camera3d })`, `Stage({ cameraClass: Camera3d })`, or `Stage({ cameras: [new Camera3d(...)] })`. Y-down + +Z forward, matching engine conventions. -- **`Octree` broadphase** — 3D spatial subdivision sibling to `QuadTree`. `World.broadphase` is now reactive: under `sortOn === "depth"` (set automatically by `Camera3d.defaultSortOn`) it's an `Octree`; under any 2D sortOn it stays a `QuadTree`. The `world.sortOn` setter swaps the broadphase across 2D↔3D crossings, so transitions like loading-screen Camera2d → in-game Camera3d are transparent. Octree mirrors QuadTree's surface (`insert`, `remove`, `retrieve`, `insertContainer`, `clear`, …) and adds region queries: `queryAABB(aabb)`, `querySphere(cx, cy, cz, r)` / `querySphere(sphere)`, `queryFrustum(planes)`, `queryRay(from, dir, tMax)`. AfterBurner showcase migrated its hand-rolled `O(N²)` sphere collision loop to `querySphere` via the adapter surface. -- **`Sphere` geometry primitive** (`new Sphere(x, y, z, r)`) — first-class 3D shape under `geometries/`, sibling to `Rect` / `Ellipse` / `Polygon`. Methods: `contains(point)`, `overlaps(other)`, `overlapsAABB(aabb)`, `getBounds()`, `setShape`, `clear`, `clone` (pooled via `spherePool`). Used as the canonical 3D query shape across the engine — `adapter.querySphere(sphere)` and `Octree.querySphere(sphere)` accept it directly. Not added to the `BodyShape` union (3D physics is out of scope today). -- **`Camera3d.queryVisible(world, out?)`** — bulk frustum cull. Returns every renderable whose octant overlaps the current frustum via `Octree.queryFrustum`. Use this as a broadphase pass before per-renderable `isVisible` narrow culling on dense 3D scenes. Returns `[]` under a 2D broadphase. +- **`Camera3d`** — perspective camera extending `Camera2d` with `fov`, `aspect`, `pitch`, `yaw`, `followOffset`, `lookAhead`. Drop-in for `Stage.cameras`. +- **`Octree` broadphase** — 3D spatial subdivision sibling to `QuadTree`. `World.broadphase` is now reactive: under `sortOn === "depth"` (set automatically by `Camera3d.defaultSortOn`) it's an `Octree`; under any 2D sortOn it stays a `QuadTree`. +- **`Sphere` geometry primitive** (`new Sphere(x, y, z, r)`) — first-class 3D shape under `geometries/`, sibling to `Rect` / `Ellipse` / `Polygon`. Methods: `contains(point)`, `overlaps(other)`, `overlapsAABB(aabb)`, `getBounds()`, `setShape`, `clear`, `clone` (pooled via `spherePool`). +- **`Camera3d.queryVisible(world, out?)`** — bulk frustum cull. Returns every renderable whose octant overlaps the current frustum via `Octree.queryFrustum`. - **`AABB3d`** — minimal 3D axis-aligned bounding box (`min`, `max`, `contains`, `overlaps`, `overlapsSphere`, `isFinite`, …). Used by `Octree` as its bounding primitive; exposed for user code that needs to reason about 3D regions. -- **`PhysicsAdapter.querySphere?`** — optional 3D sphere region query. Two call shapes: `querySphere(center, radius)` (loose) and `querySphere(sphere)` (packaged). `BuiltinAdapter` implements both; 2D-only adapters (matter, planck) omit. Narrow-phase is a centre-distance test against each candidate's `getAbsolutePosition()`. -- **`math.lerp(a, b, t)`** — scalar linear interpolation. Not clamped — values of `t` outside `[0, 1]` extrapolate. Existing `Vector2d/3d/Observable*.lerp` reroute through this for a single source of truth. -- **`math.damp(current, target, lambda, dt)`** — frame-rate independent exponential damping toward a target (Three.js `MathUtils.damp` parity). Fixes the "lerp smoothing is broken" footgun: `damp(x, target, λ, dt)` produces the same convergence after the same total elapsed time regardless of how `dt` was split across frames. `Vector2d.damp(target, lambda, dt)` and `Vector3d.damp(target, lambda, dt)` (including ObservableVector2d/3d) are the vector overrides. -- **`PhysicsAdapter.raycast3d?`** — optional 3D variant of `raycast`. Capability-gated by `AdapterCapabilities.raycasts3d`. `BuiltinAdapter` implements it via `Octree.queryRay` (slab AABB pruning) + per-candidate ray-vs-bounding-sphere narrow phase (sphere uses the bounds half-diagonal, matching `Camera3d.isVisible`'s circumradius convention). Matter / Planck adapters declare `raycasts3d: false` and omit the method. User-facing call site: `world.adapter.raycast3d?(from, to) ?? null`. +- **`PhysicsAdapter.querySphere?`** — optional 3D sphere region query. Two call shapes: `querySphere(center, radius)` (loose) and `querySphere(sphere)` (packaged). +- **`math.lerp(a, b, t)`** — scalar linear interpolation. Not clamped — values of `t` outside `[0, 1]` extrapolate. +- **`math.damp(current, target, lambda, dt)`** — frame-rate independent exponential damping toward a target (Three.js `MathUtils.damp` parity). Fixes the "lerp smoothing is broken" footgun: `damp(x, target, λ, dt)` produces the same convergence after the same total elapsed time regardless of how `dt` was split across frames. +- **`PhysicsAdapter.raycast3d?`** — optional 3D variant of `raycast`. Capability-gated by `AdapterCapabilities.raycasts3d`. - **`Mesh` under Camera3d** — world-space draw path projects vertices on the GPU; triangle winding is reversed lazily for correct back-face culling under the Y-flip. Known limitation: `Light2d` is 2D-only. - **`Frustum`** — perspective `fov` / `aspect` / `near` / `far` + projection matrix; used internally by `Camera3d`. -- **`ApplicationSettings.cameraClass` / `StageSettings.cameraClass`** — declare the default camera class. Per-stage overrides the app-level. Defaults to `Camera2d`. +- **`ApplicationSettings.cameraClass` / `StageSettings.cameraClass`** — declare the default camera class. Per-stage overrides the app-level. - **`renderer.setDepth(depth)`** — state setter for the depth attribute (mirrors `setTint`). `Renderable.preDraw` forwards `this.depth` automatically. - **Per-sprite depth on the GPU** — all batched draw paths (`Quad`, `LitQuad`, `Primitive`, GPU TMX) carry `.depth` as the z component. Under the default ortho projection z has no visual effect. - **`event.GPU_TEXTURE_CACHE_RESET`** — emitted by the texture cache after a unit-reassignment reset so subscribers can drop their `texture → unit` tracking. Renderer-agnostic name. -- **`event.RENDER_TARGET_CHANGED`** — emitted by the WebGL renderer when the active framebuffer's attachments change identity (frame-start `clear()`, non-camera FBO bind via `clearRenderTarget()`, post-effect FBO unbind via `endPostEffect()`). Batchers with target-scoped state subscribe to invalidate it — `MeshBatcher` uses it to re-arm its lazy depth clear; other batchers don't subscribe and pay nothing. Renderer-agnostic name; the future WebGPU renderer will emit the same event when it begins a new render pass. -- **Multi-material OBJ** — `Mesh` draws OBJ files with multiple `usemtl` directives + an MTL, baking per-material `Kd` into a per-vertex color buffer at construction. No extra draw calls per material. `mesh.tint` multiplies on top. Per-material textures (`map_Kd` per material) not supported — single texture binding per mesh. New `Multi-material OBJ` example. -- **`device.setAutoFocus(enable)`** (#1486) — function setter for the `device.autoFocus` flag, finally giving user code a way to disable the engine's "bring the window to the front on visibility restore" behaviour. Direct assignment (`me.device.autoFocus = false`) always threw a `TypeError` because ESM namespace-import bindings are read-only externally; the function setter routes through the module-local binding so the mutation actually takes effect. Same shape as `enableSwipe` and the other function setters already in `device`. +- **`event.RENDER_TARGET_CHANGED`** — emitted by the WebGL renderer when the active framebuffer's attachments change identity (frame-start `clear()`, non-camera FBO bind via `clearRenderTarget()`, post-effect FBO unbind via `endPostEffect()`). Batchers with target-scoped state subscribe to invalidate it — `MeshBatcher` uses it to re-arm its lazy depth clear; other batchers don't subscribe and pay nothing. +- **Multi-material OBJ** — `Mesh` draws OBJ files with multiple `usemtl` directives + an MTL, baking per-material `Kd` into a per-vertex color buffer at construction. No extra draw calls per material. +- **`device.setAutoFocus(enable)`** (#1486) — function setter for the `device.autoFocus` flag, finally giving user code a way to disable the engine's "bring the window to the front on visibility restore" behaviour. Direct assignment (`me.device.autoFocus = false`) always threw a `TypeError` because ESM namespace-import bindings are read-only externally; the function setter routes through the module-local binding so the mutation actually takes effect. ### Changed -- **`QuadTree` removed from the public package exports** (was `import { QuadTree } from "melonjs"`). The class itself still exists at `physics/broadphase/quadtree.ts` and still drives `world.broadphase` under 2D `sortOn` values — only the public re-export is dropped. The export was legacy noise from before the `world.adapter.queryAABB(rect)` / `queryRect(rect)` / `raycast(from, to)` surface existed; `QuadTree` and the new `Octree` are broadphase implementation details. Game code should use the adapter spatial-query surface (`world.adapter.queryAABB`, `world.adapter.querySphere(sphere)`, `world.adapter.raycast`, `world.adapter.raycast3d?`) rather than reaching into `world.broadphase` directly. **Migration:** any direct `import { QuadTree } from "melonjs"` should be removed — there is no replacement import; the broadphase instance is reachable as `world.broadphase` for tooling (e.g. `@melonjs/debug-plugin`), typed against the new internal `Broadphase` interface so QuadTree and Octree share a single contract. -- **`QuadTree` moved to `physics/broadphase/`** and rewritten in TypeScript (was `physics/builtin/quadtree.js`). Public-method surface unchanged — every existing `world.broadphase.insert(...)` / `retrieve(...)` call keeps working. Co-located with `Octree` and `AABB3d` under the new `physics/broadphase/` folder so the spatial primitives sit together rather than being mixed into the built-in physics namespace. -- **`aVertex` widened from `vec2` to `vec3`** across `quad-multi.vert`, `quad-multi-lit.vert`, `primitive.vert`, `orthogonal-tmxlayer.vert`. Per-vertex stride +4 bytes. Custom shaders binding by name are unaffected; `attribute vec2 aVertex;` keeps working (z dropped). -- **`VertexArrayBuffer.push()` gained a `z` parameter** between `y` and `u`. Custom `QuadBatcher` / `PrimitiveBatcher` subclasses that reimplement `addQuad` / `drawVertices` and push directly to `vertexData` need to insert `z` (default `0`) after `y`. Subclasses that delegate to `super.addQuad()` are unaffected. +- **`QuadTree` removed from the public package exports** (was `import { QuadTree } from "melonjs"`). The class itself still exists at `physics/broadphase/quadtree.ts` and still drives `world.broadphase` under 2D `sortOn` values — only the public re-export is dropped. +- **`QuadTree` moved to `physics/broadphase/`** and rewritten in TypeScript (was `physics/builtin/quadtree.js`). Public-method surface unchanged — every existing `world.broadphase.insert(...)` / `retrieve(...)` call keeps working. +- **`aVertex` widened from `vec2` to `vec3`** across `quad-multi.vert`, `quad-multi-lit.vert`, `primitive.vert`, `orthogonal-tmxlayer.vert`. Per-vertex stride +4 bytes. +- **`VertexArrayBuffer.push()` gained a `z` parameter** between `y` and `u`. Custom `QuadBatcher` / `PrimitiveBatcher` subclasses that reimplement `addQuad` / `drawVertices` and push directly to `vertexData` need to insert `z` (default `0`) after `y`. - **`Camera2d` default near/far widened from `±1000` to `±1e6`** so `depth` participating in clip-space no longer cull-clips sprites with `Container.autoDepth` enabled or Y-sort patterns on tall maps. Override per-camera for tighter z bounds. -- **Mesh rendering now clears depth once per target, not per mesh** (#1468). Each `WebGLRenderer.drawMesh` used to `gl.clear(DEPTH_BUFFER_BIT)` and toggle `DEPTH_TEST` / `BLEND` / `depthMask` on entry and exit, paying ~10 GL state calls per mesh. The new path moves all mesh-mode state ownership into `MeshBatcher` itself: `bind()` enters the mode (depth state + lazy per-target depth clear), `unbind()` restores non-mesh defaults, a new `event.RENDER_TARGET_CHANGED` broadcast re-arms the lazy clear when the active framebuffer changes identity. Consecutive mesh draws pay zero state-toggle cost between them. Matches Three.js's well-proven approach — the GPU's `LEQUAL` depth test resolves inter-mesh occlusion per pixel against the accumulated depth attachment, no per-mesh isolation needed. The renderer doesn't know anything mesh-specific anymore, so the WebGPU port stays clean (same lifecycle, different backend). **Behavioural change for one edge case:** under the old path two intersecting meshes drawn in painter-wrong order would silently swap (newer mesh's per-mesh clear wiped the older's depth → newer drew on top regardless of distance). Now the GPU's depth test does the right thing per pixel — closer mesh wins regardless of draw order. AfterBurner-scale scenes (~5 mesh draws/frame) see a marginal CPU saving; the real win is dense 3D scenes (50+ meshes) where per-mesh clear+state overhead used to scale linearly. -- **`Application` fails loudly on WebGL-required misconfiguration** (#1479). Two checks, both at construction time, both in `application.ts` — Camera3d stays pure-math, Stage untouched. **Throws** when `renderer: video.WEBGL` is requested but WebGL is unavailable (driver-blocklisted GPU, `failIfMajorPerformanceCaveat: true` on a software renderer, etc.) instead of silently falling back to Canvas. **Warns** (via `console.warn`) when `cameraClass` declares `static defaultSortOn = "depth"` (Camera3d or any subclass) but the active renderer isn't a `WebGLRenderer` — catches the `video.AUTO + cameraClass: Camera3d` combination where AUTO fell back to Canvas and the user would otherwise see a black canvas with no signal why. Warn (not throw) keeps integration tests that bypass real rendering working; the strong user-facing signal is `renderer: video.WEBGL`. **Migration:** Apps that relied on the silent `video.WEBGL` → Canvas fallback should switch to `video.AUTO` to keep the fallback semantics; Camera3d games should pair `cameraClass: Camera3d` with `renderer: video.WEBGL` to get a hard failure if WebGL isn't there. -- **`Camera2d.updateTarget` smooth follow is now frame-rate independent.** Previously `pos.lerp(target, damping)` ran a parametric per-frame fraction — same `damping = 0.1` covered 10% of the gap per frame at 30Hz, 60Hz, 120Hz or 144Hz, so wall-clock convergence sped up linearly with the player's refresh rate. Now uses `pos.damp(target, lambda, dt)` with `lambda = -ln(1 - damping) * timer.maxfps`, which recovers the legacy per-frame fraction exactly at the configured target framerate AND keeps wall-clock convergence constant if the actual frame rate drifts. **No tuning change required** — existing `damping` values keep their feel at the engine's target framerate (default 60); high-refresh users finally get the same feel the dev tuned for. Dogfoods the new `math.damp` API on melonJS's most prominent older follow path. -- **`device.platform.isMobile` no longer ORs the dead-platform regexes** (#1467). `wp` / `BlackBerry` / `Kindle` regexes were burning cycles on every page load testing for hardware nobody ships (Windows Phone EOL 2017, BB10 EOL 2016, Kindle behaves like Android anyway). The remaining chain — `/Mobi/.test(ua) || iOS || android` — covers ~99.9% of mobile traffic in 2026 per MDN. The deprecated exports themselves still compute and return; only the `isMobile` aggregate stopped consulting them. -- **`initKeyboardEvent` no longer skips listener registration on `isMobile === true`** (#1467). The gate assumed "mobile = no physical keyboard" — invalid for iPads with Magic Keyboard (now correctly detected per the iPad fix above), Samsung DeX, ChromeOS tablet mode, Bluetooth-keyboard-on-phone, etc. Two empty listener slots cost nothing on touch-only devices; the unbound-key path is a single map lookup that returns undefined. -- **`system/device` converted to TypeScript** (#1467, renamed from `device.js` → `device.ts`). 945 lines / 53 exports / 56 JSDoc blocks of feature-detection helpers and platform plumbing now ship as a `.ts` file with native type signatures. JSDoc was already exhaustive, so the conversion is mostly mechanical — `@param {Type}` blocks become parameter annotations and `@type {Type}` constants get TS-inferred. Non-standard / legacy browser surfaces (`Document.mozFullScreenEnabled`, `Navigator.standalone` / `browserLanguage` / `userLanguage`, iOS-only `DeviceOrientationEvent.requestPermission`, deprecated `Screen.lockOrientation`, `webkitAudioContext`) are typed via narrow local intersection types declared at the top of the file. Behavioural parity verified against the full 3975-test suite; downstream call sites (`pointerevent.ts`, `application.ts`, `resize.ts`, `header.ts`, etc.) are unchanged thanks to bundler-resolution rewriting `.js` imports to `.ts` source. One small correctness improvement fell out of the conversion: `onDeviceMotion` now guards against `accelerationIncludingGravity === null` rather than crashing. -- **`Application#requestFullscreen` / `Application#exitFullscreen`** — fullscreen control finally has app-instance context. `requestFullscreen` defaults to the app's `parentElement` (the container the canvas was appended into — `getParentElement()`), so the canvas plus any sibling HUD / overlay markup inside that container go fullscreen together; accepts an optional `Element` override. No deprecated `getParent()` / global-game lookup involved — the canonical fullscreen path now reaches the canvas through the Application it was created on. `Application#isFullscreen` is a thin convenience around the (still non-deprecated) `device.isFullscreen` so the trio reads together on the app instance; the underlying probe stays on `device` because there's exactly one fullscreen state per document regardless of how many Applications are running. The two examples that wire `F` → toggle fullscreen (platformer + platformer-matter) migrate to the new API. +- **Mesh rendering now clears depth once per target, not per mesh** (#1468). Each `WebGLRenderer.drawMesh` used to `gl.clear(DEPTH_BUFFER_BIT)` and toggle `DEPTH_TEST` / `BLEND` / `depthMask` on entry and exit, paying ~10 GL state calls per mesh. +- **`Application` fails loudly on WebGL-required misconfiguration** (#1479). Two checks, both at construction time, both in `application.ts` — Camera3d stays pure-math, Stage untouched. **Throws** when `renderer: video.WEBGL` is requested but WebGL is unavailable (driver-blocklisted GPU, `failIfMajorPerformanceCaveat: true` on a software renderer, etc.) instead of silently falling back to Canvas. **Warns** (via `console.warn`) when `cameraClass` declares `static defaultSortOn = "depth"` (Camera3d or any subclass) but the active renderer isn't a `WebGLRenderer` — catches the `video.AUTO + cameraClass: Camera3d` combination where AUTO fell back to Canvas and the user would otherwise see a black canvas with no signal why. +- **`Camera2d.updateTarget` smooth follow is now frame-rate independent.** Previously `pos.lerp(target, damping)` ran a parametric per-frame fraction — same `damping = 0.1` covered 10% of the gap per frame at 30Hz, 60Hz, 120Hz or 144Hz, so wall-clock convergence sped up linearly with the player's refresh rate. Now uses `pos.damp(target, lambda, dt)` with `lambda = -ln(1 - damping) * timer.maxfps`, which recovers the legacy per-frame fraction exactly at the configured target framerate AND keeps wall-clock convergence constant if the actual frame rate drifts. **No tuning change required** — existing `damping` values keep their feel at the engine's target framerate (default 60); high-refresh users finally get the same feel the dev tuned for. +- **`device.platform.isMobile` no longer ORs the dead-platform regexes** (#1467). `wp` / `BlackBerry` / `Kindle` regexes were burning cycles on every page load testing for hardware nobody ships (Windows Phone EOL 2017, BB10 EOL 2016, Kindle behaves like Android anyway). +- **`initKeyboardEvent` no longer skips listener registration on `isMobile === true`** (#1467). The gate assumed "mobile = no physical keyboard" — invalid for iPads with Magic Keyboard (now correctly detected per the iPad fix above), Samsung DeX, ChromeOS tablet mode, Bluetooth-keyboard-on-phone, etc. +- **`system/device` converted to TypeScript** (#1467, renamed from `device.js` → `device.ts`). 945 lines / 53 exports / 56 JSDoc blocks of feature-detection helpers and platform plumbing now ship as a `.ts` file with native type signatures. JSDoc was already exhaustive, so the conversion is mostly mechanical — `@param {Type}` blocks become parameter annotations and `@type {Type}` constants get TS-inferred. +- **`Application#requestFullscreen` / `Application#exitFullscreen`** — fullscreen control finally has app-instance context. `requestFullscreen` defaults to the app's `parentElement` (the container the canvas was appended into — `getParentElement()`), so the canvas plus any sibling HUD / overlay markup inside that container go fullscreen together; accepts an optional `Element` override. ### Deprecated -- **`device.requestFullscreen()` / `device.exitFullscreen()`** (#1467, since 19.7.0). Use `app.requestFullscreen()` / `app.exitFullscreen()` instead. The device wrappers still work for backwards compat through the 19.x line but rely on the deprecated global-game canvas lookup (`getParent()` → `game.getParentElement()`, deprecated since 18.3.0). -- **`device.platform.wp` / `BlackBerry` / `Kindle` / `android2`** (#1467). The underlying platforms are end-of-life (Windows Phone discontinued 2017, BlackBerry stopped BB10 in 2016, Android 2.x predates 2012, Kindle has negligible mobile-web share). Exports stay functional through the 19.x line for backwards compatibility — IDE warnings light up at consumer sites; removal scheduled for 20.x. For "is this a touch device?" use the existing `device.touch` flag (feature-detected via `navigator.maxTouchPoints` / pointer events). +- **`device.requestFullscreen()` / `device.exitFullscreen()`** (#1467, since 19.7.0). Use `app.requestFullscreen()` / `app.exitFullscreen()` instead. +- **`device.platform.wp` / `BlackBerry` / `Kindle` / `android2`** (#1467). The underlying platforms are end-of-life (Windows Phone discontinued 2017, BlackBerry stopped BB10 in 2016, Android 2.x predates 2012, Kindle has negligible mobile-web share). ### Fixed -- **`WebGLRenderer.createPattern` cache-key collision** (#1448). Two patterns created from the same source image with different repeat modes used to silently collide on a single GL texture unit — `TextureCache.getUnit(texture)` keyed only by the source, so the second `createPattern` call's upload trampled the first pattern's wrap state. The "leak prevention" `cache.has(image) / deleteTexture2D(...)` band-aid in `createPattern` (originally added for #1278) made it worse by actively freeing the still-live first-pattern texture. `TextureCache` now keys units by `(source, repeat)` via a nested `Map>`; each distinct repeat mode gets its own unit so `createPattern(img, "repeat-x")` followed by `createPattern(img, "repeat-y")` returns two independent, fully functional patterns. The band-aid in `createPattern` is gone — the same-`(source, repeat)` re-upload case is now handled implicitly by `uploadTexture`'s existing `boundTextures[unit]` short-circuit. Invisible to every existing TextureAtlas user (sprites, atlases, tilemaps, lights, meshes all use `repeat = "no-repeat"` → same composite key, same unit they had before). Three follow-ons landed under the same fix: (1) `MaterialBatcher.deleteTexture2D(texture)` now iterates every atlas registered under the image and tears down each one's bound GL texture before `cache.delete(image)` reclaims the units — without this, the OTHER repeats' GL textures would orphan at the freed unit slots (stale binds + leaked GL handles); (2) `TextureCache.delete(image)` frees every repeat's unit (was only freeing the first atlas's unit while wiping the entire multimap bucket); (3) the cache normalizes unknown / typo'd repeat values to `"no-repeat"` so a stray string like `"repat-x"` can't allocate its own unit indefinitely. Also lands a Canvas/WebGL parity tweak: `createPattern(image)` (no `repeat` arg) now defaults to `"no-repeat"` in both renderers — Canvas was previously throwing a DOM `TypeError` while WebGL silently defaulted via TextureAtlas's internal fallback. -- **`device.platform.iOS` / `device.platform.isMobile` now correctly identify iPads on iPadOS 13+** (#1467). Since Sept 2019, Safari on iPad has shipped the desktop Mac UA — no `iPad` token — so every modern iPad was falling through `isMobile` as desktop. The detection now layers a feature check on top of the UA regex: `navigator.platform === "MacIntel"` (Apple-frozen legacy string that persists on Apple Silicon Macs/iPads for backwards compat — NOT a CPU check) plus `navigator.maxTouchPoints > 1` (Macs don't have touchscreens; iPads do). Every internal consumer of `isMobile` (`keyboard.ts`, `application.ts`, `header.ts`) inherits the fix transparently. -- **`timer.step` is now the precise per-frame duration** (`1000 / maxfps`) instead of `Math.ceil(1000 / maxfps)`. The ceil rounded 16.667ms up to 17ms at 60fps target, undershooting the `delta / step` tick interpolation factor by ~2% under frame drops. The fast path (`delta < minstep → tick = 1`) masked it on healthy frames; only slow frames felt the discrepancy. Animations driven by the interpolation factor now advance the mathematically correct fraction under frame drops. -- **GPU TMX layer reset crash when a non-material batcher was active** (#1471). `OrthogonalTMXLayerGPURenderer.reset()` grabbed `renderer.currentBatcher` and called `deleteTexture2D` on it, but uploads always flow through the "quad" batcher (`_drawLayer`). When the previous frame left a `PrimitiveBatcher` active — e.g. the debug plugin's quadtree overlay — the reset path hit a method that doesn't exist on it and threw `TypeError: batcher.deleteTexture2D is not a function` on every stage change. Reset now pins to `batchers.get("quad")` and falls through to a manual cache cleanup if a user-supplied custom batcher doesn't expose `deleteTexture2D`. +- **`WebGLRenderer.createPattern` cache-key collision** (#1448). Two patterns created from the same source image with different repeat modes used to silently collide on a single GL texture unit — `TextureCache.getUnit(texture)` keyed only by the source, so the second `createPattern` call's upload trampled the first pattern's wrap state. +- **`device.platform.iOS` / `device.platform.isMobile` now correctly identify iPads on iPadOS 13+** (#1467). Since Sept 2019, Safari on iPad has shipped the desktop Mac UA — no `iPad` token — so every modern iPad was falling through `isMobile` as desktop. +- **`timer.step` is now the precise per-frame duration** (`1000 / maxfps`) instead of `Math.ceil(1000 / maxfps)`. The ceil rounded 16.667ms up to 17ms at 60fps target, undershooting the `delta / step` tick interpolation factor by ~2% under frame drops. +- **GPU TMX layer reset crash when a non-material batcher was active** (#1471). `OrthogonalTMXLayerGPURenderer.reset()` grabbed `renderer.currentBatcher` and called `deleteTexture2D` on it, but uploads always flow through the "quad" batcher (`_drawLayer`). - **WebGL `TextureCache` cross-batcher binding desync** — a unit-pool reset only cleared the current batcher's `boundTextures` map, leaving stale entries on every other batcher; meshes rendered as black silhouettes and bullets as pure white in mixed-batcher scenes after sustained gameplay. Fixed via the new `event.GPU_TEXTURE_CACHE_RESET` event consumed by `MaterialBatcher`. - **WebGL color-attribute NaN canonicalization on Apple Metal / ANGLE** — `MeshBatcher` color attribute switched from `UNSIGNED_BYTE × 4 normalized` to `FLOAT × 4`; `Batcher.flush` / `QuadBatcher.flush` upload packed-color bytes via `vertex.toUint8()` instead of `vertex.toFloat32()` so they survive driver canonicalization. No behavior change on drivers that don't canonicalize. - **`Stage.reset` re-applies the chosen camera's `defaultSortOn` on every reset** — covers both the loader-pinned-Camera2d → user-stage handoff and the explicit-camera (`Stage({ cameras: [new Camera3d(...)] })`) pattern. Previously distant meshes painted on top of nearer ones under perspective on a Camera3d app coming out of the default loader. @@ -310,118 +308,118 @@ **Design note:** the new `PhysicsAdapter` interface is a standardized integration path for adding physics engines to melonJS. The existing built-in SAT physics is the default adapter; any third-party engine (matter-js, planck.js / Box2D, Rapier, custom) can plug in via the same contract, keeping game code engine-agnostic. Two official integrations ship as separate packages — `@melonjs/matter-adapter` and `@melonjs/planck-adapter`. ### Added -- Physics: **`PhysicsAdapter` interface** (`packages/melonjs/src/physics/adapter.ts`) — pluggable rigid-body abstraction. The new `BuiltinAdapter` wraps the existing SAT detector + `Body` class as the default, so legacy game code continues to work unchanged. Third-party adapters are selected by passing an instance to `Application`'s `physic` setting (`new Application(w, h, { physic: new MatterAdapter() })`). The adapter contract covers body lifecycle (`addBody`/`removeBody`/`updateShape`), kinematics (`setVelocity`/`getVelocity`/`setMaxVelocity`/`applyForce`/`applyImpulse`/`setPosition`), state (`setStatic`/`setSensor`/`setCollisionMask`/`setCollisionType`/`setGravityScale`/`setFrictionAir`), queries (`isGrounded`/`rayCast`), and a `capabilities` flag set so portable code can branch on what the active engine supports (`constraints`, `continuousCollisionDetection`, `sleepingBodies`, `raycasts`, `velocityLimit`, `isGrounded`). -- Physics: **`@melonjs/matter-adapter`** — first official third-party adapter, packaged separately. Wraps matter-js's `Engine` and translates the `PhysicsAdapter` contract to matter's API (`Matter.Body`, `Matter.Composite`, `Matter.Events`). Forwards `collisionStart` / `collisionActive` / `collisionEnd` matter events to the renderable hooks with matter-native response shape (`{ a, b, normal, depth, pair }`). Honors `isSensor`, `fixedRotation` (default true, matching legacy SAT assumption), per-body `gravityScale` (emulated via a counter-force since matter only has engine-level gravity), per-body `maxVelocity` clamping, and `restitution` / `frictionAir` / `density` straight through. Body handle is a `Matter.Body`; legacy `me.Body` API is **not** available under this adapter (use the portable adapter methods instead). Capabilities: constraints ✓, CCD ✓, sleeping bodies ✓, raycasts ✓, velocity limit ✓ (emulated), isGrounded ✓ (heuristic). -- Physics: **`@melonjs/planck-adapter`** — second official third-party adapter, packaged separately. Wraps planck.js (a faithful JavaScript/TypeScript port of Box2D 2.3.0) and translates the `PhysicsAdapter` contract to planck's API (`planck.World`, `planck.Body`, `planck.Fixture`, `planck.Contact`). Forwards `begin-contact` / `end-contact` events to the renderable hooks plus an active-pair pass each step. Pixel ↔ meter unit conversion at the adapter boundary via the `pixelsPerMeter` option (default `32`); positions, velocities, gravity (in px/s²), forces, AABB queries, and raycasts all stay in pixel space at the engine surface. Honors `isSensor`, `fixedRotation` (default true), per-body `gravityScale` (NATIVE in planck — no emulation), per-body `maxVelocity` clamping, and `friction` / `restitution` / `density` per-fixture. Body handle is a `planck.Body` with the portable `PhysicsBody` helpers spliced on; legacy `me.Body` API is **not** available. Multi-shape `BodyDefinition.shapes` becomes a single planck body with multiple fixtures. Solver iteration counts (`velocityIterations`, `positionIterations`) and `subSteps` are exposed on the constructor. Capabilities: constraints ✓ (native joints), CCD ✓ (per-body `setBullet`), sleeping bodies ✓ (native), raycasts ✓ (native), velocity limit ✓ (emulated post-step clamp), isGrounded ✓ (contact-list scan). -- Physics: **Collision lifecycle hooks** — `onCollisionStart`, `onCollisionActive`, `onCollisionEnd` on every `Renderable`, dispatched consistently by both adapters with a **receiver-symmetric contract**: `response.a === this`, `response.b === other`, `response.normal` is the unit MTV of the receiver (`normal.y < -0.7` ⇒ "push me up to escape", the cross-adapter stomp idiom). `response.depth` is the penetration scalar. `response.pair` exposes the engine-native contact pair (`Matter.Pair` under matter, `undefined` under builtin). Dispatched **1× per pair per side per frame** via `_pairKey` / `_frameSeen` dedup. The legacy `onCollision` handler is kept unchanged (19.4 cadence: 2× per frame for dyn-dyn pairs, fixed `a`/`b` per pair, SAT-shape response). `onCollisionStart` fires exactly once when two bodies begin contact; `onCollisionActive` is the modern equivalent of `onCollision` and fires every step while in contact (and **supersedes `onCollision` per-renderable** — if you define `onCollisionActive`, the legacy `onCollision` is not dispatched on that renderable, since they're the same handler in two API styles); `onCollisionEnd` fires once on separation. Under the built-in adapter the start/end events are synthesized via a frame-diff over the SAT detector's per-frame overlap reports; under matter they map directly to matter's own collision events. -- Physics: **`CollisionResponse` interface in `physics/adapter.ts`** — published type describing the modern handler contract. Required fields: `a`, `b`, `normal: { x, y }`, `depth: number`. Optional `pair?: unknown` (engine-native pair shape — `Matter.Pair` / future `ContactPair` / `b2Contact` / etc.). Builtin's modern handlers also expose SAT legacy fields (`overlap`, `overlapN`, `overlapV`, marked `@deprecated`) on the symmetric views for migration; matter-adapter leaves them `undefined`. -- Physics: **`adapter.setSensor(renderable, isSensor)`** — runtime toggle for non-resolving sensor bodies. Collision events still fire but the bodies don't push each other out, matching matter's `isSensor` semantics. The built-in adapter uses the new `Body.isSensor` field; the legacy `respondToCollision` push-out is skipped in `Detector.collisions()` when either body is a sensor. The matter adapter maps directly to `Matter.Body.isSensor`. +- Physics: **`PhysicsAdapter` interface** (`packages/melonjs/src/physics/adapter.ts`) — pluggable rigid-body abstraction. The new `BuiltinAdapter` wraps the existing SAT detector + `Body` class as the default, so legacy game code continues to work unchanged. +- Physics: **`@melonjs/matter-adapter`** — first official third-party adapter, packaged separately. Wraps matter-js's `Engine` and translates the `PhysicsAdapter` contract to matter's API (`Matter.Body`, `Matter.Composite`, `Matter.Events`). +- Physics: **`@melonjs/planck-adapter`** — second official third-party adapter, packaged separately. Wraps planck.js (a faithful JavaScript/TypeScript port of Box2D 2.3.0) and translates the `PhysicsAdapter` contract to planck's API (`planck.World`, `planck.Body`, `planck.Fixture`, `planck.Contact`). +- Physics: **Collision lifecycle hooks** — `onCollisionStart`, `onCollisionActive`, `onCollisionEnd` on every `Renderable`, dispatched consistently by both adapters with a **receiver-symmetric contract**: `response.a === this`, `response.b === other`, `response.normal` is the unit MTV of the receiver (`normal.y < -0.7` ⇒ "push me up to escape", the cross-adapter stomp idiom). `response.depth` is the penetration scalar. +- Physics: **`CollisionResponse` interface in `physics/adapter.ts`** — published type describing the modern handler contract. Required fields: `a`, `b`, `normal: { x, y }`, `depth: number`. +- Physics: **`adapter.setSensor(renderable, isSensor)`** — runtime toggle for non-resolving sensor bodies. Collision events still fire but the bodies don't push each other out, matching matter's `isSensor` semantics. - Physics: **`Body.isSensor`** property on the legacy `Body` class (default `false`) — backs the built-in adapter's `setSensor` implementation. Also honored by the SAT detector: a sensor body still emits collision events to both sides but skips the push-out response. - Physics: **`adapter.setAngle(renderable, angle)`** — no-op stub on the built-in adapter (legacy `Body` is axis-aligned) so portable code that calls `adapter.setAngle?.(theta)` doesn't need to branch by adapter. Functional under matter. -- Physics: **Body method parity** — eight new method-style setters added to the legacy `Body` class as thin wrappers around its existing fields: `setVelocity(x, y)`, `getVelocity(out?)`, `applyForce(x, y)` (accumulating, matches matter's semantics), `applyImpulse(x, y)` (`dv = J / m`), `setSensor(isSensor = true)`, `setMass(m)`, `setBounce(r)`, and `setGravityScale(s)`. The matter-adapter bolts the same eight methods onto `Matter.Body` at registration time, plus three more — `setStatic(b)`, `setCollisionMask(m)`, `setCollisionType(t)` — to mirror legacy `Body` setters that already existed but weren't on `Matter.Body`. Net result: `renderable.body.setX(...)` is portable across either adapter for velocity, force, impulse, sensor flag, mass, bounce, gravity scale, static flag, and collision filter. Legacy direct-field mutation (`body.vel.set(...)`, `body.force.x = X`, `body.bounce = 0.5`) still works under Builtin. -- Physics: **`adapter.matter` namespace escape hatch** — `MatterAdapter` instances expose the entire `matter-js` module as `adapter.matter` (typed `typeof Matter`). Lets game code reach `Matter.Constraint`, `Matter.Composite`, `Matter.Bodies`, `Matter.Events`, `Matter.Query`, etc. without adding `matter-js` as a direct dependency. Documented as matter-only escape hatch; code that uses it is no longer adapter-portable. JSDoc links to the official matter-js documentation at brm.io/matter-js/docs. -- Physics: **`adapter.getBodyAABB(renderable, out)` / `adapter.getBodyShapes(renderable)`** — adapter-side debug-visualization surface. Both methods return body geometry in **renderable-local coordinates** regardless of the engine's internal frame (builtin Body already stores local, matter stores world and the adapter subtracts `renderable.pos`). Lets ecosystem tools — most notably `@melonjs/debug-plugin` 16.x — draw hitboxes and velocity vectors correctly under any adapter without poking at body internals (the velocity overlay reuses the existing required `adapter.getVelocity()` method, no new API needed there). Both methods are optional on the interface (`?:`), so adapters that don't implement them simply opt out of debug visualization. +- Physics: **Body method parity** — eight new method-style setters added to the legacy `Body` class as thin wrappers around its existing fields: `setVelocity(x, y)`, `getVelocity(out?)`, `applyForce(x, y)` (accumulating, matches matter's semantics), `applyImpulse(x, y)` (`dv = J / m`), `setSensor(isSensor = true)`, `setMass(m)`, `setBounce(r)`, and `setGravityScale(s)`. The matter-adapter bolts the same eight methods onto `Matter.Body` at registration time, plus three more — `setStatic(b)`, `setCollisionMask(m)`, `setCollisionType(t)` — to mirror legacy `Body` setters that already existed but weren't on `Matter.Body`. +- Physics: **`adapter.matter` namespace escape hatch** — `MatterAdapter` instances expose the entire `matter-js` module as `adapter.matter` (typed `typeof Matter`). Lets game code reach `Matter.Constraint`, `Matter.Composite`, `Matter.Bodies`, `Matter.Events`, `Matter.Query`, etc. without adding `matter-js` as a direct dependency. +- Physics: **`adapter.getBodyAABB(renderable, out)` / `adapter.getBodyShapes(renderable)`** — adapter-side debug-visualization surface. Both methods return body geometry in **renderable-local coordinates** regardless of the engine's internal frame (builtin Body already stores local, matter stores world and the adapter subtracts `renderable.pos`). - TMX: **shape factory validation** — `createShapeObject` now throws a descriptive error pointing at the offending Tiled object (id, name, type) when `getDefaultShape` returns null or an empty array. Previously poisoned `bodyDef.shapes` with `[undefined]` and crashed downstream with a cryptic "cannot read .pos" message from inside the adapter at body construction time. -- WebGL: **`ShineEffect` built-in shader effect** (`packages/melonjs/src/video/webgl/effects/shine.js`) — a pre-rolled animated shine/glint shader. Constructor accepts `{ color, speed, width, intensity, angle, bands, pulseDepth }` and exposes `setTime(t)` for driving the animation from a game-update event. Used in the matter platformer's coins; available for any sprite that wants an animated highlight (rare-item glow, achievement effect, etc.). +- WebGL: **`ShineEffect` built-in shader effect** (`packages/melonjs/src/video/webgl/effects/shine.js`) — a pre-rolled animated shine/glint shader. Constructor accepts `{ color, speed, width, intensity, angle, bands, pulseDepth }` and exposes `setTime(t)` for driving the animation from a game-update event. - Examples: two new physics demos: - **`Matter Platformer`** — same gameplay as the canonical platformer (same Tiled map, same entity code) but running on `@melonjs/matter-adapter`. Demonstrates the collision lifecycle hooks (coin pickup as `onCollisionStart`), kinematic character-style slope snap, one-way platforms via runtime `setSensor`, and the new `ShineEffect` on coins. The only file-level difference between this and the legacy platformer is the `physic: new MatterAdapter(...)` setting in `createGame.ts`. - **`Pool (Matter)`** — top-down 8-ball pool driven by matter. 16 dynamic bodies simultaneously simulating, drag-back-to-aim cue with retraction-as-power feedback (no UI aim line — the cue itself is the aim guide, matching real pool), procedural cue stick rendering, ball drop shadows, pocket sensors that sink balls (cue ball respawns at the head spot, numbered balls are removed via `removeChild` and tear down their matter body via the adapter). Rails are cut short at the corners so balls can actually reach the corner-pocket sensors. Includes pocket-entry validation (a ball only sinks if its velocity is closing on the pocket center — stops glancing rail rolls from getting vacuumed in), correct triangular close-packing for the 15-ball rack (`dx = R·√3`, `dy = 2R`), and a sliding-vs-rolling `frictionAir` swap on each ball (slide-friction kicks in above a speed threshold, rolling-friction below — so break shots scrub momentum hard at first, then settle into a long roll). -- Physics: **`PhysicsBody` interface** (`packages/melonjs/src/physics/adapter.ts`) — portable body handle type that lists the methods every adapter guarantees on `renderable.body` (`setVelocity` / `getVelocity` / `applyForce` / `applyImpulse` / `setStatic` / `setCollisionMask`, plus optional `setCollisionType` / `setSensor` / `setMass` / `setBounce` / `setGravityScale`, plus live `collisionType` / `collisionMask` field aliases). `PhysicsAdapter.addBody` is now typed as `(…) => PhysicsBody` (was `unknown`), `Renderable.body` is documented as `PhysicsBody` rather than the legacy `Body` class. Concrete adapters publish their adapter-specific body type (`MatterAdapter.Body`, `BuiltinAdapter.Body`); user code casts to those only when reaching for engine-native fields (matter's `frictionAir`, etc.) so the matter dependency stays behind the adapter boundary. -- Physics: **`MatterAdapter.Body` namespace-merged type** — concrete body handle returned by the matter adapter, defined as `ReturnType & PhysicsBody`. Reachable as `MatterAdapter.Body` without importing `matter-js` directly. The `helpers` object spliced onto each body at registration is now typed `Omit` — TypeScript fails the matter-adapter build the moment the helpers drift from the portable contract (a new method on `PhysicsBody` not implemented in helpers fails compilation; a stale method removed from `PhysicsBody` flags the helper as orphaned). -- Physics: **`BodyDefinition.friction`** — portable surface coefficient of friction (matter's `body.friction`). Determines tangential velocity transfer between contacting bodies — combined with rotation, produces "throw" between balls and "english" off walls. Distinct from `frictionAir` (per-step drag with no contact required). MatterAdapter routes it to `body.friction`; BuiltinAdapter ignores it (SAT solver doesn't model surface friction at contacts). -- Physics: **`MatterAdapter` substep support** — new `subSteps?: number` option on `MatterAdapterOptions` (default 1). Runs `Matter.Engine.update(engine, dt / N)` N times per `step()` call to keep the discrete narrow phase from missing fast-moving contacts. Matter's broad phase is not swept, so a body that travels more than ~one collision radius per tick can tunnel through walls or other bodies; halving the per-tick delta caps the inter-body motion and eliminates the tunneling at the cost of ~N× solver work. Pool's 30 px/frame break shots use `subSteps: 2`. Off by default (single step) so existing matter games are unchanged. -- Physics: **Angular API on `PhysicsBody`** — optional methods every adapter may implement: `setAngularVelocity(omega)` / `getAngularVelocity()` / `setAngle(rad)` / `getAngle()` / `applyTorque(t)`. `applyForce(x, y)` is also extended with optional `pointX, pointY` parameters — when present and different from the body centroid, the lever arm generates a torque (`τ = r × F`) that contributes to angular velocity. The 2-arg form is unchanged so existing code keeps compiling. -- Physics: **Angular dynamics in BuiltinAdapter** — `Body` class gets four new fields (`angle`, `angularVelocity`, `angularDrag`, `pseudoInertia`) all defaulting to a no-op value (`0` / `1`), plus the five new methods. `Body.update()` integrates angle from angular velocity and re-syncs `renderable.currentTransform` around the bounds center — **gated** on `angularVelocity !== 0 || angle !== 0`, so bodies that never touch the rotation API pay no per-frame cost (a single boolean check). `pseudoInertia` auto-derives from the body's bounds (`(width² + height²) / 12`, matching the moment of a unit-mass rectangle) at every `addShape` call; override manually for stiffer / floppier rotation. Rotation is **visual only** under SAT: collision shapes stay axis-aligned, but the sprite spins correctly — covers the common arcade use-cases (spinning pickups / coins / projectiles, off-centre kick impulses, fixed-angle turret aiming, damped knockback spin). Adapter-level mirrors: `adapter.setAngle` / `getAngle` / `setAngularVelocity` / `getAngularVelocity` / `applyTorque` / `applyForce(rend, F, point?)`. -- Physics: **Angular dynamics in MatterAdapter** — `helpers` spliced onto each `Matter.Body` extended with the same five methods, routing to `Matter.Body.setAngularVelocity`, `Matter.Body.setAngle`, `body.torque +=` and direct `body.angularVelocity` reads. `applyForce` extended to forward the optional point through to `Matter.Body.applyForce(body, point, F)` so matter's native lever-arm handling does the rest. Adapter-level mirrors match BuiltinAdapter's surface. -- Physics: **`adapter.raycast(from, to)` — portable single-nearest-hit raycast API across all three adapters.** Same `world.adapter.raycast(from, to)` call works under `BuiltinAdapter`, `MatterAdapter`, and `PlanckAdapter`, returning a unified `RaycastHit { renderable, point, normal, fraction }` (or `null` on miss). All three adapters compute **precise line-shape entry geometry**: `point` is the actual entry on the body's surface (parametric segment-vs-edge intersection for polygon shapes, quadratic ray-vs-ellipse for ellipses, Box2D's native fractional ray cast for planck), `normal` is the outward surface normal at that entry flipped toward the ray origin, `fraction` is the parametric `t ∈ [0, 1]` along the ray. `BuiltinAdapter`'s implementation lives in `physics/builtin/raycast.js` and is shared with the legacy `collision.rayCast(line, result)` / `Detector.rayCast` API — both APIs now return hits **sorted nearest-first** (was unspecified order). `BuiltinAdapter.capabilities.raycasts` flips to `true`. -- Physics: **`adapter.queryAABB(rect)` — portable region-query API across all three adapters.** Returns every renderable whose body bounds overlap the given world-space rectangle. Useful for area-of-effect damage, mouse / touch picking, trigger-zone sweeps, AI awareness checks. `BuiltinAdapter` walks the SAT broadphase quadtree and filters by actual AABB overlap (not just same-partition leakage). `MatterAdapter` delegates to `Matter.Query.region`. `PlanckAdapter` uses Box2D's native `World.queryAABB`. Method was previously declared optional on the `PhysicsAdapter` interface (matter / planck only); now non-optional with all three adapters implementing. -- Audio: **`audio.tone(opts)` — procedural single-shot oscillator** for envelope-shaped beeps with optional multi-partial chord (`freq` as a number or array), gain envelope (`attack` + exp-decay over `duration`), stereo pan (`pan` in `[-1, 1]`), and percussive pitch slide (`pitchSlide` as a frequency multiplier applied over the decay). Pairs with melonJS's procedural-graphics culture (`ShaderEffect`, `ParticleEmitter`) so games can ship polished UI clicks, hit feedback, retro arcade cues, and placeholder SFX without any audio asset files. Single oscillator graph per call; no synth state machine. Runs on the shared `AudioContext` (see `audio.getAudioContext`) so browser autoplay gating is consistent with the file-based playback path. Routed through the audio module's master gain so `muteAll` / `setVolume` apply uniformly. -- Audio: **`audio.noise(opts)` — procedural single-shot noise burst**, the non-pitched companion to `tone`. Picks spectral colour via `type` (`"white"` / `"pink"` / `"brown"`), shares the same gain-envelope + pan plumbing as `tone`, and accepts an optional band-shaping filter (`{ type, frequency, Q }`) with an optional exponential sweep on the filter frequency (`filterSweep`). Covers the percussive-without-pitch slice of game SFX — explosions (brown + lowpass + downward sweep), hi-hats (white + highpass), swooshes (bandpass + rising sweep), wind (pink + bandpass), footsteps (brown + lowpass). Same WebAudio gating and mute / volume routing as `tone`. -- Audio: **`audio.getAudioContext()` — expose the shared WebAudio context** Howler manages internally. Returns the same `AudioContext` used by `audio.load` / `audio.play` etc. so user code can build custom WebAudio graphs (procedural SFX, custom filters, spatial nodes, audio analysis) without spawning a second context. Browsers throttle or refuse multiple contexts on a page and each has its own suspend-until-gesture state, so sharing matters. Returns `null` when audio is disabled or unavailable. -- Audio: **`audio.getMasterGain()` — expose the master gain `GainNode`** the audio module routes all playback through. The right place to connect a custom analyser / filter / convolver so the result still respects `audio.muteAll()` and `audio.setVolume()`. Also lets `tone()` and `noise()` route their output without knowing which underlying audio backend the engine uses, so the dependency on Howler is now isolated to the two escape-hatch getters. -- Examples: **`Line of Sight` rewritten on the new Application bootstrap.** Replaces the legacy `video.init()` + global `game` singleton pattern with `new Application(...)`. The demo wraps the new portable `app.world.adapter.raycast(from, to)` API in a stealth-style vision cone — a fan of 21 rays cast each frame from a rotating "sentry" across its forward arc, whose hit points form a visible-area polygon so obstacles correctly occlude everything behind them. Arrow keys move the sentry; obstacles are draggable. The whole demo runs unchanged under any adapter — the example never references `BuiltinAdapter`, `MatterAdapter`, or `PlanckAdapter` directly. -- Renderable: **`bodyDef` field declared on the base `Renderable` class** with proper JSDoc (`@type {object|undefined}`, marked **Adapter API only**). Previously a phantom field — used everywhere in the engine (container.js, trigger.js, factories) but never declared, so subclass assignments like `this.bodyDef = { type: "dynamic", … }` raised `ts(2339) Property 'bodyDef' does not exist on type 'Sprite'` in the IDE. The generated `.d.ts` now exports `bodyDef: object | undefined`. +- Physics: **`PhysicsBody` interface** (`packages/melonjs/src/physics/adapter.ts`) — portable body handle type that lists the methods every adapter guarantees on `renderable.body` (`setVelocity` / `getVelocity` / `applyForce` / `applyImpulse` / `setStatic` / `setCollisionMask`, plus optional `setCollisionType` / `setSensor` / `setMass` / `setBounce` / `setGravityScale`, plus live `collisionType` / `collisionMask` field aliases). `PhysicsAdapter.addBody` is now typed as `(…) => PhysicsBody` (was `unknown`), `Renderable.body` is documented as `PhysicsBody` rather than the legacy `Body` class. +- Physics: **`MatterAdapter.Body` namespace-merged type** — concrete body handle returned by the matter adapter, defined as `ReturnType & PhysicsBody`. Reachable as `MatterAdapter.Body` without importing `matter-js` directly. +- Physics: **`BodyDefinition.friction`** — portable surface coefficient of friction (matter's `body.friction`). Determines tangential velocity transfer between contacting bodies — combined with rotation, produces "throw" between balls and "english" off walls. +- Physics: **`MatterAdapter` substep support** — new `subSteps?: number` option on `MatterAdapterOptions` (default 1). Runs `Matter.Engine.update(engine, dt / N)` N times per `step()` call to keep the discrete narrow phase from missing fast-moving contacts. +- Physics: **Angular API on `PhysicsBody`** — optional methods every adapter may implement: `setAngularVelocity(omega)` / `getAngularVelocity()` / `setAngle(rad)` / `getAngle()` / `applyTorque(t)`. `applyForce(x, y)` is also extended with optional `pointX, pointY` parameters — when present and different from the body centroid, the lever arm generates a torque (`τ = r × F`) that contributes to angular velocity. +- Physics: **Angular dynamics in BuiltinAdapter** — `Body` class gets four new fields (`angle`, `angularVelocity`, `angularDrag`, `pseudoInertia`) all defaulting to a no-op value (`0` / `1`), plus the five new methods. `Body.update()` integrates angle from angular velocity and re-syncs `renderable.currentTransform` around the bounds center — **gated** on `angularVelocity !== 0 || angle !== 0`, so bodies that never touch the rotation API pay no per-frame cost (a single boolean check). +- Physics: **Angular dynamics in MatterAdapter** — `helpers` spliced onto each `Matter.Body` extended with the same five methods, routing to `Matter.Body.setAngularVelocity`, `Matter.Body.setAngle`, `body.torque +=` and direct `body.angularVelocity` reads. `applyForce` extended to forward the optional point through to `Matter.Body.applyForce(body, point, F)` so matter's native lever-arm handling does the rest. +- Physics: **`adapter.raycast(from, to)` — portable single-nearest-hit raycast API across all three adapters.** Same `world.adapter.raycast(from, to)` call works under `BuiltinAdapter`, `MatterAdapter`, and `PlanckAdapter`, returning a unified `RaycastHit { renderable, point, normal, fraction }` (or `null` on miss). All three adapters compute **precise line-shape entry geometry**: `point` is the actual entry on the body's surface (parametric segment-vs-edge intersection for polygon shapes, quadratic ray-vs-ellipse for ellipses, Box2D's native fractional ray cast for planck), `normal` is the outward surface normal at that entry flipped toward the ray origin, `fraction` is the parametric `t ∈ [0, 1]` along the ray. +- Physics: **`adapter.queryAABB(rect)` — portable region-query API across all three adapters.** Returns every renderable whose body bounds overlap the given world-space rectangle. Useful for area-of-effect damage, mouse / touch picking, trigger-zone sweeps, AI awareness checks. +- Audio: **`audio.tone(opts)` — procedural single-shot oscillator** for envelope-shaped beeps with optional multi-partial chord (`freq` as a number or array), gain envelope (`attack` + exp-decay over `duration`), stereo pan (`pan` in `[-1, 1]`), and percussive pitch slide (`pitchSlide` as a frequency multiplier applied over the decay). Pairs with melonJS's procedural-graphics culture (`ShaderEffect`, `ParticleEmitter`) so games can ship polished UI clicks, hit feedback, retro arcade cues, and placeholder SFX without any audio asset files. +- Audio: **`audio.noise(opts)` — procedural single-shot noise burst**, the non-pitched companion to `tone`. Picks spectral colour via `type` (`"white"` / `"pink"` / `"brown"`), shares the same gain-envelope + pan plumbing as `tone`, and accepts an optional band-shaping filter (`{ type, frequency, Q }`) with an optional exponential sweep on the filter frequency (`filterSweep`). +- Audio: **`audio.getAudioContext()` — expose the shared WebAudio context** Howler manages internally. Returns the same `AudioContext` used by `audio.load` / `audio.play` etc. so user code can build custom WebAudio graphs (procedural SFX, custom filters, spatial nodes, audio analysis) without spawning a second context. +- Audio: **`audio.getMasterGain()` — expose the master gain `GainNode`** the audio module routes all playback through. The right place to connect a custom analyser / filter / convolver so the result still respects `audio.muteAll()` and `audio.setVolume()`. +- Examples: **`Line of Sight` rewritten on the new Application bootstrap.** Replaces the legacy `video.init()` + global `game` singleton pattern with `new Application(...)`. The demo wraps the new portable `app.world.adapter.raycast(from, to)` API in a stealth-style vision cone — a fan of 21 rays cast each frame from a rotating "sentry" across its forward arc, whose hit points form a visible-area polygon so obstacles correctly occlude everything behind them. +- Renderable: **`bodyDef` field declared on the base `Renderable` class** with proper JSDoc (`@type {object|undefined}`, marked **Adapter API only**). Previously a phantom field — used everywhere in the engine (container.js, trigger.js, factories) but never declared, so subclass assignments like `this.bodyDef = { type: "dynamic", … }` raised `ts(2339) Property 'bodyDef' does not exist on type 'Sprite'` in the IDE. - Renderable: **`onActivateEvent` / `onDeactivateEvent` declared on `Renderable`** — lifecycle method stubs so user subclasses can write `override onActivateEvent()` / `override onDeactivateEvent()` without TS complaining that there's nothing to override. Default no-op bodies; engine already dispatched these via Container. -- Renderer: **abstract drawing-primitive declarations on `Renderer` base** — `save` / `restore` / `translate` / `rotate` / `scale` / `setGlobalAlpha` / `fillRect` / `strokeRect` / `fillEllipse` / `strokeEllipse` / `strokeLine` / `drawImage` / `lineWidth` are now declared on the abstract `Renderer` class as no-op stubs with proper signatures. Previously implemented only on the concrete `CanvasRenderer` / `WebGLRenderer` subclasses, so any user code overriding `draw(renderer: Renderer)` and calling them got `ts(2339) Property 'X' does not exist on type 'Renderer'`. Runtime behavior is unchanged (the concrete subclasses still override every method); only the type surface is fixed. +- Renderer: **abstract drawing-primitive declarations on `Renderer` base** — `save` / `restore` / `translate` / `rotate` / `scale` / `setGlobalAlpha` / `fillRect` / `strokeRect` / `fillEllipse` / `strokeEllipse` / `strokeLine` / `drawImage` / `lineWidth` are now declared on the abstract `Renderer` class as no-op stubs with proper signatures. Previously implemented only on the concrete `CanvasRenderer` / `WebGLRenderer` subclasses, so any user code overriding `draw(renderer: Renderer)` and calling them got `ts(2339) Property 'X' does not exist on type 'Renderer'`. - TMX: **`.aseprite` files supported as tileset images** — Tiled 1.11+ lets you point a TMX `` directly at an `.aseprite` / `.ase` file instead of a pre-exported PNG (via the qaseprite Qt plugin). melonJS now resolves those references natively: `loader.preload({ type: "aseprite", src: "foo.aseprite" })` decodes the binary into a composited bitmap and atlas-style JSON sidecar (stored in `imgList` / `jsonList` under the asset name), so `TMXTileset` and `TextureAtlas` pick it up through the usual `getImage()` / `getJSON()` lookups with no extra wiring — Tiled-authored maps that use Aseprite tilesets "just work" without exporting to PNG first. Resolves [#1363](https://github.com/melonjs/melonJS/issues/1363). -- TMX: **Animated tiles from Aseprite frame tags** — `TMXTileset` now reads the JSON sidecar produced by the `.aseprite` parser and synthesizes per-tile animations from `meta.frameTags`. Each Aseprite frame becomes a tile in the tileset (horizontal strip layout), and each forward tag covering frames `[from..to]` becomes an animation on tile `from` cycling through frames `[from..to]` with Aseprite's per-frame durations — no `` markup needed in the TSX. Scoped to what Tiled's animation model actually supports: tags using `reverse` / `pingpong` direction or a finite repeat count throw a clear error at TMXTileset construction time pointing the user at the supported config (Aseprite "forward" + infinite repeat). -- Loader: **Aseprite binary file format parser** (`packages/melonjs/src/loader/parsers/aseprite.js`) — implementation behind the TMX feature above, also usable standalone for any asset (sprites, animations, etc.). Walks the binary chunks per the [official Aseprite spec](https://github.com/aseprite/aseprite/blob/main/docs/ase-file-specs.md): RGBA / grayscale / indexed pixel modes, raw / linked / zlib-compressed cels (decompressed via `DecompressionStream("deflate")`), visible-layer compositing, frame tags emitted as `meta.frameTags` so `parseAseprite()` in the texture-atlas path consumes the JSON unchanged. Files that use Aseprite's **tilemap mode** (tileset chunk `0x2023` or compressed-tilemap cels type `3`) are rejected with a clear error pointing at the PNG-export workaround, rather than producing a silent empty composite. Other limitations: only "normal" layer blend mode (layer's blend-mode field is read but ignored), layer-level opacity is ignored (cel opacity is honored), color management is skipped (raw RGB). +- TMX: **Animated tiles from Aseprite frame tags** — `TMXTileset` now reads the JSON sidecar produced by the `.aseprite` parser and synthesizes per-tile animations from `meta.frameTags`. Each Aseprite frame becomes a tile in the tileset (horizontal strip layout), and each forward tag covering frames `[from..to]` becomes an animation on tile `from` cycling through frames `[from..to]` with Aseprite's per-frame durations — no `` markup needed in the TSX. +- Loader: **Aseprite binary file format parser** (`packages/melonjs/src/loader/parsers/aseprite.js`) — implementation behind the TMX feature above, also usable standalone for any asset (sprites, animations, etc.). Walks the binary chunks per the [official Aseprite spec](https://github.com/aseprite/aseprite/blob/main/docs/ase-file-specs.md): RGBA / grayscale / indexed pixel modes, raw / linked / zlib-compressed cels (decompressed via `DecompressionStream("deflate")`), visible-layer compositing, frame tags emitted as `meta.frameTags` so `parseAseprite()` in the texture-atlas path consumes the JSON unchanged. - Examples: **Aseprite tileset map** added to the Tiled Map Loader viewer (`Aseprite Map`) — exercises the new TMX-with-aseprite path end-to-end: TMX → TSX → `` → composited bitmap. Tileset is "free-tileset-1" by Moon Empire; credit recorded on the TMX `` block. ### Changed -- Timer: `timer.fps` is now updated every frame inside `timer.update()` instead of via an external `timer.countFPS()` call. Previously `timer.fps` was stuck at `0` unless the debug panel was open and visible (which was the only call site for the internal `countFPS()` method). The `countFPS()` method has been removed; the FPS estimate is part of the timer's own per-frame work and is available to any reader of `timer.fps` regardless of which plugins are loaded. -- Container: **removed the `@ignore`'d `Container.drawCount` field** — a per-Container counter that was only consumed by `@melonjs/debug-plugin`'s `#draws` stat. The plugin now self-computes the stat from its own per-frame postDraw counter (counting direct children of `game.world`), so the field no longer needs to live on every Container instance. No effect on documented public API. -- Physics: **`Renderable.onCollision` default return value is now `undefined` (was `false`)** — and push-out now runs by default for dynamic non-sensor bodies regardless of whether `onCollision` is defined. The pre-19.5 "no `onCollision` defined ⇒ implicit sensor" quirk is gone, replaced by the explicit `bodyDef.isSensor: true` flag. Game code that relied on the implicit-sensor pattern (a `Renderable` with no `onCollision` override that should pass through other bodies) needs to set `bodyDef.isSensor: true` explicitly. Matches matter's "solver resolves contacts unless `isSensor`" default model and removes a footgun for users migrating from `onCollision` to `onCollisionActive`. +- Timer: `timer.fps` is now updated every frame inside `timer.update()` instead of via an external `timer.countFPS()` call. Previously `timer.fps` was stuck at `0` unless the debug panel was open and visible (which was the only call site for the internal `countFPS()` method). +- Container: **removed the `@ignore`'d `Container.drawCount` field** — a per-Container counter that was only consumed by `@melonjs/debug-plugin`'s `#draws` stat. The plugin now self-computes the stat from its own per-frame postDraw counter (counting direct children of `game.world`), so the field no longer needs to live on every Container instance. +- Physics: **`Renderable.onCollision` default return value is now `undefined` (was `false`)** — and push-out now runs by default for dynamic non-sensor bodies regardless of whether `onCollision` is defined. The pre-19.5 "no `onCollision` defined ⇒ implicit sensor" quirk is gone, replaced by the explicit `bodyDef.isSensor: true` flag. - Physics: **`Trigger` and `Collectable`** now use the new sensor pattern added to the built-in physics (`bodyDef.isSensor: true`). -- Renderable: **Lifecycle hook signatures widened in the base class** for `onCollision`, `onDestroyEvent`, and `Container.onActivateEvent` / `onDeactivateEvent`. The base methods now declare typed-but-unused parameters so subclass overrides like `onCollision(response: CollisionResponse, other: Renderable)` or `onDestroyEvent(app: Application)` remain structurally assignable to the base type under TypeScript's `strictFunctionTypes` rule. Runtime behavior unchanged (the engine already forwards `app` to `onDestroyEvent` via `Renderable.destroy.apply(this, arguments)` and dispatches `(response, other)` to the collision hook). Fixes a TS trap that bit any user-side `extends Renderable` that typed its hook params. +- Renderable: **Lifecycle hook signatures widened in the base class** for `onCollision`, `onDestroyEvent`, and `Container.onActivateEvent` / `onDeactivateEvent`. The base methods now declare typed-but-unused parameters so subclass overrides like `onCollision(response: CollisionResponse, other: Renderable)` or `onDestroyEvent(app: Application)` remain structurally assignable to the base type under TypeScript's `strictFunctionTypes` rule. - Docs: **Wiki physics section** — three new pages on the GitHub wiki: [Migrating to the Physics Adapter API](https://github.com/melonjs/melonJS/wiki/Migrating-to-the-Physics-Adapter-API) (legacy `me.Body` → 19.5 adapter API, all on Builtin), [Switching Physics Adapters](https://github.com/melonjs/melonJS/wiki/Switching-Physics-Adapters) (Builtin → matter migration with 6 portable+matter-only recipes), and [BuiltinAdapter Quirks](https://github.com/melonjs/melonJS/wiki/BuiltinAdapter-Quirks) (10 SAT-specific behaviors that don't carry to other engines). The matter-adapter README mirrors the recipes section. -- Audio: **`audio.panner()` now always returns the current `PannerAttributes` snapshot**, regardless of whether the call was a get or a set. Previously the "set" form returned the underlying `Howl` instance cast to `PannerAttributes` — the documented `@returns` was lying. Callers that read the returned value now get the attribute object the docstring promised; callers that ignored the return value see no change. +- Audio: **`audio.panner()` now always returns the current `PannerAttributes` snapshot**, regardless of whether the call was a get or a set. Previously the "set" form returned the underlying `Howl` instance cast to `PannerAttributes` — the documented `@returns` was lying. ### Fixed -- Physics: **`Body.ignoreGravity` marked `@deprecated`** — the portable equivalent is `gravityScale = 0` (or `bodyDef.gravityScale = 0` at construction, or `body.setGravityScale(0)` at runtime). `ignoreGravity` is read only by `BuiltinAdapter.applyGravity` and `Body.update`'s falling-state machine; `MatterAdapter` silently ignores it. The duplicate check in both call sites is kept (so legacy code that sets `ignoreGravity = true` still works), with a comment noting the redundancy. `Body.update`'s falling/jumping flag update now also gates on `gravityScale !== 0` so floating bodies (`gravityScale: 0`) no longer get mistakenly marked "falling" on a side-on collision. -- Container: **`addChild` / `addChildAt` no longer destroy a child when re-parenting it** — when the incoming child had a previous `ancestor`, the implicit detach called `removeChildNow(child)` without the `keepalive` flag, which falls through to `child.destroy()` for any child that isn't registered with the legacy `pool.push` registry. `Renderable.destroy()` nulls `pos`, `_bounds`, `anchorPoint`, and `currentTransform` — so the very next line in `addChild` (`child.updateBounds()`) read `child.pos.x` on a freshly-destroyed instance and crashed with `Cannot read properties of undefined (reading 'x')`. Reproduces reliably when a `ParticleEmitter` recycles a pool-managed `Particle` whose previous parent's deferred-remove hadn't fired yet: the new emitter's `addChild` saw `particle.ancestor === oldEmitter`, called the old emitter's `removeChildNow` without keepalive, the legacy pool rejected the particle (not registered), `destroy()` ran, and the surrounding `addParticles → addChild → updateBounds → getBounds → isFinite → left` chain blew up. Now `removeChildNow(child, true)` — the call site is, by definition, a re-parent, not a dispose. -- Container: **`removeChildNow` now evicts the child (and its descendants) from the world's broadphase quadtree.** The broadphase is rebuilt on every `world.update()`, but pointer events and narrow-phase queries can fire between a deferred `removeChild` (which routes through `setTimeout(0)` and lands between two frames) and the next rebuild. In that window the broadphase still held a reference to the now-removed renderable, and any caller that iterated the `retrieve()` result hit a destroyed instance — typically crashing in `Container._sortReverseZ` with `Cannot read properties of undefined (reading 'z')` when the sort comparator read `a.pos.z` on a freshly-destroyed sibling. Reproduces in scenes with rapid spawn/despawn under an active pointer (e.g. the plinko-planck demo: clicking to drop balls while pegs cycle visuals). Fix wires a new `QuadTree.removeContainer(container)` (mirror of `insertContainer`) into `Container.removeChildNow` so the broadphase stays in sync without waiting for the next world rebuild. -- WebGL: **`QuadBatcher` texture upload no longer empties an `OffscreenCanvas` source — regression fix from 19.0.0**. The Matrix3d refactor in 19.0.0 (commit `055a697`) added an `OffscreenCanvas`-specific branch to `material_batcher.js` that routed pixels through `pixels.transferToImageBitmap()`, which is **destructive**: the call moves the bitmap out of the OffscreenCanvas and leaves the source blank. First upload worked; any subsequent re-upload (context loss, cache eviction, or a second consumer of the same source) uploaded an empty texture. The immediate casualty was `ParticleEmitter` — `createDefaultParticleTexture` (`emitter.ts`) builds an `OffscreenCanvas` with `offscreenCanvas: true` and hands the bare canvas to every `Particle.image`, so under WebGL2 the first burst worked but every subsequent default-texture burst rendered as transparent quads. Latent across all 19.0–19.4 releases because none of the first-party WebGL examples relied on default-texture particles. WebGL2 (and modern WebGL1) accept `OffscreenCanvas` directly as a `TexImageSource`, so the special-case was also unnecessary. Now uses the 6-arg `gl.texImage2D(target, level, ifmt, fmt, type, source)` form against the OffscreenCanvas directly, same as the `HTMLCanvasElement` fallback below it — which is the path that worked pre-19.0. -- WebGL: **`CanvasRenderTarget.destroy` no longer deletes an unrelated texture under unit recycling.** The previous implementation looked up the texture to delete via `getTexture2D(this.glTextureUnit)`, but the `QuadBatcher`'s LRU texture-unit cache recycles units under pressure (typically 8–16 hardware units), so by the time a render target was destroyed its cached unit may have been reassigned to a different texture entirely. Result was either a crash (`getTexture2D` returned `undefined` → `texture.getTexture()` threw) or, worse, the silent deletion of an unrelated GPU texture that now occupied the recycled unit. Lookup is now keyed on the source canvas identity via `renderer.cache.get(this.canvas)`, which is unit-recycling-safe. +- Physics: **`Body.ignoreGravity` marked `@deprecated`** — the portable equivalent is `gravityScale = 0` (or `bodyDef.gravityScale = 0` at construction, or `body.setGravityScale(0)` at runtime). `ignoreGravity` is read only by `BuiltinAdapter.applyGravity` and `Body.update`'s falling-state machine; `MatterAdapter` silently ignores it. +- Container: **`addChild` / `addChildAt` no longer destroy a child when re-parenting it** — when the incoming child had a previous `ancestor`, the implicit detach called `removeChildNow(child)` without the `keepalive` flag, which falls through to `child.destroy()` for any child that isn't registered with the legacy `pool.push` registry. `Renderable.destroy()` nulls `pos`, `_bounds`, `anchorPoint`, and `currentTransform` — so the very next line in `addChild` (`child.updateBounds()`) read `child.pos.x` on a freshly-destroyed instance and crashed with `Cannot read properties of undefined (reading 'x')`. +- Container: **`removeChildNow` now evicts the child (and its descendants) from the world's broadphase quadtree.** The broadphase is rebuilt on every `world.update()`, but pointer events and narrow-phase queries can fire between a deferred `removeChild` (which routes through `setTimeout(0)` and lands between two frames) and the next rebuild. In that window the broadphase still held a reference to the now-removed renderable, and any caller that iterated the `retrieve()` result hit a destroyed instance — typically crashing in `Container._sortReverseZ` with `Cannot read properties of undefined (reading 'z')` when the sort comparator read `a.pos.z` on a freshly-destroyed sibling. +- WebGL: **`QuadBatcher` texture upload no longer empties an `OffscreenCanvas` source — regression fix from 19.0.0**. The Matrix3d refactor in 19.0.0 (commit `055a697`) added an `OffscreenCanvas`-specific branch to `material_batcher.js` that routed pixels through `pixels.transferToImageBitmap()`, which is **destructive**: the call moves the bitmap out of the OffscreenCanvas and leaves the source blank. +- WebGL: **`CanvasRenderTarget.destroy` no longer deletes an unrelated texture under unit recycling.** The previous implementation looked up the texture to delete via `getTexture2D(this.glTextureUnit)`, but the `QuadBatcher`'s LRU texture-unit cache recycles units under pressure (typically 8–16 hardware units), so by the time a render target was destroyed its cached unit may have been reassigned to a different texture entirely. Result was either a crash (`getTexture2D` returned `undefined` → `texture.getTexture()` threw) or, worse, the silent deletion of an unrelated GPU texture that now occupied the recycled unit. ### Performance - QuadTree: **subtree size is now cached** in `_subtreeCount`, maintained incrementally by `insert` / `remove` / `clear`. Makes `isPrunable()` and `hasChildren()` O(1) reads instead of recursive walks. -- QuadTree: **`retrieve()` reuses a single scratch array at the root** instead of allocating a fresh result array per call. Pointer events and the builtin narrow-phase detector both call `retrieve()` per frame per body, so a scene with 100 active bodies under a moving pointer saves ~6000 array allocations per second. Callers must consume the result synchronously (every in-engine caller already does); a new test pins the contract. +- QuadTree: **`retrieve()` reuses a single scratch array at the root** instead of allocating a fresh result array per call. Pointer events and the builtin narrow-phase detector both call `retrieve()` per frame per body, so a scene with 100 active bodies under a moving pointer saves ~6000 array allocations per second. - QuadTree: **empty subnodes are now collapsed and recycled back to the pool** when a subtree fully empties. Keeps the tree shallow under spawn/despawn churn (particle bursts, projectile lifecycles, the plinko-planck demo's peg recycles) instead of leaving placeholder empty nodes behind until the next `world.update()` rebuild. - QuadTree: **`remove()` falls back to a full subnode walk on a bounds miss.** Items whose position changed between `insert` and `remove` (typical for moving bodies removed mid-frame) can now be evicted without waiting for the next quadtree rebuild — previously they would silently leak and only get cleared on the next `world.update()`. Worst case is O(subtree); happy path stays O(log n). ## [19.4.0] (melonJS 2) - _2026-05-12_ -**Highlights:** rendering-focused release. The headline is GPU-accelerated WebGL 2 tile rendering for orthogonal TMX maps: visible layers now render as a single quad through a fragment shader instead of one draw per tile. Combined with the new shader-wide uniform cache, the per-fragment fast path, and the flat `Uint16Array`-backed tile data, a typical 3-layer 800×600 game on mid-tier mobile reclaims roughly **1.5–3.5 ms per frame** (~10–20% of the 60 fps budget). Dense large maps should see ~5–8× speedups on the rendering portion. +**Highlights:** rendering-focused release. GPU-accelerated WebGL 2 tile rendering for orthogonal TMX maps draws each visible layer as a single quad through a fragment shader instead of one draw per tile. With the shader-wide uniform cache and flat `Uint16Array` tile data, a 3-layer 800x600 game on mid-tier mobile reclaims roughly **1.5-3.5 ms per frame**; dense large maps see ~5-8x on the rendering portion. ### Added -- GPU-accelerated WebGL 2 tile rendering for orthogonal TMX maps. Each visible layer renders as a single quad whose fragment shader walks the per-layer GID index texture and samples the tileset atlas, with no per-tile draw loop. Supports animated tiles, flip bits (H/V/AD), per-layer opacity/tint, per-layer blend mode, and oversized bottom-aligned tiles. Enabled by default via `Application.settings.gpuTilemap`; falls back transparently to the legacy CPU renderer on isometric/staggered/hexagonal layers, collection-of-image tilesets, non-zero `tileoffset`, or non-WebGL-2 contexts. Rough win on a mid-tier mobile GPU with a 3-layer 800×600 viewport: ~2–4 ms down to ~0.3–0.8 ms per frame; up to ~5–8× on dense large maps; effectively free on desktop GPUs. -- WebGL: custom shaders can now be written in GLSL ES 3.00 (`#version 300 es`). Construct a `GLShader` with both vertex and fragment source in 3.00 form. The precision injector and attribute extractor handle both versions. **Note:** `ShaderEffect` is still 1.00-only since WebGL requires both stages of a program to share a version, and it pairs the user's fragment with the built-in 1.00 quad vertex shader. -- `TextureResource` / `BufferTextureResource`: a renderer-agnostic source for textures synthesized from raw byte buffers rather than loaded from an image. Flows through the standard `TextureCache` and batcher path. Supports `rgba8` and `rgba8ui` (WebGL 2) formats. Used internally by the GPU TMX renderer. +- TMX: GPU-accelerated WebGL 2 tile rendering for orthogonal maps. Each visible layer renders as one quad whose fragment shader walks a per-layer GID index texture and samples the tileset atlas, with no per-tile draw loop. Supports animated tiles, flip bits, per-layer opacity/tint/blend mode and oversized tiles. Enabled via `Application.settings.gpuTilemap`; falls back to the CPU renderer on isometric/staggered/hexagonal layers, collection-of-image tilesets, non-zero `tileoffset`, or non-WebGL-2 contexts +- WebGL: custom shaders can be written in GLSL ES 3.00 (`#version 300 es`) — construct a `GLShader` with both stages in 3.00 form. `ShaderEffect` remains 1.00-only, since WebGL requires both stages of a program to share a version and it pairs the user's fragment with the built-in 1.00 vertex shader +- Renderer: `TextureResource` / `BufferTextureResource` — a renderer-agnostic source for textures synthesized from raw byte buffers rather than loaded from an image. Flows through the standard `TextureCache` and batcher path, supporting `rgba8` and `rgba8ui` ### Fixed -- WebGL: `MaterialBatcher.uploadTexture` was using its `w` and `h` parameters (the destination quad size, not the texture's) for the `isPOT` check, which drives both the wrap-mode fallback and the `generateMipmap` gate. Visible as a `GL_INVALID_OPERATION` from `gl.generateMipmap` on WebGL 1; silent wasted work (unnecessary mipmaps, wrong `isPOT`-derived state) on WebGL 2. Texture dimensions are now derived from the source itself. -- SAT: ellipse collisions silently failed whenever the body's ancestor container had a non-zero absolute position (the typical case: `level.load` auto-centers the level container when the viewport is larger than the map, setting `container.pos` to a non-zero offset). `testEllipseEllipse` and `testPolygonEllipse` built the relative-position vector by *adding* `a.ancestor.getAbsolutePosition()` where they should have subtracted it, shifting the circle by `2 * ancestor.absPos`. The polygon/polygon path is unaffected — it builds two absolute positions and lets `isSeparatingAxis` do the subtraction. Latent because every existing SAT unit test wired the mock ancestor to `(0, 0)`, where the sign error is arithmetically invisible. -- TMX: static children of an auto-centered level container kept stale absolute bounds. `TMXTileMap.addTo` sets `container.pos` *after* adding children, so each child's cached absolute bounds (computed at `addChild` time) didn't include the centering offset. Children that moved on their own refreshed via the `pos` observer, but TMX layers, Tiled collision shapes, triggers, and decorative sprites stayed stuck at their pre-centering bounds — visible as debug overlay shapes drawn at the wrong screen position, and as broken viewport culling for anything outside the pre-centering box. `_setBounds` now walks the container subtree and refreshes absolute bounds after the position actually moves (both initial load and viewport resize). -- ImageLayer: `repeat-x` / `repeat-y` / `no-repeat` produced different visual output on Canvas vs WebGL (issue #1290). `ImageLayer.draw` was asking the renderer to fill `viewport.width * 2` × `viewport.height * 2` regardless of repeat mode, then leaning on each renderer's overflow behavior on the non-tiling axis — Canvas leaves the overflow transparent (HTML spec), WebGL stretches the bottom row / right column via `GL_CLAMP_TO_EDGE`. The draw extent is now clamped to the source dimensions on any axis that isn't tiling, so neither renderer enters its overflow path and both produce the same strip-shaped output. Matches Pixi's `TilingSprite` mental model (no `repeat-x` / `repeat-y` flags — the tile rectangle is the tile rectangle). +- WebGL: fix `MaterialBatcher.uploadTexture` using its `w`/`h` parameters — the destination quad size, not the texture's — for the `isPOT` check that drives the wrap-mode fallback and the `generateMipmap` gate. Visible as `GL_INVALID_OPERATION` on WebGL 1, silent wasted work on WebGL 2 +- SAT: fix ellipse collisions failing whenever the body's ancestor container had a non-zero absolute position, the typical case being an auto-centred level. `testEllipseEllipse` and `testPolygonEllipse` added the ancestor's absolute position where they should have subtracted it, shifting the circle by twice that offset. Latent because every SAT test wired the mock ancestor to `(0, 0)` +- TMX: fix static children of an auto-centred level container keeping stale absolute bounds. `addTo()` sets `container.pos` after adding children, so cached bounds missed the centring offset — visible as debug shapes drawn at the wrong position and broken culling outside the pre-centring box +- ImageLayer: fix `repeat-x` / `repeat-y` / `no-repeat` rendering differently on Canvas and WebGL (#1290). The draw extent is now clamped to the source dimensions on any axis that is not tiling, so neither renderer enters its overflow path ### Changed -- WebGL 1: removed the unconditional `[Texture] ... is not a POT texture` warning. The engine handles NPOT correctly (clamp wrap, non-mipmapped filters). A targeted warning now fires only when `repeat: "repeat*"` is requested on an NPOT texture under WebGL 1, the one case where the user's intent is silently downgraded. -- `throttle(fn, wait)` is now generic over its argument tuple. `throttle((...args: T) => void, wait)` preserves the wrapped function's parameter types. +- WebGL 1: the unconditional "is not a POT texture" warning is removed — the engine handles NPOT correctly. A targeted warning now fires only when `repeat` is requested on an NPOT texture under WebGL 1 +- `throttle(fn, wait)` is generic over its argument tuple, preserving the wrapped function's parameter types ### Performance -- TMX tile layers now back `layerData` with a flat `Uint16Array` and the orientation renderers read directly from it, with no `Tile` allocations during map parse or per-frame rendering. Per-layer memory drops ~25× (40 KB vs ~1 MB on a 100×100 layer); modest FPS gain on Canvas (~2–5% in tile-heavy scenes). Public API is unchanged. -- WebGL: every shader the engine builds (sprite batchers, light effects, post-effect chains, the TMX GPU renderer, user-authored `GLShader` / `ShaderEffect`) now caches the last value sent for each uniform and skips redundant `gl.uniform*` calls. Vec/mat values compare element-wise so a reused scratch `Float32Array` is detected correctly. Biggest beneficiaries are the per-frame projection-matrix upload (now skipped after the first frame) and the TMX GPU renderer's layer-lifetime constants. Modest on its own, typically ~0.1–0.5 ms saved per frame on mid-tier mobile, more in scenes with many custom shaders or post-effect chains, but stacks cleanly with every other rendering win. -- TMX GPU renderer: fragment shader branches on `uOverflow == (0, 0)` and uses a single-cell fast path for tilesets whose tiles fit the cell exactly (the common case), skipping the worst-case 25-iteration candidate-cell loop entirely. The slow path (oversized bottom-aligned tiles) is unchanged. Roughly 10–25% fragment-shader cost reduction for the common case (~0.05–0.2 ms per frame on mid-tier mobile, lost in the noise on desktop GPUs); the win compounds with viewport size since fragment work scales with pixel count. +- TMX: tile layers back `layerData` with a flat `Uint16Array` and the orientation renderers read from it directly, with no `Tile` allocations during parse or rendering. Per-layer memory drops ~25x (40 KB against ~1 MB on a 100x100 layer). Public API unchanged +- WebGL: every engine-built shader caches the last value sent for each uniform and skips redundant `gl.uniform*` calls, comparing vec/mat values element-wise. Typically ~0.1-0.5 ms per frame on mid-tier mobile, more with many custom shaders +- TMX: the GPU renderer's fragment shader uses a single-cell fast path for tilesets whose tiles fit the cell exactly, branching on `uOverflow` ## [19.3.0] (melonJS 2) - _2026-05-08_ ### Added - Light2d: now a first-class world Renderable — add lights with `app.world.addChild(light)` (or any container, including a sprite, so the light follows it via parent transforms). Auto-registers with the active Stage's lighting set via `onActivateEvent` / `onDeactivateEvent` — no manual bookkeeping. -- Normal-map sprite lighting (closes #1416). New `Sprite.normalMap` property accepts a paired normal-map image (or auto-detected from a `TextureAtlas` constructed with `{ normalMap }`). Sprites with a normal map render through the WebGL renderer's dedicated `LitQuadBatcher` — its fragment shader samples the normal at each pixel and runs a Lambertian light loop over the active `Light2d` instances (`Stage._activeLights`), with up to 8 concurrent lights and a configurable `Stage.ambientLightingColor` floor. Attenuation curve is quadratic (wider plateau near the light, softer feathered edge than a linear falloff). The renderer dispatches to the lit batcher only when a sprite has `normalMap` AND the active stage has at least one `Light2d` — every other sprite stays on the standard quad batcher (full texture-unit capacity, zero overhead for unlit scenes). The Canvas renderer silently ignores `normalMap` and emits a one-shot console warning if any active light is uploaded. +- Normal-map sprite lighting (closes #1416). New `Sprite.normalMap` property accepts a paired normal-map image (or auto-detected from a `TextureAtlas` constructed with `{ normalMap }`). - Light2d: `illuminationOnly` boolean (default `false`) — when set to `true` the light's own gradient texture isn't drawn, but the light still feeds the cutout pass and the lit-sprite shader's per-frame uniforms. Useful for SpriteIlluminator-style demos where the light should be a logical source, not a visible glow. - Light2d: `lightHeight` property (default `max(radiusX, radiusY) * 0.075`) — the Z-axis component of the light direction in the lit shader's `dot(normal, lightDir)`. Low values graze across the surface (dramatic normal-map detail); high values make lighting head-on (more uniform brightness). - Two new examples: `Normal Map` (three procedurally-generated 3D orbs in red/green/blue base colors reacting to a moving cursor light, demonstrating that the normal-map controls shape while the color texture controls hue) and `SpriteIlluminator` (faithful port of CodeAndWeb's cocos2d-x dynamic-lighting demo: animated character + foreground prop tile lit by a moving cursor light, full SpriteIlluminator + TexturePacker asset workflow). -- WebGL: procedural `Light2d` rendering (closes #1430). New `Renderer.drawLight(light)` API replaces the per-light offscreen-canvas pipeline. The WebGL renderer renders lights as quads through a shared `RadialGradientEffect` fragment shader (linear radial falloff, matches `createRadialGradient`'s two-stop interpolation for visual parity with the Canvas path); no per-light GL texture is allocated. Per-light color and intensity flow through the vertex `tint` attribute (RGB = color, A = intensity), so consecutive `drawLight` calls accumulate into the quad batcher and flush together — N lights = 1 program switch + 1 flush instead of 2N + N. The Canvas renderer caches a small `Gradient` config object per light in a `WeakMap` (rebuilt only when radii / color / intensity change), rasterizes it via `Gradient.toCanvas()` into a single shared `CanvasRenderTarget`, and composites with `drawImage` — the offscreen render target is shared across every gradient in the engine, so the heavy bitmap memory stays at O(1). Light2d itself becomes pure data — no `CanvasRenderTarget`, no shader knowledge, no renderer reference. -- Light2d: `setRadii(radiusX, radiusY)` method. Updates the radii and the underlying bbox (via `Renderable.resize(width, height)`) so `getBounds()` and `getVisibleArea()` track the new size. Fixes a latent bug where mutating `radiusX/Y` after construction left the rendered light stale while the cutout pass moved. Named `setRadii` (not `resize`) so it does not shadow `Renderable.resize(width, height)`. -- WebGL: new `RadialGradientEffect` shader effect (`video/webgl/effects/radialGradient.js`). Generic procedural radial gradient — solid color at center fading linearly to transparent at the edge of the host quad. Constructor accepts `{ color, intensity }`, plus `setColor` / `setIntensity` setters. The quad's UV-space aspect handles elliptical falloff naturally — no per-axis uniform required. Color and intensity stack from two sources multiplied together: the `uColor` / `uIntensity` uniforms (the natural API for a single-instance shader attached to a renderable) AND the per-vertex tint in `aColor` (used by `WebGLRenderer.drawLight` to encode each light's color + intensity in the vertex stream so multiple lights sharing this shader batch into a single draw call). Used internally by `WebGLRenderer.drawLight`; available for any custom path that wants a soft procedural circle. +- WebGL: procedural `Light2d` rendering (closes #1430). New `Renderer.drawLight(light)` API replaces the per-light offscreen-canvas pipeline. +- Light2d: `setRadii(radiusX, radiusY)` method. Updates the radii and the underlying bbox (via `Renderable.resize(width, height)`) so `getBounds()` and `getVisibleArea()` track the new size. +- WebGL: new `RadialGradientEffect` shader effect (`video/webgl/effects/radialGradient.js`). Generic procedural radial gradient — solid color at center fading linearly to transparent at the edge of the host quad. ### Changed - Lights are now rendered inside the camera's post-effect FBO bracket — vignette, scanlines, ColorMatrix and any other camera shader effect now wrap the lighting output (closes #1398). The `Stage.draw()` lighting block has been removed; rendering happens via the world tree walk and a public `Stage.drawLighting(renderer, camera)` pass invoked by each camera (subclassable for custom lighting). - `Stage.lights.set(name, light)` (the legacy registration API) still works — entries are auto-adopted into the world tree on stage reset so they render normally and benefit from the FBO capture. New code should use `app.world.addChild(light)` directly. -- **Breaking:** `Light2d` is now centered on its `pos` (`anchorPoint = (0.5, 0.5)`), matching `Sprite` and `Ellipse(x, y, w, h)` conventions. Constructor `x`/`y` and `light.pos.x`/`y` denote the light's **center**, not the bounding-box top-left. Transforms applied via `light.scale(...)` or `light.rotate(...)` now pivot around the visual center (so a "pulse" or "flicker" effect doesn't drift the bright spot down-right). Existing call sites that passed top-left coordinates need to add `radiusX` / `radiusY`: `new Light2d(x, y, r)` → `new Light2d(x + r, y + r, r)`. Code using `light.centerOn(x, y)` is unaffected. - -### Fixed -- Lifted the historical "Canvas mode only supports one light per stage" limitation — multiple `Light2d` instances now render correctly under both Canvas and WebGL. Follow-up to #1369 (which introduced evenodd-based inverted masking for camera mask transitions): root cause was in the underlying `setMask(shape, true)` implementation on both renderers, where chained calls did not accumulate cutouts. Canvas re-added the full-canvas outer rect on every call, which cancelled out under evenodd parity; WebGL used a 1-bit `REPLACE` stencil protocol where each new mask overwrote the previous. Canvas now adds the outer rect once per mask sequence (made tractable by the evenodd groundwork from #1369), and WebGL switched to an `INCR`-based stencil protocol so each shape adds independently. -- CanvasRenderer: `setMask(shape)` with a `Rect`, `Bounds`, or `RoundRect` mask was passing arguments in the wrong order to `context.rect` / `context.roundRect` (X and Y swapped via `mask.top` / `mask.left`). Masks at off-diagonal positions (`pos.x !== pos.y`) were clipped at the wrong location. Latent because nothing in core or examples used those shape types as a mask — `Light2d`, `MaskEffect`, and other internal masks all use `Ellipse` or `Polygon`. -- `Stage.drawLighting`: ambient-overlay cutouts now align with each light's rendered gradient when the camera is scrolled or the light is parented to a translated container. `light.getVisibleArea()` returns world-space coordinates (via `getBounds()` → `getAbsolutePosition()`), but `drawLighting` runs after the world container's `translate(-cameraPos)` has been popped from the renderer — so cutouts were landing at world coords inside a camera-local FBO. The fix re-applies the camera's world-to-screen translate inside `drawLighting`. Visible only when `ambientLight` is set with a scrolling camera (e.g. a torch on the player in a dark side-scrolling level): pre-fix, the bright gradient followed the player but the dark fill stopped cutting around it, leaving a phantom hole at a fixed world position. -- WebGL: vertex attribute leak between batchers. Each batcher owns its own attribute layout (e.g. `LitQuadBatcher` has 5 attributes at stride 28; `PrimitiveBatcher` has 3 at stride 20). On batcher switch the previous batcher's enabled vertex attribute locations stayed live with their old stride/offset — when the new batcher's smaller vertex buffer was uploaded, GL validated the stale attributes against it and threw `INVALID_OPERATION: glDrawArrays: Vertex buffer is not big enough for the draw call`. Fixed by `Batcher.unbind()` (disables the locations the batcher enabled), called from `WebGLRenderer.setBatcher` whenever the active batcher changes. Latent before the lit pipeline because no two existing batchers had attribute layouts that overlapped that way. -- WebGL: `gl.useProgram` leak after `setLightUniforms`. `Camera2d.draw()` calls `renderer.setLightUniforms(...)` every frame even when the scene has zero lights, which writes `uLightCount = 0` to `LitQuadBatcher`'s shader. `GLShader.setUniform()` calls `gl.useProgram()` internally to guarantee the right program is active for the upload, leaving the GL state pointing at the lit shader even when the active batcher is the unlit one. The next sprite draw (4-attribute vertex data) was being fed to the lit shader (5 attributes), rendering as garbage. Fixed by restoring the active batcher's program after `setLightUniforms` if it isn't `LitQuadBatcher` itself. -- Light2d: stale gradient texture on radius/color/intensity change. The pre-#1430 implementation baked the gradient once at construction and re-used the same `CanvasRenderTarget` indefinitely; mutating `radiusX/Y`, `color`, or `intensity` left the rendered light out of sync with `getVisibleArea()` (the cutout pass) and the lit shader (which already used current values). The new `drawLight` path auto-invalidates on property change — the Canvas-side `Gradient` cache rebuilds on next draw when any of `radiusX`/`radiusY`/`color`/`intensity` differ from the cached entry, and the WebGL renderer reads `light.color` / `light.intensity` live each call and packs them into the per-vertex tint, so there is nothing to invalidate. -- WebGL: stale custom shader leaking past `WebGLRenderer.setBatcher`. The previous fast path returned early when the active batcher matched and no shader was provided, so a custom shader bound by a prior call (e.g. a post-effect FBO blit, or `drawLight`'s radial-gradient program) could stay bound and silently render the next sprite batch through the wrong program. `setBatcher` now always reconciles the active shader to either the explicitly-passed one or the batcher's `defaultShader`; `useShader` is internally a no-op when the shader already matches, so the hot path stays cheap. Latent before this PR because no caller stacked a custom shader followed by a default-shader call without an intervening `useShader`. -- WebGL: `QuadBatcher.blitTexture` / `LitQuadBatcher.blitTexture` did not sync `currentTextureUnit` / `boundTextures[0]` with the GL state they mutated. After a blit (typically a post-effect FBO) ran with a non-zero `currentTextureUnit`, subsequent `bindTexture2D` calls could short-circuit on the stale cached unit and bind the new texture on the wrong unit, producing rendering corruption on the next sprite batch. Both methods now set `currentTextureUnit = 0` + `boundTextures[0] = source` on bind and reset to `-1` on unbind so the next bind re-issues `gl.activeTexture` cleanly. -- `Container` clipping (`clipping = true`) misaligned when nested inside a translated, scaled, or rotated parent (closes #1349). `Container.draw` was passing world-space `bounds.left/top` to a `clipRect` API that interpreted its input as local-to-current-transform — the parent's translate was already baked into `currentTransform` by the time `clipRect` ran, so the world-space input got double-counted and the clipped region landed offset by exactly the parent's `pos`. The WebGL `clipRect` impl additionally only honored translation (it added `currentTransform.tx/ty` manually but ignored scale and rotation), so even a directly-nested clipping container under a scaled parent produced a wrong-sized scissor box. Two fixes: (1) `Container.draw` now applies its own translate before calling `clipRect` and passes container-local `(0, 0, width, height)`, letting the renderer's transform stack drive the screen-space conversion. As a side effect the clip is now the container's own rect rather than the union bounds it used to be (which silently expanded to include overflowing children, defeating the clip); the new behavior matches the documented "crop to my rect" intent; (2) WebGL `clipRect` now transforms the four input corners through `currentTransform` and uses the AABB as the screen-space scissor box, so scale and rotation are honored (rotation collapses to the rect's screen AABB — `gl.scissor` is axis-aligned). Canvas's `context.rect` + `context.clip()` was already matrix-aware, so this fix only applied to WebGL. Latent before this release because no example exercised `Container.clipping` at all. -- WebGL: pending PrimitiveBatcher vertices now drain when a `save()`/`restore()` pair changes the scissor box. Previously `WebGLRenderer.restore()` reverted the GL scissor without flushing, so vertices queued inside a deeper clip could survive past `restore()` and flush later under a more permissive scissor (or none at all) — visually escaping their clip. Latent on master because the only call site that would expose it (deeply nested `Container.clipping`) was itself broken by #1349. +- **Breaking:** `Light2d` is now centered on its `pos` (`anchorPoint = (0.5, 0.5)`), matching `Sprite` and `Ellipse(x, y, w, h)` conventions. Constructor `x`/`y` and `light.pos.x`/`y` denote the light's **center**, not the bounding-box top-left. + +### Fixed +- Lifted the historical "Canvas mode only supports one light per stage" limitation — multiple `Light2d` instances now render correctly under both Canvas and WebGL. Follow-up to #1369 (which introduced evenodd-based inverted masking for camera mask transitions): root cause was in the underlying `setMask(shape, true)` implementation on both renderers, where chained calls did not accumulate cutouts. +- CanvasRenderer: `setMask(shape)` with a `Rect`, `Bounds`, or `RoundRect` mask was passing arguments in the wrong order to `context.rect` / `context.roundRect` (X and Y swapped via `mask.top` / `mask.left`). Masks at off-diagonal positions (`pos.x !== pos.y`) were clipped at the wrong location. +- `Stage.drawLighting`: ambient-overlay cutouts now align with each light's rendered gradient when the camera is scrolled or the light is parented to a translated container. `light.getVisibleArea()` returns world-space coordinates (via `getBounds()` → `getAbsolutePosition()`), but `drawLighting` runs after the world container's `translate(-cameraPos)` has been popped from the renderer — so cutouts were landing at world coords inside a camera-local FBO. +- WebGL: fix a vertex attribute leak between batchers. Each batcher owns its own attribute layout, and on a batcher switch the previous one's enabled attribute locations stayed live with their old stride and offset, so GL validated them against the new batcher's smaller vertex buffer +- WebGL: `gl.useProgram` leak after `setLightUniforms`. `Camera2d.draw()` calls `renderer.setLightUniforms(...)` every frame even when the scene has zero lights, which writes `uLightCount = 0` to `LitQuadBatcher`'s shader. +- Light2d: stale gradient texture on radius/color/intensity change. The pre-#1430 implementation baked the gradient once at construction and re-used the same `CanvasRenderTarget` indefinitely; mutating `radiusX/Y`, `color`, or `intensity` left the rendered light out of sync with `getVisibleArea()` (the cutout pass) and the lit shader (which already used current values). +- WebGL: stale custom shader leaking past `WebGLRenderer.setBatcher`. The previous fast path returned early when the active batcher matched and no shader was provided, so a custom shader bound by a prior call (e.g. a post-effect FBO blit, or `drawLight`'s radial-gradient program) could stay bound and silently render the next sprite batch through the wrong program. +- WebGL: `QuadBatcher.blitTexture` / `LitQuadBatcher.blitTexture` did not sync `currentTextureUnit` / `boundTextures[0]` with the GL state they mutated. After a blit (typically a post-effect FBO) ran with a non-zero `currentTextureUnit`, subsequent `bindTexture2D` calls could short-circuit on the stale cached unit and bind the new texture on the wrong unit, producing rendering corruption on the next sprite batch. +- `Container` clipping (`clipping = true`) misaligned when nested inside a translated, scaled, or rotated parent (closes #1349). `Container.draw` was passing world-space `bounds.left/top` to a `clipRect` API that interpreted its input as local-to-current-transform — the parent's translate was already baked into `currentTransform` by the time `clipRect` ran, so the world-space input got double-counted and the clipped region landed offset by exactly the parent's `pos`. +- WebGL: pending PrimitiveBatcher vertices now drain when a `save()`/`restore()` pair changes the scissor box. Previously `WebGLRenderer.restore()` reverted the GL scissor without flushing, so vertices queued inside a deeper clip could survive past `restore()` and flush later under a more permissive scissor (or none at all) — visually escaping their clip. ## [19.2.0] (melonJS 2) - _2026-04-29_ From e94937d1259ddb27f7224de6742fcc2fe72f576d Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 29 Aug 2026 08:54:15 +0800 Subject: [PATCH 3/5] docs(readme): the blend mode list was stale, and particles gained a reference space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feature list named seven blend modes. There are thirteen, and as of 20.2 every one of them works identically on WebGPU, WebGL 2 and Canvas — `exclusion` landed in 20.0 and the six shader-path modes in 20.2. Understating that in the README hides the release's headline. Also mentions the particle reference space, since "blend modes" was the only particle capability called out and this is the larger addition. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3a31bb6890..4846e06fa1 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Graphics - Extensible batcher system for custom rendering pipelines, with backend-neutral vertex formats and draw topologies (declare a layout once, describe it to either GPU backend) - High DPI resolution & Canvas advanced auto scaling - Sprite with 9-slice scaling option and frame animation -- Built-in effects such as tinting, masking, and CSS-style blend modes (normal, none, additive, multiply, screen, darken, lighten) +- Built-in effects such as tinting, masking, and the full set of CSS-style blend modes (normal, none, additive, multiply, screen, exclusion, darken, lighten, overlay, hard-light, color-dodge, color-burn, soft-light, difference) — all of them supported identically on WebGPU, WebGL 2 and Canvas since 20.2 - Standard spritesheet, single and multiple Packed Textures support - Compressed texture support (DDS, KTX, KTX2, PVR, PKM) with automatic format detection and fallback - Hardware antialiasing (`antiAlias: true`) on both GPU backends — and it survives post-processing, since the offscreen targets a post-effect chain renders into are multisampled to match @@ -138,7 +138,7 @@ Core - Tween effects with multiple easing functions (Quadratic, Cubic, Elastic, Bounce, etc.) and Bezier/Catmull-Rom interpolation - Transition effects - Pooling support for object recycling -- Particle system with `ParticleEmitter` (emission rate, lifetime, velocity, gravity, blend modes) +- Particle system with `ParticleEmitter` (emission rate, lifetime, velocity, gravity, blend modes), and a reference space so particles can be measured from the emitter, from the world, or from any container — a moving emitter leaves a trail instead of dragging its cloud along - EventEmitter based event system - Persistent data storage (save/load via localStorage) - Plugin system for extending engine capabilities From 6c6b6098dc641d9082ef4e14391e1a45342f01df Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 29 Aug 2026 09:12:47 +0800 Subject: [PATCH 4/5] docs: move the documentation homepage to the root, and guard it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DOC_README.md is what `pnpm doc` passes to typedoc as `--readme`, so it is the landing page a new user reads first. It had gone unmaintained across eight releases: - the Quick Start had no `await app.init()`, mandatory since 20.0, so the very first snippet anyone copied could not run - the feature table said "WebGL & Canvas 2D" through the whole of 20.x, omitting the backend that release was built around - the shader sample taught `renderable.shader =`, deprecated since 19.2.0 in favour of `addPostEffect()` The cause is structural: two READMEs with overlapping content in different directories. The root one is updated every release; this one, tucked inside packages/melonjs, was invisible. It now sits beside the README it duplicates. Placement alone does not enforce anything, so `scripts/check-doc-readme.ts` runs before typedoc and fails the build when a sample constructs an `Application` without awaiting `init()`, when a sample uses a member marked `@deprecated` anywhere in src, or when the page stops naming a renderer the engine supports. Deprecated members are scanned from the source rather than listed, so something deprecated later is covered without anyone remembering this file. Both original bugs were verified to fail it. Also fixes the ParticleEmitter class doc, which rendered as a broken category name in the sidebar — "Particles ### Blend modes An emitter draws no pixels of its own..." — because `@category` is a block tag and the sections added in #1604 and #1606 sat after it, so they were absorbed into its value. Prose now comes first with `@category` last, and the two `@example` tags are fenced code blocks, which cannot swallow what follows. Verified in the built output: both sections render as h3 headings and no raw markdown leaks. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- .../melonjs/DOC_README.md => DOC_README.md | 27 +++-- packages/melonjs/package.json | 4 +- packages/melonjs/scripts/check-doc-readme.ts | 114 ++++++++++++++++++ packages/melonjs/src/particles/emitter.ts | 10 +- 4 files changed, 140 insertions(+), 15 deletions(-) rename packages/melonjs/DOC_README.md => DOC_README.md (71%) create mode 100644 packages/melonjs/scripts/check-doc-readme.ts diff --git a/packages/melonjs/DOC_README.md b/DOC_README.md similarity index 71% rename from packages/melonjs/DOC_README.md rename to DOC_README.md index 4c3ff3d12e..6706696261 100644 --- a/packages/melonjs/DOC_README.md +++ b/DOC_README.md @@ -30,24 +30,31 @@ const app = new Application(1218, 562, { backgroundColor: "#202020", }); +// initialize it (builds the renderer and appends the canvas) +await app.init(); + // load and add a sprite loader.preload([{ name: "player", type: "image", src: "player.png" }], () => { app.world.addChild(new Sprite(609, 281, { image: "player" })); }); ``` +> **Note:** since version 20.0, `await app.init()` is **required** after constructing the `Application`. The WebGPU backend, which `AUTO` tries first where available, acquires its GPU device asynchronously; the call resolves without suspending on the WebGL and Canvas backends. + ## Features | Feature | Description | |---------|-------------| -| **Rendering** | WebGL & Canvas 2D with automatic fallback, 3D mesh rendering with OBJ/MTL support | -| **Tiled Maps** | First-class [Tiled](https://www.mapeditor.org/) map editor support (TMX/JSON) | +| **Rendering** | WebGPU, WebGL 2 and Canvas 2D with automatic fallback — the same feature set on every backend | +| **3D** | Perspective [Camera3d](classes/Camera3d.html), mesh instancing, ground shadows, point and spot lights, glTF/GLB and OBJ/MTL loading | +| **Tiled Maps** | First-class [Tiled](https://www.mapeditor.org/) map editor support (TMX/JSON), with GPU-accelerated tile rendering for orthogonal maps | | **Sprites** | Texture atlas, animation, TexturePacker & Aseprite support | -| **Physics** | Built-in collision detection (SAT), gravity, friction | -| **Audio** | Web Audio API with format fallback | +| **Physics** | Built-in SAT collision with gravity and friction, shape-level collision events, and a [PhysicsAdapter](interfaces/PhysicsAdapter.html) interface for Box2D (planck) or Matter.js | +| **Audio** | Web Audio API with format fallback, plus procedural tone and noise generation | | **Input** | Keyboard, mouse, touch, gamepad | -| **Particles** | Configurable particle emitter system | -| **Custom Shaders** | Per-sprite [ShaderEffect](classes/ShaderEffect.html) for WebGL fragment effects | +| **Particles** | Configurable [ParticleEmitter](classes/ParticleEmitter.html), with a reference space so particles can be measured from the emitter, the world, or any container | +| **Effects** | All thirteen CSS blend modes on every renderer, tinting, masking, and camera post-processing chains | +| **Custom Shaders** | Per-sprite [ShaderEffect](classes/ShaderEffect.html) carrying both GLSL and WGSL, so one effect runs on either GPU backend | | **UI** | Built-in UI components (buttons, text input, containers) | ## Common Tasks @@ -110,19 +117,19 @@ this.body.setFriction(0.4, 0); See: [`Body`](classes/Body.html), [`collision`](modules/collision.html) #### Apply a custom shader effect to a sprite -Apply a per-sprite fragment shader using `ShaderEffect`. You only need to write the color transformation — the vertex shader and texture sampling are handled automatically. Works with WebGL, silently ignored in Canvas mode. +Apply a per-sprite fragment shader using `ShaderEffect`. You only need to write the color transformation — the vertex shader and texture sampling are handled automatically. Runs on both GPU backends — write the body once and it is realized as GLSL or WGSL for the active renderer — and is silently ignored in Canvas mode. ```javascript import { ShaderEffect } from "melonjs"; // apply a grayscale effect to a sprite -mySprite.shader = new ShaderEffect(renderer, ` +mySprite.addPostEffect(new ShaderEffect(renderer, ` vec4 apply(vec4 color, vec2 uv) { float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114)); return vec4(vec3(gray), color.a); } -`); +`)); ``` -See: [`ShaderEffect`](classes/ShaderEffect.html), [`GLShader`](classes/GLShader.html) +See: [`ShaderEffect`](classes/ShaderEffect.html), [`addPostEffect`](classes/Renderable.html#addposteffect) ## Links diff --git a/packages/melonjs/package.json b/packages/melonjs/package.json index c73c3814c3..960561b802 100644 --- a/packages/melonjs/package.json +++ b/packages/melonjs/package.json @@ -81,8 +81,8 @@ "build": "pnpm lint && tsx scripts/build.js && pnpm types", "dist": "pnpm clean && pnpm lint && pnpm vitest run && pnpm build && pnpm doc && cp ../../README.md .", "dist:publish": "pnpm clean && pnpm lint && pnpm build && pnpm doc && cp ../../README.md .", - "doc": "typedoc src/index.ts --tsconfig tsconfig.build.json --readme DOC_README.md --hideGenerator --name 'melonJS' --navigation.includeCategories true --categorizeByGroup false", - "doc:watch": "typedoc src/index.ts --tsconfig tsconfig.build.json --readme DOC_README.md --hideGenerator --name 'melonJS' --navigation.includeCategories true --categorizeByGroup false --watch --skipErrorChecking --preserveWatchOutput --logLevel Error", + "doc": "tsx scripts/check-doc-readme.ts && typedoc src/index.ts --tsconfig tsconfig.build.json --readme ../../DOC_README.md --hideGenerator --name 'melonJS' --navigation.includeCategories true --categorizeByGroup false", + "doc:watch": "typedoc src/index.ts --tsconfig tsconfig.build.json --readme ../../DOC_README.md --hideGenerator --name 'melonJS' --navigation.includeCategories true --categorizeByGroup false --watch --skipErrorChecking --preserveWatchOutput --logLevel Error", "serve": "serve docs", "prepublishOnly": "pnpm dist:publish", "clean": "tsx scripts/clean.ts", diff --git a/packages/melonjs/scripts/check-doc-readme.ts b/packages/melonjs/scripts/check-doc-readme.ts new file mode 100644 index 0000000000..2e0b0d08e1 --- /dev/null +++ b/packages/melonjs/scripts/check-doc-readme.ts @@ -0,0 +1,114 @@ +/** + * Guard the documentation homepage against going stale. + * + * `pnpm doc` builds the API docs with `--readme ../../DOC_README.md`, so that + * file is the landing page a new user reads first. Being a second README, it + * drifts from the one that actually gets maintained: it sat untouched across + * eight releases, its Quick Start missing the `await app.init()` that became + * mandatory in 20.0 — so the very first snippet anyone copied could not run — + * and teaching `renderable.shader =`, deprecated since 19.2.0. + * + * Moving it beside the root README makes that visible. This makes it fail the + * build, by checking the things that actually went wrong rather than trying to + * validate prose. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, "../../.."); +const docReadme = resolve(repoRoot, "DOC_README.md"); +const srcDir = resolve(here, "../src"); + +const source = readFileSync(docReadme, "utf8"); +const problems: string[] = []; + +/** every ```js / ```javascript fence on the page */ +const samples = [ + ...source.matchAll(/```(?:js|javascript)\n([\s\S]*?)```/g), +].map((m) => { + return m[1]; +}); + +if (samples.length < 4) { + problems.push( + `only ${samples.length} code samples found — a rewrite that drops the fences would make every check below vacuous`, + ); +} + +// `init()` became mandatory in 20.0: the WebGPU device is acquired +// asynchronously, so a sample that constructs an Application without awaiting +// it is one a reader cannot run. +for (const sample of samples) { + if ( + sample.includes("new Application(") && + !/await\s+\w+\.init\(\)/.test(sample) + ) { + problems.push("a sample constructs an Application but never awaits init()"); + } +} + +// The renderer list is the other thing that rotted: the feature table said +// "WebGL & Canvas 2D" for the whole of 20.x, omitting the backend that release +// was built around. +for (const backend of ["WebGPU", "WebGL", "Canvas"]) { + if (!source.includes(backend)) { + problems.push(`the page never mentions ${backend}`); + } +} + +// Deprecated members are collected from the source rather than hardcoded, so +// something deprecated later is covered without anyone remembering to come +// back here. +const walk = (dir: string): string[] => { + return readdirSync(dir).flatMap((name) => { + const full = join(dir, name); + if (statSync(full).isDirectory()) { + return walk(full); + } + return /\.(js|ts)$/.test(name) ? [full] : []; + }); +}; + +const deprecated = new Set(); +for (const file of walk(srcDir)) { + const body = readFileSync(file, "utf8"); + for (const m of body.matchAll( + /@deprecated[\s\S]{0,400}?\*\/\s*(?:get\s+|set\s+)?([A-Za-z_]\w*)\s*[(=]/g, + )) { + // short names produce false hits against ordinary prose + if (m[1].length > 3) { + deprecated.add(m[1]); + } + } +} + +if (deprecated.size === 0) { + problems.push( + "found no @deprecated members to check against — the scan is broken", + ); +} + +for (const sample of samples) { + for (const name of deprecated) { + if (new RegExp(`\\.${name}\\s*[(=]`).test(sample)) { + problems.push(`a sample uses the deprecated \`${name}\``); + } + } +} + +if (problems.length > 0) { + console.error("DOC_README.md is out of date:\n"); + for (const p of [...new Set(problems)]) { + console.error(` - ${p}`); + } + console.error( + "\nIt is the documentation homepage — the first thing a new user reads.\n", + ); + process.exit(1); +} + +console.log( + `check-doc-readme: ${samples.length} samples checked against ${deprecated.size} deprecated members`, +); diff --git a/packages/melonjs/src/particles/emitter.ts b/packages/melonjs/src/particles/emitter.ts index 5b9fe908d2..a8d1aa3c89 100644 --- a/packages/melonjs/src/particles/emitter.ts +++ b/packages/melonjs/src/particles/emitter.ts @@ -62,7 +62,6 @@ function clampMinToMax( /** * Particle Emitter Object. - * @category Particles * * ### Blend modes * @@ -72,8 +71,10 @@ function clampMinToMax( * already alive and, through {@link ParticleEmitterSettings.blendMode}, the * ones emitted afterwards. The change is picked up on the emitter's next * `update`, so within the same frame. - * @example + * + * ```js * emitter.blendMode = "overlay"; // live particles AND future ones + * ``` * * ### Reference space * @@ -97,9 +98,12 @@ function clampMinToMax( * `clipping` or a `backgroundColor` on the emitter would be applied in the * emitter's own frame rather than the particles', so neither composes with * this. - * @example + * + * ```js * // exhaust that stays where it was emitted * const emitter = new ParticleEmitter(x, y, { referenceSpace: "world" }); + * ``` + * @category Particles */ export default class ParticleEmitter extends Container { /** From 7e856c49b24038a1affa6bc035d1e6c5b444c3e5 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 29 Aug 2026 09:19:38 +0800 Subject: [PATCH 5/5] chore(release): date 20.2.0 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N --- packages/melonjs/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 5b4210c14e..b764c2d5a3 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [20.2.0] (melonJS 2) - _unreleased_ +## [20.2.0] (melonJS 2) - _2026-08-29_ **Highlights:** all thirteen blend modes the engine names now work on all three renderers, closing the last gap where the Canvas fallback was the most capable backend for blending. Particles gain a reference space, so an emitter can leave a trail instead of dragging its cloud along. Two measured wins: the particle update loop drops ~37%, and WebGPU quad submission ~42%, taking that backend from slower than WebGL 2 to marginally faster.