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:
adminandClaude Opus 5.5 committed 2026-10-02 08:42:26 +02:00
1 parent 275e50af01
commit 32a0af6e2c
49 files changed
+582 -134

No files matched your search

+5 -5
View File
@@ -1,4 +1,4 @@
import { Container, Graphics, Sprite, Text, type TextStyle } from 'pixi.js';
import { Container, Graphics, Sprite, type Text, type TextStyle } from 'pixi.js';
import type { TileAsset } from '../../core/model/Asset';
import {
autotileCombinations,
@@ -19,7 +19,7 @@ import {
tilePlayback,
} from '../../core/model/tile';
import { findAssetOf } from '../../core/model/Project';
import { type Size, Theme, textStyle, ui, Widget } from '../../ui';
import { type Size, Theme, textStyle, ui, Widget, crispText } from '../../ui';
import { textureLibrary } from '../../viewport/TextureLibrary';
import { nearestZoomStep, stepFreeZoom, wheelSteps, zoomStepBelow } from '../../viewport/view';
import type { Point } from '../framing';
@@ -77,7 +77,7 @@ export class TileCanvas extends Widget {
private readonly captionLayer = new Container();
private readonly clipped = new Container();
private readonly clip = new Graphics();
private readonly hint = new Text({ text: '', style: textStyle('muted') });
private readonly hint = crispText({ text: '', style: textStyle('muted') });
private readonly tiles: BoardTile[] = [];
private readonly captions: Caption[] = [];
/** Own copies, since the size follows the zoom here and nowhere else. */
@@ -421,9 +421,9 @@ export class TileCanvas extends Widget {
/** `NESW-ne,se` becomes two lines, so the long captions need no more width than a tile. */
private addCaption(label: string, x: number, y: number): void {
const [sides = label, notches] = label.split('-');
const sidesText = new Text({ text: sides, style: this.sidesStyle, anchor: { x: 0.5, y: 0 } });
const sidesText = crispText({ text: sides, style: this.sidesStyle, anchor: { x: 0.5, y: 0 } });
const notchesText = notches
? new Text({ text: notches, style: this.notchesStyle, anchor: { x: 0.5, y: 0 } })
? crispText({ text: notches, style: this.notchesStyle, anchor: { x: 0.5, y: 0 } })
: null;
this.captionLayer.addChild(sidesText);
if (notchesText) this.captionLayer.addChild(notchesText);
+4 -3
View File
@@ -1,4 +1,4 @@
import { Container, type FederatedPointerEvent, Graphics, Sprite, Text } from 'pixi.js';
import { Container, type FederatedPointerEvent, Graphics, Sprite, type Text } from 'pixi.js';
import { loneTileQuadrants } from '../../core/model/autotile';
import { findAssetOf } from '../../core/model/Project';
import { quadrantRef, readTile } from '../../core/model/tile';
@@ -11,6 +11,7 @@ import {
textStyle,
truncate,
Widget,
crispText,
} from '../../ui';
import { textureLibrary } from '../../viewport/TextureLibrary';
import { gridMetrics, type GridMetrics, tileAt, tileRect } from '../texture/imageGrid';
@@ -187,8 +188,8 @@ export class TilePalette extends Widget {
const box = new Container();
const background = new Graphics();
const sprites = [new Sprite(), new Sprite(), new Sprite(), new Sprite()] as Swatch['sprites'];
const empty = new Text({ text: '?', style: textStyle('muted') });
const caption = new Text({ style: textStyle('small') });
const empty = crispText({ text: '?', style: textStyle('muted') });
const caption = crispText({ style: textStyle('small') });
box.addChild(background, ...sprites, empty, caption);
box.eventMode = 'none';
this.addChild(box);