Files
feuerwerk/src/viewport/generatedPictures.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

139 lines
5.3 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.
/**
* 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;
};
}