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,124 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { edgeContrast } from '../../src/editor/debug/sharpness';
|
||||
import { drawControlBox, drawHeaderBar } from '../../src/ui/draw';
|
||||
import {
|
||||
crisp,
|
||||
crispText,
|
||||
hairline,
|
||||
hairlineFor,
|
||||
initPixelGrid,
|
||||
offDeviceGrid,
|
||||
tieBreakFor,
|
||||
} from '../../src/ui/pixelGrid';
|
||||
import { drawSectionBand } from '../../src/ui/sections';
|
||||
|
||||
afterEach(() => initPixelGrid(1));
|
||||
|
||||
/** The rectangles a shape was drawn from, as `[x, y, w, h]`. */
|
||||
function rects(g: Graphics): number[][] {
|
||||
return g.context.instructions.flatMap((instruction) => {
|
||||
const data = instruction.data as { path?: { instructions: { action: string; data: number[] }[] } };
|
||||
return (data.path?.instructions ?? [])
|
||||
.filter((step) => step.action === 'rect' || step.action === 'roundRect')
|
||||
.map((step) => step.data.slice(0, 4));
|
||||
});
|
||||
}
|
||||
|
||||
describe('the device pixel grid', () => {
|
||||
it('names the scales at which whole layout pixels miss the device grid', () => {
|
||||
expect([1, 2, 1.25, 1.5, 1.75].map(offDeviceGrid)).toEqual([false, false, true, true, true]);
|
||||
});
|
||||
|
||||
it('makes a hairline whole device pixels wide, and never thinner than one', () => {
|
||||
expect(hairlineFor(1)).toBe(1);
|
||||
expect(hairlineFor(2)).toBe(1);
|
||||
expect(hairlineFor(1.5)).toBeCloseTo(1 / 1.5, 10);
|
||||
expect(hairlineFor(1.25)).toBeCloseTo(1 / 1.25, 10);
|
||||
expect(hairlineFor(1.75) * 1.75).toBeCloseTo(1, 10);
|
||||
expect(hairlineFor(0.5) * 0.5).toBeCloseTo(1, 10);
|
||||
initPixelGrid(1.5);
|
||||
expect(hairline()).toBeCloseTo(2 / 3, 10);
|
||||
});
|
||||
|
||||
it('nudges the stage only where ties can happen, and by far less than a device pixel', () => {
|
||||
expect(tieBreakFor(1)).toBe(0);
|
||||
expect(tieBreakFor(2)).toBe(0);
|
||||
for (const res of [1.25, 1.5, 1.75]) {
|
||||
expect(tieBreakFor(res)).toBeGreaterThan(0.01);
|
||||
expect(tieBreakFor(res) * res).toBeLessThan(0.1);
|
||||
}
|
||||
});
|
||||
|
||||
it('rounds every text of the interface, at any scale', () => {
|
||||
expect(crispText({ text: 'a' }).roundPixels).toBe(true);
|
||||
initPixelGrid(1.5);
|
||||
expect(crispText().roundPixels).toBe(true);
|
||||
});
|
||||
|
||||
it('rounds a box only where the layout pixels miss the device grid', () => {
|
||||
expect(crisp(new Graphics()).roundPixels).toBe(false);
|
||||
initPixelGrid(2);
|
||||
expect(crisp(new Graphics()).roundPixels).toBe(false);
|
||||
initPixelGrid(1.5);
|
||||
expect(crisp(new Graphics()).roundPixels).toBe(true);
|
||||
});
|
||||
|
||||
it('draws the shared boxes with lines of one layout pixel where that is a device pixel', () => {
|
||||
const bar = new Graphics();
|
||||
drawHeaderBar(bar, 200, 30);
|
||||
expect(rects(bar)).toEqual([
|
||||
[0, 0, 200, 30],
|
||||
[0, 29, 200, 1],
|
||||
]);
|
||||
const box = new Graphics();
|
||||
drawControlBox(box, 100, 24, { fill: 0, border: 1 });
|
||||
expect(rects(box)).toEqual([
|
||||
[0, 0, 100, 24],
|
||||
[0.5, 0.5, 99, 23],
|
||||
]);
|
||||
expect(box.roundPixels).toBe(false);
|
||||
});
|
||||
|
||||
it('draws them with lines of one device pixel at 150 %, flush with the edge they belong to', () => {
|
||||
initPixelGrid(1.5);
|
||||
const line = 2 / 3;
|
||||
const bar = new Graphics();
|
||||
drawHeaderBar(bar, 200, 30);
|
||||
expect(bar.roundPixels).toBe(true);
|
||||
expect(rects(bar)[1]).toEqual([0, 30 - line, 200, line]);
|
||||
|
||||
const box = new Graphics();
|
||||
drawControlBox(box, 100, 24, { fill: 0, border: 1 });
|
||||
expect(box.roundPixels).toBe(true);
|
||||
expect(rects(box)[1]).toEqual([line / 2, line / 2, 100 - line, 24 - line]);
|
||||
|
||||
const band = new Graphics();
|
||||
drawSectionBand(band, 0, 10, 80, 26);
|
||||
expect(band.roundPixels).toBe(true);
|
||||
expect(rects(band)).toEqual([
|
||||
[0, 10, 80, 26],
|
||||
[0, 10, 80, line],
|
||||
[0, 36 - line, 80, line],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edgeContrast', () => {
|
||||
/** One row of grey values, as RGBA, two rows high so every pixel has a neighbour below. */
|
||||
const picture = (row: number[]) => {
|
||||
const rgba = [...row, ...row].flatMap((v) => [v, v, v, 255]);
|
||||
return edgeContrast(rgba, row.length, 2);
|
||||
};
|
||||
|
||||
it('scores a hard edge higher than the same edge averaged over two pixels', () => {
|
||||
const hard = picture([0, 0, 200, 200, 0, 0]);
|
||||
const soft = picture([0, 100, 200, 100, 0, 0]);
|
||||
expect(hard).toBeCloseTo(200, 5);
|
||||
expect(soft).toBeCloseTo(100, 5);
|
||||
});
|
||||
|
||||
it('is zero for a picture without an edge', () => {
|
||||
expect(picture([40, 40, 41, 40])).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user