Put a shader on a text and draw pixel fonts sharp (#169)
A text could not wear a shader, and a pixel font was always smoothed. Pixi's Text takes no custom shader. It also shares one texture between texts of the same content and style, and the filter is not part of that key, so two equal texts with different filters would overwrite each other. - TextPicture rasters a text into a texture of its own and draws it like a sprite. Shader, time animation and edge mode work without a special path. A new text, font or size rasters again and keeps the shader. - The old texture is dropped only after the shader holds the new one. Before, Pixi warned about a destroyed texture that was still bound. - A text has a Shader row with the shader's property rows below, as a sprite has. Overrides, saving, animation and the Properties node (type Text) know them. A shader parameter named like an own row of the text loses to that row, as on the particle source. - The font asset has a Filter row, Linear or Nearest. The row existed three times already (texture, vector art, pixel art) and is now one helper filterRow. - Nearest rasters the text 8 times finer and cuts every 8x8 block to one texel: at least half covered is full, else empty. Chrome on Windows smooths canvas text sideways through DirectWrite whatever filter we set later. The cut removes those half pixels on every system, and the texture stays one texel per world unit. Found on the way: - On a font that is no pixel font thin strokes break apart under Nearest. For a pixel font at its own size this does not matter. Not done: - The preview in the Shader tab does not take a selected text as its picture, it shows the test picture then. Tests: - ./check.sh: 189 files, 2759 tests, CHECK EXIT 0. - text-shader.test.ts with 10 tests: rows and name precedence, a clamped override, save and load, animation, the font filter, and sharpCoverage on a smoothed edge and at the texture border. - e2e 30 has 7 new checks: red glyphs through the shader picked with the mouse, the shader survives a new text, red in the game, soft edges under Linear, clean 4x4 blocks under Nearest, and half covered texels (Linear 238, Nearest 0). - Full local run before the Nearest cut: all passed, 1543 checks. GM_STEPS=1,30 on the final bundle: all passed, 32 checks. The sum 1544 in the README is counted from these two runs, not measured. - Counter-checks fail as expected: in the browser no shader on a text fails 30h-30j, an always linear sampler fails the Nearest check, the old 1:1 raster leaves 107 half covered texels and fails 30k-ii. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ef2600493f
commit
89aa92d2e3
13 files changed
+578
-101
No files matched your search
@@ -17,7 +17,7 @@ const EDGE_MODE = 'mirror-repeat';
|
||||
* sampler state lives on the source – WebGL takes no style from the shader – so that is a cut of
|
||||
* its own with that address mode.
|
||||
*/
|
||||
const ADDRESS_MODE: Record<EdgeMode, 'repeat' | 'mirror-repeat' | 'clamp-to-edge'> = {
|
||||
export const ADDRESS_MODE: Record<EdgeMode, 'repeat' | 'mirror-repeat' | 'clamp-to-edge'> = {
|
||||
mirror: EDGE_MODE,
|
||||
repeat: 'repeat',
|
||||
clamp: 'clamp-to-edge',
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
solidRects,
|
||||
} from '../core/model/tilemapCollision';
|
||||
import { findAsset, findAssetOf, type ObjectHolder, type Project } from '../core/model/Project';
|
||||
import { fontFileOf } from '../core/model/font';
|
||||
import { fontFileOf, fontSmoothOf } from '../core/model/font';
|
||||
import { resolveTexture, type TextureRef } from '../core/model/texture';
|
||||
import type { FrameOutline } from '../core/model/frameOutline';
|
||||
import { liveTilemap, type TileEdits } from '../core/model/liveTilemap';
|
||||
@@ -59,6 +59,8 @@ export interface DrawItem {
|
||||
texture: TextureRef | null;
|
||||
/** The typeface file a text wears, resolved here for the same reason; `''` is the default one. */
|
||||
font: string;
|
||||
/** False when that face's Filter is Nearest: rasterised a pixel per unit and magnified sharp. */
|
||||
fontSmooth: boolean;
|
||||
/** The asset a particle source emits from, for the same reason again: it carries the graph. */
|
||||
particles: ParticlesAsset | null;
|
||||
/** What a placed tilemap draws: its layers already cut into tiles, with their pictures resolved. */
|
||||
@@ -212,6 +214,7 @@ export function collectHolderItems(
|
||||
enabled: true,
|
||||
texture: wearsPicture(asset.type) ? resolveTexture(project, props) : null,
|
||||
font: asset.type === 'text' ? fontFileOf(project, fontOf(props)) : '',
|
||||
fontSmooth: fontSmoothOf(project, fontOf(props)),
|
||||
particles: asset.type === 'particles' ? asset : null,
|
||||
tilemap:
|
||||
asset.type === 'tilemap'
|
||||
@@ -275,6 +278,7 @@ export function collectWorldItems(world: World): DrawList {
|
||||
// Resolved per frame on purpose: a `Properties` node or an animation may swap `texture`.
|
||||
texture: wearsPicture(e.type) ? resolveTexture(world.project, e.props) : null,
|
||||
font: e.type === 'text' ? fontFileOf(world.project, fontOf(e.props)) : '',
|
||||
fontSmooth: fontSmoothOf(world.project, fontOf(e.props)),
|
||||
particles: e.type === 'particles' ? (findAssetOf(world.project, e.assetId, 'particles') ?? null) : null,
|
||||
tilemap: e.type === 'tilemap' ? resolveTilemap(world.project, e.assetId, e.props, e.tileEdits) : null,
|
||||
emits: e.emits ?? 0,
|
||||
|
||||
@@ -175,6 +175,11 @@ function wornBy(mesh: Mesh): GraphShader | null {
|
||||
return mesh.shader instanceof GraphShader ? mesh.shader : null;
|
||||
}
|
||||
|
||||
/** The shader asset a mesh draws with, `''` for Pixi's own. */
|
||||
export function wornShaderId(mesh: Mesh): string {
|
||||
return wornBy(mesh)?.assetId ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the mesh's shader match `props`: updated in place while asset and fragment are the same,
|
||||
* rebuilt when either changed, dropped for no shader – and for one that does not compile, which
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 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 { type ShaderSource, syncShader, wornShader, wornShaderId } from './shaders';
|
||||
import { disposeSpriteMesh, newSpriteMesh, placeNode } 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.
|
||||
*/
|
||||
export 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 };
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
sync(item: DrawItem, scale: number, shaders: ShaderSource): 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]);
|
||||
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!;
|
||||
this.sample(texture, item.fontSmooth, wornShader(item.props, shaders)?.edge ?? SHEET_EDGE);
|
||||
placeNode(this.mesh, item.m, this.w, this.h);
|
||||
this.mesh.pivot.set(textAnchor(item.props), 0.5);
|
||||
this.mesh.texture = texture;
|
||||
syncShader(this.mesh, item.props, shaders);
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user