Free a redrawn picture only once no shader names it (#293)
Editing a vector drawing made Pixi warn "destroyed while still bound
to a shader", twice with every change, once it had begun. It began
where an object wears the drawing through a shader asset. Each
change draws the picture anew and destroyed the one before it, but
the mesh of that object keeps its picture bound until it syncs. The
issue also asks for the button "Add shape" to go: a shape without
points cannot be given any, the Pen draws a new one.
- GeneratedPictures takes two more callbacks. inUse says that a
picture cannot be freed yet, soon runs a retry once whoever asked
is done.
- A replaced picture that is still in use waits in a list of its
own. It is freed right after the redraw that asked, as a rule. On
a later ask where its wearer was not part of that redraw.
- TextureLibrary tells a bound picture by the listeners for change
on its source. Under WebGL the bind groups are the only ones that
listen there. The retry is a microtask.
- The rule is asked for every drawn picture alike, whoever wears it.
- The inspector of a drawing and of a shape has no "Add shape" any
more. "Delete shapes" for several selected shapes stays.
Decided here:
- Under the list of shapes a second note says "The Pen draws a new
shape." while only the asset is selected.
Found on the way:
- Reproduced on a throwaway copy of the project the report came
from: 6 warnings for 3 changes with the shader override on the
object, none without it. After the change none, and no replaced
picture is left waiting.
- The fixture project of the tests never showed the warning. No
object there wore a drawing through a shader.
Tests:
- Gate on melektaus-atx: 211 files, 3219 tests (3209 + 10), exit 0
- e2e 47,48 on melektaus-atx: 113 checks, all pass
- e2e 28,30,31,39,40,47,48: 350 checks, all pass, run before the
wording of the new note changed
- The first gate was red: an existing test read the wording of the
note that had changed. Only three test files had been run after
the change.
- No full run and no smokes. The sum of 1668 (1667 + 1) is reckoned,
1667 is what CI counted for ec2fd90.
- Ten unit tests, five of them against the real bind group of Pixi
in tests/viewport/bound-pictures.test.ts
- One e2e check in block 47b-ii of module 47: a drawing worn through
a shader, three changes, no warning. The check on "Add shape" now
reads that there is no such button.
- Nine unit counter-probes and two browser counter-probes all fail
their tests. Without the change the new check counts 6 warnings.
- Not checked: a tilemap or a particle emitter holding a picture
bound the same way. Nothing was looked at after the build, the
new note included.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ec2fd9084f
commit
9e869df063
11 files changed
+360
-42
No files matched your search
@@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1667 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1668 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -130,6 +130,8 @@ export function renderHooks({ app, state, editor }: HookContext) {
|
||||
kept: texture !== null,
|
||||
};
|
||||
},
|
||||
/** Drawn pictures whose revision moved on while a shader still named them (#293); freed on a later ask. */
|
||||
picturesWaiting: () => textureLibrary.waitingPictures(),
|
||||
fps: () => editor.fps,
|
||||
/**
|
||||
* What the render loop did: frames actually drawn, and whether it is currently awake. The
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
*/
|
||||
import {
|
||||
AddVectorLayerCommand,
|
||||
AddVectorShapeCommand,
|
||||
CombineVectorShapesCommand,
|
||||
DuplicateVectorShapeCommand,
|
||||
MoveVectorLayerCommand,
|
||||
@@ -66,7 +65,7 @@ import { removeShapes } from '../actions';
|
||||
import { FramePreview } from '../framePreview';
|
||||
import { listFold } from '../sectionFolds';
|
||||
import { type EditorState, shapeSelection } from '../state';
|
||||
import { activeLayerOf, activeVectorLayer, pickVectorLayer } from '../vectorart/activeLayer';
|
||||
import { activeVectorLayer, pickVectorLayer } from '../vectorart/activeLayer';
|
||||
import { groupBlocker, groupShapes, groupsToDissolve, ungroupShapes } from '../vectorart/grouping';
|
||||
import { GradientBar } from '../vectorart/GradientBar';
|
||||
import { ShapeList } from '../vectorart/ShapeList';
|
||||
@@ -276,8 +275,8 @@ export function buildShapeListSection(host: InspectorHost, asset: VectorArtAsset
|
||||
addLayerList(host, asset, '');
|
||||
addShapeList(host, asset, '', []);
|
||||
addNote(host, 'The top row is drawn over the ones below it.', 'small');
|
||||
addGap(host);
|
||||
host.body.add(addShapeButton(host, asset.id));
|
||||
// No button adds a shape (#293): one without points could not be given any.
|
||||
addNote(host, 'The Pen draws a new shape.', 'small');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -358,16 +357,17 @@ export function buildShapeSection(
|
||||
);
|
||||
} else addNote(host, 'Without an outline only the fill is drawn.', 'small');
|
||||
|
||||
// One shape goes by the cross on its row; several at once only from here. There is no button
|
||||
// that adds one (#293): a shape without points could not be given any, the Pen draws a new one.
|
||||
if (shapeIds.length > 1) {
|
||||
addGap(host);
|
||||
// One shape goes by the cross on its row; several at once only from here.
|
||||
if (shapeIds.length > 1)
|
||||
host.body.add(
|
||||
new Button('Delete shapes', {
|
||||
variant: 'danger',
|
||||
onClick: () => removeShapes(host.state, assetId, shapeIds),
|
||||
}),
|
||||
);
|
||||
host.body.add(addShapeButton(host, assetId));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -596,17 +596,6 @@ function removeShape(host: InspectorHost, assetId: string, itemId: string): void
|
||||
host.state.select(rest.length > 0 ? shapeSelection(assetId, rest) : { kind: 'asset', assetId });
|
||||
}
|
||||
|
||||
function addShapeButton(host: InspectorHost, assetId: string): Button {
|
||||
return new Button('Add shape', {
|
||||
onClick: () => {
|
||||
const asset = findAssetOf(host.state.project, assetId, 'vectorart');
|
||||
const layer = asset && activeLayerOf(host.state, asset);
|
||||
const command = new AddVectorShapeCommand(assetId, [], undefined, layer?.id);
|
||||
if (host.state.execute(command)) host.state.select(shapeSelection(assetId, [command.shapeId]));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The name row. Not `addNameField`: the shape's name also stands in the list right above it, and a
|
||||
* blank one is refused by the command, which cannot put the old text back into the field.
|
||||
|
||||
@@ -42,7 +42,7 @@ export class ShapeList extends SortableList {
|
||||
* the row pressed and whether it was pressed with Ctrl or Shift, which adds the shape to the
|
||||
* selection or takes it out (#187). `onDrop` is told where a dragged row landed. `onDuplicate` and
|
||||
* `onRemove` are the marks at the end of a row, `onRename` a name edited in its row. There is no
|
||||
* plus: a shape is drawn with the Pen, and "Add shape" stays below.
|
||||
* plus: a shape is drawn with the Pen.
|
||||
*/
|
||||
constructor(
|
||||
private readonly state: EditorState,
|
||||
|
||||
@@ -29,10 +29,15 @@ export class TextureLibrary extends FileLibrary<Texture> {
|
||||
/** Frames cut from their sheets, keyed by file and rectangle; kept like the files. */
|
||||
private readonly crops = new Map<string, Texture>();
|
||||
/** Pictures drawn instead of uploaded (#185); a new revision replaces the one before it. */
|
||||
private readonly generated = new GeneratedPictures<Texture>((texture, key) => {
|
||||
private readonly generated = new GeneratedPictures<Texture>(
|
||||
(texture, key) => {
|
||||
this.dropCropsOf(key);
|
||||
texture.destroy(true);
|
||||
});
|
||||
},
|
||||
boundToShader,
|
||||
// After the redraw that asked: every mesh it synced wears the new picture by then.
|
||||
(retry) => queueMicrotask(retry),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
super('/game-maker/data/textures', 'textures');
|
||||
@@ -97,6 +102,11 @@ export class TextureLibrary extends FileLibrary<Texture> {
|
||||
return this.loaded.get(file) ?? this.generated.peek(file);
|
||||
}
|
||||
|
||||
/** The keys of drawn pictures that are replaced but still bound to a shader (#293, test hook). */
|
||||
waitingPictures(): string[] {
|
||||
return this.generated.waitingKeys();
|
||||
}
|
||||
|
||||
/**
|
||||
* A frame as a texture of its own, cut the first time it is asked for. Drawn from the sheet
|
||||
* through a fill matrix it showed a row of the neighbouring cell along any edge that fell
|
||||
@@ -160,6 +170,17 @@ export class TextureLibrary extends FileLibrary<Texture> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a shader still names the picture (#293). An object with a shader asset keeps its picture
|
||||
* bound until its next sync, and so does Pixi's own shader for whatever mesh it drew last without
|
||||
* the batcher; destroying the source under either makes Pixi warn "destroyed while still bound to
|
||||
* a shader", for the source and for its sampler. Under WebGL the bind groups are the only ones
|
||||
* listening for `change` on a source, which is how one is told from here.
|
||||
*/
|
||||
function boundToShader(texture: Texture): boolean {
|
||||
return texture.source.listenerCount('change') > 0;
|
||||
}
|
||||
|
||||
function cropKey(file: string, rect: FrameRect): string {
|
||||
return `${file}|${rect.x},${rect.y},${rect.w},${rect.h}`;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
* One picture per asset, keyed by `gen:<assetId>@<revision>`. A new revision is a new key, and
|
||||
* making it frees the one the asset had before – that is the whole invalidation rule. The library
|
||||
* owns what a generator hands it: it destroys the picture when the revision moves on.
|
||||
*
|
||||
* One that is still drawn with at that moment waits (#293): whoever wears a picture learns of the
|
||||
* new revision by asking for it, so the old one is always still in its hands when the new one is
|
||||
* made. It is freed once it has been let go: right after the ask as a rule, on a later ask where
|
||||
* the wearer was not part of that redraw.
|
||||
*/
|
||||
import type { Texture } from 'pixi.js';
|
||||
import type { Asset } from '../core/model/Asset';
|
||||
@@ -17,9 +22,21 @@ export type PictureFactory<T> = (assetId: string) => T | null;
|
||||
export class GeneratedPictures<T> {
|
||||
private readonly kept = new Map<string, { assetId: string; picture: T }>();
|
||||
private factory: PictureFactory<T> | null = null;
|
||||
/** Pictures whose revision has moved on while something still drew with them. */
|
||||
private waiting: { key: string; picture: T }[] = [];
|
||||
private asked = false;
|
||||
|
||||
/** `free` gives a picture back, by key, once its asset has moved on to a newer revision. */
|
||||
constructor(private readonly free: (picture: T, key: string) => void) {}
|
||||
/**
|
||||
* `free` gives a picture back, by key, once its asset has moved on to a newer revision. `inUse`
|
||||
* says that it cannot go yet; it is asked again on every later `get` until it can. `soon` runs
|
||||
* a retry once whoever asked is done – the wearer that made the picture wait has taken the new
|
||||
* one by then, so the old one does not lie about until the next redraw.
|
||||
*/
|
||||
constructor(
|
||||
private readonly free: (picture: T, key: string) => void,
|
||||
private readonly inUse: (picture: T) => boolean = () => false,
|
||||
private readonly soon: (retry: () => void) => void = () => {},
|
||||
) {}
|
||||
|
||||
/** Installs who draws; a session change swaps it, and everything drawn for the old one goes. */
|
||||
setFactory(factory: PictureFactory<T> | null): void {
|
||||
@@ -29,6 +46,7 @@ export class GeneratedPictures<T> {
|
||||
|
||||
/** The picture for this key, drawn on the first ask. Null for anything that is not a key. */
|
||||
get(key: string): T | null {
|
||||
if (this.waiting.length > 0) this.freeWaiting();
|
||||
const kept = this.kept.get(key);
|
||||
if (kept) return kept.picture;
|
||||
const parsed = parseGeneratedKey(key);
|
||||
@@ -51,10 +69,23 @@ export class GeneratedPictures<T> {
|
||||
return [...this.kept.keys()];
|
||||
}
|
||||
|
||||
/** The keys of the pictures that wait to be freed, oldest first – what a test reads. */
|
||||
waitingKeys(): string[] {
|
||||
return this.waiting.map((held) => held.key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const key of this.keys()) this.drop(key);
|
||||
}
|
||||
|
||||
private freeWaiting(): void {
|
||||
this.waiting = this.waiting.filter((held) => {
|
||||
if (this.inUse(held.picture)) return true;
|
||||
this.free(held.picture, held.key);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/** Every revision of one asset, however old. */
|
||||
private release(assetId: string): void {
|
||||
for (const [key, held] of this.kept) if (held.assetId === assetId) this.drop(key);
|
||||
@@ -64,7 +95,14 @@ export class GeneratedPictures<T> {
|
||||
const held = this.kept.get(key);
|
||||
if (!held) return;
|
||||
this.kept.delete(key);
|
||||
this.free(held.picture, key);
|
||||
if (!this.inUse(held.picture)) return this.free(held.picture, key);
|
||||
this.waiting.push({ key, picture: held.picture });
|
||||
if (this.asked) return;
|
||||
this.asked = true;
|
||||
this.soon(() => {
|
||||
this.asked = false;
|
||||
this.freeWaiting();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -212,7 +212,9 @@ describe('shape rows', () => {
|
||||
buildShapeListSection(host, findAssetOf(host.state.project, assetId, 'vectorart')!);
|
||||
// The list carries its "SHAPES" header itself.
|
||||
expect(currentShapeList()?.shownIds).toHaveLength(2);
|
||||
// The way to add is the Pen, said in the note: there is no button for it (#293).
|
||||
expect(texts(host)).toContain('The top row is drawn over the ones below it.');
|
||||
expect(texts(host)).toContain('The Pen draws a new shape.');
|
||||
});
|
||||
|
||||
it('an empty drawing says so instead of showing an empty list', () => {
|
||||
|
||||
@@ -188,15 +188,18 @@ describe('the lists of the inspector', () => {
|
||||
expect(r.state.selection).toEqual({ kind: 'asset', assetId: r.asset.id });
|
||||
});
|
||||
|
||||
it('puts a shape added from the inspector onto the layer worked on', () => {
|
||||
it('offers no button that adds a shape: the Pen draws one (#293)', () => {
|
||||
const r = rig();
|
||||
const base = r.asset.layers[0]!.id;
|
||||
r.addLayer();
|
||||
pickVectorLayer(r.state, r.asset.id, base);
|
||||
r.build();
|
||||
r.button('Add shape').onClick!();
|
||||
expect(r.asset.layers[0]!.items).toHaveLength(4);
|
||||
expect(r.asset.layers[1]!.items).toHaveLength(0);
|
||||
expect(r.button('Add shape')).toBeUndefined();
|
||||
expect(r.texts().some((line) => line.includes('The Pen draws a new shape'))).toBe(true);
|
||||
r.state.select(shapeSelection(r.asset.id, ['a']));
|
||||
r.build();
|
||||
expect(r.button('Add shape')).toBeUndefined();
|
||||
// Several shapes still go at once from here.
|
||||
r.state.select(shapeSelection(r.asset.id, ['a', 'b']));
|
||||
r.build();
|
||||
expect(r.button('Delete shapes')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
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('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([]);
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,113 @@ describe('generated pictures kept in memory', () => {
|
||||
expect(f.pictures.get('gen:a@1')).toBeNull();
|
||||
});
|
||||
|
||||
describe('a picture something still draws with (#293)', () => {
|
||||
/** Like `library`, with pictures that can be held on to. */
|
||||
function held() {
|
||||
const freed: string[] = [];
|
||||
const busy = new Set<string>();
|
||||
const pictures = new GeneratedPictures<string>(
|
||||
(picture, key) => freed.push(`${picture} ${key}`),
|
||||
(picture) => busy.has(picture),
|
||||
);
|
||||
let revision = 0;
|
||||
pictures.setFactory((assetId) => `${assetId}${++revision}`);
|
||||
return { pictures, freed, busy };
|
||||
}
|
||||
|
||||
it('keeps the old revision while it is in use and frees it on the next ask after', () => {
|
||||
const f = held();
|
||||
f.pictures.get('gen:a@1');
|
||||
f.busy.add('a1');
|
||||
expect(f.pictures.get('gen:a@2')).toBe('a2');
|
||||
// Gone from what is handed out, but not freed.
|
||||
expect(f.pictures.keys()).toEqual(['gen:a@2']);
|
||||
expect(f.pictures.peek('gen:a@1')).toBeNull();
|
||||
expect(f.freed).toEqual([]);
|
||||
expect(f.pictures.waitingKeys()).toEqual(['gen:a@1']);
|
||||
// Still held: asking again changes nothing.
|
||||
f.pictures.get('gen:a@2');
|
||||
expect(f.freed).toEqual([]);
|
||||
// Let go: any ask frees it, also one for a picture that is already there.
|
||||
f.busy.delete('a1');
|
||||
f.pictures.get('gen:a@2');
|
||||
expect(f.freed).toEqual(['a1 gen:a@1']);
|
||||
expect(f.pictures.waitingKeys()).toEqual([]);
|
||||
// And only once.
|
||||
f.pictures.get('gen:a@2');
|
||||
f.pictures.get('gen:a@3');
|
||||
expect(f.freed).toEqual(['a1 gen:a@1', 'a2 gen:a@2']);
|
||||
});
|
||||
|
||||
it('frees what waits in the order it was replaced, and leaves the one still held', () => {
|
||||
const f = held();
|
||||
f.pictures.get('gen:a@1');
|
||||
f.busy.add('a1');
|
||||
f.pictures.get('gen:a@2');
|
||||
f.busy.add('a2');
|
||||
f.pictures.get('gen:a@3');
|
||||
expect(f.pictures.waitingKeys()).toEqual(['gen:a@1', 'gen:a@2']);
|
||||
f.busy.delete('a2');
|
||||
f.pictures.get('gen:a@3');
|
||||
expect(f.freed).toEqual(['a2 gen:a@2']);
|
||||
expect(f.pictures.waitingKeys()).toEqual(['gen:a@1']);
|
||||
});
|
||||
|
||||
it('frees a picture nobody holds right away', () => {
|
||||
const f = held();
|
||||
f.pictures.get('gen:a@1');
|
||||
f.pictures.get('gen:a@2');
|
||||
expect(f.freed).toEqual(['a1 gen:a@1']);
|
||||
expect(f.pictures.waitingKeys()).toEqual([]);
|
||||
});
|
||||
|
||||
it('retries once whoever asked is done, and asks for that only once per wait', () => {
|
||||
const freed: string[] = [];
|
||||
const busy = new Set<string>();
|
||||
const later: (() => void)[] = [];
|
||||
const pictures = new GeneratedPictures<string>(
|
||||
(picture) => freed.push(picture),
|
||||
(picture) => busy.has(picture),
|
||||
(retry) => later.push(retry),
|
||||
);
|
||||
let revision = 0;
|
||||
pictures.setFactory((assetId) => `${assetId}${++revision}`);
|
||||
pictures.get('gen:a@1');
|
||||
pictures.get('gen:b@1');
|
||||
busy.add('a1').add('b2');
|
||||
pictures.get('gen:a@2');
|
||||
pictures.get('gen:b@2');
|
||||
expect(later).toHaveLength(1);
|
||||
// The wearer of a took the new picture during the redraw, the one of b did not.
|
||||
busy.delete('a1');
|
||||
later.shift()!();
|
||||
expect(freed).toEqual(['a1']);
|
||||
expect(pictures.waitingKeys()).toEqual(['gen:b@1']);
|
||||
// What still waits is not retried on a timer: the next ask does that.
|
||||
expect(later).toHaveLength(0);
|
||||
busy.delete('b2');
|
||||
pictures.get('gen:b@2');
|
||||
expect(freed).toEqual(['a1', 'b2']);
|
||||
// A new wait asks for a retry again.
|
||||
busy.add('a3');
|
||||
pictures.get('gen:a@3');
|
||||
expect(later).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('lets a held picture wait through a change of session too', () => {
|
||||
const f = held();
|
||||
f.pictures.get('gen:a@1');
|
||||
f.pictures.get('gen:b@1');
|
||||
f.busy.add('a1');
|
||||
f.pictures.setFactory((assetId) => `${assetId}-next`);
|
||||
expect(f.freed).toEqual(['b2 gen:b@1']);
|
||||
expect(f.pictures.keys()).toEqual([]);
|
||||
f.busy.delete('a1');
|
||||
expect(f.pictures.get('gen:c@1')).toBe('c-next');
|
||||
expect(f.freed).toEqual(['b2 gen:b@1', 'a1 gen:a@1']);
|
||||
});
|
||||
});
|
||||
|
||||
it('answers peek only with what has been drawn', () => {
|
||||
const f = library();
|
||||
expect(f.pictures.peek('gen:a@1')).toBeNull();
|
||||
|
||||
+58
-10
@@ -176,6 +176,46 @@ export async function vectorArt(t) {
|
||||
const repainted = await at(48, 200);
|
||||
check('the sprite shows the changed fill', colourDistance(repainted, REDRAWN) < 14, repainted);
|
||||
|
||||
// 47b-ii. The object wears the drawing through a shader asset (#293). Its shader names the picture
|
||||
// until the mesh syncs, which is after the new revision was drawn: destroyed there, Pixi
|
||||
// warned for the source and for its sampler with every single change.
|
||||
const boundLogFrom = t.consoleLog.length;
|
||||
await mutate(`
|
||||
Object.assign(__gm.state.activeScene.objects.find((o) => o.id === 'va-object').overrides,
|
||||
{ shader: 'adjust-colors', saturation: 0 })`);
|
||||
await settle();
|
||||
await sleep(300);
|
||||
const waiting = [];
|
||||
for (const fill of [0x3050a0, 0x70b030, REDRAWN.replace('#', '0x')]) {
|
||||
await mutate(`
|
||||
const art = __gm.state.project.assets.find((a) => a.id === 'va-art');
|
||||
art.layers[0].items[0].fill = ${fill}`);
|
||||
await settle();
|
||||
await sleep(200);
|
||||
waiting.push((await json('__gm.picturesWaiting()')).length);
|
||||
}
|
||||
const boundWarnings = t.consoleLog.slice(boundLogFrom).filter((line) => line.includes('BindGroup'));
|
||||
const afterShaded = await json("__gm.drawnPicture('va-art')");
|
||||
// Saturation 0 turns the yellow field grey: the shader is what draws the sprite here.
|
||||
const grey = await at(48, 200);
|
||||
const [red, green, blue] = [1, 3, 5].map((i) => parseInt(grey.slice(i, i + 2), 16));
|
||||
const shaded = Math.max(red, green, blue) - Math.min(red, green, blue) < 12;
|
||||
check(
|
||||
'a drawing worn through a shader is redrawn without Pixi warning of a destroyed texture',
|
||||
shaded &&
|
||||
boundWarnings.length === 0 &&
|
||||
// The replaced picture is freed once the shader took the new one: none is left lying.
|
||||
waiting.every((count) => count === 0) &&
|
||||
afterShaded.kept,
|
||||
JSON.stringify({ grey, warnings: boundWarnings.length, waiting, file: afterShaded?.file }),
|
||||
);
|
||||
await mutate(`
|
||||
const shadedObject = __gm.state.activeScene.objects.find((o) => o.id === 'va-object');
|
||||
delete shadedObject.overrides.shader;
|
||||
delete shadedObject.overrides.saturation`);
|
||||
await settle();
|
||||
await sleep(300);
|
||||
|
||||
// 47c. The tab: the pen draws a triangle and closes it.
|
||||
await evaluate("__gm.openDocument('asset:va-art')");
|
||||
await settle();
|
||||
@@ -424,18 +464,28 @@ export async function vectorArt(t) {
|
||||
JSON.stringify(await shapeIds()),
|
||||
);
|
||||
|
||||
// A spare shape for the two checks below: the duplicate mark makes one and selects it. The
|
||||
// inspector has no button that adds an empty shape any more (#293) – the Pen draws a new one.
|
||||
const before47e = (await shapeIds()).length;
|
||||
await clickRowButton('Add shape');
|
||||
const spareCopy = async () => {
|
||||
const known = await shapeIds();
|
||||
const mark = await json("__gm.vectorArt.shapeDuplicateAt('va-badge')");
|
||||
if (mark) await click(mark.x, mark.y);
|
||||
await settle();
|
||||
const added = await shapeIds();
|
||||
return (await shapeIds()).find((id) => !known.includes(id)) ?? null;
|
||||
};
|
||||
const added = await spareCopy();
|
||||
check(
|
||||
'Add shape puts an empty shape on top and selects it',
|
||||
added.length === before47e + 1 && (await json('__gm.vectorArt.selected()')) === added.at(-1),
|
||||
JSON.stringify({ added: added.length, selected: await json('__gm.vectorArt.selected()') }),
|
||||
'the inspector offers no Add shape button',
|
||||
added !== null &&
|
||||
(await json('__gm.vectorArt.selected()')) === added &&
|
||||
(await json("__gm.inspectorButton('Add shape')")) === null &&
|
||||
(await json("__gm.inspectorButton('To front')")) !== null,
|
||||
JSON.stringify({ added, selected: await json('__gm.vectorArt.selected()') }),
|
||||
);
|
||||
|
||||
// The cross on its row takes that shape out again, and the selection with it.
|
||||
const cross = await json(`__gm.vectorArt.shapeRemoveAt(${JSON.stringify(added.at(-1))})`);
|
||||
const cross = await json(`__gm.vectorArt.shapeRemoveAt(${JSON.stringify(added)})`);
|
||||
if (cross) await click(cross.x, cross.y);
|
||||
await settle();
|
||||
const afterCross = await shapeIds();
|
||||
@@ -443,16 +493,14 @@ export async function vectorArt(t) {
|
||||
'the cross on a shape row removes that shape and nothing else',
|
||||
cross !== null &&
|
||||
afterCross.length === before47e &&
|
||||
!afterCross.includes(added.at(-1)) &&
|
||||
!afterCross.includes(added) &&
|
||||
(await json('__gm.vectorArt.selected()')) === '',
|
||||
JSON.stringify({ cross, afterCross, selected: await json('__gm.vectorArt.selected()') }),
|
||||
);
|
||||
|
||||
// Delete takes a shape away however it was picked. A row of the list leaves nothing focused, so the
|
||||
// key reaches the editor's shortcuts rather than the canvas.
|
||||
await clickRowButton('Add shape');
|
||||
await settle();
|
||||
const spare = (await shapeIds()).at(-1);
|
||||
const spare = await spareCopy();
|
||||
const spareRow = await json(`__gm.vectorArt.shapeRowAt(${JSON.stringify(spare)})`);
|
||||
await click(spareRow.x + spareRow.w / 2, spareRow.y + spareRow.h / 2);
|
||||
await settle();
|
||||
|
||||
Reference in new issue
Block a user