Keep game view steps pixel exact under Windows display scaling

At 125% Windows scaling the game view drew 100% as 1.25 screen pixels
per camera pixel, so pixel art showed texels of uneven width even at
the round steps. The steps now count device pixels instead of CSS
pixels.

- gameScaleFactor, cameraFactor and cameraView take the device pixel
  ratio. 100% is one screen pixel per camera pixel, 200% two.
- Auto picks the largest step that fits in device pixels. Fit reports
  its factor in device pixels too, e.g. 156% at 125% scaling.
- Fixed steps start the frame on a whole device pixel.
- The game view reads the ratio from the renderer resolution.

Limits: main.ts caps the resolution at 2, so scaling above 200% is
stretched by the browser again. A changed ratio at runtime only counts
after a reload, as before.

Tests:
- 2 unit tests at 125%: 100% is one device pixel on a whole pixel,
  auto and fit count device pixels.
- Counter-check: without the conversion the 100% test fails.
- check.sh: 156 files, CHECK EXIT 0 on the combined working tree.
- No e2e: the harness runs at devicePixelRatio 1, same math as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-24 10:40:43 +02:00
1 parent 2592873d76
commit 22a89ed0f0
3 files changed
+48 -15

No files matched your search

+7 -6
View File
@@ -6,7 +6,7 @@ import { type FederatedPointerEvent, Text } from 'pixi.js';
import { isSymbolType } from '../../core/model/AssetTypes';
import type { EditorState } from '../state';
import type { World } from '../../runtime/World';
import { textStyle } from '../../ui';
import { textStyle, ui } from '../../ui';
import { type ActiveCamera, cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera';
import { collectHolderItems, collectWorldItems } from '../../viewport/items';
import {
@@ -31,7 +31,7 @@ export class GameViewport extends WorldViewport {
private playWorld: World | null = null;
/** The editor run's world: it takes the mouse, but it is never what this view draws. */
private editorWorld: World | null = null;
/** Screen pixels per camera pixel as last drawn, null without a camera. */
/** Device pixels per camera pixel as last drawn, null without a camera. */
private factor: number | null = null;
/** Told the factor of every redraw: auto and fit change with the view. */
onScaleShown: ((factor: number | null) => void) | null = null;
@@ -92,8 +92,9 @@ export class GameViewport extends WorldViewport {
protected override currentView(): View | null {
const camera = this.activeCamera();
this.factor = camera ? cameraFactor(camera.props, this.w, this.h, this.scaleMode) : null;
return camera ? cameraView(camera, this.w, this.h, this.scaleMode) : null;
const ratio = ui().app.renderer.resolution;
this.factor = camera ? cameraFactor(camera.props, this.w, this.h, this.scaleMode, ratio) : null;
return camera ? cameraView(camera, this.w, this.h, this.scaleMode, ratio) : null;
}
private activeCamera(): ActiveCamera | null {
@@ -106,8 +107,8 @@ export class GameViewport extends WorldViewport {
}
/**
* Screen pixels per camera pixel and per world unit, and the camera's picture as it lies on
* screen, in global pixels; null without a camera.
* Device pixels per camera pixel, CSS pixels per world unit, and the camera's picture as it
* lies on screen, in global CSS pixels; null without a camera.
*/
scaleInfo(): {
factor: number;
+21 -9
View File
@@ -22,39 +22,51 @@ export type GameScale = 'auto' | 'fit' | number;
export const GAME_SCALE_STEPS = [0.25, 0.5, 1, 2, 4];
/** Screen pixels per camera pixel for a camera picture of `sizeW`×`sizeH` in a `w`×`h` view. */
export function gameScaleFactor(mode: GameScale, sizeW: number, sizeH: number, w: number, h: number): number {
const fit = Math.min(w / sizeW, h / sizeH);
/**
* Device pixels per camera pixel for a camera picture of `sizeW`×`sizeH` in a `w`×`h` view (CSS
* pixels). `ratio` is device pixels per CSS pixel, 1.25 under Windows scaling at 125%: the steps
* count device pixels, so 100% stays one screen pixel per camera pixel whatever the scaling.
*/
export function gameScaleFactor(
mode: GameScale,
sizeW: number,
sizeH: number,
w: number,
h: number,
ratio = 1,
): number {
const fit = Math.min(w / sizeW, h / sizeH) * ratio;
if (mode === 'fit') return fit;
if (mode !== 'auto') return mode;
return Math.max(Math.min(...GAME_SCALE_STEPS), ...GAME_SCALE_STEPS.filter((step) => step <= fit));
}
/** What `mode` comes to for this camera in a `w`×`h` view (the picker shows it for auto and fit). */
export function cameraFactor(props: PropertyBag, w: number, h: number, mode: GameScale): number {
export function cameraFactor(props: PropertyBag, w: number, h: number, mode: GameScale, ratio = 1): number {
const size = cameraSize(props);
return gameScaleFactor(mode, size.w, size.h, w, h);
return gameScaleFactor(mode, size.w, size.h, w, h, ratio);
}
/**
* The camera's picture at `mode` in `w`×`h`, centred; the remaining margin is letterbox. 100% is
* one screen pixel per camera pixel, so the camera's zoom still magnifies the world on top. A
* one device pixel per camera pixel, so the camera's zoom still magnifies the world on top. A
* picture larger than the view is cut to it, so the rectangle only ever covers what is on screen.
* Fixed steps start on a whole pixel, so pixel art at 100% stays sharp.
* Fixed steps start on a whole device pixel, so pixel art at 100% stays sharp.
*/
export function cameraView(
camera: { x: number; y: number; props: PropertyBag },
w: number,
h: number,
mode: GameScale = 'fit',
ratio = 1,
): View {
const size = cameraSize(camera.props);
const frame = cameraFrame(camera.props);
const factor = cameraFactor(camera.props, w, h, mode);
const factor = cameraFactor(camera.props, w, h, mode, ratio) / ratio;
const scale = (factor * size.w) / frame.w;
const fw = Math.min(size.w * factor, w);
const fh = Math.min(size.h * factor, h);
const snap = mode === 'fit' ? (v: number) => v : Math.round;
const snap = mode === 'fit' ? (v: number) => v : (v: number) => Math.round(v * ratio) / ratio;
return {
x: camera.x,
y: camera.y,
+20
View File
@@ -88,6 +88,26 @@ describe('cameraView with a zoomed camera', () => {
});
});
describe('cameraView under Windows scaling at 125%', () => {
const plain = { x: 0, y: 0, props: { width: 640, height: 360, zoom: 1 } };
it('100% is one device pixel per camera pixel, starting on a whole device pixel', () => {
const view = cameraView(plain, 801, 601, 1, 1.25);
expect(view.scale * 1.25).toBeCloseTo(1, 12);
expect(view.rect.w).toBeCloseTo(512, 12);
expect(view.rect.h).toBeCloseTo(288, 12);
expect(view.rect.x * 1.25).toBeCloseTo(181, 9);
expect(view.rect.y * 1.25).toBeCloseTo(196, 9);
});
it('auto picks the step in device pixels, and fit reports device pixels', () => {
expect(cameraFactor(plain.props, 1100, 700, 'auto', 1.25)).toBe(2);
expect(cameraFactor(plain.props, 1100, 700, 'auto')).toBe(1);
expect(cameraFactor(plain.props, 800, 600, 'fit', 1.25)).toBeCloseTo(1.5625, 9);
expect(cameraView(plain, 800, 600, 'fit', 1.25).scale).toBeCloseTo(1.25, 9);
});
});
describe('gameScaleFactor', () => {
it('fit is the exact factor that fills the tighter side', () => {
expect(gameScaleFactor('fit', 640, 360, 800, 600)).toBeCloseTo(1.25, 9);