Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
### 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
- **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

### Fixed
- **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

- **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)
Expand All @@ -18,11 +20,6 @@
- **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`

### Changed
- **A game already setting one of those six modes on WebGL or WebGPU will look different.** They previously fell back to `"normal"` silently and rendered unblended; they now blend for real. This is the intended fix, but it is a visible change for any existing scene that set one — and for code branching on `setBlendMode`'s return value to detect support, which now reports them honoured
- The advanced modes cost **one destination capture and one composite per draw call**, 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 an entire tilemap layer in `overlay`
- One path does not support them and falls back to `"normal"` with a one-time console warning rather than silently: **3D meshes** (`drawMesh`, where the offscreen's separate depth buffer would break subsequent depth testing). Gradient fills, shapes, sprites, text, image layers and particles all blend normally. `exclusion`, added in 20.0.0, is unaffected on every path since it needs no shader

## [20.1.1] (melonJS 2) - _2026-08-25_

### Fixed
Expand Down
14 changes: 14 additions & 0 deletions packages/melonjs/src/video/webgpu/webgpu_renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -1559,6 +1559,20 @@ export default class WebGPURenderer extends Renderer {
this.pendingColorClear = false;
this.pendingDepthClear = false;
this.currentPipeline = null;

// Every batcher's pending state belongs to the frame being thrown
// away — queued vertices, the current effect and material, segment
// entries, composed bind groups. Carrying it into the next frame is
// not merely stale, it is unsafe: a segment entry holds a
// `GPUTextureView` into a texture that `destroyRetiredTextures()`
// below is about to free, so the next `composeSegmentGroup` would
// build a bind group over destroyed resources. The recorded draws
// that referenced them died with the command buffer, so there is
// nothing to preserve.
for (const batcher of this.batchers.values()) {
batcher.reset();
}

// the recorded draws are dropped with the command buffer, so any
// texture retired during the frame can go now
this.destroyRetiredTextures();
Expand Down
67 changes: 67 additions & 0 deletions packages/melonjs/tests/webgpu_mesh_depth.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,80 @@ describe("WebGPU mesh depth policy", () => {
pendingColorClear: true,
pendingDepthClear: true,
currentPipeline: null,
batchers: new Map(),
destroyRetiredTextures() {},
};
WebGPURenderer.prototype.abandonFrame.call(stub);
expect(stub.pendingDepthClear).toBe(false);
expect(stub.pendingColorClear).toBe(false);
});

it("abandonFrame drops batcher state BEFORE retired textures die", () => {
// A batcher's segment entries hold `GPUTextureView`s into textures
// that `destroyRetiredTextures()` is about to free. Carrying them
// into the next frame would compose a bind group over destroyed
// resources — so the reset has to happen, and has to happen first.
const order = [];
const batcher = {
reset() {
order.push("reset");
},
};
const stub = {
renderPass: null,
commandEncoder: null,
frameTextureView: null,
frameTexture: null,
currentRenderTarget: null,
pendingColorClear: false,
pendingDepthClear: false,
currentPipeline: null,
batchers: new Map([["quad", batcher]]),
destroyRetiredTextures() {
order.push("destroy");
},
};

WebGPURenderer.prototype.abandonFrame.call(stub);

expect(
order,
"batcher state outlived the textures it referenced",
).toEqual(["reset", "destroy"]);
});

it("abandonFrame resets EVERY registered batcher", () => {
// quad, primitive, mesh and their lit variants all hold pending
// vertices; leaving any of them armed replays a dead frame's work
const reset = [];
const make = (name) => {
return [
name,
{
reset() {
reset.push(name);
},
},
];
};
const stub = {
renderPass: null,
commandEncoder: null,
frameTextureView: null,
frameTexture: null,
currentRenderTarget: null,
pendingColorClear: false,
pendingDepthClear: false,
currentPipeline: null,
batchers: new Map([make("quad"), make("primitive"), make("mesh")]),
destroyRetiredTextures() {},
};

WebGPURenderer.prototype.abandonFrame.call(stub);

expect(reset.sort()).toEqual(["mesh", "primitive", "quad"]);
});

it("beginPass realizes the policy table end to end (real prototype)", () => {
const passes = [];
const stub = {
Expand Down
Loading