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
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* A text wears a shader the way a sprite does (#169), over the rectangle of its rendered glyphs, and
|
||||
* a font asset says how its glyphs are magnified, the way a texture does.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||
import { AddNodeCommand } from '../../src/core/commands/graphCommands';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { animatableDefsOf } from '../../src/core/model/animation';
|
||||
import { fontSmoothOf } from '../../src/core/model/font';
|
||||
import { findAssetOf } from '../../src/core/model/Project';
|
||||
import { schemaFor, schemaForAsset, schemaForObject } from '../../src/core/properties/PropertySchema';
|
||||
import { SHADER_KEY, SHADER_PROPERTY } from '../../src/core/properties/shaders';
|
||||
import { sharpCoverage, textResolution } from '../../src/viewport/textPicture';
|
||||
import { makeSpriteScene, place, roundTrip } from '../helpers';
|
||||
|
||||
function wearing(uniform = 'glow') {
|
||||
const f = makeSpriteScene();
|
||||
const text = new CreateAssetCommand('text', 'Title');
|
||||
const shader = new CreateAssetCommand('shader', 'Glow');
|
||||
f.stack.execute(text);
|
||||
f.stack.execute(shader);
|
||||
const declared = { name: uniform, default: 3, min: 0, max: 10 };
|
||||
f.stack.execute(new AddNodeCommand(shader.asset.id, SHADER_PROPERTY, 0, 0, declared));
|
||||
const textId = text.asset.id;
|
||||
const shaderId = shader.asset.id;
|
||||
const asset = () => findAssetOf(f.project, textId, 'text')!;
|
||||
const keys = () => schemaForAsset(f.project, asset()).map((d) => d.key);
|
||||
const wear = () => f.stack.execute(new SetAssetPropertyCommand(textId, SHADER_KEY, shaderId));
|
||||
return { ...f, textId, shaderId, asset, keys, wear };
|
||||
}
|
||||
|
||||
describe('a text wearing a shader', () => {
|
||||
it('has the picker after its own rows, and the shader’s rows once one is picked', () => {
|
||||
const f = wearing();
|
||||
expect(schemaFor('text').at(-1)).toMatchObject({
|
||||
key: SHADER_KEY,
|
||||
kind: 'asset',
|
||||
assetTypes: ['shader'],
|
||||
default: '',
|
||||
});
|
||||
expect(f.keys()).not.toContain('glow');
|
||||
f.wear();
|
||||
expect(f.keys().at(-1)).toBe('glow');
|
||||
expect(schemaForAsset(f.project, f.asset()).at(-1)).toMatchObject({ kind: 'number', default: 3 });
|
||||
});
|
||||
|
||||
it('keeps a name it already has: `fontSize` stays the text’s own row', () => {
|
||||
const f = wearing('fontSize');
|
||||
f.wear();
|
||||
expect(f.keys().filter((k) => k === 'fontSize')).toEqual(['fontSize']);
|
||||
expect(schemaForAsset(f.project, f.asset()).find((d) => d.key === 'fontSize')).toMatchObject({
|
||||
label: 'Font size',
|
||||
default: 32,
|
||||
});
|
||||
});
|
||||
|
||||
it('gives a placed text the rows of the shader it overrides', () => {
|
||||
const f = wearing();
|
||||
const id = place(f, f.textId, { x: 0, y: 0 });
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, id, SHADER_KEY, f.shaderId));
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'glow', 99));
|
||||
expect(schemaForObject(f.project, f.asset(), f.object(id)).map((d) => d.key)).toContain('glow');
|
||||
// Through its row: clamped to the node's range.
|
||||
expect(f.object(id).overrides.glow).toBe(10);
|
||||
});
|
||||
|
||||
it('carries the shader and its values through a save, on the asset and on an override', () => {
|
||||
const f = wearing();
|
||||
f.wear();
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.textId, 'glow', 5));
|
||||
const id = place(f, f.textId, { x: 0, y: 0 });
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'glow', 7));
|
||||
const loaded = roundTrip(f.project);
|
||||
expect(findAssetOf(loaded, f.textId, 'text')!.props).toMatchObject({ shader: f.shaderId, glow: 5 });
|
||||
const placed = findAssetOf(loaded, f.sceneId, 'scene')!.objects.find((o) => o.id === id)!;
|
||||
expect(placed.overrides).toEqual({ glow: 7 });
|
||||
});
|
||||
|
||||
it('offers the shader’s parameters to an animation', () => {
|
||||
const f = wearing();
|
||||
f.wear();
|
||||
expect(animatableDefsOf(f.project, f.asset()).map((d) => d.key)).toContain('glow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the filter of a font', () => {
|
||||
it('is a row of the font asset, smooth by default', () => {
|
||||
expect(schemaFor('font')).toEqual([expect.objectContaining({ key: 'filter', default: 'linear' })]);
|
||||
});
|
||||
|
||||
it('tells whether the text wearing the font is drawn smooth', () => {
|
||||
const f = makeSpriteScene();
|
||||
const font = new CreateAssetCommand('font', 'Pixel');
|
||||
f.stack.execute(font);
|
||||
const id = font.asset.id;
|
||||
expect(fontSmoothOf(f.project, id)).toBe(true);
|
||||
f.stack.execute(new SetAssetPropertyCommand(id, 'filter', 'nearest'));
|
||||
expect(fontSmoothOf(f.project, id)).toBe(false);
|
||||
// The editor's own face, and one that is gone, stay smooth.
|
||||
expect(fontSmoothOf(f.project, '')).toBe(true);
|
||||
expect(fontSmoothOf(f.project, 'gone')).toBe(true);
|
||||
expect(findAssetOf(roundTrip(f.project), id, 'font')!.props.filter).toBe('nearest');
|
||||
});
|
||||
|
||||
it('rasterises a sharp face at one pixel per unit, a smooth one with the zoom', () => {
|
||||
expect([0.5, 1, 1.37, 2, 3].map((scale) => textResolution(scale, false))).toEqual([1, 1, 1, 1, 1]);
|
||||
expect([0.5, 1, 1.37, 2, 3].map((scale) => textResolution(scale, true))).toEqual([1, 2, 3, 4, 4]);
|
||||
});
|
||||
|
||||
it('cuts a finely rasterised face down to hard texels without half coverage', () => {
|
||||
// 4×2 source at factor 2 → 2×1: the left block carries a smoothed edge (Windows' DirectWrite look)
|
||||
// with 3/4 ink, the right one only a fringe of 1/4 – kept and dropped.
|
||||
const alpha = [255, 255, 96, 0, 255, 160, 0, 64];
|
||||
const src = new Uint8ClampedArray(alpha.flatMap((a) => [255, 255, 255, a]));
|
||||
const sharp = sharpCoverage(src, 4, 2, 2, 0x336699);
|
||||
expect([sharp.width, sharp.height]).toEqual([2, 1]);
|
||||
expect([...sharp.data]).toEqual([0x33, 0x66, 0x99, 255, 0, 0, 0, 0]);
|
||||
});
|
||||
|
||||
it('counts the part of a border block beyond the source as empty', () => {
|
||||
// 3×1 at factor 2, fully inked: the first block holds two real texels of four (half – kept), the
|
||||
// second only one – under half, dropped.
|
||||
const src = new Uint8ClampedArray([0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255]);
|
||||
const sharp = sharpCoverage(src, 3, 1, 2, 0xffffff);
|
||||
expect([sharp.width, sharp.height]).toEqual([2, 1]);
|
||||
expect([...sharp.data].filter((_, i) => i % 4 === 3)).toEqual([255, 0]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user