diff --git a/packages/examples/src/examples/blendModes/ExampleBlendModes.tsx b/packages/examples/src/examples/blendModes/ExampleBlendModes.tsx new file mode 100644 index 0000000000..ec1528beba --- /dev/null +++ b/packages/examples/src/examples/blendModes/ExampleBlendModes.tsx @@ -0,0 +1,134 @@ +import { game, Renderable, Text, video } from "melonjs"; +import { createExampleComponent } from "../utils"; + +const BLEND_MODES = [ + "normal", + "add", + "multiply", + "screen", + "overlay", + "darken", + "lighten", + "color-dodge", + "color-burn", + "hard-light", + "soft-light", + "difference", + "exclusion", +]; + +const COLS = 5; +const CELL_W = 180; +const CELL_H = 170; +const CIRCLE_R = 45; + +const createGame = () => { + const rows = Math.ceil(BLEND_MODES.length / COLS); + const canvasW = COLS * CELL_W; + const HEADER_H = 40; + const canvasH = rows * CELL_H + HEADER_H; + + if ( + !video.init(canvasW, canvasH, { + parent: "screen", + renderer: video.AUTO, + preferWebGL1: false, + }) + ) { + alert("Your browser does not support HTML5 canvas."); + return; + } + + // detect which modes are actually supported by probing setBlendMode + const renderer = video.renderer as any; + const supported: Record = {}; + for (const mode of BLEND_MODES) { + const applied = renderer.setBlendMode(mode); + supported[mode] = applied === mode; + } + renderer.setBlendMode("normal"); + + // single renderable that draws the entire blend mode grid + class BlendModeGrid extends Renderable { + constructor() { + super(0, 0, canvasW, canvasH); + this.anchorPoint.set(0, 0); + } + + override draw(renderer: any) { + // dark background + renderer.setBlendMode("normal"); + renderer.setGlobalAlpha(1.0); + renderer.setColor("#1a1a2e"); + renderer.fillRect(0, 0, canvasW, canvasH); + + for (let i = 0; i < BLEND_MODES.length; i++) { + const col = i % COLS; + const row = Math.floor(i / COLS); + const cx = col * CELL_W + CELL_W / 2; + const cy = row * CELL_H + CELL_H / 2 - 10 + HEADER_H; + const mode = BLEND_MODES[i]; + + // blue circle (base) — always normal blend + renderer.setBlendMode("normal"); + renderer.setGlobalAlpha(1.0); + renderer.setColor("#2266dd"); + renderer.fillEllipse( + cx - CIRCLE_R * 0.35, + cy - CIRCLE_R * 0.2, + CIRCLE_R, + CIRCLE_R, + ); + + // red circle — apply the test blend mode + renderer.setBlendMode(mode); + renderer.setGlobalAlpha(0.85); + renderer.setColor("#dd4422"); + renderer.fillEllipse( + cx + CIRCLE_R * 0.35, + cy + CIRCLE_R * 0.2, + CIRCLE_R, + CIRCLE_R, + ); + } + + // reset + renderer.setBlendMode("normal"); + } + } + + game.world.addChild(new BlendModeGrid(), 0); + + // renderer type header + const header = new Text(canvasW / 2, 8, { + font: "Arial", + size: 16, + fillStyle: "#aaaaaa", + textAlign: "center", + text: renderer.type + " Supported Blend Modes", + }); + header.anchorPoint.set(0, 0); + header.floating = true; + game.world.addChild(header, 2); + + // add labels — green if supported, red if not + for (let i = 0; i < BLEND_MODES.length; i++) { + const col = i % COLS; + const row = Math.floor(i / COLS); + const x = col * CELL_W + CELL_W / 2; + const y = row * CELL_H + CELL_H - 25 + HEADER_H; + const mode = BLEND_MODES[i]; + + const label = new Text(x, y, { + font: "Arial", + size: 14, + fillStyle: supported[mode] ? "#44dd44" : "#dd4444", + textAlign: "center", + text: mode, + }); + label.anchorPoint.set(0, 0); + game.world.addChild(label, 1); + } +}; + +export const ExampleBlendModes = createExampleComponent(createGame); diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx index dce22e6c56..8137e4b7c8 100644 --- a/packages/examples/src/main.tsx +++ b/packages/examples/src/main.tsx @@ -18,6 +18,7 @@ const sourceFiles = import.meta.glob("./examples/**/*.{ts,tsx,js}", { import { ExampleAseprite } from "./examples/aseprite/ExampleAseprite"; import { ExampleBenchmark } from "./examples/benchmark/ExampleBenchmark"; +import { ExampleBlendModes } from "./examples/blendModes/ExampleBlendModes"; import { ExampleCompressedTextures } from "./examples/compressedTextures/ExampleCompressedTextures"; import { ExampleDeviceTest } from "./examples/deviceTest/ExampleDeviceTest"; import { ExampleDragAndDrop } from "./examples/dragAndDrop/ExampleDragAndDrop"; @@ -61,6 +62,14 @@ const examples: { description: "Stress test rendering thousands of animated sprites to measure engine performance.", }, + { + component: , + label: "Blend Modes", + path: "blend-modes", + sourceDir: "blendModes", + description: + "Visual comparison of all supported blend modes (normal, multiply, screen, overlay, darken, lighten, etc.).", + }, { component: , label: "Compressed Textures", diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index eb2c7c3566..8b4975acde 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -10,6 +10,8 @@ - TMX: support Tiled 1.8+ class-type custom properties (`type="class"` with nested properties) - TMX: use Tiled 1.10+ `isCollection` tileset flag when available (fallback to image detection for older maps) - TMX: support Tiled 1.12+ per-object opacity and visibility +- CanvasRenderer: add support for additional CSS blend modes (`overlay`, `darken`, `lighten`, `color-dodge`, `color-burn`, `hard-light`, `soft-light`, `difference`, `exclusion`) when supported by the browser +- WebGLRenderer: add `darken` and `lighten` blend modes via `gl.MIN`/`gl.MAX` (WebGL2 only) ### Changed - TypeScript: convert leaf modules to TypeScript — plugin, camera, particles emitter, state, audio diff --git a/packages/melonjs/src/video/canvas/canvas_renderer.js b/packages/melonjs/src/video/canvas/canvas_renderer.js index b4c234de54..c49413a49c 100644 --- a/packages/melonjs/src/video/canvas/canvas_renderer.js +++ b/packages/melonjs/src/video/canvas/canvas_renderer.js @@ -109,34 +109,67 @@ export default class CanvasRenderer extends Renderer { /** * set a blend mode for the given context.
- * Supported blend mode between Canvas and WebGL renderer :
- * - "normal" : this is the default mode and draws new content on top of the existing content
- *
- * - "multiply" : the pixels of the top layer are multiplied with the corresponding pixel of the bottom layer. A darker picture is the result.
- *
- * - "additive or lighter" : where both content overlap the color is determined by adding color values.
- *
- * - "screen" : The pixels are inverted, multiplied, and inverted again. A lighter picture is the result (opposite of multiply)
- *
+ * All renderers support:
+ * - "normal" : draws new content on top of the existing content
+ *
+ * - "add", "additive", or "lighter" : color values are added together
+ *
+ * - "multiply" : pixels are multiplied, resulting in a darker picture
+ *
+ * - "screen" : pixels are inverted, multiplied, and inverted again (opposite of multiply)
+ *
+ * Canvas (browser-dependent) and WebGL2:
+ * - "darken" : retains the darkest pixels of both layers
+ *
+ * - "lighten" : retains the lightest pixels of both layers
+ *
+ * Canvas only, browser-dependent (falls back to "normal" if unsupported or in WebGL):
+ * - "overlay"
+ *
+ * - "color-dodge"
+ *
+ * - "color-burn"
+ *
+ * - "hard-light"
+ *
+ * - "soft-light"
+ *
+ * - "difference"
+ *
+ * - "exclusion"
+ *
* @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation - * @param {string} [mode="normal"] - blend mode : "normal", "multiply", "lighter, "additive", "screen" + * @param {string} [mode="normal"] - blend mode * @param {CanvasRenderingContext2D} [context] + * @returns {string} the blend mode actually applied (may differ if the requested mode is unsupported) */ setBlendMode(mode = "normal", context) { context = context || this.getContext(); this.currentBlendMode = mode; switch (mode) { - case "screen": - context.globalCompositeOperation = "screen"; - break; - case "lighter": case "additive": + case "add": context.globalCompositeOperation = "lighter"; break; case "multiply": - context.globalCompositeOperation = "multiply"; + case "screen": + case "overlay": + case "darken": + case "lighten": + case "color-dodge": + case "color-burn": + case "hard-light": + case "soft-light": + case "difference": + case "exclusion": + context.globalCompositeOperation = mode; + // verify the browser accepted the mode + if (context.globalCompositeOperation !== mode) { + context.globalCompositeOperation = "source-over"; + this.currentBlendMode = "normal"; + } break; default: // normal @@ -144,6 +177,7 @@ export default class CanvasRenderer extends Renderer { this.currentBlendMode = "normal"; break; } + return this.currentBlendMode; } /** diff --git a/packages/melonjs/src/video/images/add-blendmode.png b/packages/melonjs/src/video/images/add-blendmode.png new file mode 100644 index 0000000000..c889ac4f36 Binary files /dev/null and b/packages/melonjs/src/video/images/add-blendmode.png differ diff --git a/packages/melonjs/src/video/images/color-burn-blendmode.png b/packages/melonjs/src/video/images/color-burn-blendmode.png new file mode 100644 index 0000000000..b9c8ea93c7 Binary files /dev/null and b/packages/melonjs/src/video/images/color-burn-blendmode.png differ diff --git a/packages/melonjs/src/video/images/color-dodge-blendmode.png b/packages/melonjs/src/video/images/color-dodge-blendmode.png new file mode 100644 index 0000000000..1e333e74d0 Binary files /dev/null and b/packages/melonjs/src/video/images/color-dodge-blendmode.png differ diff --git a/packages/melonjs/src/video/images/darken-blendmode.png b/packages/melonjs/src/video/images/darken-blendmode.png new file mode 100644 index 0000000000..f93f0b2a7f Binary files /dev/null and b/packages/melonjs/src/video/images/darken-blendmode.png differ diff --git a/packages/melonjs/src/video/images/difference-blendmode.png b/packages/melonjs/src/video/images/difference-blendmode.png new file mode 100644 index 0000000000..b4c39f0df7 Binary files /dev/null and b/packages/melonjs/src/video/images/difference-blendmode.png differ diff --git a/packages/melonjs/src/video/images/exclusion-blendmode.png b/packages/melonjs/src/video/images/exclusion-blendmode.png new file mode 100644 index 0000000000..f276eb815f Binary files /dev/null and b/packages/melonjs/src/video/images/exclusion-blendmode.png differ diff --git a/packages/melonjs/src/video/images/hard-light-blendmode.png b/packages/melonjs/src/video/images/hard-light-blendmode.png new file mode 100644 index 0000000000..cf07d8ace2 Binary files /dev/null and b/packages/melonjs/src/video/images/hard-light-blendmode.png differ diff --git a/packages/melonjs/src/video/images/lighten-blendmode.png b/packages/melonjs/src/video/images/lighten-blendmode.png new file mode 100644 index 0000000000..e5ec2be9c8 Binary files /dev/null and b/packages/melonjs/src/video/images/lighten-blendmode.png differ diff --git a/packages/melonjs/src/video/images/multiply-blendmode.png b/packages/melonjs/src/video/images/multiply-blendmode.png index 6867dbd332..8c95a13afb 100644 Binary files a/packages/melonjs/src/video/images/multiply-blendmode.png and b/packages/melonjs/src/video/images/multiply-blendmode.png differ diff --git a/packages/melonjs/src/video/images/normal-blendmode.png b/packages/melonjs/src/video/images/normal-blendmode.png index 7011ff8b7d..94bf6cadb9 100644 Binary files a/packages/melonjs/src/video/images/normal-blendmode.png and b/packages/melonjs/src/video/images/normal-blendmode.png differ diff --git a/packages/melonjs/src/video/images/overlay-blendmode.png b/packages/melonjs/src/video/images/overlay-blendmode.png new file mode 100644 index 0000000000..15db223118 Binary files /dev/null and b/packages/melonjs/src/video/images/overlay-blendmode.png differ diff --git a/packages/melonjs/src/video/images/screen-blendmode.png b/packages/melonjs/src/video/images/screen-blendmode.png index 0da3b043ed..11764d413f 100644 Binary files a/packages/melonjs/src/video/images/screen-blendmode.png and b/packages/melonjs/src/video/images/screen-blendmode.png differ diff --git a/packages/melonjs/src/video/images/soft-light-blendmode.png b/packages/melonjs/src/video/images/soft-light-blendmode.png new file mode 100644 index 0000000000..404d0a30b2 Binary files /dev/null and b/packages/melonjs/src/video/images/soft-light-blendmode.png differ diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 97b5f88348..0556ea81d6 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -725,18 +725,27 @@ export default class WebGLRenderer extends Renderer { /** * set a blend mode for the given context.
- * Supported blend mode between Canvas and WebGL renderer :
- * - "normal" : this is the default mode and draws new content on top of the existing content
- *
- * - "multiply" : the pixels of the top layer are multiplied with the corresponding pixel of the bottom layer. A darker picture is the result.
- *
- * - "additive or lighter" : where both content overlap the color is determined by adding color values.
- *
- * - "screen" : The pixels are inverted, multiplied, and inverted again. A lighter picture is the result (opposite of multiply)
- *
+ * All renderers support:
+ * - "normal" : draws new content on top of the existing content
+ *
+ * - "add", "additive", or "lighter" : color values are added together
+ *
+ * - "multiply" : pixels are multiplied, resulting in a darker picture
+ *
+ * - "screen" : pixels are inverted, multiplied, and inverted again (opposite of multiply)
+ *
+ * WebGL2 additionally supports:
+ * - "darken" : retains the darkest pixels of both layers
+ *
+ * - "lighten" : retains the lightest pixels of both layers
+ *
+ * Other CSS blend modes ("overlay", "color-dodge", "color-burn", "hard-light", "soft-light", + * "difference", "exclusion") may be supported by the Canvas renderer (browser-dependent) + * and will always fall back to "normal" in WebGL.
* @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation - * @param {string} [mode="normal"] - blend mode : "normal", "multiply", "lighter", "additive", "screen" + * @param {string} [mode="normal"] - blend mode * @param {WebGLRenderingContext} [gl] - a WebGL context + * @returns {string} the blend mode actually applied (may differ if the requested mode is unsupported) */ setBlendMode(mode = "normal", gl = this.gl) { if (this.currentBlendMode !== mode) { @@ -746,24 +755,52 @@ export default class WebGLRenderer extends Renderer { switch (mode) { case "screen": + gl.blendEquation(gl.FUNC_ADD); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_COLOR); break; case "lighter": case "additive": + case "add": + gl.blendEquation(gl.FUNC_ADD); gl.blendFunc(gl.ONE, gl.ONE); break; case "multiply": + gl.blendEquation(gl.FUNC_ADD); gl.blendFunc(gl.DST_COLOR, gl.ONE_MINUS_SRC_ALPHA); break; + case "darken": + if (this.WebGLVersion > 1) { + gl.blendEquation(gl.MIN); + gl.blendFunc(gl.ONE, gl.ONE); + } else { + gl.blendEquation(gl.FUNC_ADD); + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + this.currentBlendMode = "normal"; + } + break; + + case "lighten": + if (this.WebGLVersion > 1) { + gl.blendEquation(gl.MAX); + gl.blendFunc(gl.ONE, gl.ONE); + } else { + gl.blendEquation(gl.FUNC_ADD); + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + this.currentBlendMode = "normal"; + } + break; + default: + gl.blendEquation(gl.FUNC_ADD); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); this.currentBlendMode = "normal"; break; } } + return this.currentBlendMode; } /**