From 05f7a2745e03fef580fcfb54785eeac83de61a91 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 13 Sep 2026 13:25:49 +0200 Subject: [PATCH] Frames: cut each frame into its own GPU texture, no more fill matrix (#47) 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 --- README.md | 2 +- src/editor/animation/AnimationCanvas.ts | 2 +- src/editor/debugHook.ts | 17 ++++- src/viewport/TextureLibrary.ts | 46 ++++++++++-- src/viewport/symbols.ts | 19 ++--- src/viewport/textureFill.ts | 32 --------- tests/viewport/texture-fill.test.ts | 94 ------------------------- tools/e2e/25-animation.mjs | 62 ++++++++++++++++ tools/smoke-actor.mjs | 26 +++++++ 9 files changed, 150 insertions(+), 150 deletions(-) delete mode 100644 src/viewport/textureFill.ts delete mode 100644 tests/viewport/texture-fill.test.ts diff --git a/README.md b/README.md index 1f424e3d..af739295 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 723 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 725 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/src/editor/animation/AnimationCanvas.ts b/src/editor/animation/AnimationCanvas.ts index 028bf6e0..fab80c82 100644 --- a/src/editor/animation/AnimationCanvas.ts +++ b/src/editor/animation/AnimationCanvas.ts @@ -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; } diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 6364c280..dd916b13 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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 })._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, }; }, diff --git a/src/viewport/TextureLibrary.ts b/src/viewport/TextureLibrary.ts index 4cbdb15a..56a725a1 100644 --- a/src/viewport/TextureLibrary.ts +++ b/src/viewport/TextureLibrary.ts @@ -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(); + /** Frames cut from their sheets, keyed by file and rectangle; kept like the files. */ + private readonly crops = new Map(); private readonly loading = new Set(); /** Never retried, so one broken reference cannot spam the network. */ private readonly failed = new Set(); @@ -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(`${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(); diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index ac710853..1655738d 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -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. */ diff --git a/src/viewport/textureFill.ts b/src/viewport/textureFill.ts deleted file mode 100644 index bef7f715..00000000 --- a/src/viewport/textureFill.ts +++ /dev/null @@ -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, - }; -} diff --git a/tests/viewport/texture-fill.test.ts b/tests/viewport/texture-fill.test.ts deleted file mode 100644 index 15b42dce..00000000 --- a/tests/viewport/texture-fill.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * The fill matrix that makes a sprite wear one frame of a sheet. Pinned against the identity - * `uv = inverse(matrix) ∘ normalise(shapeBounds)` rather than against the formula it uses, so - * the checks invert the matrix themselves and ask where the shape's corners land in the image. - */ -import { describe, expect, it } from 'vitest'; -import type { FrameRect } from '../../src/core/model/texture'; -import { frameFillMatrix, type MatrixValues } from '../../src/viewport/textureFill'; - -/** - * Where the normalised shape point `(u, v)` samples the image: Pixi's `inverse(style.matrix)`, - * written out for a general affine matrix so the test does not assume an axis-aligned one. - */ -function sampleAt(m: MatrixValues, u: number, v: number): { u: number; v: number } { - const det = m.a * m.d - m.b * m.c; - const dx = u - m.tx; - const dy = v - m.ty; - return { u: (m.d * dx - m.c * dy) / det, v: (m.a * dy - m.b * dx) / det }; -} - -/** Asserts that the shape's four corners sample exactly the four corners of `rect`. */ -function expectCovers(m: MatrixValues, rect: FrameRect, sourceWidth: number, sourceHeight: number): void { - const left = rect.x / sourceWidth; - const right = (rect.x + rect.w) / sourceWidth; - const top = rect.y / sourceHeight; - const bottom = (rect.y + rect.h) / sourceHeight; - for (const [u, v, expectedU, expectedV] of [ - [0, 0, left, top], - [1, 0, right, top], - [0, 1, left, bottom], - [1, 1, right, bottom], - ] as const) { - const got = sampleAt(m, u, v); - expect(got.u).toBeCloseTo(expectedU, 12); - expect(got.v).toBeCloseTo(expectedV, 12); - } -} - -/** - * Number by number rather than `toEqual`: a frame on the top or left edge produces a negative - * zero, which `toEqual` counts as a different value. - */ -function expectMatrix(m: MatrixValues, expected: MatrixValues): void { - for (const key of ['a', 'b', 'c', 'd', 'tx', 'ty'] as const) expect(m[key]).toBeCloseTo(expected[key], 12); -} - -describe('frameFillMatrix', () => { - it('declines when the image size is unknown, so the sprite keeps showing the whole picture', () => { - const rect = { x: 0, y: 0, w: 32, h: 32 }; - // What an upload that has not reported its size yet looks like in the model. - expect(frameFillMatrix(rect, 0, 0)).toBeNull(); - expect(frameFillMatrix(rect, 64, 0)).toBeNull(); - expect(frameFillMatrix(rect, -64, 64)).toBeNull(); - }); - - it('declines for a frame with no area, which cannot be spread over anything', () => { - expect(frameFillMatrix({ x: 0, y: 0, w: 0, h: 32 }, 64, 64)).toBeNull(); - expect(frameFillMatrix({ x: 0, y: 0, w: 32, h: 0 }, 64, 64)).toBeNull(); - }); - - it('maps the shape onto the top-right quadrant of a four-quadrant sheet', () => { - const rect = { x: 32, y: 0, w: 32, h: 32 }; - const m = frameFillMatrix(rect, 64, 64)!; - - // Half the sheet across: stretched twofold, shifted by one shape-width to the midpoint. - expectMatrix(m, { a: 2, b: 0, c: 0, d: 2, tx: -1, ty: 0 }); - expectCovers(m, rect, 64, 64); - }); - - it('stretches the axes independently for a non-square frame', () => { - const rect = { x: 0, y: 0, w: 16, h: 64 }; - const m = frameFillMatrix(rect, 128, 64)!; - - expectMatrix(m, { a: 8, b: 0, c: 0, d: 1, tx: 0, ty: 0 }); - expectCovers(m, rect, 128, 64); - }); - - it('offsets in frame-widths, not in pixels or UV', () => { - // Pixels, UV and frame-widths all differ here, so an offset in the wrong unit is caught. - const rect = { x: 48, y: 16, w: 16, h: 8 }; - const m = frameFillMatrix(rect, 256, 128)!; - - expectMatrix(m, { a: 16, b: 0, c: 0, d: 16, tx: -3, ty: -2 }); - expectCovers(m, rect, 256, 128); - }); - - it('is the identity for a frame covering the whole sheet', () => { - const rect = { x: 0, y: 0, w: 200, h: 100 }; - const m = frameFillMatrix(rect, 200, 100)!; - - expectMatrix(m, { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 }); - expectCovers(m, rect, 200, 100); - }); -}); diff --git a/tools/e2e/25-animation.mjs b/tools/e2e/25-animation.mjs index 5fd6dcf5..3d41a63d 100644 --- a/tools/e2e/25-animation.mjs +++ b/tools/e2e/25-animation.mjs @@ -338,6 +338,68 @@ export async function animation(t) { ); await shot('03-scene-first-frame'); + // 25j'. A frame is a texture of its own. Drawn from the sheet, an edge that landed inside a screen + // pixel showed the neighbouring cell: the partly covered pixel is evaluated at its centre, + // past the edge. Measured in play, where the background is flat. + await mutate(`__gm.state.project.assets.find((a) => a.id === 'an-sprite').props.texture = 'an-f1'`); + const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); + const fraction = (v) => v - Math.floor(v); + const edgesOf = async () => { + const r = await json("__gm.gameRect('an-box')"); + return { y: Math.round(r.y), left: r.x - r.w / 2, right: r.x + r.w / 2, scale: r.w / 80 }; + }; + // Moves the box so the edge's fraction lands where the pixel is partly covered but its centre + // lies outside; `outward` says which way the neighbouring cell would be. + const edgePixels = async (side, target, outward) => { + await t.play(); + await sleep(300); + const first = await edgesOf(); + await stop(); + await sleep(200); + const shift = (target - fraction(first[side]) + 1) % 1; + await mutate( + `__gm.state.activeScene.objects.find((o) => o.id === 'an-box').position.x += ${shift / first.scale}`, + ); + await t.play(); + await sleep(300); + const e = await edgesOf(); + const x = Math.floor(e[side]); + const edge = rgb(await json(`__gm.pixelAt(${x}, ${e.y})`)); + const outside = rgb(await json(`__gm.pixelAt(${x + outward * 2}, ${e.y})`)); + await stop(); + await sleep(200); + return { fraction: fraction(e[side]).toFixed(2), edge, outside }; + }; + // The edge pixel is the cell's green over the background and nothing else: red and blue rise + // above the background only in the cell's own proportion to the rise in green (plus rounding), + // and green rises enough to prove it is the partly covered pixel, not plain background. + const cell = rgb(CELLS[1]); + const onlyOwnColour = ({ edge, outside }) => { + const green = edge[1] - outside[1]; + return ( + green > 10 && + [0, 2].every( + (c) => edge[c] - outside[c] <= (green * (cell[c] - outside[c])) / (cell[1] - outside[1]) + 3, + ) + ); + }; + const leftEdge = await edgePixels('left', 0.75, -1); + check( + 'the left edge of a frame shows nothing of the cell to its left', + onlyOwnColour(leftEdge), + JSON.stringify(leftEdge), + ); + const rightEdge = await edgePixels('right', 0.25, 1); + check( + 'nor the right edge anything of the cell to its right', + onlyOwnColour(rightEdge), + JSON.stringify(rightEdge), + ); + await mutate( + `__gm.state.project.assets.find((a) => a.id === 'an-sprite').props.texture = ${JSON.stringify(walk.id)}`, + ); + await sleep(300); + // 25k. Play runs it: the pixels the game draws change colour over time. await t.play(); await sleep(150); diff --git a/tools/smoke-actor.mjs b/tools/smoke-actor.mjs index 5608eb95..79467229 100644 --- a/tools/smoke-actor.mjs +++ b/tools/smoke-actor.mjs @@ -225,6 +225,32 @@ await runChecks({ projectId, baseUrl, port: 9341, shotPrefix: 'gm-actor' }, asyn } check('standing still holds a single frame', standing.size === 1, `${standing.size} distinct`); + // 12. A frame's top edge shows nothing of the cell above it, wherever the edge lands inside a + // pixel. Drawn from the sheet, a partly covered edge pixel read the boots of the cell above – + // the hairline over the head. Nudged upwards until the edge sits in the lower half of a pixel. + const topEdge = async () => { + const r = await json(`__gm.gameRect('oplayer')`); + return { cx: Math.round(r.x), y0: r.y - r.h / 2 }; + }; + let edge; + let nudges = 0; + do { + await holdKey('w', 16); + await sleep(150); + edge = await topEdge(); + nudges += 1; + } while (nudges < 40 && !(edge.y0 % 1 > 0.6 && edge.y0 % 1 < 0.9)); + const facingUp = await entity('oplayer'); + const rowAt = async (y) => + json(`[-8,-6,-4,-2,0,2,4,6,8].map((dx) => __gm.pixelAt(${edge.cx} + dx, ${y}))`).then((p) => p.join(' ')); + const above = await rowAt(Math.floor(edge.y0) - 2); + const atEdge = await rowAt(Math.floor(edge.y0)); + check( + 'the top edge of a frame shows nothing of the cell above it', + facingUp.props.texture === IDLE.up && edge.y0 % 1 > 0.6 && atEdge === above, + JSON.stringify({ fraction: (edge.y0 % 1).toFixed(2), texture: facingUp.props.texture, above, atEdge }), + ); + await stop(); check('nothing threw', (await exceptions()).length === 0, (await exceptions()).join(' | ')); });