From e9e13348471d5ead546bf69edeeaa5e9dd873b9e Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 29 Oct 2026 19:11:32 +0800 Subject: [PATCH] Effects: a renderable keeps its opacity through its effects A renderable drawn through two or more post effects (or through one, on a UIPanel, ProgressBar or Trail, which set postEffectNeedsCapture) is drawn opaque into a render target of its own, and the chain's last blit composites the result. That blit drew with an opaque white tint, so the opacity the renderable was drawn at, its own and its containers', never reached the screen: setOpacity() or a fade did nothing while the effects were on. Fixes #1791. beginPostEffect records the opacity it was begun under, per nesting depth, and endPostEffect hands it to the chain's last blit only, as the blit's tint. The blits between effects stay opaque, and a camera's chain composites whole. Same on WebGL and WebGPU: blitEffect and the batchers' blitTexture take an optional alpha (1 by default). The sprite example's red monster now fades through Outline + Glow. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SeWCkETjW35ZbXDRswV1hx --- .../src/examples/sprite/ExampleSprite.tsx | 20 +- packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/src/video/renderer.js | 3 +- .../video/webgl/batchers/lit_quad_batcher.js | 12 +- .../src/video/webgl/batchers/quad_batcher.js | 12 +- .../melonjs/src/video/webgl/webgl_renderer.js | 35 +++- .../src/video/webgpu/batchers/quad_batcher.js | 12 +- .../src/video/webgpu/webgpu_renderer.js | 45 ++++- .../tests/effect-chain-opacity.spec.js | 186 ++++++++++++++++++ .../tests/webgpu_post_effect_flow.spec.js | 56 +++++- 10 files changed, 365 insertions(+), 17 deletions(-) create mode 100644 packages/melonjs/tests/effect-chain-opacity.spec.js diff --git a/packages/examples/src/examples/sprite/ExampleSprite.tsx b/packages/examples/src/examples/sprite/ExampleSprite.tsx index 2f4959f70..2b3d5ac9a 100644 --- a/packages/examples/src/examples/sprite/ExampleSprite.tsx +++ b/packages/examples/src/examples/sprite/ExampleSprite.tsx @@ -8,7 +8,8 @@ * seconds (click it for the next one), up to two effects chained. Coins cut * from a TexturePacker atlas orbit it, spinning and passing in front of it * and behind it; the astronaut drifts by, walking in mid-air; flies buzz - * around; tinted, fading and additive little monsters float in the dark. + * around; tinted, fading and additive little monsters float in the dark, + * one of them fading through two effects. * The nebula behind them is Earth Defender's sky, drawn once at the size of * the screen with the same noise. */ @@ -79,7 +80,8 @@ class Showcase extends Stage { 0, ); - // ---- little monsters, tinted, fading, two of them added on -------- + // ---- little monsters, tinted, fading, two of them added on, one + // of them fading through two effects --------------------------------- const minions = [ { x: 170, y: 150, tint: "#ff7a6e", blend: "normal" }, { x: 1050, y: 560, tint: "#7ab8ff", blend: "normal" }, @@ -91,6 +93,20 @@ class Showcase extends Stage { minion.scale(0.13); minion.tint.parseCSS(m.tint); minion.blendMode = m.blend; + if (i === 0 && app.renderer.shaderLanguage !== null) { + // two effects: drawn apart and composited, at the opacity it + // fades to like any other sprite + minion.addPostEffect( + new OutlineEffect(app.renderer, { color: [1, 0.9, 0.85], width: 2 }), + ); + minion.addPostEffect( + new GlowEffect(app.renderer, { + color: [1.0, 0.5, 0.4], + width: 8, + intensity: 1.5, + }), + ); + } world.addChild(minion, 1); move((time, step) => { minion.setOpacity(0.45 + 0.4 * Math.sin(time * 0.8 + i * 1.7)); diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index e3d7325a8..a92ba62f7 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -21,6 +21,7 @@ - State: the first frame of a new stage shows what it built, and the first frame after a level loaded while the game runs shows that level. That frame was drawn before the world had been stepped, and a renderable is drawn only once a step has found it on screen, so every state change showed one frame of background alone unless a transition covered it - State: a `state.change()` asked for while a transition covers the screen is made by that transition, to the last state asked for and with its arguments, as without a transition ([#1787](https://github.com/melonjs/melonJS/issues/1787)). It put up a second cover, and the first stayed on the camera for good, the screen black over a game still running; a change to a state with no transition of its own is still made at once, and takes the cover down - Effects: the built-in effects that place a pattern place it on the sprite itself, cut from an atlas or with other effects after it, and `SwayEffect` measures its strength across the sprite ([#1407](https://github.com/melonjs/melonJS/issues/1407)). On an atlas sprite, `SwayEffect` and an anchored `WaveEffect` moved its roots and `VignetteEffect`, `RadialGradientEffect` and `ScanlineEffect`'s curve landed on the atlas; with two or more effects, every pattern, `noise_uv` and `ShineEffect`'s sweep included, sat on the canvas and slid with the camera +- Effects: a renderable drawn through two or more effects keeps its opacity, its own and its containers', on WebGL and WebGPU, as does a `UIPanel`, `ProgressBar` or `Trail` with one ([#1791](https://github.com/melonjs/melonJS/issues/1791)). Their effects were composited at full opacity, so `setOpacity()` or a fade changed nothing while the effects were on ## [20.11.0] (melonJS 2) - _2026-10-27_ diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index 17fcbd7f4..005fe7292 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -1442,9 +1442,10 @@ export default class Renderer { * @param {number} height - destination height * @param {ShaderEffect} shader - the shader effect to apply * @param {boolean} [keepBlend=false] - if true, keep current blend mode (for sprite compositing) + * @param {number} [alpha=1] - the opacity to composite the blit at (0.0 to 1.0) */ // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars - blitEffect(source, x, y, width, height, shader, keepBlend) {} + blitEffect(source, x, y, width, height, shader, keepBlend, alpha) {} /** * Sets the viewport for the renderer. diff --git a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js index 48eeb8874..86fa48471 100644 --- a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js @@ -1,3 +1,4 @@ +import { Color } from "../../../math/color.ts"; import { off, on, TEXTURE2D_DESTROYED } from "../../../system/event.ts"; import { FRAME_UNBOUNDED, isFramed } from "../../gpu/frame.js"; import { scaleQuad, transformQuadCorners } from "../../gpu/quadcorners.ts"; @@ -13,6 +14,12 @@ import quadMultiLitVertex from "./../shaders/quad-multi-lit.vert"; import { WebGLTextureStore } from "../texture/store.js"; import QuadBatcher from "./quad_batcher.js"; +/** + * the blit's tint: white, at the opacity the blit composites at + * @ignore + */ +const blitTint = new Color(255, 255, 255); + /** * additional import for TypeScript * @import {TextureAtlas} from "./../../texture/atlas.js"; @@ -697,8 +704,9 @@ export default class LitQuadBatcher extends QuadBatcher { * @param {number} width - destination width * @param {number} height - destination height * @param {GLShader|ShaderEffect} shader - the shader effect to apply + * @param {number} [alpha=1] - the opacity to composite at (0.0 to 1.0) */ - blitTexture(source, x, y, width, height, shader) { + blitTexture(source, x, y, width, height, shader, alpha = 1) { const gl = this.gl; this.useShader(shader); @@ -741,7 +749,7 @@ export default class LitQuadBatcher extends QuadBatcher { // blits are always rendered at z = 0 (screen-space, ortho), carry no // normal map (-1) and no material (shininess 0), and draw the whole // target: no frame - const tint = 0xffffffff; + const tint = blitTint.toUint32(alpha); const open = FRAME_UNBOUNDED; const vertexData = this.vertexData; vertexData.pushSprite(vec0.x, vec0.y, 0, 0, 1, tint, 0, open, open, -1, 0); diff --git a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js index ab33a8333..90ce07f04 100644 --- a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js @@ -1,3 +1,4 @@ +import { Color } from "../../../math/color.ts"; import { FRAME_UNBOUNDED, frameClampMode, isFramed } from "../../gpu/frame.js"; import { scaleQuad, transformQuadCorners } from "../../gpu/quadcorners.ts"; import IndexBuffer from "../buffer/index.js"; @@ -6,6 +7,12 @@ import quadMultiVertex from "./../shaders/quad-multi.vert"; import { resolveTopology } from "../utils/topology.js"; import { MaterialBatcher } from "./material_batcher.js"; +/** + * the blit's tint: white, at the opacity the blit composites at + * @ignore + */ +const blitTint = new Color(255, 255, 255); + /** * additional import for TypeScript * @import {TextureAtlas} from "./../../texture/atlas.js"; @@ -246,8 +253,9 @@ export default class QuadBatcher extends MaterialBatcher { * @param {number} width - destination width * @param {number} height - destination height * @param {GLShader|ShaderEffect} shader - the shader effect to apply + * @param {number} [alpha=1] - the opacity to composite at (0.0 to 1.0) */ - blitTexture(source, x, y, width, height, shader) { + blitTexture(source, x, y, width, height, shader, alpha = 1) { const gl = this.gl; this.useShader(shader); @@ -300,7 +308,7 @@ export default class QuadBatcher extends MaterialBatcher { // blits stay at z = 0 (screen-space, ortho); pre-PR behavior // preserved unchanged. A blit draws the whole target: no frame - const tint = 0xffffffff; + const tint = blitTint.toUint32(alpha); const open = FRAME_UNBOUNDED; const vertexData = this.vertexData; vertexData.pushSprite(vec0.x, vec0.y, 0, 0, 1, tint, 0, open, open); diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 2205a2bcd..5a23523a1 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -240,6 +240,16 @@ export default class WebGLRenderer extends Renderer { * @internal */ this._effectProjectionStack = []; + + /** + * The opacity each (possibly nested) post-effect pass was begun + * under, by depth: the pass draws its content opaque, and the last + * blit of a renderable's chain composites it at this. + * @type {number[]} + * @ignore + * @internal + */ + this._effectAlphaStack = []; /** * @ignore * @internal @@ -2107,6 +2117,9 @@ export default class WebGLRenderer extends Renderer { new Matrix3d(); } savedProjection.copy(this.projectionMatrix); + // the opacity the renderable is drawn at: its content goes into the + // target opaque, and the chain's last blit composites it at this + this._effectAlphaStack[this._effectPassDepth] = this.globalAlpha(); this._effectPassDepth++; const rt = this._renderTargetPool.begin( @@ -2215,8 +2228,13 @@ export default class WebGLRenderer extends Renderer { // the chain's passes are told where the renderable is this._beginChainSpace(renderable); + // a renderable's chain lands at the opacity it is drawn at, by its + // last blit only; a camera's composites whole + const alpha = isCamera + ? 1 + : this._effectAlphaStack[this._effectPassDepth - 1]; if (effects.length === 1) { - this.blitEffect(rt1.texture, 0, 0, w, h, effects[0], keepBlend); + this.blitEffect(rt1.texture, 0, 0, w, h, effects[0], keepBlend, alpha); } else { // multi-pass: ping-pong between two render targets let src = rt1; @@ -2247,6 +2265,7 @@ export default class WebGLRenderer extends Renderer { h, effects[effects.length - 1], keepBlend, + alpha, ); } @@ -2450,8 +2469,18 @@ export default class WebGLRenderer extends Renderer { * @param {number} height - destination height * @param {ShaderEffect} shader - the shader effect to apply * @param {boolean} [keepBlend=false] - if true, keep current blend mode (for sprite compositing) + * @param {number} [alpha=1] - the opacity to composite the blit at (0.0 to 1.0) */ - blitEffect(source, x, y, width, height, shader, keepBlend = false) { + blitEffect( + source, + x, + y, + width, + height, + shader, + keepBlend = false, + alpha = 1, + ) { // A blit is compositing machinery, not a scene draw: it must never // open an advanced-blend bracket of its own. That covers both the // post-effect chain's ping-pong blits (which run under the @@ -2475,7 +2504,7 @@ export default class WebGLRenderer extends Renderer { if (!keepBlend) { this.setBlendEnabled(false); } - batcher.blitTexture(source, x, y, width, height, shader); + batcher.blitTexture(source, x, y, width, height, shader, alpha); if (!keepBlend) { this.setBlendEnabled(true); } diff --git a/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js b/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js index 8f1ea0544..f6bbfd20b 100644 --- a/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js @@ -1,3 +1,4 @@ +import { Color } from "../../../math/color.ts"; import IndexBuffer from "../../buffer/index.js"; import { FRAME_UNBOUNDED, isFramed } from "../../gpu/frame.js"; import { scaleQuad, transformQuadCorners } from "../../gpu/quadcorners.ts"; @@ -6,6 +7,12 @@ import { prepareEffectBinding } from "../effect_binding.js"; import { MAX_QUAD_TEXTURES } from "../pipeline/cache.js"; import WebGPUBatcher from "./webgpu_batcher.js"; +/** + * the blit's tint: white, at the opacity the blit composites at + * @ignore + */ +const blitTint = new Color(255, 255, 255); + /** * The WebGPU quad batcher — textured-quad accumulation with the same * frozen 36-byte vertex layout as the WebGL `QuadBatcher` and the same @@ -555,10 +562,11 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { * @param {number} h - destination height * @param {ShaderEffect} [effect] - the effect to composite with * @param {boolean} [keepBlend=false] - keep the current blend mode (else replace) + * @param {number} [alpha=1] - the opacity to composite at (0.0 to 1.0) * @ignore * @internal */ - blitTexture(source, x, y, w, h, effect, keepBlend = false) { + blitTexture(source, x, y, w, h, effect, keepBlend = false, alpha = 1) { // drain pending quads under their own material first this.flush(); const renderer = this.renderer; @@ -603,7 +611,7 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { // whole target drawn: no frame const vertexData = this.vertexData; const open = FRAME_UNBOUNDED; - const tint = 0xffffffff; + const tint = blitTint.toUint32(alpha); vertexData.pushSprite(x, y, 0, 0, 0, tint, 0, open, open); vertexData.pushSprite(x + w, y, 0, 1, 0, tint, 0, open, open); vertexData.pushSprite(x, y + h, 0, 0, 1, tint, 0, open, open); diff --git a/packages/melonjs/src/video/webgpu/webgpu_renderer.js b/packages/melonjs/src/video/webgpu/webgpu_renderer.js index c1efb95af..7595ab8ed 100644 --- a/packages/melonjs/src/video/webgpu/webgpu_renderer.js +++ b/packages/melonjs/src/video/webgpu/webgpu_renderer.js @@ -401,6 +401,16 @@ export default class WebGPURenderer extends Renderer { /** @ignore * @internal */ this.effectProjectionStack = []; + + /** + * The opacity each (possibly nested) post-effect pass was begun + * under, by depth: the pass draws its content opaque, and the last + * blit of a renderable's chain composites it at this. + * @type {number[]} + * @ignore + * @internal + */ + this.effectAlphaStack = []; /** @ignore * @internal */ this.effectPassDepth = 0; @@ -1829,6 +1839,9 @@ export default class WebGPURenderer extends Renderer { new Matrix3d(); } savedProjection.copy(this.projectionMatrix); + // the opacity the renderable is drawn at: its content goes into the + // target opaque, and the chain's last blit composites it at this + this.effectAlphaStack[this.effectPassDepth] = this.globalAlpha(); this.effectPassDepth++; const rt = this._postEffectPool().begin( @@ -1925,8 +1938,13 @@ export default class WebGPURenderer extends Renderer { // the chain's passes are told where the renderable is this._beginChainSpace(renderable); + // a renderable's chain lands at the opacity it is drawn at, by its + // last blit only; a camera's composites whole + const alpha = isCamera + ? 1 + : this.effectAlphaStack[this.effectPassDepth - 1]; if (effects.length === 1) { - this.blitEffect(rt1, 0, 0, w, h, effects[0], keepBlend); + this.blitEffect(rt1, 0, 0, w, h, effects[0], keepBlend, alpha); } else { // multi-pass: ping-pong between the two pool targets let src = rt1; @@ -1939,7 +1957,16 @@ export default class WebGPURenderer extends Renderer { dst = tmp; } this.setRenderTarget(parentRT); - this.blitEffect(src, 0, 0, w, h, effects[effects.length - 1], keepBlend); + this.blitEffect( + src, + 0, + 0, + w, + h, + effects[effects.length - 1], + keepBlend, + alpha, + ); } if (isCamera && renderable.isDefault === false) { @@ -1969,9 +1996,19 @@ export default class WebGPURenderer extends Renderer { * @param {number} height - destination height * @param {ShaderEffect} effect - the effect to composite with * @param {boolean} [keepBlend=false] - keep the current blend mode + * @param {number} [alpha=1] - the opacity to composite the blit at (0.0 to 1.0) * @override */ - blitEffect(source, x, y, width, height, effect, keepBlend = false) { + blitEffect( + source, + x, + y, + width, + height, + effect, + keepBlend = false, + alpha = 1, + ) { // A blit is compositing machinery, not a scene draw: it must never // open an advanced-blend bracket of its own. Covers both the // post-effect chain's ping-pong blits (which run under the @@ -1984,7 +2021,7 @@ export default class WebGPURenderer extends Renderer { blitSavedProjection.copy(this.projectionMatrix); this.projectionMatrix.ortho(0, width, height, 0, -1, 1); this.pushFrameGlobals(); - batcher.blitTexture(source, x, y, width, height, effect, keepBlend); + batcher.blitTexture(source, x, y, width, height, effect, keepBlend, alpha); this.projectionMatrix.copy(blitSavedProjection); this.pushFrameGlobals(); this._advancedBlendBusy--; diff --git a/packages/melonjs/tests/effect-chain-opacity.spec.js b/packages/melonjs/tests/effect-chain-opacity.spec.js new file mode 100644 index 000000000..e6f19202b --- /dev/null +++ b/packages/melonjs/tests/effect-chain-opacity.spec.js @@ -0,0 +1,186 @@ +/** + * A renderable drawn through two or more post effects keeps its opacity + * (#1791). + * + * With two effects or more (or one, on a renderable that sets + * `postEffectNeedsCapture`), the renderable is drawn into a target of its + * own, opaque, and the chain's last blit composites the result onto what is + * behind it. That blit drew with an opaque tint, so the opacity the + * renderable was drawn at never reached the screen: `setOpacity()`, a + * parent's opacity, or a fading tween did nothing until the renderable was + * removed. With one effect drawn straight on (no target of its own) it + * always worked. + * + * Read in pixels on WebGL: a white sprite over black at opacity 0.2 is 51, + * however many effects it goes through. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { boot, Container, ShaderEffect, Sprite } from "../src/index.js"; +import Renderer from "../src/video/renderer.js"; +import WebGLRenderer from "../src/video/webgl/webgl_renderer.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, +} from "./helpers/webgl-context.js"; + +const SIZE = 64; +// what a channel of white at this opacity comes out as, over black +const at = (opacity) => { + return Math.round(255 * opacity); +}; + +describe("opacity through a post-effect chain (WebGL)", () => { + let renderer; + let gl; + let white; + + beforeAll(async () => { + boot(); + renderer = await getWebGLRenderer(SIZE, SIZE); + if (renderer instanceof WebGLRenderer) { + gl = renderer.gl; + renderer.projectionMatrix.ortho(0, SIZE, SIZE, 0, -1000, 1000); + renderer.currentBatcher.setProjection(renderer.projectionMatrix); + } else { + renderer = undefined; + } + white = Renderer.createCanvas(32, 32); + const ctx = white.getContext("2d"); + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, 32, 32); + }); + + afterAll(() => { + releaseWebGLRenderer(); + }); + + const requireWebGL = (ctx) => { + if (renderer === undefined) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + /** an effect drawing what it is given */ + const passThrough = () => { + return new ShaderEffect(renderer, { + glsl: "vec4 apply(vec4 color, vec2 uv) { return color; }", + wgsl: "fn apply(color : vec4f, uv : vec2f) -> vec4f { return color; }", + }); + }; + + /** a white square in the middle, through `effects` pass-throughs */ + const square = (effects) => { + const sprite = new Sprite(SIZE / 2, SIZE / 2, { image: white }); + for (let i = 0; i < effects; i++) { + sprite.addPostEffect(passThrough()); + } + return sprite; + }; + + /** draw a renderable over `background`, as a container would, and read the middle */ + const middle = (renderable, background = "#000000") => { + renderer.backgroundColor.parseCSS(background); + renderer.clear(); + renderer.save(); + renderer.resetTransform(); + renderable.preDraw(renderer); + renderable.draw(renderer); + renderable.postDraw(renderer); + renderer.restore(); + renderer.flush(); + const px = new Uint8Array(4); + gl.finish(); + gl.readPixels(SIZE / 2, SIZE / 2, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); + return px[0]; + }; + + it.for([0, 1, 2, 3])( + "a sprite at opacity 0.2 through %i effect(s) is drawn at 0.2", + (effects, ctx) => { + requireWebGL(ctx); + const sprite = square(effects); + sprite.setOpacity(0.2); + expect(Math.abs(middle(sprite) - at(0.2))).toBeLessThanOrEqual(2); + }, + ); + + it("a fully opaque sprite through two effects stays opaque", (ctx) => { + requireWebGL(ctx); + expect(middle(square(2))).toBe(255); + }); + + it("takes its parent's opacity too", (ctx) => { + requireWebGL(ctx); + const parent = new Container(0, 0, SIZE, SIZE); + parent.setOpacity(0.5); + const sprite = square(2); + sprite.setOpacity(0.5); + parent.addChild(sprite); + // drawn as a container draws what an update found in view + sprite.inViewport = true; + // the container draws its children through its own preDraw + expect(Math.abs(middle(parent) - at(0.25))).toBeLessThanOrEqual(2); + }); + + it("so does one effect drawn into a target of its own", (ctx) => { + requireWebGL(ctx); + // as a UIPanel, a ProgressBar or a Trail draws its single effect + const sprite = square(1); + sprite.postEffectNeedsCapture = true; + sprite.setOpacity(0.2); + expect(Math.abs(middle(sprite) - at(0.2))).toBeLessThanOrEqual(2); + }); + + it("a chain inside a chain lands at both opacities, once each", (ctx) => { + requireWebGL(ctx); + const parent = new Container(0, 0, SIZE, SIZE); + parent.addPostEffect(passThrough()); + parent.addPostEffect(passThrough()); + parent.setOpacity(0.5); + const sprite = square(2); + sprite.setOpacity(0.5); + parent.addChild(sprite); + // drawn as a container draws what an update found in view + sprite.inViewport = true; + expect(Math.abs(middle(parent) - at(0.25))).toBeLessThanOrEqual(2); + }); + + it("keeps its blend mode as it fades", (ctx) => { + requireWebGL(ctx); + // added onto a grey of 64, at half opacity: 64 + 128 + const one = square(1); + one.setOpacity(0.5); + one.blendMode = "lighter"; + const two = square(2); + two.setOpacity(0.5); + two.blendMode = "lighter"; + const expected = middle(one, "#404040"); + expect(Math.abs(expected - (64 + at(0.5)))).toBeLessThanOrEqual(2); + expect(Math.abs(middle(two, "#404040") - expected)).toBeLessThanOrEqual(2); + }); + + it("leaves a camera's chain whole", (ctx) => { + requireWebGL(ctx); + // a camera composites everything it drew, whatever alpha is current + const camera = { + _postEffectManaged: true, + isDefault: true, + postEffects: [passThrough(), passThrough()], + }; + renderer.backgroundColor.parseCSS("#000000"); + renderer.clear(); + renderer.save(); + renderer.resetTransform(); + renderer.setGlobalAlpha(0.3); + renderer.beginPostEffect(camera); + renderer.setGlobalAlpha(1); + renderer.drawImage(white, 0, 0, 32, 32, 0, 0, SIZE, SIZE); + renderer.endPostEffect(camera); + renderer.restore(); + renderer.flush(); + const px = new Uint8Array(4); + gl.finish(); + gl.readPixels(SIZE / 2, SIZE / 2, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); + expect(px[0]).toBe(255); + }); +}); diff --git a/packages/melonjs/tests/webgpu_post_effect_flow.spec.js b/packages/melonjs/tests/webgpu_post_effect_flow.spec.js index c1d233e6b..56aba757d 100644 --- a/packages/melonjs/tests/webgpu_post_effect_flow.spec.js +++ b/packages/melonjs/tests/webgpu_post_effect_flow.spec.js @@ -16,6 +16,8 @@ import WebGPURenderer from "../src/video/webgpu/webgpu_renderer.js"; describe("WebGPU post-effect control flow (recorded primitives)", () => { let renderer; let log; + // the alpha each blit composites at, by effect + let alphas; function fakeTarget(name) { return { @@ -61,12 +63,19 @@ describe("WebGPU post-effect control flow (recorded primitives)", () => { beforeEach(() => { log = []; + alphas = []; let targetCount = 0; renderer = Object.create(WebGPURenderer.prototype); Object.assign(renderer, { customShader: undefined, effectProjectionStack: [], effectPassDepth: 0, + // the opacity each pass begins under, and the one drawn at now + effectAlphaStack: [], + alpha: 1, + globalAlpha() { + return this.alpha; + }, // where each renderable with a chain is, for its passes // (Renderer#_setEffectSpace) _effectSpaces: new WeakMap(), @@ -96,10 +105,11 @@ describe("WebGPU post-effect control flow (recorded primitives)", () => { captureFrame() { log.push(`capture:${this.currentRenderTarget?.name ?? "canvas"}`); }, - blitEffect(source, x, y, w, h, effect, keepBlend) { + blitEffect(source, x, y, w, h, effect, keepBlend, alpha = 1) { log.push( `blit:${source.name}→${effect.name}:keep=${keepBlend === true}`, ); + alphas.push(`${effect.name}@${alpha}`); }, clipRect(x, y, w, h) { log.push(`clip:${x},${y},${w},${h}`); @@ -177,6 +187,50 @@ describe("WebGPU post-effect control flow (recorded primitives)", () => { expect(log.at(-3)).toBe("blit:rt1→fx2:keep=true"); }); + it("a sprite chain lands at the opacity it began under, by its last blit only", () => { + const sprite = makeRenderable([ + makeEffect({ name: "a" }), + makeEffect({ name: "b" }), + makeEffect({ name: "c" }), + ]); + // drawn at 0.3: its content goes into the pass opaque + renderer.alpha = 0.3; + renderer.beginPostEffect(sprite); + renderer.endPostEffect(sprite); + // the ping-pong passes stay whole; the composite onto the parent fades + expect(alphas).toEqual(["a@1", "b@1", "c@0.3"]); + }); + + it("a camera's chain composites whole, whatever the alpha it began under", () => { + const camera = makeRenderable( + [makeEffect({ name: "c1" }), makeEffect({ name: "c2" })], + { camera: true }, + ); + renderer.alpha = 0.3; + renderer.beginPostEffect(camera); + renderer.endPostEffect(camera); + expect(alphas).toEqual(["c1@1", "c2@1"]); + }); + + it("a sprite chain inside another lands at its own opacity", () => { + const outer = makeRenderable([ + makeEffect({ name: "o1" }), + makeEffect({ name: "o2" }), + ]); + const inner = makeRenderable([ + makeEffect({ name: "i1" }), + makeEffect({ name: "i2" }), + ]); + renderer.alpha = 0.5; + renderer.beginPostEffect(outer); + // inside the outer pass: drawn at its own opacity + renderer.alpha = 0.25; + renderer.beginPostEffect(inner); + renderer.endPostEffect(inner); + renderer.endPostEffect(outer); + expect(alphas).toEqual(["i1@1", "i2@0.25", "o1@1", "o2@0.5"]); + }); + it("nesting: a sprite pass inside a camera pass restores the camera's projection slot", () => { const cameraFx = [makeEffect({ name: "c1" }), makeEffect({ name: "c2" })]; const spriteFx = [makeEffect({ name: "s1" }), makeEffect({ name: "s2" })];