Skip to content

Effects: a chain measures, places and clips as one effect does - #1793

Merged
obiot merged 1 commit into
masterfrom
fix/effect-chain-texel-size
Oct 29, 2026
Merged

obiot merged 1 commit into
masterfrom
fix/effect-chain-texel-size

Conversation

@obiot

@obiot obiot commented Oct 29, 2026

Copy link
Copy Markdown
Member

Description

A renderable drawn through two or more effects (or one, on a UIPanel, ProgressBar or Trail, which set postEffectNeedsCapture) is drawn into a capture the size of the canvas, and its effects run over that capture. Four things went wrong on that path. This fixes all of them, so a chain measures, places and clips the way a single effect does.

1. Widths came out in screen pixels. Glow, outline, blur, drop shadow, chromatic aberration, pixelate and bloom measured their width, offset or size in the capture's pixels. A single effect measures in the sprite's own pixels, so at 0.13 scale, adding a second effect made an outline about 8 times thicker.

  • Each pass is now handed the size of the renderable's pixels on the target (Renderer#_effectScaleOf).
  • ShaderEffect#_clearFrameRect divides the target size by it.
  • A container measures in its own pixels. That covers a default-size container too, since the scale comes from the transform, not the rectangle.

2. Patterns were misplaced under a camera's zoom. In a chain, the passes placed patterns (vignette, gradient, scanline curve, noise_uv) using the renderable's transform alone. A pass actually runs in the target's pixels, and the projection decides where the renderable lands there, so under zoom or a camera drawn away from the screen's corner the patterns were misplaced or missing. The chain's space now maps through the projection too (Renderer#_drawnTransform), and so does the pixel size. This bug came from #1789, which hasn't been released.

3. The composite ignored the clip. The capture drops the clip the renderable is drawn under, and the composite drew before that clip was restored. So a minimap's sprites with effects spilled over the main view, and out of a clipping container. The composite now draws inside the clip the pass began under (_resumeSavedClip, on WebGL and WebGPU).

4. The backends read the capture differently. WebGPU read it through a fixed linear sampler. WebGL used whatever sampler the last draw had left on texture unit 0. Both now read it at the game's texture filter, as a sprite reads its own texture, so a pixel-art game stays crisp on WebGPU as well.

Unchanged: camera effects keep measuring in screen pixels, an effect given a textureSize keeps it, and custom ShaderEffects are never given a size.

Example: the sprite example's two chained looks (the hero's Outline + Glow, and the fading red monster) are re-tuned for the new units and look as they did.

Type of change

  • Bug fix
  • New feature
  • Documentation update
  • Performance improvement
  • Refactoring (no functional changes)

Checklist

  • I have read the Contributing Guide
  • My code follows the existing code style (pnpm lint passes): eslint and biome on the changed files, tsc on melonjs
  • I have tested my changes locally (pnpm test passes): 365 files, 9127 tests
  • I have added tests that cover my changes (if applicable)
  • The build succeeds (pnpm build)

Tests:

  • effect-texture-size.spec.js (WebGL pixels): an outline is equally thick with one effect and through a chain on a scaled sprite, with a different scale on each axis, rotated, under a zoomed camera, and on a default-size container. A game-given textureSize is kept. The capture is read at the game's filter, even when the last draw left the other filter's sampler on the unit (tested in both directions).
  • effect-local-space.spec.js: every pattern effect draws the same chained as alone under a zoomed camera, and the chain's space maps the art's corners from the target's pixels through zoom and offset.
  • effect-chain-clip.spec.js (new, WebGL pixels): 0 to 3 effects, a single effect with postEffectNeedsCapture, and a clipping container all stay inside the clip, and the clip is back as it was afterwards.
  • webgpu_post_effect_flow.spec.js: the clip is resumed right before the composite, after the passes between effects, and never for a camera's chain.
  • webgpu_render_target.spec.js and a WebGPU mock test: sampling at the game's filter, rebuilt when the filter changes, and the pixel size handed to a chain's pass.
  • Mutation-checked: deliberately breaking each part of the fix makes a test fail in 16 of 17 cases. The one that survives drops only the renderer's own record of the clip, which the restore() that follows reconciles, so it can't change a pixel.

Checked in a browser: a temporary demo (not committed) shows each case as pairs, one effect next to the same effect plus a pass-through, including a second camera zoomed ×2. On master the pairs differ and the zoomed camera spills; with the fix they match, on WebGL and WebGPU.

A sweep of the 22 gallery examples that use effects, against master on both backends, found these differences above run-to-run noise:

  • Lights on WebGPU: its camera scanline curve is now crisp, matching WebGL.
  • HDR on WebGPU: the bloom changes by a few levels, which can't be seen.
  • Whac-a-mole on WebGPU: a few hundred pixels at the moles' shadow edges.
  • The re-tuned sprite example.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SeWCkETjW35ZbXDRswV1hx

A renderable drawn through two or more effects (or one, on a UIPanel,
ProgressBar or Trail, which set postEffectNeedsCapture) is drawn into a
capture the size of the canvas, and its effects run over that. Four
things went wrong there:

- Widths, offsets and sizes (glow, outline, blur, drop shadow,
  chromatic aberration, pixelate, bloom) were measured in the capture's
  pixels, the screen's, where one effect measures in the sprite's own:
  at 0.13 scale, adding a second effect made an outline about 8 times
  thicker. Each pass is now handed the size of the renderable's pixels
  on the target (Renderer#_effectScaleOf), and measures in those.
- Under a camera's zoom (or one drawn away from the screen's corner),
  the passes placed patterns (vignette, gradient, scanline curve,
  noise_uv) by the renderable's transform alone, though a pass runs in
  the target's pixels, which the projection puts it on: they were
  misplaced or missing. The chain's space now maps through the
  projection too (Renderer#_drawnTransform), as does the pixel size.
- The capture drops the clip it is drawn under, and the composite drew
  before the clip was back, so a minimap's sprites with effects spilled
  over the main view, and out of a clipping container. The composite now
  draws inside the clip the pass began under (_resumeSavedClip).
- WebGPU read a capture through a fixed linear sampler, WebGL through
  whatever sampler the last draw left on the unit. Both now read it at
  the game's texture filter, as a sprite reads its own texture, so a
  pixel-art game stays crisp on WebGPU too.

Camera effects keep measuring in screen pixels, and an effect given a
textureSize keeps it. The sprite example's two chained looks are
re-tuned for the new units and look as they did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeWCkETjW35ZbXDRswV1hx
Copilot AI balanced review requested due to automatic review settings October 29, 2026 13:48

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 was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@obiot
obiot merged commit 5ef1596 into master Oct 29, 2026
6 checks passed
@obiot
obiot deleted the fix/effect-chain-texel-size branch October 29, 2026 13:54
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