Files
feuerwerk/src/viewport/textPicture.ts
T
adminandClaude Opus 5.5 d4ff652293
feuerwerk / gate (push) Successful in 5m21s
feuerwerk / suite (push) Successful in 18m31s
Keep descenders of a text whole and frame it at its drawn size
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>
2026-10-13 15:18:59 +02:00

345 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
}
}