Textures: Linear/Nearest labels; prove the filter pass does not resample pixel art (#68)
game-maker / suite (push) Successful in 8m13s
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:
1 parent
3e99480b55
commit
06e1494f94
4 files changed
+63
-6
No files matched your search
@@ -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, 720 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 723 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
|
||||
|
||||
@@ -138,8 +138,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
kind: 'enum',
|
||||
default: 'linear',
|
||||
options: [
|
||||
{ value: 'linear', label: 'Smooth' },
|
||||
{ value: 'nearest', label: 'Pixel art' },
|
||||
{ value: 'linear', label: 'Linear' },
|
||||
{ value: 'nearest', label: 'Nearest' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -224,6 +224,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
objectRect: (id: string) => viewport.objectRect(id),
|
||||
/** The same for the Game tab – through the camera, so what the player sees. */
|
||||
gameScreen: (id: string) => gameView.objectScreen(id),
|
||||
gameRect: (id: string) => gameView.objectRect(id),
|
||||
gameCenter: () => centreOf(gameView),
|
||||
graph: {
|
||||
current: () => graphEditor.currentAssetId,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user