A text was drawn into a texture as tall as Pixi measures the string "|ÉqÅM". Whatever reached further, like the "g" of Manrope (0.015 em below the "q"), was cut off at the bottom. The blue frame around a text was only estimated from the font size and the number of letters, so it was far too wide for most texts. - textPicture.ts takes the height of a line from the font itself (fontBoundingBoxAscent and fontBoundingBoxDescent, as a browser does). Pixi draws with a margin, and the picture is cut to that box afterwards. Where Pixi's own box is larger, Pixi's box wins. - Both raster paths (linear and nearest) use the cut. - The height no longer depends on the letters typed, so a text does not jump while it is edited. - bounds.ts asks a measurer that textPicture.ts registers (setTextMeasure). Selection, picking in a running game, framing the camera, the drop preview and culling all get the measured size. The footprint functions take the font file for this. - Unit tests in node have no canvas and keep the estimate (estimatedTextSize). - Texts get taller: Source Sans 3 at 48 px is 68 instead of 55 px, Manrope is 1.366 em instead of 1.25 em. The glyphs stay in place, the centre moves by less than half a pixel. A shader on a text gets a taller UV rectangle. Tests: - Gate on melektaus-atx green: 307 files, 4762 tests. - 5 new unit tests for textBox, textCut and the measurer hook. - e2e 30: 26 checks, 30f-iii new. The texture is 68 high, the font box is 68, the frame equals the drawn picture (237x68). - Counter test: with Pixi's old box the texture is 55 high and 30f-iii is red. - Not checked in the browser: the Manrope caption of the user and texts with several lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
345 lines
15 KiB
TypeScript
345 lines
15 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, CanvasTextMetrics, type Mesh, Texture, TextStyle } from 'pixi.js';
|
||
import { type PropertyBag, textOf } from '../core/model/Properties';
|
||
import { setTextMeasure, 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 };
|
||
}
|
||
|
||
/** Where a text lies on the canvas Pixi draws it on, in world units – see `textBox`. */
|
||
export interface TextBox {
|
||
width: number;
|
||
height: number;
|
||
/** The box's top edge, from the top of Pixi's own (unpadded) box; zero or above it. */
|
||
top: number;
|
||
/** The padding Pixi has to draw with, so that the box lies inside its canvas. */
|
||
room: number;
|
||
}
|
||
|
||
/** The faces' own line boxes, by CSS font string. */
|
||
const faceBoxes = new Map<string, { ascent: number; descent: number }>();
|
||
let faceProbe: OffscreenCanvasRenderingContext2D | null = null;
|
||
|
||
function faceBox(font: string): { ascent: number; descent: number } | null {
|
||
const known = faceBoxes.get(font);
|
||
if (known) return known;
|
||
faceProbe ??= new OffscreenCanvas(1, 1).getContext('2d');
|
||
if (!faceProbe) return null;
|
||
faceProbe.font = font;
|
||
const metrics = faceProbe.measureText('M');
|
||
// Missing in a browser that predates the line box; Pixi's own box then stands alone.
|
||
if (!Number.isFinite(metrics.fontBoundingBoxAscent)) return null;
|
||
const box = { ascent: metrics.fontBoundingBoxAscent, descent: metrics.fontBoundingBoxDescent };
|
||
faceBoxes.set(font, box);
|
||
return box;
|
||
}
|
||
|
||
/**
|
||
* Pixi makes a line as tall as "|ÉqÅM" reaches above and below the baseline, and whatever reaches
|
||
* further is off its canvas – the tail of Manrope's "g" was. A text takes the line box its face
|
||
* declares (`fontBoundingBox…`) instead, or Pixi's where that one is taller, so every glyph that
|
||
* keeps to its face's metrics is whole, and the height does not change with what is typed.
|
||
*/
|
||
export function textBox(
|
||
pixi: { width: number; lineHeight: number; lines: number; ascent: number; descent: number },
|
||
face: { ascent: number; descent: number } | null,
|
||
): TextBox {
|
||
const above = Math.max(pixi.ascent, face?.ascent ?? 0);
|
||
const below = Math.max(pixi.descent, face?.descent ?? 0);
|
||
return {
|
||
width: Math.ceil(Math.max(1, pixi.width)),
|
||
height: above + (pixi.lines - 1) * pixi.lineHeight + below,
|
||
top: pixi.ascent - above,
|
||
room: Math.ceil(Math.max(above - pixi.ascent, below - pixi.descent)),
|
||
};
|
||
}
|
||
|
||
function measureBox(text: string, style: TextStyle): TextBox {
|
||
const measured = CanvasTextMetrics.measureText(text || ' ', style);
|
||
const { ascent, descent } = measured.fontProperties;
|
||
const pixi = { width: measured.width, lineHeight: measured.lineHeight, lines: measured.lines.length };
|
||
return textBox({ ...pixi, ascent, descent }, faceBox(style._fontString));
|
||
}
|
||
|
||
/**
|
||
* The box's rectangle in the pixels of a canvas drawn at `resolution` with the box's `room` as
|
||
* padding. Its edges fall on whole steps of `1 / snap` world units: a pixel for a smooth face, a
|
||
* whole unit for a sharp one, whose blocks of `resolution` pixels each make a texel.
|
||
*/
|
||
export function textCut(
|
||
box: TextBox,
|
||
resolution: number,
|
||
snap: number,
|
||
): { x: number; y: number; w: number; h: number } {
|
||
const step = resolution / snap;
|
||
const top = Math.floor((box.room + box.top) * snap);
|
||
const bottom = Math.ceil((box.room + box.top + box.height) * snap);
|
||
return { x: box.room * resolution, y: top * step, w: box.width * resolution, h: (bottom - top) * step };
|
||
}
|
||
|
||
/** Sets what a text's props say about its glyphs, `family` being its face as the browser knows it. */
|
||
function styleText(style: TextStyle, props: PropertyBag, family: string): string {
|
||
style.fontFamily = family;
|
||
style.fontSize = Math.max(1, Number(props.fontSize ?? 32));
|
||
style.letterSpacing = Number(props.letterSpacing ?? 0) || 0;
|
||
return textOf(props.text ?? '');
|
||
}
|
||
|
||
const measureStyle = new TextStyle({ fontFamily: Theme.font, fontSize: 32 });
|
||
const measuredTexts = new Map<string, { w: number; h: number }>();
|
||
|
||
/**
|
||
* A text's footprint is the box its picture is cut to, so the frame around it, a click on it and
|
||
* the camera's framing all go by what is drawn rather than by an estimate. Without a canvas – a
|
||
* unit test in node – there is nothing to measure with, and the estimate stands.
|
||
*/
|
||
setTextMeasure((props, font) => {
|
||
if (typeof OffscreenCanvas === 'undefined') return null;
|
||
const text = styleText(measureStyle, props, fontLibrary.family(font) || Theme.font);
|
||
const key = JSON.stringify([text, measureStyle._fontString, measureStyle.letterSpacing]);
|
||
const known = measuredTexts.get(key);
|
||
if (known) return known;
|
||
const box = measureBox(text, measureStyle);
|
||
const size = { w: box.width, h: box.height };
|
||
if (measuredTexts.size >= 1000) measuredTexts.clear();
|
||
measuredTexts.set(key, size);
|
||
return size;
|
||
});
|
||
|
||
/**
|
||
* 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 text = styleText(this.style, item.props, fontLibrary.family(item.font) || Theme.font);
|
||
const color = Number(item.props.color ?? 0xffffff);
|
||
const resolution = textResolution(scale, item.fontSmooth);
|
||
const key = JSON.stringify([text, this.style._fontString, this.style.letterSpacing, color, resolution]);
|
||
shownTexts.set(this.mesh, text);
|
||
const stale = key !== this.rasterKey;
|
||
const previous = stale ? this.texture : null;
|
||
if (stale) {
|
||
this.rasterKey = key;
|
||
this.style.fill = color;
|
||
if (item.fontSmooth) this.raster(text, resolution);
|
||
else this.rasterSharp(text, 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 size before the view's zoom, how the glyphs are sampled, how
|
||
* many texels of the picture are neither empty nor fully covered, and how many in its top and its
|
||
* bottom row are inked – a glyph cut off at an edge leaves ink there.
|
||
*/
|
||
info(): {
|
||
family: string;
|
||
width: number;
|
||
height: number;
|
||
resolution: number;
|
||
smooth: boolean;
|
||
shader: string;
|
||
partial: number;
|
||
edgeInk: { top: number; bottom: number };
|
||
} {
|
||
const source = this.texture?.source;
|
||
return {
|
||
family: String(this.style.fontFamily),
|
||
width: Math.round(Math.abs(this.mesh.scale.x)),
|
||
height: Math.round(Math.abs(this.mesh.scale.y)),
|
||
resolution: source?.resolution ?? 0,
|
||
smooth: source?.style.scaleMode !== 'nearest',
|
||
shader: wornShaderId(this.mesh),
|
||
...this.texels(),
|
||
};
|
||
}
|
||
|
||
private texels(): { partial: number; edgeInk: { top: number; bottom: number } } {
|
||
const canvas = this.texture?.source.resource as OffscreenCanvas | undefined;
|
||
const context = canvas?.getContext('2d');
|
||
if (!canvas || !context) return { partial: 0, edgeInk: { top: 0, bottom: 0 } };
|
||
const { data } = context.getImageData(0, 0, canvas.width, canvas.height);
|
||
const row = canvas.width * 4;
|
||
let partial = 0;
|
||
let top = 0;
|
||
let bottom = 0;
|
||
for (let i = 3; i < data.length; i += 4) {
|
||
const alpha = data[i]!;
|
||
if (alpha > 0 && alpha < 255) partial++;
|
||
if (alpha > 0 && i < row) top++;
|
||
if (alpha > 0 && i >= data.length - row) bottom++;
|
||
}
|
||
return { partial, edgeInk: { top, bottom } };
|
||
}
|
||
|
||
destroy(): void {
|
||
disposeSpriteMesh(this.mesh);
|
||
this.texture?.destroy(true);
|
||
this.texture = null;
|
||
}
|
||
|
||
private raster(text: string, resolution: number): void {
|
||
const box = measureBox(text, this.style);
|
||
this.style.padding = box.room;
|
||
const { canvasAndContext } = 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 cut = textCut(box, resolution, resolution);
|
||
const width = cut.w;
|
||
const height = cut.h;
|
||
const canvas = new OffscreenCanvas(width, height);
|
||
canvas
|
||
.getContext('2d')
|
||
?.drawImage(
|
||
canvasAndContext.canvas as CanvasImageSource,
|
||
cut.x,
|
||
cut.y,
|
||
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, color: number): void {
|
||
const box = measureBox(text, this.style);
|
||
this.style.padding = box.room;
|
||
const { canvasAndContext } = CanvasTextGenerator.getCanvasAndContext({
|
||
text,
|
||
style: this.style,
|
||
resolution: SHARP_SUPERSAMPLE,
|
||
});
|
||
const cut = textCut(box, SHARP_SUPERSAMPLE, 1);
|
||
const fine = canvasAndContext.context.getImageData(cut.x, cut.y, cut.w, cut.h).data;
|
||
CanvasTextGenerator.returnCanvasAndContext(canvasAndContext);
|
||
const sharp = sharpCoverage(fine, cut.w, cut.h, 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();
|
||
}
|
||
}
|