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>
69 lines
3.0 KiB
TypeScript
69 lines
3.0 KiB
TypeScript
/**
|
||
* The picture a camera's shader is laid over (#299), as numbers: how many pixels it gets, which
|
||
* transform takes the world into it, and what a pixel of it counts as. Pure, so the arithmetic is
|
||
* tested without a GPU.
|
||
*/
|
||
import type { Mat } from '../core/model/matrix';
|
||
import type { PropertyBag } from '../core/model/Properties';
|
||
import { shaderResolutionOf } from '../core/properties/cameraShader';
|
||
import { cameraFrame, cameraSize } from '../core/properties/camera';
|
||
import { gridSteps, lookFrom } from './pixelGrid';
|
||
|
||
/** The longest side the picture may have, a game tab on a large screen included. */
|
||
export const CAMERA_MAX_PIXELS = 4096;
|
||
|
||
export interface CameraPicture {
|
||
/** The picture's size in pixels: the camera's raster, or the frame as large as it stands on screen. */
|
||
pixels: { w: number; h: number };
|
||
/** World units to pixels of the picture: the frame's top left corner is 0,0. */
|
||
toPicture: Mat;
|
||
/** The frame in world units, centred on `centre`: where the shaded picture is drawn. */
|
||
frame: { w: number; h: number };
|
||
/** The camera's position, or on a pixel grid the point the picture is taken from. */
|
||
centre: { x: number; y: number };
|
||
/** On a pixel grid the steps per unit the objects in the picture are drawn on, else null. */
|
||
grid: number | null;
|
||
/**
|
||
* Camera pixels one pixel of the picture covers, per axis: what the shader counts a pixel as in
|
||
* either resolution (the user's choice), so a Blur of 4 is 4 camera pixels at any game scale.
|
||
*/
|
||
pixelScale: { x: number; y: number };
|
||
/** Whether the picture is scaled up without smoothing: the camera's raster, for pixel art. */
|
||
blocky: boolean;
|
||
}
|
||
|
||
/**
|
||
* `devicePixelsPerUnit` is what one world unit covers on screen, in device pixels. `onGrid`: the view
|
||
* is a pixel perfect camera's, so the world's origin lands on a whole pixel of the picture and the
|
||
* objects on the picture's own grid – one step per pixel of it, whichever resolution it has.
|
||
*/
|
||
export function cameraPicture(
|
||
camera: { x: number; y: number; props: PropertyBag },
|
||
devicePixelsPerUnit: number,
|
||
onGrid = false,
|
||
): CameraPicture {
|
||
const frame = cameraFrame(camera.props);
|
||
const size = cameraSize(camera.props);
|
||
const blocky = shaderResolutionOf(camera.props) === 'camera';
|
||
const pixels = blocky
|
||
? { w: pixelsAlong(size.w), h: pixelsAlong(size.h) }
|
||
: { w: pixelsAlong(frame.w * devicePixelsPerUnit), h: pixelsAlong(frame.h * devicePixelsPerUnit) };
|
||
const a = pixels.w / frame.w;
|
||
const d = pixels.h / frame.h;
|
||
const x = onGrid ? lookFrom(camera.x, pixels.w / 2, a) : camera.x;
|
||
const y = onGrid ? lookFrom(camera.y, pixels.h / 2, d) : camera.y;
|
||
return {
|
||
pixels,
|
||
toPicture: { a, b: 0, c: 0, d, tx: pixels.w / 2 - x * a, ty: pixels.h / 2 - y * d },
|
||
frame,
|
||
centre: { x, y },
|
||
grid: onGrid ? gridSteps(a) : null,
|
||
pixelScale: { x: size.w / pixels.w, y: size.h / pixels.h },
|
||
blocky,
|
||
};
|
||
}
|
||
|
||
function pixelsAlong(length: number): number {
|
||
return Math.min(CAMERA_MAX_PIXELS, Math.max(1, Math.round(Number.isFinite(length) ? length : 1)));
|
||
}
|