Put a shader on a text and draw pixel fonts sharp (#169)
game-maker / gate (push) Successful in 4m5s
game-maker / suite (push) Failing after 11m40s

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:
adminandClaude Opus 5.5 committed 2026-09-29 06:01:28 +02:00
1 parent ef2600493f
commit 89aa92d2e3
13 files changed
+578 -101

No files matched your search

+28 -33
View File
@@ -324,6 +324,20 @@ export function isValidVariableName(name: string): boolean {
return isValidFieldName(name) && !isCommonProp(name);
}
/** How a picture is magnified: smoothed, or sharp for pixels. Pictures and typefaces share it (#169). */
function filterRow(smoothByDefault: boolean): PropertyDef {
return {
key: 'filter',
label: 'Filter',
kind: 'enum',
default: smoothByDefault ? 'linear' : 'nearest',
options: [
{ value: 'linear', label: 'Linear' },
{ value: 'nearest', label: 'Nearest' },
],
};
}
/** Position is deliberately absent: it belongs to scene objects, not to asset types. */
const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
scene: [
@@ -349,16 +363,7 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
],
// The picture itself is intrinsic data on the asset (see TextureAsset), not a property.
texture: [
{
key: 'filter',
label: 'Filter',
kind: 'enum',
default: 'linear',
options: [
{ value: 'linear', label: 'Linear' },
{ value: 'nearest', label: 'Nearest' },
],
},
filterRow(true),
// How big the borders of every sliced frame are drawn: 2 makes a 16 px border 32 units wide (#96).
{ key: SLICE_SCALE_KEY, label: 'Nine-slice scale', kind: 'number', default: 1, min: 0.01, step: 0.1 },
],
@@ -386,31 +391,13 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
step: 1,
decimals: 0,
},
{
key: 'filter',
label: 'Filter',
kind: 'enum',
default: 'linear',
options: [
{ value: 'linear', label: 'Linear' },
{ value: 'nearest', label: 'Nearest' },
],
},
filterRow(true),
],
// Layers, frames and the canvas size are intrinsic data on the asset (see PixelArtAsset). A palette
// is shared, so the asset names one; the filter is the texture's row, sharp by default for pixels.
pixelart: [
{ key: PALETTE_KEY, label: 'Palette', kind: 'asset', default: '', assetTypes: ['palette'] },
{
key: 'filter',
label: 'Filter',
kind: 'enum',
default: 'nearest',
options: [
{ value: 'linear', label: 'Linear' },
{ value: 'nearest', label: 'Nearest' },
],
},
filterRow(false),
],
// The colours are intrinsic data on the asset (see PaletteAsset).
palette: [],
@@ -527,8 +514,9 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
statemachine: [],
// The nodes are the asset's graph (see ShaderAsset); its parameters are rows of the sprites wearing it.
shader: [],
// The typeface itself is intrinsic data on the asset (see FontAsset), not a property.
font: [],
// The typeface itself is intrinsic data on the asset (see FontAsset), not a property. Nearest draws
// a pixel font a pixel per unit, magnified sharp.
font: [filterRow(true)],
// The file is intrinsic data (see SoundAsset); these two say how it is played by default, and a
// `Play sound` node scales the one and can add to the other.
sound: [
@@ -655,6 +643,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
{ value: 'right', label: 'Right' },
],
},
// Worn by the rectangle of the rendered text, as by a sprite's picture (#169).
{ key: SHADER_KEY, label: 'Shader', kind: 'asset', default: '', assetTypes: ['shader'] },
],
};
@@ -711,6 +701,11 @@ function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): Propert
const own = [...FULL_SCHEMAS.particles, ...particleParamDefs(asset.graph)];
return [...own, ...rowsNotClaimedBy(shaderParamRows(project, shaderOf(bag)), own)];
}
if (asset.type === 'text')
return [
...FULL_SCHEMAS.text,
...rowsNotClaimedBy(shaderParamRows(project, shaderOf(bag)), FULL_SCHEMAS.text),
];
return FULL_SCHEMAS[asset.type];
}
@@ -718,7 +713,7 @@ function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): Propert
* A source draws its rows from two graphs, so one name can arrive twice: `speed` on the particle
* graph and `speed` on the shader it wears. The source's own row wins and the uniform keeps its
* declared default – the shader cannot be blamed for the collision, the same one is legitimate on a
* sprite, so nothing is rejected on its side.
* sprite, so nothing is rejected on its side. A text's own rows (`fontSize`, `align` …) win the same way.
*/
export function rowsNotClaimedBy(rows: readonly PropertyDef[], taken: readonly PropertyDef[]): PropertyDef[] {
const claimed = new Set(taken.map((d) => d.key));