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 = { 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 { private readonly loaded = new Map(); /** Frames cut from their sheets, keyed by file and rectangle; kept like the files. */ private readonly crops = new Map(); 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 { return Assets.load(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();