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>
139 lines
5.3 KiB
TypeScript
139 lines
5.3 KiB
TypeScript
/**
|
||
* The drawn half of the picture path (#185): everything that keeps a generated picture in memory,
|
||
* without touching the GPU, so the bookkeeping can be measured without a browser.
|
||
*
|
||
* 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';
|
||
import { parseGeneratedKey } from '../core/model/generated';
|
||
import { findAsset, type Project } from '../core/model/Project';
|
||
|
||
/** Draws the picture of one asset, or null while it cannot be drawn. */
|
||
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. `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, key: string) => 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 {
|
||
this.clear();
|
||
this.factory = factory;
|
||
}
|
||
|
||
/** 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);
|
||
if (!parsed || !this.factory) return null;
|
||
const picture = this.factory(parsed.assetId);
|
||
// No picture is not an error and is not remembered: an asset may be drawn a frame later.
|
||
if (picture === null) return null;
|
||
this.release(parsed.assetId);
|
||
this.kept.set(key, { assetId: parsed.assetId, picture });
|
||
return picture;
|
||
}
|
||
|
||
/** What is in memory, without drawing anything. */
|
||
peek(key: string): T | null {
|
||
return this.kept.get(key)?.picture ?? null;
|
||
}
|
||
|
||
/** The keys held, in the order they were drawn – what a test reads to see a revision go. */
|
||
keys(): string[] {
|
||
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, held.key)) 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);
|
||
}
|
||
|
||
private drop(key: string): void {
|
||
const held = this.kept.get(key);
|
||
if (!held) return;
|
||
this.kept.delete(key);
|
||
if (!this.inUse(held.picture, key)) 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();
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Who draws an asset type, keyed by type as a plain string for the same reason the readers in
|
||
* `core/model/generated.ts` are: a plugin may bring a type of its own.
|
||
*/
|
||
export type PictureGenerator = (asset: Asset, project: Project) => Texture | null;
|
||
|
||
const generators = new Map<string, PictureGenerator>();
|
||
|
||
export function registerPictureGenerator(type: string, draw: PictureGenerator): () => void {
|
||
generators.set(type, draw);
|
||
return () => {
|
||
if (generators.get(type) === draw) generators.delete(type);
|
||
};
|
||
}
|
||
|
||
function pictureGeneratorFor(type: string): PictureGenerator | null {
|
||
return generators.get(type) ?? null;
|
||
}
|
||
|
||
/**
|
||
* The factory the library runs with: it looks the asset up in whatever project is open at that
|
||
* moment and hands it to the generator of its type. Nothing is drawn for an asset that is gone.
|
||
*/
|
||
export function projectPictureFactory(projectOf: () => Project | null): PictureFactory<Texture> {
|
||
return (assetId) => {
|
||
const project = projectOf();
|
||
const asset = project ? findAsset(project, assetId) : null;
|
||
return project && asset ? (pictureGeneratorFor(asset.type)?.(asset, project) ?? null) : null;
|
||
};
|
||
}
|