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
11 changes: 7 additions & 4 deletions packages/examples/src/examples/afterBurner/GameController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,8 +359,9 @@ export class GameController extends Renderable {
angleVariation: Math.PI * 2,
minLife: 50,
maxLife: 110,
speed: 3,
speedVariation: 2,
// raised from 3 with the 20.2 particle transform fix
speed: 4.5,
speedVariation: 3,
minStartScale: 0.8,
maxStartScale: 1.4,
minEndScale: 0.05,
Expand Down Expand Up @@ -543,8 +544,10 @@ export class GameController extends Renderable {
angleVariation: Math.PI * 2,
minLife: 320,
maxLife: 720,
speed: 7,
speedVariation: 4,
// raised from 7 with the 20.2 particle transform fix — see the
// CHANGELOG; bursts no longer gain radius as they fade
speed: 10,
speedVariation: 5.5,
minStartScale: 0.6,
maxStartScale: 1.4,
minEndScale: 0.05,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
/**
* melonJS — particle reference spaces.
* Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License.
* See `packages/examples/LICENSE.md` for full license + asset credits.
*/
import {
Application,
Container,
event,
game,
ParticleEmitter,
Renderable,
Text,
Vector2d,
video,
} from "melonjs";
import { createExampleComponent } from "../utils";

const WIDTH = 1100;
const HEIGHT = 640;

// One lane per reference space. Identical emitters, identical motion — the
// only thing that differs is what a particle's position is measured against,
// which is the whole point: any difference you see on screen is the setting.
const LANE_HEIGHT = 168;
const LANE_TOP = 118;
const TRAVEL = WIDTH - 160;

/** the moving object each emitter rides on */
class Ship extends Renderable {
constructor(
x: number,
y: number,
private readonly tint: string,
) {
super(x, y, 34, 20);
this.anchorPoint.set(0.5, 0.5);
}

// biome-ignore lint/suspicious/noExplicitAny: renderer type varies by backend
override draw(renderer: any) {
const x = this.pos.x;
const y = this.pos.y;
renderer.setColor(this.tint);
// a blunt arrow, pointing the way it travels
renderer.fillRect(x, y + 6, 26, 9);
renderer.fillEllipse(x + 28, y + 10, 8, 10);
renderer.setColor("rgba(255, 255, 255, 0.75)");
renderer.fillRect(x + 4, y + 8, 12, 3);
}
}

const createGame = async () => {
try {
const app = new Application(WIDTH, HEIGHT, {
parent: "screen",
scaleMethod: "fit",
renderer: video.AUTO,
});
await app.init();
} catch {
alert("Your browser does not support HTML5 canvas.");
return;
}

const world = game.world;

// backdrop, dark enough that the particles read at every lane
const backdrop = new Renderable(0, 0, WIDTH, HEIGHT);
backdrop.anchorPoint.set(0, 0);
// biome-ignore lint/suspicious/noExplicitAny: renderer type varies by backend
(backdrop as any).draw = (renderer: any) => {
renderer.setColor("#141326");
renderer.fillRect(0, 0, WIDTH, HEIGHT);
for (let i = 0; i < 3; i++) {
renderer.setColor(i % 2 === 0 ? "#191833" : "#15142b");
renderer.fillRect(0, LANE_TOP + i * LANE_HEIGHT - 24, WIDTH, LANE_HEIGHT);
}
};
world.addChild(backdrop, 0);

const emitterSettings = {
width: 6,
height: 6,
totalParticles: 220,
maxParticles: 5,
frequency: 24,
angle: Math.PI,
angleVariation: 0.55,
minLife: 1400,
maxLife: 2100,
speed: 0.4,
speedVariation: 0.3,
minStartScale: 1.6,
maxStartScale: 2.6,
minEndScale: 0.2,
maxEndScale: 0.4,
framesToSkip: 0,
};

const label = (text: string, sub: string, y: number) => {
const heading = new Text(28, y, {
font: "Arial",
size: 17,
bold: true,
fillStyle: "#ffffff",
text,
});
heading.floating = true;
world.addChild(heading, 20);

const caption = new Text(28, y + 22, {
font: "Arial",
size: 13,
fillStyle: "#a9a7c4",
text: sub,
});
caption.floating = true;
world.addChild(caption, 20);
};

// ---- lane 1: local — the cloud is welded to the ship ------------------
const localY = LANE_TOP;
const localShip = new Ship(80, localY + 40, "#ff8a5c");
world.addChild(localShip, 5);
const localEmitter = new ParticleEmitter(80, localY + 50, {
...emitterSettings,
tint: "#ff8a5c",
// the default — stated explicitly here because the whole example is
// about this one setting
referenceSpace: "local",
});
world.addChild(localEmitter, 4);
localEmitter.streamParticles();
label(
'referenceSpace: "local"',
"the default — particles are measured from the emitter, so the cloud travels with it",
localY - 46,
);

// ---- lane 2: world — the trail is left behind -------------------------
const worldY = LANE_TOP + LANE_HEIGHT;
const worldShip = new Ship(80, worldY + 40, "#5cd0ff");
world.addChild(worldShip, 5);
const worldEmitter = new ParticleEmitter(80, worldY + 50, {
...emitterSettings,
tint: "#5cd0ff",
referenceSpace: "world",
});
world.addChild(worldEmitter, 4);
worldEmitter.streamParticles();
label(
'referenceSpace: "world"',
"measured from the container the emitter sits in — the ship flies away and leaves the smoke behind",
worldY - 46,
);

// ---- lane 3: custom — travel without the bob --------------------------
// The emitter rides a ship that BOBS vertically, while the reference
// container only moves horizontally. So the particles inherit the travel
// and not the bob — the case that is neither "welded on" nor "left behind".
const customY = LANE_TOP + LANE_HEIGHT * 2;
const carriage = new Container(0, 0, WIDTH, LANE_HEIGHT);
carriage.anchorPoint.set(0, 0);
world.addChild(carriage, 3);

const customShip = new Ship(80, customY + 40, "#b98cff");
world.addChild(customShip, 5);
const customEmitter = new ParticleEmitter(80, customY + 50, {
...emitterSettings,
tint: "#b98cff",
referenceSpace: carriage,
});
world.addChild(customEmitter, 4);
customEmitter.streamParticles();
label(
"referenceSpace: <Container>",
"measured from a container sliding side to side — the whole trail rides along with it, and never inherits the ship's bob",
customY - 46,
);

const heading = new Text(WIDTH / 2, 30, {
font: "Arial",
size: 23,
bold: true,
fillStyle: "#ffffff",
textAlign: "center",
text: "Particle reference spaces",
});
heading.floating = true;
world.addChild(heading, 20);

const subheading = new Text(WIDTH / 2, 60, {
font: "Arial",
size: 13,
fillStyle: "#a9a7c4",
textAlign: "center",
text: "three identical emitters — only what their particles are measured against differs",
});
subheading.floating = true;
world.addChild(subheading, 20);

// ---- motion -----------------------------------------------------------
let t = 0;
const start = new Vector2d(80, 0);

const onUpdate = () => {
t += 1 / 60;

// A triangle wave, so the ships turn around at each end instead of
// snapping back to the left. The turn is the most legible moment in
// the whole example: a "local" cloud simply reverses along with its
// emitter, while a "world" ship drives back THROUGH the trail it just
// laid down — which is only possible if those particles really did
// stay where they were emitted.
const sweep = (t * 150) % (TRAVEL * 2);
const outbound = sweep <= TRAVEL;
const x = start.x + (outbound ? sweep : TRAVEL * 2 - sweep);

// a rotation on the emitters, so the correction is exercised under a
// rotated frame rather than only in the unit tests
const spin = Math.sin(t * 2) * 0.25;
// exhaust always trails the direction of travel
const exhaust = outbound ? Math.PI : 0;

localShip.pos.x = x;
localShip.flipX(!outbound);
localEmitter.pos.x = x;
localEmitter.settings.angle = exhaust;
localEmitter.currentTransform.identity();
localEmitter.rotate(spin);

worldShip.pos.x = x;
worldShip.flipX(!outbound);
worldEmitter.pos.x = x;
worldEmitter.settings.angle = exhaust;
worldEmitter.currentTransform.identity();
worldEmitter.rotate(spin);

// The carriage slides side to side. Nothing else does — so anything
// that moves with it is moving because the particles are measured
// against it, which is the only way to see a custom frame at work.
carriage.pos.x = Math.sin(t * 0.9) * 130;

// The bob, by contrast, is on the SHIP (and therefore the emitter) and
// never on the carriage — so the particles do not inherit it. They are
// emitted at whatever height the ship happened to be, and stay there.
const bob = Math.sin(t * 4) * 26;
customShip.pos.x = x;
customShip.pos.y = customY + 40 + bob;
customShip.flipX(!outbound);
customEmitter.pos.x = x;
customEmitter.pos.y = customY + 50 + bob;
customEmitter.settings.angle = exhaust;
};

event.on(event.GAME_UPDATE, onUpdate);

return () => {
event.off(event.GAME_UPDATE, onUpdate);
};
};

export const ExampleParticleReferenceSpace = createExampleComponent(createGame);
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,9 @@ class PathEnemyEntity extends Sprite {
angleVariation: 6.283185307179586,
minLife: 400,
maxLife: 800,
speed: 3,
// raised from 3 with the 20.2 particle transform fix — see
// the CHANGELOG; bursts no longer gain radius as they fade
speed: 4.5,
autoDestroyOnComplete: true,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@ export const spawnSparkBurst = (
y: number,
count: number,
tint: string,
speed = 4,
// Raised from 4 alongside the particle transform fix in 20.2. Particles
// used to be drawn at up to twice the displacement they had actually
// simulated, which inflated a burst's radius as it faded; now that they
// land where they simulate, the speed has to be what it always looked
// like rather than what it was set to.
speed = 6,
): void => {
const emitter = new ParticleEmitter(x, y, {
width: 4,
Expand Down
13 changes: 13 additions & 0 deletions packages/examples/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ const ExampleBlendModeRenderables = lazy(() =>
(m) => ({ default: m.ExampleBlendModeRenderables }),
),
);
const ExampleParticleReferenceSpace = lazy(() =>
import(
"./examples/particleReferenceSpace/ExampleParticleReferenceSpace"
).then((m) => ({ default: m.ExampleParticleReferenceSpace })),
);
const ExampleAfterBurner = lazy(() =>
import("./examples/afterBurner/ExampleAfterBurner").then((m) => ({
default: m.ExampleAfterBurner,
Expand Down Expand Up @@ -296,6 +301,14 @@ const examples: {
description:
"The same blend mode applied to a sprite, text, a shape fill, particles and an image layer.",
},
{
component: <ExampleParticleReferenceSpace />,
label: "Particle Reference Space",
path: "particle-reference-space",
sourceDir: "particleReferenceSpace",
description:
"Particles measured from the emitter, from the world, or from another container.",
},
{
component: <ExampleAfterBurner />,
label: "AfterBurner Clone",
Expand Down
4 changes: 4 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
## [20.2.0] (melonJS 2) - _unreleased_

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

### Fixed
- **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)
Expand Down
Loading
Loading