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" })];