Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
9 changes: 9 additions & 0 deletions packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 3 additions & 1 deletion packages/melonjs/src/camera/camera2d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions packages/melonjs/src/renderable/renderable.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions packages/melonjs/src/state/stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
Expand Down
14 changes: 12 additions & 2 deletions packages/melonjs/src/video/webgl/webgl_renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions packages/melonjs/src/video/webgpu/webgpu_renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
174 changes: 174 additions & 0 deletions packages/melonjs/tests/camera-post-effect-over.spec.js
Original file line number Diff line number Diff line change
@@ -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");
}
});
});
Loading