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 @@ -19,6 +19,7 @@
- `BloomEffect`: the bright parts of a frame bleed light into the pixels around them, which is what makes emitters, neon and specular highlights read as light rather than as bright paint. `threshold`, `intensity` and `radius` are settable live, and it sizes itself from the renderer. One gather pass with a soft knee, so a light fading through the threshold ramps in rather than popping. `GlowEffect` is an outline drawn outside a sprite's silhouette and returns early on an opaque fragment, so it never was the screen bloom people reached for it as

### Fixed
- A single post effect on a renderable that draws with primitives applies, instead of silently doing nothing. One effect is normally applied by drawing the renderable with the effect's own program rather than capturing it offscreen, which costs no render target but only works when everything the renderable draws is a textured quad: `fillRect` and the shape dispatch go to a batcher that never reads that shader. Measured with a `DesaturateEffect` over pure red, one effect read back `[255, 0, 0]` untouched while two read `[76, 76, 76]`, so only the single-effect case was ever wrong. `Trail` was affected and is fixed; a renderable of your own that draws with primitives sets `postEffectNeedsCapture` to opt in, and everything else keeps the cheap path exactly as before. Both GPU backends
- Input: a region covered by something that consumed the pointer is told it lost it, instead of being left in its hover state. A widget only ever got its leave by the pointer going outside its own bounds, so a button half covered by a panel stayed lit when the pointer slid off its exposed part and onto the panel, which never takes it out of the button's bounds. A consumed move now carries on down the candidate list, not to offer the event to anything underneath but to take it away from whatever still holds it. A consumed press, release or wheel does not, since none of those says where the pointer is
- Input: the pointer hit test asks the renderable that is drawn on top first. `pos.z` is container-local, because `autoDepth` numbers each container's own children from 1, and `Container#draw` never compares across containers: it recurses, so a child's z is only ever weighed against its siblings. The hit test sorted one flat list of broadphase candidates on raw z instead, so a button at local z 8 inside a low panel outranked an entire panel stacked on top of it and **a covered widget answered clicks and lit up on hover right through whatever was drawn over it**. Each pair is now resolved where `draw` resolves it, between the two siblings whose order decides which subtree paints last, with a child ahead of the container holding it and equal sibling z falling back to child order. Ordering between siblings of one container is unchanged, which is every case a game with a single container has
- The loading screen's progress bar is the public `ProgressBar`, and the loader subscription moved out of it. It used to call `on(LOADER_PROGRESS, ...)` from its own constructor, which is what kept it private: a renderable that subscribes to the loader can only ever show loading. It also stored its fill as a pixel count rather than a ratio, so a viewport resize part way through a load left the fill at the old scale until the next asset happened to land. Output is unchanged but for the fill's leading edge, which no longer truncates to a whole pixel
Expand Down
30 changes: 29 additions & 1 deletion packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: melonjs-effects-and-shaders
description: "Use this skill for post-processing effects, custom shaders, blend modes and colour grading in melonJS. Covers the built-in ShaderEffect presets, addPostEffect on renderables and cameras, writing a custom dual-language GLSL/WGSL effect, the screen_texture builtins, and why effects silently do nothing on the Canvas fallback. Triggers on: ShaderEffect, addPostEffect, removePostEffect, getPostEffect, VignetteEffect, GlowEffect, BlurEffect, PixelateEffect, ScanlineEffect, shader, GLSL, WGSL, uniform, setUniform, setTexture, setTime, blendMode, colorMatrix, screen_texture, toFrameTexture, post effect, filter."
description: "Use this skill for post-processing effects, custom shaders, blend modes and colour grading in melonJS. Covers the built-in ShaderEffect presets, addPostEffect on renderables and cameras, writing a custom dual-language GLSL/WGSL effect, the screen_texture builtins, and why effects silently do nothing on the Canvas fallback. Triggers on: ShaderEffect, addPostEffect, removePostEffect, getPostEffect, postEffectNeedsCapture, VignetteEffect, GlowEffect, BlurEffect, PixelateEffect, ScanlineEffect, shader, GLSL, WGSL, uniform, setUniform, setTexture, setTime, blendMode, colorMatrix, screen_texture, toFrameTexture, post effect, filter."
license: MIT
---

