Skip to content

Commit bc81635

Browse files
authored
Add Camera2d.colorMatrix property for built-in color grading (#1409)
* Add Camera2d.colorMatrix property for built-in color grading (#1396) Add a public `colorMatrix` property to Camera2d — a built-in ColorMatrix that is always applied as the final post-processing pass, after any effects added via addPostEffect(). Zero overhead when identity (default). The effect is transient: pushed to postEffects before beginPostEffect and removed after endPostEffect each frame. Camera2d uses only the public ColorMatrixEffect API (reset + multiply), no internal shader knowledge. Also fix FileReader error handling in RenderTarget.toDataURL().
1 parent bb86df4 commit bc81635

6 files changed

Lines changed: 153 additions & 12 deletions

File tree

packages/examples/src/examples/platformer/play.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import {
22
type Application,
33
audio,
4-
ColorMatrixEffect,
54
device,
65
level,
76
plugin,
@@ -46,11 +45,9 @@ export class PlayScreen extends Stage {
4645
app.world.addChild(this.virtualJoypad);
4746
}
4847

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

5552
// play some music
5653
audio.playTrack("dst-gameforest");

packages/melonjs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- 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.
88
- Renderable: `addPostEffect(effect)`, `getPostEffect(EffectClass?)`, `removePostEffect(effect)`, `clearPostEffects()` — manage post-processing shader effects on any renderable
99
- Renderable: `shader` getter/setter — backward-compatible access to `postEffects[0]` (deprecated)
10+
- 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).
1011
- 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.
1112
- 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.
1213
- 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.

packages/melonjs/src/camera/camera2d.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { game } from "../application/application.ts";
22
import { Rect } from "./../geometries/rectangle.ts";
33
import type { Color } from "../math/color.ts";
4+
import { ColorMatrix } from "../math/color_matrix.ts";
45
import { clamp, toBeCloseTo } from "./../math/math.ts";
56
import { Matrix3d } from "../math/matrix3d.ts";
67
import { Vector2d, vector2dPool } from "../math/vector2d.ts";
@@ -17,6 +18,7 @@ import {
1718
VIEWPORT_ONRESIZE,
1819
} from "../system/event.ts";
1920
import type Renderer from "./../video/renderer.js";
21+
import ColorMatrixEffect from "./../video/webgl/effects/colorMatrix.js";
2022
import type CameraEffect from "./effects/camera_effect.ts";
2123
import FadeEffect from "./effects/fade_effect.ts";
2224
import ShakeEffect from "./effects/shake_effect.ts";
@@ -162,6 +164,22 @@ export default class Camera2d extends Renderable {
162164
*/
163165
cameraEffects: CameraEffect[];
164166

167+
/**
168+
* A built-in color transformation matrix applied as the final post-processing pass.
169+
* Provides convenient color grading (brightness, contrast, saturation, etc.)
170+
* that is always applied after any effects added via {@link addPostEffect}.
171+
* When set to identity (default), no effect is applied and there is zero overhead.
172+
* @example
173+
* // warm HDR-like color grading
174+
* camera.colorMatrix.contrast(1.2).saturate(1.1);
175+
* // reset to no color grading
176+
* camera.colorMatrix.identity();
177+
*/
178+
colorMatrix: ColorMatrix;
179+
180+
/** @ignore */
181+
_colorMatrixEffect: ColorMatrixEffect | null;
182+
165183
/** the camera deadzone */
166184
deadzone: Rect;
167185

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

252+
this.colorMatrix = new ColorMatrix();
253+
this._colorMatrixEffect = null;
254+
234255
this.bounds.setMinMax(minX, minY, maxX, maxY);
235256

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

883+
// sync the built-in colorMatrix: append as final pass if non-identity
884+
if (!this.colorMatrix.isIdentity()) {
885+
if (!this._colorMatrixEffect) {
886+
this._colorMatrixEffect = new ColorMatrixEffect(renderer as any);
887+
}
888+
this._colorMatrixEffect.reset().multiply(this.colorMatrix);
889+
this.postEffects.push(this._colorMatrixEffect);
890+
}
891+
862892
// post-effect: bind FBO if shader effects are set (WebGL only)
863893
const usePostEffect = r.beginPostEffect(this);
864894

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

965+
// remove the transient colorMatrix effect so it doesn't persist between frames
966+
if (this._colorMatrixEffect) {
967+
const idx = this.postEffects.indexOf(this._colorMatrixEffect);
968+
if (idx !== -1) {
969+
this.postEffects.splice(idx, 1);
970+
}
971+
}
972+
935973
// translate the world coordinates by default to screen coordinates
936974
container.translate(translateX, translateY);
937975
}
976+
977+
/**
978+
* @ignore
979+
*/
980+
override destroy(): void {
981+
// clean up the internal colorMatrix effect (may not be in postEffects if identity)
982+
if (this._colorMatrixEffect) {
983+
if (typeof this._colorMatrixEffect.destroy === "function") {
984+
this._colorMatrixEffect.destroy();
985+
}
986+
this._colorMatrixEffect = null;
987+
}
988+
super.destroy();
989+
}
938990
}

packages/melonjs/src/video/rendertarget/rendertarget.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@ export default abstract class RenderTarget {
7777
const imageData = this.getImageData();
7878
if (typeof OffscreenCanvas !== "undefined") {
7979
const canvas = new OffscreenCanvas(this.width, this.height);
80-
const ctx = canvas.getContext("2d")!;
80+
const ctx = canvas.getContext("2d");
81+
if (!ctx) {
82+
return Promise.reject(new Error("Failed to get 2d context"));
83+
}
8184
ctx.putImageData(imageData, 0, 0);
8285
const options: { type: string; quality?: number } = { type };
8386
if (typeof quality !== "undefined") {
@@ -88,7 +91,10 @@ export default abstract class RenderTarget {
8891
const canvas = document.createElement("canvas");
8992
canvas.width = this.width;
9093
canvas.height = this.height;
91-
const ctx = canvas.getContext("2d")!;
94+
const ctx = canvas.getContext("2d");
95+
if (!ctx) {
96+
return Promise.reject(new Error("Failed to get 2d context"));
97+
}
9298
ctx.putImageData(imageData, 0, 0);
9399
return new Promise((resolve, reject) => {
94100
canvas.toBlob(
@@ -123,10 +129,16 @@ export default abstract class RenderTarget {
123129
toDataURL(type = "image/png", quality?: number): Promise<string> {
124130
return this.toBlob(type, quality).then((blob) => {
125131
const reader = new FileReader();
126-
return new Promise<string>((resolve) => {
127-
reader.onloadend = () => {
132+
return new Promise<string>((resolve, reject) => {
133+
reader.onload = () => {
128134
resolve(reader.result as string);
129135
};
136+
reader.onerror = () => {
137+
reject(new Error(reader.error?.message ?? "FileReader failed"));
138+
};
139+
reader.onabort = () => {
140+
reject(new Error("FileReader aborted"));
141+
};
130142
reader.readAsDataURL(blob);
131143
});
132144
});

packages/melonjs/src/video/rendertarget/webglrendertarget.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ export default class WebGLRenderTarget extends RenderTarget {
2323
this.framebuffer = gl.createFramebuffer();
2424

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

93-
// unbind
95+
// unbind and restore the previously active texture unit
9496
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
9597
gl.bindRenderbuffer(gl.RENDERBUFFER, null);
9698
gl.bindTexture(gl.TEXTURE_2D, null);
99+
gl.activeTexture(prevUnit);
97100
}
98101

99102
/**
@@ -126,7 +129,9 @@ export default class WebGLRenderTarget extends RenderTarget {
126129
this.height = height;
127130

128131
// resize color texture — use TEXTURE0 explicitly to avoid corrupting
129-
// other texture units that the multi-texture batcher may have active
132+
// other texture units that the multi-texture batcher may have active.
133+
// Save/restore the active unit so the batcher's cache stays in sync.
134+
const prevUnit = gl.getParameter(gl.ACTIVE_TEXTURE);
130135
gl.activeTexture(gl.TEXTURE0);
131136
gl.bindTexture(gl.TEXTURE_2D, this.texture);
132137
gl.texImage2D(
@@ -152,6 +157,7 @@ export default class WebGLRenderTarget extends RenderTarget {
152157

153158
gl.bindTexture(gl.TEXTURE_2D, null);
154159
gl.bindRenderbuffer(gl.RENDERBUFFER, null);
160+
gl.activeTexture(prevUnit);
155161
}
156162

157163
/**

packages/melonjs/tests/camera.spec.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
boot,
44
Camera2d,
55
CameraEffect,
6+
ColorMatrix,
67
Ellipse,
78
FadeEffect,
89
game,
@@ -1477,4 +1478,76 @@ describe("Camera2d", () => {
14771478
game.world.pos.set(0, 0, 0);
14781479
});
14791480
});
1481+
1482+
describe("colorMatrix", () => {
1483+
it("should default to identity", () => {
1484+
setup();
1485+
const cam = new Camera2d(0, 0, 800, 600);
1486+
expect(cam.colorMatrix).toBeInstanceOf(ColorMatrix);
1487+
expect(cam.colorMatrix.isIdentity()).toBe(true);
1488+
});
1489+
1490+
it("should not add effect to postEffects when identity", () => {
1491+
setup();
1492+
const cam = new Camera2d(0, 0, 800, 600);
1493+
cam.draw(video.renderer, game.world);
1494+
expect(cam.postEffects).toHaveLength(0);
1495+
});
1496+
1497+
it("non-identity should lazily create the internal effect", () => {
1498+
setup();
1499+
const cam = new Camera2d(0, 0, 800, 600);
1500+
expect(cam._colorMatrixEffect).toBeNull();
1501+
cam.colorMatrix.contrast(1.2);
1502+
cam.draw(video.renderer, game.world);
1503+
// effect created but removed from postEffects after draw (transient)
1504+
expect(cam._colorMatrixEffect).not.toBeNull();
1505+
});
1506+
1507+
it("colorMatrix effect should not persist in postEffects after draw", () => {
1508+
setup();
1509+
const cam = new Camera2d(0, 0, 800, 600);
1510+
cam.colorMatrix.saturate(1.5);
1511+
cam.draw(video.renderer, game.world);
1512+
// transient — removed after endPostEffect
1513+
expect(cam.postEffects.indexOf(cam._colorMatrixEffect)).toBe(-1);
1514+
});
1515+
1516+
it("user effects should not be affected by colorMatrix lifecycle", () => {
1517+
setup();
1518+
const cam = new Camera2d(0, 0, 800, 600);
1519+
cam.colorMatrix.saturate(1.5);
1520+
const other = { enabled: true };
1521+
cam.addPostEffect(other);
1522+
cam.draw(video.renderer, game.world);
1523+
// user effect persists, colorMatrix effect is transient
1524+
expect(cam.postEffects.indexOf(other)).not.toBe(-1);
1525+
expect(cam.postEffects.indexOf(cam._colorMatrixEffect)).toBe(-1);
1526+
});
1527+
1528+
it("reset to identity should not create or add effect", () => {
1529+
setup();
1530+
const cam = new Camera2d(0, 0, 800, 600);
1531+
cam.colorMatrix.contrast(1.2);
1532+
cam.draw(video.renderer, game.world);
1533+
// reset to identity
1534+
cam.colorMatrix.identity();
1535+
cam.draw(video.renderer, game.world);
1536+
// no colorMatrix effect in postEffects
1537+
expect(cam.postEffects.indexOf(cam._colorMatrixEffect)).toBe(-1);
1538+
});
1539+
1540+
it("clearPostEffects should not prevent colorMatrix from working", () => {
1541+
setup();
1542+
const cam = new Camera2d(0, 0, 800, 600);
1543+
cam.colorMatrix.brightness(1.3);
1544+
cam.draw(video.renderer, game.world);
1545+
// internal effect was created
1546+
expect(cam._colorMatrixEffect).not.toBeNull();
1547+
cam.clearPostEffects();
1548+
// draw again — colorMatrix still works (effect recreated/re-added transiently)
1549+
cam.draw(video.renderer, game.world);
1550+
expect(cam._colorMatrixEffect).not.toBeNull();
1551+
});
1552+
});
14801553
});

0 commit comments

Comments
 (0)