diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 08f5d6f1d..f95cd8812 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -23,6 +23,7 @@ - UI: `UITextButton#bitmapText`, for `label` ### Fixed +- Camera: a camera with post effects no longer wipes out the cameras drawn before it. A minimap with post effects now shows the view behind wherever it draws nothing, as one without them does, instead of the background colour - Container: `reset()`, and so `level.load`, no longer drops the sort pending for the persistent children it keeps, which left them in the order they were added - Particles: particles are on their emitter's `visibilityLayer`, and follow a change to it. They stayed on layer 1, so a camera whose `cullMask` drew only the emitter's layer drew none of its particles - Loader: assets on the game's own origin are requested with its cookies and HTTP auth, so a game behind a login no longer gets a 403 for every asset ([#1801](https://github.com/melonjs/melonJS/issues/1801)) diff --git a/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md b/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md index dcf0ab0e6..0bbb0a82f 100644 --- a/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md +++ b/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md @@ -205,6 +205,14 @@ and an overridden `postDraw`: this.cameras.set("minimap", new MinimapCamera()); ``` +A stage's cameras draw in the order they were added, each over the ones before +it, so a full-screen second camera is a layer: a `Camera3d` drawing a 3D +background first, the game's own camera over it, each kept to its part of the +world with `visibilityLayer` / `cullMask` (below). A camera's post effects +apply to what that camera draws: the first camera's pass starts the frame, and +a later camera's is blended over what is already there (20.13 and later; +before, it wiped out the cameras before it). + View state is per camera, not per scene: a `Camera3d` carries its own distance fog (`setFog`), so a minimap or split-screen view fogs independently of the main one — and a `Camera2d` never fogs. See `melonjs-3d`. @@ -386,6 +394,7 @@ const data = await rt.toImageData(0, 0, w, h); | symptom | cause | |---|---| +| a second camera's post effect wipes out what the first camera drew | before 20.13 a later camera's post-effect pass replaced the whole frame; update, or put the effects on a container instead of the camera | | `follow()` set but the view never moves | viewport bounds still at renderer size | | `invalid target for me.Camera2d.follow` | passed `player.pos` — pass the renderable, a `Vector2d` or a `Vector3d` | | camera drag jumps around | using world (`gameWorldX`) instead of screen (`gameScreenX`) coordinates | diff --git a/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md b/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md index 46ee0c2dc..9fa274b3b 100644 --- a/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md +++ b/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md @@ -758,6 +758,7 @@ same question after construction. | symptom | cause | |---|---| +| a camera's post effect hides another camera drawn before it | before 20.13 a later camera's pass replaced the whole frame; update. Since then each camera's effects apply to what it draws, over the cameras before it | | effect does nothing, warning in console | Canvas fallback — no programmable pipeline | | effect does nothing on some machines only | GLSL-only shader, `video.AUTO` chose WebGPU | | a white or solid box where the effect should be | carrier renderable drawn while the effect is disabled | diff --git a/packages/melonjs/src/camera/camera2d.ts b/packages/melonjs/src/camera/camera2d.ts index 328a1e7bc..b8eec3e33 100644 --- a/packages/melonjs/src/camera/camera2d.ts +++ b/packages/melonjs/src/camera/camera2d.ts @@ -1252,7 +1252,9 @@ export default class Camera2d extends Renderable { this.postEffects.push(colorMatrixEffect); } - // post-effect: bind FBO if shader effects are set (WebGL only) + // post-effect: bind FBO if shader effects are set (WebGL only). On a + // camera drawn after another one (`_postEffectOver`, set by + // `Stage#draw`) the pass goes OVER what that one drew, see there const usePostEffect = r.beginPostEffect(this); // apply the world-to-camera-view transform on the container. diff --git a/packages/melonjs/src/renderable/renderable.js b/packages/melonjs/src/renderable/renderable.js index 8cdc32942..662311007 100644 --- a/packages/melonjs/src/renderable/renderable.js +++ b/packages/melonjs/src/renderable/renderable.js @@ -439,6 +439,15 @@ export default class Renderable extends Rect { */ this._postEffectManaged = false; + /** + * whether this camera's post-effect pass goes OVER what is already + * drawn in the frame (another camera drew first), rather than + * starting the frame (see {@link Camera2d#draw}) + * @ignore + * @internal + */ + this._postEffectOver = false; + /** * the blend mode to be applied to this renderable — any of the modes * listed on {@link CanvasRenderer#setBlendMode}, honoured identically by diff --git a/packages/melonjs/src/state/stage.ts b/packages/melonjs/src/state/stage.ts index ee0e967cf..f5ef7aac5 100644 --- a/packages/melonjs/src/state/stage.ts +++ b/packages/melonjs/src/state/stage.ts @@ -382,7 +382,16 @@ export default class Stage { * @param world - the world object to draw */ draw(renderer: Renderer, world: World): void { + let first = true; this.cameras.forEach((camera) => { + // A camera after the first draws over the ones before it, post + // effects or not: a background camera first, the game's camera + // over it. Its post-effect pass is then blended over the frame + // instead of starting it, cleared to the background colour and + // copied over the whole screen, which would wipe out everything + // the cameras before it drew. + camera._postEffectOver = !first; + first = false; camera.draw(renderer, world); }); } diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index dbea41ac8..011bc1a89 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -2163,7 +2163,10 @@ export default class WebGLRenderer extends Renderer { this.setGlobalAlpha(1.0); this.setBlendMode("normal"); - if (isCamera) { + // a camera that starts the frame starts its pass like one (background + // colour, fresh stencil); one drawn over another camera, and any + // renderable, starts transparent so what is behind shows through + if (isCamera && !renderable._postEffectOver) { this.clear(); } else { this.clearRenderTarget(); @@ -2198,7 +2201,10 @@ export default class WebGLRenderer extends Renderer { const isCamera = renderable._postEffectManaged; const rt1 = this._renderTargetPool.getCaptureTarget(); const rt2 = this._renderTargetPool.getPingPongTarget(); - const keepBlend = !isCamera; + // a camera that starts the frame replaces it; one drawn over another + // camera is blended over what that one drew + const over = isCamera && renderable._postEffectOver === true; + const keepBlend = !isCamera || over; const canvas = this.getCanvas(); const w = canvas.width; const h = canvas.height; @@ -2251,6 +2257,10 @@ export default class WebGLRenderer extends Renderer { // the chain's passes are told where the renderable is this._beginChainSpace(renderable); + if (over) { + // whatever the camera's last draw left set + this.setBlendMode("normal"); + } // 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 diff --git a/packages/melonjs/src/video/webgpu/webgpu_renderer.js b/packages/melonjs/src/video/webgpu/webgpu_renderer.js index e32c467ac..6885519d2 100644 --- a/packages/melonjs/src/video/webgpu/webgpu_renderer.js +++ b/packages/melonjs/src/video/webgpu/webgpu_renderer.js @@ -1889,7 +1889,9 @@ export default class WebGPURenderer extends Renderer { // retarget with the appropriate clear: a camera's offscreen pass // starts like a frame (background color + fresh stencil), a // sprite's starts transparent so unpainted texels stay see-through - if (isCamera) { + // (a camera drawn over another camera starts transparent too, so that + // one shows through: see `Camera2d#draw`) + if (isCamera && !renderable._postEffectOver) { const [r, g, b, a] = this.backgroundColor.toArray(); this.setRenderTarget(rt, { clear: true, @@ -1937,7 +1939,10 @@ export default class WebGPURenderer extends Renderer { const pool = this._renderTargetPool; const rt1 = pool.getCaptureTarget(); const rt2 = pool.getPingPongTarget(); - const keepBlend = !isCamera; + // a camera that starts the frame replaces it; one drawn over another + // camera is blended over what that one drew + const over = isCamera && renderable._postEffectOver === true; + const keepBlend = !isCamera || over; const canvas = this.getCanvas(); const w = canvas.width; const h = canvas.height; @@ -1974,6 +1979,10 @@ export default class WebGPURenderer extends Renderer { // the chain's passes are told where the renderable is this._beginChainSpace(renderable); + if (over) { + // whatever the camera's last draw left set + this.setBlendMode("normal"); + } // 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 diff --git a/packages/melonjs/tests/camera-post-effect-over.spec.js b/packages/melonjs/tests/camera-post-effect-over.spec.js new file mode 100644 index 000000000..0ca444e06 --- /dev/null +++ b/packages/melonjs/tests/camera-post-effect-over.spec.js @@ -0,0 +1,174 @@ +/** + * A camera with post effects, drawn after another camera, draws OVER it. + * + * A stage's cameras draw in order, each over the ones before it: a + * background camera first, the game's camera over it. Without post effects + * that already held. With one, the camera's pass started like a frame, + * cleared to the background colour, and its result was copied over the + * whole screen, wiping out everything the cameras before it drew. A camera + * after the first now starts its pass transparent and blends it over the + * frame, so a post effect no longer changes what a camera covers. + * + * Read in pixels on WebGL: a red background on one camera, a blue square on + * a second, full-screen camera with a pass-through post effect. + */ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + Application, + boot, + Camera2d, + Renderable, + ShaderEffect, + state, + video, +} from "../src/index.js"; + +const SIZE = 100; +const BACKGROUND = 2; +const GAME = 4; + +/** a filled square, top left at (x, y), on `layer` */ +class Square extends Renderable { + constructor(x, y, size, color, layer) { + super(x, y, size, size); + this.anchorPoint.set(0, 0); + this.color = color; + this.visibilityLayer = layer; + } + draw(renderer) { + renderer.setColor(this.color); + renderer.fillRect(this.pos.x, this.pos.y, this.width, this.height); + } +} + +describe("a camera's post effects, over another camera (WebGL)", () => { + let app; + let stage; + let back; + let front; + + beforeAll(async () => { + boot(); + app = new Application(SIZE, SIZE, { + parent: "screen", + renderer: video.WEBGL, + scale: "1.0", + antiAlias: false, + backgroundColor: "#000000", + failIfMajorPerformanceCaveat: false, + }); + await app.init(); + stage = state.current(); + // the stage's own camera draws first: the background + back = stage.cameras.get("default"); + // then a second, full-screen one: the game + front = new Camera2d(0, 0, SIZE, SIZE); + front.name = "front"; + stage.cameras.set("front", front); + }); + + afterEach(() => { + app.world.reset(); + back.cullMask = -1; + front.cullMask = -1; + for (const camera of [back, front]) { + camera.clearPostEffects?.(); + camera.postEffects.length = 0; + } + }); + + afterAll(() => { + stage?.cameras.delete("front"); + return app?.destroy(); + }); + + const requireWebGL = (ctx) => { + if (app.renderer.gl === undefined) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + /** an effect drawing what it is given */ + const passThrough = () => { + return new ShaderEffect(app.renderer, { + glsl: "vec4 apply(vec4 color, vec2 uv) { return color; }", + wgsl: "fn apply(color : vec4f, uv : vec2f) -> vec4f { return color; }", + }); + }; + + /** a red background for one camera, a blue square for the other */ + const drawScene = () => { + const world = app.world; + world.visibilityLayer = 1 | BACKGROUND | GAME; + back.cullMask = BACKGROUND; + front.cullMask = GAME; + world.addChild(new Square(0, 0, SIZE, "#ff0000", BACKGROUND), 0); + world.addChild(new Square(40, 40, 20, "#0000ff", GAME), 1); + world.sortNow(); + world.update(0); + app.repaint(); + app.draw(); + }; + + const pixel = (x, y) => { + const gl = app.renderer.gl; + app.renderer.flush(); + const out = new Uint8Array(4); + gl.readPixels(x, SIZE - 1 - y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, out); + return Array.from(out.slice(0, 3)).join(","); + }; + + it("draws both cameras when neither has a post effect", (ctx) => { + requireWebGL(ctx); + drawScene(); + expect(pixel(5, 5)).toBe("255,0,0"); + expect(pixel(50, 50)).toBe("0,0,255"); + }); + + it("keeps what the first camera drew when the second has a post effect", (ctx) => { + requireWebGL(ctx); + front.addPostEffect(passThrough()); + drawScene(); + // the background camera's red, not the background colour + expect(pixel(5, 5)).toBe("255,0,0"); + // and the second camera's square over it + expect(pixel(50, 50)).toBe("0,0,255"); + }); + + it("keeps both when both cameras have post effects", (ctx) => { + requireWebGL(ctx); + back.addPostEffect(passThrough()); + front.addPostEffect(passThrough()); + drawScene(); + expect(pixel(5, 5)).toBe("255,0,0"); + expect(pixel(50, 50)).toBe("0,0,255"); + }); + + /** an effect inverting what it is given (premultiplied) */ + const invert = () => { + return new ShaderEffect(app.renderer, { + glsl: "vec4 apply(vec4 color, vec2 uv) { return vec4(color.a - color.rgb, color.a); }", + wgsl: "fn apply(color : vec4f, uv : vec2f) -> vec4f { return vec4f(color.a - color.rgb, color.a); }", + }); + }; + + it("still starts the frame with the first camera's post-effect pass", (ctx) => { + requireWebGL(ctx); + // nothing on the first camera's layer: its pass is the background + // colour, red, which the effect inverts to cyan. Started transparent + // (as a later camera's is), it would invert to white instead + app.renderer.backgroundColor.parseCSS("#ff0000"); + try { + back.addPostEffect(invert()); + front.cullMask = 0; + app.world.visibilityLayer = 1 | BACKGROUND | GAME; + back.cullMask = 8; + app.world.update(0); + app.repaint(); + app.draw(); + expect(pixel(5, 5)).toBe("0,255,255"); + } finally { + app.renderer.backgroundColor.parseCSS("#000000"); + } + }); +});