Changing a palette colour of pixel art flooded the console with Pixi warnings: "[BindGroup] a 'textureSource' was destroyed while still bound to a shader", and the same for its sampler. #293 had stopped this for a drawing, but a frame of pixel art is worn as a cut of the sheet. The cut has a source of its own, so the sheet looked unbound, was freed at once and took the cut with it under the shader. - The check whether a replaced picture is still in use gets its key too. TextureLibrary then asks the picture and every cut taken from it. - A sheet with a bound cut waits like a bound picture did since #293, and both go together once the shader has taken the new frame. Found on the way: - The project the report came from has a sprite wearing a frame of an eleven-frame pixel art through a shader asset. - A nine-sliced sprite wearing the frame showed no warning, even without the change. The first try of the e2e check used it. Tests: - Gate on melektaus-atx: 236 files, 3577 tests (+1) - e2e 48: 45 checks (+1, sum 1741). 48l-iii puts a shader on the object wearing the second frame, changes a palette colour three times and counts the warnings. - Counter-checks bite: without the cuts in the check the unit test fails, and 48l-iii counts 6 warnings for 3 changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
5.9 KiB
TypeScript
149 lines
5.9 KiB
TypeScript
import { BindGroup, Texture, TextureSource } from 'pixi.js';
|
||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
import { TextureLibrary } from '../../src/viewport/TextureLibrary';
|
||
|
||
/**
|
||
* #293: Pixi warned "[BindGroup] a 'textureSource' was destroyed while still bound to a shader"
|
||
* with every change to a drawing that an object wore through a shader asset. The mesh's shader
|
||
* names the picture until its next sync, and the library destroyed the old revision the moment it
|
||
* drew the new one – which is before any wearer can have heard of it.
|
||
*
|
||
* Measured with Pixi's own bind group, which is what a shader keeps its resources in.
|
||
*/
|
||
function rig() {
|
||
const library = new TextureLibrary();
|
||
const drawn: Texture[] = [];
|
||
library.setPictureFactory(() => {
|
||
const texture = new Texture({ source: new TextureSource({ width: 4, height: 4 }) });
|
||
drawn.push(texture);
|
||
return texture;
|
||
});
|
||
const warned = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||
const warnings = () =>
|
||
warned.mock.calls.map((call) => call.join(' ')).filter((line) => line.includes('BindGroup'));
|
||
/** What a shader does with a picture it wears. */
|
||
const bind = (group: BindGroup, texture: Texture): void => {
|
||
group.setResource(texture.source, 0);
|
||
group.setResource(texture.source.style, 1);
|
||
};
|
||
return { library, drawn, warnings, bind };
|
||
}
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
describe('a drawn picture a shader still names (#293)', () => {
|
||
it('is told by the bind group listening on its source', () => {
|
||
const r = rig();
|
||
const picture = r.library.load('gen:a@1')!;
|
||
expect(picture.source.listenerCount('change')).toBe(0);
|
||
const group = new BindGroup({ 0: picture.source, 1: picture.source.style });
|
||
expect(picture.source.listenerCount('change')).toBe(1);
|
||
r.bind(group, Texture.WHITE);
|
||
expect(picture.source.listenerCount('change')).toBe(0);
|
||
});
|
||
|
||
it('is not destroyed under the shader when its drawing changes, and nothing is warned', () => {
|
||
const r = rig();
|
||
const first = r.library.load('gen:a@1')!;
|
||
const shader = new BindGroup({ 0: first.source, 1: first.source.style });
|
||
|
||
const second = r.library.load('gen:a@2')!;
|
||
expect(second).not.toBe(first);
|
||
expect(first.destroyed).toBe(false);
|
||
expect(first.source.destroyed).toBe(false);
|
||
expect(r.library.waitingPictures()).toEqual(['gen:a@1']);
|
||
// It is no longer what the library hands out.
|
||
expect(r.library.peek('gen:a@1')).toBeNull();
|
||
expect(r.warnings()).toEqual([]);
|
||
|
||
// The mesh syncs: its shader takes the new picture. The next ask frees the old one.
|
||
r.bind(shader, second);
|
||
expect(r.library.load('gen:a@2')).toBe(second);
|
||
expect(first.destroyed).toBe(true);
|
||
expect(r.library.waitingPictures()).toEqual([]);
|
||
expect(second.destroyed).toBe(false);
|
||
expect(r.warnings()).toEqual([]);
|
||
});
|
||
|
||
it('is freed right after the redraw in which the shader took the new picture', async () => {
|
||
const r = rig();
|
||
const first = r.library.load('gen:a@1')!;
|
||
const shader = new BindGroup({ 0: first.source, 1: first.source.style });
|
||
// One redraw: the mesh asks for its picture and hands it to its shader.
|
||
r.bind(shader, r.library.load('gen:a@2')!);
|
||
expect(first.destroyed).toBe(false);
|
||
await Promise.resolve();
|
||
expect(first.destroyed).toBe(true);
|
||
expect(r.library.waitingPictures()).toEqual([]);
|
||
expect(r.warnings()).toEqual([]);
|
||
});
|
||
|
||
it('stays through any number of changes while the shader does not sync, and only that one', () => {
|
||
const r = rig();
|
||
const first = r.library.load('gen:a@1')!;
|
||
const shader = new BindGroup({ 0: first.source, 1: first.source.style });
|
||
// The scene is not on screen: nobody syncs, the drawing is edited three times.
|
||
r.library.load('gen:a@2');
|
||
r.library.load('gen:a@3');
|
||
r.library.load('gen:a@4');
|
||
expect(r.drawn.map((texture) => texture.destroyed)).toEqual([false, true, true, false]);
|
||
expect(r.library.waitingPictures()).toEqual(['gen:a@1']);
|
||
expect(r.warnings()).toEqual([]);
|
||
shader.destroy();
|
||
r.library.load('gen:a@4');
|
||
expect(first.destroyed).toBe(true);
|
||
expect(r.warnings()).toEqual([]);
|
||
});
|
||
|
||
it('waits while a shader names a frame cut from it, as pixel art is worn', () => {
|
||
// A frame is cut onto a canvas of its own, which needs an image to cut from.
|
||
vi.stubGlobal(
|
||
'OffscreenCanvas',
|
||
class {
|
||
getContext = () => ({ drawImage: () => {} });
|
||
},
|
||
);
|
||
const library = new TextureLibrary();
|
||
library.setPictureFactory(
|
||
() => new Texture({ source: new TextureSource({ resource: {}, width: 8, height: 4 }) }),
|
||
);
|
||
const warned = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||
const frameOf = (file: string) =>
|
||
library.get({
|
||
file,
|
||
rect: { x: 4, y: 0, w: 4, h: 4 },
|
||
smooth: false,
|
||
sourceWidth: 8,
|
||
sourceHeight: 4,
|
||
slice: null,
|
||
})!;
|
||
library.load('gen:a@1');
|
||
const cut = frameOf('gen:a@1');
|
||
const shader = new BindGroup({ 0: cut.source, 1: cut.source.style });
|
||
|
||
// A colour of the palette changes: a new revision, and the cut is still in the shader.
|
||
library.load('gen:a@2');
|
||
expect(cut.destroyed).toBe(false);
|
||
expect(library.waitingPictures()).toEqual(['gen:a@1']);
|
||
|
||
shader.setResource(frameOf('gen:a@2').source, 0);
|
||
shader.setResource(Texture.WHITE.source.style, 1);
|
||
library.load('gen:a@2');
|
||
expect(cut.destroyed).toBe(true);
|
||
expect(library.waitingPictures()).toEqual([]);
|
||
expect(warned.mock.calls.filter((call) => call.join(' ').includes('BindGroup'))).toEqual([]);
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it('is destroyed right away when no shader names it', () => {
|
||
const r = rig();
|
||
const first = r.library.load('gen:a@1')!;
|
||
r.library.load('gen:a@2');
|
||
expect(first.destroyed).toBe(true);
|
||
expect(r.library.waitingPictures()).toEqual([]);
|
||
expect(r.warnings()).toEqual([]);
|
||
});
|
||
});
|