Problem
When a renderable has multiple post-processing effects (multi-pass via FBO ping-pong), the shader UVs are relative to the full canvas instead of the sprite. This causes UV-dependent effects (glint sweeps, scan lines, hologram patterns) to appear as "cutouts" of a full-screen effect that scroll with the camera.
Single-effect sprites are NOT affected — they use the customShader fast path which replaces the default shader during the draw call. The sprite's native texture UVs are used directly.
Root Cause
In WebGLRenderer.beginPostEffect(), when a non-camera renderable has 2+ effects, we create a canvas-sized FBO and render the sprite into it using the camera's projection. The sprite occupies a small region of the FBO at its screen position. When the effect shaders process this FBO, the UV coordinates (0..1) span the entire canvas, not the sprite.
File: packages/melonjs/src/video/webgl/webgl_renderer.js — beginPostEffect() / endPostEffect()
What Needs to Change
Use sprite-sized FBOs with a local projection, so the sprite fills the FBO and UVs go from 0..1 across the sprite. This is how PixiJS FilterSystem works (source frame + destination frame).
The Transform Chain Challenge
In Renderable.preDraw() (packages/melonjs/src/renderable/renderable.js), beginPostEffect is called before autoTransform and the anchor offset:
save()
globalAlpha, flip, mask
beginPostEffect(this) ← FBO bound here
autoTransform ← sprite rotation/scale (only if non-identity)
translate(-ax, -ay) ← anchor offset
At beginPostEffect time, currentTransform has the camera scroll + parent transforms, but NOT the sprite's own position. The sprite's position comes from Sprite.draw() which draws at this.pos.x, this.pos.y.
The Projection Math
To make the sprite fill a sprite-sized FBO:
-
Compute screen-space bounds: getBounds() returns world-space AABB (includes pos, anchor, rotation). Transform through currentTransform (which has camera scroll) to get screen bounds:
screenX = bounds.x * transform.a + bounds.y * transform.c + transform.tx
screenY = bounds.x * transform.b + bounds.y * transform.d + transform.ty
-
Set projection: ortho(screenX, screenX + spriteW, screenY + spriteH, screenY) — maps the sprite's screen region to NDC, so the sprite fills the FBO.
-
Set viewport: gl.viewport(0, 0, spriteW, spriteH) — FBO pixel dimensions.
-
In endPostEffect: intermediate ping-pong blits use ortho(0, spriteW, spriteH, 0) (sprite-local). The final blit uses ortho(0, canvasW, canvasH, 0) with the quad at (screenX, screenY, spriteW, spriteH) — this requires blitEffect to accept separate projection dimensions via projWidth/projHeight parameters.
What We Tried
Two attempts were made in the feat/post-effect-chaining branch:
- Attempt 1: Moved
beginPostEffect after all transforms, computed bounds from local (0,0,w,h) through full transform. Failed — broke the save/restore nesting and the transform was already partially consumed.
- Attempt 2: Kept
beginPostEffect in original position, computed bounds from getBounds() + currentTransform. The projection math didn't produce correct results — sprites were invisible or mispositioned.
PixiJS Reference
PixiJS v7 FilterSystem (packages/core/src/filters/FilterSystem.ts):
push(): gets target.getBounds(true), pads by filter padding, clips to visible area, allocates FBO from RenderTexturePool, sets projection via ProjectionSystem.update(destinationFrame, sourceFrame)
pop(): ping-pong with CLEAR_MODES.CLEAR for intermediate passes, CLEAR_MODES.BLEND for final pass to parent
- Projection formula:
pm.a = 2/sourceFrame.width, pm.tx = -1 - sourceFrame.x * pm.a (maps source frame to NDC)
Key Files
packages/melonjs/src/video/webgl/webgl_renderer.js — beginPostEffect(), endPostEffect(), blitEffect()
packages/melonjs/src/renderable/renderable.js — preDraw() transform order
packages/melonjs/src/renderable/sprite.js — draw() uses this.pos.x/y for draw position
packages/melonjs/src/video/rendertarget/render_target_pool.js — pool already supports arbitrary sizes
packages/melonjs/src/video/rendertarget/rendertarget.ts — base class already supports bind/unbind/resize
Acceptance Criteria
Problem
When a renderable has multiple post-processing effects (multi-pass via FBO ping-pong), the shader UVs are relative to the full canvas instead of the sprite. This causes UV-dependent effects (glint sweeps, scan lines, hologram patterns) to appear as "cutouts" of a full-screen effect that scroll with the camera.
Single-effect sprites are NOT affected — they use the
customShaderfast path which replaces the default shader during the draw call. The sprite's native texture UVs are used directly.Root Cause
In
WebGLRenderer.beginPostEffect(), when a non-camera renderable has 2+ effects, we create a canvas-sized FBO and render the sprite into it using the camera's projection. The sprite occupies a small region of the FBO at its screen position. When the effect shaders process this FBO, the UV coordinates (0..1) span the entire canvas, not the sprite.File:
packages/melonjs/src/video/webgl/webgl_renderer.js—beginPostEffect()/endPostEffect()What Needs to Change
Use sprite-sized FBOs with a local projection, so the sprite fills the FBO and UVs go from 0..1 across the sprite. This is how PixiJS FilterSystem works (source frame + destination frame).
The Transform Chain Challenge
In
Renderable.preDraw()(packages/melonjs/src/renderable/renderable.js),beginPostEffectis called beforeautoTransformand the anchor offset:At
beginPostEffecttime,currentTransformhas the camera scroll + parent transforms, but NOT the sprite's own position. The sprite's position comes fromSprite.draw()which draws atthis.pos.x, this.pos.y.The Projection Math
To make the sprite fill a sprite-sized FBO:
Compute screen-space bounds:
getBounds()returns world-space AABB (includes pos, anchor, rotation). Transform throughcurrentTransform(which has camera scroll) to get screen bounds:Set projection:
ortho(screenX, screenX + spriteW, screenY + spriteH, screenY)— maps the sprite's screen region to NDC, so the sprite fills the FBO.Set viewport:
gl.viewport(0, 0, spriteW, spriteH)— FBO pixel dimensions.In endPostEffect: intermediate ping-pong blits use
ortho(0, spriteW, spriteH, 0)(sprite-local). The final blit usesortho(0, canvasW, canvasH, 0)with the quad at(screenX, screenY, spriteW, spriteH)— this requiresblitEffectto accept separate projection dimensions viaprojWidth/projHeightparameters.What We Tried
Two attempts were made in the
feat/post-effect-chainingbranch:beginPostEffectafter all transforms, computed bounds from local (0,0,w,h) through full transform. Failed — broke the save/restore nesting and the transform was already partially consumed.beginPostEffectin original position, computed bounds fromgetBounds()+ currentTransform. The projection math didn't produce correct results — sprites were invisible or mispositioned.PixiJS Reference
PixiJS v7
FilterSystem(packages/core/src/filters/FilterSystem.ts):push(): getstarget.getBounds(true), pads by filter padding, clips to visible area, allocates FBO fromRenderTexturePool, sets projection viaProjectionSystem.update(destinationFrame, sourceFrame)pop(): ping-pong withCLEAR_MODES.CLEARfor intermediate passes,CLEAR_MODES.BLENDfor final pass to parentpm.a = 2/sourceFrame.width,pm.tx = -1 - sourceFrame.x * pm.a(maps source frame to NDC)Key Files
packages/melonjs/src/video/webgl/webgl_renderer.js—beginPostEffect(),endPostEffect(),blitEffect()packages/melonjs/src/renderable/renderable.js—preDraw()transform orderpackages/melonjs/src/renderable/sprite.js—draw()usesthis.pos.x/yfor draw positionpackages/melonjs/src/video/rendertarget/render_target_pool.js— pool already supports arbitrary sizespackages/melonjs/src/video/rendertarget/rendertarget.ts— base class already supports bind/unbind/resizeAcceptance Criteria