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>
This commit is contained in:
1 parent
92395f6773
commit
1665576fb9
14 files changed
+353
-33
No files matched your search
@@ -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
|
||||
|
||||
@@ -470,6 +470,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
{ 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'] },
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -153,6 +153,10 @@ export class GameViewport extends WorldViewport<EditorState> {
|
||||
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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<S extends ViewSource = ViewSource> 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<S extends ViewSource = ViewSource> 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<S extends ViewSource = ViewSource> 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<S extends ViewSource = ViewSource> 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<string>();
|
||||
@@ -388,11 +411,12 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> 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<S extends ViewSource = ViewSource> 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<S extends ViewSource = ViewSource> 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<S extends ViewSource = ViewSource> 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<S extends ViewSource = ViewSource> 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<S extends ViewSource = ViewSource> 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<S extends ViewSource = ViewSource> 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<S extends ViewSource = ViewSource> 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+37
-10
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<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 });
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user