Files
feuerwerk/tests/viewport/bound-pictures.test.ts
T
adminandClaude Opus 5.5 3f57cf731d Keep a pixel frame worn through a shader alive while it is drawn
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>
2026-10-04 00:30:00 +02:00

149 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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([]);
});
});