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
1 change: 1 addition & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
- UI: `UITextButton#bitmapText`, for `label`

### Fixed
- Particles: particles are on their emitter's `visibilityLayer`, and follow a change to it. They stayed on layer 1, so a camera whose `cullMask` drew only the emitter's layer drew none of its particles
- Loader: assets on the game's own origin are requested with its cookies and HTTP auth, so a game behind a login no longer gets a 403 for every asset ([#1801](https://github.com/melonjs/melonJS/issues/1801))
- Loader: an asset on another origin loads whenever its server allows it (CORS), without `crossOrigin`, and one it does not allow fails at load with an error naming it, instead of later with "Unexpected end of input" or "The source image could not be decoded". `crossOrigin` now only applies to the `<video>`, `<script>` and glTF image elements the loader creates; `"use-credentials"` also sends credentials, as `withCredentials` does
- Text: an outline no longer hides thin or small letters, being drawn behind them, and is no longer cut off at the edges of the text. Its corners are round by default; only its outer half shows now, so double `lineWidth` to keep its weight, or set `paintOrder: "fill"` and `lineJoin: "miter"` for the old look
Expand Down
3 changes: 3 additions & 0 deletions packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,9 @@ minimap.cullMask = ~PARTICLES; // the minimap draws every layer but 2
set it on an emitter and none of its particles are drawn. The world itself
obeys it too, and so does what it contains: a child is drawn only if it and
every container above it share a bit with the camera.
- Particles are on their emitter's layers, and follow a change to them on the
emitter's next update, so a camera that draws only the emitter's layer draws
its particles too.
- A renderable on layer `0` is drawn by no camera; a `cullMask` of `0` draws
nothing.
- It decides drawing only: what a camera leaves out still updates, collides and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@ answer when you want discrete puffs rather than a continuous ribbon.
|---|---|
| trail looks flat under a `Camera3d` | `Trail` is 2D; use `Trail3d` |
| particles clutter a minimap or a second camera | every camera draws them: set the emitter's `visibilityLayer` to a layer that camera's `cullMask` leaves out (see `melonjs-camera-and-drawing`) |
| a camera that draws only the emitter's layer shows no particles | before 20.13 particles stayed on layer 1 whatever their emitter was on; update |
| `Trail3d` draws nothing, with a console warning | it needs a `Camera3d`; the app's `cameraClass` is still the 2D default |
| `trail.tint` does nothing | `Trail` cannot be tinted at all; `Trail3d` can |
| assigning `maxPoints` throws | `Trail3d` sizes its geometry once; pass `length` to the constructor |
Expand Down
29 changes: 29 additions & 0 deletions packages/melonjs/src/particles/emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,13 @@ function particleFrames(
* emitter.blendMode = "overlay"; // live particles AND future ones
* ```
*
* ### Visibility layers
*
* Particles are on their emitter's {@link Renderable#visibilityLayer}: they
* are born on it, and follow a change to it on the emitter's next `update`.
* So a camera whose {@link Camera2d#cullMask} draws only the emitter's layer
* draws the emitter's particles as well.
*
* ### Reference space
*
* A particle stores a position, and
Expand Down Expand Up @@ -294,6 +301,15 @@ export default class ParticleEmitter extends Container {
*/
#appliedBlendMode: string = "normal";

/**
* The visibility layers last fanned out to the particles, so a change to
* the emitter's {@link Renderable#visibilityLayer} can be detected per
* frame.
* @ignore
* @internal
*/
#appliedVisibilityLayer = 1;

/**
* Decide whether this emitter's particles have to be depth sorted.
*
Expand Down Expand Up @@ -800,6 +816,19 @@ export default class ParticleEmitter extends Container {
this.isDirty = true;
}

// The same for the visibility layers. A camera draws a particle only
// if the particle AND its emitter are on a layer it draws, and a
// particle is born on the emitter's layers (see `Particle`), so the
// ones alive have to follow a change too: or a camera that draws
// only the emitter's new layer would leave every one of them out.
if (this.visibilityLayer !== this.#appliedVisibilityLayer) {
this.#appliedVisibilityLayer = this.visibilityLayer;
for (const particle of this.children ?? []) {
particle.visibilityLayer = this.visibilityLayer;
}
this.isDirty = true;
}

// frame-skip: only do the bookkeeping when actually configured.
// Defaults to 0 (every frame), and that path is the hot one.
if (this.settings.framesToSkip > 0) {
Expand Down
4 changes: 4 additions & 0 deletions packages/melonjs/src/particles/particle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,10 @@ export default class Particle extends Renderable {
this.tint.parseCSS(emitter.settings.tint);
}

// on the emitter's layers, so a camera that draws the emitter draws
// its particles (a pooled particle would otherwise keep the layer of
// its last life)
this.visibilityLayer = emitter.visibilityLayer;
this.blendMode = emitter.settings.textureAdditive ? "additive" : "normal";

if (emitter.settings.blendMode !== "normal") {
Expand Down
62 changes: 62 additions & 0 deletions packages/melonjs/tests/camera-cull-mask.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,68 @@ describe("visibilityLayer and cullMask", () => {
emitter.destroy();
});

// an emitter with its particles counted as they are drawn
const countedEmitter = (layer) => {
const emitter = new ParticleEmitter(0, 0, {
width: 10,
height: 10,
totalParticles: 6,
});
if (layer !== undefined) {
emitter.visibilityLayer = layer;
}
emitter.burstParticles();
emitter.draws = 0;
for (const particle of emitter.getChildren()) {
particle.inViewport = true;
particle.draw = () => {
emitter.draws++;
};
}
return emitter;
};

it("draws an emitter's particles in a camera that draws only the emitter's layer", () => {
const emitter = countedEmitter(2);
const count = emitter.getChildren().length;
expect(count).toBeGreaterThan(0);
for (const particle of emitter.getChildren()) {
expect(particle.visibilityLayer).toBe(2);
}
const camera = corner();
camera.cullMask = 2;
drawWith(camera, emitter);
expect(emitter.draws).toBe(count);
emitter.destroy();
});

it("moves the particles already alive when the emitter's layer changes", () => {
const emitter = countedEmitter();
const count = emitter.getChildren().length;
const camera = corner();
camera.cullMask = 2;
emitter.visibilityLayer = 2;
// picked up on the emitter's next update
emitter.update(16);
for (const particle of emitter.getChildren()) {
expect(particle.visibilityLayer).toBe(2);
}
drawWith(camera, emitter);
expect(emitter.draws).toBe(emitter.getChildren().length);
expect(count).toBeGreaterThan(0);
emitter.destroy();
});

it("does not let a pooled particle keep the layer of its last life", () => {
const first = countedEmitter(4);
first.destroy();
const second = countedEmitter();
for (const particle of second.getChildren()) {
expect(particle.visibilityLayer).toBe(1);
}
second.destroy();
});

it("draws nothing of a world on a layer the camera leaves out", () => {
const camera = corner();
const world = new Container(0, 0, 100, 100);
Expand Down
Loading