Effects: a single post effect reaches a renderable that draws with primitives - #1709
Merged
Merged
Conversation
Base automatically changed from
feat/progressbar-and-hit-test-ordering
to
master
October 3, 2026 07:50
…imitives One effect is applied by drawing the renderable with the effect's own program instead of capturing it offscreen, which costs no render target. That is equivalent only when everything the renderable draws is a textured quad: fillRect and the shape dispatch go to a batcher that never reads customShader, so ONE effect silently did nothing while two worked, because a chain always captures. Measured with a DesaturateEffect over pure red, one effect read back [255, 0, 0] untouched and two read [76, 76, 76]. The predicate deciding that path was written out inline at both ends of both GPU backends, and the two ends have to agree: a begin that opens a render target an end then declines to resolve draws the renderable into a buffer nobody reads. It is now one method on the base Renderer, asked from all five sites. Renderable#postEffectNeedsCapture opts in, defaults to false so nothing existing moves, and is set on ProgressBar and on Trail, which was affected and is fixed. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Contributor
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The newly selected capture paths introduce unresolved rendering-state regressions.
Review effort: Balanced
Findings: 4
Open (4)
What changed in this PR
Adds opt-in offscreen capture so single post effects can reach primitive-drawing renderables while preserving the sprite fast path.
Changes:
- Centralizes fast-path selection across both GPU backends.
- Opts
ProgressBarandTrailinto capture. - Adds regression tests and documents the new flag.
| File | Description |
|---|---|
| packages/melonjs/tests/webgpu_post_effect_flow.spec.js | Tests capture flow and pass balance. |
| packages/melonjs/tests/posteffect-fastpath.spec.js | Adds predicate, opt-in, and pixel tests. |
| packages/melonjs/src/video/webgpu/webgpu_renderer.js | Uses the shared predicate. |
| packages/melonjs/src/video/webgl/webgl_renderer.js | Uses the shared predicate. |
| packages/melonjs/src/video/renderer.js | Defines shared fast-path selection. |
| packages/melonjs/src/renderable/ui/progressbar.ts | Enables capture for progress bars. |
| packages/melonjs/src/renderable/trail.js | Enables capture for trails. |
| packages/melonjs/src/renderable/renderable.js | Defines and documents the capture flag. |
| packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md | Explains primitive-renderable opt-in. |
| packages/melonjs/CHANGELOG.md | Records the intended fix. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| } | ||
| // single effect on non-managed renderable: fast path via customShader (no FBO) | ||
| if (effects.length === 1 && !renderable._postEffectManaged) { | ||
| if (this._usesPostEffectFastPath(renderable, effects)) { |
| } | ||
| // single effect on non-managed renderable: fast path via customShader (no FBO) | ||
| if (effects.length === 1 && !renderable._postEffectManaged) { | ||
| if (this._usesPostEffectFastPath(renderable, effects)) { |
| } | ||
| // single effect on non-managed renderable used customShader — no FBO to unbind | ||
| if (effects.length === 1 && !renderable._postEffectManaged) { | ||
| if (this._usesPostEffectFastPath(renderable, effects)) { |
Comment on lines
+103
to
+105
| if (!isWebGL) { | ||
| ctx.skip("WebGL renderer not available in this environment"); | ||
| return true; |
obiot
force-pushed
the
fix/post-effect-primitive-capture
branch
from
October 3, 2026 07:51
384462b to
fecf11f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

The bug
One post 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 —
fillRectand the shape dispatch gothrough a batcher that never reads
customShader.So one effect silently did nothing, while two worked, because a chain
always captures. Measured on a real WebGL context, a
DesaturateEffectoverpure red:
[255, 0, 0][255, 0, 0]— untouched[76, 76, 76]✓[76, 76, 76]✓The shape of the fix
The predicate deciding that path was written out inline at both ends of both
backends —
beginPostEffectandendPostEffect, WebGL and WebGPU. The twoends have to agree, and a
beginthat opens a render target whichendthendeclines to resolve draws the renderable into a buffer nobody reads: it simply
disappears. (
endPostEffect's own comment already warned about this — "thetwo ends disagreeing about the pass count is a bug with no symptom until it is
a very confusing one." A first attempt at this fix reproduced exactly that.)
So it becomes one method on the base
Renderer, asked from all five sites:Renderable#postEffectNeedsCaptureis public, documented, and defaults tofalse, so nothing existing moves — a sprite keeps the cheap path byte forbyte.
ProgressBarandTrailset it.Two other
effects.length === 1checks exist inside the capture path ("oneeffect, no ping-pong"); those are a different question and are left alone.
Tests
tests/posteffect-fastpath.spec.jsis new: the predicate's five cases, whoopts in, and five WebGL pixel tests reading back real framebuffer pixels.
tests/webgpu_post_effect_flow.spec.jsgains three tests driving the realbeginPostEffect/endPostEffectthrough the existing recorded-primitiveharness, asserting the exact primitive log and that the pass depth returns to
zero.
Mutation-checked, with the two backends independently covered rather than one
standing in for the other:
beginhonours it,enddoes notOne gap, stated plainly
Trail's flag is set on a verified code chain —fill()→stroke(undefined, true)→setBatcher("primitive"), and no batcher filereferences
customShader— plus a dispatch-level test. It is not confirmedin pixels:
Trailwill not render in a bare-renderer harness (its defaultwidthCurveis[1, 0], so a short hand-fed trail is largely degenerate) andit wants the full app pipeline.
Full suite green, eslint 0 errors, biome clean,
npm run doc0 errors.🤖 Generated with Claude Code
https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t