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
@@ -1,18 +1,59 @@
|
||||
import { type FrameRect, isTextureFile, resolvePicture } from '../../core/model/texture';
|
||||
import { effectiveScale, gameClock } from '../../core/time';
|
||||
import { afterFrames, isRendering, renderCount, requestRender, stagePixel, stagePixels } from '../../ui';
|
||||
import {
|
||||
afterFrames,
|
||||
hairline,
|
||||
isRendering,
|
||||
renderCount,
|
||||
requestRender,
|
||||
stagePixel,
|
||||
stagePixels,
|
||||
} from '../../ui';
|
||||
import { particleLibrary } from '../../viewport/ParticleLibrary';
|
||||
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||
import { type Container, Mesh } from 'pixi.js';
|
||||
import type { HookContext } from './hookHelpers';
|
||||
import { edgeContrast } from './sharpness';
|
||||
|
||||
/** What was really drawn, and the clocks behind it: pixels, textures, frames, shaders and particles. */
|
||||
export function renderHooks({ app, state, editor }: HookContext) {
|
||||
const { viewport, gameView } = editor;
|
||||
const pixelAt = (x: number, y: number): string => stagePixel(app, x, y);
|
||||
/**
|
||||
* A part of the frame as the screen gets it, device pixel for device pixel. `pixelAt` cannot say
|
||||
* that: it draws the stage again into a target of its own, at the layout's size.
|
||||
*/
|
||||
const deviceFrame = (x: number, y: number, w: number, h: number): ImageData => {
|
||||
const res = app.renderer.resolution;
|
||||
const [sx, sy, sw, sh] = [x, y, w, h].map((v) => Math.round(v * res)) as [number, number, number, number];
|
||||
const copy = document.createElement('canvas');
|
||||
copy.width = Math.max(1, sw);
|
||||
copy.height = Math.max(1, sh);
|
||||
const context = copy.getContext('2d')!;
|
||||
// In one go with the render: the drawing buffer is only promised to hold the frame until the task ends.
|
||||
app.render();
|
||||
context.drawImage(app.canvas, sx, sy, copy.width, copy.height, 0, 0, copy.width, copy.height);
|
||||
return context.getImageData(0, 0, copy.width, copy.height);
|
||||
};
|
||||
return {
|
||||
pixelAt,
|
||||
/** The grid the interface rounds to: device pixels per layout pixel, and what a hairline measures. */
|
||||
pixelGrid: () => ({ resolution: app.renderer.resolution, hairline: hairline() }),
|
||||
/** How hard the edges are in a rectangle of the screen, given in layout pixels – see `edgeContrast`. */
|
||||
edgeContrast: (x: number, y: number, w: number, h: number) => {
|
||||
const frame = deviceFrame(x, y, w, h);
|
||||
return edgeContrast(frame.data, frame.width, frame.height);
|
||||
},
|
||||
/** The device pixels down one column, from layout `y` over `h`, as `#rrggbb`. */
|
||||
deviceColumn: (x: number, y: number, h: number) => {
|
||||
const { data, height } = deviceFrame(x, y, 1 / app.renderer.resolution, h);
|
||||
const hex = (v: number | undefined) => (v ?? 0).toString(16).padStart(2, '0');
|
||||
return Array.from(
|
||||
{ length: height },
|
||||
(_, row) => `#${hex(data[row * 4])}${hex(data[row * 4 + 1])}${hex(data[row * 4 + 2])}`,
|
||||
);
|
||||
},
|
||||
/** Many points from one render, each the colour `pixelAt` would give – for checks that sample hundreds. */
|
||||
pixels: (points: readonly { x: number; y: number }[]) => stagePixels(app, points),
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* How hard the edges in a picture are: the mean jump in brightness between neighbours, over the
|
||||
* pixels that have one at all. Text and lines drawn between two device pixels are averaged across
|
||||
* both and score lower – the one number that tells such a frame from a crisp one (see `pixelGrid.ts`).
|
||||
*/
|
||||
export function edgeContrast(rgba: ArrayLike<number>, width: number, height: number): number {
|
||||
const lum = (x: number, y: number): number => {
|
||||
const i = (y * width + x) * 4;
|
||||
return 0.299 * (rgba[i] ?? 0) + 0.587 * (rgba[i + 1] ?? 0) + 0.114 * (rgba[i + 2] ?? 0);
|
||||
};
|
||||
let sum = 0;
|
||||
let edges = 0;
|
||||
for (let y = 0; y < height - 1; y++)
|
||||
for (let x = 0; x < width - 1; x++) {
|
||||
const here = lum(x, y);
|
||||
const dx = Math.abs(lum(x + 1, y) - here);
|
||||
const dy = Math.abs(lum(x, y + 1) - here);
|
||||
sum += dx + dy;
|
||||
if (dx > EDGE || dy > EDGE) edges += 1;
|
||||
}
|
||||
return edges ? sum / edges : 0;
|
||||
}
|
||||
|
||||
/** Below this a difference is noise of the blending, not an edge. */
|
||||
const EDGE = 2;
|
||||
Reference in new issue
Block a user