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>
223 lines
9.6 KiB
TypeScript
223 lines
9.6 KiB
TypeScript
/**
|
||
* A text object drawn the way a sprite is (#169): its glyphs rasterised into a texture of its own and
|
||
* put on the sprite quad, so a shader replaces the fragment of that draw just as on a sprite, and the
|
||
* UV run over the rectangle of the rendered text. Pixi's own `Text` takes no shader, and it shares
|
||
* one texture between texts of the same content and style, whatever filter each of them wants.
|
||
*/
|
||
import { CanvasSource, CanvasTextGenerator, type Mesh, Texture, TextStyle } from 'pixi.js';
|
||
import { textOf } from '../core/model/Properties';
|
||
import { textAnchor } from '../core/properties/bounds';
|
||
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';
|
||
|
||
/**
|
||
* How many texture pixels a world unit of text gets. A smooth face follows the zoom in coarse steps –
|
||
* re-rendering the glyphs per wheel notch is wasteful. A sharp one is rasterised at one pixel per
|
||
* unit and then magnified without smoothing, which is what "Nearest" means on a texture as well.
|
||
*/
|
||
export function textResolution(scale: number, smooth: boolean): number {
|
||
if (!smooth) return 1;
|
||
return Math.min(4, Math.max(1, (Math.round(scale * 2) / 2) * 2));
|
||
}
|
||
|
||
/**
|
||
* How much finer than one pixel per unit a sharp face is rasterised before it is brought down again.
|
||
* Chrome on Windows smooths canvas glyphs sideways (DirectWrite) whatever the filter is afterwards;
|
||
* at this factor the smoothing is an eighth of a font pixel and drowns in the block around it.
|
||
*/
|
||
const SHARP_SUPERSAMPLE = 8;
|
||
|
||
/**
|
||
* Glyphs rasterised `factor` times too fine, brought down to one texel per `factor`×`factor` block:
|
||
* the block's mean coverage is cut hard at half, and a kept texel is the text colour at full alpha.
|
||
* So a sharp face has no half-covered texel on any platform, and its texture is as small as at 1:1.
|
||
*/
|
||
export function sharpCoverage(
|
||
src: Uint8ClampedArray,
|
||
width: number,
|
||
height: number,
|
||
factor: number,
|
||
color: number,
|
||
): { data: Uint8ClampedArray<ArrayBuffer>; width: number; height: number } {
|
||
const w = Math.max(1, Math.ceil(width / factor));
|
||
const h = Math.max(1, Math.ceil(height / factor));
|
||
const data = new Uint8ClampedArray(new ArrayBuffer(w * h * 4));
|
||
const half = (255 * factor * factor) / 2;
|
||
const r = (color >> 16) & 0xff;
|
||
const g = (color >> 8) & 0xff;
|
||
const b = color & 0xff;
|
||
for (let y = 0; y < h; y++) {
|
||
for (let x = 0; x < w; x++) {
|
||
let sum = 0;
|
||
// Beyond the source's edge counts as empty, so a partial block at the border needs more ink.
|
||
for (let sy = y * factor; sy < Math.min(height, (y + 1) * factor); sy++) {
|
||
for (let sx = x * factor; sx < Math.min(width, (x + 1) * factor); sx++)
|
||
sum += src[(sy * width + sx) * 4 + 3]!;
|
||
}
|
||
if (sum < half) continue;
|
||
const at = (y * w + x) * 4;
|
||
data[at] = r;
|
||
data[at + 1] = g;
|
||
data[at + 2] = b;
|
||
data[at + 3] = 255;
|
||
}
|
||
}
|
||
return { data, width: w, height: h };
|
||
}
|
||
|
||
/**
|
||
* The text each picture shows, by its mesh. The mesh is a sprite quad and carries no text, so the test
|
||
* hook reads a game text through here – otherwise `locate` finds only the editor's own labels.
|
||
*/
|
||
const shownTexts = new WeakMap<Mesh, string>();
|
||
|
||
/** The text a node on screen draws as a `TextPicture`, or undefined for any other node. */
|
||
export function pictureText(node: object): string | undefined {
|
||
return shownTexts.get(node as Mesh);
|
||
}
|
||
|
||
export class TextPicture {
|
||
readonly mesh: Mesh = newSpriteMesh();
|
||
private readonly style = new TextStyle({ fontFamily: Theme.font, fontSize: 32, fill: 0xffffff });
|
||
private texture: Texture | null = null;
|
||
private rasterKey = '';
|
||
/** The text's rectangle in world units, as rasterised. */
|
||
private w = 1;
|
||
private h = 1;
|
||
|
||
/** `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;
|
||
const text = textOf(item.props.text ?? '');
|
||
const size = Math.max(1, Number(item.props.fontSize ?? 32));
|
||
const color = Number(item.props.color ?? 0xffffff);
|
||
const resolution = textResolution(scale, item.fontSmooth);
|
||
const key = JSON.stringify([text, family, size, color, resolution]);
|
||
shownTexts.set(this.mesh, text);
|
||
const stale = key !== this.rasterKey;
|
||
const previous = stale ? this.texture : null;
|
||
if (stale) {
|
||
this.rasterKey = key;
|
||
if (item.fontSmooth) this.raster(text, family, size, color, resolution);
|
||
else this.rasterSharp(text, family, size, color);
|
||
}
|
||
const texture = this.texture!;
|
||
const worn = wornShader(item.props, shaders);
|
||
this.sample(texture, item.fontSmooth, worn?.edge ?? SHEET_EDGE);
|
||
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(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.
|
||
previous?.destroy(true);
|
||
}
|
||
|
||
/**
|
||
* What a check reads: the face, the width before the view's zoom, how the glyphs are sampled, and
|
||
* how many texels of the picture are neither empty nor fully covered.
|
||
*/
|
||
info(): {
|
||
family: string;
|
||
width: number;
|
||
resolution: number;
|
||
smooth: boolean;
|
||
shader: string;
|
||
partial: number;
|
||
} {
|
||
const source = this.texture?.source;
|
||
return {
|
||
family: String(this.style.fontFamily),
|
||
width: Math.round(Math.abs(this.mesh.scale.x)),
|
||
resolution: source?.resolution ?? 0,
|
||
smooth: source?.style.scaleMode !== 'nearest',
|
||
shader: wornShaderId(this.mesh),
|
||
partial: this.partialTexels(),
|
||
};
|
||
}
|
||
|
||
private partialTexels(): number {
|
||
const canvas = this.texture?.source.resource as OffscreenCanvas | undefined;
|
||
const context = canvas?.getContext('2d');
|
||
if (!canvas || !context) return 0;
|
||
const { data } = context.getImageData(0, 0, canvas.width, canvas.height);
|
||
let partial = 0;
|
||
for (let i = 3; i < data.length; i += 4) if (data[i]! > 0 && data[i]! < 255) partial++;
|
||
return partial;
|
||
}
|
||
|
||
destroy(): void {
|
||
disposeSpriteMesh(this.mesh);
|
||
this.texture?.destroy(true);
|
||
this.texture = null;
|
||
}
|
||
|
||
private raster(text: string, family: string, size: number, color: number, resolution: number): void {
|
||
this.style.fontFamily = family;
|
||
this.style.fontSize = size;
|
||
this.style.fill = color;
|
||
const { canvasAndContext, frame } = CanvasTextGenerator.getCanvasAndContext({
|
||
text,
|
||
style: this.style,
|
||
resolution,
|
||
});
|
||
// The pooled canvas goes back at once and may be bigger than the text, so the text is copied onto
|
||
// a canvas of exactly its size: the quad's 0..1 UV are then the text's rectangle and nothing more.
|
||
const { width, height } = frame;
|
||
const canvas = new OffscreenCanvas(width, height);
|
||
canvas
|
||
.getContext('2d')
|
||
?.drawImage(canvasAndContext.canvas as CanvasImageSource, 0, 0, width, height, 0, 0, width, height);
|
||
CanvasTextGenerator.returnCanvasAndContext(canvasAndContext);
|
||
this.texture = new Texture({
|
||
source: new CanvasSource({ resource: canvas, resolution, alphaMode: 'premultiply-alpha-on-upload' }),
|
||
});
|
||
this.w = width / resolution;
|
||
this.h = height / resolution;
|
||
}
|
||
|
||
/** A sharp face: rasterised `SHARP_SUPERSAMPLE` times finer and cut down to hard texels. */
|
||
private rasterSharp(text: string, family: string, size: number, color: number): void {
|
||
this.style.fontFamily = family;
|
||
this.style.fontSize = size;
|
||
this.style.fill = color;
|
||
const { canvasAndContext, frame } = CanvasTextGenerator.getCanvasAndContext({
|
||
text,
|
||
style: this.style,
|
||
resolution: SHARP_SUPERSAMPLE,
|
||
});
|
||
const fine = canvasAndContext.context.getImageData(0, 0, frame.width, frame.height).data;
|
||
CanvasTextGenerator.returnCanvasAndContext(canvasAndContext);
|
||
const sharp = sharpCoverage(fine, frame.width, frame.height, SHARP_SUPERSAMPLE, color);
|
||
const canvas = new OffscreenCanvas(sharp.width, sharp.height);
|
||
canvas.getContext('2d')?.putImageData(new ImageData(sharp.data, sharp.width, sharp.height), 0, 0);
|
||
this.texture = new Texture({
|
||
source: new CanvasSource({ resource: canvas, resolution: 1, alphaMode: 'premultiply-alpha-on-upload' }),
|
||
});
|
||
this.w = sharp.width;
|
||
this.h = sharp.height;
|
||
}
|
||
|
||
/** The texture is this text's alone, so filter and edge are set on it rather than on a variant. */
|
||
private sample(texture: Texture, smooth: boolean, edge: EdgeMode): void {
|
||
const style = texture.source.style;
|
||
const scaleMode = smooth ? 'linear' : 'nearest';
|
||
const addressMode = ADDRESS_MODE[edge];
|
||
if (style.scaleMode === scaleMode && style.addressMode === addressMode) return;
|
||
style.scaleMode = scaleMode;
|
||
style.addressMode = addressMode;
|
||
// The setters leave the style's cached sampler id stale – see `TextureLibrary.get`.
|
||
style.update();
|
||
}
|
||
}
|