Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Change Log

## Unreleased

### Bug Fixes

- Fixed sprites drawing with the wrong texture in WebGL multi-texture mode, because the texture unit cache (`PIXI.WebGLRenderer.textureArray`) went stale after other code rebound texture units.
- Fixed late-loaded and changed textures (e.g., Text, BitmapData) rendering blank or stale in WebGL multi-texture mode.
- Fixed WebGL feedback-loop errors when rendering filters in multi-texture mode.
- Fixed WebGL multi-texture mode losing texture bindings when a TileSprite texture was uploaded, when a shader was first used mid-render, or when a RenderTexture was created or resized.

## Version 2.21.0 - 23 October 2026

### New Features
Expand Down
3 changes: 3 additions & 0 deletions src/gameobjects/Creature.js
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,9 @@ Phaser.Creature.prototype._renderCreature = function (renderSession)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, this.indices, gl.STATIC_DRAW);
}

// unit 0 was rebound above, outside the sprite batch
PIXI.WebGLRenderer.invalidateTextureUnit(0);

gl.drawElements(gl.TRIANGLES, this.indices.length, gl.UNSIGNED_SHORT, 0);
};

Expand Down
3 changes: 3 additions & 0 deletions src/gameobjects/Rope.js
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,9 @@ Phaser.Rope.prototype._renderStrip = function (renderSession)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, this.indices, gl.STATIC_DRAW);
}

// unit 0 was rebound above, outside the sprite batch
PIXI.WebGLRenderer.invalidateTextureUnit(0);

gl.drawElements(drawMode, this.indices.length, gl.UNSIGNED_SHORT, 0);
};

Expand Down
6 changes: 6 additions & 0 deletions src/gameobjects/TileSprite.js
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,12 @@ Phaser.TileSprite.prototype._renderWebGL = function (renderSession)
{
if (this.tilingTexture.needsUpdate)
{
if (PIXI._enableMultiTextureToggle)
{
// the upload binds on a texture unit that pending sprites may be using
renderSession.spriteBatch.flush();
}

this.tilingTexture.baseTexture.textureIndex = this.texture.baseTexture.textureIndex;
renderSession.renderer.updateTexture(this.tilingTexture.baseTexture);
this.tilingTexture.needsUpdate = false;
Expand Down
27 changes: 27 additions & 0 deletions src/pixi/renderers/webgl/WebGLRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,9 @@ PIXI.WebGLRenderer.prototype.initContext = function ()
this.blendModeManager.setContext(gl);
this.stencilManager.setContext(gl);

// a new context starts with nothing bound, and the setContext() calls above bind dummy textures
PIXI.WebGLRenderer.invalidateTextureUnit();

this.renderSession.gl = this.gl;

// now resize and we are good to go!
Expand Down Expand Up @@ -511,6 +514,7 @@ PIXI.WebGLRenderer.prototype.updateCompressedTexture = function (texture)
gl.activeTexture(gl.TEXTURE0 + texture.textureIndex);

gl.bindTexture(gl.TEXTURE_2D, texture._glTextures[gl.id]);
PIXI.WebGLRenderer.textureArray[texture.textureIndex] = texture;

gl.compressedTexImage2D(
gl.TEXTURE_2D,
Expand Down Expand Up @@ -575,6 +579,7 @@ PIXI.WebGLRenderer.prototype.updateTexture = function (texture)
gl.activeTexture(gl.TEXTURE0 + texture.textureIndex);

gl.bindTexture(gl.TEXTURE_2D, texture._glTextures[gl.id]);
PIXI.WebGLRenderer.textureArray[texture.textureIndex] = texture;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flushed the sprite batch before the eager TileSprite texture upload in 6cb50ea.


gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, texture.premultipliedAlpha);

Expand Down Expand Up @@ -686,4 +691,26 @@ PIXI.enableMultiTexture = function ()
PIXI._enableMultiTextureToggle = true;
};

/**
* Records which base texture is bound to each texture unit (multi-texture mode only).
* Shared by all renderers.
*/
PIXI.WebGLRenderer.textureArray = [];

/**
* Forgets what is bound to a texture unit (or to all units), after code outside the sprite batch has rebound it.
*
* @method PIXI.WebGLRenderer.invalidateTextureUnit
* @param {number} [unit] - The texture unit to clear. If omitted, all units are cleared.
*/
PIXI.WebGLRenderer.invalidateTextureUnit = function (unit)
{
if (typeof unit === 'number')
{
PIXI.WebGLRenderer.textureArray[unit] = null;
}
else
{
PIXI.WebGLRenderer.textureArray.length = 0;
}
};
28 changes: 26 additions & 2 deletions src/pixi/renderers/webgl/shaders/PixiShader.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,20 +131,42 @@ PIXI.PixiShader.prototype.initMultitexShader = function ()

var indices = [];

// This can run in the middle of a render (first use of a shader), so remember what is bound and put it back.
var previousActive = gl.getParameter(gl.ACTIVE_TEXTURE);
var previousBindings = [];
var i;

for (i = 0; i < this.MAX_TEXTURES; ++i)
{
gl.activeTexture(gl.TEXTURE0 + i);
previousBindings.push(gl.getParameter(gl.TEXTURE_BINDING_2D));
}

// HACK: we bind an empty texture to avoid WebGL warning spam.
var tempTexture = gl.createTexture();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, tempTexture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, 1, 1, 0, gl.RGB, gl.UNSIGNED_BYTE, null);
for (var i = 0; i < this.MAX_TEXTURES; ++i)
for (i = 0; i < this.MAX_TEXTURES; ++i)
{
gl.activeTexture(gl.TEXTURE0 + i);
gl.bindTexture(gl.TEXTURE_2D, tempTexture);
indices.push(i);
}
gl.activeTexture(gl.TEXTURE0);
gl.uniform1iv(this.uSamplerArray, indices);

