Textures: Linear/Nearest labels; prove the filter pass does not resample pixel art (#68)
game-maker / suite (push) Successful in 8m13s

Dropdown labels are now Linear (default) and Nearest; the stored values
linear/nearest are unchanged, old project files load as before.

e2e 12c-ii measures in Play at camera zoom 1.37 (screen scale 1.434): a
Nearest edge sampled across seven pixels is byte-identical with and without
a neutral Adjust colors shader, and brightness 0.5 on the same path darkens,
so the pass really runs there. New hook __gm.gameRect(id).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-13 12:53:21 +02:00
1 parent 3e99480b55
commit 06e1494f94
4 files changed
+63 -6

No files matched your search

+59 -3
View File
@@ -40,7 +40,7 @@ const PASTE_SPARE = `(async () => {
export async function textures(t) {
const { check, sleep, json, evaluate, shot, mutate, press } = t;
const { api, click, clickText, doubleClick, locate, undo } = t;
const { api, click, clickText, doubleClick, locate, undo, play, stop } = t;
// The previous step leaves a picker open and a text field focused, and paste is deliberately
// ignored while an input has focus, so that Ctrl+V inside a field still edits text.
@@ -170,7 +170,7 @@ export async function textures(t) {
const hard = await sample('nearest');
const pure = (c) => colourDistance(c, '#e63c3c') < 60 || colourDistance(c, '#3cc85a') < 60;
check(
'pixel-art filtering keeps the border between two quadrants hard',
'nearest filtering keeps the border between two quadrants hard',
hard.pixels.every(pure),
JSON.stringify(hard),
);
@@ -180,11 +180,67 @@ export async function textures(t) {
// a working switch drifts 425, a hung one 0, and 30 even if every channel were off by two.
const drift = hard.pixels.reduce((sum, c, i) => sum + colourDistance(c, soft.pixels[i]), 0);
check(
'switching the filter back to smooth blends the border without a reload',
'switching the filter back to linear blends the border without a reload',
soft.pixels.some((c) => !pure(c)) && drift > 150,
JSON.stringify({ drift, nearest: hard, linear: soft }),
);
await shot('33-texture-filter-smooth');
// 12c-ii. A shader is a filter pass: the sprite goes into an intermediate texture and back. At a
// camera zoom that puts the picture on a fractional pixel grid, that copy must not resample the
// nearest-filtered border. Measured in play, where the camera's zoom is the world scale; one play
// per setting, since the play world is built from the project when it starts.
await mutate(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.filter = 'nearest'`,
);
const placed = await json(
"(() => { const o = __gm.state.activeScene.objects.find((o) => o.id === 'otex'); return { x: o.position.x, y: o.position.y }; })()",
);
await mutate(`
const o = __gm.state.activeScene.objects.find((o) => o.id === 'otex');
o.position.x = 0; o.position.y = 0;
__gm.state.project.assets.find((a) => a.id === 'cam').props.zoom = 1.37`);
const played = async (patch) => {
await mutate(
`Object.assign(__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props, ${JSON.stringify(patch)})`,
);
await play();
await sleep(500);
const rect = await json("__gm.gameRect('otex')");
const y = Math.round(rect.y - rect.h * 0.25);
const pixels = [];
for (let dx = -3; dx <= 3; dx++)
pixels.push(await json(`__gm.pixelAt(${Math.round(rect.x + dx)}, ${y})`));
await stop();
return { pixels, scale: rect.w / 320 };
};
const bare = await played({ shader: '' });
const filtered = await played({ shader: 'adjust-colors' });
const darkened = await played({ shader: 'adjust-colors', brightness: 0.5 });
check(
'at a fractional camera zoom the nearest border is still hard in play',
Number.isInteger(bare.scale) === false && bare.pixels.every(pure),
JSON.stringify(bare),
);
check(
'a neutral shader draws the very same pixels there: the filter pass does not resample',
JSON.stringify(filtered.pixels) === JSON.stringify(bare.pixels),
JSON.stringify({ bare: bare.pixels, filtered: filtered.pixels }),
);
// Without this, "the same" could also mean the shader never ran on that path.
check(
'and the shader does run on that path: brightness 0.5 darkens every one of them',
darkened.pixels.every((c, i) => colourDistance(c, bare.pixels[i]) > 100 && !pure(c)),
JSON.stringify(darkened),
);
await mutate(`
const o = __gm.state.activeScene.objects.find((o) => o.id === 'otex');
o.position.x = ${placed.x}; o.position.y = ${placed.y};
__gm.state.project.assets.find((a) => a.id === 'cam').props.zoom = 1;
const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite');
sprite.props.shader = ''; delete sprite.props.brightness;
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.filter = 'linear'`);
await sleep(400);
await mutate(enlarge(160, 120));
await sleep(400);