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
7 changes: 2 additions & 5 deletions packages/examples/src/examples/platformer/play.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
type Application,
audio,
ColorMatrixEffect,
device,
level,
plugin,
Expand Down Expand Up @@ -46,11 +45,9 @@ export class PlayScreen extends Stage {
app.world.addChild(this.virtualJoypad);
}

// multi-pass post-effects: vignette + HDR-like color grading
// vignette post-effect + built-in color grading (always applied last)
app.viewport.addPostEffect(new VignetteEffect(app.renderer as any));
app.viewport.addPostEffect(
new ColorMatrixEffect(app.renderer as any).contrast(1.1).saturate(1.1),
);
app.viewport.colorMatrix.contrast(1.1).saturate(1.1);

// play some music
audio.playTrack("dst-gameforest");
Expand Down
1 change: 1 addition & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- Renderable: multi-pass post-effect chaining — `postEffects` array replaces single `shader` property. Multiple effects are applied in sequence via FBO ping-pong (e.g. `sprite.addPostEffect(new DesaturateEffect(r)); sprite.addPostEffect(new InvertEffect(r));`). Single-effect renderables use a zero-overhead `customShader` fast path (no FBO). Camera manages its own FBO lifecycle; per-sprite effects use a separate FBO pair.
- Renderable: `addPostEffect(effect)`, `getPostEffect(EffectClass?)`, `removePostEffect(effect)`, `clearPostEffects()` — manage post-processing shader effects on any renderable
- Renderable: `shader` getter/setter — backward-compatible access to `postEffects[0]` (deprecated)
- Camera: `colorMatrix` property — built-in `ColorMatrix` for color grading (brightness, contrast, saturation, etc.), always applied as the final post-processing pass after any effects added via `addPostEffect()`. Zero overhead when identity (default).
- Rendering: `RenderTarget` abstract base class — renderer-agnostic interface for offscreen render targets (`bind`, `unbind`, `resize`, `clear`, `destroy`, `getImageData`, `toBlob`, `toImageBitmap`, `toDataURL`). Concrete implementations: `WebGLRenderTarget` (FBO) and `CanvasRenderTarget` (canvas surface). Designed for future WebGPU support.
- Rendering: `RenderTargetPool` — renderer-agnostic pool for post-effect ping-pong render targets. Uses a factory function provided by the renderer, no GL dependency. Camera effects use pool indices 0+1, sprite effects use indices 2+3.
- Renderer: `setViewport()`, `clearRenderTarget()`, `enableScissor()`, `disableScissor()`, `setBlendEnabled()` — renderer-agnostic state methods on the base Renderer (no-ops for Canvas), implemented on WebGLRenderer. Eliminates direct GL calls from the post-effect pipeline.
Expand Down
52 changes: 52 additions & 0 deletions packages/melonjs/src/camera/camera2d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { game } from "../application/application.ts";
import { Rect } from "./../geometries/rectangle.ts";
import type { Color } from "../math/color.ts";
import { ColorMatrix } from "../math/color_matrix.ts";
import { clamp, toBeCloseTo } from "./../math/math.ts";
import { Matrix3d } from "../math/matrix3d.ts";
import { Vector2d, vector2dPool } from "../math/vector2d.ts";
Expand All @@ -17,6 +18,7 @@ import {
VIEWPORT_ONRESIZE,
} from "../system/event.ts";
import type Renderer from "./../video/renderer.js";
import ColorMatrixEffect from "./../video/webgl/effects/colorMatrix.js";
import type CameraEffect from "./effects/camera_effect.ts";
import FadeEffect from "./effects/fade_effect.ts";
import ShakeEffect from "./effects/shake_effect.ts";
Expand Down Expand Up @@ -162,6 +164,22 @@ export default class Camera2d extends Renderable {
*/
cameraEffects: CameraEffect[];

/**
* A built-in color transformation matrix applied as the final post-processing pass.
* Provides convenient color grading (brightness, contrast, saturation, etc.)
* that is always applied after any effects added via {@link addPostEffect}.
* When set to identity (default), no effect is applied and there is zero overhead.
* @example
* // warm HDR-like color grading
* camera.colorMatrix.contrast(1.2).saturate(1.1);
* // reset to no color grading
* camera.colorMatrix.identity();
*/
colorMatrix: ColorMatrix;

/** @ignore */
_colorMatrixEffect: ColorMatrixEffect | null;

/** the camera deadzone */
deadzone: Rect;

Expand Down Expand Up @@ -231,6 +249,9 @@ export default class Camera2d extends Renderable {
// camera manages its own FBO lifecycle in draw()
this._postEffectManaged = true;

this.colorMatrix = new ColorMatrix();
this._colorMatrixEffect = null;

this.bounds.setMinMax(minX, minY, maxX, maxY);

// update the projection matrix
Expand Down Expand Up @@ -859,6 +880,15 @@ export default class Camera2d extends Renderable {
const translateX = this.pos.x + this.offset.x + containerOffsetX;
const translateY = this.pos.y + this.offset.y + containerOffsetY;

// sync the built-in colorMatrix: append as final pass if non-identity
if (!this.colorMatrix.isIdentity()) {
if (!this._colorMatrixEffect) {
this._colorMatrixEffect = new ColorMatrixEffect(renderer as any);
}
this._colorMatrixEffect.reset().multiply(this.colorMatrix);
this.postEffects.push(this._colorMatrixEffect);
}
Comment thread
obiot marked this conversation as resolved.

// post-effect: bind FBO if shader effects are set (WebGL only)
const usePostEffect = r.beginPostEffect(this);

Expand Down Expand Up @@ -932,7 +962,29 @@ export default class Camera2d extends Renderable {
// post-effect: unbind FBO and blit to screen through shader effect
r.endPostEffect(this);

// remove the transient colorMatrix effect so it doesn't persist between frames
if (this._colorMatrixEffect) {
const idx = this.postEffects.indexOf(this._colorMatrixEffect);
if (idx !== -1) {
this.postEffects.splice(idx, 1);
}
}

// translate the world coordinates by default to screen coordinates
container.translate(translateX, translateY);
}

/**
* @ignore
*/
override destroy(): void {
// clean up the internal colorMatrix effect (may not be in postEffects if identity)
if (this._colorMatrixEffect) {
if (typeof this._colorMatrixEffect.destroy === "function") {
this._colorMatrixEffect.destroy();
}
this._colorMatrixEffect = null;
}
super.destroy();
}
}
20 changes: 16 additions & 4 deletions packages/melonjs/src/video/rendertarget/rendertarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ export default abstract class RenderTarget {
const imageData = this.getImageData();
if (typeof OffscreenCanvas !== "undefined") {
const canvas = new OffscreenCanvas(this.width, this.height);
const ctx = canvas.getContext("2d")!;
const ctx = canvas.getContext("2d");
if (!ctx) {
return Promise.reject(new Error("Failed to get 2d context"));
}
ctx.putImageData(imageData, 0, 0);
const options: { type: string; quality?: number } = { type };
Comment thread
obiot marked this conversation as resolved.
if (typeof quality !== "undefined") {
Expand All @@ -88,7 +91,10 @@ export default abstract class RenderTarget {
const canvas = document.createElement("canvas");
canvas.width = this.width;
canvas.height = this.height;
const ctx = canvas.getContext("2d")!;
const ctx = canvas.getContext("2d");
if (!ctx) {
return Promise.reject(new Error("Failed to get 2d context"));
}
ctx.putImageData(imageData, 0, 0);
return new Promise((resolve, reject) => {
canvas.toBlob(
Expand Down Expand Up @@ -123,10 +129,16 @@ export default abstract class RenderTarget {
toDataURL(type = "image/png", quality?: number): Promise<string> {
return this.toBlob(type, quality).then((blob) => {
const reader = new FileReader();
return new Promise<string>((resolve) => {
reader.onloadend = () => {
return new Promise<string>((resolve, reject) => {
reader.onload = () => {
resolve(reader.result as string);
};
reader.onerror = () => {
reject(new Error(reader.error?.message ?? "FileReader failed"));
};
reader.onabort = () => {
reject(new Error("FileReader aborted"));
};
reader.readAsDataURL(blob);
});
});
Expand Down
12 changes: 9 additions & 3 deletions packages/melonjs/src/video/rendertarget/webglrendertarget.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ export default class WebGLRenderTarget extends RenderTarget {
this.framebuffer = gl.createFramebuffer();

// create color texture — use TEXTURE0 explicitly to avoid corrupting
// other texture units that the multi-texture batcher may have active
// other texture units that the multi-texture batcher may have active.
// Save/restore the active unit so the batcher's cache stays in sync.
const prevUnit = gl.getParameter(gl.ACTIVE_TEXTURE);
this.texture = gl.createTexture();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.texture);
Expand Down Expand Up @@ -90,10 +92,11 @@ export default class WebGLRenderTarget extends RenderTarget {
);
}

// unbind
// unbind and restore the previously active texture unit
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.bindRenderbuffer(gl.RENDERBUFFER, null);
gl.bindTexture(gl.TEXTURE_2D, null);
gl.activeTexture(prevUnit);
}

/**
Expand Down Expand Up @@ -126,7 +129,9 @@ export default class WebGLRenderTarget extends RenderTarget {
this.height = height;

// resize color texture — use TEXTURE0 explicitly to avoid corrupting
// other texture units that the multi-texture batcher may have active
// other texture units that the multi-texture batcher may have active.
// Save/restore the active unit so the batcher's cache stays in sync.
const prevUnit = gl.getParameter(gl.ACTIVE_TEXTURE);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.texImage2D(
Comment thread
obiot marked this conversation as resolved.
Expand All @@ -152,6 +157,7 @@ export default class WebGLRenderTarget extends RenderTarget {

gl.bindTexture(gl.TEXTURE_2D, null);
gl.bindRenderbuffer(gl.RENDERBUFFER, null);
gl.activeTexture(prevUnit);
}

/**
Expand Down
73 changes: 73 additions & 0 deletions packages/melonjs/tests/camera.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
boot,
Camera2d,
CameraEffect,
ColorMatrix,
Ellipse,
FadeEffect,
game,
Expand Down Expand Up @@ -1477,4 +1478,76 @@ describe("Camera2d", () => {
game.world.pos.set(0, 0, 0);
});
});

describe("colorMatrix", () => {
it("should default to identity", () => {
setup();
const cam = new Camera2d(0, 0, 800, 600);
expect(cam.colorMatrix).toBeInstanceOf(ColorMatrix);
expect(cam.colorMatrix.isIdentity()).toBe(true);
});

it("should not add effect to postEffects when identity", () => {
setup();
const cam = new Camera2d(0, 0, 800, 600);
cam.draw(video.renderer, game.world);
expect(cam.postEffects).toHaveLength(0);
});

it("non-identity should lazily create the internal effect", () => {
setup();
const cam = new Camera2d(0, 0, 800, 600);
expect(cam._colorMatrixEffect).toBeNull();
cam.colorMatrix.contrast(1.2);
cam.draw(video.renderer, game.world);
// effect created but removed from postEffects after draw (transient)
expect(cam._colorMatrixEffect).not.toBeNull();
});

it("colorMatrix effect should not persist in postEffects after draw", () => {
setup();
const cam = new Camera2d(0, 0, 800, 600);
cam.colorMatrix.saturate(1.5);
cam.draw(video.renderer, game.world);
// transient — removed after endPostEffect
expect(cam.postEffects.indexOf(cam._colorMatrixEffect)).toBe(-1);
});

it("user effects should not be affected by colorMatrix lifecycle", () => {
setup();
const cam = new Camera2d(0, 0, 800, 600);
cam.colorMatrix.saturate(1.5);
const other = { enabled: true };
cam.addPostEffect(other);
cam.draw(video.renderer, game.world);
// user effect persists, colorMatrix effect is transient
expect(cam.postEffects.indexOf(other)).not.toBe(-1);
expect(cam.postEffects.indexOf(cam._colorMatrixEffect)).toBe(-1);
});

it("reset to identity should not create or add effect", () => {
setup();
const cam = new Camera2d(0, 0, 800, 600);
cam.colorMatrix.contrast(1.2);
cam.draw(video.renderer, game.world);
// reset to identity
cam.colorMatrix.identity();
cam.draw(video.renderer, game.world);
// no colorMatrix effect in postEffects
expect(cam.postEffects.indexOf(cam._colorMatrixEffect)).toBe(-1);
});

it("clearPostEffects should not prevent colorMatrix from working", () => {
setup();
const cam = new Camera2d(0, 0, 800, 600);
cam.colorMatrix.brightness(1.3);
cam.draw(video.renderer, game.world);
// internal effect was created
expect(cam._colorMatrixEffect).not.toBeNull();
cam.clearPostEffects();
// draw again — colorMatrix still works (effect recreated/re-added transiently)
cam.draw(video.renderer, game.world);
expect(cam._colorMatrixEffect).not.toBeNull();
});
});
});
Loading