Files
feuerwerk/src/page.ts
T
adminandClaude Opus 5.5 32a0af6e2c 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>
2026-10-02 08:42:26 +02:00

59 lines
2.1 KiB
TypeScript

import { Application, Container } from 'pixi.js';
import { initPixelGrid, initRenderLoop, initUI, loadEditorFonts, Theme, tieBreakFor, VStack } from './ui';
// `desynchronized` bypasses the compositor queue (headless click-to-paint 72 → 16 ms) but may
// tear; `?vsync` opts out. Pixi has no option for it, hence our own context.
function lowLatencyContext(): { canvas?: HTMLCanvasElement; context?: WebGL2RenderingContext } {
const canvas = document.createElement('canvas');
const context = canvas.getContext('webgl2', {
alpha: false,
antialias: true,
premultipliedAlpha: true,
stencil: true,
desynchronized: true,
});
return context ? { canvas, context } : {};
}
/**
* The one canvas a page is: the editor's and a released game's alike (#266). `root` fills the
* window and follows it when it is resized.
*/
export async function createPage(params: URLSearchParams): Promise<{ app: Application; root: VStack }> {
const fonts = loadEditorFonts();
const app = new Application();
await app.init({
resizeTo: window,
background: Theme.colors.background,
antialias: true,
resolution: Math.min(2, window.devicePixelRatio || 1),
autoDensity: true,
preference: 'webgl',
...(params.has('vsync') ? {} : lowLatencyContext()),
});
initPixelGrid(app.renderer.resolution);
document.body.appendChild(app.canvas);
await fonts;
const root = new VStack();
const overlay = new Container();
app.stage.addChild(root, overlay);
app.stage.position.set(tieBreakFor(app.renderer.resolution));
app.stage.eventMode = 'static';
app.stage.hitArea = app.screen;
initUI(app, root, overlay);
// From here on the picture is drawn when something changes, not sixty times a second.
initRenderLoop(app);
app.renderer.on('resize', () => {
app.stage.hitArea = app.screen;
root.resize(app.screen.width, app.screen.height);
});
return { app, root };
}
export function showFailure(error: unknown): void {
console.error(error);
document.body.innerHTML = `<pre style="color:#e0553f;padding:16px;font:13px monospace">${String(error)}</pre>`;
}