Files
feuerwerk/tests/viewport/pixel-grid.test.ts
T
adminandClaude Opus 5.5 1665576fb9
game-maker / gate (push) Successful in 3m6s
game-maker / suite (push) Successful in 16m1s
Let a pixel perfect camera draw on the game view's pixel grid
A camera that follows smoothly stands between pixels most of the time,
and pixel art then wanders between them. The new camera switch draws
what the camera shows on the game view's own grid, while positions in
the model keep their fractions.

- `Pixel perfect` is a camera row, off by default. It works at every
  fixed game scale and at Auto, but not at Fit and not in the Scene
  tab, where one works with the fractions.
- The grid has one step per device pixel of a world unit. At 100% that
  is whole units, at 500% fifths. The view carries it (`View.grid`),
  so no view draws with the grid of another.
- The view looks from a point that puts the world's origin on a whole
  device pixel (`lookFrom`). This also covers odd camera sizes and
  views that cut the picture to an odd size.
- Sprites, texts and tilemaps snap their top left corner when they are
  unturned, so an odd size lands on whole pixels too. A turned object
  snaps its pivot (`snapPlacement` in `pixelGrid.ts`).
- Under a camera shader the picture is taken the same way, and the
  objects in it use the picture's grid. With Camera pixels that is one
  step per camera pixel.
- An overlay camera has its own switch and snaps only while the main
  picture is at a fixed scale.
- Particles are drawn where they are.
- New test hook `__gm.gameDrawnRect(id)` reads where a sprite's picture
  is drawn, which `gameRect` (the model's) cannot tell.

Found: the e2e fixture at 300% drew the crate at y 205.5 device pixels
without moving the camera, because the view cut to an odd height puts
its centre on half a pixel. With the switch on it is at 206.

Tests:
- Gate: 237 files, 3587 tests (+10 in pixel-grid.test.ts)
- e2e 2,3: ALL PASSED, 75 checks (+1, 3j)
- Counter-checks: without the origin snap 3 unit tests fail, with the
  centre snapped instead of the corner 2 fail

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:30:31 +02:00

114 lines
4.4 KiB
TypeScript

/**
* A pixel perfect camera draws on the game view's pixel grid: one step per device pixel a unit gets,
* the world's origin on a whole pixel, an unturned box by its corner. Fit stays exact.
*/
import { describe, expect, it } from 'vitest';
import { SHADER_RESOLUTION_KEY } from '../../src/core/properties/cameraShader';
import { cameraPicture } from '../../src/viewport/cameraPicture';
import { gridSteps, snapPlacement } from '../../src/viewport/pixelGrid';
import { cameraView, overlayView, worldToScreen } from '../../src/viewport/view';
const turned = (deg: number) => {
const r = (deg * Math.PI) / 180;
return { a: Math.cos(r), b: Math.sin(r), c: -Math.sin(r), d: Math.cos(r) };
};
/** Whether `v` device pixels is a whole one, within float noise. */
const whole = (v: number) => Math.abs(v - Math.round(v)) < 1e-9;
describe('snapPlacement', () => {
const plain = { a: 1, b: 0, c: 0, d: 1, tx: 10.3, ty: -4.6 };
it('puts the corner of an odd box on the grid, so its centre lies between', () => {
const m = snapPlacement(plain, 15, 9, 1);
expect(m.tx - 7.5).toBe(3);
expect(m.ty - 4.5).toBe(-9);
});
it('counts the corner from the pivot, as a text anchored left is placed', () => {
const m = snapPlacement(plain, 15, 9, 1, { x: 0, y: 0.5 });
expect(m.tx).toBe(10);
});
it('snaps to fifths at five steps per unit, and a turned box by its pivot', () => {
expect(snapPlacement({ ...plain, tx: 10.33 }, 15, 9, 5).tx - 7.5).toBeCloseTo(2.8, 12);
const m = snapPlacement({ ...plain, ...turned(30) }, 15, 9, 1);
expect([m.tx, m.ty]).toEqual([10, -5]);
});
it('leaves the placement as it is without a grid', () => {
expect(snapPlacement(plain, 15, 9, null)).toBe(plain);
});
});
describe('cameraView through a pixel perfect camera', () => {
const camera = (props: Record<string, unknown>) => ({
x: 10.37,
y: -3.81,
props: { width: 481, height: 271, zoom: 1, pixelPerfect: true, ...props },
});
it('has one step per device pixel of a unit, and the world origin on a whole one', () => {
// Game scale, device pixels per CSS pixel, camera zoom, steps.
for (const [mode, ratio, zoom, steps] of [
[1, 1, 1, 1],
[5, 1, 1, 5],
[2, 1.25, 1, 2],
[1, 1, 2, 2],
] as const) {
const zoomed = camera({ zoom });
const view = cameraView(zoomed, 2600, 1500, mode, ratio);
expect(view.grid).toBe(steps);
for (const n of [0, 1, -7, 333]) {
const p = worldToScreen(view, n / steps, n / steps);
expect(whole(p.x * ratio) && whole(p.y * ratio)).toBe(true);
}
expect(Math.abs(view.x - zoomed.x)).toBeLessThanOrEqual(0.5 / (view.scale * ratio) + 1e-12);
}
});
it('stays exact at fit and with the switch off', () => {
const fit = cameraView(camera({}), 1000, 700, 'fit');
expect(fit.grid).toBeUndefined();
expect([fit.x, fit.y]).toEqual([10.37, -3.81]);
const off = cameraView(camera({ pixelPerfect: false }), 1000, 700, 1);
expect(off.grid).toBeUndefined();
expect(off.x).toBe(10.37);
});
it('snaps an overlay only at a fixed scale of the main picture', () => {
const main = cameraView(camera({}), 1000, 700, 1);
const overlay = camera({ width: 241, height: 135 });
expect(overlayView(overlay, main, { w: 481, h: 271 }, 1, false).grid).toBeUndefined();
const fixed = overlayView(overlay, main, { w: 481, h: 271 }, 1, true);
expect(fixed.grid).toBe(gridSteps(fixed.scale));
expect(whole(worldToScreen(fixed, 0, 0).x)).toBe(true);
});
});
describe('a pixel perfect camera wearing a shader', () => {
const at = (props: Record<string, unknown>) => ({
x: 10.37,
y: -3.81,
props: { width: 481, height: 271, zoom: 1, ...props },
});
it('takes its picture with the world origin on a whole pixel of it', () => {
const p = cameraPicture(at({}), 5, true);
expect(p.grid).toBe(5);
expect(whole(p.toPicture.tx) && whole(p.toPicture.ty)).toBe(true);
expect(p.centre.x).not.toBe(10.37);
});
it('draws its objects on the picture grid, one step per camera pixel at camera resolution', () => {
expect(cameraPicture(at({ [SHADER_RESOLUTION_KEY]: 'camera' }), 5, true).grid).toBe(1);
expect(cameraPicture(at({ [SHADER_RESOLUTION_KEY]: 'camera', zoom: 2 }), 10, true).grid).toBe(2);
});
it('is taken from the camera itself without the grid', () => {
const p = cameraPicture(at({}), 5);
expect(p.grid).toBeNull();
expect(p.centre).toEqual({ x: 10.37, y: -3.81 });
});
});