From 06e1494f94b3a7f68c5214ac044a5eef385523d4 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 13 Sep 2026 12:53:21 +0200 Subject: [PATCH] Textures: Linear/Nearest labels; prove the filter pass does not resample pixel art (#68) 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 --- README.md | 2 +- src/core/properties/PropertySchema.ts | 4 +- src/editor/debugHook.ts | 1 + tools/e2e/12-textures.mjs | 62 +++++++++++++++++++++++++-- 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index cd4e35ad..1f424e3d 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, 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 diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 68019b0d..67ae55d6 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -138,8 +138,8 @@ const ASSET_SCHEMAS: Record = { kind: 'enum', default: 'linear', options: [ - { value: 'linear', label: 'Smooth' }, - { value: 'nearest', label: 'Pixel art' }, + { value: 'linear', label: 'Linear' }, + { value: 'nearest', label: 'Nearest' }, ], }, ], diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 99e7f6c5..6364c280 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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, diff --git a/tools/e2e/12-textures.mjs b/tools/e2e/12-textures.mjs index 846184e5..710ddbca 100644 --- a/tools/e2e/12-textures.mjs +++ b/tools/e2e/12-textures.mjs @@ -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);