// Units that held nothing keep the dummy texture.
for (i = 0; i < this.MAX_TEXTURES; ++i)
{
if (previousBindings[i])
{
gl.activeTexture(gl.TEXTURE0 + i);
gl.bindTexture(gl.TEXTURE_2D, previousBindings[i]);
}
}

gl.activeTexture(previousActive);

// Begin worst hack eva //

/*
Expand Down Expand Up @@ -344,6 +366,7 @@ PIXI.PixiShader.prototype.initSampler2D = function (uniform)
// No need to do string manipulation for this.
gl.activeTexture(gl.TEXTURE0 + this.textureCount);
gl.bindTexture(gl.TEXTURE_2D, uniform.value.baseTexture._glTextures[gl.id]);
PIXI.WebGLRenderer.invalidateTextureUnit(this.textureCount);

// Extended texture data
if (uniform.textureData)
Expand Down Expand Up @@ -461,6 +484,7 @@ PIXI.PixiShader.prototype.syncUniforms = function ()
}

// gl.bindTexture(gl.TEXTURE_2D, uniform.value.baseTexture._glTextures[gl.id] || PIXI.createWebGLTexture( uniform.value.baseTexture, gl));
PIXI.WebGLRenderer.invalidateTextureUnit(this.textureCount);
gl.uniform1i(uniform.uniformLocation, this.textureCount);
this.textureCount++;
}
Expand Down
12 changes: 12 additions & 0 deletions src/pixi/renderers/webgl/utils/FilterTexture.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ function _CreateFramebuffer (gl, width, height, scaleMode, textureUnit)
var colorBuffer = null;
var fbStatus = 0;

// Creating the texture binds on this unit, which may hold a texture that is still waiting to be drawn.
var previousActive = gl.getParameter(gl.ACTIVE_TEXTURE);
gl.activeTexture(gl.TEXTURE0 + textureUnit);
var previousTexture = gl.getParameter(gl.TEXTURE_BINDING_2D);

gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.bindRenderbuffer(gl.RENDERBUFFER, depthStencilBuffer);
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_STENCIL_ATTACHMENT, gl.RENDERBUFFER, this.renderBuffer);
Expand All @@ -52,6 +56,10 @@ function _CreateFramebuffer (gl, width, height, scaleMode, textureUnit)
framebuffer.height = height;
framebuffer.targetTexture = colorBuffer;
framebuffer.renderBuffer = depthStencilBuffer;

gl.bindTexture(gl.TEXTURE_2D, previousTexture);
gl.activeTexture(previousActive);

return framebuffer;
}

Expand Down Expand Up @@ -121,8 +129,12 @@ PIXI.FilterTexture.prototype.resize = function (width, height)
this.height = height;

var gl = this.gl;

// resizing can happen mid-render, so leave the active unit's binding as it was
var previousTexture = gl.getParameter(gl.TEXTURE_BINDING_2D);
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width , height , 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.bindTexture(gl.TEXTURE_2D, previousTexture);

// update the stencil buffer width and height
gl.bindRenderbuffer(gl.RENDERBUFFER, this.renderBuffer);
Expand Down
51 changes: 42 additions & 9 deletions src/pixi/renderers/webgl/utils/WebGLFastSpriteBatch.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,10 +199,24 @@ PIXI.WebGLFastSpriteBatch.prototype.render = function (spriteBatch)

var textureIndex = this.currentBaseTexture.textureIndex;
var gl = this.gl;

gl.activeTexture(gl.TEXTURE0 + textureIndex);
gl.bindTexture(gl.TEXTURE_2D, this.currentBaseTexture._glTextures[gl.id]);
PIXI.WebGLRenderer.textureArray[textureIndex] = this.currentBaseTexture;

if (!this.currentBaseTexture.source && this.currentBaseTexture._glTextures[gl.id])
{
// a render target (RenderTexture) has no source to upload, so it is never stale
this.currentBaseTexture._dirty[gl.id] = false;
}

if (this.currentBaseTexture._dirty[gl.id] || !this.currentBaseTexture._glTextures[gl.id])
{
// binds on the texture's own unit and updates the texture unit cache
this.renderSession.renderer.updateTexture(this.currentBaseTexture);
}
else
{
gl.activeTexture(gl.TEXTURE0 + textureIndex);
gl.bindTexture(gl.TEXTURE_2D, this.currentBaseTexture._glTextures[gl.id]);
PIXI.WebGLRenderer.textureArray[textureIndex] = this.currentBaseTexture;
}

for(var i = 0,j = children.length; i < j; i++)
{
Expand All @@ -223,13 +237,32 @@ PIXI.WebGLFastSpriteBatch.prototype.renderSprite = function (sprite)
var gl = this.gl;
var textureIndex = sprite.texture.baseTexture.textureIndex;

if (PIXI.WebGLRenderer.textureArray[textureIndex] != baseTexture && // eslint-disable-line eqeqeq
baseTexture._glTextures[gl.id] && !sprite.texture.baseTexture.skipRender)
if (!baseTexture.source && baseTexture._glTextures[gl.id])
{
baseTexture._dirty[gl.id] = false;
}

var dirty = baseTexture._dirty[gl.id] || !baseTexture._glTextures[gl.id];

if ((dirty || PIXI.WebGLRenderer.textureArray[textureIndex] !== baseTexture) && !baseTexture.skipRender)
{
this.flush();
gl.activeTexture(gl.TEXTURE0 + textureIndex);
gl.bindTexture(gl.TEXTURE_2D, baseTexture._glTextures[gl.id]);
PIXI.WebGLRenderer.textureArray[textureIndex] = baseTexture;

if (dirty)
{
// binds on the texture's own unit and updates the texture unit cache
if (!this.renderSession.renderer.updateTexture(baseTexture))
{
return;
}
}
else
{
gl.activeTexture(gl.TEXTURE0 + textureIndex);
gl.bindTexture(gl.TEXTURE_2D, baseTexture._glTextures[gl.id]);
PIXI.WebGLRenderer.textureArray[textureIndex] = baseTexture;
}

if(!sprite.texture._uvs) { return; }
}

Expand Down
8 changes: 7 additions & 1 deletion src/pixi/renderers/webgl/utils/WebGLFilterManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ PIXI.WebGLFilterManager.prototype.pushFilter = function (filterBlock)
this.offsetX += filterBlock._filterArea.x;
this.offsetY += filterBlock._filterArea.y;

// FilterTexture creation and resizing bind on the active unit, so use unit 0 and leave it unbound.
gl.activeTexture(gl.TEXTURE0);

var texture = this.texturePool.pop();
if(!texture)
{
Expand All @@ -103,7 +106,8 @@ PIXI.WebGLFilterManager.prototype.pushFilter = function (filterBlock)
texture.resize(this.width * this.renderSession.resolution, this.height * this.renderSession.resolution);
}

gl.bindTexture(gl.TEXTURE_2D, texture.texture);
gl.bindTexture(gl.TEXTURE_2D, null);
PIXI.WebGLRenderer.invalidateTextureUnit();

var filterArea = filterBlock._filterArea;// filterBlock.target.getBounds();///filterBlock.target.filterArea;

Expand Down Expand Up @@ -210,6 +214,7 @@ PIXI.WebGLFilterManager.prototype.popFilter = function ()
// set texture
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, inputTexture.texture);
PIXI.WebGLRenderer.invalidateTextureUnit();

/*
* draw texture..
Expand Down Expand Up @@ -315,6 +320,7 @@ PIXI.WebGLFilterManager.prototype.popFilter = function ()
// set texture
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture.texture);
PIXI.WebGLRenderer.invalidateTextureUnit();

// >>> modify by nextht
if (this.renderSession.stencilManager)
Expand Down
64 changes: 50 additions & 14 deletions src/pixi/renderers/webgl/utils/WebGLSpriteBatch.js
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,50 @@ PIXI.WebGLSpriteBatch.prototype.end = function ()
this.flush();
};

/**
* Multi-texture mode: makes sure the base texture is uploaded if dirty and bound to the given texture unit.
* Flushes the batch first if the unit binding or contents will change.
*
* @method PIXI.WebGLSpriteBatch#bindMultiTexture
* @private
* @param {PIXI.BaseTexture} baseTexture
* @param {number} textureUnit
* @return {boolean} False if the texture is dirty but can't be uploaded yet (not loaded), so the sprite should be skipped.
*/
PIXI.WebGLSpriteBatch.prototype.bindMultiTexture = function (baseTexture, textureUnit)
{
var gl = this.gl;

if (!baseTexture.source && baseTexture._glTextures[gl.id])
{
// a render target (RenderTexture) has no source to upload, so it is never stale
baseTexture._dirty[gl.id] = false;
}

var dirty = baseTexture._dirty[gl.id] || !baseTexture._glTextures[gl.id];

if (!dirty && PIXI.WebGLRenderer.textureArray[textureUnit] === baseTexture)
{
return true;
}

this.flush();

if (dirty)
{
// the upload binds on the base texture's own unit, which has to be the one the vertices sample
baseTexture.textureIndex = textureUnit;

return this.renderSession.renderer.updateTexture(baseTexture);
}

gl.activeTexture(gl.TEXTURE0 + textureUnit);
gl.bindTexture(gl.TEXTURE_2D, baseTexture._glTextures[gl.id]);
PIXI.WebGLRenderer.textureArray[textureUnit] = baseTexture;

return true;
};

