Refactoring round two: the particle and shader compilers share emitGraph.ts (cycle detection, duplicate names, emit order) and keep only their rules. FontLibrary and TextureLibrary sit on FileLibrary (base URL, loading/failed bookkeeping, listeners); SoundLibrary keeps its own contract. The three Create*Command share ImportFileCommand, the three renames share RenameCommand - class names stay. bandBounds() in framing.ts and portNameField() in portSection.ts replace in-file clones, and three comments that told history now state the rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
116 lines
4.6 KiB
TypeScript
116 lines
4.6 KiB
TypeScript
import { Assets, CanvasSource, Texture } from 'pixi.js';
|
||
import type { FrameRect, TextureRef } from '../core/model/texture';
|
||
import { type EdgeMode, SHEET_EDGE } from '../core/shader/nodes';
|
||
import { FileLibrary } from './FileLibrary';
|
||
|
||
/**
|
||
* Pixi turns `clamp-to-edge` into `repeat` on every texture a Graphics fill draws with
|
||
* (`generateTextureFillMatrix`), and a fill samples past the shape's edge – MSAA evaluates a partly
|
||
* covered pixel at its centre. `mirror-repeat` is left alone and reads like clamp within a texel.
|
||
*/
|
||
const EDGE_MODE = 'mirror-repeat';
|
||
|
||
/**
|
||
* A shader may want the picture to continue differently past its border (the Output's Edge). The
|
||
* sampler state lives on the source – WebGL takes no style from the shader – so that is a cut of
|
||
* its own with that address mode.
|
||
*/
|
||
const ADDRESS_MODE: Record<EdgeMode, 'repeat' | 'mirror-repeat' | 'clamp-to-edge'> = {
|
||
mirror: EDGE_MODE,
|
||
repeat: 'repeat',
|
||
clamp: 'clamp-to-edge',
|
||
};
|
||
|
||
/** Keeps uploaded images on the GPU; callers never await and get a redraw when the picture lands. */
|
||
export class TextureLibrary extends FileLibrary<Texture> {
|
||
private readonly loaded = new Map<string, Texture>();
|
||
/** Frames cut from their sheets, keyed by file and rectangle; kept like the files. */
|
||
private readonly crops = new Map<string, Texture>();
|
||
|
||
constructor() {
|
||
super('/game-maker/data/textures', 'textures');
|
||
}
|
||
|
||
/**
|
||
* The picture if it is in memory, otherwise null while it loads. The scale mode belongs to the
|
||
* shared source, so two assets naming the same file with different `smooth` fight over it and
|
||
* the last one drawn wins; upload avoids that by reusing the existing asset for a known file.
|
||
*/
|
||
get(ref: TextureRef, edge: EdgeMode = SHEET_EDGE): Texture | null {
|
||
const sheet = this.load(ref.file);
|
||
if (!sheet) return null;
|
||
const rect = ref.rect ?? (edge === SHEET_EDGE ? null : wholeOf(sheet));
|
||
const texture = rect ? this.crop(ref.file, sheet, rect, edge) : sheet;
|
||
if (!texture) return null;
|
||
const mode = ref.smooth ? 'linear' : 'nearest';
|
||
if (texture.source.scaleMode !== mode) {
|
||
texture.source.scaleMode = mode;
|
||
// Pixi's `scaleMode` setter leaves the style's cached `_sharedResourceId` stale, so the old
|
||
// GPU sampler keeps being used until a reload; `update()` drops the id.
|
||
texture.source.style.update();
|
||
}
|
||
return texture;
|
||
}
|
||
|
||
/**
|
||
* The picture if it is in memory, otherwise null while it loads – without touching the scale
|
||
* mode, which belongs to whoever wears the texture. A thumbnail must not decide the filter of
|
||
* a picture the scene is drawing with.
|
||
*/
|
||
load(file: string): Texture | null {
|
||
const texture = this.loaded.get(file);
|
||
if (!texture) {
|
||
this.startLoading(file);
|
||
return null;
|
||
}
|
||
return texture;
|
||
}
|
||
|
||
/** The picture, or one of its cut frames, if in memory – without a load or a scale-mode change. */
|
||
peek(file: string, rect: FrameRect | null = null): Texture | null {
|
||
if (rect) return this.crops.get(cropKey(file, rect)) ?? null;
|
||
return this.loaded.get(file) ?? null;
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
* inside a screen pixel; a cut has no neighbour to show.
|
||
*/
|
||
private crop(file: string, sheet: Texture, rect: FrameRect, edge: EdgeMode): Texture | null {
|
||
if (rect.w <= 0 || rect.h <= 0) return null;
|
||
const key = edge === SHEET_EDGE ? cropKey(file, rect) : `${cropKey(file, rect)}|${edge}`;
|
||
let texture = this.crops.get(key);
|
||
if (!texture) {
|
||
const canvas = new OffscreenCanvas(rect.w, rect.h);
|
||
const picture = sheet.source.resource as CanvasImageSource;
|
||
canvas.getContext('2d')?.drawImage(picture, rect.x, rect.y, rect.w, rect.h, 0, 0, rect.w, rect.h);
|
||
texture = new Texture({
|
||
source: new CanvasSource({ resource: canvas, addressMode: ADDRESS_MODE[edge] }),
|
||
});
|
||
this.crops.set(key, texture);
|
||
}
|
||
return texture;
|
||
}
|
||
|
||
protected fetchFile(url: string): Promise<Texture> {
|
||
return Assets.load<Texture>(url);
|
||
}
|
||
|
||
protected keep(file: string, texture: Texture): void {
|
||
texture.source.style.addressMode = EDGE_MODE;
|
||
texture.source.style.update();
|
||
this.loaded.set(file, texture);
|
||
}
|
||
}
|
||
|
||
function cropKey(file: string, rect: FrameRect): string {
|
||
return `${file}|${rect.x},${rect.y},${rect.w},${rect.h}`;
|
||
}
|
||
|
||
function wholeOf(sheet: Texture): FrameRect {
|
||
return { x: 0, y: 0, w: sheet.source.pixelWidth, h: sheet.source.pixelHeight };
|
||
}
|
||
|
||
export const textureLibrary = new TextureLibrary();
|