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

+1 -1
View File
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU: The browser suite and the three example games need a machine with a GPU:
```bash ```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1535 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1544 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+1 -1
View File
@@ -379,7 +379,7 @@ export function animatableDefsOf(
const declared = const declared =
asset.type === 'behavior' asset.type === 'behavior'
? asset.variables ? asset.variables
: asset.type === 'sprite' : asset.type === 'sprite' || asset.type === 'text'
? shaderParamRows(project, shaderOf(bag)) ? shaderParamRows(project, shaderOf(bag))
: []; : [];
const taken = new Set(own.map((d) => d.key)); const taken = new Set(own.map((d) => d.key));
+10
View File
@@ -1,4 +1,5 @@
import { findAsset, type Project } from './Project'; import { findAsset, type Project } from './Project';
import { textOf } from './Properties';
/** Mirrors `FontStore::isValidName` on the server, where the name is derived from the bytes. */ /** Mirrors `FontStore::isValidName` on the server, where the name is derived from the bytes. */
const FILE_PATTERN = /^[a-f0-9]{64}\.(ttf|otf|woff|woff2)$/; const FILE_PATTERN = /^[a-f0-9]{64}\.(ttf|otf|woff|woff2)$/;
@@ -16,6 +17,15 @@ export function fontFamilyOf(file: string): string {
return `gm-font-${file.slice(0, 16)}`; return `gm-font-${file.slice(0, 16)}`;
} }
/**
* Whether the face a `font` property points at is drawn smoothed, the font asset's Filter row the
* way it is a texture's. The editor's own typeface, and a dangling id, are smooth.
*/
export function fontSmoothOf(project: Project, id: string): boolean {
const asset = id === '' ? undefined : findAsset(project, id);
return asset?.type !== 'font' || textOf(asset.props.filter ?? 'linear') !== 'nearest';
}
/** The file a `font` property points at, empty when the id is unset or no longer resolves. */ /** The file a `font` property points at, empty when the id is unset or no longer resolves. */
export function fontFileOf(project: Project, id: string): string { export function fontFileOf(project: Project, id: string): string {
if (id === '') return ''; if (id === '') return '';
+28 -33
View File
@@ -324,6 +324,20 @@ export function isValidVariableName(name: string): boolean {
return isValidFieldName(name) && !isCommonProp(name); 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. */ /** Position is deliberately absent: it belongs to scene objects, not to asset types. */
const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = { const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
scene: [ 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. // The picture itself is intrinsic data on the asset (see TextureAsset), not a property.
texture: [ texture: [
{ filterRow(true),
key: 'filter',
label: 'Filter',
kind: 'enum',
default: 'linear',
options: [
{ value: 'linear', label: 'Linear' },
{ value: 'nearest', label: 'Nearest' },
],
},
// How big the borders of every sliced frame are drawn: 2 makes a 16 px border 32 units wide (#96). // 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 }, { 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, step: 1,
decimals: 0, decimals: 0,
}, },
{ filterRow(true),
key: 'filter',
label: 'Filter',
kind: 'enum',
default: 'linear',
options: [
{ value: 'linear', label: 'Linear' },
{ value: 'nearest', label: 'Nearest' },
],
},
], ],
// Layers, frames and the canvas size are intrinsic data on the asset (see PixelArtAsset). A palette // 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. // is shared, so the asset names one; the filter is the texture's row, sharp by default for pixels.
pixelart: [ pixelart: [
{ key: PALETTE_KEY, label: 'Palette', kind: 'asset', default: '', assetTypes: ['palette'] }, { key: PALETTE_KEY, label: 'Palette', kind: 'asset', default: '', assetTypes: ['palette'] },
{ filterRow(false),
key: 'filter',
label: 'Filter',
kind: 'enum',
default: 'nearest',
options: [
{ value: 'linear', label: 'Linear' },
{ value: 'nearest', label: 'Nearest' },
],
},
], ],
// The colours are intrinsic data on the asset (see PaletteAsset). // The colours are intrinsic data on the asset (see PaletteAsset).
palette: [], palette: [],
@@ -527,8 +514,9 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
statemachine: [], statemachine: [],
// The nodes are the asset's graph (see ShaderAsset); its parameters are rows of the sprites wearing it. // The nodes are the asset's graph (see ShaderAsset); its parameters are rows of the sprites wearing it.
shader: [], shader: [],
// The typeface itself is intrinsic data on the asset (see FontAsset), not a property. // The typeface itself is intrinsic data on the asset (see FontAsset), not a property. Nearest draws
font: [], // 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 // 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. // `Play sound` node scales the one and can add to the other.
sound: [ sound: [
@@ -655,6 +643,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
{ value: 'right', label: 'Right' }, { 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)]; const own = [...FULL_SCHEMAS.particles, ...particleParamDefs(asset.graph)];
return [...own, ...rowsNotClaimedBy(shaderParamRows(project, shaderOf(bag)), own)]; 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]; 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 * 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 * 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 * 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[] { export function rowsNotClaimedBy(rows: readonly PropertyDef[], taken: readonly PropertyDef[]): PropertyDef[] {
const claimed = new Set(taken.map((d) => d.key)); const claimed = new Set(taken.map((d) => d.key));
+5 -5
View File
@@ -112,11 +112,11 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
const shaderGraphs = new Map<string, Graph>(); const shaderGraphs = new Map<string, Graph>();
const shaderRows = new Map<string, PropertyDef[]>(); const shaderRows = new Map<string, PropertyDef[]>();
const particleGraphs = new Map<string, Graph>(); const particleGraphs = new Map<string, Graph>();
const wearers = new Map<string, 'sprite' | 'particles'>(); const wearers = new Map<string, 'sprite' | 'particles' | 'text'>();
for (const a of rawAssets) { for (const a of rawAssets) {
if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue; if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue;
rawProps.set(a.id, asRecord(a.props)); rawProps.set(a.id, asRecord(a.props));
if (a.type === 'sprite' || a.type === 'particles') wearers.set(a.id, a.type); if (a.type === 'sprite' || a.type === 'particles' || a.type === 'text') wearers.set(a.id, a.type);
if (a.type === 'behavior' || a.type === 'variables' || a.type === 'struct') { if (a.type === 'behavior' || a.type === 'variables' || a.type === 'struct') {
const vars = normalizeVariables(a.variables); const vars = normalizeVariables(a.variables);
variables.set(a.id, vars); variables.set(a.id, vars);
@@ -139,8 +139,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
} }
} }
/** /**
* A sprite's or source's rows end with the parameters of the shader its bag names – an override's, * A sprite's, source's or text's rows end with the parameters of the shader its bag names – an
* else the asset's. A source already carries its own graph's rows, so a name it has stays its own. * override's, else the asset's. Only a sprite's rows yield to none: a name the others have stays theirs.
*/ */
const schemaOf = (assetId: string, bag: Record<string, unknown>): PropertySchema | undefined => { const schemaOf = (assetId: string, bag: Record<string, unknown>): PropertySchema | undefined => {
const schema = schemas.get(assetId); const schema = schemas.get(assetId);
@@ -149,7 +149,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
const own = bag[SHADER_KEY]; const own = bag[SHADER_KEY];
const shader = typeof own === 'string' ? own : rawProps.get(assetId)?.[SHADER_KEY]; const shader = typeof own === 'string' ? own : rawProps.get(assetId)?.[SHADER_KEY];
const rows = shaderRows.get(typeof shader === 'string' ? shader : '') ?? []; const rows = shaderRows.get(typeof shader === 'string' ? shader : '') ?? [];
return [...schema, ...(wearer === 'particles' ? rowsNotClaimedBy(rows, schema) : rows)]; return [...schema, ...(wearer === 'sprite' ? rows : rowsNotClaimedBy(rows, schema))];
}; };
// Pass 2: build assets. Frame ids share the reference space with asset ids, so `normalizeTexture` // Pass 2: build assets. Frame ids share the reference space with asset ids, so `normalizeTexture`
+4 -4
View File
@@ -95,10 +95,10 @@ export function buildPropertyPortSection(
} }
if (target !== 'behavior') { if (target !== 'behavior') {
const owner = `a ${targetLabel(target)}`; const owner = `a ${targetLabel(target)}`;
// A sprite's rows go on with the parameters of the shader it wears – any shader, the node cannot // A sprite's or text's rows go on with the parameters of the shader it wears – any shader, the node
// know which, so each entry says whose it is. Keyed by the def itself: a shader parameter and a // cannot know which, so each entry says whose it is. Keyed by the def itself: a shader parameter and
// sprite property may share a name, and only the shader's entry earns the note. // a property of the type may share a name, and only the shader's entry earns the note.
const shaderRows = target === 'sprite' ? allShaderParamRows(host.state.project) : []; const shaderRows = target === 'sprite' || target === 'text' ? allShaderParamRows(host.state.project) : [];
const from = new Map(shaderRows.map((row) => [row.def, row.shaders.join(', ')])); const from = new Map(shaderRows.map((row) => [row.def, row.shaders.join(', ')]));
buildPickedPortSection(host, assetId, node, { buildPickedPortSection(host, assetId, node, {
noun: 'Property', noun: 'Property',
+18 -48
View File
@@ -1,19 +1,11 @@
import { import { Container, type FederatedPointerEvent, Graphics, type Mesh, Texture } from 'pixi.js';
Container,
type FederatedPointerEvent,
Graphics,
type Mesh,
Text,
Texture,
TextStyle,
} from 'pixi.js';
import { isSymbolType } from '../../core/model/AssetTypes'; import { isSymbolType } from '../../core/model/AssetTypes';
import { type Extent, scaleOf } from '../../core/model/matrix'; import { type Extent, scaleOf } from '../../core/model/matrix';
import { type Vec2, textOf } from '../../core/model/Properties'; import type { Vec2 } from '../../core/model/Properties';
import { footprintExtent, footprintOf, placementOf, textAnchor } from '../../core/properties/bounds'; import { footprintExtent, footprintOf, placementOf } from '../../core/properties/bounds';
import { rectsIntersect } from '../../core/properties/camera'; import { rectsIntersect } from '../../core/properties/camera';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { type Size, Theme, Widget } from '../../ui'; import { type Size, Widget } from '../../ui';
import { fontLibrary } from '../../viewport/FontLibrary'; import { fontLibrary } from '../../viewport/FontLibrary';
import { drawSlots } from '../../viewport/drawOrder'; import { drawSlots } from '../../viewport/drawOrder';
import type { DrawItem } from '../../viewport/items'; import type { DrawItem } from '../../viewport/items';
@@ -23,13 +15,8 @@ import { particleLibrary } from '../../viewport/ParticleLibrary';
import { SHEET_EDGE } from '../../core/shader/nodes'; import { SHEET_EDGE } from '../../core/shader/nodes';
import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { type ShaderSource, wornShader } from '../../viewport/shaders'; import { type ShaderSource, wornShader } from '../../viewport/shaders';
import { import { disposeSpriteMesh, drawItem, newSpriteMesh, syncSpriteMesh } from '../../viewport/symbols';
disposeSpriteMesh, import { TextPicture } from '../../viewport/textPicture';
drawItem,
newSpriteMesh,
placeNode,
syncSpriteMesh,
} from '../../viewport/symbols';
import { cellCentre, type LayerFocus, TilemapField } from '../../viewport/tilemap'; import { cellCentre, type LayerFocus, TilemapField } from '../../viewport/tilemap';
import { textureLibrary } from '../../viewport/TextureLibrary'; import { textureLibrary } from '../../viewport/TextureLibrary';
import { gameClock } from '../../core/time'; import { gameClock } from '../../core/time';
@@ -51,7 +38,7 @@ export abstract class WorldViewport extends Widget {
protected readonly contentMask = new Graphics(); protected readonly contentMask = new Graphics();
protected readonly nodes = new Map<string, Graphics>(); protected readonly nodes = new Map<string, Graphics>();
/** Text content of text items: a sibling of the item's node, kept next to it in z. */ /** Text content of text items: a sibling of the item's node, kept next to it in z. */
private readonly texts = new Map<string, Text>(); private readonly texts = new Map<string, TextPicture>();
/** Pictures of sprite items: siblings as well, each right under its node. */ /** Pictures of sprite items: siblings as well, each right under its node. */
private readonly sprites = new Map<string, Mesh>(); private readonly sprites = new Map<string, Mesh>();
/** The drawn particles of each source, one container – and one draw call – per source. */ /** The drawn particles of each source, one container – and one draw call – per source. */
@@ -322,7 +309,7 @@ export abstract class WorldViewport extends Widget {
if (item.enabled && !hidden) culled.push({ node, extent: item.type === 'particles' ? null : extent }); if (item.enabled && !hidden) culled.push({ node, extent: item.type === 'particles' ? null : extent });
node.eventMode = this.itemsInteractive && node.visible ? 'static' : 'none'; node.eventMode = this.itemsInteractive && node.visible ? 'static' : 'none';
this.syncDecorations(item, onScreen, view.scale, extent); this.syncDecorations(item, onScreen, view.scale, extent);
this.syncText(item, onScreen, view.scale); this.syncText(item, onScreen, view.scale, shaders);
this.syncSprite(item, onScreen, size, shaders); this.syncSprite(item, onScreen, size, shaders);
this.syncParticles(item, onScreen, shaders); this.syncParticles(item, onScreen, shaders);
this.syncTilemap(item, onScreen); this.syncTilemap(item, onScreen);
@@ -378,7 +365,7 @@ export abstract class WorldViewport extends Widget {
const node = this.nodes.get(id); const node = this.nodes.get(id);
if (node) this.objects.addChild(node); if (node) this.objects.addChild(node);
const text = this.texts.get(id); const text = this.texts.get(id);
if (text) this.objects.addChild(text); if (text) this.objects.addChild(text.mesh);
const field = this.fields.get(id); const field = this.fields.get(id);
if (field) this.objects.addChild(field.container); if (field) this.objects.addChild(field.container);
} }
@@ -491,41 +478,24 @@ export abstract class WorldViewport extends Widget {
* The typeface a text is drawn in and how wide that makes it. Both are what a font asset is * The typeface a text is drawn in and how wide that makes it. Both are what a font asset is
* for, and both arrive asynchronously, so a test cannot read them off the model. * for, and both arrive asynchronously, so a test cannot read them off the model.
*/ */
textInfo(id: string): { family: string; width: number } | null { textInfo(id: string): ReturnType<TextPicture['info']> | null {
const text = this.texts.get(id); const text = this.texts.get(id);
if (!text?.visible) return null; return text?.mesh.visible ? text.info() : null;
return { family: String(text.style.fontFamily), width: Math.round(text.width) };
} }
/** Resolution follows the zoom in coarse steps: re-rendering the glyphs per wheel notch is wasteful. */ private syncText(item: DrawItem, onScreen: boolean, scale: number, shaders: ShaderSource): void {
private syncText(item: DrawItem, onScreen: boolean, scale: number): void {
let text = this.texts.get(item.id); let text = this.texts.get(item.id);
if (item.type !== 'text' || !onScreen) { if (item.type !== 'text' || !onScreen) {
if (text) text.visible = false; if (text) text.mesh.visible = false;
return; return;
} }
if (!text) { if (!text) {
text = new Text({ text = new TextPicture();
text: '',
style: new TextStyle({ fontFamily: Theme.font, fontSize: 32, fill: 0xffffff }),
});
text.eventMode = 'none';
this.texts.set(item.id, text); this.texts.set(item.id, text);
this.objects.addChild(text); this.objects.addChild(text.mesh);
} }
text.visible = true; text.mesh.visible = true;
placeNode(text, item.m); if (wornShader(item.props, shaders)?.time) this.animated = true;
text.text = textOf(item.props.text ?? ''); text.sync(item, scale, shaders);
const size = Math.max(1, Number(item.props.fontSize ?? 32));
if (text.style.fontSize !== size) text.style.fontSize = size;
// 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;
if (text.style.fontFamily !== family) text.style.fontFamily = family;
const color = Number(item.props.color ?? 0xffffff);
if (text.style.fill !== color) text.style.fill = color;
text.anchor.set(textAnchor(item.props), 0.5);
const resolution = Math.min(4, Math.max(1, (Math.round(scale * 2) / 2) * 2));
if (text.resolution !== resolution) text.resolution = resolution;
} }
} }
+1 -1
View File
@@ -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 * 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. * 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, mirror: EDGE_MODE,
repeat: 'repeat', repeat: 'repeat',
clamp: 'clamp-to-edge', clamp: 'clamp-to-edge',
+5 -1
View File
@@ -22,7 +22,7 @@ import {
solidRects, solidRects,
} from '../core/model/tilemapCollision'; } from '../core/model/tilemapCollision';
import { findAsset, findAssetOf, type ObjectHolder, type Project } from '../core/model/Project'; 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 { resolveTexture, type TextureRef } from '../core/model/texture';
import type { FrameOutline } from '../core/model/frameOutline'; import type { FrameOutline } from '../core/model/frameOutline';
import { liveTilemap, type TileEdits } from '../core/model/liveTilemap'; import { liveTilemap, type TileEdits } from '../core/model/liveTilemap';
@@ -59,6 +59,8 @@ export interface DrawItem {
texture: TextureRef | null; texture: TextureRef | null;
/** The typeface file a text wears, resolved here for the same reason; `''` is the default one. */ /** The typeface file a text wears, resolved here for the same reason; `''` is the default one. */
font: string; 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. */ /** The asset a particle source emits from, for the same reason again: it carries the graph. */
particles: ParticlesAsset | null; particles: ParticlesAsset | null;
/** What a placed tilemap draws: its layers already cut into tiles, with their pictures resolved. */ /** What a placed tilemap draws: its layers already cut into tiles, with their pictures resolved. */
@@ -212,6 +214,7 @@ export function collectHolderItems(
enabled: true, enabled: true,
texture: wearsPicture(asset.type) ? resolveTexture(project, props) : null, texture: wearsPicture(asset.type) ? resolveTexture(project, props) : null,
font: asset.type === 'text' ? fontFileOf(project, fontOf(props)) : '', font: asset.type === 'text' ? fontFileOf(project, fontOf(props)) : '',
fontSmooth: fontSmoothOf(project, fontOf(props)),
particles: asset.type === 'particles' ? asset : null, particles: asset.type === 'particles' ? asset : null,
tilemap: tilemap:
asset.type === '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`. // Resolved per frame on purpose: a `Properties` node or an animation may swap `texture`.
texture: wearsPicture(e.type) ? resolveTexture(world.project, e.props) : null, texture: wearsPicture(e.type) ? resolveTexture(world.project, e.props) : null,
font: e.type === 'text' ? fontFileOf(world.project, fontOf(e.props)) : '', 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, 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, tilemap: e.type === 'tilemap' ? resolveTilemap(world.project, e.assetId, e.props, e.tileEdits) : null,
emits: e.emits ?? 0, emits: e.emits ?? 0,
+5
View File
@@ -175,6 +175,11 @@ function wornBy(mesh: Mesh): GraphShader | null {
return mesh.shader instanceof GraphShader ? mesh.shader : 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, * 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 * rebuilt when either changed, dropped for no shader – and for one that does not compile, which
+204
View File
@@ -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();
}
}
+129
View File
@@ -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]);
});
});
+167 -7
View File
@@ -1,3 +1,4 @@
import { edge, node } from '../lib/builders.mjs';
import { LANES, pickAssetType } from './lib/scaffold.mjs'; import { LANES, pickAssetType } from './lib/scaffold.mjs';
/** /**
@@ -15,6 +16,21 @@ const TEXT_OBJECT = 'ft-obj';
/** The two files the seed uploads, told apart by their exact size rather than by their name. */ /** The two files the seed uploads, told apart by their exact size rather than by their name. */
const SERIF_BYTES = 380660; const SERIF_BYTES = 380660;
const MONO_BYTES = 343140; const MONO_BYTES = 343140;
/** How many pixels across the text a check reads. */
const SAMPLE = 120;
const RED_SHADER = {
id: 'ft-red',
type: 'shader',
name: 'Red glyphs',
props: {},
graph: {
nodes: [
node('ft-red-color', 'ShaderColor', 0, 0, { value: 0xe63c3c }),
node('ft-red-out', 'ShaderOutput', 260, 0),
],
edges: [edge('ft-red-e1', 'ft-red-color', 'color', 'ft-red-out', 'color')],
},
};
export async function font(t) { export async function font(t) {
const { check, sleep, json, evaluate, mutate, click, clickText, locate, press, play, stop, shot, showTab } = const { check, sleep, json, evaluate, mutate, click, clickText, locate, press, play, stop, shot, showTab } =
@@ -124,14 +140,23 @@ export async function font(t) {
// 30d. Wearing the font, chosen the way a user chooses it: the Font row of the text object. // 30d. Wearing the font, chosen the way a user chooses it: the Font row of the text object.
await select({ kind: 'object', holderId: 'scene1', objectIds: [TEXT_OBJECT], objectId: TEXT_OBJECT }); await select({ kind: 'object', holderId: 'scene1', objectIds: [TEXT_OBJECT], objectId: TEXT_OBJECT });
// The label is no anchor on its own: the assets panel has a row per font. The dropdown of the // The label is no anchor on its own: the assets panel has a row per font. Parent and Shader (#169)
// Font row is the last "None" in the window – Parent says it above, nothing says it below. // say "None" as well, so the Font row's dropdown is the "None" at its height.
const fontRow = await json("__gm.inspectorField('Font', true)"); const fontRow = await json("__gm.inspectorField('Font', true)");
const none = await locate('None', true); const nones = await evaluate(`(() => {
const out = [];
const walk = (node) => {
if (!node.visible) return;
if (node.text === 'None') out.push(Math.round(node.getBounds().y + node.getBounds().height / 2));
for (const child of node.children ?? []) walk(child);
};
walk(__gm.app.stage);
return out;
})()`);
check( check(
'a text has a Font row, and it reads None', 'a text has a Font row, and it reads None',
Boolean(fontRow) && Math.abs(none.y - fontRow.y) < 12, Boolean(fontRow) && nones.some((y) => Math.abs(y - fontRow.y) < 12),
JSON.stringify({ fontRow, none }), JSON.stringify({ fontRow, nones }),
); );
await click(fontRow.x, fontRow.y); await click(fontRow.x, fontRow.y);
await clickLast('Bookish (font)'); await clickLast('Bookish (font)');
@@ -204,9 +229,144 @@ export async function font(t) {
JSON.stringify({ dropped, both, notice: await json('__gm.state.notice') }), JSON.stringify({ dropped, both, notice: await json('__gm.state.notice') }),
); );
// 30h. The step takes back everything it made, so the steps after it see the fixture again. // 30h. A text wears a shader like a sprite (#169), picked in its own Shader row. The graph paints
// every glyph pixel one red and keeps the text's alpha, so no white may survive it.
const overrides = `__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)}).overrides`;
await mutate(` await mutate(`
const gone = new Set([${JSON.stringify(TEXT_ASSET)}, ...${JSON.stringify(both.map((a) => a.id))}]); Object.assign(${overrides}, { font: ${JSON.stringify(fontId)} });
__gm.state.project.assets.push(${JSON.stringify(RED_SHADER)});`);
await waitFor(info, (value) => Boolean(value?.family.startsWith('gm-font-')));
/** A run of pixels across the middle of the text, where every glyph is cut. */
const rowAcross = async (view = 'objectRect') => {
const r = await json(`__gm.${view}(${JSON.stringify(TEXT_OBJECT)})`);
return r ? json(`__gm.pixelRow(${Math.round(r.x - SAMPLE / 2)}, ${Math.round(r.y)}, ${SAMPLE})`) : [];
};
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
// Red is counted by hue, not by the exact colour: drawn small, a glyph's pixels blend into the background.
const reddish = (hex) => {
const [r, g, b] = channels(hex);
return r > 0x60 && r > 2 * g && r > 2 * b;
};
const summary = (row) => ({
red: row.filter(reddish).length,
white: row.filter((hex) => channels(hex).every((c) => c > 0xd0)).length,
});
const white = summary(await rowAcross());
await select({ kind: 'object', holderId: 'scene1', objectIds: [TEXT_OBJECT], objectId: TEXT_OBJECT });
const shaderRow = await json("__gm.inspectorField('Shader', true)");
const noShader = await locate('None', true);
check(
'a text has a Shader row below its own, and it reads None',
Boolean(shaderRow) && Math.abs(noShader.y - shaderRow.y) < 12 && white.white > 0 && white.red === 0,
JSON.stringify({ shaderRow, noShader, white }),
);
await click(shaderRow.x, shaderRow.y);
await clickLast('Red glyphs (shader)');
const redInfo = await info();
const red = summary(await rowAcross());
check(
'picking a shader draws the text through it: its glyphs red, none of them white',
redInfo?.shader === RED_SHADER.id && red.red > 0 && red.white === 0,
JSON.stringify({ redInfo, red }),
);
await shot('30-font-text-shader');
// 30i. New content is rasterised anew, and the shader stays on it.
await mutate(`${overrides}.text = 'Handgloves Handgloves'`);
const longer = await info();
const redLonger = summary(await rowAcross());
check(
'changing the text keeps the shader on the new glyphs',
longer?.shader === RED_SHADER.id &&
longer.width > redInfo.width &&
redLonger.red > 0 &&
redLonger.white === 0,
JSON.stringify({ longer, redLonger }),
);
// 30j. The game draws a text through the same picture, shader included. In front of everything:
// in the chained run, earlier steps leave pictures of their own at the camera.
await place('{ x: cam.x, y: cam.y, z: 1000 }');
await play();
await sleep(600);
const playedShaded = await json(`__gm.gameTextInfo(${JSON.stringify(TEXT_OBJECT)})`);
const playedPixels = await rowAcross('gameRect');
const playedRow = summary(playedPixels);
check(
'the running game draws the text through its shader as well',
playedShaded?.shader === RED_SHADER.id && playedRow.red > 0 && playedRow.white === 0,
JSON.stringify({
playedShaded,
playedRow,
rect: await json(`__gm.gameRect(${JSON.stringify(TEXT_OBJECT)})`),
scale: await json('__gm.gameScale()'),
playedPixels,
}),
);
await stop();
await place(`{ x: 0, y: ${LANE}, z: 0 }`);
// 30k. The font's Filter (#169 comment): Nearest rasterises a pixel per unit and magnifies without
// smoothing. At scale 4 a sharp glyph is blocks of four screen pixels, a smooth one blends
// its edges – so the shortest run of one colour inside the row tells them apart.
// Nothing selected: the outline of a selected text would cross the row with runs of its own.
await mutate(`Object.assign(${overrides}, { shader: '', text: 'Hg', fontSize: 16, scale: 4 })`);
await evaluate('__gm.state.select(null)');
// The scene's grid shows through where the text is transparent, in runs of its own, so everything
// darker than the glyphs counts as one background.
const shortestRun = (pixels) => {
const row = pixels.map((hex) => (channels(hex)[0] > 0x60 ? hex : 'background'));
const runs = [];
let length = 1;
for (let i = 1; i < row.length; i++) {
if (row[i] === row[i - 1]) length++;
else {
runs.push(length);
length = 1;
}
}
// The first and the last run are cut off by the ends of the row.
return Math.min(...runs.slice(1));
};
const smoothInfo = await info();
const smoothRow = await rowAcross();
const smoothRun = shortestRun(smoothRow);
await select({ kind: 'asset', assetId: fontId });
const filterRow = await json("__gm.inspectorField('Filter', true)");
const linear = await locate('Linear', true);
check(
'a font has a Filter row, Linear by default, and its text blends the edges of its glyphs',
Boolean(filterRow) && Math.abs(linear.y - filterRow.y) < 12 && smoothInfo?.smooth && smoothRun <= 2,
JSON.stringify({ filterRow, linear, smoothInfo, smoothRun, smoothRow }),
);
await click(filterRow.x, filterRow.y);
await clickLast('Nearest');
const filter = await json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fontId)}).props.filter`,
);
const sharpInfo = await info();
const sharpRow = await rowAcross();
const sharpRun = shortestRun(sharpRow);
check(
'Nearest draws its text a pixel per unit, magnified in blocks',
filter === 'nearest' && sharpInfo?.smooth === false && sharpInfo.resolution === 1 && sharpRun >= 3,
JSON.stringify({ filter, sharpInfo, sharpRun, sharpRow }),
);
await shot('30-font-nearest');
// 30k-ii. …and its texels are hard: rasterised finer and cut at half coverage, so no glyph edge is
// left half covered – Chrome on Windows smooths canvas text sideways, whatever the filter.
// The fixture's face is no pixel font, so rasterised 1:1 its edges would be half covered here too.
check(
'Nearest leaves no half-covered texel in the text, the smooth face does',
smoothInfo?.partial > 0 && sharpInfo?.partial === 0,
JSON.stringify({ smooth: smoothInfo?.partial, sharp: sharpInfo?.partial }),
);
// 30l. The step takes back everything it made, so the steps after it see the fixture again.
await mutate(`
const gone = new Set([${JSON.stringify(TEXT_ASSET)}, ${JSON.stringify(RED_SHADER.id)},
...${JSON.stringify(both.map((a) => a.id))}]);
const project = __gm.state.project; const project = __gm.state.project;
project.assets = project.assets.filter((a) => !gone.has(a.id)); project.assets = project.assets.filter((a) => !gone.has(a.id));
__gm.state.activeScene.objects = __gm.state.activeScene.objects.filter( __gm.state.activeScene.objects = __gm.state.activeScene.objects.filter(