diff --git a/.cspell/project-words.txt b/.cspell/project-words.txt index d4456708..1b32d41e 100644 --- a/.cspell/project-words.txt +++ b/.cspell/project-words.txt @@ -1,6 +1,7 @@ aabb aabbs afterrun +antialiases Aoboshi artboard attw @@ -28,6 +29,7 @@ Flaticon fphysics fract frontmatter +fwidth gamedev gamepadconnected Gamepads @@ -39,6 +41,7 @@ highp hitscan hotspot hoverable +imageout impluse Infima inigo @@ -55,9 +58,13 @@ Mertens mousedown mousemove mouseup +Msdf +msdf +MSDF Narkowicz ndot Nisbet +nokerning normalise Oboro perlin @@ -65,6 +72,7 @@ pingpong Pixabay prebuild prestart +pxrange quilez rasterizer readback @@ -118,4 +126,5 @@ WASD webgl Wenrexa yoavbls +yorigin Zoltan diff --git a/AGENTS.md b/AGENTS.md index 828be002..0dbd249d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ Forge is a browser-based, code-only game engine built with TypeScript. It provid - **Input**: Keyboard, mouse, and gamepad input handling - **Particles**: Particle system - **Asset Loading**: Resource management +- **Text**: MSDF font atlas text rendering - **FSM**: Finite state machine implementation **Important**: The engine contains general-purpose game functionality. Game-specific or genre-specific code should be in separate packages. @@ -53,6 +54,7 @@ Forge is a browser-based, code-only game engine built with TypeScript. It provid /physics # Physics integration /pooling # Object pooling /rendering # Rendering system + /text # MSDF font atlas text rendering /timer # Timer utilities /utilities # General utilities index.ts # Main exports diff --git a/CHANGELOG.md b/CHANGELOG.md index d5b67ab0..4d2aa3bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **physics:** Add `raycast(world, start, end, sort?)`, casting a line segment against every entity in an `EcsWorld` with a `ColliderEcsComponent` (`CircleCollider`, `PolygonCollider`, and `TerrainCollider` alike) and returning every intersection as a `RaycastHit` (`entity`, `point`, `normal`, `distance`), ordered by distance from `start` by default - **physics:** Add `RigidBodyEcsComponent.type` (`'dynamic'` | `'kinematic'` | `'static'`, defaulting to `'dynamic'`), letting a body be moved directly by game code (`'kinematic'`) so it still pushes dynamic bodies on contact without itself being affected by gravity, forces, or impulses - previously only possible implicitly, by giving an entity no `RigidBodyEcsComponent` at all (still supported, and equivalent to `type: 'static'`) +- **text:** Add a new `@forge-game-engine/forge/text` module for MSDF (multi-channel signed distance field) text rendering: `loadFontAtlas`/`FontAtlas` (`/asset-loading`) parse an `msdf-atlas-gen` JSON metrics file and atlas PNG; `createMsdfTextRenderable` (`/rendering`) builds the GPU-side renderable from a font atlas; `TextEcsComponent`/`addTextComponent` and `createTextShapingEcsSystem` shape a string (with word-wrapping, kerning, alignment, and line spacing) into glyph quads a `TextMeshEcsComponent` holds, drawn by `createRenderEcsSystem` batched alongside sprites. Shaping is dirty-tracked, only re-running when a shape-affecting field actually changes. Does not yet include a default shipped font, a Canvas2D prototyping fallback, or text effects (outline/glow/shadow) - see the module's docs for current limitations #### Changed diff --git a/documentation-site/docs/docs/asset-loading/index.md b/documentation-site/docs/docs/asset-loading/index.md index 00079a93..8b8a6da7 100644 --- a/documentation-site/docs/docs/asset-loading/index.md +++ b/documentation-site/docs/docs/asset-loading/index.md @@ -13,12 +13,18 @@ plus two supporting building blocks: - [`AssetCache`](/Forge/docs/api/interfaces/AssetCache): the common `get` / `load` / `getOrLoad` contract that asset caches implement. `ImageCache` implements it for `HTMLImageElement`; if you add a cache for - another asset type (audio buffers, JSON data, fonts), implement this - interface so it behaves consistently with the rest of the engine. + another asset type (audio buffers, JSON data), implement this interface + so it behaves consistently with the rest of the engine. - [`AssetRegistry`](/Forge/docs/api/classes/AssetRegistry): maps human-readable string IDs to compact numeric IDs, so hot-path code (like a per-frame animation system) can look up an asset by index instead of by string. +- [`loadFontAtlas`](/Forge/docs/api/functions/loadFontAtlas): parses an + MSDF font atlas (an `msdf-atlas-gen` JSON metrics file plus its PNG + texture, loaded through `ImageCache`) into a + [`FontAtlas`](/Forge/docs/api/interfaces/FontAtlas) for + `@forge-game-engine/forge/text` to shape and draw. See + [Text](../text/index.md) for the full text rendering guide. Guides in this section: diff --git a/documentation-site/docs/docs/rendering/index.md b/documentation-site/docs/docs/rendering/index.md index 9a668c04..11f5152d 100644 --- a/documentation-site/docs/docs/rendering/index.md +++ b/documentation-site/docs/docs/rendering/index.md @@ -8,6 +8,8 @@ draws the sprites matching that camera's `cullingMask`, sorted by each sprite's `layer` (draw order, lower first) and then by depth (world Y position) within a layer, batching consecutive sprites that share a [`Renderable`](/Forge/docs/api/classes/Renderable) into a single draw call. +Text (`@forge-game-engine/forge/text`) draws through this same pass, sorted +and batched alongside your sprites; see [Text](../text/index.md). This section is a work in progress and currently covers the multipass rendering foundation and its first post-processing effect; a full guide to diff --git a/documentation-site/docs/docs/text/_category_.json b/documentation-site/docs/docs/text/_category_.json new file mode 100644 index 00000000..c5e2ce70 --- /dev/null +++ b/documentation-site/docs/docs/text/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Text", + "position": 11, + "link": { + "type": "doc", + "id": "docs/text/index" + } +} diff --git a/documentation-site/docs/docs/text/index.md b/documentation-site/docs/docs/text/index.md new file mode 100644 index 00000000..81e05b3d --- /dev/null +++ b/documentation-site/docs/docs/text/index.md @@ -0,0 +1,82 @@ +# Text + +`@forge-game-engine/forge/text` draws text - labels, scores, dialogue, +damage numbers - using MSDF font atlases, so it stays crisp at any size and +draws in the same batched pass as your sprites. + +Guides in this section: + +- [MSDF Text](./msdf-text.md): generating a font atlas and configuring + wrapping, alignment, color, and line spacing. + +Try it in the [Text demo](/Forge/demos/text). + +## Quick Start + +```ts +import { addPositionComponent } from '@forge-game-engine/forge/common'; +import { loadFontAtlas } from '@forge-game-engine/forge/asset-loading'; +import { SystemRegistrationOrder } from '@forge-game-engine/forge/ecs'; +import { + createMsdfTextRenderable, + createRenderEcsSystem, +} from '@forge-game-engine/forge/rendering'; +import { + addTextComponent, + createTextShapingEcsSystem, +} from '@forge-game-engine/forge/text'; +import { createGame } from '@forge-game-engine/forge/utilities'; + +const { world, renderContext } = createGame('game-container'); + +const font = await loadFontAtlas( + 'fonts/roboto-msdf.json', + 'fonts/roboto-msdf.png', + renderContext.imageCache, +); +const renderable = createMsdfTextRenderable(font, renderContext); + +// Register shaping before rendering, so text always shows this frame's glyphs. +world.addSystem(createTextShapingEcsSystem(), SystemRegistrationOrder.early); +world.addSystem(createRenderEcsSystem(renderContext)); + +const label = world.createEntity(); +addPositionComponent(world, label, { world: { x: 0, y: 0 } }); +addTextComponent(world, label, { + text: 'Hello, Forge!', + font, + renderable, + fontSize: 32, +}); +``` + +See [MSDF Text](./msdf-text.md) for generating the font atlas this example +loads. + +## Options + +Pass these to [`addTextComponent`](/Forge/docs/api/functions/addTextComponent): + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `text` | `string` | *(required)* | The string to display. Assign a new value any time to update it. | +| `font` | `FontAtlas` | *(required)* | The loaded font atlas, from [`loadFontAtlas`](./msdf-text.md). | +| `renderable` | `Renderable` | *(required)* | From [`createMsdfTextRenderable`](./msdf-text.md#loading-and-drawing-text) - share one per font across every label using it. | +| `fontSize` | `number` | *(required)* | The text size, in world units. | +| `color` | `Color` | `Color.white` | The text's color. | +| `alignment` | `'left'` \| `'center'` \| `'right'` | `'left'` | How lines are positioned relative to each other. | +| `wrapWidth` | `number` | *(none)* | Wraps `text` between words to fit this width, in world units. Omit for a single unwrapped line. | +| `lineSpacing` | `number` | `1` | Multiplier for the gap between lines. | +| `pivot` | `{ x, y }` | `(0.5, 0.5)` | The text block's origin, normalized to its own size - `(0, 0)` is bottom-left, `(1, 1)` is top-right. | +| `enabled` | `boolean` | `true` | Set `false` to hide the text. | +| `layer` | `number` | `0` | Draw order relative to other sprites/text. | + +Explicit `\n` characters in `text` always start a new line. + +## Good to know + +- Forge doesn't ship a bundled font - see [MSDF Text](./msdf-text.md) for + generating your own atlas. +- Wrapping breaks between words, not mid-word: a single word wider than + `wrapWidth` is placed on its own line unbroken. +- There's no built-in outline, glow, or drop shadow yet. diff --git a/documentation-site/docs/docs/text/msdf-text.md b/documentation-site/docs/docs/text/msdf-text.md new file mode 100644 index 00000000..2040d737 --- /dev/null +++ b/documentation-site/docs/docs/text/msdf-text.md @@ -0,0 +1,96 @@ +--- +sidebar_position: 1 +--- + +# MSDF Text + +## Generating an atlas + +Generate a font atlas with +[`msdf-atlas-gen`](https://github.com/Chlumsky/msdf-atlas-gen): + +```sh +msdf-atlas-gen -font Roboto-Regular.ttf -type msdf -format png \ + -imageout roboto-msdf.png -json roboto-msdf.json \ + -charset charset.txt -size 32 -pxrange 4 +``` + +Host the resulting `.json` and `.png` as static assets (e.g. next to your +other sprite sheets). + +Generate `charset.txt` from every string your game actually displays, +including punctuation and any non-English text - a character missing from +the atlas is silently skipped when drawn, rather than erroring, so it's +easy to miss until someone types it. Leave the default kerning table +enabled (`msdf-atlas-gen` includes it unless you pass `-nokerning`), or +letter pairs like "AV" or "To" will look subtly too far apart. + +## Loading and drawing text + +Load the atlas with +[`loadFontAtlas`](/Forge/docs/api/functions/loadFontAtlas): + +```ts +import { loadFontAtlas } from '@forge-game-engine/forge/asset-loading'; + +const font = await loadFontAtlas( + 'fonts/roboto-msdf.json', + 'fonts/roboto-msdf.png', + renderContext.imageCache, +); +``` + +Build a renderable for it with +[`createMsdfTextRenderable`](/Forge/docs/api/functions/createMsdfTextRenderable) - +once per font, not once per label: + +```ts +import { createMsdfTextRenderable } from '@forge-game-engine/forge/rendering'; + +const renderable = createMsdfTextRenderable(font, renderContext); +``` + +Then attach a text component to any entity with a position: + +```ts +import { addPositionComponent } from '@forge-game-engine/forge/common'; +import { Color } from '@forge-game-engine/forge/rendering'; +import { addTextComponent } from '@forge-game-engine/forge/text'; + +const score = world.createEntity(); +addPositionComponent(world, score, { world: { x: -300, y: 260 } }); +const scoreText = addTextComponent(world, score, { + text: 'Score: 0', + font, + renderable, + fontSize: 24, + color: new Color(1, 0.9, 0.2, 1), +}); +``` + +Update the score later just by assigning a new string: + +```ts +scoreText.text = `Score: ${points}`; +``` + +That's cheap to do every frame - only `text`, `font`, `fontSize`, +`wrapWidth`, `lineSpacing`, `alignment`, or `pivot` changing triggers a +re-shape. Changing `color`, `enabled`, or `layer` doesn't. + +See [Options](./index.md#options) for the full list of settings, including +wrapping, alignment, and line spacing. + +## Setup + +Register the shaping system before the render system, so text always +shows this frame's glyphs: + +```ts +import { SystemRegistrationOrder } from '@forge-game-engine/forge/ecs'; +import { createTextShapingEcsSystem } from '@forge-game-engine/forge/text'; +import { createRenderEcsSystem } from '@forge-game-engine/forge/rendering'; + +world.addSystem(createTextShapingEcsSystem(), SystemRegistrationOrder.early); +world.addSystem(createRenderEcsSystem(renderContext)); +``` diff --git a/documentation-site/docusaurus.config.ts b/documentation-site/docusaurus.config.ts index 1d88a1db..8a36b6e1 100644 --- a/documentation-site/docusaurus.config.ts +++ b/documentation-site/docusaurus.config.ts @@ -195,6 +195,10 @@ const config: Config = { to: 'demos/texture-filtering', label: 'Texture Filtering', }, + { + to: 'demos/text', + label: 'Text', + }, ], }, { diff --git a/documentation-site/src/pages/demos/text/_create-game.ts b/documentation-site/src/pages/demos/text/_create-game.ts new file mode 100644 index 00000000..8f7e4c8f --- /dev/null +++ b/documentation-site/src/pages/demos/text/_create-game.ts @@ -0,0 +1,66 @@ +import { loadFontAtlas } from '@forge-game-engine/forge/asset-loading'; +import { SystemRegistrationOrder } from '@forge-game-engine/forge/ecs'; +import { + createCamera, + createCameraEcsSystem, + createMsdfTextRenderable, + createRenderEcsSystem, +} from '@forge-game-engine/forge/rendering'; +import { createTextShapingEcsSystem } from '@forge-game-engine/forge/text'; +import { createGame, Game } from '@forge-game-engine/forge/utilities'; +import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; +import { createElapsedCounterEcsSystem } from './_elapsed-counter.system'; +import { createLabels } from './_create-labels'; + +const renderLayers = { + foreground: 1 << 0, +}; + +/** + * Builds the Text demo: loads an Open Sans MSDF font atlas, builds its + * shared renderable, and lays out a few showcase labels (see + * `_create-labels.ts`) - a title/byline, three word-wrapped paragraphs + * (one per alignment), and a live elapsed-time counter. + * @param fontJsonUrl - The URL of the atlas's `msdf-atlas-gen` JSON metrics + * file. + * @param fontPngUrl - The URL of the atlas's PNG texture. + * @returns The created game. + */ +export const createTextGame = async ( + fontJsonUrl: string, + fontPngUrl: string, +): Promise => { + const { game, world, renderContext, time } = createGame('demo-game'); + + createCamera(world, { + isStatic: true, + cullingMask: renderLayers.foreground, + verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, + }); + + const font = await loadFontAtlas( + fontJsonUrl, + fontPngUrl, + renderContext.imageCache, + ); + const renderable = createMsdfTextRenderable( + font, + renderContext, + renderLayers.foreground, + ); + + createLabels(world, font, renderable); + + world.addSystem(createCameraEcsSystem(time)); + // Both registered `early`, before `createRenderEcsSystem`'s default + // (`normal`) order: the counter must update `text` before shaping reads + // it, and shaping must run before render consumes this frame's glyphs. + world.addSystem( + createElapsedCounterEcsSystem(time), + SystemRegistrationOrder.early, + ); + world.addSystem(createTextShapingEcsSystem(), SystemRegistrationOrder.early); + world.addSystem(createRenderEcsSystem(renderContext)); + + return game; +}; diff --git a/documentation-site/src/pages/demos/text/_create-labels.ts b/documentation-site/src/pages/demos/text/_create-labels.ts new file mode 100644 index 00000000..0911dc6c --- /dev/null +++ b/documentation-site/src/pages/demos/text/_create-labels.ts @@ -0,0 +1,91 @@ +import { EcsWorld } from '@forge-game-engine/forge/ecs'; +import { addPositionComponent } from '@forge-game-engine/forge/common'; +import { Color, Renderable } from '@forge-game-engine/forge/rendering'; +import { FontAtlas } from '@forge-game-engine/forge/asset-loading'; +import { + addTextComponent, + TextAlignment, +} from '@forge-game-engine/forge/text'; +import { elapsedCounterTag } from './_elapsed-counter.component'; + +const paragraph = + 'Word-wrapped MSDF text with kerning and configurable alignment.'; + +interface LabelOptions { + text: string; + fontSize: number; + color?: Color; + alignment?: TextAlignment; + wrapWidth?: number; +} + +function createLabel( + world: EcsWorld, + font: FontAtlas, + renderable: Renderable, + y: number, + options: LabelOptions, +): void { + const entity = world.createEntity(); + + addPositionComponent(world, entity, { world: { x: 0, y } }); + addTextComponent(world, entity, { + font, + renderable, + ...options, + }); +} + +/** + * Builds the demo's showcase text entities: a title/byline, three + * word-wrapped paragraphs (one per `TextAlignment`) stacked so their + * layout can be compared directly, and a live elapsed-time counter tagged + * for `createElapsedCounterEcsSystem` to update every frame. + * @param world - The ECS world to add the text entities to. + * @param font - The loaded font atlas every label shares. + * @param renderable - The MSDF renderable every label shares, built once + * for this font via `createMsdfTextRenderable`. + */ +export function createLabels( + world: EcsWorld, + font: FontAtlas, + renderable: Renderable, +): void { + createLabel(world, font, renderable, 250, { + text: 'Forge Text', + fontSize: 44, + }); + + createLabel(world, font, renderable, 205, { + text: 'MSDF font atlas rendering, batched with sprites.', + fontSize: 16, + color: new Color(0.7, 0.72, 0.78, 1), + }); + + const alignments: { alignment: TextAlignment; y: number }[] = [ + { alignment: 'left', y: 130 }, + { alignment: 'center', y: 10 }, + { alignment: 'right', y: -110 }, + ]; + + for (const { alignment, y } of alignments) { + createLabel(world, font, renderable, y, { + text: `${alignment}: ${paragraph}`, + fontSize: 16, + wrapWidth: 260, + alignment, + }); + } + + const counterEntity = world.createEntity(); + + addPositionComponent(world, counterEntity, { world: { x: 0, y: -240 } }); + addTextComponent(world, counterEntity, { + text: 'Elapsed: 0.0s', + font, + renderable, + fontSize: 26, + color: new Color(0.4, 0.85, 1, 1), + }); + world.addTag(counterEntity, elapsedCounterTag); +} diff --git a/documentation-site/src/pages/demos/text/_elapsed-counter.component.ts b/documentation-site/src/pages/demos/text/_elapsed-counter.component.ts new file mode 100644 index 00000000..f09fb4b1 --- /dev/null +++ b/documentation-site/src/pages/demos/text/_elapsed-counter.component.ts @@ -0,0 +1,10 @@ +import { createTagId } from '@forge-game-engine/forge/ecs'; + +/** + * Marks a text entity whose `text` `createElapsedCounterEcsSystem` + * (`_elapsed-counter.system.ts`) updates every frame with the demo's + * elapsed time - showcasing that updating `TextEcsComponent.text` is cheap, + * since `createTextShapingEcsSystem` only re-shapes glyphs for entities + * whose text (or another shape-affecting field) actually changed. + */ +export const elapsedCounterTag = createTagId('elapsedCounter'); diff --git a/documentation-site/src/pages/demos/text/_elapsed-counter.system.ts b/documentation-site/src/pages/demos/text/_elapsed-counter.system.ts new file mode 100644 index 00000000..4e2a06fc --- /dev/null +++ b/documentation-site/src/pages/demos/text/_elapsed-counter.system.ts @@ -0,0 +1,22 @@ +import { EcsSystem } from '@forge-game-engine/forge/ecs'; +import { Time } from '@forge-game-engine/forge/common'; +import { TextEcsComponent, textId } from '@forge-game-engine/forge/text'; +import { elapsedCounterTag } from './_elapsed-counter.component'; + +/** + * Creates a system that updates every entity tagged `elapsedCounterTag`'s + * text with the demo's elapsed time, once per frame. + * @param time - The time instance used to read elapsed seconds. + * @returns The ECS system. + */ +export const createElapsedCounterEcsSystem = ( + time: Time, +): EcsSystem<[TextEcsComponent]> => ({ + query: [textId], + tags: [elapsedCounterTag], + update: (_world, { components: [texts] }) => { + for (const text of texts) { + text.text = `Elapsed: ${time.timeInSeconds.toFixed(1)}s`; + } + }, +}); diff --git a/documentation-site/src/pages/demos/text/index.tsx b/documentation-site/src/pages/demos/text/index.tsx new file mode 100644 index 00000000..b08f67cc --- /dev/null +++ b/documentation-site/src/pages/demos/text/index.tsx @@ -0,0 +1,39 @@ +import React, { JSX } from 'react'; +import useBaseUrl from '@docusaurus/useBaseUrl'; +import { createTextGame } from './_create-game'; +import gameCode from '!!raw-loader!./_create-game'; +import createLabelsCode from '!!raw-loader!./_create-labels'; +import elapsedCounterComponentCode from '!!raw-loader!./_elapsed-counter.component'; +import elapsedCounterSystemCode from '!!raw-loader!./_elapsed-counter.system'; + +import { Demo } from '@site/src/components/Demo'; + +export default function Text(): JSX.Element { + const fontJsonUrl = useBaseUrl('fonts/open-sans/open-sans-msdf.json'); + const fontPngUrl = useBaseUrl('fonts/open-sans/open-sans-msdf.png'); + + return ( + createTextGame(fontJsonUrl, fontPngUrl)} + codeFiles={[ + { name: 'game.ts', content: gameCode }, + { name: 'create-labels.ts', content: createLabelsCode }, + { + name: 'elapsed-counter.component.ts', + content: elapsedCounterComponentCode, + }, + { + name: 'elapsed-counter.system.ts', + content: elapsedCounterSystemCode, + }, + ]} + /> + ); +} diff --git a/documentation-site/static/fonts/open-sans/LICENSE.txt b/documentation-site/static/fonts/open-sans/LICENSE.txt new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/documentation-site/static/fonts/open-sans/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/documentation-site/static/fonts/open-sans/README.md b/documentation-site/static/fonts/open-sans/README.md new file mode 100644 index 00000000..ed62ec55 --- /dev/null +++ b/documentation-site/static/fonts/open-sans/README.md @@ -0,0 +1,19 @@ +# Open Sans MSDF atlas + +`open-sans-msdf.png` + `open-sans-msdf.json` were generated from +`OpenSans-Regular.ttf` (Apache License 2.0, see `LICENSE.txt`) using +[`msdf-atlas-gen`](https://github.com/Chlumsky/msdf-atlas-gen) v1.4.0: + +```sh +msdf-atlas-gen \ + -font OpenSans-Regular.ttf \ + -type msdf -format png \ + -imageout open-sans-msdf.png \ + -json open-sans-msdf.json \ + -size 48 -pxrange 4 -yorigin bottom +``` + +Used by the [Text demo](../../src/pages/demos/text) as the sample font for +`@forge-game-engine/forge/text`. Regenerate with the same command (pointed +at an updated font file, or with a different `-charset` if the demo needs +characters outside the default ASCII set) to refresh these assets. diff --git a/documentation-site/static/fonts/open-sans/open-sans-msdf.json b/documentation-site/static/fonts/open-sans/open-sans-msdf.json new file mode 100644 index 00000000..7993d146 --- /dev/null +++ b/documentation-site/static/fonts/open-sans/open-sans-msdf.json @@ -0,0 +1 @@ +{"atlas":{"type":"msdf","distanceRange":4,"distanceRangeMiddle":0,"size":48,"width":320,"height":320,"yOrigin":"bottom"},"metrics":{"emSize":1,"lineHeight":1.36181640625,"ascender":1.06884765625,"descender":-0.29296875,"underlineY":-0.10009765625,"underlineThickness":0.0498046875},"glyphs":[{"unicode":32,"advance":0.259765625},{"unicode":33,"advance":0.26708984375,"planeBounds":{"left":0.028889973958333336,"bottom":-0.072916666666666657,"right":0.23722330729166669,"top":0.76041666666666663},"atlasBounds":{"left":251.5,"bottom":225.5,"right":261.5,"top":265.5}},{"unicode":34,"advance":0.40087890625,"planeBounds":{"left":0.012939453125000002,"bottom":0.40625,"right":0.38793945312499994,"top":0.76041666666666663},"atlasBounds":{"left":84.5,"bottom":10.5,"right":102.5,"top":27.5}},{"unicode":35,"advance":0.64599609375,"planeBounds":{"left":-0.021240234375,"bottom":-0.052083333333333336,"right":0.666259765625,"top":0.76041666666666663},"atlasBounds":{"left":286.5,"bottom":280.5,"right":319.5,"top":319.5}},{"unicode":36,"advance":0.57177734375,"planeBounds":{"left":0.014078776041666631,"bottom":-0.11458333333333333,"right":0.55574544270833326,"top":0.80208333333333326},"atlasBounds":{"left":186.5,"bottom":275.5,"right":212.5,"top":319.5}},{"unicode":37,"advance":0.8232421875,"planeBounds":{"left":0.0051269531250000017,"bottom":-0.052083333333333336,"right":0.817626953125,"top":0.78125},"atlasBounds":{"left":262.5,"bottom":225.5,"right":301.5,"top":265.5}},{"unicode":38,"advance":0.72998046875,"planeBounds":{"left":0.0061848958333333339,"bottom":-0.052083333333333336,"right":0.77701822916666663,"top":0.78125},"atlasBounds":{"left":0.5,"bottom":181.5,"right":37.5,"top":221.5}},{"unicode":39,"advance":0.22119140625,"planeBounds":{"left":0.0166015625,"bottom":0.40625,"right":0.2041015625,"top":0.76041666666666663},"atlasBounds":{"left":103.5,"bottom":10.5,"right":112.5,"top":27.5}},{"unicode":40,"advance":0.2958984375,"planeBounds":{"left":-0.0031738281249999792,"bottom":-0.21875,"right":0.309326171875,"top":0.76041666666666663},"atlasBounds":{"left":80.5,"bottom":272.5,"right":95.5,"top":319.5}},{"unicode":41,"advance":0.2958984375,"planeBounds":{"left":-0.013427734374999977,"bottom":-0.21875,"right":0.299072265625,"top":0.76041666666666663},"atlasBounds":{"left":96.5,"bottom":272.5,"right":111.5,"top":319.5}},{"unicode":42,"advance":0.5517578125,"planeBounds":{"left":-0.0068359375000000364,"bottom":0.26041666666666669,"right":0.55566406249999989,"top":0.80208333333333326},"atlasBounds":{"left":29.5,"bottom":1.5,"right":56.5,"top":27.5}},{"unicode":43,"advance":0.57177734375,"planeBounds":{"left":0.0041503906249999653,"bottom":0.052083333333333329,"right":0.56665039062499989,"top":0.65624999999999989},"atlasBounds":{"left":231.5,"bottom":30.5,"right":258.5,"top":59.5}},{"unicode":44,"advance":0.2451171875,"planeBounds":{"left":-0.0205078125,"bottom":-0.17708333333333334,"right":0.22949218749999997,"top":0.17708333333333331},"atlasBounds":{"left":113.5,"bottom":10.5,"right":125.5,"top":27.5}},{"unicode":45,"advance":0.32177734375,"planeBounds":{"left":-0.0057779947916666487,"bottom":0.17708333333333331,"right":0.32755533854166669,"top":0.36458333333333331},"atlasBounds":{"left":180.5,"bottom":18.5,"right":196.5,"top":27.5}},{"unicode":46,"advance":0.26611328125,"planeBounds":{"left":0.028889973958333336,"bottom":-0.072916666666666657,"right":0.23722330729166669,"top":0.17708333333333331},"atlasBounds":{"left":141.5,"bottom":15.5,"right":151.5,"top":27.5}},{"unicode":47,"advance":0.3671875,"planeBounds":{"left":-0.035400390625,"bottom":-0.052083333333333336,"right":0.40209960937499994,"top":0.76041666666666663},"atlasBounds":{"left":64.5,"bottom":141.5,"right":85.5,"top":180.5}},{"unicode":48,"advance":0.57177734375,"planeBounds":{"left":0.0046386718749999653,"bottom":-0.052083333333333336,"right":0.56713867187499989,"top":0.78125},"atlasBounds":{"left":38.5,"bottom":181.5,"right":65.5,"top":221.5}},{"unicode":49,"advance":0.57177734375,"planeBounds":{"left":0.043375651041666664,"bottom":-0.052083333333333336,"right":0.39754231770833331,"top":0.76041666666666663},"atlasBounds":{"left":302.5,"bottom":226.5,"right":319.5,"top":265.5}},{"unicode":50,"advance":0.57177734375,"planeBounds":{"left":0.0021972656249999653,"bottom":-0.052083333333333336,"right":0.56469726562499989,"top":0.78125},"atlasBounds":{"left":76.5,"bottom":181.5,"right":103.5,"top":221.5}},{"unicode":51,"advance":0.57177734375,"planeBounds":{"left":-0.0017089843750000364,"bottom":-0.052083333333333336,"right":0.56079101562499989,"top":0.78125},"atlasBounds":{"left":104.5,"bottom":181.5,"right":131.5,"top":221.5}},{"unicode":52,"advance":0.57177734375,"planeBounds":{"left":-0.026123046875000035,"bottom":-0.052083333333333336,"right":0.59887695312499989,"top":0.76041666666666663},"atlasBounds":{"left":262.5,"bottom":141.5,"right":292.5,"top":180.5}},{"unicode":53,"advance":0.57177734375,"planeBounds":{"left":0.01871744791666663,"bottom":-0.052083333333333336,"right":0.56038411458333326,"top":0.76041666666666663},"atlasBounds":{"left":152.5,"bottom":100.5,"right":178.5,"top":139.5}},{"unicode":54,"advance":0.57177734375,"planeBounds":{"left":0.0087890624999999636,"bottom":-0.052083333333333336,"right":0.57128906249999989,"top":0.78125},"atlasBounds":{"left":132.5,"bottom":181.5,"right":159.5,"top":221.5}},{"unicode":55,"advance":0.57177734375,"planeBounds":{"left":0.0021972656249999653,"bottom":-0.052083333333333336,"right":0.56469726562499989,"top":0.76041666666666663},"atlasBounds":{"left":98.5,"bottom":60.5,"right":125.5,"top":99.5}},{"unicode":56,"advance":0.57177734375,"planeBounds":{"left":0.0041503906249999653,"bottom":-0.052083333333333336,"right":0.56665039062499989,"top":0.78125},"atlasBounds":{"left":160.5,"bottom":181.5,"right":187.5,"top":221.5}},{"unicode":57,"advance":0.57177734375,"planeBounds":{"left":0.0036621093749999636,"bottom":-0.052083333333333336,"right":0.56616210937499989,"top":0.78125},"atlasBounds":{"left":188.5,"bottom":181.5,"right":215.5,"top":221.5}},{"unicode":58,"advance":0.26611328125,"planeBounds":{"left":0.028889973958333336,"bottom":-0.072916666666666657,"right":0.23722330729166669,"top":0.59374999999999989},"atlasBounds":{"left":161.5,"bottom":67.5,"right":171.5,"top":99.5}},{"unicode":59,"advance":0.26611328125,"planeBounds":{"left":-0.014648437499999998,"bottom":-0.17708333333333334,"right":0.23535156249999997,"top":0.59375},"atlasBounds":{"left":148.5,"bottom":62.5,"right":160.5,"top":99.5}},{"unicode":60,"advance":0.57177734375,"planeBounds":{"left":0.0041503906249999653,"bottom":0.072916666666666671,"right":0.56665039062499989,"top":0.65625},"atlasBounds":{"left":259.5,"bottom":31.5,"right":286.5,"top":59.5}},{"unicode":61,"advance":0.57177734375,"planeBounds":{"left":0.014322916666666631,"bottom":0.17708333333333331,"right":0.55598958333333326,"top":0.53125},"atlasBounds":{"left":57.5,"bottom":10.5,"right":83.5,"top":27.5}},{"unicode":62,"advance":0.57177734375,"planeBounds":{"left":0.0041503906249999653,"bottom":0.072916666666666671,"right":0.56665039062499989,"top":0.65625},"atlasBounds":{"left":287.5,"bottom":31.5,"right":314.5,"top":59.5}},{"unicode":63,"advance":0.42919921875,"planeBounds":{"left":-0.031575520833333336,"bottom":-0.072916666666666657,"right":0.44759114583333331,"top":0.78124999999999989},"atlasBounds":{"left":109.5,"bottom":224.5,"right":132.5,"top":265.5}},{"unicode":64,"advance":0.89892578125,"planeBounds":{"left":0.011962890625000002,"bottom":-0.13541666666666669,"right":0.886962890625,"top":0.76041666666666663},"atlasBounds":{"left":243.5,"bottom":276.5,"right":285.5,"top":319.5}},{"unicode":65,"advance":0.6328125,"planeBounds":{"left":-0.048177083333333336,"bottom":-0.052083333333333336,"right":0.68098958333333326,"top":0.76041666666666663},"atlasBounds":{"left":62.5,"bottom":60.5,"right":97.5,"top":99.5}},{"unicode":66,"advance":0.64794921875,"planeBounds":{"left":0.053792317708333294,"bottom":-0.052083333333333336,"right":0.63712565104166663,"top":0.76041666666666663},"atlasBounds":{"left":33.5,"bottom":60.5,"right":61.5,"top":99.5}},{"unicode":67,"advance":0.630859375,"planeBounds":{"left":0.018554687499999965,"bottom":-0.052083333333333336,"right":0.64355468749999989,"top":0.78125},"atlasBounds":{"left":216.5,"bottom":181.5,"right":246.5,"top":221.5}},{"unicode":68,"advance":0.72900390625,"planeBounds":{"left":0.04972330729166663,"bottom":-0.052083333333333336,"right":0.71638997395833326,"top":0.76041666666666663},"atlasBounds":{"left":0.5,"bottom":60.5,"right":32.5,"top":99.5}},{"unicode":69,"advance":0.55615234375,"planeBounds":{"left":0.047119140625,"bottom":-0.052083333333333336,"right":0.547119140625,"top":0.76041666666666663},"atlasBounds":{"left":274.5,"bottom":100.5,"right":298.5,"top":139.5}},{"unicode":70,"advance":0.51611328125,"planeBounds":{"left":0.047119140625,"bottom":-0.052083333333333336,"right":0.547119140625,"top":0.76041666666666663},"atlasBounds":{"left":249.5,"bottom":100.5,"right":273.5,"top":139.5}},{"unicode":71,"advance":0.72802734375,"planeBounds":{"left":0.014160156250000002,"bottom":-0.052083333333333336,"right":0.70166015625,"top":0.78125},"atlasBounds":{"left":247.5,"bottom":181.5,"right":280.5,"top":221.5}},{"unicode":72,"advance":0.73779296875,"planeBounds":{"left":0.046223958333333294,"bottom":-0.052083333333333336,"right":0.69205729166666663,"top":0.76041666666666663},"atlasBounds":{"left":188.5,"bottom":100.5,"right":219.5,"top":139.5}},{"unicode":73,"advance":0.27880859375,"planeBounds":{"left":0.056315104166666657,"bottom":-0.052083333333333336,"right":0.22298177083333331,"top":0.76041666666666663},"atlasBounds":{"left":179.5,"bottom":100.5,"right":187.5,"top":139.5}},{"unicode":74,"advance":0.26708984375,"planeBounds":{"left":-0.12825520833333334,"bottom":-0.23958333333333334,"right":0.22591145833333331,"top":0.76041666666666652},"atlasBounds":{"left":25.5,"bottom":271.5,"right":42.5,"top":319.5}},{"unicode":75,"advance":0.61376953125,"planeBounds":{"left":0.05387369791666663,"bottom":-0.052083333333333336,"right":0.65804036458333326,"top":0.76041666666666663},"atlasBounds":{"left":122.5,"bottom":100.5,"right":151.5,"top":139.5}},{"unicode":76,"advance":0.51904296875,"planeBounds":{"left":0.047119140625,"bottom":-0.052083333333333336,"right":0.547119140625,"top":0.76041666666666663},"atlasBounds":{"left":97.5,"bottom":100.5,"right":121.5,"top":139.5}},{"unicode":77,"advance":0.90283203125,"planeBounds":{"left":0.055826822916666664,"bottom":-0.052083333333333336,"right":0.84749348958333326,"top":0.76041666666666663},"atlasBounds":{"left":281.5,"bottom":182.5,"right":319.5,"top":221.5}},{"unicode":78,"advance":0.75390625,"planeBounds":{"left":0.054036458333333294,"bottom":-0.052083333333333336,"right":0.69986979166666663,"top":0.76041666666666663},"atlasBounds":{"left":33.5,"bottom":100.5,"right":64.5,"top":139.5}},{"unicode":79,"advance":0.77880859375,"planeBounds":{"left":0.014404296875000002,"bottom":-0.052083333333333336,"right":0.764404296875,"top":0.78125},"atlasBounds":{"left":0.5,"bottom":140.5,"right":36.5,"top":180.5}},{"unicode":80,"advance":0.60205078125,"planeBounds":{"left":0.05362955729166663,"bottom":-0.052083333333333336,"right":0.59529622395833326,"top":0.76041666666666663},"atlasBounds":{"left":293.5,"bottom":141.5,"right":319.5,"top":180.5}},{"unicode":81,"advance":0.77880859375,"planeBounds":{"left":0.014404296875000002,"bottom":-0.21875,"right":0.764404296875,"top":0.78124999999999989},"atlasBounds":{"left":43.5,"bottom":271.5,"right":79.5,"top":319.5}},{"unicode":82,"advance":0.6181640625,"planeBounds":{"left":0.04752604166666663,"bottom":-0.052083333333333336,"right":0.65169270833333326,"top":0.76041666666666663},"atlasBounds":{"left":232.5,"bottom":141.5,"right":261.5,"top":180.5}},{"unicode":83,"advance":0.548828125,"planeBounds":{"left":0.0055338541666666314,"bottom":-0.052083333333333336,"right":0.54720052083333326,"top":0.78125},"atlasBounds":{"left":37.5,"bottom":140.5,"right":63.5,"top":180.5}},{"unicode":84,"advance":0.55322265625,"planeBounds":{"left":-0.036132812500000035,"bottom":-0.052083333333333336,"right":0.58886718749999989,"top":0.76041666666666663},"atlasBounds":{"left":152.5,"bottom":141.5,"right":182.5,"top":180.5}},{"unicode":85,"advance":0.72802734375,"planeBounds":{"left":0.041097005208333301,"bottom":-0.052083333333333336,"right":0.68693033854166663,"top":0.76041666666666663},"atlasBounds":{"left":120.5,"bottom":141.5,"right":151.5,"top":180.5}},{"unicode":86,"advance":0.59521484375,"planeBounds":{"left":-0.046142578125,"bottom":-0.052083333333333336,"right":0.641357421875,"top":0.76041666666666663},"atlasBounds":{"left":86.5,"bottom":141.5,"right":119.5,"top":180.5}},{"unicode":87,"advance":0.92578125,"planeBounds":{"left":-0.037353515625,"bottom":-0.052083333333333336,"right":0.96264648437499989,"top":0.76041666666666663},"atlasBounds":{"left":183.5,"bottom":141.5,"right":231.5,"top":180.5}},{"unicode":88,"advance":0.5771484375,"planeBounds":{"left":-0.04475911458333337,"bottom":-0.052083333333333336,"right":0.62190755208333326,"top":0.76041666666666663},"atlasBounds":{"left":0.5,"bottom":100.5,"right":32.5,"top":139.5}},{"unicode":89,"advance":0.56005859375,"planeBounds":{"left":-0.042887369791666706,"bottom":-0.052083333333333336,"right":0.60294596354166663,"top":0.76041666666666663},"atlasBounds":{"left":65.5,"bottom":100.5,"right":96.5,"top":139.5}},{"unicode":90,"advance":0.57080078125,"planeBounds":{"left":-0.0062662760416667043,"bottom":-0.052083333333333336,"right":0.57706705729166663,"top":0.76041666666666663},"atlasBounds":{"left":220.5,"bottom":100.5,"right":248.5,"top":139.5}},{"unicode":91,"advance":0.3291015625,"planeBounds":{"left":0.036376953125000021,"bottom":-0.21875,"right":0.348876953125,"top":0.76041666666666663},"atlasBounds":{"left":133.5,"bottom":272.5,"right":148.5,"top":319.5}},{"unicode":92,"advance":0.3671875,"planeBounds":{"left":-0.0341796875,"bottom":-0.052083333333333336,"right":0.40332031249999994,"top":0.76041666666666663},"atlasBounds":{"left":126.5,"bottom":60.5,"right":147.5,"top":99.5}},{"unicode":93,"advance":0.3291015625,"planeBounds":{"left":-0.019775390624999979,"bottom":-0.21875,"right":0.292724609375,"top":0.76041666666666663},"atlasBounds":{"left":149.5,"bottom":272.5,"right":164.5,"top":319.5}},{"unicode":94,"advance":0.5419921875,"planeBounds":{"left":-0.021158854166666706,"bottom":0.21875,"right":0.56217447916666663,"top":0.78125},"atlasBounds":{"left":0.5,"bottom":0.5,"right":28.5,"top":27.5}},{"unicode":95,"advance":0.4482421875,"planeBounds":{"left":-0.046712239583333336,"bottom":-0.19791666666666669,"right":0.49495442708333326,"top":-0.031250000000000028},"atlasBounds":{"left":57.5,"bottom":1.5,"right":83.5,"top":9.5}},{"unicode":96,"advance":0.5771484375,"planeBounds":{"left":0.14200846354166669,"bottom":0.55208333333333326,"right":0.43367513020833337,"top":0.82291666666666663},"atlasBounds":{"left":126.5,"bottom":14.5,"right":140.5,"top":27.5}},{"unicode":97,"advance":0.55615234375,"planeBounds":{"left":8.1380208333297482e-05,"bottom":-0.052083333333333336,"right":0.52091471354166663,"top":0.59375},"atlasBounds":{"left":176.5,"bottom":28.5,"right":201.5,"top":59.5}},{"unicode":98,"advance":0.61279296875,"planeBounds":{"left":0.040283203124999965,"bottom":-0.052083333333333336,"right":0.60278320312499989,"top":0.80208333333333326},"atlasBounds":{"left":168.5,"bottom":224.5,"right":195.5,"top":265.5}},{"unicode":99,"advance":0.47607421875,"planeBounds":{"left":0.0099283854166666678,"bottom":-0.052083333333333336,"right":0.48909505208333331,"top":0.59375},"atlasBounds":{"left":77.5,"bottom":28.5,"right":100.5,"top":59.5}},{"unicode":100,"advance":0.61279296875,"planeBounds":{"left":0.010253906249999965,"bottom":-0.052083333333333336,"right":0.57275390624999989,"top":0.80208333333333326},"atlasBounds":{"left":196.5,"bottom":224.5,"right":223.5,"top":265.5}},{"unicode":101,"advance":0.56103515625,"planeBounds":{"left":0.011637369791666631,"bottom":-0.052083333333333336,"right":0.55330403645833326,"top":0.59375},"atlasBounds":{"left":285.5,"bottom":68.5,"right":311.5,"top":99.5}},{"unicode":102,"advance":0.3388671875,"planeBounds":{"left":-0.031168619791666664,"bottom":-0.052083333333333336,"right":0.42716471354166669,"top":0.82291666666666663},"atlasBounds":{"left":86.5,"bottom":223.5,"right":108.5,"top":265.5}},{"unicode":103,"advance":0.5478515625,"planeBounds":{"left":-0.03059895833333337,"bottom":-0.30208333333333331,"right":0.57356770833333326,"top":0.59375},"atlasBounds":{"left":56.5,"bottom":222.5,"right":85.5,"top":265.5}},{"unicode":104,"advance":0.61376953125,"planeBounds":{"left":0.03873697916666663,"bottom":-0.052083333333333336,"right":0.58040364583333326,"top":0.80208333333333326},"atlasBounds":{"left":224.5,"bottom":224.5,"right":250.5,"top":265.5}},{"unicode":105,"advance":0.2529296875,"planeBounds":{"left":0.033203125,"bottom":-0.052083333333333336,"right":0.220703125,"top":0.78125},"atlasBounds":{"left":66.5,"bottom":181.5,"right":75.5,"top":221.5}},{"unicode":106,"advance":0.2529296875,"planeBounds":{"left":-0.095947265624999986,"bottom":-0.30208333333333331,"right":0.21655273437499997,"top":0.78125},"atlasBounds":{"left":9.5,"bottom":267.5,"right":24.5,"top":319.5}},{"unicode":107,"advance":0.52490234375,"planeBounds":{"left":0.039632161458333294,"bottom":-0.052083333333333336,"right":0.56046549479166663,"top":0.80208333333333326},"atlasBounds":{"left":142.5,"bottom":224.5,"right":167.5,"top":265.5}},{"unicode":108,"advance":0.2529296875,"planeBounds":{"left":0.043131510416666657,"bottom":-0.052083333333333336,"right":0.20979817708333331,"top":0.80208333333333326},"atlasBounds":{"left":133.5,"bottom":224.5,"right":141.5,"top":265.5}},{"unicode":109,"advance":0.93017578125,"planeBounds":{"left":0.040445963541666664,"bottom":-0.052083333333333336,"right":0.89461263020833326,"top":0.59375},"atlasBounds":{"left":172.5,"bottom":68.5,"right":213.5,"top":99.5}},{"unicode":110,"advance":0.61376953125,"planeBounds":{"left":0.03873697916666663,"bottom":-0.052083333333333336,"right":0.58040364583333326,"top":0.59375},"atlasBounds":{"left":149.5,"bottom":28.5,"right":175.5,"top":59.5}},{"unicode":111,"advance":0.60400390625,"planeBounds":{"left":0.010335286458333297,"bottom":-0.052083333333333336,"right":0.59366861979166663,"top":0.59375},"atlasBounds":{"left":214.5,"bottom":68.5,"right":242.5,"top":99.5}},{"unicode":112,"advance":0.61279296875,"planeBounds":{"left":0.040283203124999965,"bottom":-0.30208333333333331,"right":0.60278320312499989,"top":0.59375},"atlasBounds":{"left":28.5,"bottom":222.5,"right":55.5,"top":265.5}},{"unicode":113,"advance":0.61279296875,"planeBounds":{"left":0.010253906249999965,"bottom":-0.30208333333333331,"right":0.57275390624999989,"top":0.59375},"atlasBounds":{"left":0.5,"bottom":222.5,"right":27.5,"top":265.5}},{"unicode":114,"advance":0.408203125,"planeBounds":{"left":0.042073567708333336,"bottom":-0.052083333333333336,"right":0.43790690104166669,"top":0.59375},"atlasBounds":{"left":57.5,"bottom":28.5,"right":76.5,"top":59.5}},{"unicode":115,"advance":0.47705078125,"planeBounds":{"left":0.0018717447916666696,"bottom":-0.052083333333333336,"right":0.48103841145833331,"top":0.59375},"atlasBounds":{"left":125.5,"bottom":28.5,"right":148.5,"top":59.5}},{"unicode":116,"advance":0.35302734375,"planeBounds":{"left":-0.034749348958333336,"bottom":-0.052083333333333336,"right":0.38191731770833331,"top":0.71875},"atlasBounds":{"left":299.5,"bottom":102.5,"right":319.5,"top":139.5}},{"unicode":117,"advance":0.61376953125,"planeBounds":{"left":0.03312174479166663,"bottom":-0.052083333333333336,"right":0.57478841145833326,"top":0.59375},"atlasBounds":{"left":30.5,"bottom":28.5,"right":56.5,"top":59.5}},{"unicode":118,"advance":0.5009765625,"planeBounds":{"left":-0.05159505208333337,"bottom":-0.052083333333333336,"right":0.55257161458333326,"top":0.59375},"atlasBounds":{"left":0.5,"bottom":28.5,"right":29.5,"top":59.5}},{"unicode":119,"advance":0.77783203125,"planeBounds":{"left":-0.037923177083333336,"bottom":-0.052083333333333336,"right":0.81624348958333326,"top":0.59375},"atlasBounds":{"left":243.5,"bottom":68.5,"right":284.5,"top":99.5}},{"unicode":120,"advance":0.52392578125,"planeBounds":{"left":-0.030192057291666706,"bottom":-0.052083333333333336,"right":0.55314127604166663,"top":0.59375},"atlasBounds":{"left":202.5,"bottom":28.5,"right":230.5,"top":59.5}},{"unicode":121,"advance":0.50390625,"planeBounds":{"left":-0.05013020833333337,"bottom":-0.30208333333333331,"right":0.55403645833333326,"top":0.59375},"atlasBounds":{"left":213.5,"bottom":276.5,"right":242.5,"top":319.5}},{"unicode":122,"advance":0.4677734375,"planeBounds":{"left":-0.0054524739583333339,"bottom":-0.052083333333333336,"right":0.47371419270833331,"top":0.59375},"atlasBounds":{"left":101.5,"bottom":28.5,"right":124.5,"top":59.5}},{"unicode":123,"advance":0.37890625,"planeBounds":{"left":-0.021321614583333336,"bottom":-0.21875,"right":0.39534505208333331,"top":0.76041666666666663},"atlasBounds":{"left":112.5,"bottom":272.5,"right":132.5,"top":319.5}},{"unicode":124,"advance":0.55078125,"planeBounds":{"left":0.19230143229166666,"bottom":-0.30208333333333331,"right":0.35896809895833331,"top":0.80208333333333326},"atlasBounds":{"left":0.5,"bottom":266.5,"right":8.5,"top":319.5}},{"unicode":125,"advance":0.37890625,"planeBounds":{"left":-0.016194661458333329,"bottom":-0.21875,"right":0.40047200520833331,"top":0.76041666666666663},"atlasBounds":{"left":165.5,"bottom":272.5,"right":185.5,"top":319.5}},{"unicode":126,"advance":0.57177734375,"planeBounds":{"left":0.0041503906249999653,"bottom":0.23958333333333331,"right":0.56665039062499989,"top":0.46875},"atlasBounds":{"left":152.5,"bottom":16.5,"right":179.5,"top":27.5}}],"kerning":[{"unicode1":34,"unicode2":65,"advance":-0.06982421875},{"unicode1":34,"unicode2":84,"advance":0.02001953125},{"unicode1":34,"unicode2":86,"advance":0.02001953125},{"unicode1":34,"unicode2":87,"advance":0.02001953125},{"unicode1":34,"unicode2":89,"advance":0.009765625},{"unicode1":34,"unicode2":97,"advance":-0.0400390625},{"unicode1":34,"unicode2":99,"advance":-0.06005859375},{"unicode1":34,"unicode2":100,"advance":-0.06005859375},{"unicode1":34,"unicode2":101,"advance":-0.06005859375},{"unicode1":34,"unicode2":103,"advance":-0.02978515625},{"unicode1":34,"unicode2":109,"advance":-0.02978515625},{"unicode1":34,"unicode2":110,"advance":-0.02978515625},{"unicode1":34,"unicode2":111,"advance":-0.06005859375},{"unicode1":34,"unicode2":112,"advance":-0.02978515625},{"unicode1":34,"unicode2":113,"advance":-0.06005859375},{"unicode1":34,"unicode2":114,"advance":-0.02978515625},{"unicode1":34,"unicode2":115,"advance":-0.02978515625},{"unicode1":34,"unicode2":117,"advance":-0.02978515625},{"unicode1":39,"unicode2":65,"advance":-0.06982421875},{"unicode1":39,"unicode2":84,"advance":0.02001953125},{"unicode1":39,"unicode2":86,"advance":0.02001953125},{"unicode1":39,"unicode2":87,"advance":0.02001953125},{"unicode1":39,"unicode2":89,"advance":0.009765625},{"unicode1":39,"unicode2":97,"advance":-0.0400390625},{"unicode1":39,"unicode2":99,"advance":-0.06005859375},{"unicode1":39,"unicode2":100,"advance":-0.06005859375},{"unicode1":39,"unicode2":101,"advance":-0.06005859375},{"unicode1":39,"unicode2":103,"advance":-0.02978515625},{"unicode1":39,"unicode2":109,"advance":-0.02978515625},{"unicode1":39,"unicode2":110,"advance":-0.02978515625},{"unicode1":39,"unicode2":111,"advance":-0.06005859375},{"unicode1":39,"unicode2":112,"advance":-0.02978515625},{"unicode1":39,"unicode2":113,"advance":-0.06005859375},{"unicode1":39,"unicode2":114,"advance":-0.02978515625},{"unicode1":39,"unicode2":115,"advance":-0.02978515625},{"unicode1":39,"unicode2":117,"advance":-0.02978515625},{"unicode1":40,"unicode2":74,"advance":0.08984375},{"unicode1":44,"unicode2":67,"advance":-0.0498046875},{"unicode1":44,"unicode2":71,"advance":-0.0498046875},{"unicode1":44,"unicode2":79,"advance":-0.0498046875},{"unicode1":44,"unicode2":81,"advance":-0.0498046875},{"unicode1":44,"unicode2":84,"advance":-0.06982421875},{"unicode1":44,"unicode2":85,"advance":-0.02001953125},{"unicode1":44,"unicode2":86,"advance":-0.06005859375},{"unicode1":44,"unicode2":87,"advance":-0.06005859375},{"unicode1":44,"unicode2":89,"advance":-0.06005859375},{"unicode1":45,"unicode2":84,"advance":-0.0400390625},{"unicode1":46,"unicode2":67,"advance":-0.0498046875},{"unicode1":46,"unicode2":71,"advance":-0.0498046875},{"unicode1":46,"unicode2":79,"advance":-0.0498046875},{"unicode1":46,"unicode2":81,"advance":-0.0498046875},{"unicode1":46,"unicode2":84,"advance":-0.06982421875},{"unicode1":46,"unicode2":85,"advance":-0.02001953125},{"unicode1":46,"unicode2":86,"advance":-0.06005859375},{"unicode1":46,"unicode2":87,"advance":-0.06005859375},{"unicode1":46,"unicode2":89,"advance":-0.06005859375},{"unicode1":65,"unicode2":34,"advance":-0.06982421875},{"unicode1":65,"unicode2":39,"advance":-0.06982421875},{"unicode1":65,"unicode2":67,"advance":-0.02001953125},{"unicode1":65,"unicode2":71,"advance":-0.02001953125},{"unicode1":65,"unicode2":74,"advance":0.1298828125},{"unicode1":65,"unicode2":79,"advance":-0.02001953125},{"unicode1":65,"unicode2":81,"advance":-0.02001953125},{"unicode1":65,"unicode2":84,"advance":-0.06982421875},{"unicode1":65,"unicode2":86,"advance":-0.0400390625},{"unicode1":65,"unicode2":87,"advance":-0.0400390625},{"unicode1":65,"unicode2":89,"advance":-0.06005859375},{"unicode1":66,"unicode2":44,"advance":-0.0400390625},{"unicode1":66,"unicode2":46,"advance":-0.0400390625},{"unicode1":66,"unicode2":65,"advance":-0.02001953125},{"unicode1":66,"unicode2":84,"advance":-0.02978515625},{"unicode1":66,"unicode2":86,"advance":-0.009765625},{"unicode1":66,"unicode2":87,"advance":-0.009765625},{"unicode1":66,"unicode2":88,"advance":-0.02001953125},{"unicode1":66,"unicode2":89,"advance":-0.009765625},{"unicode1":66,"unicode2":90,"advance":-0.009765625},{"unicode1":67,"unicode2":67,"advance":-0.02001953125},{"unicode1":67,"unicode2":71,"advance":-0.02001953125},{"unicode1":67,"unicode2":79,"advance":-0.02001953125},{"unicode1":67,"unicode2":81,"advance":-0.02001953125},{"unicode1":68,"unicode2":44,"advance":-0.0400390625},{"unicode1":68,"unicode2":46,"advance":-0.0400390625},{"unicode1":68,"unicode2":65,"advance":-0.02001953125},{"unicode1":68,"unicode2":84,"advance":-0.02978515625},{"unicode1":68,"unicode2":86,"advance":-0.009765625},{"unicode1":68,"unicode2":87,"advance":-0.009765625},{"unicode1":68,"unicode2":88,"advance":-0.02001953125},{"unicode1":68,"unicode2":89,"advance":-0.009765625},{"unicode1":68,"unicode2":90,"advance":-0.009765625},{"unicode1":69,"unicode2":74,"advance":0.06005859375},{"unicode1":70,"unicode2":44,"advance":-0.06005859375},{"unicode1":70,"unicode2":46,"advance":-0.06005859375},{"unicode1":70,"unicode2":63,"advance":0.02001953125},{"unicode1":70,"unicode2":65,"advance":-0.02001953125},{"unicode1":75,"unicode2":67,"advance":-0.02001953125},{"unicode1":75,"unicode2":71,"advance":-0.02001953125},{"unicode1":75,"unicode2":79,"advance":-0.02001953125},{"unicode1":75,"unicode2":81,"advance":-0.02001953125},{"unicode1":76,"unicode2":34,"advance":-0.080078125},{"unicode1":76,"unicode2":39,"advance":-0.080078125},{"unicode1":76,"unicode2":67,"advance":-0.02001953125},{"unicode1":76,"unicode2":71,"advance":-0.02001953125},{"unicode1":76,"unicode2":79,"advance":-0.02001953125},{"unicode1":76,"unicode2":81,"advance":-0.02001953125},{"unicode1":76,"unicode2":84,"advance":-0.02001953125},{"unicode1":76,"unicode2":85,"advance":-0.009765625},{"unicode1":76,"unicode2":86,"advance":-0.02001953125},{"unicode1":76,"unicode2":87,"advance":-0.02001953125},{"unicode1":76,"unicode2":89,"advance":-0.02978515625},{"unicode1":79,"unicode2":44,"advance":-0.0400390625},{"unicode1":79,"unicode2":46,"advance":-0.0400390625},{"unicode1":79,"unicode2":65,"advance":-0.02001953125},{"unicode1":79,"unicode2":84,"advance":-0.02978515625},{"unicode1":79,"unicode2":86,"advance":-0.009765625},{"unicode1":79,"unicode2":87,"advance":-0.009765625},{"unicode1":79,"unicode2":88,"advance":-0.02001953125},{"unicode1":79,"unicode2":89,"advance":-0.009765625},{"unicode1":79,"unicode2":90,"advance":-0.009765625},{"unicode1":80,"unicode2":44,"advance":-0.1298828125},{"unicode1":80,"unicode2":46,"advance":-0.1298828125},{"unicode1":80,"unicode2":65,"advance":-0.0498046875},{"unicode1":80,"unicode2":88,"advance":-0.02001953125},{"unicode1":80,"unicode2":90,"advance":-0.009765625},{"unicode1":81,"unicode2":44,"advance":-0.0400390625},{"unicode1":81,"unicode2":46,"advance":-0.0400390625},{"unicode1":81,"unicode2":65,"advance":-0.02001953125},{"unicode1":81,"unicode2":84,"advance":-0.02978515625},{"unicode1":81,"unicode2":86,"advance":-0.009765625},{"unicode1":81,"unicode2":87,"advance":-0.009765625},{"unicode1":81,"unicode2":88,"advance":-0.02001953125},{"unicode1":81,"unicode2":89,"advance":-0.009765625},{"unicode1":81,"unicode2":90,"advance":-0.009765625},{"unicode1":84,"unicode2":44,"advance":-0.06005859375},{"unicode1":84,"unicode2":45,"advance":-0.0400390625},{"unicode1":84,"unicode2":46,"advance":-0.06005859375},{"unicode1":84,"unicode2":63,"advance":0.02001953125},{"unicode1":84,"unicode2":65,"advance":-0.06982421875},{"unicode1":84,"unicode2":67,"advance":-0.02001953125},{"unicode1":84,"unicode2":71,"advance":-0.02001953125},{"unicode1":84,"unicode2":79,"advance":-0.02001953125},{"unicode1":84,"unicode2":81,"advance":-0.02001953125},{"unicode1":84,"unicode2":84,"advance":0.02001953125},{"unicode1":84,"unicode2":97,"advance":-0.080078125},{"unicode1":84,"unicode2":99,"advance":-0.06982421875},{"unicode1":84,"unicode2":100,"advance":-0.06982421875},{"unicode1":84,"unicode2":101,"advance":-0.06982421875},{"unicode1":84,"unicode2":103,"advance":-0.06982421875},{"unicode1":84,"unicode2":109,"advance":-0.0498046875},{"unicode1":84,"unicode2":110,"advance":-0.0498046875},{"unicode1":84,"unicode2":111,"advance":-0.06982421875},{"unicode1":84,"unicode2":112,"advance":-0.0498046875},{"unicode1":84,"unicode2":113,"advance":-0.06982421875},{"unicode1":84,"unicode2":114,"advance":-0.0498046875},{"unicode1":84,"unicode2":115,"advance":-0.06005859375},{"unicode1":84,"unicode2":117,"advance":-0.0498046875},{"unicode1":84,"unicode2":118,"advance":-0.02001953125},{"unicode1":84,"unicode2":119,"advance":-0.02001953125},{"unicode1":84,"unicode2":120,"advance":-0.02001953125},{"unicode1":84,"unicode2":121,"advance":-0.02001953125},{"unicode1":84,"unicode2":122,"advance":-0.0400390625},{"unicode1":85,"unicode2":44,"advance":-0.02001953125},{"unicode1":85,"unicode2":46,"advance":-0.02001953125},{"unicode1":85,"unicode2":65,"advance":-0.009765625},{"unicode1":86,"unicode2":44,"advance":-0.0498046875},{"unicode1":86,"unicode2":46,"advance":-0.0498046875},{"unicode1":86,"unicode2":63,"advance":0.02001953125},{"unicode1":86,"unicode2":65,"advance":-0.0400390625},{"unicode1":86,"unicode2":67,"advance":-0.009765625},{"unicode1":86,"unicode2":71,"advance":-0.009765625},{"unicode1":86,"unicode2":79,"advance":-0.009765625},{"unicode1":86,"unicode2":81,"advance":-0.009765625},{"unicode1":86,"unicode2":97,"advance":-0.02001953125},{"unicode1":86,"unicode2":99,"advance":-0.02001953125},{"unicode1":86,"unicode2":100,"advance":-0.02001953125},{"unicode1":86,"unicode2":101,"advance":-0.02001953125},{"unicode1":86,"unicode2":103,"advance":-0.009765625},{"unicode1":86,"unicode2":109,"advance":-0.009765625},{"unicode1":86,"unicode2":110,"advance":-0.009765625},{"unicode1":86,"unicode2":111,"advance":-0.02001953125},{"unicode1":86,"unicode2":112,"advance":-0.009765625},{"unicode1":86,"unicode2":113,"advance":-0.02001953125},{"unicode1":86,"unicode2":114,"advance":-0.009765625},{"unicode1":86,"unicode2":115,"advance":-0.009765625},{"unicode1":86,"unicode2":117,"advance":-0.009765625},{"unicode1":87,"unicode2":44,"advance":-0.0498046875},{"unicode1":87,"unicode2":46,"advance":-0.0498046875},{"unicode1":87,"unicode2":63,"advance":0.02001953125},{"unicode1":87,"unicode2":65,"advance":-0.0400390625},{"unicode1":87,"unicode2":67,"advance":-0.009765625},{"unicode1":87,"unicode2":71,"advance":-0.009765625},{"unicode1":87,"unicode2":79,"advance":-0.009765625},{"unicode1":87,"unicode2":81,"advance":-0.009765625},{"unicode1":87,"unicode2":97,"advance":-0.02001953125},{"unicode1":87,"unicode2":99,"advance":-0.02001953125},{"unicode1":87,"unicode2":100,"advance":-0.02001953125},{"unicode1":87,"unicode2":101,"advance":-0.02001953125},{"unicode1":87,"unicode2":103,"advance":-0.009765625},{"unicode1":87,"unicode2":109,"advance":-0.009765625},{"unicode1":87,"unicode2":110,"advance":-0.009765625},{"unicode1":87,"unicode2":111,"advance":-0.02001953125},{"unicode1":87,"unicode2":112,"advance":-0.009765625},{"unicode1":87,"unicode2":113,"advance":-0.02001953125},{"unicode1":87,"unicode2":114,"advance":-0.009765625},{"unicode1":87,"unicode2":115,"advance":-0.009765625},{"unicode1":87,"unicode2":117,"advance":-0.009765625},{"unicode1":88,"unicode2":67,"advance":-0.02001953125},{"unicode1":88,"unicode2":71,"advance":-0.02001953125},{"unicode1":88,"unicode2":79,"advance":-0.02001953125},{"unicode1":88,"unicode2":81,"advance":-0.02001953125},{"unicode1":89,"unicode2":44,"advance":-0.06005859375},{"unicode1":89,"unicode2":46,"advance":-0.06005859375},{"unicode1":89,"unicode2":63,"advance":0.02001953125},{"unicode1":89,"unicode2":65,"advance":-0.06005859375},{"unicode1":89,"unicode2":67,"advance":-0.02001953125},{"unicode1":89,"unicode2":71,"advance":-0.02001953125},{"unicode1":89,"unicode2":79,"advance":-0.02001953125},{"unicode1":89,"unicode2":81,"advance":-0.02001953125},{"unicode1":89,"unicode2":97,"advance":-0.0498046875},{"unicode1":89,"unicode2":99,"advance":-0.0498046875},{"unicode1":89,"unicode2":100,"advance":-0.0498046875},{"unicode1":89,"unicode2":101,"advance":-0.0498046875},{"unicode1":89,"unicode2":103,"advance":-0.02001953125},{"unicode1":89,"unicode2":109,"advance":-0.02978515625},{"unicode1":89,"unicode2":110,"advance":-0.02978515625},{"unicode1":89,"unicode2":111,"advance":-0.0498046875},{"unicode1":89,"unicode2":112,"advance":-0.02978515625},{"unicode1":89,"unicode2":113,"advance":-0.0498046875},{"unicode1":89,"unicode2":114,"advance":-0.02978515625},{"unicode1":89,"unicode2":115,"advance":-0.0400390625},{"unicode1":89,"unicode2":117,"advance":-0.02978515625},{"unicode1":89,"unicode2":122,"advance":-0.02001953125},{"unicode1":90,"unicode2":67,"advance":-0.009765625},{"unicode1":90,"unicode2":71,"advance":-0.009765625},{"unicode1":90,"unicode2":79,"advance":-0.009765625},{"unicode1":90,"unicode2":81,"advance":-0.009765625},{"unicode1":91,"unicode2":74,"advance":0.08984375},{"unicode1":97,"unicode2":34,"advance":-0.009765625},{"unicode1":97,"unicode2":39,"advance":-0.009765625},{"unicode1":98,"unicode2":34,"advance":-0.009765625},{"unicode1":98,"unicode2":39,"advance":-0.009765625},{"unicode1":98,"unicode2":118,"advance":-0.02001953125},{"unicode1":98,"unicode2":119,"advance":-0.02001953125},{"unicode1":98,"unicode2":120,"advance":-0.02001953125},{"unicode1":98,"unicode2":121,"advance":-0.02001953125},{"unicode1":98,"unicode2":122,"advance":-0.009765625},{"unicode1":99,"unicode2":34,"advance":0.02001953125},{"unicode1":99,"unicode2":39,"advance":0.02001953125},{"unicode1":101,"unicode2":34,"advance":-0.009765625},{"unicode1":101,"unicode2":39,"advance":-0.009765625},{"unicode1":101,"unicode2":118,"advance":-0.02001953125},{"unicode1":101,"unicode2":119,"advance":-0.02001953125},{"unicode1":101,"unicode2":120,"advance":-0.02001953125},{"unicode1":101,"unicode2":121,"advance":-0.02001953125},{"unicode1":101,"unicode2":122,"advance":-0.009765625},{"unicode1":102,"unicode2":34,"advance":0.06005859375},{"unicode1":102,"unicode2":39,"advance":0.06005859375},{"unicode1":104,"unicode2":34,"advance":-0.009765625},{"unicode1":104,"unicode2":39,"advance":-0.009765625},{"unicode1":107,"unicode2":99,"advance":-0.02001953125},{"unicode1":107,"unicode2":100,"advance":-0.02001953125},{"unicode1":107,"unicode2":101,"advance":-0.02001953125},{"unicode1":107,"unicode2":111,"advance":-0.02001953125},{"unicode1":107,"unicode2":113,"advance":-0.02001953125},{"unicode1":109,"unicode2":34,"advance":-0.009765625},{"unicode1":109,"unicode2":39,"advance":-0.009765625},{"unicode1":110,"unicode2":34,"advance":-0.009765625},{"unicode1":110,"unicode2":39,"advance":-0.009765625},{"unicode1":111,"unicode2":34,"advance":-0.009765625},{"unicode1":111,"unicode2":39,"advance":-0.009765625},{"unicode1":111,"unicode2":118,"advance":-0.02001953125},{"unicode1":111,"unicode2":119,"advance":-0.02001953125},{"unicode1":111,"unicode2":120,"advance":-0.02001953125},{"unicode1":111,"unicode2":121,"advance":-0.02001953125},{"unicode1":111,"unicode2":122,"advance":-0.009765625},{"unicode1":112,"unicode2":34,"advance":-0.009765625},{"unicode1":112,"unicode2":39,"advance":-0.009765625},{"unicode1":112,"unicode2":118,"advance":-0.02001953125},{"unicode1":112,"unicode2":119,"advance":-0.02001953125},{"unicode1":112,"unicode2":120,"advance":-0.02001953125},{"unicode1":112,"unicode2":121,"advance":-0.02001953125},{"unicode1":112,"unicode2":122,"advance":-0.009765625},{"unicode1":114,"unicode2":34,"advance":0.0400390625},{"unicode1":114,"unicode2":39,"advance":0.0400390625},{"unicode1":114,"unicode2":97,"advance":-0.02001953125},{"unicode1":114,"unicode2":99,"advance":-0.02001953125},{"unicode1":114,"unicode2":100,"advance":-0.02001953125},{"unicode1":114,"unicode2":101,"advance":-0.02001953125},{"unicode1":114,"unicode2":103,"advance":-0.009765625},{"unicode1":114,"unicode2":111,"advance":-0.02001953125},{"unicode1":114,"unicode2":113,"advance":-0.02001953125},{"unicode1":116,"unicode2":34,"advance":0.02001953125},{"unicode1":116,"unicode2":39,"advance":0.02001953125},{"unicode1":118,"unicode2":34,"advance":0.0400390625},{"unicode1":118,"unicode2":39,"advance":0.0400390625},{"unicode1":118,"unicode2":44,"advance":-0.0400390625},{"unicode1":118,"unicode2":46,"advance":-0.0400390625},{"unicode1":118,"unicode2":63,"advance":0.02001953125},{"unicode1":119,"unicode2":34,"advance":0.0400390625},{"unicode1":119,"unicode2":39,"advance":0.0400390625},{"unicode1":119,"unicode2":44,"advance":-0.0400390625},{"unicode1":119,"unicode2":46,"advance":-0.0400390625},{"unicode1":119,"unicode2":63,"advance":0.02001953125},{"unicode1":120,"unicode2":99,"advance":-0.02001953125},{"unicode1":120,"unicode2":100,"advance":-0.02001953125},{"unicode1":120,"unicode2":101,"advance":-0.02001953125},{"unicode1":120,"unicode2":111,"advance":-0.02001953125},{"unicode1":120,"unicode2":113,"advance":-0.02001953125},{"unicode1":121,"unicode2":34,"advance":0.0400390625},{"unicode1":121,"unicode2":39,"advance":0.0400390625},{"unicode1":121,"unicode2":44,"advance":-0.0400390625},{"unicode1":121,"unicode2":46,"advance":-0.0400390625},{"unicode1":121,"unicode2":63,"advance":0.02001953125},{"unicode1":123,"unicode2":74,"advance":0.08984375}]} diff --git a/documentation-site/static/fonts/open-sans/open-sans-msdf.png b/documentation-site/static/fonts/open-sans/open-sans-msdf.png new file mode 100644 index 00000000..881f027e Binary files /dev/null and b/documentation-site/static/fonts/open-sans/open-sans-msdf.png differ diff --git a/package.json b/package.json index ac2cb09d..862fd90d 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,12 @@ "default": "./dist/rendering/index.js" } }, + "./text": { + "import": { + "types": "./dist/text/index.d.ts", + "default": "./dist/text/index.js" + } + }, "./timer": { "import": { "types": "./dist/timer/index.d.ts", diff --git a/src/asset-loading/index.ts b/src/asset-loading/index.ts index 24e29cd0..80722edc 100644 --- a/src/asset-loading/index.ts +++ b/src/asset-loading/index.ts @@ -1,3 +1,5 @@ export * from './asset-cache.js'; export * from './asset-caches/index.js'; export * from './asset-registry.js'; +export * from './load-font-atlas.js'; +export * from './types/font-atlas.js'; diff --git a/src/asset-loading/load-font-atlas.test.ts b/src/asset-loading/load-font-atlas.test.ts new file mode 100644 index 00000000..c82a2cc4 --- /dev/null +++ b/src/asset-loading/load-font-atlas.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ImageCache } from './asset-caches/index.js'; +import { loadFontAtlas } from './load-font-atlas.js'; + +const msdfAtlasGenJson = { + atlas: { + distanceRange: 4, + width: 512, + height: 256, + yOrigin: 'bottom' as const, + }, + metrics: { + emSize: 1, + lineHeight: 1.2, + ascender: 0.95, + descender: -0.25, + }, + glyphs: [ + { unicode: 32, advance: 0.25 }, + { + unicode: 65, + advance: 0.66, + planeBounds: { left: 0.01, bottom: 0, right: 0.65, top: 0.68 }, + atlasBounds: { left: 100, bottom: 50, right: 164, top: 114 }, + }, + ], + kerning: [{ unicode1: 65, unicode2: 86, advance: -0.06 }], +}; + +function mockJsonResponse(body: unknown, ok = true): Response { + return { + ok, + status: ok ? 200 : 404, + statusText: ok ? 'OK' : 'Not Found', + json: () => Promise.resolve(body), + } as Response; +} + +describe('loadFontAtlas', () => { + it('parses metrics and glyphs from the msdf-atlas-gen JSON', async () => { + const mockImage = new Image(); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + mockJsonResponse(msdfAtlasGenJson), + ); + + const imageCache = new ImageCache(); + vi.spyOn(imageCache, 'getOrLoad').mockResolvedValue(mockImage); + + const fontAtlas = await loadFontAtlas('font.json', 'font.png', imageCache); + + expect(fontAtlas.image).toBe(mockImage); + expect(fontAtlas.atlasWidth).toBe(512); + expect(fontAtlas.atlasHeight).toBe(256); + expect(fontAtlas.distanceRange).toBe(4); + expect(fontAtlas.emSize).toBe(1); + expect(fontAtlas.lineHeight).toBeCloseTo(1.2); + expect(fontAtlas.ascender).toBeCloseTo(0.95); + expect(fontAtlas.descender).toBeCloseTo(-0.25); + expect(imageCache.getOrLoad).toHaveBeenCalledWith('font.png'); + }); + + it('omits uv/plane bounds for glyphs with no visible shape', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + mockJsonResponse(msdfAtlasGenJson), + ); + + const imageCache = new ImageCache(); + vi.spyOn(imageCache, 'getOrLoad').mockResolvedValue(new Image()); + + const fontAtlas = await loadFontAtlas('font.json', 'font.png', imageCache); + + const space = fontAtlas.glyphs.get(32); + + expect(space?.advance).toBe(0.25); + expect(space?.planeBounds).toBeUndefined(); + expect(space?.uvOffset).toBeUndefined(); + expect(space?.uvScale).toBeUndefined(); + }); + + it("converts a visible glyph's bottom-left-origin atlasBounds pixels into top-left-origin normalized uv rects", async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + mockJsonResponse(msdfAtlasGenJson), + ); + + const imageCache = new ImageCache(); + vi.spyOn(imageCache, 'getOrLoad').mockResolvedValue(new Image()); + + const fontAtlas = await loadFontAtlas('font.json', 'font.png', imageCache); + + const glyphA = fontAtlas.glyphs.get(65); + + expect(glyphA?.planeBounds).toEqual({ + left: 0.01, + bottom: 0, + right: 0.65, + top: 0.68, + }); + // atlasBounds: left 100/512, right 164/512; top 114 -> 1 - 114/256, bottom 50 -> 1 - 50/256 + expect(glyphA?.uvOffset?.x).toBeCloseTo(100 / 512); + expect(glyphA?.uvOffset?.y).toBeCloseTo(1 - 114 / 256); + expect(glyphA?.uvScale?.x).toBeCloseTo((164 - 100) / 512); + expect(glyphA?.uvScale?.y).toBeCloseTo(114 / 256 - 50 / 256); + }); + + it('parses kerning pairs keyed by "unicode1:unicode2"', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + mockJsonResponse(msdfAtlasGenJson), + ); + + const imageCache = new ImageCache(); + vi.spyOn(imageCache, 'getOrLoad').mockResolvedValue(new Image()); + + const fontAtlas = await loadFontAtlas('font.json', 'font.png', imageCache); + + expect(fontAtlas.kerning.get('65:86')).toBeCloseTo(-0.06); + expect(fontAtlas.kerning.get('65:65')).toBeUndefined(); + }); + + it('throws when the metrics JSON fetch fails', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + mockJsonResponse(undefined, false), + ); + + const imageCache = new ImageCache(); + + await expect( + loadFontAtlas('font.json', 'font.png', imageCache), + ).rejects.toThrow( + 'Failed to fetch font atlas metrics at "font.json": 404 Not Found', + ); + }); + + it('defaults to no kerning when the JSON omits the kerning field', async () => { + const jsonWithoutKerning = { + atlas: msdfAtlasGenJson.atlas, + metrics: msdfAtlasGenJson.metrics, + glyphs: msdfAtlasGenJson.glyphs, + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + mockJsonResponse(jsonWithoutKerning), + ); + + const imageCache = new ImageCache(); + vi.spyOn(imageCache, 'getOrLoad').mockResolvedValue(new Image()); + + const fontAtlas = await loadFontAtlas('font.json', 'font.png', imageCache); + + expect(fontAtlas.kerning.size).toBe(0); + }); +}); diff --git a/src/asset-loading/load-font-atlas.ts b/src/asset-loading/load-font-atlas.ts new file mode 100644 index 00000000..fe9fdf7b --- /dev/null +++ b/src/asset-loading/load-font-atlas.ts @@ -0,0 +1,141 @@ +import { Vector2 } from '../math/index.js'; +import { + FontAtlas, + FontAtlasGlyph, + getKerningKey, +} from './types/font-atlas.js'; +import { ImageCache } from './asset-caches/index.js'; + +/** + * The subset of `msdf-atlas-gen`'s JSON output this loader reads. Other + * fields the tool emits (e.g. per-glyph `index`, `atlas.type`) are ignored. + */ +interface MsdfAtlasGenJson { + atlas: { + distanceRange: number; + width: number; + height: number; + yOrigin?: 'bottom' | 'top'; + }; + metrics: { + emSize: number; + lineHeight: number; + ascender: number; + descender: number; + }; + glyphs: { + unicode: number; + advance: number; + planeBounds?: { left: number; bottom: number; right: number; top: number }; + atlasBounds?: { left: number; bottom: number; right: number; top: number }; + }[]; + kerning?: { unicode1: number; unicode2: number; advance: number }[]; +} + +/** + * Converts a glyph's `atlasBounds` (pixels, measured from the bottom-left + * per `msdf-atlas-gen`'s default `yOrigin: "bottom"`) into + * `uvOffset`/`uvScale` (0 to 1, top-left origin), matching + * `SpriteEcsComponent.uvOffset`'s convention. + */ +function toUvRect( + atlasBounds: { left: number; bottom: number; right: number; top: number }, + atlasWidth: number, + atlasHeight: number, + yOrigin: 'bottom' | 'top', +): { uvOffset: Vector2; uvScale: Vector2 } { + const top = + yOrigin === 'bottom' + ? 1 - atlasBounds.top / atlasHeight + : atlasBounds.top / atlasHeight; + const bottom = + yOrigin === 'bottom' + ? 1 - atlasBounds.bottom / atlasHeight + : atlasBounds.bottom / atlasHeight; + + return { + uvOffset: { x: atlasBounds.left / atlasWidth, y: top }, + uvScale: { + x: (atlasBounds.right - atlasBounds.left) / atlasWidth, + y: bottom - top, + }, + }; +} + +function parseGlyphs( + json: MsdfAtlasGenJson, +): ReadonlyMap { + const glyphs = new Map(); + const { + width: atlasWidth, + height: atlasHeight, + yOrigin = 'bottom', + } = json.atlas; + + for (const glyph of json.glyphs) { + const uvRect = glyph.atlasBounds + ? toUvRect(glyph.atlasBounds, atlasWidth, atlasHeight, yOrigin) + : undefined; + + glyphs.set(glyph.unicode, { + advance: glyph.advance, + planeBounds: glyph.planeBounds, + uvOffset: uvRect?.uvOffset, + uvScale: uvRect?.uvScale, + }); + } + + return glyphs; +} + +function parseKerning(json: MsdfAtlasGenJson): ReadonlyMap { + const kerning = new Map(); + + for (const pair of json.kerning ?? []) { + kerning.set(getKerningKey(pair.unicode1, pair.unicode2), pair.advance); + } + + return kerning; +} + +/** + * Loads an MSDF font atlas produced by + * [`msdf-atlas-gen`](https://github.com/Chlumsky/msdf-atlas-gen): its JSON + * metrics file and its atlas PNG. Data-only - build the GPU-side + * `Renderable` for drawing text with the loaded atlas via + * `createMsdfTextRenderable` (`/rendering`). + * @param jsonPath - The URL of the atlas's `msdf-atlas-gen` JSON metrics file. + * @param imagePath - The URL of the atlas's PNG texture. + * @param imageCache - The image cache to load `imagePath` through. + * @returns The parsed font atlas. + * @throws An error if the JSON metrics file can't be fetched or parsed. + */ +export async function loadFontAtlas( + jsonPath: string, + imagePath: string, + imageCache: ImageCache, +): Promise { + const response = await fetch(jsonPath); + + if (!response.ok) { + throw new Error( + `Failed to fetch font atlas metrics at "${jsonPath}": ${response.status} ${response.statusText}`, + ); + } + + const json = (await response.json()) as MsdfAtlasGenJson; + const image = await imageCache.getOrLoad(imagePath); + + return { + image, + atlasWidth: json.atlas.width, + atlasHeight: json.atlas.height, + distanceRange: json.atlas.distanceRange, + emSize: json.metrics.emSize, + lineHeight: json.metrics.lineHeight, + ascender: json.metrics.ascender, + descender: json.metrics.descender, + glyphs: parseGlyphs(json), + kerning: parseKerning(json), + }; +} diff --git a/src/asset-loading/types/font-atlas.ts b/src/asset-loading/types/font-atlas.ts new file mode 100644 index 00000000..542b2409 --- /dev/null +++ b/src/asset-loading/types/font-atlas.ts @@ -0,0 +1,122 @@ +import { Vector2 } from '../../math/index.js'; + +/** + * A single glyph's metrics, as produced by `msdf-atlas-gen`'s JSON output. + * + * `planeBounds`/`uvOffset`/`uvScale` are all omitted together for glyphs + * with no visible shape (e.g. the space character) - such a glyph still + * advances the cursor but contributes no glyph quad. + */ +export interface FontAtlasGlyph { + /** + * How far the cursor moves (in em units) after placing this glyph. + */ + advance: number; + + /** + * The glyph's visible quad, in em units relative to the glyph's baseline + * origin (`left`/`right` relative to the cursor, `bottom`/`top` relative + * to the baseline, `top` positive/upward). Omitted for glyphs with no + * visible shape. + */ + planeBounds?: { + left: number; + bottom: number; + right: number; + top: number; + }; + + /** + * The top-left corner of this glyph's region in the atlas texture, 0 to 1 + * - already normalized from `msdf-atlas-gen`'s bottom-left-origin pixel + * `atlasBounds` to match `SpriteEcsComponent.uvOffset`'s top-left, Y-down + * convention. Omitted for glyphs with no visible shape. + */ + uvOffset?: Vector2; + + /** + * The width/height of this glyph's region in the atlas texture, 0 to 1. + * Omitted for glyphs with no visible shape. + */ + uvScale?: Vector2; +} + +/** + * Parsed metrics and glyph metadata for an MSDF font atlas, as produced by + * [`msdf-atlas-gen`](https://github.com/Chlumsky/msdf-atlas-gen)'s JSON + * output alongside its atlas PNG. Data-only - pairs with + * `createMsdfTextRenderable` (`/rendering`) to build the GPU-side + * `Renderable` used to actually draw text with this font. + */ +export interface FontAtlas { + /** + * The loaded atlas texture image, ready to be uploaded to the GPU by + * `createMsdfTextRenderable`. + */ + image: HTMLImageElement; + + /** + * The atlas image's width, in pixels. + */ + atlasWidth: number; + + /** + * The atlas image's height, in pixels. + */ + atlasHeight: number; + + /** + * The signed distance field range, in atlas pixels, that the MSDF was + * generated with. Feeds the fragment shader's screen-space-derivative + * antialiasing so glyph edges stay crisp at any scale. + */ + distanceRange: number; + + /** + * The em size (in the same units as `metrics.lineHeight`/glyph `advance` + * values) that every other measurement in this atlas is normalized + * against. A consumer scale factor is `fontSize / emSize`. + */ + emSize: number; + + /** + * The vertical distance (in em units) between successive lines' baselines. + */ + lineHeight: number; + + /** + * The distance (in em units) from the baseline to the top of a typical + * ascending glyph (e.g. "h"). + */ + ascender: number; + + /** + * The distance (in em units) from the baseline to the bottom of a typical + * descending glyph (e.g. "g"). Negative. + */ + descender: number; + + /** + * Every glyph in the atlas, keyed by Unicode code point. + */ + glyphs: ReadonlyMap; + + /** + * Kerning adjustments (in em units, added to the first code point's + * `advance`) for specific consecutive code point pairs, keyed by + * `` `${unicode1}:${unicode2}` ``. A pair with no entry has no kerning + * adjustment. + */ + kerning: ReadonlyMap; +} + +/** + * Builds the `` `${unicode1}:${unicode2}` `` key `FontAtlas.kerning` is + * indexed by. + * @param unicode1 - The first code point. + * @param unicode2 - The second code point. + * @returns The kerning map key for this code point pair. + */ +export function getKerningKey(unicode1: number, unicode2: number): string { + return `${unicode1}:${unicode2}`; +} diff --git a/src/index.ts b/src/index.ts index 520d6fe4..a9caf667 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ export * from './input/index.js'; export * from './lifecycle/index.js'; export * from './math/index.js'; export * from './rendering/index.js'; +export * from './text/index.js'; export * from './timer/index.js'; export * from './utilities/index.js'; export * from './finite-state-machine/index.js'; diff --git a/src/rendering/shaders/index.ts b/src/rendering/shaders/index.ts index d0295024..4859d0da 100644 --- a/src/rendering/shaders/index.ts +++ b/src/rendering/shaders/index.ts @@ -1,4 +1,5 @@ export * from './utils/index.js'; +export * from './msdf/index.js'; export * from './sprite/index.js'; export * from './post-process/index.js'; export * from './gradients/index.js'; diff --git a/src/rendering/shaders/msdf/index.ts b/src/rendering/shaders/msdf/index.ts new file mode 100644 index 00000000..80176bfc --- /dev/null +++ b/src/rendering/shaders/msdf/index.ts @@ -0,0 +1,3 @@ +import msdfFragmentShaderSource from './msdf.frag.glsl?raw'; + +export const msdfFragmentShader = msdfFragmentShaderSource; diff --git a/src/rendering/shaders/msdf/msdf.frag.glsl b/src/rendering/shaders/msdf/msdf.frag.glsl new file mode 100644 index 00000000..e5ddde0c --- /dev/null +++ b/src/rendering/shaders/msdf/msdf.frag.glsl @@ -0,0 +1,42 @@ +#version 300 es + +#pragma forge name(msdf.frag) + +precision mediump float; + +uniform sampler2D u_texture; // The MSDF font atlas +uniform float u_distanceRange; // The atlas's signed distance field range, in atlas pixels + +in vec2 v_texCoord; // Input from vertex shader +in vec4 v_tint; // Tint color (text color) +out vec4 fragColor; // Output color + +// The median of the MSDF's three channels reconstructs the single-channel +// signed distance field (see msdf-atlas-gen's own recommended shader): +// wherever two of the three channels agree, their shared value is the "true" +// distance, since only one channel is ever allowed to diverge to preserve a +// sharp corner. +float median(float r, float g, float b) { + return max(min(r, g), min(max(r, g), b)); +} + +// Converts `u_distanceRange` (in atlas pixels) into screen pixels via the +// texture coordinate's screen-space derivative (`fwidth`), so the glyph +// edge's antialiasing band stays a constant width in screen pixels +// regardless of how much the text is scaled, instead of blurring when +// scaled up or aliasing when scaled down. +float screenPxRange() { + vec2 unitRange = vec2(u_distanceRange) / vec2(textureSize(u_texture, 0)); + vec2 screenTexSize = vec2(1.0) / fwidth(v_texCoord); + + return max(0.5 * dot(unitRange, screenTexSize), 1.0); +} + +void main() { + vec3 msd = texture(u_texture, v_texCoord).rgb; + float signedDistance = median(msd.r, msd.g, msd.b) - 0.5; + float screenPxDistance = screenPxRange() * signedDistance; + float alpha = clamp(screenPxDistance + 0.5, 0.0, 1.0); + + fragColor = vec4(v_tint.rgb, v_tint.a * alpha); +} diff --git a/src/rendering/systems/render-system.test.ts b/src/rendering/systems/render-system.test.ts index caf23409..a7fc9df6 100644 --- a/src/rendering/systems/render-system.test.ts +++ b/src/rendering/systems/render-system.test.ts @@ -17,9 +17,15 @@ import { Color } from '../color'; import { Geometry } from '../geometry/geometry'; import { Material } from '../materials/material'; import { ShaderCache } from '../shaders'; -import { ImageCache } from '../../asset-loading'; +import { FontAtlas, ImageCache } from '../../asset-loading'; import { createProjectionMatrix } from '../shaders'; import { calculatePixelsPerUnit } from '../utilities/calculate-pixels-per-unit'; +import { + addTextComponent, + addTextMeshComponent, + TextEcsComponent, + TextMeshEcsComponent, +} from '../../text'; describe('createRenderEcsSystem', () => { let canvas: HTMLCanvasElement; @@ -606,4 +612,200 @@ describe('createRenderEcsSystem', () => { expect(bindInstanceData).toHaveBeenCalledTimes(1); }); }); + + describe('text', () => { + const createGlyph = (offset = Vec2.zero) => ({ + offset, + size: { x: 1, y: 1 }, + uvOffset: Vec2.zero, + uvScale: Vec2.one, + }); + + const addTextEntity = ( + renderable: Renderable, + worldY: number, + textOverrides: Partial = {}, + meshOverrides: Partial = {}, + ): number => { + const entity = world.createEntity(); + + addPositionComponent(world, entity, { + local: { x: 0, y: worldY }, + world: { x: 0, y: worldY }, + }); + const text = addTextComponent(world, entity, { + text: 'A', + font: {} as FontAtlas, + renderable, + fontSize: 1, + ...textOverrides, + }); + addTextMeshComponent(world, entity, { + glyphs: [createGlyph()], + bounds: Vec2.one, + sourceText: text.text, + sourceFont: text.font, + sourceFontSize: text.fontSize, + sourceWrapWidth: text.wrapWidth, + sourceLineSpacing: text.lineSpacing, + sourceAlignment: text.alignment, + sourcePivot: text.pivot, + ...meshOverrides, + }); + + return entity; + }; + + it('does not draw text with no TextMeshEcsComponent yet (not shaped)', () => { + addCameraEntity(); + const { renderable, bindInstanceData } = createRenderable(4); + + const entity = world.createEntity(); + + addPositionComponent(world, entity); + addTextComponent(world, entity, { + text: 'A', + font: {} as FontAtlas, + renderable, + fontSize: 1, + }); + + world.update(); + + expect(bindInstanceData).not.toHaveBeenCalled(); + expect(mockGl.drawArraysInstanced).not.toHaveBeenCalled(); + }); + + it('skips disabled text', () => { + addCameraEntity(); + const { renderable, bindInstanceData } = createRenderable(4); + + addTextEntity(renderable, 0, { enabled: false }); + + world.update(); + + expect(bindInstanceData).not.toHaveBeenCalled(); + }); + + it("skips text whose renderable category does not match the camera's culling mask", () => { + addCameraEntity(0b0010); + const { renderable, bindInstanceData } = createRenderable(4); + + renderable.category = 0b0001; + addTextEntity(renderable, 0); + + world.update(); + + expect(bindInstanceData).not.toHaveBeenCalled(); + }); + + it('draws one instance per glyph, batched into a single draw call', () => { + addCameraEntity(); + const { renderable, bindInstanceData } = createRenderable(4); + + addTextEntity(renderable, 0, undefined, { + glyphs: [createGlyph(), createGlyph(), createGlyph()], + }); + + world.update(); + + expect(bindInstanceData).toHaveBeenCalledTimes(3); + expect(mockGl.drawArraysInstanced).toHaveBeenCalledTimes(1); + expect(mockGl.drawArraysInstanced).toHaveBeenCalledWith( + undefined, + 0, + 6, + 3, + ); + }); + + it("positions each glyph at the entity's world position plus the glyph's offset", () => { + addCameraEntity(); + const { renderable, bindInstanceData } = createRenderable(4); + + const entity = world.createEntity(); + + addPositionComponent(world, entity, { + local: { x: 10, y: 20 }, + world: { x: 10, y: 20 }, + }); + const text = addTextComponent(world, entity, { + text: 'A', + font: {} as FontAtlas, + renderable, + fontSize: 1, + }); + addTextMeshComponent(world, entity, { + glyphs: [createGlyph({ x: 5, y: 3 })], + bounds: Vec2.one, + sourceText: text.text, + sourceFont: text.font, + sourceFontSize: text.fontSize, + sourceWrapWidth: text.wrapWidth, + sourceLineSpacing: text.lineSpacing, + sourceAlignment: text.alignment, + sourcePivot: text.pivot, + }); + + world.update(); + + const [{ position }] = bindInstanceData.mock.calls[0] as [ + { position: PositionEcsComponent }, + ]; + + expect(position.world).toEqual({ x: 15, y: 23 }); + }); + + it('sets each glyph instance from the shaped uv rect and the text color', () => { + addCameraEntity(); + const { renderable, bindInstanceData } = createRenderable(4); + const color = new Color(1, 0, 0, 1); + + addTextEntity( + renderable, + 0, + { color }, + { + glyphs: [ + { + offset: Vec2.zero, + size: { x: 2, y: 3 }, + uvOffset: { x: 0.25, y: 0.5 }, + uvScale: { x: 0.1, y: 0.2 }, + }, + ], + }, + ); + + world.update(); + + const [{ sprite }] = bindInstanceData.mock.calls[0] as [ + { sprite: SpriteEcsComponent }, + ]; + + expect(sprite.width).toBe(2); + expect(sprite.height).toBe(3); + expect(sprite.uvOffset).toEqual({ x: 0.25, y: 0.5 }); + expect(sprite.uvScale).toEqual({ x: 0.1, y: 0.2 }); + expect(sprite.tintColor).toBe(color); + }); + + it('interleaves with sprites in the same sorted, depth-ordered command buffer', () => { + addCameraEntity(); + const { renderable, bindInstanceData } = createRenderable(4); + + addSpriteEntity(renderable, 10); + addTextEntity(renderable, -5); + addSpriteEntity(renderable, 2); + + world.update(); + + const drawnDepths = bindInstanceData.mock.calls.map( + (call) => + (call[0] as { position: PositionEcsComponent }).position.world.y, + ); + + expect(drawnDepths).toEqual([-5, 2, 10]); + }); + }); }); diff --git a/src/rendering/systems/render-system.ts b/src/rendering/systems/render-system.ts index 974b6f54..56fc1921 100644 --- a/src/rendering/systems/render-system.ts +++ b/src/rendering/systems/render-system.ts @@ -11,6 +11,12 @@ import { import { Matrix3x3, Vec2 } from '../../math/index.js'; import { EcsSystem } from '../../ecs/ecs-system.js'; import { matchesMask } from '../../utilities/matches-mask.js'; +import { + TextEcsComponent, + textId, + TextMeshEcsComponent, + textMeshId, +} from '../../text/components/index.js'; import { CameraEcsComponent, cameraId, @@ -170,6 +176,107 @@ const pushSpriteRenderCommands = ( } }; +// Text is expanded into per-glyph, `SpriteEcsComponent`-shaped render +// commands right here, isolated to these two functions, rather than +// generalizing nine-slice's expansion into a shared `SubQuad[]` path (see +// issue #584): every glyph quad needs exactly the same instance data a +// sprite already carries (position, rotation, scale, size, pivot, uv, +// tint), so a glyph can reuse the sprite vertex shader/instancing pipeline +// unchanged by simply constructing a `SpriteEcsComponent` for it, the same +// trick nine-slice regions already use. +function pushTextRenderCommands( + commands: RenderCommand[], + textComponent: TextEcsComponent, + textMeshComponent: TextMeshEcsComponent, + entityPosition: PositionEcsComponent, + rotationComponent: RotationEcsComponent | null, + scaleComponent: ScaleEcsComponent | null, + flipComponent: FlipEcsComponent | null, +): void { + const { renderable, layer, color } = textComponent; + const depth = entityPosition.world.y; + + const rotationRadians = rotationComponent?.world ?? 0; + const scaleX = + (scaleComponent?.world.x ?? 1) * (flipComponent?.flipX ? -1 : 1); + const scaleY = + (scaleComponent?.world.y ?? 1) * (flipComponent?.flipY ? -1 : 1); + + for (const glyph of textMeshComponent.glyphs) { + const glyphOffset = Vec2.rotate( + { x: glyph.offset.x * scaleX, y: glyph.offset.y * scaleY }, + rotationRadians, + ); + + const glyphPosition: PositionEcsComponent = { + local: entityPosition.local, + // Clone before adding, matching `pushSpriteRenderCommands`: must not + // mutate the entity's live world position. + world: Vec2.add(Vec2.clone(entityPosition.world), glyphOffset), + }; + + const glyphSprite: SpriteEcsComponent = { + enabled: true, + width: glyph.size.x, + height: glyph.size.y, + pivot: { x: 0.5, y: 0.5 }, + tintColor: color, + renderable, + uvOffset: glyph.uvOffset, + uvScale: glyph.uvScale, + layer, + }; + + commands.push({ + layer, + depth, + renderable, + components: { + position: glyphPosition, + rotation: rotationComponent, + scale: scaleComponent, + sprite: glyphSprite, + flip: flipComponent, + }, + }); + } +} + +function buildTextCameraCommands( + world: EcsWorld, + texts: TextEcsComponent[], + textMeshes: TextMeshEcsComponent[], + textPositions: PositionEcsComponent[], + textEntities: readonly number[], + cullingMask: number, + commands: RenderCommand[], +): void { + for (let t = 0; t < textEntities.length; t++) { + const textComponent = texts[t]; + + if (!textComponent.enabled) { + continue; + } + + if (!matchesMask(textComponent.renderable.category, cullingMask)) { + continue; + } + + const textEntity = textEntities[t]; + const entityPosition = textPositions[t]; + + pushTextRenderCommands( + commands, + textComponent, + textMeshes[t], + entityPosition, + world.getComponent(textEntity, rotationId), + world.getComponent(textEntity, scaleId), + world.getComponent(textEntity, flipId), + ); + } +} + function buildCameraCommands( world: EcsWorld, sprites: SpriteEcsComponent[], @@ -246,6 +353,13 @@ export const createRenderEcsSystem = ( positionId, ]); + const { + entities: textEntities, + components: [texts, textMeshes, textPositions], + } = world.query< + [TextEcsComponent, TextMeshEcsComponent, PositionEcsComponent] + >([textId, textMeshId, positionId]); + for (let c = 0; c < cameras.length; c++) { const cameraComponent = cameras[c]; const cameraPositionComponent = cameraPositions[c]; @@ -281,6 +395,16 @@ export const createRenderEcsSystem = ( commands, ); + buildTextCameraCommands( + world, + texts, + textMeshes, + textPositions, + textEntities, + cameraComponent.cullingMask, + commands, + ); + const target = cameraComponent.renderTarget ?? null; renderContext.bindRenderTarget(target); diff --git a/src/rendering/utilities/create-msdf-text-renderable.test.ts b/src/rendering/utilities/create-msdf-text-renderable.test.ts new file mode 100644 index 00000000..df9b137b --- /dev/null +++ b/src/rendering/utilities/create-msdf-text-renderable.test.ts @@ -0,0 +1,154 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { createMsdfTextRenderable } from './create-msdf-text-renderable.js'; +import { FontAtlas, ImageCache } from '../../asset-loading/index.js'; +import { RenderContext } from '../render-context.js'; +import { + ForgeShaderSource, + msdfFragmentShader, + ShaderCache, + spriteVertexShader, +} from '../shaders/index.js'; + +// Mock WebGLTexture constructor for instanceof checks in Material.bind +globalThis.WebGLTexture = class WebGLTexture {}; + +describe('createMsdfTextRenderable', () => { + let canvas: HTMLCanvasElement; + let mockGl: WebGL2RenderingContext; + let renderContext: RenderContext; + let fontAtlas: FontAtlas; + let distanceRangeLocation: WebGLUniformLocation; + + beforeEach(() => { + canvas = document.createElement('canvas'); + canvas.width = 800; + canvas.height = 600; + + distanceRangeLocation = {}; + + fontAtlas = { + image: { width: 512, height: 512 } as HTMLImageElement, + atlasWidth: 512, + atlasHeight: 512, + distanceRange: 4, + emSize: 1, + lineHeight: 1.2, + ascender: 0.95, + descender: -0.25, + glyphs: new Map(), + kerning: new Map(), + }; + + mockGl = { + VERTEX_SHADER: 'VERTEX_SHADER', + FRAGMENT_SHADER: 'FRAGMENT_SHADER', + COMPILE_STATUS: 'COMPILE_STATUS', + LINK_STATUS: 'LINK_STATUS', + ACTIVE_UNIFORMS: 'ACTIVE_UNIFORMS', + TEXTURE0: 0, + TEXTURE_2D: 'TEXTURE_2D', + ARRAY_BUFFER: 'ARRAY_BUFFER', + STATIC_DRAW: 'STATIC_DRAW', + CLAMP_TO_EDGE: 'CLAMP_TO_EDGE', + TEXTURE_WRAP_S: 'TEXTURE_WRAP_S', + TEXTURE_WRAP_T: 'TEXTURE_WRAP_T', + TEXTURE_MIN_FILTER: 'TEXTURE_MIN_FILTER', + TEXTURE_MAG_FILTER: 'TEXTURE_MAG_FILTER', + LINEAR: 'LINEAR', + RGBA: 'RGBA', + UNSIGNED_BYTE: 'UNSIGNED_BYTE', + + createBuffer: vi.fn().mockReturnValue({}), + bindBuffer: vi.fn(), + bufferData: vi.fn(), + + createTexture: vi.fn().mockImplementation(() => new WebGLTexture()), + bindTexture: vi.fn(), + texParameteri: vi.fn(), + texImage2D: vi.fn(), + + createShader: vi.fn().mockReturnValue({}), + shaderSource: vi.fn(), + compileShader: vi.fn(), + getShaderParameter: vi.fn().mockReturnValue(true), + getShaderInfoLog: vi.fn().mockReturnValue(''), + + createProgram: vi.fn().mockReturnValue({}), + attachShader: vi.fn(), + linkProgram: vi.fn(), + getProgramParameter: vi + .fn() + .mockImplementation((_program: unknown, pname: unknown) => + pname === 'ACTIVE_UNIFORMS' ? 2 : true, + ), + getProgramInfoLog: vi.fn().mockReturnValue(''), + + getActiveUniform: vi.fn().mockImplementation( + (_program, index: number) => + [ + { name: 'u_texture', type: 0, size: 1 }, + { name: 'u_distanceRange', type: 0, size: 1 }, + ][index] ?? null, + ), + getUniformLocation: vi + .fn() + .mockImplementation((_program, name: string) => { + if (name === 'u_distanceRange') { + return distanceRangeLocation; + } + + return {} as WebGLUniformLocation; + }), + useProgram: vi.fn(), + uniform1i: vi.fn(), + uniform1f: vi.fn(), + activeTexture: vi.fn(), + } as unknown as WebGL2RenderingContext; + + vi.spyOn(canvas, 'getContext').mockReturnValue(mockGl); + + const shaderCache = new ShaderCache([]) + .addShader(new ForgeShaderSource(spriteVertexShader)) + .addShader(new ForgeShaderSource(msdfFragmentShader)); + + renderContext = new RenderContext(shaderCache, new ImageCache(), canvas); + }); + + it('does not throw when creating a renderable', () => { + expect(() => + createMsdfTextRenderable(fontAtlas, renderContext), + ).not.toThrow(); + }); + + it('sets u_distanceRange from the font atlas', () => { + const renderable = createMsdfTextRenderable(fontAtlas, renderContext); + + renderable.material.bind(mockGl); + + const distanceRangeCalls = (mockGl.uniform1f as Mock).mock.calls.filter( + ([location]) => location === distanceRangeLocation, + ); + + expect(distanceRangeCalls).toHaveLength(1); + expect(distanceRangeCalls[0][1]).toBe(4); + }); + + it('assigns the given layer as the renderable category', () => { + const renderable = createMsdfTextRenderable(fontAtlas, renderContext, 3); + + expect(renderable.category).toBe(3); + }); + + it('defaults the layer to 0', () => { + const renderable = createMsdfTextRenderable(fontAtlas, renderContext); + + expect(renderable.category).toBe(0); + }); + + it('uses the standard sprite instance data layout so glyphs batch with sprites', () => { + const renderable = createMsdfTextRenderable(fontAtlas, renderContext); + + expect(renderable.floatsPerInstance).toBe(17); + }); +}); diff --git a/src/rendering/utilities/create-msdf-text-renderable.ts b/src/rendering/utilities/create-msdf-text-renderable.ts new file mode 100644 index 00000000..c669e922 --- /dev/null +++ b/src/rendering/utilities/create-msdf-text-renderable.ts @@ -0,0 +1,62 @@ +import { FontAtlas } from '../../asset-loading/index.js'; +import { createQuadGeometry } from '../geometry/index.js'; +import { Material } from '../materials/index.js'; +import { RenderContext } from '../render-context.js'; +import { Renderable } from '../renderable.js'; +import { createTextureFromImage } from '../shaders/index.js'; +import { combineInstanceDataSegments } from './instance-data-segment.js'; +import { spriteInstanceDataSegment } from './sprite-instance-data-segment.js'; + +/** + * Creates the `Renderable` used to draw text set in a given `FontAtlas`. + * + * Reuses the sprite vertex shader (`sprite.vert`) and its instance data + * layout unchanged - a glyph quad's position/rotation/scale/size/pivot/uv/ + * tint are exactly the data a sprite instance already carries - paired with + * an MSDF fragment shader that reconstructs a signed distance field from the + * atlas texture's three channels and antialiases the glyph edge against the + * texture coordinate's screen-space derivative, so text stays crisp at any + * scale. Share one `Renderable` (call this once per `FontAtlas`, not per + * `TextEcsComponent`) so every entity drawing with the same font batches + * into a single instanced draw call. + * @param fontAtlas - The font atlas to draw text from. + * @param renderContext - The render context to build GPU resources with. + * @param layer - The rendering category, matched against each camera's + * culling mask. Defaults to `0`. + * @param pixelated - Samples the atlas texture with nearest-neighbor + * filtering instead of linear. An MSDF atlas is reconstructed by the + * fragment shader's own antialiasing rather than by texture filtering, so + * this should almost always stay `false` (the default). + * @returns The created renderable. + */ +export function createMsdfTextRenderable( + fontAtlas: FontAtlas, + renderContext: RenderContext, + layer: number = 0, + pixelated: boolean = false, +): Renderable { + const { shaderCache, gl } = renderContext; + + const vertexShader = shaderCache.getShader('sprite.vert'); + const fragmentShader = shaderCache.getShader('msdf.frag'); + + const material = new Material(vertexShader, fragmentShader, gl); + + material.setUniform( + 'u_texture', + createTextureFromImage(gl, fontAtlas.image, pixelated), + ); + material.setUniform('u_distanceRange', fontAtlas.distanceRange); + + const { floatsPerInstance, bindInstanceData, setupInstanceAttributes } = + combineInstanceDataSegments(spriteInstanceDataSegment); + + return new Renderable( + createQuadGeometry(gl), + material, + floatsPerInstance, + layer, + bindInstanceData, + setupInstanceAttributes, + ); +} diff --git a/src/rendering/utilities/create-shader-cache.ts b/src/rendering/utilities/create-shader-cache.ts index 041bc4d4..07b1e8b3 100644 --- a/src/rendering/utilities/create-shader-cache.ts +++ b/src/rendering/utilities/create-shader-cache.ts @@ -5,6 +5,7 @@ import { cubicShaderInclude, ForgeShaderSource, gaussianBlurFragmentShader, + msdfFragmentShader, passthroughFragmentShader, passthroughVertexShader, perlinNoiseFragmentShader, @@ -69,6 +70,7 @@ export function createShaderCache(): ShaderCache { .addShader(new ForgeShaderSource(perlinNoiseFragmentShader)) .addShader(new ForgeShaderSource(spriteFragmentShader)) .addShader(new ForgeShaderSource(spriteVertexShader)) + .addShader(new ForgeShaderSource(msdfFragmentShader)) .addShader(new ForgeShaderSource(passthroughFragmentShader)) .addShader(new ForgeShaderSource(passthroughVertexShader)) .addShader(new ForgeShaderSource(gaussianBlurFragmentShader)) diff --git a/src/rendering/utilities/index.ts b/src/rendering/utilities/index.ts index 9e687219..5c39100f 100644 --- a/src/rendering/utilities/index.ts +++ b/src/rendering/utilities/index.ts @@ -4,6 +4,7 @@ export * from './compute-nine-slice-regions.js'; export * from './create-camera.js'; export * from './create-canvas.js'; export * from './create-image-sprite.js'; +export * from './create-msdf-text-renderable.js'; export * from './create-shader-cache.js'; export * from './create-sprite.js'; export * from './instance-data-segment.js'; diff --git a/src/text/components/index.ts b/src/text/components/index.ts new file mode 100644 index 00000000..9670950b --- /dev/null +++ b/src/text/components/index.ts @@ -0,0 +1,2 @@ +export * from './text-component.js'; +export * from './text-mesh-component.js'; diff --git a/src/text/components/text-component.test.ts b/src/text/components/text-component.test.ts new file mode 100644 index 00000000..44716025 --- /dev/null +++ b/src/text/components/text-component.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { addTextComponent, textId } from './text-component.js'; +import { EcsWorld } from '../../ecs/index.js'; +import { Color } from '../../rendering/color.js'; +import type { Renderable } from '../../rendering/renderable.js'; +import type { FontAtlas } from '../../asset-loading/index.js'; + +const renderable = {} as Renderable; +const font = {} as FontAtlas; + +describe('addTextComponent', () => { + it('attaches a component with default values for unspecified options', () => { + const world = new EcsWorld(); + const entity = world.createEntity(); + + addTextComponent(world, entity, { + text: 'Hello', + font, + renderable, + fontSize: 16, + }); + + expect(world.getComponent(entity, textId)).toEqual({ + text: 'Hello', + font, + renderable, + fontSize: 16, + color: Color.white, + alignment: 'left', + lineSpacing: 1, + pivot: { x: 0.5, y: 0.5 }, + enabled: true, + layer: 0, + }); + }); + + it('overrides only the provided options', () => { + const world = new EcsWorld(); + const entity = world.createEntity(); + + addTextComponent(world, entity, { + text: 'Hello', + font, + renderable, + fontSize: 16, + alignment: 'center', + wrapWidth: 100, + enabled: false, + }); + + expect(world.getComponent(entity, textId)).toMatchObject({ + alignment: 'center', + wrapWidth: 100, + enabled: false, + lineSpacing: 1, + }); + }); + + it('returns the attached component', () => { + const world = new EcsWorld(); + const entity = world.createEntity(); + + const component = addTextComponent(world, entity, { + text: 'Hello', + font, + renderable, + fontSize: 16, + }); + + expect(world.getComponent(entity, textId)).toBe(component); + }); + + it('gives each entity its own pivot vector instance', () => { + const world = new EcsWorld(); + const first = world.createEntity(); + const second = world.createEntity(); + const options = { text: 'Hello', font, renderable, fontSize: 16 }; + + addTextComponent(world, first, options); + addTextComponent(world, second, options); + + expect(world.getComponent(first, textId)?.pivot).not.toBe( + world.getComponent(second, textId)?.pivot, + ); + }); +}); diff --git a/src/text/components/text-component.ts b/src/text/components/text-component.ts new file mode 100644 index 00000000..c5fa78b1 --- /dev/null +++ b/src/text/components/text-component.ts @@ -0,0 +1,147 @@ +import { FontAtlas } from '../../asset-loading/index.js'; +import { createComponentId } from '../../ecs/ecs-component.js'; +import { EcsWorld } from '../../ecs/ecs-world.js'; +import { Vector2 } from '../../math/index.js'; +// Imported from their own leaf files (not `../../rendering/index.js`) so +// this file never has a runtime dependency on `render-system.ts`, which +// itself imports `textId`/`TextMeshEcsComponent` from this module - a +// `Renderable`/`Color` value import routed through the rendering barrel +// would create a genuine circular module load between `/rendering` and +// `/text`. `Renderable` is only ever used here as a field type, so it's +// imported as a type-only import to guarantee it's erased entirely. +import { Color } from '../../rendering/color.js'; +import type { Renderable } from '../../rendering/renderable.js'; + +/** + * How a text block's lines are horizontally positioned relative to the + * block's own width (the widest line, or `wrapWidth` when set). + */ +export type TextAlignment = 'left' | 'center' | 'right'; + +/** + * Fields of {@link TextEcsComponent} with no sensible default; callers must + * always provide these. + */ +export interface TextRequiredOptions { + /** + * The string to render. Explicit `\n` characters start a new line; + * `wrapWidth` (if set) additionally breaks lines between words. + */ + text: string; + + /** + * The font atlas metrics to shape `text` against. + */ + font: FontAtlas; + + /** + * The renderable used to draw this text's glyph quads, built once per + * `FontAtlas` via `createMsdfTextRenderable` (`/rendering`) and shared + * across every entity using that font, so they batch into a single + * instanced draw call. + */ + renderable: Renderable; + + /** + * The font size, in world units, that `font`'s em-normalized metrics are + * scaled by. + */ + fontSize: number; +} + +/** + * Fields of {@link TextEcsComponent} with a sensible default; callers may + * omit these. + */ +export interface TextDefaultedOptions { + /** + * The text's color. Multiplied against the MSDF atlas's reconstructed + * coverage, so unlike `SpriteEcsComponent.tintColor` this is the text's + * actual, only color rather than a tint over a sampled texture color. + * Defaults to `Color.white`. + */ + color: Color; + + /** + * How each line is horizontally positioned relative to the text block's + * own width. Defaults to `'left'`. + */ + alignment: TextAlignment; + + /** + * The width, in world units, that lines wrap to fit within, breaking + * between words (not mid-word). Omit for a single unwrapped line per `\n` + * in `text`, however long. + */ + wrapWidth?: number; + + /** + * A multiplier over `font.lineHeight` for the vertical distance between + * successive lines' baselines. Defaults to `1`. + */ + lineSpacing: number; + + /** + * The text block's origin, normalized to the block's own size: `(0, 0)` + * is the bottom-left corner, `(0.5, 0.5)` (the default) is the center, + * and `(1, 1)` is the top-right corner - matching + * `SpriteEcsComponent.pivot`'s Y-up convention exactly. + */ + pivot: Vector2; + + /** + * Whether this text is drawn. When `false`, the render system skips this + * entity entirely, before any culling-mask check. + */ + enabled: boolean; + + /** + * The draw-order layer for this text, relative to other sprites/text + * drawn by the same camera - see `SpriteEcsComponent.layer`. + */ + layer: number; +} + +export interface TextEcsComponent + extends TextRequiredOptions, TextDefaultedOptions {} + +export const textId = createComponentId('text'); + +/** + * Attaches a {@link TextEcsComponent} to `entity`. `createTextShapingEcsSystem` + * shapes `text` into glyph quads (a `TextMeshEcsComponent`, added + * automatically) whenever `text`, `font`, `fontSize`, `wrapWidth`, + * `lineSpacing`, `alignment`, or `pivot` change. + * @param world - The ECS world `entity` belongs to. + * @param entity - The entity to attach the component to. + * @param options - Options for configuring the text. `text`, `font`, + * `renderable`, and `fontSize` have no sensible default and must always be + * provided. + * @returns The attached component, for further tuning or runtime changes. + */ +export function addTextComponent( + world: EcsWorld, + entity: number, + options: TextRequiredOptions & Partial, +): TextEcsComponent { + // Built inside the function body (rather than as a shared module-level + // default), matching `addSpriteComponent`: `Color.white` can't be safely + // read at module-init time in a codebase with circular import cycles + // through barrel files, and `pivot` is a `Vector2` callers/systems may + // mutate in place, so each entity needs its own instance. + const defaultTextOptions: TextDefaultedOptions = { + color: Color.white, + alignment: 'left', + lineSpacing: 1, + pivot: { x: 0.5, y: 0.5 }, + enabled: true, + layer: 0, + }; + + const component: TextEcsComponent = { + ...defaultTextOptions, + ...options, + }; + + return world.addComponent(entity, textId, component); +} diff --git a/src/text/components/text-mesh-component.test.ts b/src/text/components/text-mesh-component.test.ts new file mode 100644 index 00000000..1b67ba2e --- /dev/null +++ b/src/text/components/text-mesh-component.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { addTextMeshComponent, textMeshId } from './text-mesh-component.js'; +import { EcsWorld } from '../../ecs/index.js'; +import type { FontAtlas } from '../../asset-loading/index.js'; + +const font = {} as FontAtlas; + +describe('addTextMeshComponent', () => { + it('attaches the given shaped text data as-is', () => { + const world = new EcsWorld(); + const entity = world.createEntity(); + + const options = { + glyphs: [ + { + offset: { x: 0, y: 0 }, + size: { x: 1, y: 1 }, + uvOffset: { x: 0, y: 0 }, + uvScale: { x: 1, y: 1 }, + }, + ], + bounds: { x: 1, y: 1 }, + sourceText: 'A', + sourceFont: font, + sourceFontSize: 16, + sourceWrapWidth: undefined, + sourceLineSpacing: 1, + sourceAlignment: 'left' as const, + sourcePivot: { x: 0.5, y: 0.5 }, + }; + + addTextMeshComponent(world, entity, options); + + expect(world.getComponent(entity, textMeshId)).toBe(options); + }); + + it('returns the attached component', () => { + const world = new EcsWorld(); + const entity = world.createEntity(); + + const options = { + glyphs: [], + bounds: { x: 0, y: 0 }, + sourceText: '', + sourceFont: font, + sourceFontSize: 16, + sourceWrapWidth: undefined, + sourceLineSpacing: 1, + sourceAlignment: 'left' as const, + sourcePivot: { x: 0.5, y: 0.5 }, + }; + + const component = addTextMeshComponent(world, entity, options); + + expect(world.getComponent(entity, textMeshId)).toBe(component); + }); +}); diff --git a/src/text/components/text-mesh-component.ts b/src/text/components/text-mesh-component.ts new file mode 100644 index 00000000..7bd93373 --- /dev/null +++ b/src/text/components/text-mesh-component.ts @@ -0,0 +1,95 @@ +import { FontAtlas } from '../../asset-loading/index.js'; +import { createComponentId } from '../../ecs/ecs-component.js'; +import { EcsWorld } from '../../ecs/ecs-world.js'; +import { Vector2 } from '../../math/index.js'; +import { TextAlignment } from './text-component.js'; + +/** + * A single glyph's quad, in a text block's own local text-space, that the + * render system draws for each character `createTextShapingEcsSystem` + * placed. Mirrors `NineSliceRegion` (`/rendering`) - the same "offset + + * size + uv rect" shape the render system already knows how to expand into + * a `SpriteEcsComponent`-shaped render command. + */ +export interface GlyphQuad { + /** + * This glyph's center, as an offset from the text block's pivot-adjusted + * anchor point, in unscaled local units (before rotation and + * `ScaleEcsComponent` are applied). + */ + offset: Vector2; + + /** This glyph's rendered width/height, in the same unscaled units. */ + size: Vector2; + + /** The top-left corner of this glyph's region in the font atlas, 0 to 1. */ + uvOffset: Vector2; + + /** The width/height of this glyph's region in the font atlas, 0 to 1. */ + uvScale: Vector2; +} + +/** + * The shaped glyph quads produced by `createTextShapingEcsSystem` from a + * `TextEcsComponent`. Added and kept up to date automatically - shaping a + * paragraph is real cost, so this only happens when `TextEcsComponent`'s + * shape-affecting fields actually change (dirty-tracked via the `source*` + * fields below), not every frame. + */ +export interface TextMeshEcsComponent { + /** + * This text block's shaped glyph quads, one per visible (non-whitespace) + * character. + */ + glyphs: GlyphQuad[]; + + /** + * This text block's overall size (the widest line's width, and the full + * stack of lines' height), in world units. Reflects `pivot`'s effect on + * `glyphs`' offsets, not just the raw shaped size. + */ + bounds: Vector2; + + /** + * The `TextEcsComponent.text` this mesh was last shaped from, used by + * `createTextShapingEcsSystem` to detect when re-shaping is needed. + */ + sourceText: string; + + /** The `TextEcsComponent.font` this mesh was last shaped from. */ + sourceFont: FontAtlas; + + /** The `TextEcsComponent.fontSize` this mesh was last shaped from. */ + sourceFontSize: number; + + /** The `TextEcsComponent.wrapWidth` this mesh was last shaped from. */ + sourceWrapWidth: number | undefined; + + /** The `TextEcsComponent.lineSpacing` this mesh was last shaped from. */ + sourceLineSpacing: number; + + /** The `TextEcsComponent.alignment` this mesh was last shaped from. */ + sourceAlignment: TextAlignment; + + /** The `TextEcsComponent.pivot` this mesh was last shaped from. */ + sourcePivot: Vector2; +} + +export const textMeshId = createComponentId('textMesh'); + +/** + * Attaches a {@link TextMeshEcsComponent} to `entity`. Normally managed + * automatically by `createTextShapingEcsSystem` - call this directly only + * when hand-authoring shaped glyph data outside of that system. + * @param world - The ECS world `entity` belongs to. + * @param entity - The entity to attach the component to. + * @param options - The shaped text mesh data. + * @returns The attached component, for further tuning or runtime changes. + */ +export function addTextMeshComponent( + world: EcsWorld, + entity: number, + options: TextMeshEcsComponent, +): TextMeshEcsComponent { + return world.addComponent(entity, textMeshId, options); +} diff --git a/src/text/index.ts b/src/text/index.ts new file mode 100644 index 00000000..9a9fe1fc --- /dev/null +++ b/src/text/index.ts @@ -0,0 +1,3 @@ +export * from './components/index.js'; +export * from './systems/index.js'; +export * from './utilities/index.js'; diff --git a/src/text/systems/index.ts b/src/text/systems/index.ts new file mode 100644 index 00000000..11b8d26f --- /dev/null +++ b/src/text/systems/index.ts @@ -0,0 +1 @@ +export * from './text-shaping-system.js'; diff --git a/src/text/systems/text-shaping-system.test.ts b/src/text/systems/text-shaping-system.test.ts new file mode 100644 index 00000000..7fec33a3 --- /dev/null +++ b/src/text/systems/text-shaping-system.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from 'vitest'; +import { createTextShapingEcsSystem } from './text-shaping-system.js'; +import { EcsWorld } from '../../ecs/index.js'; +import { addPositionComponent } from '../../common/index.js'; +import type { FontAtlas } from '../../asset-loading/index.js'; +import type { Renderable } from '../../rendering/renderable.js'; +import { addTextComponent, textId } from '../components/text-component.js'; +import { textMeshId } from '../components/text-mesh-component.js'; + +const renderable = {} as Renderable; + +function createFont(): FontAtlas { + return { + image: {} as HTMLImageElement, + atlasWidth: 512, + atlasHeight: 512, + distanceRange: 4, + emSize: 1, + lineHeight: 1.2, + ascender: 0.9, + descender: -0.2, + glyphs: new Map([ + [ + 65, + { + advance: 0.6, + planeBounds: { left: 0, bottom: 0, right: 0.6, top: 0.7 }, + uvOffset: { x: 0, y: 0 }, + uvScale: { x: 0.1, y: 0.1 }, + }, + ], + ]), + kerning: new Map(), + }; +} + +describe('createTextShapingEcsSystem', () => { + it('shapes an entity with a TextEcsComponent but no TextMeshEcsComponent yet', () => { + const world = new EcsWorld(); + const system = createTextShapingEcsSystem(); + world.addSystem(system); + + const entity = world.createEntity(); + + addPositionComponent(world, entity); + addTextComponent(world, entity, { + text: 'A', + font: createFont(), + renderable, + fontSize: 10, + }); + + world.update(); + + const mesh = world.getComponent(entity, textMeshId); + + expect(mesh?.glyphs).toHaveLength(1); + }); + + it('skips disabled text entities', () => { + const world = new EcsWorld(); + const system = createTextShapingEcsSystem(); + world.addSystem(system); + + const entity = world.createEntity(); + + addPositionComponent(world, entity); + addTextComponent(world, entity, { + text: 'A', + font: createFont(), + renderable, + fontSize: 10, + enabled: false, + }); + + world.update(); + + expect(world.getComponent(entity, textMeshId)).toBeNull(); + }); + + it('does not re-shape when nothing shape-affecting changed', () => { + const world = new EcsWorld(); + const system = createTextShapingEcsSystem(); + world.addSystem(system); + + const entity = world.createEntity(); + + addPositionComponent(world, entity); + addTextComponent(world, entity, { + text: 'A', + font: createFont(), + renderable, + fontSize: 10, + }); + + world.update(); + + const meshAfterFirstUpdate = world.getComponent(entity, textMeshId); + const glyphsAfterFirstUpdate = meshAfterFirstUpdate?.glyphs; + + world.update(); + + const meshAfterSecondUpdate = world.getComponent(entity, textMeshId); + + // Same component instance (mutated in place, not replaced) and the + // same `glyphs` array reference - proof `shapeText` wasn't called + // again, since a reshape always allocates a fresh glyphs array. + expect(meshAfterSecondUpdate).toBe(meshAfterFirstUpdate); + expect(meshAfterSecondUpdate?.glyphs).toBe(glyphsAfterFirstUpdate); + }); + + it('re-shapes when the text changes', () => { + const world = new EcsWorld(); + const system = createTextShapingEcsSystem(); + world.addSystem(system); + + const entity = world.createEntity(); + + addPositionComponent(world, entity); + const text = addTextComponent(world, entity, { + text: 'A', + font: createFont(), + renderable, + fontSize: 10, + }); + + world.update(); + + const glyphsAfterFirstUpdate = world.getComponent( + entity, + textMeshId, + )?.glyphs; + + text.text = 'AA'; + world.update(); + + const glyphsAfterSecondUpdate = world.getComponent( + entity, + textMeshId, + )?.glyphs; + + expect(glyphsAfterSecondUpdate).not.toBe(glyphsAfterFirstUpdate); + expect(glyphsAfterSecondUpdate).toHaveLength(2); + }); + + it('re-shapes when fontSize changes', () => { + const world = new EcsWorld(); + const system = createTextShapingEcsSystem(); + world.addSystem(system); + + const entity = world.createEntity(); + + addPositionComponent(world, entity); + const text = addTextComponent(world, entity, { + text: 'A', + font: createFont(), + renderable, + fontSize: 10, + }); + + world.update(); + + const boundsAfterFirstUpdate = world.getComponent(entity, textMeshId) + ?.bounds.x; + + text.fontSize = 20; + world.update(); + + const boundsAfterSecondUpdate = world.getComponent(entity, textMeshId) + ?.bounds.x; + + expect(boundsAfterSecondUpdate).toBeGreaterThan(boundsAfterFirstUpdate!); + }); + + it('re-shapes when wrapWidth, lineSpacing, alignment, or pivot change', () => { + const world = new EcsWorld(); + const system = createTextShapingEcsSystem(); + world.addSystem(system); + + const entity = world.createEntity(); + + addPositionComponent(world, entity); + const text = addTextComponent(world, entity, { + text: 'A A', + font: createFont(), + renderable, + fontSize: 10, + }); + + world.update(); + + const firstGlyphs = world.getComponent(entity, textMeshId)?.glyphs; + + text.wrapWidth = 1; + world.update(); + + const secondGlyphs = world.getComponent(entity, textMeshId)?.glyphs; + + expect(secondGlyphs).not.toBe(firstGlyphs); + + text.lineSpacing = 2; + world.update(); + + const thirdGlyphs = world.getComponent(entity, textMeshId)?.glyphs; + + expect(thirdGlyphs).not.toBe(secondGlyphs); + + text.alignment = 'center'; + world.update(); + + const fourthGlyphs = world.getComponent(entity, textMeshId)?.glyphs; + + expect(fourthGlyphs).not.toBe(thirdGlyphs); + + text.pivot = { x: 0, y: 0 }; + world.update(); + + const fifthGlyphs = world.getComponent(entity, textMeshId)?.glyphs; + + expect(fifthGlyphs).not.toBe(fourthGlyphs); + }); + + it('does not re-shape when only non-shape-affecting fields change', () => { + const world = new EcsWorld(); + const system = createTextShapingEcsSystem(); + world.addSystem(system); + + const entity = world.createEntity(); + + addPositionComponent(world, entity); + const text = addTextComponent(world, entity, { + text: 'A', + font: createFont(), + renderable, + fontSize: 10, + }); + + world.update(); + + const glyphsAfterFirstUpdate = world.getComponent( + entity, + textMeshId, + )?.glyphs; + + text.layer = 5; + world.update(); + + const glyphsAfterSecondUpdate = world.getComponent( + entity, + textMeshId, + )?.glyphs; + + expect(glyphsAfterSecondUpdate).toBe(glyphsAfterFirstUpdate); + }); + + it('queries only entities with a TextEcsComponent', () => { + const system = createTextShapingEcsSystem(); + + expect(system.query).toEqual([textId]); + }); +}); diff --git a/src/text/systems/text-shaping-system.ts b/src/text/systems/text-shaping-system.ts new file mode 100644 index 00000000..b6b80c9d --- /dev/null +++ b/src/text/systems/text-shaping-system.ts @@ -0,0 +1,112 @@ +import { EcsSystem } from '../../ecs/ecs-system.js'; +import { EcsWorld } from '../../ecs/ecs-world.js'; +import { + addTextMeshComponent, + TextEcsComponent, + textId, + TextMeshEcsComponent, + textMeshId, +} from '../components/index.js'; +import { shapeText } from '../utilities/shape-text.js'; + +/** + * Whether `mesh` was shaped from the shape-affecting fields `text` currently + * has. `TextEcsComponent.color`/`enabled`/`layer`/`renderable` don't affect + * shaping and are deliberately excluded, so changing them never triggers a + * re-shape. + */ +function isMeshCurrent( + mesh: TextMeshEcsComponent, + text: TextEcsComponent, +): boolean { + return ( + mesh.sourceText === text.text && + mesh.sourceFont === text.font && + mesh.sourceFontSize === text.fontSize && + mesh.sourceWrapWidth === text.wrapWidth && + mesh.sourceLineSpacing === text.lineSpacing && + mesh.sourceAlignment === text.alignment && + mesh.sourcePivot.x === text.pivot.x && + mesh.sourcePivot.y === text.pivot.y + ); +} + +function reshape( + world: EcsWorld, + entity: number, + text: TextEcsComponent, + existingMesh: TextMeshEcsComponent | null, +): void { + const shaped = shapeText(text.text, text.font, text.fontSize, { + alignment: text.alignment, + wrapWidth: text.wrapWidth, + lineSpacing: text.lineSpacing, + pivot: text.pivot, + }); + + const mesh: TextMeshEcsComponent = { + glyphs: shaped.glyphs, + bounds: shaped.bounds, + sourceText: text.text, + sourceFont: text.font, + sourceFontSize: text.fontSize, + sourceWrapWidth: text.wrapWidth, + sourceLineSpacing: text.lineSpacing, + sourceAlignment: text.alignment, + sourcePivot: { x: text.pivot.x, y: text.pivot.y }, + }; + + if (existingMesh) { + Object.assign(existingMesh, mesh); + + return; + } + + addTextMeshComponent(world, entity, mesh); +} + +/** + * Creates a system that shapes every entity's `TextEcsComponent` into a + * `TextMeshEcsComponent` of glyph quads for the render system to draw. + * + * Re-shaping a paragraph is real cost (word-wrapping, kerning lookups, one + * quad per character), so this only happens when `text`, `font`, + * `fontSize`, `wrapWidth`, `lineSpacing`, `alignment`, or `pivot` actually + * changed since the last shape - every other system in a typical pipeline + * recomputes unconditionally per frame, but text shaping is the deliberate + * exception. + * + * Register this with a `SystemRegistrationOrder` after any system that can + * change an entity's `wrapWidth` (e.g. a UI layout pass resolving a + * container's rect) and before `createRenderEcsSystem`, so the render pass + * always consumes this frame's glyph quads rather than last frame's. + * Standalone text with no layout dependency (damage numbers, floating + * names, debug overlays) has no ordering constraint beyond "before render". + * @returns The ECS system. + */ +export const createTextShapingEcsSystem = (): EcsSystem< + [TextEcsComponent] +> => ({ + query: [textId], + update: (world, { entities, components: [texts] }) => { + for (let i = 0; i < entities.length; i++) { + const text = texts[i]; + + if (!text.enabled) { + continue; + } + + const entity = entities[i]; + const existingMesh = world.getComponent( + entity, + textMeshId, + ); + + if (existingMesh && isMeshCurrent(existingMesh, text)) { + continue; + } + + reshape(world, entity, text, existingMesh); + } + }, +}); diff --git a/src/text/utilities/index.ts b/src/text/utilities/index.ts new file mode 100644 index 00000000..5807b5a7 --- /dev/null +++ b/src/text/utilities/index.ts @@ -0,0 +1 @@ +export * from './shape-text.js'; diff --git a/src/text/utilities/shape-text.test.ts b/src/text/utilities/shape-text.test.ts new file mode 100644 index 00000000..914266ba --- /dev/null +++ b/src/text/utilities/shape-text.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from 'vitest'; +import { shapeText } from './shape-text.js'; +import type { FontAtlas } from '../../asset-loading/index.js'; + +/** + * A tiny synthetic font: "A" and "V" are 1 em wide/tall monospace-ish + * glyphs, "V" following "A" kerns -0.2em tighter, and space just advances. + */ +function createFont(overrides: Partial = {}): FontAtlas { + return { + image: {} as HTMLImageElement, + atlasWidth: 100, + atlasHeight: 100, + distanceRange: 4, + emSize: 1, + lineHeight: 1, + ascender: 0.8, + descender: -0.2, + glyphs: new Map([ + [ + 65, // 'A' + { + advance: 1, + planeBounds: { left: 0, bottom: 0, right: 1, top: 1 }, + uvOffset: { x: 0, y: 0 }, + uvScale: { x: 0.1, y: 0.1 }, + }, + ], + [ + 86, // 'V' + { + advance: 1, + planeBounds: { left: 0, bottom: 0, right: 1, top: 1 }, + uvOffset: { x: 0.1, y: 0 }, + uvScale: { x: 0.1, y: 0.1 }, + }, + ], + [ + 32, // ' ' + { advance: 0.5 }, + ], + ]), + kerning: new Map([['65:86', -0.2]]), + ...overrides, + }; +} + +describe('shapeText', () => { + it('produces one glyph per visible character', () => { + const shaped = shapeText('AA', createFont(), 10); + + expect(shaped.glyphs).toHaveLength(2); + }); + + it('scales glyph size by fontSize / emSize', () => { + const font = createFont({ emSize: 2 }); + const shaped = shapeText('A', font, 10); + + // planeBounds spans 0..1 em; scale = fontSize / emSize = 5. + expect(shaped.glyphs[0].size).toEqual({ x: 5, y: 5 }); + }); + + it('produces no glyph quad for whitespace, but still advances the cursor', () => { + const shaped = shapeText('A A', createFont(), 10); + + expect(shaped.glyphs).toHaveLength(2); + // Second 'A' advance: 1 (first A) + 0.5 (space) = 1.5em * 10 = 15. + expect(shaped.glyphs[1].offset.x).toBeGreaterThan( + shaped.glyphs[0].offset.x, + ); + }); + + it('skips characters missing from the font atlas without throwing', () => { + expect(() => shapeText('A?A', createFont(), 10)).not.toThrow(); + + const shaped = shapeText('A?A', createFont(), 10); + + expect(shaped.glyphs).toHaveLength(2); + }); + + it('applies kerning between consecutive glyphs', () => { + const withKerning = shapeText('AV', createFont(), 10); + const withoutKerningFont = createFont({ kerning: new Map() }); + const withoutKerning = shapeText('AV', withoutKerningFont, 10); + + // Kerning is -0.2em * fontSize(10) = -2 tighter, so the second glyph's + // offset should be 2 units further left with kerning applied. + expect(withKerning.glyphs[1].offset.x).toBeLessThan( + withoutKerning.glyphs[1].offset.x, + ); + }); + + it('does not apply kerning across a line break', () => { + const font = createFont(); + const shapedTwoLines = shapeText('A\nV', font, 10); + + expect(shapedTwoLines.glyphs).toHaveLength(2); + // The 'V' on line 2 starts a fresh cursor (x relative to its own line), + // so it isn't pulled left by the A-V kerning pair the way it would be + // on a single line. + const shapedOneLine = shapeText('AV', font, 10); + const kernedOffset = shapedOneLine.glyphs[1].offset.x; + + expect(shapedTwoLines.glyphs[1].offset.x).not.toBe(kernedOffset); + }); + + describe('bounds', () => { + it('computes width as the widest line when no wrapWidth is given', () => { + const shaped = shapeText('AA\nA', createFont(), 10); + + // 'AA' is 2em wide * 10 = 20; 'A' alone is 10. + expect(shaped.bounds.x).toBe(20); + }); + + it('computes height as lines * lineHeight * lineSpacing * fontSize', () => { + const shaped = shapeText('A\nA\nA', createFont(), 10, { + lineSpacing: 2, + }); + + // font.lineHeight = 1, so height = 3 lines * 1 * 2 * 10 = 60. + expect(shaped.bounds.y).toBe(60); + }); + + it('uses wrapWidth as the block width when given', () => { + const shaped = shapeText('A', createFont(), 10, { wrapWidth: 50 }); + + expect(shaped.bounds.x).toBe(50); + }); + }); + + describe('word wrapping', () => { + it('does not wrap when wrapWidth is omitted, however long the line', () => { + const shaped = shapeText('A A A A A A A A A A', createFont(), 10); + + // 19 characters (10 'A's + 9 spaces) all placed as visible or + // whitespace on a single unwrapped line: 10 visible glyphs. + expect(shaped.glyphs).toHaveLength(10); + expect(shaped.bounds.y).toBe(10); // one line only + }); + + it('wraps between words to fit wrapWidth', () => { + // Each "A A" pair is 1.5em wide; with fontSize 10 that's 15 units. + // A wrapWidth of 20 allows one word (10 units) but not two per line. + const shaped = shapeText('A A A', createFont(), 10, { wrapWidth: 20 }); + + // 3 lines of one 'A' each -> height = 3 * 1 * 1 * 10 = 30. + expect(shaped.bounds.y).toBe(30); + }); + + it('places a single word wider than wrapWidth on its own line, unbroken', () => { + const shaped = shapeText('AA', createFont(), 10, { wrapWidth: 5 }); + + expect(shaped.glyphs).toHaveLength(2); + expect(shaped.bounds.y).toBe(10); // still a single line + }); + + it('preserves explicit line breaks alongside wrapping', () => { + const shaped = shapeText('A A\nA', createFont(), 10, { wrapWidth: 30 }); + + // "A A" is A(1em) + space(0.5em) + A(1em) = 2.5em wide, 25 units at + // fontSize 10, which fits within a 30-unit wrapWidth as one line; + // "A" from the explicit break is a second line -> 2 lines total. + expect(shaped.bounds.y).toBe(20); + }); + }); + + describe('alignment', () => { + it('left-aligns by default, starting every line at x = 0 (before pivot)', () => { + const shaped = shapeText('AA\nA', createFont(), 10, { + wrapWidth: 20, + pivot: { x: 0, y: 0 }, + }); + + const firstGlyphOfEachLine = [shaped.glyphs[0], shaped.glyphs[2]]; + + for (const glyph of firstGlyphOfEachLine) { + expect(glyph.offset.x).toBeCloseTo(glyph.size.x / 2); + } + }); + + it('center-aligns shorter lines within the block width', () => { + const shaped = shapeText('AA\nA', createFont(), 10, { + alignment: 'center', + pivot: { x: 0, y: 0 }, + }); + + // Block width is 20 (the "AA" line). The lone "A" line (10 wide) + // should be centered, starting at x = 5 instead of x = 0. + const secondLineGlyph = shaped.glyphs[2]; + + expect(secondLineGlyph.offset.x).toBeCloseTo( + 5 + secondLineGlyph.size.x / 2, + ); + }); + + it('right-aligns shorter lines within the block width', () => { + const shaped = shapeText('AA\nA', createFont(), 10, { + alignment: 'right', + pivot: { x: 0, y: 0 }, + }); + + const secondLineGlyph = shaped.glyphs[2]; + + expect(secondLineGlyph.offset.x).toBeCloseTo( + 10 + secondLineGlyph.size.x / 2, + ); + }); + }); + + describe('pivot', () => { + it('defaults to the block center (0.5, 0.5)', () => { + const centered = shapeText('A', createFont(), 10); + const bottomLeft = shapeText('A', createFont(), 10, { + pivot: { x: 0, y: 0 }, + }); + + expect(centered.glyphs[0].offset.x).toBeLessThan( + bottomLeft.glyphs[0].offset.x, + ); + }); + + it('offsets every glyph by pivot * bounds, Y-up like SpriteEcsComponent.pivot', () => { + const bottomLeft = shapeText('AA', createFont(), 10, { + pivot: { x: 0, y: 0 }, + }); + const topRight = shapeText('AA', createFont(), 10, { + pivot: { x: 1, y: 1 }, + }); + + const { bounds } = bottomLeft; + + expect(topRight.glyphs[0].offset.x).toBeCloseTo( + bottomLeft.glyphs[0].offset.x - bounds.x, + ); + // pivot.y = 1 means top (Y-up), so it shifts the block down (offset.y + // decreases) relative to pivot.y = 0 (bottom) - the opposite sign + // from offset.x, since a higher pivot.x (more "right") shifts the + // block left the same way a higher pivot.y (more "top") shifts it down. + expect(topRight.glyphs[0].offset.y).toBeCloseTo( + bottomLeft.glyphs[0].offset.y - bounds.y, + ); + }); + }); + + describe('uv rects', () => { + it("passes through each glyph's uvOffset/uvScale from the font atlas unchanged", () => { + const shaped = shapeText('AV', createFont(), 10); + + expect(shaped.glyphs[0].uvOffset).toEqual({ x: 0, y: 0 }); + expect(shaped.glyphs[0].uvScale).toEqual({ x: 0.1, y: 0.1 }); + expect(shaped.glyphs[1].uvOffset).toEqual({ x: 0.1, y: 0 }); + }); + }); +}); diff --git a/src/text/utilities/shape-text.ts b/src/text/utilities/shape-text.ts new file mode 100644 index 00000000..e230c75d --- /dev/null +++ b/src/text/utilities/shape-text.ts @@ -0,0 +1,261 @@ +import { + FontAtlas, + FontAtlasGlyph, + getKerningKey, +} from '../../asset-loading/index.js'; +import { Vector2 } from '../../math/index.js'; +import { GlyphQuad } from '../components/text-mesh-component.js'; +import { TextAlignment } from '../components/text-component.js'; + +/** + * Options for {@link shapeText}, mirroring `TextEcsComponent`'s + * shape-affecting fields. + */ +export interface ShapeTextOptions { + /** How each line is horizontally positioned. Defaults to `'left'`. */ + alignment?: TextAlignment; + + /** + * The width, in world units, that lines wrap to fit within, breaking + * between words (not mid-word - a single word wider than `wrapWidth` is + * still placed on its own line, unbroken). Omit for a single unwrapped + * line per `\n` in `text`. + */ + wrapWidth?: number; + + /** A multiplier over `font.lineHeight`. Defaults to `1`. */ + lineSpacing?: number; + + /** + * The text block's origin, normalized to its own size and Y-up (`(0, 0)` + * is bottom-left, `(1, 1)` is top-right), matching + * `SpriteEcsComponent.pivot`. Defaults to `(0.5, 0.5)`. + */ + pivot?: Vector2; +} + +/** The result of {@link shapeText}. */ +export interface ShapedText { + /** One quad per visible (non-whitespace, in-atlas) character. */ + glyphs: GlyphQuad[]; + + /** + * The text block's overall size, in world units: the widest line's width + * (or `wrapWidth`, if given) and the full line-height stack's height. + */ + bounds: Vector2; +} + +const defaultShapeTextOptions = { + alignment: 'left' as TextAlignment, + lineSpacing: 1, +}; + +/** + * Walks `line`'s characters left to right, applying each glyph's kerning + * (against the previous placed glyph) and advance, in em-to-world-unit + * `scale`. Characters with no matching glyph in `font` are skipped (no + * advance) - a font missing a character has no way to represent it. + * @param line - The line to lay out (must not contain `\n`). + * @param font - The font atlas to look up glyphs and kerning in. + * @param scale - The em-to-world-unit scale factor (`fontSize / font.emSize`). + * @param onGlyph - Invoked for each placed glyph, with its code point, its + * atlas metrics, and its cursor position (the pen position immediately + * before this glyph, in world units, before its own advance is applied). + * @returns The line's total advance width, in world units. + */ +function layoutLine( + line: string, + font: FontAtlas, + scale: number, + onGlyph?: (codePoint: number, glyph: FontAtlasGlyph, cursorX: number) => void, +): number { + let cursorX = 0; + let previousCodePoint: number | null = null; + + for (const character of line) { + // `codePointAt` (not charCodeAt/index access) so a surrogate-pair + // character (e.g. most emoji) resolves to one code point, matching how + // `for...of` already iterated by code point rather than UTF-16 unit. + const codePoint = character.codePointAt(0)!; + const glyph = font.glyphs.get(codePoint); + + if (!glyph) { + previousCodePoint = null; + + continue; + } + + if (previousCodePoint !== null) { + const kerning = + font.kerning.get(getKerningKey(previousCodePoint, codePoint)) ?? 0; + + cursorX += kerning * scale; + } + + onGlyph?.(codePoint, glyph, cursorX); + + cursorX += glyph.advance * scale; + previousCodePoint = codePoint; + } + + return cursorX; +} + +/** + * Greedily word-wraps `paragraph` (a single line's worth of source text, + * already split on `\n`) to fit within `wrapWidth`, breaking between words. + * A word wider than `wrapWidth` on its own is still placed on its own line, + * unbroken. Collapses runs of spaces to a single space between words - + * word-wrapping re-flows the text anyway, so original whitespace runs + * aren't preserved (unlike the no-`wrapWidth` case, which uses `paragraph` + * verbatim). + * @param paragraph - The paragraph to wrap. + * @param font - The font atlas to measure candidate lines against. + * @param scale - The em-to-world-unit scale factor. + * @param wrapWidth - The width, in world units, to wrap within. + * @returns The wrapped lines. + */ +function wrapParagraph( + paragraph: string, + font: FontAtlas, + scale: number, + wrapWidth: number, +): string[] { + const words = paragraph.split(' '); + const lines: string[] = []; + let currentLine = ''; + + for (const word of words) { + const candidate = currentLine ? `${currentLine} ${word}` : word; + + if (currentLine !== '' && layoutLine(candidate, font, scale) > wrapWidth) { + lines.push(currentLine); + currentLine = word; + + continue; + } + + currentLine = candidate; + } + + lines.push(currentLine); + + return lines; +} + +function computeLines( + text: string, + font: FontAtlas, + scale: number, + wrapWidth: number | undefined, +): string[] { + const paragraphs = text.split('\n'); + + if (wrapWidth === undefined) { + return paragraphs; + } + + return paragraphs.flatMap((paragraph) => + wrapParagraph(paragraph, font, scale, wrapWidth), + ); +} + +function alignmentOffset( + alignment: TextAlignment, + blockWidth: number, + lineWidth: number, +): number { + if (alignment === 'center') { + return (blockWidth - lineWidth) / 2; + } + + if (alignment === 'right') { + return blockWidth - lineWidth; + } + + return 0; +} + +/** + * Shapes `text` into glyph quads ready for the render system: word-wrapping + * (if `wrapWidth` is given), applying kerning, and laying out lines + * according to `alignment`, `lineSpacing`, and `pivot`. + * + * Coordinates follow the same convention as `computeNineSliceRegions` + * (`/rendering`): each glyph's `offset` is relative to the text block's + * pivot-adjusted anchor, in the engine's Y-up world-unit space, ready to be + * rotated/scaled and added to an entity's world position. + * @param text - The string to shape. Explicit `\n` characters start a new + * line. + * @param font - The font atlas to shape against. + * @param fontSize - The font size, in world units, that `font`'s + * em-normalized metrics are scaled by. + * @param options - Shaping options. + * @returns The shaped glyph quads and the text block's overall bounds. + */ +export function shapeText( + text: string, + font: FontAtlas, + fontSize: number, + options: ShapeTextOptions = {}, +): ShapedText { + const { alignment, wrapWidth, lineSpacing, pivot } = { + ...defaultShapeTextOptions, + pivot: { x: 0.5, y: 0.5 }, + ...options, + }; + + const scale = fontSize / (font.emSize || 1); + const lines = computeLines(text, font, scale, wrapWidth); + const lineWidths = lines.map((line) => layoutLine(line, font, scale)); + const blockWidth = wrapWidth ?? Math.max(0, ...lineWidths); + const rowHeight = font.lineHeight * lineSpacing * scale; + const blockHeight = lines.length * rowHeight; + + const glyphs: GlyphQuad[] = []; + + lines.forEach((line, lineIndex) => { + const lineOffsetX = alignmentOffset( + alignment, + blockWidth, + lineWidths[lineIndex], + ); + // Y-down, row 0 at the block's top edge - matching `NineSliceRegion`'s + // "start" convention before the pivot subtraction below converts it to + // the engine's Y-up world space. + const baselineDown = lineIndex * rowHeight + font.ascender * scale; + + layoutLine(line, font, scale, (_codePoint, glyph, cursorX) => { + if (!glyph.planeBounds || !glyph.uvOffset || !glyph.uvScale) { + return; + } + + const left = lineOffsetX + cursorX + glyph.planeBounds.left * scale; + const right = lineOffsetX + cursorX + glyph.planeBounds.right * scale; + const topDown = baselineDown - glyph.planeBounds.top * scale; + const bottomDown = baselineDown - glyph.planeBounds.bottom * scale; + + glyphs.push({ + offset: { + x: (left + right) / 2 - pivot.x * blockWidth, + // `topDown`/`bottomDown` run Y-down (distance from the block's + // top edge), but `pivot.y` is Y-up public API - `(0, 0)` bottom, + // `(1, 1)` top, matching `SpriteEcsComponent.pivot` - so + // `1 - pivot.y` first converts it to the same Y-down + // distance-from-top before the subtraction flips the whole + // result back to the engine's Y-up world space. + y: (1 - pivot.y) * blockHeight - (topDown + bottomDown) / 2, + }, + size: { x: right - left, y: bottomDown - topDown }, + uvOffset: glyph.uvOffset, + uvScale: glyph.uvScale, + }); + }); + }); + + return { + glyphs, + bounds: { x: blockWidth, y: blockHeight }, + }; +}