/**
* @method PIXI.WebGLSpriteBatch#render
* @param sprite {Sprite} the sprite to render when using this spritebatch
Expand All @@ -260,13 +304,9 @@ PIXI.WebGLSpriteBatch.prototype.render = function (sprite, matrix)
if (PIXI._enableMultiTextureToggle)
{
var baseTexture = texture.baseTexture;
var gl = this.gl;
if (PIXI.WebGLRenderer.textureArray[baseTexture.textureIndex] != baseTexture) // eslint-disable-line eqeqeq
if (!this.bindMultiTexture(baseTexture, baseTexture.textureIndex))
{
this.flush();
gl.activeTexture(gl.TEXTURE0 + baseTexture.textureIndex);
gl.bindTexture(gl.TEXTURE_2D, baseTexture._glTextures[gl.id]);
PIXI.WebGLRenderer.textureArray[baseTexture.textureIndex] = baseTexture;
return;
}
}

Expand Down Expand Up @@ -447,14 +487,9 @@ PIXI.WebGLSpriteBatch.prototype.renderTilingSprite = function (sprite)

if (PIXI._enableMultiTextureToggle)
{
var baseTexture = texture.baseTexture;
var gl = this.gl;
if (PIXI.WebGLRenderer.textureArray[textureIndex] != baseTexture) // eslint-disable-line eqeqeq
if (!this.bindMultiTexture(texture.baseTexture, textureIndex))
{
this.flush();
gl.activeTexture(gl.TEXTURE0 + textureIndex);
gl.bindTexture(gl.TEXTURE_2D, baseTexture._glTextures[gl.id]);
PIXI.WebGLRenderer.textureArray[textureIndex] = baseTexture;
return;
}
}

Expand Down Expand Up @@ -782,8 +817,9 @@ PIXI.WebGLSpriteBatch.prototype.renderBatch = function (texture, size, startInde
return;
}
}
else
else if (!PIXI._enableMultiTextureToggle)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restored the cached texture bindings after first-use multi-texture shader initialization in 6cb50ea.

{
// in multi-texture mode the batch's textures are already bound on their own units
gl.bindTexture(gl.TEXTURE_2D, texture._glTextures[gl.id]);
}

Expand Down
Loading