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

+1 -1
View File
@@ -423,7 +423,7 @@ export class AnimationCanvas extends Widget {
}
drawChecker(g, rect.x, rect.y, rect.w, rect.h, this.w, this.h);
const texture = textureLibrary.get(picture);
if (texture) g.rect(rect.x, rect.y, rect.w, rect.h).fill(spriteFill(texture, 0xffffff, picture));
if (texture) g.rect(rect.x, rect.y, rect.w, rect.h).fill(spriteFill(texture, 0xffffff));
return rect;
}
+14 -3
View File
@@ -1,6 +1,7 @@
import { type Application, Color, type ColorSource, type Container, Rectangle, Text } from 'pixi.js';
import { resolveObjects } from '../core/model/instance';
import { findObjectHolder } from '../core/model/Project';
import type { FrameRect } from '../core/model/texture';
import { activeDrag, Button, ui, Widget } from '../ui';
import { textureLibrary } from '../viewport/TextureLibrary';
import type { CentreTab, Editor } from './Editor';
@@ -348,9 +349,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
const hex = (value: number | undefined) => (value ?? 0).toString(16).padStart(2, '0');
return `#${hex(pixels[0])}${hex(pixels[1])}${hex(pixels[2])}`;
},
/** The filter the GPU sampler actually got, next to the model's – only the driver knows the former. */
textureFilter: (file: string) => {
const texture = textureLibrary.peek(file);
/** The filter and wrap the GPU sampler actually got, next to the model's – only the driver knows the former. */
textureFilter: (file: string, rect: FrameRect | null = null) => {
const texture = textureLibrary.peek(file, rect);
if (!texture) return null;
const source = texture.source;
const renderer = app.renderer as unknown as {
@@ -363,14 +364,24 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
(source as unknown as { _gpuData: Record<number, unknown> })._gpuData[renderer.uid],
);
let magFilter = 0;
let wrapT = 0;
if (gl) {
// Through Pixi rather than a raw `bindTexture`, so its idea of what is bound stays true.
renderer.texture.bindSource(source, 0);
magFilter = gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER) as number;
wrapT = gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T) as number;
}
return {
scaleMode: source.scaleMode,
gpu: magFilter === gl?.NEAREST ? 'nearest' : magFilter === gl?.LINEAR ? 'linear' : 'none',
wrap:
wrapT === gl?.CLAMP_TO_EDGE
? 'clamp'
: wrapT === gl?.REPEAT
? 'repeat'
: wrapT === gl?.MIRRORED_REPEAT
? 'mirror'
: 'none',
uploaded,
};
},
+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();
+5 -14
View File
@@ -1,12 +1,10 @@
/** Everything here draws in world units at the item's origin. */
import { type Graphics, Matrix, Rectangle, type Texture } from 'pixi.js';
import type { TextureRef } from '../core/model/texture';
import { type Graphics, Rectangle, type Texture } from 'pixi.js';
import { BEHAVIOR_SYMBOL, CONTAINER_SYMBOL, textAnchor } from '../core/properties/bounds';
import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider';
import { Theme } from '../ui';
import type { DrawItem } from './items';
import { type ShaderSource, syncShader } from './shaders';
import { frameFillMatrix } from './textureFill';
import type { TextureLibrary } from './TextureLibrary';
/** Screen-pixel half-width of the grab band along a collider's outline. */
@@ -29,7 +27,7 @@ export function drawItem(
// Null while the picture is still loading: the box shows its plain colour until then, and
// once the picture is there the colour tints it.
const texture = item.texture ? textures.get(item.texture) : null;
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(spriteFill(texture, color, item.texture));
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(spriteFill(texture, color));
syncShader(node, item.props, shaders);
return;
}
@@ -92,19 +90,12 @@ export function drawItem(
}
}
/**
* Only a frame gets a matrix: an unmatrixed fill already spreads the whole image over the shape,
* and skipping it spares Pixi an inversion per redraw. See `textureFill.ts`.
*/
/** A fill spreads the whole texture over the shape; a frame is a texture of its own (`TextureLibrary`). */
export function spriteFill(
texture: Texture | null,
color: number,
ref: TextureRef | null,
): number | { texture: Texture; color: number; matrix?: Matrix } {
if (!texture || !ref) return color;
const m = ref.rect && frameFillMatrix(ref.rect, ref.sourceWidth, ref.sourceHeight);
if (!m) return { texture, color };
return { texture, color, matrix: new Matrix(m.a, m.b, m.c, m.d, m.tx, m.ty) };
): number | { texture: Texture; color: number } {
return texture ? { texture, color } : color;
}
/** Hit area covering only the outline band, in local coordinates and world units. */
-32
View File
@@ -1,32 +0,0 @@
import type { FrameRect } from '../core/model/texture';
/** The six numbers of a 2D affine matrix, in Pixi's `Matrix(a, b, c, d, tx, ty)` order. */
export interface MatrixValues {
a: number;
b: number;
c: number;
d: number;
tx: number;
ty: number;
}
/**
* Pixi v8 Graphics fills ignore a `Texture`'s `frame`, so a frame must be a fill matrix. Pixi inverts it
* against the shape's bounds, hence the inverse of "unit square onto the frame".
*/
export function frameFillMatrix(
rect: FrameRect,
sourceWidth: number,
sourceHeight: number,
): MatrixValues | null {
// Without a known image size the rectangle has no reference frame: draw the whole picture.
if (sourceWidth <= 0 || sourceHeight <= 0 || rect.w <= 0 || rect.h <= 0) return null;
return {
a: sourceWidth / rect.w,
b: 0,
c: 0,
d: sourceHeight / rect.h,
tx: -rect.x / rect.w,
ty: -rect.y / rect.h,
};
}