diff --git a/README.md b/README.md index 071ccfb4..84b181a9 100644 --- a/README.md +++ b/README.md @@ -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: ```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 node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index 8472b65a..ad6ed0e0 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -379,7 +379,7 @@ export function animatableDefsOf( const declared = asset.type === 'behavior' ? asset.variables - : asset.type === 'sprite' + : asset.type === 'sprite' || asset.type === 'text' ? shaderParamRows(project, shaderOf(bag)) : []; const taken = new Set(own.map((d) => d.key)); diff --git a/src/core/model/font.ts b/src/core/model/font.ts index 7e0adb78..341040f1 100644 --- a/src/core/model/font.ts +++ b/src/core/model/font.ts @@ -1,4 +1,5 @@ import { findAsset, type Project } from './Project'; +import { textOf } from './Properties'; /** 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)$/; @@ -16,6 +17,15 @@ export function fontFamilyOf(file: string): string { 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. */ export function fontFileOf(project: Project, id: string): string { if (id === '') return ''; diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 8cd1da0c..5055e640 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -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 = { scene: [ @@ -349,16 +363,7 @@ const ASSET_SCHEMAS: Record = { ], // 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 = { 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 = { 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 = { { 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)); diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index e9682163..481cb90d 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -112,11 +112,11 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { const shaderGraphs = new Map(); const shaderRows = new Map(); const particleGraphs = new Map(); - const wearers = new Map(); + const wearers = new Map(); for (const a of rawAssets) { if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue; 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') { const vars = normalizeVariables(a.variables); 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, - * else the asset's. A source already carries its own graph's rows, so a name it has stays its own. + * A sprite's, source's or text's rows end with the parameters of the shader its bag names – an + * 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): PropertySchema | undefined => { const schema = schemas.get(assetId); @@ -149,7 +149,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { const own = bag[SHADER_KEY]; const shader = typeof own === 'string' ? own : rawProps.get(assetId)?.[SHADER_KEY]; 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` diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts index 90d2cb50..7717e0e4 100644 --- a/src/editor/inspector/portSection.ts +++ b/src/editor/inspector/portSection.ts @@ -95,10 +95,10 @@ export function buildPropertyPortSection( } if (target !== 'behavior') { const owner = `a ${targetLabel(target)}`; - // A sprite's rows go on with the parameters of the shader it wears – any shader, the node cannot - // know which, so each entry says whose it is. Keyed by the def itself: a shader parameter and a - // sprite property may share a name, and only the shader's entry earns the note. - const shaderRows = target === 'sprite' ? allShaderParamRows(host.state.project) : []; + // A sprite's or text's rows go on with the parameters of the shader it wears – any shader, the node + // cannot know which, so each entry says whose it is. Keyed by the def itself: a shader parameter and + // a property of the type may share a name, and only the shader's entry earns the note. + const shaderRows = target === 'sprite' || target === 'text' ? allShaderParamRows(host.state.project) : []; const from = new Map(shaderRows.map((row) => [row.def, row.shaders.join(', ')])); buildPickedPortSection(host, assetId, node, { noun: 'Property', diff --git a/src/editor/viewport/WorldViewport.ts b/src/editor/viewport/WorldViewport.ts index 3db65d24..fa1d9eaf 100644 --- a/src/editor/viewport/WorldViewport.ts +++ b/src/editor/viewport/WorldViewport.ts @@ -1,19 +1,11 @@ -import { - Container, - type FederatedPointerEvent, - Graphics, - type Mesh, - Text, - Texture, - TextStyle, -} from 'pixi.js'; +import { Container, type FederatedPointerEvent, Graphics, type Mesh, Texture } from 'pixi.js'; import { isSymbolType } from '../../core/model/AssetTypes'; import { type Extent, scaleOf } from '../../core/model/matrix'; -import { type Vec2, textOf } from '../../core/model/Properties'; -import { footprintExtent, footprintOf, placementOf, textAnchor } from '../../core/properties/bounds'; +import type { Vec2 } from '../../core/model/Properties'; +import { footprintExtent, footprintOf, placementOf } from '../../core/properties/bounds'; import { rectsIntersect } from '../../core/properties/camera'; import type { EditorState } from '../state'; -import { type Size, Theme, Widget } from '../../ui'; +import { type Size, Widget } from '../../ui'; import { fontLibrary } from '../../viewport/FontLibrary'; import { drawSlots } from '../../viewport/drawOrder'; import type { DrawItem } from '../../viewport/items'; @@ -23,13 +15,8 @@ import { particleLibrary } from '../../viewport/ParticleLibrary'; import { SHEET_EDGE } from '../../core/shader/nodes'; import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { type ShaderSource, wornShader } from '../../viewport/shaders'; -import { - disposeSpriteMesh, - drawItem, - newSpriteMesh, - placeNode, - syncSpriteMesh, -} from '../../viewport/symbols'; +import { disposeSpriteMesh, drawItem, newSpriteMesh, syncSpriteMesh } from '../../viewport/symbols'; +import { TextPicture } from '../../viewport/textPicture'; import { cellCentre, type LayerFocus, TilemapField } from '../../viewport/tilemap'; import { textureLibrary } from '../../viewport/TextureLibrary'; import { gameClock } from '../../core/time'; @@ -51,7 +38,7 @@ export abstract class WorldViewport extends Widget { protected readonly contentMask = new Graphics(); protected readonly nodes = new Map(); /** Text content of text items: a sibling of the item's node, kept next to it in z. */ - private readonly texts = new Map(); + private readonly texts = new Map(); /** Pictures of sprite items: siblings as well, each right under its node. */ private readonly sprites = new Map(); /** 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 }); node.eventMode = this.itemsInteractive && node.visible ? 'static' : 'none'; 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.syncParticles(item, onScreen, shaders); this.syncTilemap(item, onScreen); @@ -378,7 +365,7 @@ export abstract class WorldViewport extends Widget { const node = this.nodes.get(id); if (node) this.objects.addChild(node); 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); 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 * 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 | null { const text = this.texts.get(id); - if (!text?.visible) return null; - return { family: String(text.style.fontFamily), width: Math.round(text.width) }; + return text?.mesh.visible ? text.info() : null; } - /** Resolution follows the zoom in coarse steps: re-rendering the glyphs per wheel notch is wasteful. */ - private syncText(item: DrawItem, onScreen: boolean, scale: number): void { + private syncText(item: DrawItem, onScreen: boolean, scale: number, shaders: ShaderSource): void { let text = this.texts.get(item.id); if (item.type !== 'text' || !onScreen) { - if (text) text.visible = false; + if (text) text.mesh.visible = false; return; } if (!text) { - text = new Text({ - text: '', - style: new TextStyle({ fontFamily: Theme.font, fontSize: 32, fill: 0xffffff }), - }); - text.eventMode = 'none'; + text = new TextPicture(); this.texts.set(item.id, text); - this.objects.addChild(text); + this.objects.addChild(text.mesh); } - text.visible = true; - placeNode(text, item.m); - text.text = textOf(item.props.text ?? ''); - 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; + text.mesh.visible = true; + if (wornShader(item.props, shaders)?.time) this.animated = true; + text.sync(item, scale, shaders); } } diff --git a/src/viewport/TextureLibrary.ts b/src/viewport/TextureLibrary.ts index bbfd4c49..f88c214a 100644 --- a/src/viewport/TextureLibrary.ts +++ b/src/viewport/TextureLibrary.ts @@ -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 = { +export const ADDRESS_MODE: Record = { mirror: EDGE_MODE, repeat: 'repeat', clamp: 'clamp-to-edge', diff --git a/src/viewport/items.ts b/src/viewport/items.ts index 6fd272b9..d8d908bf 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -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, diff --git a/src/viewport/shaders.ts b/src/viewport/shaders.ts index 5257d27c..bc75f65c 100644 --- a/src/viewport/shaders.ts +++ b/src/viewport/shaders.ts @@ -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 diff --git a/src/viewport/textPicture.ts b/src/viewport/textPicture.ts new file mode 100644 index 00000000..1155464e --- /dev/null +++ b/src/viewport/textPicture.ts @@ -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; 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(); + } +} diff --git a/tests/core/text-shader.test.ts b/tests/core/text-shader.test.ts new file mode 100644 index 00000000..49af524d --- /dev/null +++ b/tests/core/text-shader.test.ts @@ -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]); + }); +}); diff --git a/tools/e2e/30-font.mjs b/tools/e2e/30-font.mjs index 52bc74b1..283a9cdc 100644 --- a/tools/e2e/30-font.mjs +++ b/tools/e2e/30-font.mjs @@ -1,3 +1,4 @@ +import { edge, node } from '../lib/builders.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. */ const SERIF_BYTES = 380660; 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) { 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. 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 - // Font row is the last "None" in the window – Parent says it above, nothing says it below. + // The label is no anchor on its own: the assets panel has a row per font. Parent and Shader (#169) + // 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 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( 'a text has a Font row, and it reads None', - Boolean(fontRow) && Math.abs(none.y - fontRow.y) < 12, - JSON.stringify({ fontRow, none }), + Boolean(fontRow) && nones.some((y) => Math.abs(y - fontRow.y) < 12), + JSON.stringify({ fontRow, nones }), ); await click(fontRow.x, fontRow.y); await clickLast('Bookish (font)'); @@ -204,9 +229,144 @@ export async function font(t) { 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(` - 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; project.assets = project.assets.filter((a) => !gone.has(a.id)); __gm.state.activeScene.objects = __gm.state.activeScene.objects.filter(