Frames: cut each frame into its own GPU texture, no more fill matrix (#47)
game-maker / suite (push) Successful in 8m18s

A frame drawn straight from the sheet showed a hairline of the neighbouring
cell along any edge that fell inside a screen pixel: with antialias on, a
partly covered pixel is still evaluated at its centre, past the edge. On top
of that Pixi rewrites a Graphics fill texture from clamp-to-edge to repeat at
draw time (generateTextureFillMatrix.mjs), so the top row of a sheet read its
bottom row.

TextureLibrary.get(ref) now cuts the rectangle out of the loaded sheet with an
OffscreenCanvas on first use and uploads the cut (key file|x,y,w,h, kept for
the session). Address mode is mirror-repeat: Pixi only rewrites clamp-to-edge,
and within a texel of the border mirror behaves like clamp, for linear as well
as nearest. Loaded sheets get it too, since whole pictures wrapped at their own
borders the same way. viewport/textureFill.ts and its test are gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-13 13:25:49 +02:00
1 parent 06e1494f94
commit 05f7a2745e
9 files changed
+150 -150

No files matched your search

+41 -5
View File
@@ -1,8 +1,15 @@
import { Assets, type Texture } from 'pixi.js';
import type { TextureRef } from '../core/model/texture';
import { Assets, CanvasSource, Texture } from 'pixi.js';
import type { FrameRect, TextureRef } from '../core/model/texture';
const DEFAULT_BASE = '/game-maker/data/textures';
/**
* 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';
/**
* Keeps uploaded images on the GPU, keyed by file name. Nothing is evicted: file names are
* content addressed, so a cached texture can never go stale. A miss is not an error – it starts
@@ -11,6 +18,8 @@ const DEFAULT_BASE = '/game-maker/data/textures';
export class TextureLibrary {
private base = DEFAULT_BASE;
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>();
private readonly loading = new Set<string>();
/** Never retried, so one broken reference cannot spam the network. */
private readonly failed = new Set<string>();
@@ -27,7 +36,8 @@ export class TextureLibrary {
* the last one drawn wins; upload avoids that by reusing the existing asset for a known file.
*/
get(ref: TextureRef): Texture | null {
const texture = this.load(ref.file);
const sheet = this.load(ref.file);
const texture = sheet && (ref.rect ? this.crop(ref.file, sheet, ref.rect) : sheet);
if (!texture) return null;
const mode = ref.smooth ? 'linear' : 'nearest';
if (texture.source.scaleMode !== mode) {
@@ -53,8 +63,9 @@ export class TextureLibrary {
return texture;
}
/** The picture if it is in memory, without starting a load or touching the scale mode. */
peek(file: string): Texture | null {
/** 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;
}
@@ -64,12 +75,33 @@ export class TextureLibrary {
return () => this.listeners.delete(fn);
}
/**
* 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): Texture | null {
if (rect.w <= 0 || rect.h <= 0) return null;
const key = cropKey(file, rect);
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: EDGE_MODE }) });
this.crops.set(key, texture);
}
return texture;
}
private startLoading(file: string): void {
if (this.loading.has(file) || this.failed.has(file)) return;
this.loading.add(file);
void Assets.load<Texture>(`${this.base}/${file}`).then(
(texture) => {
this.loading.delete(file);
texture.source.style.addressMode = EDGE_MODE;
texture.source.style.update();
this.loaded.set(file, texture);
for (const listener of this.listeners) listener();
},
@@ -82,4 +114,8 @@ export class TextureLibrary {
}
}
function cropKey(file: string, rect: FrameRect): string {
return `${file}|${rect.x},${rect.y},${rect.w},${rect.h}`;
}
export const textureLibrary = new TextureLibrary();