/** * 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; 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(); 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(); /** * 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(); /** 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(); } }