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 the WebGL 2 floor, and plenty of hardware reports more — so the hardcoded cap discards capacity the device already offers.
Measure before assuming which side you are on. An Apple M4 Max through Chrome/ANGLE-Metal reports MAX_TEXTURE_IMAGE_UNITS = 16 — the floor — on both WebGL 1 and WebGL 2, and under both --use-angle=metal and the default. ANGLE's Metal backend caps shader samplers at 16 per stage; the hardware is not the constraint. That machine gains nothing from this change on the unlit path.
Note also that MAX_COMBINED_TEXTURE_IMAGE_UNITS on the same device is 32 — exactly 16 fragment + 16 vertex. That is a different limit, and is the likely source of widely-quoted "32 texture units" figures. The one that governs uSamplerN in the fragment shader is MAX_TEXTURE_IMAGE_UNITS.
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→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 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
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. - 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
maxBatchTexturesfollows the device value (and the setting, if one is added) — unit test with a stubbedmaxTextures- pixel readback: N distinct textures above the old 16 all sample correctly in one batch, using the existing
gl.readPixelsidiom (19 specs already do this) - with the counters from Texture-set overflow thrashes both backends: replace the slot ladder with texture arrays #1584 in place: zero cache resets for a scene below the new limit
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.
Measured, after implementation
Headless Chromium (SwiftShader, reports 32 units), 512 quads/frame, round-robin over N distinct textures. Reset/draw counts are exact; milliseconds are noisy single runs.
| N textures | 19.9.1 (pool 16) | this change (pool 32) |
|---|---|---|
| 16 | 0 resets, 1 draw, ~0.10 ms | 0 resets, 1 draw, ~0.10 ms |
| 17 | 32 resets, 33 draws, ~2.0 ms | 0 resets, 1 draw, ~0.10 ms |
| 32 | 32 resets, 32 draws, ~3.2 ms | 0 resets, 1 draw, ~0.12 ms |
| 33 | — | 16 resets, 17 draws, ~2.5 ms |
Over 9 interleaved repetitions at N=32 the ratio of medians was ~115x, with a conservative floor (fastest pool-16 vs slowest pool-32) of ~24x.
Read it as a threshold, not a speedup. Nothing got faster — the cliff moved from 17 textures to 33. A scene below the old limit gains nothing; a scene far past the new one still falls off. And under a skewed access pattern (90% of draws hitting 4 hot textures) the old behaviour was already cheap, so the gain there is single-digit.
The lit half is the part that helps every device, including the 16-unit ones: LitQuadBatcher previously halved the pool and reserved the upper half, so lit content ran at an effective pool of 8. It now runs at the full pool — the same cliff-crossing, for lit scenes with 9-16 textures.
Part 2 — what an overflow actually costs
Found while benchmarking the above. Pre-existing — reproduced on unmodified master.
What happens
When the texture cache runs out of units it flushes, drops every unit assignment, and emits GPU_TEXTURE_CACHE_RESET. Every batcher's handler clears boundTextures wholesale (webgl/batchers/material_batcher.js:103).
The GL texture handle is reachable only through boundTextures[unit]. Clearing that array does not merely forget which unit a texture sits on — it loses the handle. The next uploadTexture finds boundTextures[unit] === undefined, calls createTexture2D with no previous handle, and rebuilds the texture from scratch.
So past the batching limit, a scene does not re-bind its textures. It re-creates them, once per draw, every frame.
Measured
Every function on the GL context was wrapped and counted, then one frame that batches cleanly was diffed against one that overflows. 512 quads/frame, headless Chromium, master:
pool=16, 512 quads no overflow -> 17 textures
createTexture 0 -> 542
texStorage2D 0 -> 542
texSubImage2D 0 -> 542
generateMipmap 0 -> 542
texParameteri 0 -> 2168
bindTexture 0 -> 1084
deleteTexture 0 -> 0
drawElements 1 -> 33
542 ≈ 512 quads minus the 17 that fit before the first overflow. Essentially every quad after the first overflow re-uploads its texture and regenerates its mip chain.
deleteTexture is 0: displaced handles become unreachable when boundTextures is overwritten, so they are left to GC rather than freed — allocation churn and unpredictable collection pauses on top of the upload cost.
Frame time for that workload goes from ~0.10 ms to ~2.0-3.2 ms. Attribution rules out the obvious suspects:
- not the extra draw calls — forcing 32 flushes with a single texture measured faster than one flush (0.058 ms vs 0.105 ms, i.e. within noise)
- not texture variety — 32 distinct textures that fit the pool cost the same as one (0.085 ms vs 0.105 ms)
- not the bind calls — a
bindTexturemeasures 0.015 us, so the ~1084 an overflowing frame issues total 0.016 ms, under 1% of the delta
The cost is the re-creation path and nothing else.
Why this matters more than the batch limit
Raising the pool moves the cliff; it does nothing for a scene genuinely past it. This does. A game with 40 distinct textures overflows on any device — and what that should cost is a flush and some re-binds, not 500 texture uploads per frame.
It also affects devices where raising the pool cannot help at all: Apple Silicon reports MAX_TEXTURE_IMAGE_UNITS = 16 through ANGLE/Metal, so the pool cannot grow there, yet this cost is paid from the 17th texture onward.
Root cause, stated generally
There are two distinct questions about a texture, and this backend answers both with one field:
| question | lifetime | bounded by |
|---|---|---|
| which slot is it in for this draw? | per-batch, transient | pool capacity |
| does it exist on the GPU, and is its content current? | per-source, persistent | eviction / disposal |
boundTextures[unit] answers both, so losing the slot answer destroys the residency answer.
The WebGPU backend already separates them: residency lives in WebGPUTextureStore.records, slot assignment lives in the batcher's segment, and clearing a segment cannot destroy a texture. WebGL has no residency concept at all.
Residency is currently implemented three times in the tree — MaterialBatcher.boundTextures + dirtyUnits + the immutableStorage WeakMap; WebGPUTextureStore.records; and LitQuadBatcher.normalMapTextures + boundNormalVersions, which is source-keyed with a version and is already the correct shape.
Fix: promote the store
Extract the backend-neutral half of WebGPUTextureStore into a shared base beside TextureSlotTable, mirroring the existing Batcher / WebGLBatcher / WebGPUBatcher arrangement in src/video/gpu/.
// src/video/gpu/texturestore.js export class TextureStore { constructor({ onCreate, onUpload, onDestroy }) get generation() // bumped on context / device loss getResidentRecord(source, options) // reuse, or (re)upload invalidate(source) // content changed destroyTexture(source) // source disposed retire(handle) // deferred destruction releaseAll() // clear + bump generation }
The generic half owns the source→record index, the reuse-vs-upload decision (a version compare), and lifetime bookkeeping. Each backend supplies create/upload/destroy through callbacks and touches no GL or WebGPU API in the shared code, exactly as TextureSlotTable does for slots.
WebGPUTextureStore extends TextureStore, keeping only its device-specific create/upload/view/sampler/bind-group code.- WebGL adopts the same base, owned by
WebGLRenderer.MaterialBatcher.uploadTexturereduces to: slot from the table, record from the store,bindTexture2D(record.handle, unit). LitQuadBatcher.normalMapTexturesbecomes a third consumer rather than a third implementation.
Pairs with the slot table above: TextureSlotTable answers which slot, this draw; TextureStore answers does it exist and is it current. Naming them separately is what makes this bug unrepresentable rather than merely fixed.
One correction falls out: WebGPUTextureStore.records is keyed by texture unit today (store.js:99,162,232). The shared base must key by source. Unit-keying is safe there only because WebGPU constructs TextureCache with max_size = Infinity, so units are never recycled — the same fragile coupling, currently unreachable.
Design constraints
Texture lifetime is the risky part of this change. These four are drawn from failures that mature WebGL renderers have shipped and had to fix, and each should be a test rather than a convention:
- Records carry a generation, and one from an older generation is never returned. Otherwise a handle minted under a dead context can be bound after a restore — silent corruption rather than a clean miss. A generation check turns it into an automatic re-upload.
- The store is renderer-owned, and on restore it is CLEARED, never reconstructed. Replacing the registry orphans every handle it was tracking, so the second context loss leaks everything. This engine is directly exposed:
MaterialBatcher.init()andLitQuadBatcher.init()both re-run on context restore, so a batcher-owned store would reproduce it exactly. - A fresh record's
versionstarts at a value that cannot match (e.g.-1), so it always uploads once. Guards the newly reachable "record exists but was never uploaded" state. - Sampler uniform indices are re-established after restore, not just handles.
bindColorSamplers()covers this today; it should be pinned rather than assumed.
Verification
The GL-call diff above is the primary test: assert a frame that overflows adds zero createTexture / texStorage2D / texSubImage2D / generateMipmap calls over a frame that does not. Exact, no timing involved. Pin deleteTexture too, so the fix cannot trade an upload storm for a leak.
For the constraints above, add a double context-loss/restore cycle. Single-loss tests pass straight through constraint 2, which is precisely why that failure mode survives in the wild.