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
4 changes: 4 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
- **`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

### 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.

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

- **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
Expand Down
71 changes: 60 additions & 11 deletions packages/melonjs/src/particles/particle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ export default class Particle extends Renderable {
wind: number;
followTrajectory: boolean;
onlyInViewport: boolean;
accurateBounds: boolean;
_deltaInv: number;
_halfW: number;
_halfH: number;
Expand All @@ -60,6 +59,27 @@ export default class Particle extends Renderable {
this.onResetEvent(emitter, true);
}

/**
* Whether the bounds need recomputing before anyone reads them.
*
* A `Renderable` refreshes its bounds eagerly, from a callback fired on
* every `pos` assignment. That is the right trade for a scene object, and
* the wrong one for a particle: `update()` writes `pos.x` and `pos.y`
* separately, so the callback fires TWICE per particle per frame, and both
* runs happen before `currentTransform` is rebuilt — deriving bounds from
* the previous frame's matrix and then throwing that away. Measured, the
* redundant pass was about two thirds of the whole particle update loop.
*
* So a particle invalidates instead of recomputing, and pays once, on
* read, for particles something actually looks at.
*
* Deliberately not a `#private` field: `updateBounds()` is reached from the
* base constructor chain (`Polygon.setVertices`) before a subclass's field
* initializers have run, and writing an undeclared private field throws.
* @ignore
*/
_boundsDirty = true;

/**
* @ignore
*/
Expand Down Expand Up @@ -100,6 +120,22 @@ export default class Particle extends Renderable {
// Particle will always update
this.alwaysUpdate = true;

// Swap the position callback `Renderable` installs — which recomputes
// bounds on every single assignment — for one that just marks them
// stale. See `_boundsDirty`. Re-installed on every reset because a
// pooled instance may have been handed back with the default.
//
// The cast is the `pos` type mismatch tracked in melonjs/melonJS#817:
// `pos` is declared `Vector2d` up the shape chain but is really an
// `ObservableVector3d`, so `setCallback` is invisible from TypeScript.
(
this.pos as unknown as { setCallback: (cb: () => void) => void }
).setCallback(() => {
this._boundsDirty = true;
this.isDirty = true;
});
this._boundsDirty = true;

// Anchor is baked into currentTransform (see update()), so reset the
// renderable anchor to (0,0) — otherwise updateBounds() would apply
// the default 0.5/0.5 offset on top of the already-anchored matrix.
Expand Down Expand Up @@ -155,9 +191,6 @@ export default class Particle extends Renderable {
// Set if the particle update only in Viewport
this.onlyInViewport = emitter.settings.onlyInViewport;

// whether to refresh bounds every frame (debug-grade hitbox accuracy)
this.accurateBounds = emitter.settings.accurateBounds;

// read the cached delta inverse from the emitter (constant after boot)
this._deltaInv = emitter._deltaInv;

Expand Down Expand Up @@ -279,13 +312,6 @@ export default class Particle extends Renderable {
1,
);

// Refresh bounds only when the user opted in to per-frame accuracy.
// Without this, the hitbox lags one frame behind the visual — fine for
// viewport culling, visible only if you draw debug bounds.
if (this.accurateBounds) {
this.updateBounds();
}

// mark as dirty if the particle is not dead yet
this.isDirty = this.inViewport || !this.onlyInViewport;

Expand Down Expand Up @@ -314,7 +340,30 @@ export default class Particle extends Renderable {
* top of a matrix that already contains it, counting it twice.
* @ignore
*/
/**
* Bounds are recomputed here rather than when `pos` moves, so a particle
* nothing looks at this frame pays nothing at all.
*
* `updateBounds()` deliberately keeps its eager contract — a caller that
* asks for a recompute, such as {@link Container} aggregating child bounds
* under `enableChildBoundsUpdate`, still gets fresh values back.
* @ignore
*/
override getBounds() {
const bounds = super.getBounds();
if (this._boundsDirty) {
this.updateBounds();
}
return bounds;
}

override updateBounds(absolute = true) {
// this IS the recompute, so whatever invalidated the bounds is now
// satisfied. Clearing here (rather than only in `getBounds`) keeps an
// explicit caller — `accurateBounds`, or a container aggregating child
// bounds — from leaving the flag set and paying for a second pass.
this._boundsDirty = false;

if (!this.isRenderable) {
return super.updateBounds(absolute);
}
Expand Down
14 changes: 9 additions & 5 deletions packages/melonjs/src/particles/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,12 +239,16 @@ export interface ParticleEmitterSettings {
framesToSkip: number;

/**
* When `true`, each particle refreshes its bounding box every frame so the
* hitbox tracks the visual exactly (useful for debug visualization or
* collision queries). When `false` (default), bounds reflect the previous
* frame's transform — sufficient for viewport culling and significantly
* cheaper at high particle counts.
* No longer has any effect, and kept only so existing configurations keep
* working.
*
* It used to trade hitbox accuracy for speed, because bounds were
* recomputed eagerly on every position write — twice per particle per
* frame, from a transform that had not been rebuilt yet. Particles now
* invalidate on write and recompute once on read, so the bounds a reader
* gets are always current.
* @default false
* @deprecated since 20.2.0 — bounds are always up to date; remove it
*/
accurateBounds: boolean;

Expand Down
Loading
Loading