From 6653644ad3a776c8399fa091498948b9933c9af0 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 16 Apr 2026 07:55:49 +0800 Subject: [PATCH 01/12] Add multi-texture batching for WebGL renderer (#1376) Allows up to 16 textures in a single draw call, eliminating GPU flushes on texture changes. ~80% fewer draw calls on the platformer example, 1000 quads/flush on the sprite benchmark with 5000 objects. - Generate multi-texture fragment shader dynamically with per-texture sampler uniforms and if/else selection chain - Static quad-multi.vert with aTextureId attribute - push() accepts optional textureId parameter (6 floats when provided) - uploadTexture/bindTexture2D accept flush parameter to skip flushing - QuadBatcher falls back to single-texture when custom ShaderEffect active - Track scissor state with _scissorActive instead of gl.isEnabled() query - Add unit tests for push with textureId and fragment shader generation - Add "Tap or Click to spawn" hint to benchmark example Co-Authored-By: Claude Opus 4.6 (1M context) --- .../examples/benchmark/ExampleBenchmark.tsx | 13 +++ packages/melonjs/CHANGELOG.md | 3 + .../video/webgl/batchers/material_batcher.js | 16 ++-- .../src/video/webgl/batchers/quad_batcher.js | 82 ++++++++++++++++--- .../melonjs/src/video/webgl/buffer/vertex.js | 13 ++- .../src/video/webgl/shaders/multitexture.js | 42 ++++++++++ .../src/video/webgl/shaders/quad-multi.vert | 21 +++++ .../melonjs/src/video/webgl/webgl_renderer.js | 8 +- packages/melonjs/tests/vertexBuffer.spec.js | 74 ++++++++++++++++- 9 files changed, 249 insertions(+), 23 deletions(-) create mode 100644 packages/melonjs/src/video/webgl/shaders/multitexture.js create mode 100644 packages/melonjs/src/video/webgl/shaders/quad-multi.vert diff --git a/packages/examples/src/examples/benchmark/ExampleBenchmark.tsx b/packages/examples/src/examples/benchmark/ExampleBenchmark.tsx index 6fc79d504..c50e52fa7 100644 --- a/packages/examples/src/examples/benchmark/ExampleBenchmark.tsx +++ b/packages/examples/src/examples/benchmark/ExampleBenchmark.tsx @@ -7,6 +7,7 @@ import { loader, plugin, ScaleMethods, + Text, video, } from "melonjs"; import { createExampleComponent } from "../utils"; @@ -65,6 +66,18 @@ const createGame = () => { // reset/empty the game world game.world.reset(); + // add hint text + const hint = new Text(game.viewport.width / 2, 20, { + font: "Arial", + size: "16px", + fillStyle: "#ffffff", + textAlign: "center", + text: "Tap or Click to spawn more sprites", + }); + hint.floating = true; + hint.setOpacity(0.6); + game.world.addChild(hint, Infinity); + addFruits(FRUIT_STEP, "watermelon"); }); }; diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index e24ac8e60..8bdd40a33 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -2,6 +2,9 @@ ## [19.1.0] (melonJS 2) - _2026-04-15_ +### Added +- WebGL: multi-texture batching — up to 16 textures (based on device capabilities) drawn in a single batch/draw call, eliminating GPU flushes on texture changes. Automatically falls back to single-texture mode when a custom `ShaderEffect` is active. ~80% fewer draw calls on the platformer example (14 vs ~70 flushes/frame), with an estimated 30-50% FPS improvement on low-end mobile devices. + ### Changed - WebGL: `CanvasRenderTarget` internal default for `failIfMajorPerformanceCaveat` changed from `true` to `false` — allows WebGL context creation on machines with blocklisted GPU drivers when creating render targets directly. Application default remains `true`; set `failIfMajorPerformanceCaveat: false` in Application options to opt in. diff --git a/packages/melonjs/src/video/webgl/batchers/material_batcher.js b/packages/melonjs/src/video/webgl/batchers/material_batcher.js index d99dd7370..4bb359d04 100644 --- a/packages/melonjs/src/video/webgl/batchers/material_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/material_batcher.js @@ -216,11 +216,13 @@ export class MaterialBatcher extends Batcher { * @param {WebGLTexture} texture - a WebGL texture * @param {number} unit - Texture unit to which the given texture is bound */ - bindTexture2D(texture, unit) { + bindTexture2D(texture, unit, flush = true) { const gl = this.gl; if (texture !== this.boundTextures[unit]) { - this.flush(); + if (flush) { + this.flush(); + } if (this.currentTextureUnit !== unit) { this.currentTextureUnit = unit; gl.activeTexture(gl.TEXTURE0 + unit); @@ -228,7 +230,9 @@ export class MaterialBatcher extends Batcher { gl.bindTexture(gl.TEXTURE_2D, texture); this.boundTextures[unit] = texture; } else if (this.currentTextureUnit !== unit) { - this.flush(); + if (flush) { + this.flush(); + } this.currentTextureUnit = unit; gl.activeTexture(gl.TEXTURE0 + unit); } @@ -256,7 +260,7 @@ export class MaterialBatcher extends Batcher { /** * @ignore */ - uploadTexture(texture, w, h, force = false) { + uploadTexture(texture, w, h, force = false, flush = true) { const unit = this.renderer.cache.getUnit(texture); const texture2D = this.boundTextures[unit]; @@ -273,9 +277,9 @@ export class MaterialBatcher extends Batcher { texture2D, ); } else { - this.bindTexture2D(texture2D, unit); + this.bindTexture2D(texture2D, unit, flush); } - return this.currentTextureUnit; + return flush ? this.currentTextureUnit : unit; } } diff --git a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js index 6b5d15fcd..80b457a00 100644 --- a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js @@ -1,7 +1,7 @@ import { Vector2d } from "../../../math/vector2d.ts"; import IndexBuffer from "../buffer/index.js"; -import quadFragment from "./../shaders/quad.frag"; -import quadVertex from "./../shaders/quad.vert"; +import { buildMultiTextureFragment } from "./../shaders/multitexture.js"; +import quadMultiVertex from "./../shaders/quad-multi.vert"; import { MaterialBatcher } from "./material_batcher.js"; /** @@ -28,6 +28,13 @@ export default class QuadBatcher extends MaterialBatcher { * @ignore */ init(renderer) { + /** + * the maximum number of texture units used for multi-texture batching + * @type {number} + * @ignore + */ + this.maxBatchTextures = Math.min(renderer.maxTextures, 16); + super.init(renderer, { attributes: [ { @@ -51,13 +58,33 @@ export default class QuadBatcher extends MaterialBatcher { normalized: true, offset: 4 * Float32Array.BYTES_PER_ELEMENT, }, + { + name: "aTextureId", + size: 1, + type: renderer.gl.FLOAT, + normalized: false, + offset: 5 * Float32Array.BYTES_PER_ELEMENT, + }, ], shader: { - vertex: quadVertex, - fragment: quadFragment, + vertex: quadMultiVertex, + fragment: buildMultiTextureFragment(this.maxBatchTextures), }, }); + // bind all sampler uniforms to their respective texture units + for (let i = 0; i < this.maxBatchTextures; i++) { + this.defaultShader.setUniform("uSampler" + i, i); + } + + /** + * whether multi-texture batching is currently active + * (disabled when a custom ShaderEffect is applied) + * @type {boolean} + * @ignore + */ + this.useMultiTexture = true; + // create the index buffer for quad batching (4 verts + 6 indices per quad) const maxQuads = this.vertexData.maxVertex / 4; this.indexBuffer = new IndexBuffer( @@ -68,6 +95,18 @@ export default class QuadBatcher extends MaterialBatcher { this.indexBuffer.fillQuadPattern(maxQuads); } + /** + * Select the shader to use for compositing. + * Multi-texture batching is automatically enabled when the default + * shader is active, and disabled for custom ShaderEffect shaders. + * @see GLShader + * @param {GLShader} shader - a reference to a GLShader instance + */ + useShader(shader) { + super.useShader(shader); + this.useMultiTexture = shader === this.defaultShader; + } + /** * Reset compositor internal state * @ignore @@ -83,6 +122,12 @@ export default class QuadBatcher extends MaterialBatcher { this.renderer.WebGLVersion > 1, ); this.indexBuffer.fillQuadPattern(maxQuads); + + // re-bind sampler uniforms after context restore + for (let i = 0; i < this.maxBatchTextures; i++) { + this.defaultShader.setUniform("uSampler" + i, i); + } + this.useMultiTexture = true; } /** @@ -145,11 +190,20 @@ export default class QuadBatcher extends MaterialBatcher { this.flush(); } - const unit = this.uploadTexture(texture, w, h, reupload); - - if (unit !== this.currentSamplerUnit) { - this.currentShader.setUniform("uSampler", unit); - this.currentSamplerUnit = unit; + let unit; + + if (this.useMultiTexture) { + // multi-texture path: embed the texture unit in the vertex data + // and avoid flushing on texture changes + unit = this.uploadTexture(texture, w, h, reupload, false); + } else { + // single-texture fallback (custom ShaderEffect active): + // use regular upload which flushes on texture change, and set uSampler + unit = this.uploadTexture(texture, w, h, reupload); + if (unit !== this.currentSamplerUnit) { + this.currentShader.setUniform("uSampler", unit); + this.currentSamplerUnit = unit; + } } // Transform vertices @@ -167,9 +221,11 @@ export default class QuadBatcher extends MaterialBatcher { } // 4 vertices per quad; the index buffer provides the 6 indices - vertexData.push(vec0.x, vec0.y, u0, v0, tint); - vertexData.push(vec1.x, vec1.y, u1, v0, tint); - vertexData.push(vec2.x, vec2.y, u0, v1, tint); - vertexData.push(vec3.x, vec3.y, u1, v1, tint); + // textureId is the unit index for multi-texture, or 0 for single-texture fallback + const textureId = this.useMultiTexture ? unit : 0; + vertexData.push(vec0.x, vec0.y, u0, v0, tint, textureId); + vertexData.push(vec1.x, vec1.y, u1, v0, tint, textureId); + vertexData.push(vec2.x, vec2.y, u0, v1, tint, textureId); + vertexData.push(vec3.x, vec3.y, u1, v1, tint, textureId); } } diff --git a/packages/melonjs/src/video/webgl/buffer/vertex.js b/packages/melonjs/src/video/webgl/buffer/vertex.js index 79db90775..d3cbe0025 100644 --- a/packages/melonjs/src/video/webgl/buffer/vertex.js +++ b/packages/melonjs/src/video/webgl/buffer/vertex.js @@ -39,10 +39,16 @@ export default class VertexArrayBuffer { } /** - * push a new vertex to the buffer (quad format: x, y, u, v, tint) + * push a new vertex to the buffer + * @param {number} x - x position + * @param {number} y - y position + * @param {number} u - texture U coordinate + * @param {number} v - texture V coordinate + * @param {number} tint - tint color in UINT32 (argb) format + * @param {number} [textureId] - texture unit index for multi-texture batching * @ignore */ - push(x, y, u, v, tint) { + push(x, y, u, v, tint, textureId) { const offset = this.vertexCount * this.vertexSize; this.bufferF32[offset] = x; @@ -50,6 +56,9 @@ export default class VertexArrayBuffer { this.bufferF32[offset + 2] = u; this.bufferF32[offset + 3] = v; this.bufferU32[offset + 4] = tint; + if (arguments.length > 5) { + this.bufferF32[offset + 5] = textureId; + } this.vertexCount++; diff --git a/packages/melonjs/src/video/webgl/shaders/multitexture.js b/packages/melonjs/src/video/webgl/shaders/multitexture.js new file mode 100644 index 000000000..4a7c78088 --- /dev/null +++ b/packages/melonjs/src/video/webgl/shaders/multitexture.js @@ -0,0 +1,42 @@ +/** + * Generates a multi-texture fragment shader source string. + * Declares individual sampler uniforms (uSampler0..uSamplerN) and uses + * an if/else chain with 0.5-offset thresholds to select the correct texture unit. + * @param {number} maxTextures - the number of texture units to support + * @returns {string} GLSL fragment shader source + * @ignore + */ +export function buildMultiTextureFragment(maxTextures) { + const lines = []; + + // declare sampler uniforms + for (let i = 0; i < maxTextures; i++) { + lines.push("uniform sampler2D uSampler" + i + ";"); + } + + lines.push("varying vec4 vColor;"); + lines.push("varying vec2 vRegion;"); + lines.push("varying float vTextureId;"); + lines.push(""); + lines.push("void main(void) {"); + lines.push(" vec4 color;"); + + // generate if/else chain using < N.5 thresholds + for (let i = 0; i < maxTextures; i++) { + if (i === 0) { + lines.push(" if (vTextureId < 0.5) {"); + } else { + lines.push(" } else if (vTextureId < " + (i + 0.5) + ") {"); + } + lines.push(" color = texture2D(uSampler" + i + ", vRegion);"); + } + + // fallback to first sampler if vTextureId is out of range + lines.push(" } else {"); + lines.push(" color = texture2D(uSampler0, vRegion);"); + lines.push(" }"); + lines.push(" gl_FragColor = color * vColor;"); + lines.push("}"); + + return lines.join("\n"); +} diff --git a/packages/melonjs/src/video/webgl/shaders/quad-multi.vert b/packages/melonjs/src/video/webgl/shaders/quad-multi.vert new file mode 100644 index 000000000..43e9d59ff --- /dev/null +++ b/packages/melonjs/src/video/webgl/shaders/quad-multi.vert @@ -0,0 +1,21 @@ +// Current vertex point +attribute vec2 aVertex; +attribute vec2 aRegion; +attribute vec4 aColor; +attribute float aTextureId; + +// Projection matrix +uniform mat4 uProjectionMatrix; + +varying vec2 vRegion; +varying vec4 vColor; +varying float vTextureId; + +void main(void) { + // Transform the vertex position by the projection matrix + gl_Position = uProjectionMatrix * vec4(aVertex, 0.0, 1.0); + // Pass the remaining attributes to the fragment shader + vColor = vec4(aColor.bgr * aColor.a, aColor.a); + vRegion = aRegion; + vTextureId = aTextureId; +} diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 0f23344aa..5593374c5 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -158,6 +158,7 @@ export default class WebGLRenderer extends Renderer { this.gl.depthMask(false); this.gl.disable(this.gl.SCISSOR_TEST); + this._scissorActive = false; this.gl.enable(this.gl.BLEND); // set default mode @@ -325,6 +326,7 @@ export default class WebGLRenderer extends Renderer { this.setBatcher("quad"); this.gl.disable(this.gl.SCISSOR_TEST); + this._scissorActive = false; } /** @@ -948,6 +950,7 @@ export default class WebGLRenderer extends Renderer { const gl = this.gl; const s = this.currentScissor; gl.enable(gl.SCISSOR_TEST); + this._scissorActive = true; gl.scissor( s[0] + this.currentTransform.tx, canvas.height - s[3] - s[1] - this.currentTransform.ty, @@ -956,6 +959,7 @@ export default class WebGLRenderer extends Renderer { ); } else { this.gl.disable(this.gl.SCISSOR_TEST); + this._scissorActive = false; } } // sync gradient from renderState @@ -976,7 +980,7 @@ export default class WebGLRenderer extends Renderer { * renderer.restore(); */ save() { - this.renderState.save(this.gl.isEnabled(this.gl.SCISSOR_TEST)); + this.renderState.save(this._scissorActive === true); } /** @@ -1711,6 +1715,7 @@ export default class WebGLRenderer extends Renderer { this.flush(); // turn on scissor test gl.enable(this.gl.SCISSOR_TEST); + this._scissorActive = true; // set the scissor rectangle (note : coordinates are left/bottom) gl.scissor( // scissor does not account for currentTransform, so manually adjust @@ -1727,6 +1732,7 @@ export default class WebGLRenderer extends Renderer { } else { // turn off scissor test gl.disable(gl.SCISSOR_TEST); + this._scissorActive = false; } } diff --git a/packages/melonjs/tests/vertexBuffer.spec.js b/packages/melonjs/tests/vertexBuffer.spec.js index 1f7d7969d..58dac6b66 100644 --- a/packages/melonjs/tests/vertexBuffer.spec.js +++ b/packages/melonjs/tests/vertexBuffer.spec.js @@ -1,9 +1,10 @@ import { describe, expect, it } from "vitest"; import VertexArrayBuffer from "../src/video/webgl/buffer/vertex.js"; +import { buildMultiTextureFragment } from "../src/video/webgl/shaders/multitexture.js"; describe("VertexArrayBuffer", () => { describe("push()", () => { - it("should write vertex data at the correct offsets", () => { + it("should write vertex data at the correct offsets (5 floats)", () => { // vertexSize=5 matches the quad format: x, y, u, v, tint const buf = new VertexArrayBuffer(5, 4); @@ -17,6 +18,30 @@ describe("VertexArrayBuffer", () => { expect(buf.bufferU32[4]).toBe(0xffffffff); // tint }); + it("should write vertex data with textureId (6 floats)", () => { + // vertexSize=6 matches the multi-texture quad format + const buf = new VertexArrayBuffer(6, 4); + + buf.push(10, 20, 0.0, 1.0, 0xffffffff, 3); + + expect(buf.vertexCount).toBe(1); + expect(buf.bufferF32[0]).toBe(10); // x + expect(buf.bufferF32[1]).toBe(20); // y + expect(buf.bufferF32[2]).toBe(0.0); // u + expect(buf.bufferF32[3]).toBe(1.0); // v + expect(buf.bufferU32[4]).toBe(0xffffffff); // tint + expect(buf.bufferF32[5]).toBe(3); // textureId + }); + + it("should not write textureId when not provided", () => { + const buf = new VertexArrayBuffer(6, 4); + + buf.push(10, 20, 0.0, 1.0, 0xffffffff); + + expect(buf.vertexCount).toBe(1); + expect(buf.bufferF32[5]).toBe(0); // untouched (default zero) + }); + it("should write multiple vertices sequentially", () => { const buf = new VertexArrayBuffer(5, 4); @@ -31,6 +56,53 @@ describe("VertexArrayBuffer", () => { expect(buf.bufferF32[8]).toBe(1.0); // v expect(buf.bufferU32[9]).toBe(0x00ff0000); // tint }); + + it("should write multiple vertices with textureId sequentially", () => { + const buf = new VertexArrayBuffer(6, 4); + + buf.push(1, 2, 0.0, 0.0, 0xff000000, 0); + buf.push(3, 4, 1.0, 1.0, 0x00ff0000, 5); + + expect(buf.vertexCount).toBe(2); + expect(buf.bufferF32[5]).toBe(0); // textureId vertex 0 + expect(buf.bufferF32[11]).toBe(5); // textureId vertex 1 + }); + }); + + describe("buildMultiTextureFragment()", () => { + it("should generate correct number of sampler uniforms", () => { + const src = buildMultiTextureFragment(4); + expect(src).toContain("uniform sampler2D uSampler0;"); + expect(src).toContain("uniform sampler2D uSampler1;"); + expect(src).toContain("uniform sampler2D uSampler2;"); + expect(src).toContain("uniform sampler2D uSampler3;"); + expect(src).not.toContain("uSampler4"); + }); + + it("should generate if/else chain with 0.5 thresholds", () => { + const src = buildMultiTextureFragment(3); + expect(src).toContain("if (vTextureId < 0.5)"); + expect(src).toContain("else if (vTextureId < 1.5)"); + expect(src).toContain("else if (vTextureId < 2.5)"); + }); + + it("should include fallback to uSampler0", () => { + const src = buildMultiTextureFragment(2); + expect(src).toContain("} else {"); + expect(src).toContain("color = texture2D(uSampler0, vRegion);"); + }); + + it("should generate a single sampler for maxTextures=1", () => { + const src = buildMultiTextureFragment(1); + expect(src).toContain("uniform sampler2D uSampler0;"); + expect(src).not.toContain("uSampler1"); + expect(src).toContain("if (vTextureId < 0.5)"); + }); + + it("should include vColor multiplication", () => { + const src = buildMultiTextureFragment(2); + expect(src).toContain("gl_FragColor = color * vColor;"); + }); }); describe("pushFloats()", () => { From daba370bf1064fdcb1e961d7438488376b0ab0c1 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 16 Apr 2026 08:14:58 +0800 Subject: [PATCH 02/12] Fix Copilot review: clamp texture unit, guard null compressed formats - Flush and reset texture cache if assigned unit >= maxBatchTextures (prevents wrong texture sampling on GPUs with >16 units) - Guard hasSupportedCompressedFormats against null format entries Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/melonjs/src/video/renderer.js | 8 ++++++-- packages/melonjs/src/video/webgl/batchers/quad_batcher.js | 8 ++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index baa4cdce2..93050deff 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -243,8 +243,12 @@ export default class Renderer { hasSupportedCompressedFormats(format) { const supportedFormats = this.getSupportedCompressedTextureFormats(); for (const supportedFormat in supportedFormats) { - for (const extension in supportedFormats[supportedFormat]) { - if (format === supportedFormats[supportedFormat][extension]) { + const entry = supportedFormats[supportedFormat]; + if (entry === null || typeof entry === "undefined") { + continue; + } + for (const extension in entry) { + if (format === entry[extension]) { return true; } } diff --git a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js index 80b457a00..666f49843 100644 --- a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js @@ -196,6 +196,14 @@ export default class QuadBatcher extends MaterialBatcher { // multi-texture path: embed the texture unit in the vertex data // and avoid flushing on texture changes unit = this.uploadTexture(texture, w, h, reupload, false); + // shader only supports maxBatchTextures samplers — flush and + // reset if the cache assigned a unit beyond the shader's range + if (unit >= this.maxBatchTextures) { + this.flush(); + this.renderer.cache.units.clear(); + this.renderer.cache.usedUnits.clear(); + unit = this.uploadTexture(texture, w, h, reupload, false); + } } else { // single-texture fallback (custom ShaderEffect active): // use regular upload which flushes on texture change, and set uSampler From b553d6148e6706c5b416b592bd24487ce72f6901 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 16 Apr 2026 10:37:46 +0800 Subject: [PATCH 03/12] Thread flush parameter through createTexture2D to bindTexture2D MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Texture re-uploads (e.g. Text canvas changes) in multi-texture mode no longer force a batch flush. The flush flag is now passed from uploadTexture → createTexture2D → bindTexture2D consistently. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/melonjs/src/video/webgl/batchers/material_batcher.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/melonjs/src/video/webgl/batchers/material_batcher.js b/packages/melonjs/src/video/webgl/batchers/material_batcher.js index 4bb359d04..cad4d063b 100644 --- a/packages/melonjs/src/video/webgl/batchers/material_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/material_batcher.js @@ -77,6 +77,7 @@ export class MaterialBatcher extends Batcher { premultipliedAlpha = true, mipmap = true, texture, + flush = true, ) { const gl = this.gl; const isPOT = isPowerOfTwo(w) && isPowerOfTwo(h); @@ -96,7 +97,7 @@ export class MaterialBatcher extends Batcher { currentTexture = gl.createTexture(); } - this.bindTexture2D(currentTexture, unit); + this.bindTexture2D(currentTexture, unit, flush); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, rs); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, rt); @@ -275,6 +276,7 @@ export class MaterialBatcher extends Batcher { texture.premultipliedAlpha, undefined, texture2D, + flush, ); } else { this.bindTexture2D(texture2D, unit, flush); From a9bdb757247c69439bef050532d829890effb4d9 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 16 Apr 2026 10:51:36 +0800 Subject: [PATCH 04/12] Fix gradient rendering with multi-texture batching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flush before and after drawing gradient fillRect — the shared gradient canvas is reused across gradients, so the texture must be uploaded immediately before the canvas is overwritten by the next gradient. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/melonjs/src/video/webgl/webgl_renderer.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 5593374c5..46a4bc46f 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -1380,7 +1380,11 @@ export default class WebGLRenderer extends Renderer { fillRect(x, y, width, height) { if (this._currentGradient) { const canvas = this._currentGradient.toCanvas(this, x, y, width, height); + // flush before drawing — the gradient uses a shared canvas that gets + // overwritten by the next gradient, so the texture must be uploaded immediately + this.flush(); this.drawImage(canvas, 0, 0, width, height, x, y, width, height); + this.flush(); return; } this.setBatcher("primitive"); From 3d21780bf8f8b8566eff5d527649fbf043596006 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 16 Apr 2026 10:55:54 +0800 Subject: [PATCH 05/12] Flush on CanvasRenderTarget.invalidate() for multi-texture batching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a canvas texture is invalidated (Text, Gradient, etc.), flush pending draws that reference the old texture data before unbinding. This is the single correct fix point for all dynamic canvas textures. Simplifies the gradient fillRect path — no longer needs manual flushes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../melonjs/src/video/rendertarget/canvasrendertarget.js | 3 +++ packages/melonjs/src/video/webgl/webgl_renderer.js | 5 +---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/melonjs/src/video/rendertarget/canvasrendertarget.js b/packages/melonjs/src/video/rendertarget/canvasrendertarget.js index 6b4644fe2..c77df7472 100644 --- a/packages/melonjs/src/video/rendertarget/canvasrendertarget.js +++ b/packages/melonjs/src/video/rendertarget/canvasrendertarget.js @@ -275,6 +275,9 @@ class CanvasRenderTarget { */ invalidate(renderer) { if (typeof renderer.gl !== "undefined") { + // flush pending draws that reference the current texture data + // before invalidating (required for multi-texture batching) + renderer.flush(); // make sure the right batcher is active renderer.setBatcher("quad"); // invalidate the previous corresponding texture so that it can reuploaded once changed diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 46a4bc46f..b655b75ed 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -1379,12 +1379,9 @@ export default class WebGLRenderer extends Renderer { */ fillRect(x, y, width, height) { if (this._currentGradient) { + // toCanvas() calls invalidate() which flushes pending draws const canvas = this._currentGradient.toCanvas(this, x, y, width, height); - // flush before drawing — the gradient uses a shared canvas that gets - // overwritten by the next gradient, so the texture must be uploaded immediately - this.flush(); this.drawImage(canvas, 0, 0, width, height, x, y, width, height); - this.flush(); return; } this.setBatcher("primitive"); From 1fda0ca2c3d366b06632e4e2e9b7ca6ce171a3cd Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 16 Apr 2026 10:57:57 +0800 Subject: [PATCH 06/12] Convert compressed textures example to Application pattern Replace video.init() + game/state globals with new Application(). Pass app to CompressedTextureDisplay for viewport access. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ExampleCompressedTextures.tsx | 91 ++++++++++--------- 1 file changed, 47 insertions(+), 44 deletions(-) diff --git a/packages/examples/src/examples/compressedTextures/ExampleCompressedTextures.tsx b/packages/examples/src/examples/compressedTextures/ExampleCompressedTextures.tsx index e98febc6d..c0243b235 100644 --- a/packages/examples/src/examples/compressedTextures/ExampleCompressedTextures.tsx +++ b/packages/examples/src/examples/compressedTextures/ExampleCompressedTextures.tsx @@ -1,11 +1,9 @@ import { - type CanvasRenderer, + Application, ColorLayer, - game, loader, Renderable, Sprite, - state, Text, video, type WebGLRenderer, @@ -40,10 +38,16 @@ const textureAssets = [ src: "assets/compressedTextures/format_bc7_unorm.ktx", }, { - name: "ktx-astc", - label: "KTX (ASTC)", - ext: "astc", - src: "assets/compressedTextures/format_astc_4x4_srgb.ktx", + name: "pvr-pvrtc4", + label: "PVR (PVRTC 4bpp)", + ext: "pvrtc", + src: "assets/compressedTextures/shannon-pvrtc-4bpp-rgba.pvr", + }, + { + name: "ktx-pvrtc", + label: "KTX (PVRTC)", + ext: "pvrtc", + src: "assets/compressedTextures/format_pvrtc1_4bpp_unorm.ktx", }, { name: "ktx-etc2", @@ -52,10 +56,10 @@ const textureAssets = [ src: "assets/compressedTextures/format_etc2_r8g8b8_srgb.ktx", }, { - name: "ktx2-bc1", - label: "KTX2 (BC1)", - ext: "s3tc", - src: "assets/compressedTextures/synthetic_bc1.ktx2", + name: "ktx-astc4", + label: "KTX (ASTC 4x4)", + ext: "astc", + src: "assets/compressedTextures/format_astc_4x4_srgb.ktx", }, { name: "pkm-etc1", @@ -70,33 +74,38 @@ const textureAssets = [ src: "assets/compressedTextures/synthetic_etc2.pkm", }, { - name: "pvr-4bpp", - label: "PVR (PVRTC)", - ext: "pvrtc", - src: "assets/compressedTextures/shannon-pvrtc-4bpp-rgba.pvr", + name: "ktx2-bc1", + label: "KTX2 (BC1)", + ext: "s3tc", + src: "assets/compressedTextures/synthetic_bc1.ktx2", }, ]; +/** + * A display renderable that shows compressed texture support info and loaded textures. + */ class CompressedTextureDisplay extends Renderable { + formats: ReturnType; + sprites: { sprite: Sprite; label: string; x: number; y: number }[] = []; titleFont: Text; font: Text; smallFont: Text; - formats: Record; - loadedAssets: typeof textureAssets; - sprites: { sprite: Sprite; label: string; x: number; y: number }[] = []; constructor( - formats: Record, - loadedAssets: typeof textureAssets, + app: Application, + formats: ReturnType, + loadedAssets: (typeof textureAssets)[number][], ) { - super(0, 0, game.viewport.width, game.viewport.height); + super(0, 0, app.viewport.width, app.viewport.height); + this.formats = formats; - this.loadedAssets = loadedAssets; this.anchorPoint.set(0, 0); + this.floating = true; + this.isPersistent = true; this.titleFont = new Text(0, 0, { font: "Arial", - size: "20px", + size: "24px", fillStyle: "#FFFFFF", }); this.font = new Text(0, 0, { @@ -114,7 +123,7 @@ class CompressedTextureDisplay extends Renderable { // create sprites for each loaded compressed texture const cols = Math.min(Math.max(loadedAssets.length, 1), 4); const spacing = 160; - const startX = game.viewport.width / 2 - ((cols - 1) * spacing) / 2; + const startX = app.viewport.width / 2 - ((cols - 1) * spacing) / 2; const startY = 200; for (let i = 0; i < loadedAssets.length; i++) { @@ -143,7 +152,7 @@ class CompressedTextureDisplay extends Renderable { /** @ignore */ drawText( - renderer: WebGLRenderer | CanvasRenderer, + renderer: WebGLRenderer, font: Text, text: string, x: number, @@ -156,7 +165,7 @@ class CompressedTextureDisplay extends Renderable { font.postDraw(renderer); } - override draw(renderer: WebGLRenderer | CanvasRenderer) { + override draw(renderer: WebGLRenderer) { let y = 10; const x = 10; @@ -182,7 +191,8 @@ class CompressedTextureDisplay extends Renderable { for (const [key, label] of extensions) { const supported = - this.formats[key] !== null && this.formats[key] !== undefined; + (this.formats as Record)[key] !== null && + (this.formats as Record)[key] !== undefined; this.font.fillStyle.parseCSS(supported ? "#4ade80" : "#f87171"); this.drawText( renderer, @@ -211,7 +221,7 @@ class CompressedTextureDisplay extends Renderable { } // Footer info - const footerY = game.viewport.height - 40; + const footerY = this.height - 40; this.font.fillStyle.parseCSS("#64748b"); this.drawText( renderer, @@ -224,18 +234,13 @@ class CompressedTextureDisplay extends Renderable { } const createGame = () => { - if ( - !video.init(800, 600, { - parent: "screen", - scaleMethod: "flex", - renderer: video.WEBGL, - }) - ) { - alert("Your browser does not support WebGL."); - return; - } + const app = new Application(800, 600, { + parent: "screen", + scaleMethod: "flex", + renderer: video.WEBGL, + }); - const renderer = video.renderer as WebGLRenderer; + const renderer = app.renderer as WebGLRenderer; const formats = renderer.getSupportedCompressedTextureFormats(); // Filter texture assets to only those whose extension is supported @@ -252,11 +257,9 @@ const createGame = () => { })); const showScene = () => { - state.change(state.DEFAULT, true); - game.world.reset(); - game.world.addChild(new ColorLayer("background", "#0f172a"), 0); - game.world.addChild( - new CompressedTextureDisplay(formats, supportedAssets), + app.world.addChild(new ColorLayer("background", "#0f172a"), 0); + app.world.addChild( + new CompressedTextureDisplay(app, formats, supportedAssets), 1, ); }; From 36e87d3edd73523be025407e8617b9bbc82c653b Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 16 Apr 2026 11:11:39 +0800 Subject: [PATCH 07/12] Change failIfMajorPerformanceCaveat default to false, tests use CANVAS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Application default now false — allows WebGL on machines with blocklisted GPU drivers, matching PixiJS and Phaser - Non-WebGL tests switched to video.CANVAS for deterministic behavior - WebGL-specific tests (webgl_save_restore, texture) keep video.AUTO with explicit failIfMajorPerformanceCaveat: true Co-Authored-By: Claude Opus 4.6 (1M context) --- .../melonjs/src/application/defaultApplicationSettings.ts | 2 +- packages/melonjs/tests/application.spec.js | 4 ++-- packages/melonjs/tests/camera.spec.js | 2 +- packages/melonjs/tests/emitter.spec.js | 2 +- packages/melonjs/tests/entity.spec.js | 2 +- packages/melonjs/tests/fillpolygon_mutation.spec.js | 4 ++-- packages/melonjs/tests/font.spec.js | 3 +-- packages/melonjs/tests/imagelayer.spec.js | 2 +- packages/melonjs/tests/input.spec.js | 2 +- packages/melonjs/tests/quadtree.spec.js | 2 +- packages/melonjs/tests/renderer.spec.js | 2 +- packages/melonjs/tests/renderer_save_restore.spec.js | 4 ++-- packages/melonjs/tests/sprite-trimming.spec.js | 2 +- packages/melonjs/tests/sprite.spec.js | 2 +- packages/melonjs/tests/state.spec.js | 2 +- packages/melonjs/tests/tmxobject.spec.js | 2 +- packages/melonjs/tests/tmxrenderer.spec.js | 2 +- packages/melonjs/tests/tmxtilemap.spec.js | 2 +- packages/melonjs/tests/tmxtileset.spec.js | 2 +- packages/melonjs/tests/ui.spec.js | 2 +- packages/melonjs/tests/world.spec.js | 2 +- 21 files changed, 24 insertions(+), 25 deletions(-) diff --git a/packages/melonjs/src/application/defaultApplicationSettings.ts b/packages/melonjs/src/application/defaultApplicationSettings.ts index d1b55b885..45a9c127b 100644 --- a/packages/melonjs/src/application/defaultApplicationSettings.ts +++ b/packages/melonjs/src/application/defaultApplicationSettings.ts @@ -13,7 +13,7 @@ export const defaultApplicationSettings = { consoleHeader: true, blendMode: "normal", physic: "builtin", - failIfMajorPerformanceCaveat: true, + failIfMajorPerformanceCaveat: false, subPixel: false, verbose: false, legacy: false, diff --git a/packages/melonjs/tests/application.spec.js b/packages/melonjs/tests/application.spec.js index 3d5cd1f20..afec2f8ff 100644 --- a/packages/melonjs/tests/application.spec.js +++ b/packages/melonjs/tests/application.spec.js @@ -24,7 +24,7 @@ describe("Application", () => { video.init(800, 600, { parent: "screen", scale: "auto", - renderer: video.AUTO, + renderer: video.CANVAS, }); }); @@ -245,7 +245,7 @@ describe("Application", () => { video.init(800, 600, { parent: "screen", scale: "auto", - renderer: video.AUTO, + renderer: video.CANVAS, }); }); diff --git a/packages/melonjs/tests/camera.spec.js b/packages/melonjs/tests/camera.spec.js index b309127a2..fc4ff92a2 100644 --- a/packages/melonjs/tests/camera.spec.js +++ b/packages/melonjs/tests/camera.spec.js @@ -13,7 +13,7 @@ const setup = () => { video.init(800, 600, { parent: "screen", scale: "auto", - renderer: video.AUTO, + renderer: video.CANVAS, }); // a camera instance diff --git a/packages/melonjs/tests/emitter.spec.js b/packages/melonjs/tests/emitter.spec.js index 9dced5965..529fa10b7 100644 --- a/packages/melonjs/tests/emitter.spec.js +++ b/packages/melonjs/tests/emitter.spec.js @@ -9,7 +9,7 @@ describe("ParticleEmitter", () => { video.init(800, 600, { parent: "screen", scale: "auto", - renderer: video.AUTO, + renderer: video.CANVAS, }); emitter = new ParticleEmitter(100, 100, { width: 16, diff --git a/packages/melonjs/tests/entity.spec.js b/packages/melonjs/tests/entity.spec.js index 9c3bb8182..3d476f7b8 100644 --- a/packages/melonjs/tests/entity.spec.js +++ b/packages/melonjs/tests/entity.spec.js @@ -10,7 +10,7 @@ describe("Entity", () => { video.init(800, 600, { parent: "screen", scale: "auto", - renderer: video.AUTO, + renderer: video.CANVAS, }); loader.setOptions({ crossOrigin: "anonymous" }); diff --git a/packages/melonjs/tests/fillpolygon_mutation.spec.js b/packages/melonjs/tests/fillpolygon_mutation.spec.js index 6bac7a7e2..df4cc03a7 100644 --- a/packages/melonjs/tests/fillpolygon_mutation.spec.js +++ b/packages/melonjs/tests/fillpolygon_mutation.spec.js @@ -26,7 +26,7 @@ describe("Drawing methods should not mutate input shapes", () => { video.init(800, 600, { parent: "screen", scale: "auto", - renderer: video.AUTO, + renderer: video.CANVAS, }); renderer = video.renderer; }); @@ -35,7 +35,7 @@ describe("Drawing methods should not mutate input shapes", () => { video.init(800, 600, { parent: "screen", scale: "auto", - renderer: video.AUTO, + renderer: video.CANVAS, }); }); diff --git a/packages/melonjs/tests/font.spec.js b/packages/melonjs/tests/font.spec.js index 583ccb7f2..ae6b07d79 100644 --- a/packages/melonjs/tests/font.spec.js +++ b/packages/melonjs/tests/font.spec.js @@ -18,8 +18,7 @@ describe("Font : Text", () => { video.init(800, 600, { parent: "screen", scale: "auto", - renderer: video.AUTO, - failIfMajorPerformanceCaveat: true, + renderer: video.CANVAS, }); font = new Text(0, 0, { diff --git a/packages/melonjs/tests/imagelayer.spec.js b/packages/melonjs/tests/imagelayer.spec.js index ef3af9225..ae6d4f92e 100644 --- a/packages/melonjs/tests/imagelayer.spec.js +++ b/packages/melonjs/tests/imagelayer.spec.js @@ -9,7 +9,7 @@ describe("ImageLayer", () => { video.init(800, 600, { parent: "screen", scale: "auto", - renderer: video.AUTO, + renderer: video.CANVAS, }); // create a small canvas to use as the image source diff --git a/packages/melonjs/tests/input.spec.js b/packages/melonjs/tests/input.spec.js index 5c14ee78b..66aeeb98f 100644 --- a/packages/melonjs/tests/input.spec.js +++ b/packages/melonjs/tests/input.spec.js @@ -7,7 +7,7 @@ describe("input", () => { video.init(800, 600, { parent: "screen", scale: "auto", - renderer: video.AUTO, + renderer: video.CANVAS, }); }); diff --git a/packages/melonjs/tests/quadtree.spec.js b/packages/melonjs/tests/quadtree.spec.js index 433eb13cf..f82a0a632 100644 --- a/packages/melonjs/tests/quadtree.spec.js +++ b/packages/melonjs/tests/quadtree.spec.js @@ -20,7 +20,7 @@ describe("QuadTree & Collision Detection", () => { video.init(800, 600, { parent: "screen", scale: "auto", - renderer: video.AUTO, + renderer: video.CANVAS, }); }); diff --git a/packages/melonjs/tests/renderer.spec.js b/packages/melonjs/tests/renderer.spec.js index a90eccbd0..49e05ab32 100644 --- a/packages/melonjs/tests/renderer.spec.js +++ b/packages/melonjs/tests/renderer.spec.js @@ -27,7 +27,7 @@ describe("setAntiAlias", () => { video.init(800, 600, { parent: "screen", scale: "auto", - renderer: video.AUTO, + renderer: video.CANVAS, }); }); diff --git a/packages/melonjs/tests/renderer_save_restore.spec.js b/packages/melonjs/tests/renderer_save_restore.spec.js index 953c81b51..6485fdce1 100644 --- a/packages/melonjs/tests/renderer_save_restore.spec.js +++ b/packages/melonjs/tests/renderer_save_restore.spec.js @@ -14,7 +14,7 @@ describe("Renderer save/restore", () => { video.init(800, 600, { parent: "screen", scale: "auto", - renderer: video.AUTO, + renderer: video.CANVAS, }); renderer = video.renderer; }); @@ -30,7 +30,7 @@ describe("Renderer save/restore", () => { video.init(800, 600, { parent: "screen", scale: "auto", - renderer: video.AUTO, + renderer: video.CANVAS, }); }); diff --git a/packages/melonjs/tests/sprite-trimming.spec.js b/packages/melonjs/tests/sprite-trimming.spec.js index dad5c515c..b1b6262ef 100644 --- a/packages/melonjs/tests/sprite-trimming.spec.js +++ b/packages/melonjs/tests/sprite-trimming.spec.js @@ -9,7 +9,7 @@ describe("Sprite trimming and Entity anchor sync", () => { video.init(800, 600, { parent: "screen", scale: "auto", - renderer: video.AUTO, + renderer: video.CANVAS, }); // create a mock image for sprite creation mockImage = video.createCanvas(512, 512); diff --git a/packages/melonjs/tests/sprite.spec.js b/packages/melonjs/tests/sprite.spec.js index 21cb2032d..17fba052d 100644 --- a/packages/melonjs/tests/sprite.spec.js +++ b/packages/melonjs/tests/sprite.spec.js @@ -10,7 +10,7 @@ describe("Sprite", () => { video.init(800, 600, { parent: "screen", scale: "auto", - renderer: video.AUTO, + renderer: video.CANVAS, }); container = new Container(50, 50, 150, 150); diff --git a/packages/melonjs/tests/state.spec.js b/packages/melonjs/tests/state.spec.js index b3a36dcd3..9009b7818 100644 --- a/packages/melonjs/tests/state.spec.js +++ b/packages/melonjs/tests/state.spec.js @@ -7,7 +7,7 @@ describe("state", () => { video.init(800, 600, { parent: "screen", scale: "auto", - renderer: video.AUTO, + renderer: video.CANVAS, }); }); diff --git a/packages/melonjs/tests/tmxobject.spec.js b/packages/melonjs/tests/tmxobject.spec.js index 4dfdae27e..e2010edeb 100644 --- a/packages/melonjs/tests/tmxobject.spec.js +++ b/packages/melonjs/tests/tmxobject.spec.js @@ -21,7 +21,7 @@ describe("TMXObject", () => { video.init(128, 128, { parent: "screen", scale: "auto", - renderer: video.AUTO, + renderer: video.CANVAS, }); }); diff --git a/packages/melonjs/tests/tmxrenderer.spec.js b/packages/melonjs/tests/tmxrenderer.spec.js index d769720ba..310ec8274 100644 --- a/packages/melonjs/tests/tmxrenderer.spec.js +++ b/packages/melonjs/tests/tmxrenderer.spec.js @@ -24,7 +24,7 @@ describe("TMX Renderers", () => { video.init(128, 128, { parent: "screen", scale: "auto", - renderer: video.AUTO, + renderer: video.CANVAS, }); fakeImage("drawtest", 256, 256); }); diff --git a/packages/melonjs/tests/tmxtilemap.spec.js b/packages/melonjs/tests/tmxtilemap.spec.js index 5021f65c8..e0d54b533 100644 --- a/packages/melonjs/tests/tmxtilemap.spec.js +++ b/packages/melonjs/tests/tmxtilemap.spec.js @@ -676,7 +676,7 @@ describe("TMXTileMap", () => { video.init(128, 128, { parent: "screen", scale: "auto", - renderer: video.AUTO, + renderer: video.CANVAS, }); // pre-register fake images for tileset tests fakeImage("testtiles", 64, 64); diff --git a/packages/melonjs/tests/tmxtileset.spec.js b/packages/melonjs/tests/tmxtileset.spec.js index 5623af304..5c6346f7a 100644 --- a/packages/melonjs/tests/tmxtileset.spec.js +++ b/packages/melonjs/tests/tmxtileset.spec.js @@ -19,7 +19,7 @@ describe("TMXTileset", () => { video.init(128, 128, { parent: "screen", scale: "auto", - renderer: video.AUTO, + renderer: video.CANVAS, }); // pre-register fake images of various sizes diff --git a/packages/melonjs/tests/ui.spec.js b/packages/melonjs/tests/ui.spec.js index ec1df6f15..c6991727f 100644 --- a/packages/melonjs/tests/ui.spec.js +++ b/packages/melonjs/tests/ui.spec.js @@ -14,7 +14,7 @@ describe("UI", () => { video.init(800, 600, { parent: "screen", scale: "auto", - renderer: video.AUTO, + renderer: video.CANVAS, }); }); diff --git a/packages/melonjs/tests/world.spec.js b/packages/melonjs/tests/world.spec.js index b253d934e..786ca92f5 100644 --- a/packages/melonjs/tests/world.spec.js +++ b/packages/melonjs/tests/world.spec.js @@ -9,7 +9,7 @@ describe("Physics : World", () => { video.init(800, 600, { parent: "screen", scale: "auto", - renderer: video.AUTO, + renderer: video.CANVAS, }); }); From aa4e87e470b5a8d3dc1a2ba001379a6740ea3ef6 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 16 Apr 2026 11:15:35 +0800 Subject: [PATCH 08/12] Address Copilot review: push deopt, cache encapsulation, scissor tracking - push(): use vertexSize > 5 instead of arguments.length (avoids V8 deopt), always write default textureId 0 when vertexSize is 6 - TextureCache.resetUnitAssignments(): encapsulate unit reset logic - clipRect: use _scissorActive instead of gl.isEnabled() query - Update vertex buffer tests for new push behavior Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/melonjs/src/application/settings.ts | 37 +++++++++++++++++-- .../video/rendertarget/canvasrendertarget.js | 4 +- packages/melonjs/src/video/texture/cache.js | 10 +++++ .../src/video/webgl/batchers/quad_batcher.js | 3 +- .../melonjs/src/video/webgl/buffer/vertex.js | 4 +- .../melonjs/src/video/webgl/webgl_renderer.js | 2 +- packages/melonjs/tests/vertexBuffer.spec.js | 15 +++++++- 7 files changed, 62 insertions(+), 13 deletions(-) diff --git a/packages/melonjs/src/application/settings.ts b/packages/melonjs/src/application/settings.ts index a0ff4662c..77692fde4 100644 --- a/packages/melonjs/src/application/settings.ts +++ b/packages/melonjs/src/application/settings.ts @@ -29,7 +29,7 @@ export type ApplicationSettings = { /** * screen scaling modes - * @default fit + * @default "manual" */ scaleMethod: ScaleMethod; @@ -67,6 +67,11 @@ export type ApplicationSettings = { * @default true */ consoleHeader: boolean; + + /** + * the default blend mode to use ("normal", "multiply", "lighter", "additive", "screen") + * @default "normal" + */ blendMode: BlendMode; /** @@ -74,9 +79,30 @@ export type ApplicationSettings = { * @default "builtin" */ physic: PhysicsType; + /** + * if true, the renderer will fail if the browser reports a major performance caveat + * (e.g. software WebGL). Set to false to allow WebGL on machines with + * blocklisted GPU drivers or software renderers. + * @default false + */ failIfMajorPerformanceCaveat: boolean; + + /** + * whether to enable sub-pixel rendering (avoid sprite flickering when using transforms) + * @default false + */ subPixel: boolean; + + /** + * whether to enable verbose mode (additional console output for debugging) + * @default false + */ verbose: boolean; + + /** + * whether to enable legacy mode (enables deprecated `video.init()` entry point) + * @default false + */ legacy: boolean; /** @@ -99,13 +125,18 @@ export type ApplicationSettings = { batcher?: (new (renderer: any) => Batcher) | undefined; } & ( | { - // the DOM parent element (or its string ID) to hold the canvas in the HTML file + /** + * the DOM parent element (or its string ID) to hold the canvas in the HTML file + */ parent: string | HTMLElement; canvas?: never; } | { parent?: never; - // an existing canvas element to use as the renderer target (by default melonJS will create its own canvas based on given parameters) + /** + * an existing canvas element to use as the renderer target + * (by default melonJS will create its own canvas based on given parameters) + */ canvas: HTMLCanvasElement; } ); diff --git a/packages/melonjs/src/video/rendertarget/canvasrendertarget.js b/packages/melonjs/src/video/rendertarget/canvasrendertarget.js index c77df7472..9958f221f 100644 --- a/packages/melonjs/src/video/rendertarget/canvasrendertarget.js +++ b/packages/melonjs/src/video/rendertarget/canvasrendertarget.js @@ -275,10 +275,8 @@ class CanvasRenderTarget { */ invalidate(renderer) { if (typeof renderer.gl !== "undefined") { - // flush pending draws that reference the current texture data - // before invalidating (required for multi-texture batching) + // flush pending draws referencing the current texture data renderer.flush(); - // make sure the right batcher is active renderer.setBatcher("quad"); // invalidate the previous corresponding texture so that it can reuploaded once changed this.glTextureUnit = renderer.cache.getUnit( diff --git a/packages/melonjs/src/video/texture/cache.js b/packages/melonjs/src/video/texture/cache.js index bf031d614..b6b001a53 100644 --- a/packages/melonjs/src/video/texture/cache.js +++ b/packages/melonjs/src/video/texture/cache.js @@ -61,6 +61,16 @@ class TextureCache { return 0; } + /** + * Reset all texture unit assignments without clearing the texture cache. + * Used by multi-texture batching when the shader's sampler range is exceeded. + * @ignore + */ + resetUnitAssignments() { + this.units.clear(); + this.usedUnits.clear(); + } + /** * @ignore */ diff --git a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js index 666f49843..7ccd05029 100644 --- a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js @@ -200,8 +200,7 @@ export default class QuadBatcher extends MaterialBatcher { // reset if the cache assigned a unit beyond the shader's range if (unit >= this.maxBatchTextures) { this.flush(); - this.renderer.cache.units.clear(); - this.renderer.cache.usedUnits.clear(); + this.renderer.cache.resetUnitAssignments(); unit = this.uploadTexture(texture, w, h, reupload, false); } } else { diff --git a/packages/melonjs/src/video/webgl/buffer/vertex.js b/packages/melonjs/src/video/webgl/buffer/vertex.js index d3cbe0025..16597a462 100644 --- a/packages/melonjs/src/video/webgl/buffer/vertex.js +++ b/packages/melonjs/src/video/webgl/buffer/vertex.js @@ -56,8 +56,8 @@ export default class VertexArrayBuffer { this.bufferF32[offset + 2] = u; this.bufferF32[offset + 3] = v; this.bufferU32[offset + 4] = tint; - if (arguments.length > 5) { - this.bufferF32[offset + 5] = textureId; + if (this.vertexSize > 5) { + this.bufferF32[offset + 5] = textureId || 0; } this.vertexCount++; diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index b655b75ed..0ebd56625 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -1701,7 +1701,7 @@ export default class WebGLRenderer extends Renderer { height !== canvas.height ) { const currentScissor = this.currentScissor; - if (gl.isEnabled(gl.SCISSOR_TEST)) { + if (this._scissorActive) { // if same as the current scissor box do nothing if ( currentScissor[0] === x && diff --git a/packages/melonjs/tests/vertexBuffer.spec.js b/packages/melonjs/tests/vertexBuffer.spec.js index 58dac6b66..a4aa6e2be 100644 --- a/packages/melonjs/tests/vertexBuffer.spec.js +++ b/packages/melonjs/tests/vertexBuffer.spec.js @@ -33,13 +33,24 @@ describe("VertexArrayBuffer", () => { expect(buf.bufferF32[5]).toBe(3); // textureId }); - it("should not write textureId when not provided", () => { + it("should write default textureId 0 when not provided (vertexSize 6)", () => { const buf = new VertexArrayBuffer(6, 4); buf.push(10, 20, 0.0, 1.0, 0xffffffff); expect(buf.vertexCount).toBe(1); - expect(buf.bufferF32[5]).toBe(0); // untouched (default zero) + expect(buf.bufferF32[5]).toBe(0); // default 0 + }); + + it("should not write textureId when vertexSize is 5", () => { + const buf = new VertexArrayBuffer(5, 4); + + // write a sentinel at offset 5 + buf.bufferF32[5] = 99; + buf.push(10, 20, 0.0, 1.0, 0xffffffff); + + expect(buf.vertexCount).toBe(1); + expect(buf.bufferF32[5]).toBe(99); // untouched }); it("should write multiple vertices sequentially", () => { From 01cc6f29363bd77c06f5259100490c0534910551 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 16 Apr 2026 11:22:53 +0800 Subject: [PATCH 09/12] Fix resetUnitAssignments stale batcher state, update CHANGELOG - resetUnitAssignments() now clears batcher's boundTextures and currentTextureUnit to prevent stale GL textures after reset - CHANGELOG reflects actual failIfMajorPerformanceCaveat default (false) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/melonjs/CHANGELOG.md | 2 +- packages/melonjs/src/video/texture/cache.js | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 8bdd40a33..6a03d4c12 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -6,7 +6,7 @@ - WebGL: multi-texture batching — up to 16 textures (based on device capabilities) drawn in a single batch/draw call, eliminating GPU flushes on texture changes. Automatically falls back to single-texture mode when a custom `ShaderEffect` is active. ~80% fewer draw calls on the platformer example (14 vs ~70 flushes/frame), with an estimated 30-50% FPS improvement on low-end mobile devices. ### Changed -- WebGL: `CanvasRenderTarget` internal default for `failIfMajorPerformanceCaveat` changed from `true` to `false` — allows WebGL context creation on machines with blocklisted GPU drivers when creating render targets directly. Application default remains `true`; set `failIfMajorPerformanceCaveat: false` in Application options to opt in. +- WebGL: `failIfMajorPerformanceCaveat` default changed from `true` to `false` — allows WebGL context creation on machines with blocklisted GPU drivers or software renderers. Set to `true` in Application options to restore the previous stricter behavior. ### Fixed - WebGL: `getSupportedCompressedTextureFormats()` no longer crashes when the GL context is unavailable — falls back to the base renderer's empty format list diff --git a/packages/melonjs/src/video/texture/cache.js b/packages/melonjs/src/video/texture/cache.js index b6b001a53..ec1fbdf24 100644 --- a/packages/melonjs/src/video/texture/cache.js +++ b/packages/melonjs/src/video/texture/cache.js @@ -67,6 +67,10 @@ class TextureCache { * @ignore */ resetUnitAssignments() { + if (this.renderer.currentBatcher) { + this.renderer.currentBatcher.boundTextures.length = 0; + this.renderer.currentBatcher.currentTextureUnit = -1; + } this.units.clear(); this.usedUnits.clear(); } From d333fc63c9b0bc510ad644df96c9169cf8a9513d Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 16 Apr 2026 11:26:31 +0800 Subject: [PATCH 10/12] Revert failIfMajorPerformanceCaveat to true everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canvas performs better than software WebGL — keep true as the default so AUTO falls back to Canvas on blocklisted GPUs. The gl guard in getSupportedCompressedTextureFormats handles the crash case. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/melonjs/CHANGELOG.md | 3 --- packages/melonjs/src/application/defaultApplicationSettings.ts | 2 +- packages/melonjs/src/application/settings.ts | 2 +- packages/melonjs/src/video/rendertarget/canvasrendertarget.js | 2 +- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 6a03d4c12..3e43ac2df 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -5,9 +5,6 @@ ### Added - WebGL: multi-texture batching — up to 16 textures (based on device capabilities) drawn in a single batch/draw call, eliminating GPU flushes on texture changes. Automatically falls back to single-texture mode when a custom `ShaderEffect` is active. ~80% fewer draw calls on the platformer example (14 vs ~70 flushes/frame), with an estimated 30-50% FPS improvement on low-end mobile devices. -### Changed -- WebGL: `failIfMajorPerformanceCaveat` default changed from `true` to `false` — allows WebGL context creation on machines with blocklisted GPU drivers or software renderers. Set to `true` in Application options to restore the previous stricter behavior. - ### Fixed - WebGL: `getSupportedCompressedTextureFormats()` no longer crashes when the GL context is unavailable — falls back to the base renderer's empty format list - Examples: compressed textures example updated to use `setText()`/`preDraw()`/`draw()`/`postDraw()` pattern — fixes text not rendering after `Text.draw()` standalone removal in 19.0 diff --git a/packages/melonjs/src/application/defaultApplicationSettings.ts b/packages/melonjs/src/application/defaultApplicationSettings.ts index 45a9c127b..d1b55b885 100644 --- a/packages/melonjs/src/application/defaultApplicationSettings.ts +++ b/packages/melonjs/src/application/defaultApplicationSettings.ts @@ -13,7 +13,7 @@ export const defaultApplicationSettings = { consoleHeader: true, blendMode: "normal", physic: "builtin", - failIfMajorPerformanceCaveat: false, + failIfMajorPerformanceCaveat: true, subPixel: false, verbose: false, legacy: false, diff --git a/packages/melonjs/src/application/settings.ts b/packages/melonjs/src/application/settings.ts index 77692fde4..046b6956f 100644 --- a/packages/melonjs/src/application/settings.ts +++ b/packages/melonjs/src/application/settings.ts @@ -83,7 +83,7 @@ export type ApplicationSettings = { * if true, the renderer will fail if the browser reports a major performance caveat * (e.g. software WebGL). Set to false to allow WebGL on machines with * blocklisted GPU drivers or software renderers. - * @default false + * @default true */ failIfMajorPerformanceCaveat: boolean; diff --git a/packages/melonjs/src/video/rendertarget/canvasrendertarget.js b/packages/melonjs/src/video/rendertarget/canvasrendertarget.js index 9958f221f..219666307 100644 --- a/packages/melonjs/src/video/rendertarget/canvasrendertarget.js +++ b/packages/melonjs/src/video/rendertarget/canvasrendertarget.js @@ -18,7 +18,7 @@ const defaultAttributes = { premultipliedAlpha: true, stencil: true, blendMode: "normal", - failIfMajorPerformanceCaveat: false, + failIfMajorPerformanceCaveat: true, preferWebGL1: false, powerPreference: "default", }; From 26c594e0ee05e8cdea545a60a9e2f3b91e271a99 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 16 Apr 2026 11:31:31 +0800 Subject: [PATCH 11/12] Fix UI example: replace standalone Text.draw() with preDraw/draw/postDraw Text.draw(renderer, text, x, y) standalone pattern was removed in 19.0. Use pos.set + setText + preDraw/draw/postDraw instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../examples/src/examples/ui/ExampleUI.tsx | 104 ++++++++---------- 1 file changed, 44 insertions(+), 60 deletions(-) diff --git a/packages/examples/src/examples/ui/ExampleUI.tsx b/packages/examples/src/examples/ui/ExampleUI.tsx index af1dfb6ec..5da124cac 100644 --- a/packages/examples/src/examples/ui/ExampleUI.tsx +++ b/packages/examples/src/examples/ui/ExampleUI.tsx @@ -9,7 +9,6 @@ import { TextureAtlas, UIBaseElement, UISpriteElement, - video, } from "melonjs"; import { createExampleComponent } from "../utils"; @@ -20,10 +19,9 @@ let texture: TextureAtlas; class ButtonUI extends UISpriteElement { private unclicked_region: object; private clicked_region: object; - private font: Text; - private label: string; + label: Text; - constructor(x: number, y: number, color: string, label: string) { + constructor(x: number, y: number, color: string, labelText: string) { super(x, y, { image: texture, region: `${color}_button04`, @@ -33,15 +31,16 @@ class ButtonUI extends UISpriteElement { this.clicked_region = texture.getRegion(`${color}_button05`); this.anchorPoint.set(0, 0); this.setOpacity(0.5); - this.label = label; this.floating = false; - this.font = new Text(0, 0, { + // create label as a sibling — added to the parent by the caller + this.label = new Text(x + this.width / 2, y + this.height / 2, { font: "kenpixel", size: 12, fillStyle: "black", textAlign: "center", textBaseline: "middle", + text: labelText, }); } @@ -70,29 +69,15 @@ class ButtonUI extends UISpriteElement { ); return false; } - - override draw(renderer: Parameters[0]) { - super.draw(renderer); - this.font.draw( - renderer, - this.label, - this.pos.x + this.width / 2, - this.pos.y + this.height / 2, - ); - } - - override onDestroyEvent() { - this.font.destroy(); - } } class CheckBoxUI extends UISpriteElement { private on_icon_region: object; private off_icon_region: object; - private font: Text; private isSelected: boolean; private label_on: string; private label_off: string; + label: Text; constructor( x: number, @@ -116,16 +101,15 @@ class CheckBoxUI extends UISpriteElement { this.label_off = offLabel; this.floating = false; - this.font = new Text(0, 0, { + // create label as a sibling — added to the parent by the caller + this.label = new Text(x + this.width, y + this.height / 2, { font: "kenpixel", size: 12, fillStyle: "black", textAlign: "left", textBaseline: "middle", - text: offLabel, + text: onLabel, }); - - this.getBounds().width += this.font.measureText().width; } override onOver() { @@ -140,9 +124,11 @@ class CheckBoxUI extends UISpriteElement { if (selected) { this.setRegion(this.on_icon_region); this.isSelected = true; + this.label.setText(this.label_on); } else { this.setRegion(this.off_icon_region); this.isSelected = false; + this.label.setText(this.label_off); } } @@ -150,16 +136,6 @@ class CheckBoxUI extends UISpriteElement { this.setSelected(!this.isSelected); return false; } - - override draw(renderer: Parameters[0]) { - super.draw(renderer); - this.font.draw( - renderer, - ` ${this.isSelected ? this.label_on : this.label_off}`, - this.pos.x + this.width, - this.pos.y + this.height / 2, - ); - } } class UIContainer extends UIBaseElement { @@ -192,7 +168,6 @@ class UIContainer extends UIBaseElement { fillStyle: "black", textAlign: "center", textBaseline: "top", - bold: true, text: label, }), ); @@ -213,41 +188,50 @@ class PlayScreen extends Stage { const cbPanel = new UIBaseElement(125, 75, 100, 100); - cbPanel.addChild( - new CheckBoxUI( - 0, - 0, - texture, - "green_boxCheckmark", - "grey_boxCheckmark", - "Music ON", - "Music OFF", - ), + const cb1 = new CheckBoxUI( + 0, + 0, + texture, + "green_boxCheckmark", + "grey_boxCheckmark", + "Music ON", + "Music OFF", ); - cbPanel.addChild( - new CheckBoxUI( - 0, - 50, - texture, - "green_boxCheckmark", - "grey_boxCheckmark", - "Sound FX ON", - "Sound FX OFF", - ), + cbPanel.addChild(cb1); + cbPanel.addChild(cb1.label); + + const cb2 = new CheckBoxUI( + 0, + 50, + texture, + "green_boxCheckmark", + "grey_boxCheckmark", + "Sound FX ON", + "Sound FX OFF", ); + cbPanel.addChild(cb2); + cbPanel.addChild(cb2.label); panel.addChild(cbPanel); - panel.addChild(new ButtonUI(125, 175, "blue", "Video Options")); - panel.addChild(new ButtonUI(30, 250, "green", "Accept")); - panel.addChild(new ButtonUI(230, 250, "yellow", "Cancel")); + const btn1 = new ButtonUI(125, 175, "blue", "Video Options"); + panel.addChild(btn1); + panel.addChild(btn1.label); + + const btn2 = new ButtonUI(30, 250, "green", "Accept"); + panel.addChild(btn2); + panel.addChild(btn2.label); + + const btn3 = new ButtonUI(230, 250, "yellow", "Cancel"); + panel.addChild(btn3); + panel.addChild(btn3.label); app.world.addChild(panel, 1); } } const createGame = () => { - const app = new App(800, 600, { + new App(800, 600, { parent: "screen", scale: "auto", scaleMethod: "flex-width", From 246e0139abbde140efc768b13dda76c9fb65fb4f Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Thu, 16 Apr 2026 11:37:25 +0800 Subject: [PATCH 12/12] Guard buildMultiTextureFragment against maxTextures < 1 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/melonjs/src/video/webgl/shaders/multitexture.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/melonjs/src/video/webgl/shaders/multitexture.js b/packages/melonjs/src/video/webgl/shaders/multitexture.js index 4a7c78088..81dd9e95e 100644 --- a/packages/melonjs/src/video/webgl/shaders/multitexture.js +++ b/packages/melonjs/src/video/webgl/shaders/multitexture.js @@ -7,10 +7,11 @@ * @ignore */ export function buildMultiTextureFragment(maxTextures) { + const count = Math.max(maxTextures, 1); const lines = []; // declare sampler uniforms - for (let i = 0; i < maxTextures; i++) { + for (let i = 0; i < count; i++) { lines.push("uniform sampler2D uSampler" + i + ";"); } @@ -22,7 +23,7 @@ export function buildMultiTextureFragment(maxTextures) { lines.push(" vec4 color;"); // generate if/else chain using < N.5 thresholds - for (let i = 0; i < maxTextures; i++) { + for (let i = 0; i < count; i++) { if (i === 0) { lines.push(" if (vTextureId < 0.5) {"); } else {