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/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, ); }; 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", diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index e24ac8e60..3e43ac2df 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -2,8 +2,8 @@ ## [19.1.0] (melonJS 2) - _2026-04-15_ -### 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. +### 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. ### 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/application/settings.ts b/packages/melonjs/src/application/settings.ts index a0ff4662c..046b6956f 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 true + */ 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/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/rendertarget/canvasrendertarget.js b/packages/melonjs/src/video/rendertarget/canvasrendertarget.js index 6b4644fe2..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", }; @@ -275,7 +275,8 @@ class CanvasRenderTarget { */ invalidate(renderer) { if (typeof renderer.gl !== "undefined") { - // make sure the right batcher is active + // flush pending draws referencing the current texture data + renderer.flush(); 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..ec1fbdf24 100644 --- a/packages/melonjs/src/video/texture/cache.js +++ b/packages/melonjs/src/video/texture/cache.js @@ -61,6 +61,20 @@ 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() { + if (this.renderer.currentBatcher) { + this.renderer.currentBatcher.boundTextures.length = 0; + this.renderer.currentBatcher.currentTextureUnit = -1; + } + this.units.clear(); + this.usedUnits.clear(); + } + /** * @ignore */ diff --git a/packages/melonjs/src/video/webgl/batchers/material_batcher.js b/packages/melonjs/src/video/webgl/batchers/material_batcher.js index d99dd7370..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); @@ -216,11 +217,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 +231,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 +261,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]; @@ -271,11 +276,12 @@ export class MaterialBatcher extends Batcher { texture.premultipliedAlpha, undefined, texture2D, + flush, ); } 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..7ccd05029 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,27 @@ 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); + // 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.resetUnitAssignments(); + 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 +228,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..16597a462 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 (this.vertexSize > 5) { + this.bufferF32[offset + 5] = textureId || 0; + } 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..81dd9e95e --- /dev/null +++ b/packages/melonjs/src/video/webgl/shaders/multitexture.js @@ -0,0 +1,43 @@ +/** + * 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 count = Math.max(maxTextures, 1); + const lines = []; + + // declare sampler uniforms + for (let i = 0; i < count; 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 < count; 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..0ebd56625 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); } /** @@ -1375,6 +1379,7 @@ 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); this.drawImage(canvas, 0, 0, width, height, x, y, width, height); return; @@ -1696,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 && @@ -1711,6 +1716,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 +1733,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/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/vertexBuffer.spec.js b/packages/melonjs/tests/vertexBuffer.spec.js index 1f7d7969d..a4aa6e2be 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,41 @@ 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 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); // 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", () => { const buf = new VertexArrayBuffer(5, 4); @@ -31,6 +67,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()", () => { 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, }); });