Expand Down Expand Up @@ -64,6 +64,33 @@ wants a small depth, not the huge z that would put it on top in 2D).
Either way, verify it rather than reasoning about it: return a flat colour from
the body for one frame and see what it tints.

## A renderable that draws with PRIMITIVES has to ask to be captured

One effect is applied the cheap way: the renderable is drawn with the effect's
own program instead of being captured offscreen and post-processed, which costs
no render target. That is equivalent only when everything the renderable draws
is a textured quad. `fillRect`, `strokeRect` and `renderer.fill(shape)` go
through a batcher that never reads that shader, so **one** effect on such a
renderable would silently do nothing, while **two** would work, because a chain
always captures.

So a renderable of your own that draws with primitives says so:

```js
class Bar extends Renderable {
constructor(x, y, w, h) {
super(x, y, w, h);
this.postEffectNeedsCapture = true; // ← or one effect is a no-op
}
draw(renderer) {
renderer.fillRect(this.pos.x, this.pos.y, this.width, this.height);
}
}
```

`ProgressBar` and `Trail` set it for you, and a sprite needs nothing: the flag
defaults to `false` and only ever changes the single-effect case.

## Toggle with `enabled`, do not remove

**`removePostEffect()` destroys the effect** — it calls `effect.destroy()` and
Expand Down Expand Up @@ -576,6 +603,7 @@ same question after construction.
| effect does nothing, warning in console | Canvas fallback — no programmable pipeline |
| effect does nothing on some machines only | GLSL-only shader, `video.AUTO` chose WebGPU |
| a white or solid box where the effect should be | carrier renderable drawn while the effect is disabled |
| ONE effect does nothing but two of them work | the renderable draws with primitives — set `postEffectNeedsCapture` |
| effect cannot be re-enabled | `removePostEffect()` destroyed it — use `enabled` |
| ported shader renders upside down | a hand-bound `toFrameTexture()` capture — GL is bottom-up, WebGPU top-down |
| shadow/smear offset flips on some draws | vertical UV offset not multiplied by `uUVYDir` |
Expand Down
31 changes: 31 additions & 0 deletions packages/melonjs/src/renderable/renderable.js
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,37 @@ export default class Renderable extends Rect {
*/
this.isKinematic = true;

/**
* whether a single post effect on this renderable needs an offscreen
* capture instead of the renderer's shader-swap fast path.
*
* One effect is normally applied by drawing the renderable with the
* effect's own program rather than capturing and post-processing it,
* which costs no render target. That is only equivalent when everything
* the renderable draws is a textured quad. `fillRect` and the shape
* dispatch go through a batcher that never looks at that shader, so a
* single effect on a renderable that draws with primitives silently
* does nothing. Set this and the capture is used instead, at the cost
* of a render target while the effect is active.
*
* Two or more effects always capture, so this only ever changes the
* single-effect case.
* @type {boolean}
* @default false
* @example
* class Bar extends Renderable {
* constructor(x, y, w, h) {
* super(x, y, w, h);
* // this draws itself with primitives, not as a sprite
* this.postEffectNeedsCapture = true;
* }
* draw(renderer) {
* renderer.fillRect(this.pos.x, this.pos.y, this.width, this.height);
* }
* }
*/
this.postEffectNeedsCapture = false;

/**
* when true the renderable will be redrawn during the next update cycle
* @type {boolean}
Expand Down
6 changes: 6 additions & 0 deletions packages/melonjs/src/renderable/trail.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ export default class Trail extends Renderable {
* @ignore
* @internal
*/
// Every segment is a path filled through `renderer.fill()`, which goes
// to the primitive batcher. That batcher never reads `customShader`,
// so the renderer's single-effect shader-swap would apply to nothing
// at all: a trail with one post effect has to be captured instead.
this.postEffectNeedsCapture = true;

this._gradient = this._buildGradient(options);
/**
* @ignore
Expand Down
6 changes: 6 additions & 0 deletions packages/melonjs/src/renderable/ui/progressbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,12 @@ export default class ProgressBar extends Renderable {
// everything by half the bar
this.anchorPoint.set(0, 0);

// Drawn with primitives rather than as a textured quad, so a single
// post effect has to capture rather than take the shader-swap path:
// the primitive batcher never reads that shader and the effect would
// silently do nothing.
this.postEffectNeedsCapture = true;

// Bound for exactly as long as this bar exists. Tying the two together
// is what stops the listener outliving the thing it writes into: a
// subscription held somewhere else goes on firing after the bar is
Expand Down
32 changes: 31 additions & 1 deletion packages/melonjs/src/video/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,36 @@ export default class Renderer {
return false;
}

/**
* Whether ONE post effect on this renderable can be applied by swapping the
* shader the draw uses, rather than capturing the renderable offscreen and
* post-processing what it drew.
*
* The swap is the cheap path and costs no render target, but it is only
* equivalent when everything the renderable draws is a textured quad: the
* effect's own program stands in for the quad shader and samples the same
* texture. A renderable that draws with primitives goes through a batcher
* that never reads `customShader`, so its effect would silently do nothing.
* Such a renderable sets {@link Renderable#postEffectNeedsCapture}.
*
* Asked at BOTH ends from this one place on purpose. `beginPostEffect` and
* `endPostEffect` have to reach the same answer, and a `begin` that opens a
* render target which `end` then declines to resolve leaves the renderable
* drawn into a buffer nobody reads: it simply disappears.
* @param {Renderable} renderable - the renderable being drawn
* @param {object[]} effects - its enabled effect chain
* @returns {boolean} true to take the shader-swap path
* @ignore
* @internal
*/
_usesPostEffectFastPath(renderable, effects) {
return (
effects.length === 1 &&
!renderable._postEffectManaged &&
renderable.postEffectNeedsCapture !== true
);
}

/**
* Begin capturing rendering to an offscreen buffer for post-effect processing.
* Call endPostEffect() after rendering to blit the result to the screen.
Expand All @@ -1117,7 +1147,7 @@ export default class Renderer {
const effects = renderable.postEffects.filter((fx) => {
return fx.enabled !== false;
});
if (effects.length === 1) {
if (this._usesPostEffectFastPath(renderable, effects)) {
this.customShader = effects[0];
} else {
this.customShader = undefined;
Expand Down
4 changes: 2 additions & 2 deletions packages/melonjs/src/video/webgl/webgl_renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -1728,7 +1728,7 @@ export default class WebGLRenderer extends Renderer {
return false;
}
// single effect on non-managed renderable: fast path via customShader (no FBO)
if (effects.length === 1 && !renderable._postEffectManaged) {
if (this._usesPostEffectFastPath(renderable, effects)) {
this.customShader = effects[0];
return false;
}
Expand Down Expand Up @@ -1790,7 +1790,7 @@ export default class WebGLRenderer extends Renderer {
return;
}
// single effect on non-managed renderable used customShader — no FBO to unbind
if (effects.length === 1 && !renderable._postEffectManaged) {
if (this._usesPostEffectFastPath(renderable, effects)) {
return;
}

Expand Down
4 changes: 2 additions & 2 deletions packages/melonjs/src/video/webgpu/webgpu_renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -1274,7 +1274,7 @@ export default class WebGPURenderer extends Renderer {
return false;
}
// single effect on a non-managed renderable: fast path (no target)
if (effects.length === 1 && !renderable._postEffectManaged) {
if (this._usesPostEffectFastPath(renderable, effects)) {
this.customShader = effects[0];
return false;
}
Expand Down Expand Up @@ -1362,7 +1362,7 @@ export default class WebGPURenderer extends Renderer {
return;
}
// the fast path set customShader — nothing offscreen to composite
if (effects.length === 1 && !renderable._postEffectManaged) {
if (this._usesPostEffectFastPath(renderable, effects)) {
return;
}

Expand Down
Loading
Loading