Skip to content

Effects: a single post effect reaches a renderable that draws with primitives - #1709

Merged
obiot merged 1 commit into
masterfrom
fix/post-effect-primitive-capture
Oct 3, 2026
Merged

obiot merged 1 commit into
masterfrom
fix/post-effect-primitive-capture

Conversation

@obiot

@obiot obiot commented Oct 3, 2026

Copy link
Copy Markdown
Member

Stacked on #1707. Its base is that branch, so the diff shown here is
only this change. GitHub will retarget it to master once #1707 merges.
It depends on #1707 because the pixel tests use ProgressBar as the
primitive-drawing renderable to measure.

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 — fillRect and the shape dispatch go
through 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 DesaturateEffect over
pure red:

effects attached pixel read back
none [255, 0, 0]
one [255, 0, 0] — untouched
two [76, 76, 76] ✓
three [76, 76, 76] ✓

The shape of the fix

The predicate deciding that path was written out inline at both ends of both
backends
— beginPostEffect and endPostEffect, WebGL and WebGPU. The two
ends have to agree, and a begin that opens a render target which end then
declines to resolve draws the renderable into a buffer nobody reads: it simply
disappears. (endPostEffect's own comment already warned about this — "the
two 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:

_usesPostEffectFastPath(renderable, effects) {
    return (
        effects.length === 1 &&
        !renderable._postEffectManaged &&
        renderable.postEffectNeedsCapture !== true
    );
}

Renderable#postEffectNeedsCapture is public, documented, and defaults to
false, so nothing existing moves — a sprite keeps the cheap path byte for
byte. ProgressBar and Trail set it.

Two other effects.length === 1 checks exist inside the capture path ("one
effect, no ping-pong"); those are a different question and are left alone.

Tests

tests/posteffect-fastpath.spec.js is new: the predicate's five cases, who
opts in, and five WebGL pixel tests reading back real framebuffer pixels.
tests/webgpu_post_effect_flow.spec.js gains three tests driving the real
beginPostEffect/endPostEffect through the existing recorded-primitive
harness, 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:

mutant killed by
drop the flag clause (the fix itself) 4 tests, both backends
WebGL begin honours it, end does not the WebGL pixel test
WebGPU same desync the WebGPU flow tests
drop the camera clause the predicate test

One gap, stated plainly

Trail's flag is set on a verified code chain — fill() →
stroke(undefined, true) → setBatcher("primitive"), and no batcher file
references customShader — plus a dispatch-level test. It is not confirmed
in pixels: Trail will not render in a bare-renderer harness (its default
widthCurve is [1, 0], so a short hand-fed trail is largely degenerate) and
it wants the full app pipeline.

Full suite green, eslint 0 errors, biome clean, npm run doc 0 errors.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

Copilot AI balanced review requested due to automatic review settings October 3, 2026 07:43
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The newly selected capture paths introduce unresolved rendering-state regressions.

Review effort: Balanced
Findings: 4 Medium severity

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 ProgressBar and Trail into 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
obiot force-pushed the fix/post-effect-primitive-capture branch from 384462b to fecf11f Compare October 3, 2026 07:51
@obiot
obiot merged commit 222404d into master Oct 3, 2026
6 checks passed
@obiot
obiot deleted the fix/post-effect-primitive-capture branch October 3, 2026 07:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants