Files
feuerwerk/src/viewport/TextureLibrary.ts
T
adminandClaude Opus 5 d3563897d8
game-maker / suite (push) Failing after 2m22s
game-maker / gate (push) Successful in 2m53s
Two compilers that walk a graph the same way walk it through one emitter (#85)
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>
2026-09-16 19:28:28 +02:00

116 lines
4.6 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.
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();