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(' | ')); });