Skip to content

Commit f151ef4

Browse files
obiotclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 6efdaa7 commit f151ef4

9 files changed

Lines changed: 250 additions & 24 deletions

File tree

packages/examples/src/examples/benchmark/ExampleBenchmark.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
loader,
88
plugin,
99
ScaleMethods,
10+
Text,
1011
video,
1112
} from "melonjs";
1213
import { createExampleComponent } from "../utils";
@@ -65,6 +66,18 @@ const createGame = () => {
6566
// reset/empty the game world
6667
game.world.reset();
6768

69+
// add hint text
70+
const hint = new Text(game.viewport.width / 2, 20, {
71+
font: "Arial",
72+
size: "16px",
73+
fillStyle: "#ffffff",
74+
textAlign: "center",
75+
text: "Tap or Click to spawn more sprites",
76+
});
77+
hint.floating = true;
78+
hint.setOpacity(0.6);
79+
game.world.addChild(hint, Infinity);
80+
6881
addFruits(FRUIT_STEP, "watermelon");
6982
});
7083
};

packages/melonjs/CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
## [19.1.0] (melonJS 2) - _2026-04-15_
44

5+
### Added
6+
- 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.
7+
58
### Changed
6-
- WebGL: `failIfMajorPerformanceCaveat` default changed from `true` to `false` — allows WebGL context creation on machines with blocklisted GPU drivers or software renderers, matching PixiJS and Phaser behavior
9+
- WebGL: `failIfMajorPerformanceCaveat` default changed from `true` to `false` — allows WebGL context creation on machines with blocklisted GPU drivers or software renderers.
710

811
### Fixed
912
- WebGL: `getSupportedCompressedTextureFormats()` no longer crashes when the GL context is unavailable — falls back to the base renderer's empty format list

