diff --git a/README.md b/README.md index 8b1e2646..47b5c23f 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1741 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1742 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index f729fcab..089264a5 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -470,6 +470,8 @@ const ASSET_SCHEMAS: Record = { { key: SEES_KEY, label: 'Sees', kind: 'layers', default: ALL_LAYERS, renderLayers: true }, // Off: a candidate for the main camera (#5). On: drawn over the main one, see-through (#302). { key: OVERLAY_KEY, label: 'Overlay', kind: 'boolean', default: false }, + // Draws on the game view's pixel grid at a fixed scale; fit and the scene view stay exact. + { key: 'pixelPerfect', label: 'Pixel perfect', kind: 'boolean', default: false }, // Laid over the camera's whole picture (#299); its parameters follow, as on a sprite. { key: SHADER_KEY, label: 'Shader', kind: 'asset', default: '', assetTypes: ['shader'] }, { diff --git a/src/core/properties/camera.ts b/src/core/properties/camera.ts index 1fadc77a..f1a21ddd 100644 --- a/src/core/properties/camera.ts +++ b/src/core/properties/camera.ts @@ -25,6 +25,10 @@ export function cameraSize(props: PropertyBag): { w: number; h: number } { return { w: Number(props.width ?? DEFAULTS.width), h: Number(props.height ?? DEFAULTS.height) }; } +export function isPixelPerfect(props: PropertyBag): boolean { + return props.pixelPerfect === true; +} + /** * Where the mouse is in the world as `camera` sees it, with that camera's picture fitted into the * main camera's and centred, the way #302 lays an overlay on. `onPicture` is the mouse on the main diff --git a/src/editor/debug/sceneHooks.ts b/src/editor/debug/sceneHooks.ts index 4cd95168..3c16043a 100644 --- a/src/editor/debug/sceneHooks.ts +++ b/src/editor/debug/sceneHooks.ts @@ -110,6 +110,7 @@ export function sceneHooks({ state, editor }: HookContext) { /** The same for the Game tab – through the camera, so what the player sees. */ gameScreen: (id: string) => gameView.objectScreen(id), gameRect: (id: string) => gameView.objectRect(id), + gameDrawnRect: (id: string) => gameView.drawnRect(id), gameCenter: () => centreOf(gameView), /** What the main camera draws in the Game tab, back to front (#302). */ gameDrawOrder: () => gameView.drawOrder(), diff --git a/src/editor/viewport/GameViewport.ts b/src/editor/viewport/GameViewport.ts index 3e159059..d0edc7e7 100644 --- a/src/editor/viewport/GameViewport.ts +++ b/src/editor/viewport/GameViewport.ts @@ -153,6 +153,10 @@ export class GameViewport extends WorldViewport { return this.activeCamera(); } + protected override fixedScale(): boolean { + return this.scaleMode !== 'fit'; + } + protected override overlayCameras(): ActiveCamera[] { const world = this.playWorld; return world ? overlaysOfWorld(world) : overlaysOfItems(this.modelFrame().items); diff --git a/src/player/GameScreen.ts b/src/player/GameScreen.ts index c2c2e977..a6ffebb1 100644 --- a/src/player/GameScreen.ts +++ b/src/player/GameScreen.ts @@ -103,6 +103,10 @@ export class GameScreen extends WorldViewport { return this.running ? overlaysOfWorld(this.running) : []; } + protected override fixedScale(): boolean { + return this.state.project.gameLinkZoom !== 'fit'; + } + protected override collectFrame(): Frame { return this.running ? collectWorldItems(this.running) : { items: [], background: this.baseColor }; } diff --git a/src/viewport/WorldViewport.ts b/src/viewport/WorldViewport.ts index d93cc1cf..b5eb31be 100644 --- a/src/viewport/WorldViewport.ts +++ b/src/viewport/WorldViewport.ts @@ -23,7 +23,8 @@ import { textureLibrary } from './TextureLibrary'; import { gameClock } from '../core/time'; import { overlayView, type View, worldBounds, worldToScreen } from './view'; import type { ActiveCamera } from './activeCamera'; -import { CameraShade } from './cameraShade'; +import { CameraShade, shadedPicture } from './cameraShade'; +import { snapPlacement } from './pixelGrid'; import { cameraSees } from '../core/model/renderLayers'; /** How strongly the cut-off part of an object shows while the scene view opens its clip area (#260). */ @@ -87,6 +88,7 @@ export abstract class WorldViewport extends W scale: number; w: number; h: number; + grid: number | null; culled: { node: Graphics; extent: Extent | null }[]; } | null = null; /** Full redraws and camera-only moves since start (test hook). */ @@ -216,7 +218,14 @@ export abstract class WorldViewport extends W protected moveCamera(): void { const view = this.currentView(); const drawn = this.drawn; - if (!view || !drawn || view.scale !== drawn.scale || view.rect.w !== drawn.w || view.rect.h !== drawn.h) { + if ( + !view || + !drawn || + view.scale !== drawn.scale || + view.rect.w !== drawn.w || + view.rect.h !== drawn.h || + (view.grid ?? null) !== drawn.grid + ) { this.redraw(); return; } @@ -333,6 +342,18 @@ export abstract class WorldViewport extends W }; } + /** + * Where a sprite's picture is drawn, in global device pixels: unlike `objectRect`, which is the + * model's, this is where a pixel perfect camera snapped it to (test hook). + */ + drawnRect(objectId: string): { x: number; y: number; w: number; h: number } | null { + const sprite = this.sprites.get(objectId); + if (!sprite?.visible) return null; + const bounds = sprite.getBounds(); + const ratio = ui().app.renderer.resolution; + return { x: bounds.x * ratio, y: bounds.y * ratio, w: bounds.width * ratio, h: bounds.height * ratio }; + } + override redraw(): void { const view = this.currentView(); this.bg.clear().rect(0, 0, this.w, this.h).fill(this.baseColor); @@ -361,6 +382,8 @@ export abstract class WorldViewport extends W this.placeWorld(view); const bounds = worldBounds(view); const shaders = shaderLibrary.source(this.state.project); + const shading = this.shadingCamera(camera, items); + const grid = this.objectGrid(view, shading, shaders); const culled: { node: Graphics; extent: Extent | null }[] = []; const seen = new Set(); @@ -388,11 +411,12 @@ export abstract class WorldViewport extends W node.visible = onScreen && !hidden; if (item.enabled && !hidden) culled.push({ node, extent: unbound ? null : extent }); node.eventMode = this.itemsInteractive && node.visible ? 'static' : 'none'; + const placed = grid ? { ...item, m: snapPlacement(item.m, size.w, size.h, grid) } : item; this.syncDecorations(item, onScreen, view.scale, extent); - this.syncText(item, onScreen, view.scale, shaders); - this.syncSprite(item, onScreen, size, shaders); + this.syncText(item, onScreen, view.scale, shaders, grid); + this.syncSprite(placed, onScreen, size, shaders); this.syncParticles(item, onScreen, shaders, view, bounds); - this.syncTilemap(item, onScreen); + this.syncTilemap(placed, onScreen); if (!onScreen) continue; this.visibleCount++; if (hidden) continue; @@ -406,7 +430,7 @@ export abstract class WorldViewport extends W } this.afterItems(items, view); this.drawOnScreen(items, view); - this.drawn = { scale: view.scale, w: rect.w, h: rect.h, culled }; + this.drawn = { scale: view.scale, w: rect.w, h: rect.h, grid: view.grid ?? null, culled }; for (const [id, node] of this.nodes) { if (seen.has(id)) continue; this.texts.get(id)?.destroy(); @@ -425,10 +449,25 @@ export abstract class WorldViewport extends W } this.syncDrawOrder(items); this.sweepMasks(); - this.shadeCamera(this.shadingCamera(camera, items), view, shaders, background); + this.shadeCamera(shading, view, shaders, background); this.syncOverlays(frame, view, camera); } + /** + * The steps per unit the objects are drawn on, or null to draw them as they are: through a pixel + * perfect camera the view's grid, or with its shader laid over the grid of the shader's picture. + */ + private objectGrid(view: View, shading: ActiveCamera | null, shaders: ShaderSource): number | null { + if (!view.grid) return null; + const ppu = view.scale * ui().app.renderer.resolution; + return (shading && shadedPicture(shading, shaders, ppu, true)?.grid) || view.grid; + } + + /** Whether the game view is at a fixed scale rather than fit: the only one overlays snap at. */ + protected fixedScale(): boolean { + return false; + } + /** * Lays the camera's shader over its frame (#299): the masks and objects are drawn into the * camera's picture, cleared to the scene's background – see-through on a view that paints none – @@ -444,7 +483,8 @@ export abstract class WorldViewport extends W const shade = this.cameraShade ?? new CameraShade(); const clear = this.paintsBackground() ? background : null; const ppu = view.scale * renderer.resolution; - const mesh = camera ? shade.draw(this.shaded, camera, shaders, ppu, renderer, clear) : null; + const onGrid = Boolean(view.grid); + const mesh = camera ? shade.draw(this.shaded, camera, shaders, ppu, renderer, clear, onGrid) : null; if (!camera || !mesh) { if (shade !== this.cameraShade) shade.destroy(); this.unshadeCamera(); @@ -492,7 +532,8 @@ export abstract class WorldViewport extends W this.addChild(overlay); } this.setChildIndex(overlay, at++); - const view = overlayView(overlayCamera, main!, cameraFrame(camera!.props)); + const ratio = ui().app.renderer.resolution; + const view = overlayView(overlayCamera, main!, cameraFrame(camera!.props), ratio, this.fixedScale()); overlay.show(overlayCamera, view, frame!, this.w, this.h); if (overlay.animates) this.animated = true; } @@ -733,7 +774,13 @@ export abstract class WorldViewport extends W return text?.mesh.visible ? text.info() : null; } - private syncText(item: DrawItem, onScreen: boolean, scale: number, shaders: ShaderSource): void { + private syncText( + item: DrawItem, + onScreen: boolean, + scale: number, + shaders: ShaderSource, + grid: number | null, + ): void { let text = this.texts.get(item.id); if (item.type !== 'text' || !onScreen) { if (text) text.mesh.visible = false; @@ -748,7 +795,7 @@ export abstract class WorldViewport extends W } text.mesh.visible = true; if (wornShader(item.props, shaders)?.time) this.animated = true; - text.sync(item, scale, shaders); + text.sync(item, scale, shaders, grid); let ghost = this.ghostTexts.get(item.id); if (!this.ghosted(item, onScreen)) { ghost?.destroy(); @@ -760,7 +807,7 @@ export abstract class WorldViewport extends W ghost.mesh.alpha = CLIP_GHOST_ALPHA; this.ghostTexts.set(item.id, ghost); } - ghost.sync(item, scale, shaders); + ghost.sync(item, scale, shaders, grid); } } diff --git a/src/viewport/cameraPicture.ts b/src/viewport/cameraPicture.ts index d133d84d..bd9df2cb 100644 --- a/src/viewport/cameraPicture.ts +++ b/src/viewport/cameraPicture.ts @@ -7,6 +7,7 @@ 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; @@ -16,8 +17,12 @@ export interface CameraPicture { 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 the camera: where the shaded picture is drawn. */ + /** 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. @@ -27,10 +32,15 @@ export interface CameraPicture { blocky: boolean; } -/** `devicePixelsPerUnit` is what one world unit covers on screen, in device pixels. */ +/** + * `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); @@ -40,10 +50,14 @@ export function cameraPicture( : { 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 - camera.x * a, ty: pixels.h / 2 - camera.y * d }, + 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, }; diff --git a/src/viewport/cameraShade.ts b/src/viewport/cameraShade.ts index 1951355e..501823ce 100644 --- a/src/viewport/cameraShade.ts +++ b/src/viewport/cameraShade.ts @@ -1,11 +1,22 @@ import type { Container, Mesh, Renderer } from 'pixi.js'; import { shaderOf } from '../core/properties/shaders'; import type { ActiveCamera } from './activeCamera'; -import { cameraPicture } from './cameraPicture'; +import { type CameraPicture, cameraPicture } from './cameraPicture'; import { ShaderPicture } from './shaderPicture'; import { type ShaderSource, syncShader } from './shaders'; import { disposeSpriteMesh, newSpriteMesh, placeNode } from './symbols'; +/** The picture `camera`'s shader is laid over, or null while it wears none that compiles. */ +export function shadedPicture( + camera: ActiveCamera, + shaders: ShaderSource, + devicePixelsPerUnit: number, + onGrid: boolean, +): CameraPicture | null { + const id = shaderOf(camera.props); + return id && shaders.compiled(id) ? cameraPicture(camera, devicePixelsPerUnit, onGrid) : null; +} + /** * A camera's shader (#299): the world as the camera sees it is drawn into a picture of its frame, and * the shader draws that picture where the frame stands. @@ -18,7 +29,7 @@ export class CameraShade { * Draws `world`, which is in world units, into the camera's picture and lays the shader over it. * `clearColor` is the scene's background, or null for a see-through overlay camera. Returns what a * view attaches, in world units – or null, and nothing drawn, while the camera wears no shader that - * compiles. + * compiles. `onGrid`: through a pixel perfect camera (see `cameraPicture`). */ draw( world: Container, @@ -27,11 +38,12 @@ export class CameraShade { devicePixelsPerUnit: number, renderer: Renderer, clearColor: number | null, + onGrid = false, ): Mesh | null { const id = shaderOf(camera.props); const compiled = id ? shaders.compiled(id) : null; if (!compiled) return null; - const shape = cameraPicture(camera, devicePixelsPerUnit); + const shape = cameraPicture(camera, devicePixelsPerUnit, onGrid); const scaleMode = shape.blocky ? 'nearest' : 'linear'; const picture = this.picture.draw( renderer, @@ -43,7 +55,8 @@ export class CameraShade { scaleMode, ); const mesh = (this.mesh ??= newSpriteMesh()); - placeNode(mesh, { a: 1, b: 0, c: 0, d: 1, tx: camera.x, ty: camera.y }, shape.frame.w, shape.frame.h); + const { x, y } = shape.centre; + placeNode(mesh, { a: 1, b: 0, c: 0, d: 1, tx: x, ty: y }, shape.frame.w, shape.frame.h); mesh.texture = picture; // The frame is the edge, so neither Padding nor Vertex output moves it; a pixel counts as a camera pixel. syncShader(mesh, camera.props, shaders, null, id, shape.pixelScale); diff --git a/src/viewport/pixelGrid.ts b/src/viewport/pixelGrid.ts new file mode 100644 index 00000000..da509b10 --- /dev/null +++ b/src/viewport/pixelGrid.ts @@ -0,0 +1,44 @@ +/** + * Pixel perfect cameras: what they show is drawn on a grid of one step per device pixel a world unit + * gets – a game view at 100% on whole units, at 500% on fifths. The model keeps its fractions; only + * where things are drawn snaps. Pure, so the arithmetic is tested without a GPU. + */ +import type { Mat } from '../core/model/matrix'; + +export function gridSteps(devicePixelsPerUnit: number): number { + return Math.max(1, Math.round(devicePixelsPerUnit)); +} + +export function onGrid(v: number, steps: number): number { + return Math.round(v * steps) / steps; +} + +/** + * Where a box of `w`×`h` of its own units, placed by `m` around `pivot` (parts of the box), is drawn + * on a grid of `steps` per unit; null draws it as it is. An unturned box snaps a corner, so an odd + * size lands on whole pixels too, a turned one its pivot. + */ +export function snapPlacement( + m: Mat, + w: number, + h: number, + steps: number | null, + pivot = { x: 0.5, y: 0.5 }, +): Mat { + if (!steps) return m; + if (Math.abs(m.b) > 1e-9 || Math.abs(m.c) > 1e-9) + return { ...m, tx: onGrid(m.tx, steps), ty: onGrid(m.ty, steps) }; + const left = pivot.x * m.a * w; + const top = pivot.y * m.d * h; + return { ...m, tx: onGrid(m.tx - left, steps) + left, ty: onGrid(m.ty - top, steps) + top }; +} + +/** + * The point a view looks from instead of `x`, so the world's origin lands on a whole pixel: `centre` + * is where the view puts what it looks at, `scale` pixels per unit and `ratio` pixels per CSS pixel + * (1 for a picture that counts its own). + */ +export function lookFrom(x: number, centre: number, scale: number, ratio = 1): number { + const origin = Math.round((centre - x * scale) * ratio) / ratio; + return (centre - origin) / scale; +} diff --git a/src/viewport/textPicture.ts b/src/viewport/textPicture.ts index 34f84871..3ba12c2f 100644 --- a/src/viewport/textPicture.ts +++ b/src/viewport/textPicture.ts @@ -11,6 +11,7 @@ import { type EdgeMode, SHEET_EDGE } from '../core/shader/nodes'; import { Theme } from '../ui'; import { fontLibrary } from './FontLibrary'; import type { DrawItem } from './items'; +import { snapPlacement } from './pixelGrid'; import { type ShaderSource, syncShader, wornShader, wornShaderId } from './shaders'; import { disposeSpriteMesh, newSpriteMesh, placeNode, syncQuadPadding } from './symbols'; import { ADDRESS_MODE } from './TextureLibrary'; @@ -90,7 +91,8 @@ export class TextPicture { private w = 1; private h = 1; - sync(item: DrawItem, scale: number, shaders: ShaderSource): void { + /** `grid`: the steps per unit of a pixel perfect camera's view (see `snapPlacement`). */ + sync(item: DrawItem, scale: number, shaders: ShaderSource, grid: number | null = null): void { // An uploaded face that has not arrived yet reads as `''`; until it does, the editor's own // typeface stands in, and the library's change event brings the redraw that swaps it. const family = fontLibrary.family(item.font) || Theme.font; @@ -110,10 +112,11 @@ export class TextPicture { const texture = this.texture!; const worn = wornShader(item.props, shaders); this.sample(texture, item.fontSmooth, worn?.edge ?? SHEET_EDGE); - placeNode(this.mesh, item.m, this.w, this.h); + const pivot = { x: textAnchor(item.props), y: 0.5 }; + placeNode(this.mesh, snapPlacement(item.m, this.w, this.h, grid, pivot), this.w, this.h); // A text's pixel is a world unit, whatever resolution the zoom had it rastered at. syncQuadPadding(this.mesh, worn?.padding ?? 0, this.w, this.h, worn?.vertex?.subdivisions ?? 1); - this.mesh.pivot.set(textAnchor(item.props), 0.5); + this.mesh.pivot.set(pivot.x, pivot.y); this.mesh.texture = texture; syncShader(this.mesh, item.props, shaders, { w: this.w, h: this.h }); // Only now: until the shader took the new picture, the old one was still bound to it. diff --git a/src/viewport/view.ts b/src/viewport/view.ts index a9ab4e4c..b1c43ac1 100644 --- a/src/viewport/view.ts +++ b/src/viewport/view.ts @@ -1,16 +1,25 @@ import { GAME_SCALE_STEPS, type GameScale } from '../core/model/playSettings'; import type { PropertyBag, Vec2 } from '../core/model/Properties'; -import { cameraFrame, cameraSize, rectAround, type WorldRect } from '../core/properties/camera'; +import { + cameraFrame, + cameraSize, + isPixelPerfect, + rectAround, + type WorldRect, +} from '../core/properties/camera'; +import { gridSteps, lookFrom } from './pixelGrid'; /** * The world point (`x`, `y`) sits at the centre of the screen rectangle `rect` (viewport-local - * pixels) at `scale` pixels per world unit. + * pixels) at `scale` pixels per world unit. `grid` is set through a pixel perfect camera: the steps + * per unit its objects are drawn on. */ export interface View { x: number; y: number; scale: number; rect: { x: number; y: number; w: number; h: number }; + grid?: number; } /** @@ -125,23 +134,39 @@ export function cameraView( const fw = Math.min(size.w * factor, w); const fh = Math.min(size.h * factor, h); const snap = mode === 'fit' ? (v: number) => v : (v: number) => Math.round(v * ratio) / ratio; + const rect = { x: snap((w - fw) / 2), y: snap((h - fh) / 2), w: fw, h: fh }; + if (mode === 'fit' || !isPixelPerfect(camera.props)) return { x: camera.x, y: camera.y, scale, rect }; + return onPixelGrid(camera, scale, rect, ratio); +} + +/** A pixel perfect camera's view: the world's origin on a whole device pixel, objects on its grid. */ +function onPixelGrid( + camera: { x: number; y: number }, + scale: number, + rect: View['rect'], + ratio: number, +): View { return { - x: camera.x, - y: camera.y, + x: lookFrom(camera.x, rect.x + rect.w / 2, scale, ratio), + y: lookFrom(camera.y, rect.y + rect.h / 2, scale, ratio), scale, - rect: { x: snap((w - fw) / 2), y: snap((h - fh) / 2), w: fw, h: fh }, + rect, + grid: gridSteps(scale * ratio), }; } /** * An overlay camera's picture (#302), laid on the main camera's: fitted into the main picture whole, * centred, never distorted – the same size and zoom give the very same view. What the main picture - * loses to the edge of the screen the overlay loses with it. + * loses to the edge of the screen the overlay loses with it. `fixed`: the main picture is at a fixed + * scale rather than fit, the only one a pixel perfect overlay snaps at. */ export function overlayView( camera: { x: number; y: number; props: PropertyBag }, main: View, mainFrame: { w: number; h: number }, + ratio = 1, + fixed = false, ): View { const frame = cameraFrame(camera.props); const pictureW = mainFrame.w * main.scale; @@ -151,7 +176,9 @@ export function overlayView( const h = Math.min(frame.h * scale, main.rect.h); const cx = main.rect.x + main.rect.w / 2; const cy = main.rect.y + main.rect.h / 2; - return { x: camera.x, y: camera.y, scale, rect: { x: cx - w / 2, y: cy - h / 2, w, h } }; + const rect = { x: cx - w / 2, y: cy - h / 2, w, h }; + if (!fixed || !isPixelPerfect(camera.props)) return { x: camera.x, y: camera.y, scale, rect }; + return onPixelGrid(camera, scale, rect, ratio); } /** @@ -166,10 +193,10 @@ export function freeViewOfCamera( mode: GameScale, ratio = 1, ): { x: number; y: number; zoom: number } { - const { rect, scale } = cameraView(camera, w, h, mode, ratio); + const { rect, scale, x, y } = cameraView(camera, w, h, mode, ratio); return { - x: camera.x - (rect.x + rect.w / 2 - w / 2) / scale, - y: camera.y - (rect.y + rect.h / 2 - h / 2) / scale, + x: x - (rect.x + rect.w / 2 - w / 2) / scale, + y: y - (rect.y + rect.h / 2 - h / 2) / scale, zoom: scale * ratio, }; } diff --git a/tests/viewport/pixel-grid.test.ts b/tests/viewport/pixel-grid.test.ts new file mode 100644 index 00000000..ad33163c --- /dev/null +++ b/tests/viewport/pixel-grid.test.ts @@ -0,0 +1,113 @@ +/** + * 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) => ({ + 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) => ({ + 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 }); + }); +}); diff --git a/tools/e2e/03-play.mjs b/tools/e2e/03-play.mjs index 0e321089..82c91bfc 100644 --- a/tools/e2e/03-play.mjs +++ b/tools/e2e/03-play.mjs @@ -418,6 +418,50 @@ export async function play(t) { await showTab('game'); g = await pickScale('100%'); check('and 100% can be picked back', g.mode === 1 && g.scale === 1, JSON.stringify(g)); + + // 3j. Pixel perfect, a switch on the camera: at a fixed scale the crate is drawn on whole device + // pixels although the camera stands between them. Off, and at fit, it is drawn where the model + // puts it. Read off the drawn picture, since the model keeps its fractions either way. + const cameraX = (await object('o3')).position.x; + const moveCamera = (x) => + mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o3').position.x = ${x}`); + const drawnCrate = async () => { + const drawn = await json(`__gm.gameDrawnRect('o1')`); + const model = await json(`__gm.gameRect('o1')`); + const ratio = drawn.w / model.w; + const left = (model.x - model.w / 2) * ratio; + const top = (model.y - model.h / 2) * ratio; + const whole = (v) => Math.abs(v - Math.round(v)) < 1e-6; + return { + onGrid: whole(drawn.x) && whole(drawn.y), + asModel: Math.abs(drawn.x - left) < 1e-6 && Math.abs(drawn.y - top) < 1e-6, + offModel: Math.max(Math.abs(drawn.x - left), Math.abs(drawn.y - top)), + drawn, + }; + }; + await pickScale('300%'); + await moveCamera(cameraX + 0.37); + const off = await drawnCrate(); + await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' }); + const pixelSwitch = await json("__gm.inspectorField('Pixel perfect')"); + await click(pixelSwitch.x, pixelSwitch.y); + const on = await drawnCrate(); + await pickScale('Fit'); + const atFit = await drawnCrate(); + check( + 'a pixel perfect camera draws the crate on whole pixels at 300%, but not at fit or switched off', + (await object('o3')).overrides.pixelPerfect === true && + !off.onGrid && + off.asModel && + on.onGrid && + // The origin and the corner move half a device pixel at most each. + on.offModel <= 1 + 1e-6 && + atFit.asModel, + JSON.stringify({ off, on, atFit }), + ); + await undo(); + await moveCamera(cameraX); + await pickScale('100%'); await showTab('scene'); check( 'the fixture is left as it was found',