Split out of #1584 as a self-contained mitigation.
Current state
packages/melonjs/src/video/webgl/batchers/quad_batcher.js:29
this.maxBatchTextures = Math.min(renderer.maxTextures, 16);
renderer.maxTextures is the device's MAX_TEXTURE_IMAGE_UNITS (webgl_renderer.js:149). 16 is only the WebGL 2 floor: desktop GPUs commonly report more, and most modern phones report 32. So the hardcoded 16 is discarding half the available capacity on the large majority of current hardware, mobile included — this is not a desktop-only win.
The fragment shader is generated for the count (buildMultiTextureFragment(n)), so the generator already supports any n. The 16 is a policy choice, not a structural limit.
Two things this fixes
1. The cliff moves. A scene batches until it exceeds the limit; past it, throughput collapses (see #1584 for the mechanism). Raising the limit to the device value moves that boundary from 17 textures to whatever the hardware supports.
2. It removes a capacity mismatch that currently wastes work. TextureCache is constructed with max_size = renderer.maxTextures (webgl_renderer.js:270) while the shader only addresses min(maxTextures, 16). Where the device reports more than 16 the two disagree, and every overflow does this:
uploadTexture → cache.getUnit → allocateTextureUnit returns unit 16 — free as far as the cache is concerned — and the texture is uploaded and bound to a GL unit the shader can never read
addQuad then finds unit >= this.maxBatchTextures (quad_batcher.js:287), flushes, calls resetUnitAssignments(), and re-uploads the same texture to unit 0
So a full bind — and on first sight a full texImage2D — is thrown away per overflow, and the cache's own exhaustion path never fires for quads because the batcher's wipe always precedes it. Making the two agree removes the wasted work regardless of what the limit is set to.
On a device reporting exactly 16 the min() already makes them agree and neither problem exists — but since most current hardware reports 32, today's default means the majority of devices are paying for this mismatch on every overflow.
Risks
- Shader cost. The generated fragment shader carries one sampler and one
switch arm per slot. Only one arm executes per fragment, so this is not 2× per-pixel work — the cost is shader compile time and register pressure from the extra sampler declarations. Worth measuring on a mid-range phone rather than assumed either way.
- Older devices sitting at the WebGL 2 floor of 16 are unaffected:
min() already resolves to 16 there, so nothing changes for them.
- Driver quirks with large sampler arrays are a known hazard and want testing across backends before the default changes.
Open decision
Whether to take the device value unconditionally, or expose a maxTextures application setting so a project can raise or lower it. A setting also gives an escape hatch if a specific device misbehaves. Needs a call before implementation.
Testing
Scope
This moves the cliff, it does not remove it — a scene exceeding the new limit degrades exactly as before. #1584 tracks the structural fix; this is the cheap mitigation that is worth having either way.
Split out of #1584 as a self-contained mitigation.
Current state
packages/melonjs/src/video/webgl/batchers/quad_batcher.js:29renderer.maxTexturesis the device'sMAX_TEXTURE_IMAGE_UNITS(webgl_renderer.js:149). 16 is only the WebGL 2 floor: desktop GPUs commonly report more, and most modern phones report 32. So the hardcoded 16 is discarding half the available capacity on the large majority of current hardware, mobile included — this is not a desktop-only win.The fragment shader is generated for the count (
buildMultiTextureFragment(n)), so the generator already supports anyn. The 16 is a policy choice, not a structural limit.Two things this fixes
1. The cliff moves. A scene batches until it exceeds the limit; past it, throughput collapses (see #1584 for the mechanism). Raising the limit to the device value moves that boundary from 17 textures to whatever the hardware supports.
2. It removes a capacity mismatch that currently wastes work.
TextureCacheis constructed withmax_size = renderer.maxTextures(webgl_renderer.js:270) while the shader only addressesmin(maxTextures, 16). Where the device reports more than 16 the two disagree, and every overflow does this:uploadTexture→cache.getUnit→allocateTextureUnitreturns unit 16 — free as far as the cache is concerned — and the texture is uploaded and bound to a GL unit the shader can never readaddQuadthen findsunit >= this.maxBatchTextures(quad_batcher.js:287), flushes, callsresetUnitAssignments(), and re-uploads the same texture to unit 0So a full bind — and on first sight a full
texImage2D— is thrown away per overflow, and the cache's own exhaustion path never fires for quads because the batcher's wipe always precedes it. Making the two agree removes the wasted work regardless of what the limit is set to.On a device reporting exactly 16 the
min()already makes them agree and neither problem exists — but since most current hardware reports 32, today's default means the majority of devices are paying for this mismatch on every overflow.Risks
switcharm per slot. Only one arm executes per fragment, so this is not 2× per-pixel work — the cost is shader compile time and register pressure from the extra sampler declarations. Worth measuring on a mid-range phone rather than assumed either way.min()already resolves to 16 there, so nothing changes for them.Open decision
Whether to take the device value unconditionally, or expose a
maxTexturesapplication setting so a project can raise or lower it. A setting also gives an escape hatch if a specific device misbehaves. Needs a call before implementation.Testing
maxBatchTexturesfollows the device value (and the setting, if one is added) — unit test with a stubbedmaxTexturesgl.readPixelsidiom (19 specs already do this)Scope
This moves the cliff, it does not remove it — a scene exceeding the new limit degrades exactly as before. #1584 tracks the structural fix; this is the cheap mitigation that is worth having either way.