Put the text and boxes of the interface on device pixels
On a screen scaled to 125 % or 150 % the editor looked soft beside a page the browser draws itself. The layout counts in whole CSS pixels, and most of those fall between two device pixels there. The GPU averages both: text loses its edges, and a 1 px line becomes a pixel and a half of mixed colour. Now text and boxes are rounded to device pixels, and a line is one device pixel wide. - ui/pixelGrid.ts holds the rule. crispText() makes a text with Pixi's roundPixels, crisp() does the same for a Graphics of upright boxes, and hairline() is the width of one device pixel. - Every text of the interface is made with crispText() instead of new Text(). - crisp() only rounds where the scale is not a whole number. At 100 % and 200 % nothing changes. - drawControlBox, drawHeaderBar and drawSectionBand draw their lines a hairline wide and flush with the edge. The widgets that draw lines of their own do the same. - Two bands share exactly the line they draw, and a column still ends on a whole pixel. - The stage is nudged by 1/32 px where the scale is not whole. At 150 % every odd pixel is an exact half, and without the nudge one band came out a row shorter than its neighbours. - Curves, icons and everything of the game stay unrounded. Rounding their points would bend them. - The harness takes deviceScaleFactor as an option, and the hooks deviceColumn, edgeContrast and pixelGrid read the frame as the screen gets it. Not in here: the subpixel smoothing a browser gives its own text. Pixi draws a text into a transparent picture first, and there only greyscale smoothing is possible. Measured at 150 %, edge contrast before and after: top bar 31.5 to 47.8, the lists on the left 68.6 to 86.4, inspector 27.6 to 38.4. At 100 % it is 46.5, 90.1 and 37.9, and the picture there is the same as before pixel for pixel, apart from the fps display. Tests: - Gate on melektaus-atx: 206 files, 3114 tests (3104 + 10), exit 0 - Full run on melektaus-atx: 1655 of 1656 checks passed, smokes 24/19/21. The one that failed was a condition of the new step 62 itself: it scrolled an inspector that had nothing to scroll. The step now makes the window lower for that reading. - e2e 61,62 after that fix: 14 checks. The full run was not repeated, and CI is switched off for now. - Ten unit tests, nine in tests/ui/pixel-grid.test.ts - Eight e2e checks in the new step 62, which runs at 150 % - Eleven unit counter-probes fail tests - Four browser counter-probes fail their checks. They ran before the fix to step 62 and were not repeated for it. - Screenshots looked at for 125 % and 150 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
275e50af01
commit
32a0af6e2c
49 files changed
+582
-134
No files matched your search
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* The interface on a screen scaled to 150 %: the one thing every other step is blind to, since the
|
||||
* harness runs at 100 %, where a layout pixel is a device pixel. Text has to come out as hard as it
|
||||
* does at 100 %, and a hairline has to be one device pixel of its own colour, not one and a half
|
||||
* with the half mixed. Reloads the page twice, so the steps after it start from a fresh editor.
|
||||
*/
|
||||
export async function devicePixels(t) {
|
||||
const { check, json, cdp, navigate, select, storedObjects, wheel, exceptions } = t;
|
||||
const size = { width: await json('innerWidth'), height: await json('innerHeight') };
|
||||
const scale = (deviceScaleFactor) =>
|
||||
cdp.send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor, mobile: false });
|
||||
// The toolbar: captions on a plain ground, read at 100 % and at 150 %.
|
||||
const textEdges = () => json(`__gm.edgeContrast(0, 0, innerWidth, 30)`);
|
||||
const pickFirst = async () => {
|
||||
const holderId = await json('__gm.state.activeScene.id');
|
||||
const first = (await storedObjects())[0];
|
||||
await select({ kind: 'object', holderId, objectId: first.id, objectIds: [first.id] });
|
||||
};
|
||||
|
||||
await pickFirst();
|
||||
const plain = { grid: await json('__gm.pixelGrid()'), edges: await textEdges() };
|
||||
|
||||
await scale(1.5);
|
||||
await navigate();
|
||||
await pickFirst();
|
||||
|
||||
// 62a. The editor knows the grid it draws on.
|
||||
const grid = await json('__gm.pixelGrid()');
|
||||
check(
|
||||
'at 150 % a hairline is one device pixel wide',
|
||||
plain.grid.resolution === 1 &&
|
||||
plain.grid.hairline === 1 &&
|
||||
grid.resolution === 1.5 &&
|
||||
Math.abs(grid.hairline * 1.5 - 1) < 1e-9,
|
||||
JSON.stringify({ plain: plain.grid, grid }),
|
||||
);
|
||||
|
||||
// 62b. Both lines of every band of the inspector, read down a column of device pixels: one row
|
||||
// each, in the border's own colour, and only the band's colour between them. A band on an odd
|
||||
// layout pixel starts half-way between two device pixels, which is the case that used to blur.
|
||||
const colours = {
|
||||
border: await json(`__gm.themeColor('border')`),
|
||||
header: await json(`__gm.themeColor('panelHeader')`),
|
||||
};
|
||||
// One pixel of scroll flips every band between even and odd, so both are read. The window is made
|
||||
// too low for the inspector's rows first: one that shows them all has nothing to scroll.
|
||||
await cdp.send('Emulation.setDeviceMetricsOverride', {
|
||||
width: size.width,
|
||||
height: 420,
|
||||
deviceScaleFactor: 1.5,
|
||||
mobile: false,
|
||||
});
|
||||
await t.settle();
|
||||
const inspector = await json('__gm.inspectorRect()');
|
||||
const bands = [];
|
||||
for (const scrolled of [0, 1]) {
|
||||
if (scrolled) await wheel(inspector.x + inspector.w / 2, inspector.y + inspector.h / 2, 1);
|
||||
for (const title of ['SWITCHES', 'POSITION', 'TRANSFORM', 'PROPERTIES']) {
|
||||
const band = await json(`__gm.section(${JSON.stringify(title)})`);
|
||||
if (!band) continue;
|
||||
const { x, y, w, h } = band.rect;
|
||||
// A band cut off by the edge of the panel has no second line to read.
|
||||
if (y < inspector.y || y + h > inspector.y + inspector.h) continue;
|
||||
const column = await json(`__gm.deviceColumn(${x + w - 40}, ${y}, ${h})`);
|
||||
const runs = [];
|
||||
for (const colour of column) {
|
||||
if (runs.at(-1)?.colour === colour) runs.at(-1).rows += 1;
|
||||
else runs.push({ colour, rows: 1 });
|
||||
}
|
||||
bands.push({ title, y, runs: runs.map((run) => `${run.colour}*${run.rows}`) });
|
||||
}
|
||||
}
|
||||
const crisp = (band) =>
|
||||
band.runs.length === 3 &&
|
||||
band.runs[0] === `${colours.border}*1` &&
|
||||
band.runs[1].startsWith(`${colours.header}*`) &&
|
||||
band.runs[2] === `${colours.border}*1`;
|
||||
const odd = (band) => Math.round(band.y) % 2 === 1;
|
||||
check(
|
||||
'the lines of a band are one device pixel each, in the unmixed colour',
|
||||
bands.every(crisp) && bands.some(odd) && bands.some((band) => !odd(band)),
|
||||
JSON.stringify({ bands, colours }),
|
||||
);
|
||||
await scale(1.5);
|
||||
await t.settle();
|
||||
|
||||
// 62c. Text keeps its edges: against the same captions at 100 %, where nothing can sit between pixels.
|
||||
const edges = await textEdges();
|
||||
const kept = edges / plain.edges;
|
||||
check(
|
||||
'text is as hard-edged at 150 % as at 100 %',
|
||||
kept > 0.96,
|
||||
JSON.stringify({ plain: plain.edges, edges, kept }),
|
||||
);
|
||||
await t.shot('62-device-pixels');
|
||||
|
||||
await scale(1);
|
||||
await navigate();
|
||||
const back = await json('__gm.pixelGrid()');
|
||||
check(
|
||||
'back at 100 % without exceptions',
|
||||
back.resolution === 1 && exceptions().length === 0,
|
||||
JSON.stringify({ back, exceptions: exceptions() }),
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,7 @@ import { clipAreas } from './58-clip-areas.mjs';
|
||||
import { whilePlaying } from './59-while-playing.mjs';
|
||||
import { release } from './60-release.mjs';
|
||||
import { fullscreen } from './61-fullscreen.mjs';
|
||||
import { devicePixels } from './62-device-pixels.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -127,4 +128,5 @@ export const STEPS = [
|
||||
whilePlaying,
|
||||
release,
|
||||
fullscreen,
|
||||
devicePixels,
|
||||
];
|
||||
Reference in new issue
Block a user