Add multi-texture batching for WebGL renderer (#1376)#1389
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates melonJS’ WebGL initialization defaults and improves robustness around compressed texture format detection, along with an example update and a package version/changelog bump.
Changes:
- Guard
WebGLRenderer.getSupportedCompressedTextureFormats()against missing GL context. - Change default
failIfMajorPerformanceCaveattofalseinCanvasRenderTargetattributes. - Bump
melonjspackage version to19.1.0and add19.1.0changelog entries; update the compressed textures example to thesetText()+preDraw/draw/postDrawpattern.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/melonjs/src/video/webgl/webgl_renderer.js | Adds a null/undefined GL context guard for compressed texture extension probing. |
| packages/melonjs/src/video/rendertarget/canvasrendertarget.js | Changes default WebGL context creation attribute failIfMajorPerformanceCaveat. |
| packages/melonjs/package.json | Version bump to 19.1.0. |
| packages/melonjs/CHANGELOG.md | Adds 19.1.0 entry describing the WebGL changes and example fix. |
| packages/examples/src/examples/compressedTextures/ExampleCompressedTextures.tsx | Updates text rendering to explicit setText() + renderable draw lifecycle calls. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
7ac7b2c to
8b98be3
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
8b98be3 to
f151ef4
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
e7be282 to
3647800
Compare
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>
- 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
3647800 to
b553d61
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
ec60e38 to
c2316dc
Compare
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) <noreply@anthropic.com>
c2316dc to
1fda0ca
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- 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) <noreply@anthropic.com>
c630485 to
485e6e6
Compare
…king - 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) <noreply@anthropic.com>
485e6e6 to
aa4e87e
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
191b992 to
4bd0052
Compare
…Draw 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) <noreply@anthropic.com>
4bd0052 to
26c594e
Compare
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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; | ||
| } |
There was a problem hiding this comment.
Renderer.hasSupportedCompressedFormats() is now null-safe, but WebGLRenderer still overrides this method with the previous implementation (which will still throw when an unsupported extension family is null). This means WebGL compressed texture loading can still crash (e.g. parseCompressedImage() calls _renderer.hasSupportedCompressedFormats). Consider removing the WebGLRenderer.hasSupportedCompressedFormats override so it inherits this fixed base implementation, or apply the same null/undefined guard in the override.
| 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 + ";"); | ||
| } |
There was a problem hiding this comment.
buildMultiTextureFragment() produces invalid GLSL when maxTextures <= 0 (it still emits a fallback sampling uSampler0 even though no sampler uniforms were declared). Adding an explicit validation/throw (or clamping to at least 1) would prevent hard-to-diagnose shader compile failures if an unexpected value is passed in.
| this.bufferF32[offset + 3] = v; | ||
| this.bufferU32[offset + 4] = tint; | ||
| if (this.vertexSize > 5) { | ||
| this.bufferF32[offset + 5] = textureId || 0; |
There was a problem hiding this comment.
textureId || 0 relies on truthiness and will silently coerce some invalid numeric values (e.g. NaN) to 0. Using a nullish check (textureId ?? 0) would be more explicit about the intent (default only when omitted) and avoids masking unexpected inputs.
| this.bufferF32[offset + 5] = textureId || 0; | |
| this.bufferF32[offset + 5] = textureId ?? 0; |
| // re-bind sampler uniforms after context restore | ||
| for (let i = 0; i < this.maxBatchTextures; i++) { | ||
| this.defaultShader.setUniform("uSampler" + i, i); | ||
| } |
There was a problem hiding this comment.
QuadBatcher.reset() is called on normal renderer resets (e.g. GAME_RESET), not on WebGL context restore (where batcher.init() is invoked). The comment about “after context restore” is misleading, and the uniform rebind here may be redundant; either adjust the comment or move/restrict the rebind logic to the actual context-restore path.
| ### 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. |
There was a problem hiding this comment.
The changelog entry doesn’t mention that CanvasRenderTarget’s internal default failIfMajorPerformanceCaveat behavior changed in this release (see canvasrendertarget.js). If that default change is intended, it should be documented here; otherwise consider reverting the default to avoid an untracked behavior change.
| scaleMethod: "flex", | ||
| renderer: video.WEBGL, | ||
| }); | ||
|
|
There was a problem hiding this comment.
This code assumes app.renderer is a WebGLRenderer, but autoDetectRenderer() can fall back to CanvasRenderer when WebGL isn’t available or is rejected due to failIfMajorPerformanceCaveat. Consider guarding with renderer.type.includes("WebGL") / instanceof WebGLRenderer and showing a user-facing message (or forcing WebGL) to avoid incorrect behavior/casts in the compressed-texture example.
| if (!app.renderer.type.includes("WebGL")) { | |
| app.world.addChild(new ColorLayer("background", "#0f172a"), 0); | |
| app.world.addChild( | |
| new Text(24, 24, { | |
| text: "Compressed textures require WebGL.\nWebGL is not available in this browser/environment.", | |
| size: 24, | |
| fillStyle: "#e2e8f0", | |
| }), | |
| 1, | |
| ); | |
| return; | |
| } |
| this.off_icon_region = tex.getRegion(offIcon); | ||
| this.setOpacity(0.5); | ||
| this.isSelected = true; | ||
| this.label_on = onLabel; | ||
| 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, { |
There was a problem hiding this comment.
The checkbox label is initialized with text: onLabel, which makes the UI start in the “ON” state visually. If the intended default is “OFF” (as in the prior version that initially displayed offLabel), initialize the label with offLabel and/or call setSelected(false) in the constructor to keep region/text/state consistent from the first frame.
Summary
Multi-texture batching allows up to 16 textures to be drawn in a single batch/draw call, eliminating flushes on texture changes.
Changes
shaders/multitexture.js— dynamically generates vertex/fragment shaders with per-texture sampler uniforms and if/else selection chainbuffer/vertex.js— newpushTextured(x, y, u, v, tint, textureId)method (6 floats per vertex)material_batcher.js— newuploadTextureMulti/bindTexture2DMultinon-flushing methodsquad_batcher.js— uses generated multi-texture shaders, embeds texture unit index in vertex data, falls back to single-texture when custom ShaderEffect is activeBackward compatible
drawImage()API unchangedShaderEffect/GLShader— when active, single-texture fallback kicks in automaticallySpineBatcher,MeshBatcher,PrimitiveBatcher— unaffectedquad.vert/quad.frag— unchanged (used by ShaderEffect fallback)Test plan
Closes #1376
🤖 Generated with Claude Code