packages/melonjs/src/video/webgl/batchers/material_batcher.js

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -216,19 +216,23 @@ export class MaterialBatcher extends Batcher {
216216
* @param {WebGLTexture} texture - a WebGL texture
217217
* @param {number} unit - Texture unit to which the given texture is bound
218218
*/
219-
bindTexture2D(texture, unit) {
219+
bindTexture2D(texture, unit, flush = true) {
220220
const gl = this.gl;
221221

222222
if (texture !== this.boundTextures[unit]) {
223-
this.flush();
223+
if (flush) {
224+
this.flush();
225+
}
224226
if (this.currentTextureUnit !== unit) {
225227
this.currentTextureUnit = unit;
226228
gl.activeTexture(gl.TEXTURE0 + unit);
227229
}
228230
gl.bindTexture(gl.TEXTURE_2D, texture);
229231
this.boundTextures[unit] = texture;
230232
} else if (this.currentTextureUnit !== unit) {
231-
this.flush();
233+
if (flush) {
234+
this.flush();
235+
}
232236
this.currentTextureUnit = unit;
233237
gl.activeTexture(gl.TEXTURE0 + unit);
234238
}
@@ -256,7 +260,7 @@ export class MaterialBatcher extends Batcher {
256260
/**
257261
* @ignore
258262
*/
259-
uploadTexture(texture, w, h, force = false) {
263+
uploadTexture(texture, w, h, force = false, flush = true) {
260264
const unit = this.renderer.cache.getUnit(texture);
261265
const texture2D = this.boundTextures[unit];
262266

@@ -273,9 +277,9 @@ export class MaterialBatcher extends Batcher {
273277
texture2D,
274278
);
275279
} else {
276-
this.bindTexture2D(texture2D, unit);
280+
this.bindTexture2D(texture2D, unit, flush);
277281
}
278282

279-
return this.currentTextureUnit;
283+
return flush ? this.currentTextureUnit : unit;
280284
}
281285
}

packages/melonjs/src/video/webgl/batchers/quad_batcher.js

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Vector2d } from "../../../math/vector2d.ts";
22
import IndexBuffer from "../buffer/index.js";
3-
import quadFragment from "./../shaders/quad.frag";
4-
import quadVertex from "./../shaders/quad.vert";
3+
import { buildMultiTextureFragment } from "./../shaders/multitexture.js";
4+
import quadMultiVertex from "./../shaders/quad-multi.vert";
55
import { MaterialBatcher } from "./material_batcher.js";
66

77
/**
@@ -28,6 +28,13 @@ export default class QuadBatcher extends MaterialBatcher {
2828
* @ignore
2929
*/
3030
init(renderer) {
31+
/**
32+
* the maximum number of texture units used for multi-texture batching
33+
* @type {number}
34+
* @ignore
35+
*/
36+
this.maxBatchTextures = Math.min(renderer.maxTextures, 16);
37+
3138
super.init(renderer, {
3239
attributes: [
3340
{
@@ -51,13 +58,33 @@ export default class QuadBatcher extends MaterialBatcher {
5158
normalized: true,
5259
offset: 4 * Float32Array.BYTES_PER_ELEMENT,
5360
},
61+
{
62+
name: "aTextureId",
63+
size: 1,
64+
type: renderer.gl.FLOAT,
65+
normalized: false,
66+
offset: 5 * Float32Array.BYTES_PER_ELEMENT,
67+
},
5468
],
5569
shader: {
56-
vertex: quadVertex,
57-
fragment: quadFragment,
70+
vertex: quadMultiVertex,
71+
fragment: buildMultiTextureFragment(this.maxBatchTextures),
5872
},
5973
});
6074

75+
// bind all sampler uniforms to their respective texture units
76+
for (let i = 0; i < this.maxBatchTextures; i++) {
77+
this.defaultShader.setUniform("uSampler" + i, i);
78+
}
79+
80+
/**
81+
* whether multi-texture batching is currently active
82+
* (disabled when a custom ShaderEffect is applied)
83+
* @type {boolean}
84+
* @ignore
85+
*/
86+
this.useMultiTexture = true;
87+
6188
// create the index buffer for quad batching (4 verts + 6 indices per quad)
6289
const maxQuads = this.vertexData.maxVertex / 4;
6390
this.indexBuffer = new IndexBuffer(
@@ -68,6 +95,18 @@ export default class QuadBatcher extends MaterialBatcher {
6895
this.indexBuffer.fillQuadPattern(maxQuads);
6996
}
7097

98+
/**
99+
* Select the shader to use for compositing.
100+
* Multi-texture batching is automatically enabled when the default
101+
* shader is active, and disabled for custom ShaderEffect shaders.
102+
* @see GLShader
103+
* @param {GLShader} shader - a reference to a GLShader instance
104+
*/
105+
useShader(shader) {
106+
super.useShader(shader);
107+
this.useMultiTexture = shader === this.defaultShader;
108+
}
109+
71110
/**
72111
* Reset compositor internal state
73112
* @ignore
@@ -83,6 +122,12 @@ export default class QuadBatcher extends MaterialBatcher {
83122
this.renderer.WebGLVersion > 1,
84123
);
85124
this.indexBuffer.fillQuadPattern(maxQuads);
125+
126+
// re-bind sampler uniforms after context restore
127+
for (let i = 0; i < this.maxBatchTextures; i++) {
128+
this.defaultShader.setUniform("uSampler" + i, i);
129+
}
130+
this.useMultiTexture = true;
86131
}
87132

88133
/**
@@ -145,11 +190,20 @@ export default class QuadBatcher extends MaterialBatcher {
145190
this.flush();
146191
}
147192

148-
const unit = this.uploadTexture(texture, w, h, reupload);
149-
150-
if (unit !== this.currentSamplerUnit) {
151-
this.currentShader.setUniform("uSampler", unit);
152-
this.currentSamplerUnit = unit;
193+
let unit;
194+
195+
if (this.useMultiTexture) {
196+
// multi-texture path: embed the texture unit in the vertex data
197+
// and avoid flushing on texture changes
198+
unit = this.uploadTexture(texture, w, h, reupload, false);
199+
} else {
200+
// single-texture fallback (custom ShaderEffect active):
201+
// use regular upload which flushes on texture change, and set uSampler
202+
unit = this.uploadTexture(texture, w, h, reupload);
203+
if (unit !== this.currentSamplerUnit) {
204+
this.currentShader.setUniform("uSampler", unit);
205+
this.currentSamplerUnit = unit;
206+
}
153207
}
154208

155209
// Transform vertices
@@ -167,9 +221,11 @@ export default class QuadBatcher extends MaterialBatcher {
167221
}
168222

169223
// 4 vertices per quad; the index buffer provides the 6 indices
170-
vertexData.push(vec0.x, vec0.y, u0, v0, tint);
171-
vertexData.push(vec1.x, vec1.y, u1, v0, tint);
172-
vertexData.push(vec2.x, vec2.y, u0, v1, tint);
173-
vertexData.push(vec3.x, vec3.y, u1, v1, tint);
224+
// textureId is the unit index for multi-texture, or 0 for single-texture fallback
225+
const textureId = this.useMultiTexture ? unit : 0;
226+
vertexData.push(vec0.x, vec0.y, u0, v0, tint, textureId);
227+
vertexData.push(vec1.x, vec1.y, u1, v0, tint, textureId);
228+
vertexData.push(vec2.x, vec2.y, u0, v1, tint, textureId);
229+
vertexData.push(vec3.x, vec3.y, u1, v1, tint, textureId);
174230
}
175231
}

packages/melonjs/src/video/webgl/buffer/vertex.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,26 @@ export default class VertexArrayBuffer {
3939
}
4040

4141
/**
42-
* push a new vertex to the buffer (quad format: x, y, u, v, tint)
42+
* push a new vertex to the buffer
43+
* @param {number} x - x position
44+
* @param {number} y - y position
45+
* @param {number} u - texture U coordinate
46+
* @param {number} v - texture V coordinate
47+
* @param {number} tint - tint color in UINT32 (argb) format
48+
* @param {number} [textureId] - texture unit index for multi-texture batching
4349
* @ignore
4450
*/
45-
push(x, y, u, v, tint) {
51+
push(x, y, u, v, tint, textureId) {
4652
const offset = this.vertexCount * this.vertexSize;
4753

4854
this.bufferF32[offset] = x;
4955
this.bufferF32[offset + 1] = y;
5056
this.bufferF32[offset + 2] = u;
5157
this.bufferF32[offset + 3] = v;
5258
this.bufferU32[offset + 4] = tint;
59+
if (arguments.length > 5) {
60+
this.bufferF32[offset + 5] = textureId;
61+
}
5362

5463
this.vertexCount++;
5564

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Generates a multi-texture fragment shader source string.
3+
* Declares individual sampler uniforms (uSampler0..uSamplerN) and uses
4+
* an if/else chain with 0.5-offset thresholds to select the correct texture unit.
5+
* @param {number} maxTextures - the number of texture units to support
6+
* @returns {string} GLSL fragment shader source
7+
* @ignore
8+
*/
9+
export function buildMultiTextureFragment(maxTextures) {
10+
const lines = [];
11+
12+
// declare sampler uniforms
13+
for (let i = 0; i < maxTextures; i++) {
14+
lines.push("uniform sampler2D uSampler" + i + ";");
15+
}
16+
17+
lines.push("varying vec4 vColor;");
18+
lines.push("varying vec2 vRegion;");
19+
lines.push("varying float vTextureId;");
20+
lines.push("");
21+
lines.push("void main(void) {");
22+
lines.push(" vec4 color;");
23+
24+
// generate if/else chain using < N.5 thresholds
25+
for (let i = 0; i < maxTextures; i++) {
26+
if (i === 0) {
27+
lines.push(" if (vTextureId < 0.5) {");
28+
} else {
29+
lines.push(" } else if (vTextureId < " + (i + 0.5) + ") {");
30+
}
31+
lines.push(" color = texture2D(uSampler" + i + ", vRegion);");
32+
}
33+
34+
// fallback to first sampler if vTextureId is out of range
35+
lines.push(" } else {");
36+
lines.push(" color = texture2D(uSampler0, vRegion);");
37+
lines.push(" }");
38+
lines.push(" gl_FragColor = color * vColor;");
39+
lines.push("}");
40+
41+
return lines.join("\n");
42+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Current vertex point
2+
attribute vec2 aVertex;
3+
attribute vec2 aRegion;
4+
attribute vec4 aColor;
5+
attribute float aTextureId;
6+
7+
// Projection matrix
8+
uniform mat4 uProjectionMatrix;
9+
10+
varying vec2 vRegion;
11+
varying vec4 vColor;
12+
varying float vTextureId;
13+
14+
void main(void) {
15+
// Transform the vertex position by the projection matrix
16+
gl_Position = uProjectionMatrix * vec4(aVertex, 0.0, 1.0);
17+
// Pass the remaining attributes to the fragment shader
18+
vColor = vec4(aColor.bgr * aColor.a, aColor.a);
19+
vRegion = aRegion;
20+
vTextureId = aTextureId;
21+
}

packages/melonjs/src/video/webgl/webgl_renderer.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ export default class WebGLRenderer extends Renderer {
158158
this.gl.depthMask(false);
159159

160160
this.gl.disable(this.gl.SCISSOR_TEST);
161+
this._scissorActive = false;
161162
this.gl.enable(this.gl.BLEND);
162163

163164
// set default mode
@@ -325,6 +326,7 @@ export default class WebGLRenderer extends Renderer {
325326
this.setBatcher("quad");
326327

327328
this.gl.disable(this.gl.SCISSOR_TEST);
329+
this._scissorActive = false;
328330
}
329331

330332
/**
@@ -948,6 +950,7 @@ export default class WebGLRenderer extends Renderer {
948950
const gl = this.gl;
949951
const s = this.currentScissor;
950952
gl.enable(gl.SCISSOR_TEST);
953+
this._scissorActive = true;
951954
gl.scissor(
952955
s[0] + this.currentTransform.tx,
953956
canvas.height - s[3] - s[1] - this.currentTransform.ty,
@@ -956,6 +959,7 @@ export default class WebGLRenderer extends Renderer {
956959
);
957960
} else {
958961
this.gl.disable(this.gl.SCISSOR_TEST);
962+
this._scissorActive = false;
959963
}
960964
}
961965
// sync gradient from renderState
@@ -976,7 +980,7 @@ export default class WebGLRenderer extends Renderer {
976980
* renderer.restore();
977981
*/
978982
save() {
979-
this.renderState.save(this.gl.isEnabled(this.gl.SCISSOR_TEST));
983+
this.renderState.save(this._scissorActive === true);
980984
}
981985

982986
/**
@@ -1711,6 +1715,7 @@ export default class WebGLRenderer extends Renderer {
17111715
this.flush();
17121716
// turn on scissor test
17131717
gl.enable(this.gl.SCISSOR_TEST);
1718+
this._scissorActive = true;
17141719
// set the scissor rectangle (note : coordinates are left/bottom)
17151720
gl.scissor(
17161721
// scissor does not account for currentTransform, so manually adjust
@@ -1727,6 +1732,7 @@ export default class WebGLRenderer extends Renderer {
17271732
} else {
17281733
// turn off scissor test
17291734
gl.disable(gl.SCISSOR_TEST);
1735+
this._scissorActive = false;
17301736
}
17311737
}
17321738

0 commit comments

Comments
 (0)