diff --git a/.cspell/project-words.txt b/.cspell/project-words.txt index cb023b9f..5ef17b5c 100644 --- a/.cspell/project-words.txt +++ b/.cspell/project-words.txt @@ -222,3 +222,5 @@ winit xadvance yoavbls Zoltan +rrggbb +rrggbbaa diff --git a/CHANGELOG.md b/CHANGELOG.md index 0641ba08..c709652a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **ecs:** Systems and system groups can run conditionally. Pass a `runIf` function of the world to `addSystem` or `addSystemGroup`, and the world checks it each tick just before the system or group would run, skipping it (and its query) when it returns `false`. `EcsWorld` also gains a built-in `firstSystemGroup` that runs before every other group of the tick. A group ordered `after` it joins the start of the tick, before every other group. Ordering a group `before` the first group throws, and so does ordering a group before a start-of-tick group, or a start-of-tick group after one that isn't - **states:** New `@forge-game-engine/forge/states` module for a game's top-level states (menu, playing, paused, game over). `createGameState(world, initial)` returns a `GameState` whose `set` switches state at the start of the next tick. Its `exitGroup` and `enterGroup` run once per transition, before any other system, holding systems that use `onExit`/`onEnter` as their `runIf`; `inState` runs a system only in some states. The initial state is entered on the first tick, and setting the current state again restarts it. `addStateScopedComponent` removes an entity when its state leaves one of `removeOnExit` or enters one of `removeOnEnter`. See the new Game States guide and demo - **rendering:** `getCameraView(world, camera, renderContext)` (and `computeCameraView(camera, position, renderContext)` for systems that already hold the camera's components) returns what a camera sees: the world area it shows (`bounds`, `size`, accounting for its position and zoom), its `pixelsPerUnit` in CSS pixels, and `worldToViewport`/`viewportToWorld` conversions to and from CSS pixels on the canvas. To place something drawn by one camera over something drawn by another, convert through the viewport: `hudView.viewportToWorld(gameView.worldToViewport(position))` +- **text:** Rich text tags. `TextEcsComponent.text` (and `shapeText`) parse `...` and `...` (`#rgb`, `#rgba` and `#rrggbbaa` too) to style part of a string. Tags are stripped before shaping, so they never change kerning or wrapping. `` draws a synthetic bold from the same atlas, thickening each glyph by `FAUX_BOLD_EMBOLDEN` ems per side and widening its advance to match. A `` replaces the text's `color`, alpha included. Markup that isn't a valid tag, such as `HP < 50%` or ``, is drawn as literal text, so a string that happens to contain `` or `` now renders styled. `parseRichText` is public, and `GlyphQuad` gains `color` and `embolden` - **text:** The engine's default font can be imported through a bundler from `@forge-game-engine/forge/fonts/default/default.json` and `@forge-game-engine/forge/fonts/default/default.png`, so you no longer need to copy it out of `node_modules` #### Changed diff --git a/design/rich-text-tags.md b/design/rich-text-tags.md deleted file mode 100644 index 5ebdd5ab..00000000 --- a/design/rich-text-tags.md +++ /dev/null @@ -1,254 +0,0 @@ -# Design: Rich Text Tags - -| | | -| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Status** | Draft — for review. Scoped out of `design/ui-system.md` backlog 5.7 rather than implemented directly, per real open questions surfaced while scoping it (see below). | -| **Target module** | `/src/text` (shaping pipeline, `GlyphQuad`/`TextMeshEcsComponent`) — new; `/src/ui` unaffected, this is a text concern, not a UI one (matching DL-04's precedent) | -| **Engine version at time of writing** | `0.24.2` | -| **Model** | Inline markup tags (``, ``) parsed out of a `TextEcsComponent.text` string into per-run style data, consumed by `shapeText`/`createTextShapingEcsSystem` | - ---- - -## 1. Summary - -`design/ui-system.md`'s Phase 5 backlog (5.7) calls for rich text tags - -`bold` and `red` - as inline markup inside -`TextEcsComponent.text`, sized **L**. Scoping it for implementation (rather -than guessing at an approach) surfaced real architectural questions the -existing text pipeline doesn't have answers to yet, spanning three areas -that are each individually non-trivial: - -1. **Per-glyph vs. per-entity styling.** Every color/effect field - (`TextEcsComponent.color`, `outlineColor`, `outlineWidth`, `shadowColor`, - `shadowOffset`, `shadowSoftness`) is a single value applied uniformly to - every glyph in the entity. `GlyphQuad` carries no style data of its own - at all - `pushTextFillRenderCommands` (`src/text/rendering/glyph-quad.ts`) - reads `textComponent.color` once per entity and applies it to every - glyph's synthetic sprite. A `` span needs a color that varies - *within* one entity's text - a genuinely new capability, not a bug fix - or an extension of an existing per-entity field. -2. **What `` means without a bold-weight atlas.** `forge-generate-font-atlas` - bakes exactly one `.ttf`/`.otf` file - one weight, one style - into one - `FontAtlas`. There is no bold variant, no italic variant, and no concept - of a font *family* with multiple weights anywhere in `/src/text`. `` - has no meaning to render with today; it needs one of several genuinely - different mechanisms (§7), each with real cost and limitations, decided - before any shaping code is written. -3. **Interaction with word-wrap and kerning.** `shapeText` - (`src/text/utilities/shape-text.ts`, 560+ lines) tokenizes into words, - measures each word's width for wrapping, and walks kerning pairs - character-by-character within a word. Tags must be invisible to both - - `AV` must kern "AV" exactly as `AV` would, and must - not count the tag markup's own characters toward wrap width - which - means stripping tags before measuring/kerning while retaining a - character-index-to-style mapping for the glyphs that measurement and - kerning walk eventually produces. Retrofitting that into an existing, - well-tested shaping pipeline (rather than designing it in from the - start) is exactly the kind of change worth a design pass before code. - -This document exists to answer these questions before backlog 5.7 is -implemented, not to implement it - no code changes ship with this -document, matching how the MSDF text rendering and -column-aligned form layout designs were written before their own -implementation phases. - ---- - -## 2. Scope - -### In scope - -- Inline tag syntax for the two tags backlog 5.7 names: `...` and - `...`. -- How tags interact with existing `TextEcsComponent` features: word-wrap - (`maxWidth`), kerning, `horizontalAlign`/`verticalAlign`, and the - existing outline/shadow effects. -- The data-model change `GlyphQuad`/`TextMeshEcsComponent` needs to carry - per-run style through to rendering. -- Malformed/unclosed tag handling. - -### Out of scope - -- A general-purpose rich-text/markup language (headings, lists, inline - images, hyperlinks). Backlog 5.7 names exactly two tags; this document - scopes only those two, though the tag-parsing approach chosen should not - make adding a third tag later disproportionately hard. -- Bidirectional text and complex script shaping - already an explicit - non-goal of the whole text/UI effort (`design/ui-system.md` §2). -- Any change to `forge-generate-font-atlas`'s single-file-in, single-atlas-out - model, unless the bold decision below (§7) requires it. - ---- - -## 3. Why this needs a design pass rather than a guess - -The three points in §1 are not independent - the answer to "what does -`` mean" determines whether bold needs new atlas-level data (a second -`FontAtlas`) or can be expressed as a per-glyph shader parameter on the -*existing* atlas, which in turn determines how much of `GlyphQuad`'s shape -needs to change, and whether that change looks anything like ``'s -(a plain per-glyph field) or needs its own, differently-shaped mechanism -entirely. Picking a shape for one without deciding the other risks a -second migration once the real answer to `` is settled. Guessing here - -the way an implementer would -have to, absent this document - risks: shipping a `GlyphQuad` shape that -can express color-per-glyph but not weight-per-glyph (or vice versa), -tag-stripping logic bolted onto `shapeText` in a way that silently breaks -an existing wrap/kerning test case, and a syntax choice (see §6) that -doesn't extend cleanly if a third tag is ever wanted. - ---- - -## 4. Phases - -Two independently shippable phases fall out of resolving §7 in favor of -faux-bold (the recommended direction) - if a real bold atlas is chosen -instead, Phase 2 changes shape but Phase 1 is unaffected either way, since -`` doesn't depend on the `` decision at all. - -### Phase 1 — Tag parsing + `` - -| # | Task | Size | -| - | ---- | ---- | -| 1.1 | Tag parser: strips ``/`` (and, structurally, any future tag) from a `TextEcsComponent.text` string into a plain string plus a list of `{ startIndex, endIndex, color }` runs over that plain string's character indices | M | -| 1.2 | `shapeText` accepts the parsed plain string (unchanged measurement/kerning/wrap logic - it already only sees the stripped string) plus the run list, and stamps each emitted `GlyphQuad` with the color of the run its source character index falls in | M | -| 1.3 | `GlyphQuad.color?: Color` (optional - `undefined` for a glyph with no active `` run, falling back to `TextEcsComponent.color` exactly as today) | S | -| 1.4 | `pushTextFillRenderCommands` reads `glyph.color ?? textComponent.color` per glyph instead of `textComponent.color` for every glyph | S | -| 1.5 | Malformed-tag handling: an unclosed `` runs to the end of the string; an unrecognized tag name throws (see §8) | S | -| 1.6 | Unit tests: nested-vs-sequential runs, a tag spanning a word-wrap break, kerning across a tag boundary, malformed tags | M | -| 1.7 | Docs + demo update (`documentation-site/src/pages/demos/text`) | S | - -**Phase 1 exit criterion:** `red and green text` renders with each span in its own color, word-wraps identically to the same string with tags removed, and kerns identically across tag boundaries to the equivalent untagged string. - -### Phase 2 — `` - -Blocked on resolving the bold mechanism (§7). Sized **L** on its own if a -second atlas per weight is chosen (needs `forge-generate-font-atlas` -changes, a `FontAtlas` weight-selection API, and asset-pipeline docs); **M** -if faux-bold is chosen (a per-glyph shader parameter, closer in shape to -the existing outline effect than to a new asset type). - ---- - -## 5. Decision log - -### DL-1 — Runs are computed as index ranges over the stripped string, not stored inline in a parsed tree - -**Options.** (a) Parse into a tree of styled nodes (an AST), walked during -shaping. (b) Strip tags into a plain string plus a flat list of -`{ startIndex, endIndex, style }` runs against that string's indices. - -**Decision: (b).** - -**Rationale.** `shapeText` already operates on a plain string end to end -- tokenizing into words, measuring, kerning, wrapping. A flat run list -keyed by character index slots into that pipeline with the smallest -possible change: every place `shapeText` already tracks "which source -character is this glyph" (it must, to look up whitespace/kerning) can look -up the active run(s) at that index the same way. A tree walk would instead -require re-deriving word/line boundaries against tree node boundaries, -duplicating logic `shapeText` already has for the plain-string case. -Nested tags (`...`) are simply two run lists -that both cover the same index range - no tree structure needed to express -nesting when styles are independent axes (weight, color) rather than a -strict containment hierarchy. - -**Consequences.** Adding a future tag type is "add another run list", not -a tree-schema change. The parser (1.1) still has to validate proper -nesting/closing at parse time even though the *output* is flat. - ---- - -## 6. Tag syntax - -**Recommendation: an HTML-like syntax, `` / `` / ``, -matching the backlog's own examples exactly.** This is also what Unity's -TextMeshPro and most game-engine rich text implementations converge on, -not because of that precedent but because it reuses a syntax most authors -already have muscle memory for, and is unambiguous to tokenize (a `<` -followed immediately by a letter or `/` starts a tag; a lone `<` followed -by anything else - a space, a digit, end of string - is literal text, so -existing strings containing a bare `<` for other reasons stay unaffected -unless they happen to spell out a real tag name). - -Literal `<`/`>` in authored text (wanting to display the character `<` -itself) needs an escape - recommend `<`/`>`, again matching the -existing web-adjacent convention rather than inventing a new one, at the -cost of also needing to escape `&` itself (`&`) once entities exist at -all. **Open question**, see §8 - whether this corner is worth the parser -complexity for a v1 that only ships two tags is genuinely debatable. - ---- - -## 7. What `` means without a bold-weight atlas - -**Options.** - -(a) **A second `FontAtlas` per weight**, generated from a separate bold -`.ttf`/`.otf` file, with `TextEcsComponent`/`createLabel` accepting a -`boldFontAtlas` alongside `fontAtlas`, switched to for glyphs inside a -`` run. Matches how real bold type actually looks (different glyph -shapes, not just thicker outlines) - genuine typographic bold, at the cost -of every consumer needing to source and ship a matching bold font file, -and `forge-generate-font-atlas`/`FontAtlasCache` both needing multi-atlas -awareness. - -(b) **Faux-bold via the existing outline mechanism**, extended to be -settable per-glyph: a `` run's glyphs render with fill *plus* a small -same-color outline (or a slightly negative signed-distance threshold -shift), thickening strokes without a second atlas. This is what browsers -do when a `font-weight: bold` is requested but only a regular weight is -installed ("synthetic bold"), and is exactly the well-known approximation, -with the well-known limitation: it doesn't reproduce a real bold cut's -actual glyph shapes (different curve tension, different counter sizes), -and reads noticeably worse at small sizes or with already-thick strokes. -Reuses `msdf-effects.frag`'s existing screen-pixel-range outline math -directly - the smallest implementation, and the one that needs no new -asset type or pipeline change. - -(c) **No `` in v1** - ship `` only, defer bold until a concrete -consumer needs real typographic bold badly enough to justify sourcing a -second font file. - -**No decision recorded here** - this is the open question this whole -document exists to raise, not resolve unilaterally, since it trades off -asset-pipeline complexity (a) against visual fidelity (a vs. b) in a way -that depends on how this module's consumers actually use bold text (a -"press A to continue" prompt's occasional bold word vs. a text-heavy -document viewer that needs correct typography), which isn't something to -guess at. **Recommendation, not a decision: (b) for v1** - it ships without -any asset-pipeline change, is a small, contained addition next to the -outline effect that already exists, and can be superseded by (a) later -for any consumer that needs real bold cuts without (b) having been wasted -work, since the `` tag/run-list plumbing from Phase 1 doesn't change -either way - only how a `` run is *rendered* changes. - ---- - -## 8. Open questions - -1. **Which of §7's three options for ``?** The one genuinely - load-bearing decision this document raises. Needs a decision before - Phase 2 can be sized precisely or started. -2. **Escape sequences for literal `<`/`>`/`&`** - full entity-style - escaping (§6), or a simpler rule (e.g. "a `<` not immediately followed - by a recognized tag name or `/` is literal, no escaping needed at all")? - The simpler rule covers the common case (stray `<`/`>` in game text, - e.g. "HP < 50%") with zero authoring overhead, at the cost of making a - literal ``-shaped substring unrepresentable without an escape hatch - of some kind - probably an acceptable trade for a v1 shipping exactly - two tag names. -3. **Malformed tags: throw, or degrade gracefully?** §4's Phase 1 plan - assumes an unrecognized tag name throws (loud failure during - development, matching this codebase's general error-handling - convention of throwing descriptive errors rather than silently - degrading) and an unclosed tag runs to the end of the string (matching - how a browser's own malformed-HTML recovery behaves, and simple to - implement). Worth confirming both defaults before Phase 1 ships rather - than after. -4. **Does a `` run need its own alpha, or only RGB?** `Color` in - this codebase always carries all four channels; the recommendation is - a `` run fully replaces the fill color (RGB and alpha both), - matching how `TextEcsComponent.color` itself works today, but this is - worth confirming rather than assuming - a consumer might expect - `` to change hue without changing opacity. diff --git a/design/ui-system.md b/design/ui-system.md index 73c26faa..d1eafc9a 100644 --- a/design/ui-system.md +++ b/design/ui-system.md @@ -87,9 +87,9 @@ unblocked. CSS-ish style sheets, no parser. Layout is expressed in TypeScript. - **No visual editor.** The engine is code-only by design. A future editor may read and write this system, but is out of scope here. -- **No rich-text document layout.** Single-font, single-style runs per text - element for v1. No inline images, no bidirectional text, no complex script - shaping (Arabic, Devanagari). +- **No rich-text document layout.** One font per text element, styled only + by inline ``/`` tags. No inline images, no bidirectional text, no + complex script shaping (Arabic, Devanagari). - **No DOM-backed widgets.** The one place browser text entry is unavoidable (IME, mobile keyboards, clipboard) is isolated as an input primitive in `/src/input`, not as DOM inside a UI component — so this module stays @@ -1500,7 +1500,7 @@ widest label with no hand-computed offsets. Documented in the UI doc's "Layout g | 5.4 | **Landed**, outside this module: text effects (outline, drop shadow, glow as MSDF shader parameters) shipped via `/src/text` ([#608](https://github.com/Forge-Game-Engine/Forge/pull/608), [#610](https://github.com/Forge-Game-Engine/Forge/pull/610)), documented in the text module's Text Effects doc. Nothing left to do here. | S | | 5.5 | **Landed.** Tooltips + a UI-safe-area concept for notched displays | S | | 5.6 | **Landed.** UI stress-test demo - dirty-tracking optimization evaluated and deferred, not warranted at the scale measured; see DL-12 | M (DL-12) | -| 5.7 | **Scoped out.** Rich text tags (``, ``) — scoping surfaced real architectural gaps (no per-glyph styling data model, no bold-weight concept in the font-atlas pipeline); see [`design/rich-text-tags.md`](./rich-text-tags.md) for the design addendum instead of a guessed implementation. | L | +| 5.7 | **Landed.** Rich text tags (``, ``) in `TextEcsComponent.text`: `shapeText` parses them into per-glyph color and a synthetic-bold embolden; see the Rendering Text guide. | L | --- diff --git a/documentation-site/docs/docs/text/rendering-text.md b/documentation-site/docs/docs/text/rendering-text.md index 09a22177..6c1ecfcd 100644 --- a/documentation-site/docs/docs/text/rendering-text.md +++ b/documentation-site/docs/docs/text/rendering-text.md @@ -95,7 +95,8 @@ wrapping, alignment) when `text`, `fontAtlas`, `size`, `letterSpacing`, `lineHeight`, `horizontalAlign`, `verticalAlign`, `maxWidth`, or `horizontalAlignPivot` actually changed since the last tick it ran against this entity. Changing `color`, `layer`, or `enabled` alone never triggers a -re-shape, they're read directly by the render system each frame. +re-shape, they're read directly by the render system each frame. A +`` tag is part of `text`, so changing one re-shapes the string. ## Multi-line layout @@ -156,6 +157,57 @@ A single word wider than `maxWidth` on its own is never split mid-word - it simply overflows its own line, the same as any other greedy word-wrapping implementation. +## Rich text tags + +Tags inside `text` style part of a string. Two are supported: + +- `...` draws its text bold. +- `...` draws its text in a color. `#rgb`, `#rgba` + and `#rrggbbaa` work too. The color replaces `color` for those + characters, alpha included, so `` is half transparent. + +```ts +addTextComponent(world, label, { + text: 'Press A to continue', + fontAtlas, + size: 24, +}); +``` + +Tags nest. When colors nest, the innermost one wins. `` and `` +are independent, so their ranges can overlap without nesting cleanly. + +Tags are stripped before the string is shaped. Kerning, wrapping and +alignment see only the visible text, so a tagged string lays out exactly as +the same string without tags would. Bold glyphs are the one exception: they +take up a little more room (see below). `shapeText` parses tags too, so +bounds you measure with it match what's drawn. + +### Bold without a bold font + +A font atlas holds one weight. `` draws a synthetic bold, the same thing +a browser does when a page asks for bold and only the regular font is +installed: each glyph's edge is pushed out by +[`FAUX_BOLD_EMBOLDEN`](/Forge/docs/api/variables/FAUX_BOLD_EMBOLDEN) ems on +every side, and its advance grows by the added width so bold letters don't +run into each other. It reads well at body and heading sizes, but it isn't a +real bold cut: letter shapes are only thickened, not redrawn. An outline on +bold text wraps the thickened ink (see [Text Effects](./text-effects.md)). + +### Literal `<` + +A `<` only starts a tag when it begins a complete ``, ``, +`` or `` tag. Anything else is drawn as written, never +an error, since text often comes from players or translations: + +- a `<` that doesn't start a complete tag: `HP < 50%`, `a<3` +- an unknown tag, such as `` +- a tag with an invalid value, such as `` +- a closing tag with no matching open tag + +A tag that's never closed runs to the end of the string. There's no escape +syntax, so the exact text `` can't be displayed. + ## Positioning and scale `size` is the rendered em height in world units, positioned the same way as diff --git a/documentation-site/docs/docs/text/text-effects.md b/documentation-site/docs/docs/text/text-effects.md index bdd9281f..bdaabc60 100644 --- a/documentation-site/docs/docs/text/text-effects.md +++ b/documentation-site/docs/docs/text/text-effects.md @@ -103,6 +103,13 @@ merged stroke or glow; see "outline + soft shadow / glow together, at a larger size" example for what a properly bold effect looks like at a size that has room for it. +Bold text spends part of the same budget. A `` glyph (see +[Rich text tags](./rendering-text.md#rich-text-tags)) is drawn by pushing +its edge outwards through the distance field, and its outline and shadow +are measured from that thicker edge, so they wrap the bold ink. What the +bold edge uses is no longer available to the outline and shadow on that +glyph. + ## A note on extreme values and unusual glyphs Multi-channel signed distance fields are a lossy, low-resolution encoding of diff --git a/documentation-site/src/data/demos.ts b/documentation-site/src/data/demos.ts index 170c09a3..dd000f6b 100644 --- a/documentation-site/src/data/demos.ts +++ b/documentation-site/src/data/demos.ts @@ -169,7 +169,7 @@ export const demos: Demo[] = [ slug: 'text', title: 'Text Rendering', description: - 'MSDF text rendering: alignment, line height, live reflow and outline/shadow effects.', + 'MSDF text rendering: alignment, line height, live reflow, rich text tags and outline/shadow effects.', categories: ['rendering'], }, { diff --git a/documentation-site/src/pages/demos/text/_create-game.ts b/documentation-site/src/pages/demos/text/_create-game.ts index 1b881111..27661820 100644 --- a/documentation-site/src/pages/demos/text/_create-game.ts +++ b/documentation-site/src/pages/demos/text/_create-game.ts @@ -19,6 +19,7 @@ import { createEffectsHeroExample } from './_create-effects-hero-example'; import { createHorizontalAlignmentExamples } from './_create-horizontal-alignment-examples'; import { createLineHeightExamples } from './_create-line-height-examples'; import { createLiveMaxWidthExample } from './_create-live-max-width-example'; +import { createRichTextExample } from './_create-rich-text-example'; import { createVerticalAlignmentExamples } from './_create-vertical-alignment-examples'; import { createLiveMaxWidthEcsSystem } from './_live-max-width.system'; import { createPlayground, Playground } from './_create-playground'; @@ -42,7 +43,8 @@ const sectionGap = 16; * Builds the text rendering demo: a showcase of every `horizontalAlign` and * `verticalAlign` value, a comparison of a few `lineHeight` multipliers, a * paragraph whose `maxWidth` oscillates every frame to show - * `createTextShapingEcsSystem` reflowing text live, an interactive + * `createTextShapingEcsSystem` reflowing text live, rich text tags, an + * interactive * playground, and an outline/soft-shadow showcase, all drawn from one * shared `FontAtlas`: the engine's default font, imported through the * package's `fonts/default` exports so webpack serves both files. @@ -135,6 +137,17 @@ export const createTextGame = async ( ); y -= sectionGap; + y = createRichTextExample( + world, + fontAtlas, + whiteSprite, + drawOrder.guide, + drawOrder.content, + { x: left, y }, + usableWidth, + ); + y -= sectionGap; + const playground = createPlayground( world, fontAtlas, diff --git a/documentation-site/src/pages/demos/text/_create-playground.ts b/documentation-site/src/pages/demos/text/_create-playground.ts index d2481db4..d9561caa 100644 --- a/documentation-site/src/pages/demos/text/_create-playground.ts +++ b/documentation-site/src/pages/demos/text/_create-playground.ts @@ -22,7 +22,7 @@ const glowColor = new Color(0.15, 0.65, 1, 1); /** The values the playground's controls start at (see `_PlaygroundControls.tsx`). */ export const playgroundDefaults = { - text: "Type your own text here! This is the engine's shipped default font atlas (Liberation Sans, SIL OFL 1.1) - zero font setup required.", + text: "Type your own text here! This is the engine's shipped default font atlas (Liberation Sans, SIL OFL 1.1) - zero font setup required. Try bold and color tags.", size: 24, minSize: 12, maxSize: 56, diff --git a/documentation-site/src/pages/demos/text/_create-rich-text-example.ts b/documentation-site/src/pages/demos/text/_create-rich-text-example.ts new file mode 100644 index 00000000..31ebd02e --- /dev/null +++ b/documentation-site/src/pages/demos/text/_create-rich-text-example.ts @@ -0,0 +1,107 @@ +import { addPositionComponent } from '@forge-game-engine/forge/common'; +import { EcsWorld } from '@forge-game-engine/forge/ecs'; +import { Vector2 } from '@forge-game-engine/forge/math'; +import { Color, SpriteEcsComponent } from '@forge-game-engine/forge/rendering'; +import { + addTextComponent, + FontAtlas, + shapeText, +} from '@forge-game-engine/forge/text'; +import { createGuideBox } from './_create-guide-box'; + +const paragraph = + "Rich text tags style part of a string: bold words, colored words, and both at once. Tags are stripped before shaping, so this paragraph wraps and kerns exactly as it would without them. Markup that isn't a tag stays literal: HP < 50%."; +const outlinedText = 'Bold and regular, outlined'; +const bodySize = 16; +const outlinedSize = 24; +const captionSize = 13; +const captionColor = new Color(0.55, 0.6, 0.72, 1); +const bodyColor = new Color(0.9, 0.92, 0.96, 1); +const outlineColor = new Color(1, 0.55, 0.15, 1); +const captionGap = 14; +const rowGap = 12; + +/** + * Builds the rich text section: a wrapped paragraph mixing `` and + * `` tags, and a bold word next to a regular one with an outline, to + * show the outline following the thickened bold ink. + * @param world - The ECS world to add label entities to. + * @param fontAtlas - The font atlas every label draws from. + * @param whiteSprite - A plain white sprite template for the guide box. + * @param guideLayer - The draw-order layer for the guide box (drawn behind text). + * @param contentLayer - The draw-order layer for the caption/body text. + * @param topLeft - This section's top-left corner, in world units. + * @param usableWidth - The total width available to lay the example out in. + * @returns The y coordinate immediately below the section's content. + */ +export function createRichTextExample( + world: EcsWorld, + fontAtlas: FontAtlas, + whiteSprite: SpriteEcsComponent, + guideLayer: number, + contentLayer: number, + topLeft: Vector2, + usableWidth: number, +): number { + const captionEntity = world.createEntity(); + addPositionComponent(world, captionEntity, { + local: { x: topLeft.x, y: topLeft.y }, + }); + addTextComponent(world, captionEntity, { + text: 'rich text tags: b and color=#rrggbb', + fontAtlas, + size: captionSize, + color: captionColor, + layer: contentLayer, + }); + + // `shapeText` parses tags too, so the guide box is sized from exactly the + // text that's drawn. + const { bounds: paragraphBounds } = shapeText(paragraph, fontAtlas.data, { + size: bodySize, + maxWidth: usableWidth, + }); + const { bounds: outlinedBounds } = shapeText(outlinedText, fontAtlas.data, { + size: outlinedSize, + }); + + const paragraphTop = topLeft.y - captionGap; + const outlinedTop = paragraphTop - paragraphBounds.height - rowGap; + + createGuideBox( + world, + whiteSprite, + { x: topLeft.x, y: paragraphTop }, + { x: usableWidth, y: paragraphBounds.height }, + guideLayer, + ); + + const paragraphEntity = world.createEntity(); + addPositionComponent(world, paragraphEntity, { + local: { x: topLeft.x, y: paragraphTop }, + }); + addTextComponent(world, paragraphEntity, { + text: paragraph, + fontAtlas, + size: bodySize, + color: bodyColor, + maxWidth: usableWidth, + layer: contentLayer, + }); + + const outlinedEntity = world.createEntity(); + addPositionComponent(world, outlinedEntity, { + local: { x: topLeft.x, y: outlinedTop }, + }); + addTextComponent(world, outlinedEntity, { + text: outlinedText, + fontAtlas, + size: outlinedSize, + color: bodyColor, + outlineColor, + outlineWidth: 1.5, + layer: contentLayer, + }); + + return outlinedTop - outlinedBounds.height; +} diff --git a/documentation-site/src/pages/demos/text/index.tsx b/documentation-site/src/pages/demos/text/index.tsx index c037efe0..f59442b4 100644 --- a/documentation-site/src/pages/demos/text/index.tsx +++ b/documentation-site/src/pages/demos/text/index.tsx @@ -154,10 +154,10 @@ export default function Text(): JSX.Element { metaData={{ title: 'Text Rendering Demo', description: - 'A demo showcasing MSDF text rendering, multi-line layout, alignment, live reflow, an interactive playground, and outline/soft-shadow effects with addTextComponent and createTextShapingEcsSystem.', + 'A demo showcasing MSDF text rendering, multi-line layout, alignment, live reflow, rich text tags, an interactive playground, and outline/soft-shadow effects with addTextComponent and createTextShapingEcsSystem.', }} header="Text Rendering" - blurb="A showcase of MSDF text rendering using the engine's shipped default font atlas (Liberation Sans, SIL OFL 1.1 - zero font setup required): every horizontalAlign value (left/center/right/justify) wrapping the same sentence, every verticalAlign value (top/middle/bottom/baseline/capline) positioned against a shared anchor line, with middle centering the cap-height-to-baseline band, a few lineHeight multipliers compared side by side, a paragraph whose maxWidth oscillates every frame (driving createTextShapingEcsSystem to reflow it live), an interactive playground you can type into using the controls above, and - at the bottom - outline/soft-shadow effects at a conservative, documented-safe size (see the Text Effects guide for why). Every guide box/line is sized from shapeText's own computed bounds, not guessed." + blurb="A showcase of MSDF text rendering using the engine's shipped default font atlas (Liberation Sans, SIL OFL 1.1 - zero font setup required): every horizontalAlign value (left/center/right/justify) wrapping the same sentence, every verticalAlign value (top/middle/bottom/baseline/capline) positioned against a shared anchor line, with middle centering the cap-height-to-baseline band, a few lineHeight multipliers compared side by side, a paragraph whose maxWidth oscillates every frame (driving createTextShapingEcsSystem to reflow it live), a paragraph styled with and rich text tags, an interactive playground you can type into using the controls above, and - at the bottom - outline/soft-shadow effects at a conservative, documented-safe size (see the Text Effects guide for why). Every guide box/line is sized from shapeText's own computed bounds, not guessed." createGame={createGame} interactions={ 128 && g < 100 && b < 100) { + return 'red'; + } + + if (g > 128 && r < 100 && b < 100) { + return 'green'; + } + + return null; +} + +/** + * Builds a minimal scene with one text entity drawn from a synthetic, + * analytically generated MSDF atlas whose only glyph, "A", is a filled + * square. The base text color is red, so a `` tag shows up + * as green ink and `` as a wider square. + * @param container - The element to render the scene's canvas into. + * @returns The scene's handle. + */ +export const createScene: CreateScene = async ( + container: HTMLElement, +): Promise => { + const time = new Time(); + const world = new EcsWorld(); + const canvas = createCanvas(container); + + canvas.width = 700; + canvas.height = 200; + + const renderContext = createRenderContext(canvas, { + preserveDrawingBuffer: true, + }); + + createCamera(world, { + isStatic: true, + clearColor: Color.black, + verticalWorldUnits: canvas.height, + }); + + const glyphImage = await createSyntheticMsdfGlyphImage(); + + const fontAtlas: FontAtlas = { + data: { + formatVersion: CURRENT_FONT_ATLAS_FORMAT_VERSION, + type: 'msdf', + atlasSize: { + width: SYNTHETIC_GLYPH_TILE_SIZE, + height: SYNTHETIC_GLYPH_TILE_SIZE, + }, + distanceRange: SYNTHETIC_GLYPH_DISTANCE_RANGE, + metrics: { + lineHeight: 1, + ascender: 0.5, + descender: 0.5, + capHeight: 0.5, + }, + glyphs: new Map([ + [ + ATLAS_A_CODE_POINT, + { + codePoint: ATLAS_A_CODE_POINT, + advance: 1, + planeBounds: glyphBounds, + atlasBounds: glyphBounds, + }, + ], + ]), + kerning: new Map(), + }, + image: glyphImage, + }; + + const textEntity = world.createEntity(); + + // Three glyphs at 1 em each, centered on the canvas. The synthetic + // glyph's ink is centered half an em above its baseline, so anchoring the + // baseline half an em below y = 0 centers the ink on the scanned row. + addPositionComponent(world, textEntity, { + local: { x: -1.5 * SIZE, y: -0.5 * SIZE }, + }); + + const textComponent = addTextComponent(world, textEntity, { + text: 'AAA', + fontAtlas, + size: SIZE, + color: Color.red, + verticalAlign: 'baseline', + }); + + world.addSystem(createTransformEcsSystem()); + world.addSystem(createTextShapingEcsSystem(renderContext)); + world.addSystem(createRenderEcsSystem(renderContext)); + world.addSystem(createPresentEcsSystem(renderContext)); + + let clockInMilliseconds = 0; + + return { + step(deltaMilliseconds: number = defaultStepDeltaMilliseconds): void { + clockInMilliseconds += deltaMilliseconds; + time.update(clockInMilliseconds); + world.update(); + }, + + setText(text: string): void { + textComponent.text = text; + }, + + boldWidening: 2 * FAUX_BOLD_EMBOLDEN * SIZE, + + measureInkSpans(): InkSpan[] { + const sampleCanvas = document.createElement('canvas'); + + sampleCanvas.width = canvas.width; + sampleCanvas.height = canvas.height; + + const context2d = sampleCanvas.getContext('2d'); + + if (!context2d) { + throw new Error('2D canvas context not available'); + } + + context2d.drawImage(canvas, 0, 0); + + const y = Math.floor(canvas.height / 2); + const { data } = context2d.getImageData(0, y, canvas.width, 1); + const spans: InkSpan[] = []; + let current: InkSpan | null = null; + + for (let x = 0; x < canvas.width; x++) { + const offset = x * 4; + const color = classifyInk( + data[offset], + data[offset + 1], + data[offset + 2], + ); + + if (current && color === current.color) { + current.right = x; + + continue; + } + + if (current) { + spans.push(current); + current = null; + } + + if (color) { + current = { left: x, right: x, color }; + } + } + + if (current) { + spans.push(current); + } + + return spans; + }, + }; +}; diff --git a/e2e/specs/rich-text-tags.spec.ts b/e2e/specs/rich-text-tags.spec.ts new file mode 100644 index 00000000..7640dd49 --- /dev/null +++ b/e2e/specs/rich-text-tags.spec.ts @@ -0,0 +1,103 @@ +import { expect, test } from '@playwright/test'; +import type { + InkSpan, + RichTextTagsSceneHandle, +} from '../fixtures/scenes/rich-text-tags.js'; + +type Hooks = RichTextTagsSceneHandle; + +const width = (span: InkSpan): number => span.right - span.left + 1; + +test.describe('rich text tags', () => { + test.beforeEach(async ({ page }) => { + let pageError: Error | undefined; + + page.once('pageerror', (error) => { + pageError = error; + }); + + await page.goto('/?scene=rich-text-tags'); + + try { + await page.waitForFunction(() => Boolean(window.__forgeTestHooks)); + } catch (timeoutError) { + throw pageError ?? timeoutError; + } + }); + + test('draws a color tag in its color, laid out exactly like the untagged text', async ({ + page, + }) => { + const { untagged, tagged } = await page.evaluate(() => { + const scene = window.__forgeTestHooks as unknown as Hooks; + + scene.setText('AAA'); + scene.step(); + const untaggedSpans = scene.measureInkSpans(); + + scene.setText('AAA'); + scene.step(); + + return { untagged: untaggedSpans, tagged: scene.measureInkSpans() }; + }); + + expect(untagged.map((span) => span.color)).toEqual(['red', 'red', 'red']); + expect(tagged.map((span) => span.color)).toEqual(['red', 'green', 'red']); + + // The tag changes the middle glyph's color, never where any glyph sits. + for (let i = 0; i < 3; i++) { + expect(Math.abs(tagged[i].left - untagged[i].left)).toBeLessThanOrEqual( + 1, + ); + expect(Math.abs(tagged[i].right - untagged[i].right)).toBeLessThanOrEqual( + 1, + ); + } + }); + + test('draws a bold glyph wider and pushes the glyphs after it along', async ({ + page, + }) => { + const { regular, bold, boldWidening } = await page.evaluate(() => { + const scene = window.__forgeTestHooks as unknown as Hooks; + + scene.setText('AAA'); + scene.step(); + const regularSpans = scene.measureInkSpans(); + + scene.setText('AAA'); + scene.step(); + + return { + regular: regularSpans, + bold: scene.measureInkSpans(), + boldWidening: scene.boldWidening, + }; + }); + + expect(regular).toHaveLength(3); + expect(bold).toHaveLength(3); + + // Pixel quantization and anti-aliased edges allow about a pixel either + // way; the widening itself is several pixels. + const tolerance = 1.5; + + expect(boldWidening).toBeGreaterThan(3); + expect(width(bold[0]) - width(regular[0])).toBeGreaterThan( + boldWidening - tolerance, + ); + expect(width(bold[0]) - width(regular[0])).toBeLessThan( + boldWidening + tolerance, + ); + + // The bold glyph's advance grew by the same widening, so the regular + // glyphs after it keep their width and move right by it. + expect(Math.abs(width(bold[1]) - width(regular[1]))).toBeLessThanOrEqual(1); + expect(bold[1].left - regular[1].left).toBeGreaterThan( + boldWidening - tolerance, + ); + expect(bold[1].left - regular[1].left).toBeLessThan( + boldWidening + tolerance, + ); + }); +}); diff --git a/src/rendering/renderable.ts b/src/rendering/renderable.ts index 08b9b123..7658206b 100644 --- a/src/rendering/renderable.ts +++ b/src/rendering/renderable.ts @@ -70,6 +70,13 @@ export interface InstanceComponents { * sprites. */ textEffects?: TextEffectsInstanceData; + + /** + * The glyph's faux-bold edge shift (`GlyphQuad.embolden`), if this + * instance is a glyph quad pushed by `pushTextRenderCommands`. + * `undefined` for ordinary sprites. + */ + textEmbolden?: number; } /** diff --git a/src/rendering/systems/render-system.test.ts b/src/rendering/systems/render-system.test.ts index 5c993f62..ca064404 100644 --- a/src/rendering/systems/render-system.test.ts +++ b/src/rendering/systems/render-system.test.ts @@ -816,12 +816,14 @@ describe('createRenderEcsSystem', () => { size: { x: 1, y: 1 }, uvOffset: { x: 0, y: 0 }, uvScale: { x: 0.1, y: 0.1 }, + embolden: 0, }, { offset: { x: 1, y: 0 }, size: { x: 1, y: 1 }, uvOffset: { x: 0.1, y: 0 }, uvScale: { x: 0.1, y: 0.1 }, + embolden: 0, }, ], }); @@ -858,6 +860,7 @@ describe('createRenderEcsSystem', () => { size: { x: 1, y: 1 }, uvOffset: Vec2.zero, uvScale: Vec2.one, + embolden: 0, }, ], }); @@ -1062,6 +1065,7 @@ describe('createRenderEcsSystem', () => { size: { x: 1, y: 1 }, uvOffset: Vec2.zero, uvScale: Vec2.one, + embolden: 0, }); addTextEntity(renderable, 0, { @@ -1092,6 +1096,7 @@ describe('createRenderEcsSystem', () => { size: { x: 1, y: 1 }, uvOffset: Vec2.zero, uvScale: Vec2.one, + embolden: 0, }, ], }, diff --git a/src/rendering/utilities/create-shader-cache.ts b/src/rendering/utilities/create-shader-cache.ts index 4f82128c..c6f460c1 100644 --- a/src/rendering/utilities/create-shader-cache.ts +++ b/src/rendering/utilities/create-shader-cache.ts @@ -35,6 +35,7 @@ import { import { msdfEffectsFragmentShader, msdfFillFragmentShader, + msdfFillVertexShader, msdfVertexShader, } from '../../text/rendering/shaders/index.js'; @@ -85,6 +86,7 @@ export function createShaderCache(): ShaderCache { .addShader(new ForgeShaderSource(toneMappingFragmentShader)) .addShader(new ForgeShaderSource(terrainVertexShader)) .addShader(new ForgeShaderSource(terrainFragmentShader)) + .addShader(new ForgeShaderSource(msdfFillVertexShader)) .addShader(new ForgeShaderSource(msdfFillFragmentShader)) .addShader(new ForgeShaderSource(msdfEffectsFragmentShader)) .addShader(new ForgeShaderSource(msdfVertexShader)); diff --git a/src/text/components/text-mesh-component.ts b/src/text/components/text-mesh-component.ts index 749f8f19..ffd83432 100644 --- a/src/text/components/text-mesh-component.ts +++ b/src/text/components/text-mesh-component.ts @@ -1,5 +1,6 @@ import { createComponentId } from '../../ecs/ecs-component.js'; import type { Vector2 } from '../../math/index.js'; +import type { Color } from '../../rendering/color.js'; import type { Renderable } from '../../rendering/renderable.js'; /** @@ -23,6 +24,21 @@ export interface GlyphQuad { /** The width/height of this glyph's texture rect in the atlas, 0 to 1. */ uvScale: Vector2; + + /** + * This glyph's fill color from a `` rich text tag, replacing + * `TextEcsComponent.color` (alpha included). `undefined` outside a + * `` tag, where the glyph draws in `TextEcsComponent.color`. + */ + color?: Color; + + /** + * How far this glyph's ink is thickened by a `` rich text tag (faux + * bold), as a shift of the distance field's edge threshold in the + * field's own units (`0.5` is the field's full encoded range). `0` for + * regular-weight glyphs. + */ + embolden: number; } /** diff --git a/src/text/rendering/create-text-renderable.test.ts b/src/text/rendering/create-text-renderable.test.ts index a745f2a0..82ccbfe7 100644 --- a/src/text/rendering/create-text-renderable.test.ts +++ b/src/text/rendering/create-text-renderable.test.ts @@ -6,13 +6,13 @@ import { RenderContext, ShaderCache, spriteFragmentShader, - spriteVertexShader, } from '../../rendering/index.js'; import type { FontAtlas } from '../font-atlas/font-atlas.js'; import { createTextRenderable } from './create-text-renderable.js'; import { msdfEffectsFragmentShader, msdfFillFragmentShader, + msdfFillVertexShader, msdfVertexShader, } from './shaders/index.js'; @@ -136,9 +136,9 @@ describe('createTextRenderable', () => { vi.spyOn(canvas, 'getContext').mockReturnValue(mockGl); const shaderCache = new ShaderCache([]) - .addShader(new ForgeShaderSource(spriteVertexShader)) .addShader(new ForgeShaderSource(spriteFragmentShader)) .addShader(new ForgeShaderSource(msdfVertexShader)) + .addShader(new ForgeShaderSource(msdfFillVertexShader)) .addShader(new ForgeShaderSource(msdfFillFragmentShader)) .addShader(new ForgeShaderSource(msdfEffectsFragmentShader)); @@ -189,7 +189,7 @@ describe('createTextRenderable', () => { expect(calls[1][1]).toBe(512); }); - it('assigns the plain sprite instance data layout to fillRenderable and the combined sprite + text-effects layout to effectsRenderable', () => { + it('assigns the sprite + embolden instance data layout to fillRenderable and the sprite + embolden + text-effects layout to effectsRenderable', () => { const { fillRenderable, effectsRenderable } = createTextRenderable( renderContext, fontAtlas, @@ -197,13 +197,13 @@ describe('createTextRenderable', () => { ); // Sprite: position(2) + rotation(1) + scale(2) + size(2) + pivot(2) + - // texOffset(2) + texSize(2) + tint(4) = 17. - expect(fillRenderable.floatsPerInstance).toBe(17); + // texOffset(2) + texSize(2) + tint(4) = 17, plus embolden(1) = 18. + expect(fillRenderable.floatsPerInstance).toBe(18); - // Sprite (17) + text effects: outlineColor(4) + outlineWidth(1) + - // shadowColor(4) + shadowOffset(2) + shadowSoftness(1) = 12, for a - // total of 29. - expect(effectsRenderable.floatsPerInstance).toBe(29); + // Sprite + embolden (18) + text effects: outlineColor(4) + + // outlineWidth(1) + shadowColor(4) + shadowOffset(2) + + // shadowSoftness(1) = 12, for a total of 30. + expect(effectsRenderable.floatsPerInstance).toBe(30); }); it('shares a single GPU texture between both renderables', () => { diff --git a/src/text/rendering/create-text-renderable.ts b/src/text/rendering/create-text-renderable.ts index 7fdf6c80..09b54dfa 100644 --- a/src/text/rendering/create-text-renderable.ts +++ b/src/text/rendering/create-text-renderable.ts @@ -9,6 +9,7 @@ import { } from '../../rendering/index.js'; import type { FontAtlas } from '../font-atlas/font-atlas.js'; import { textEffectsInstanceDataSegment } from './text-effects-instance-data-segment.js'; +import { textEmboldenInstanceDataSegment } from './text-embolden-instance-data-segment.js'; /** * The default rendering category a text entity's glyphs are drawn with when @@ -29,7 +30,8 @@ export const TEXT_RENDER_CATEGORY = 1; export interface TextRenderables { /** * Draws only a glyph's own anti-aliased ink (`msdf-fill.frag`), using the - * plain sprite vertex layout - no outline/shadow instance data. Always + * sprite vertex layout plus the glyph's faux-bold embolden - no + * outline/shadow instance data. Always * drawn *after* `effectsRenderable` for the same glyphs (see * `pushTextRenderCommands` in `glyph-quad.ts`), so a glyph's fill can * never be painted over by a neighboring glyph's outline/shadow. @@ -88,7 +90,7 @@ export function createTextRenderable( const atlasTexture = createTextureFromImage(gl, fontAtlas.image); const fillMaterial = new Material( - shaderCache.getShader('sprite.vert'), + shaderCache.getShader('msdf-fill.vert'), shaderCache.getShader('msdf-fill.frag'), gl, ); @@ -101,7 +103,10 @@ export function createTextRenderable( floatsPerInstance: fillFloatsPerInstance, bindInstanceData: fillBindInstanceData, setupInstanceAttributes: fillSetupInstanceAttributes, - } = combineInstanceDataSegments(spriteInstanceDataSegment); + } = combineInstanceDataSegments( + spriteInstanceDataSegment, + textEmboldenInstanceDataSegment, + ); const fillRenderable = new Renderable( createQuadGeometry(gl), @@ -112,9 +117,9 @@ export function createTextRenderable( fillSetupInstanceAttributes, ); - // `msdf.vert` extends `sprite.vert`'s positioning/pivot/rotation/ - // projection math verbatim, adding only the per-instance forwarding - // outline/shadow effects need (see `msdf.vert.glsl`). + // `msdf.vert` extends `msdf-fill.vert` verbatim, adding only the + // per-instance forwarding outline/shadow effects need (see + // `msdf.vert.glsl`). const effectsMaterial = new Material( shaderCache.getShader('msdf.vert'), shaderCache.getShader('msdf-effects.frag'), @@ -131,6 +136,7 @@ export function createTextRenderable( setupInstanceAttributes: effectsSetupInstanceAttributes, } = combineInstanceDataSegments( spriteInstanceDataSegment, + textEmboldenInstanceDataSegment, textEffectsInstanceDataSegment, ); diff --git a/src/text/rendering/glyph-quad.test.ts b/src/text/rendering/glyph-quad.test.ts index 583b11ad..26a57594 100644 --- a/src/text/rendering/glyph-quad.test.ts +++ b/src/text/rendering/glyph-quad.test.ts @@ -62,6 +62,7 @@ const glyph: GlyphQuad = { size: { x: 5, y: 7 }, uvOffset: { x: 0.1, y: 0.2 }, uvScale: { x: 0.3, y: 0.4 }, + embolden: 0, }; describe('pushTextRenderCommands', () => { @@ -127,6 +128,40 @@ describe('pushTextRenderCommands', () => { }); }); + it("tints a glyph with its own rich text color in place of the text's color", () => { + const commands: RenderCommand[] = []; + const glyphColor = new Color(0, 1, 0, 0.5); + + pushTextRenderCommands( + commands, + buildTextComponent({ color: Color.white }), + buildTextMesh([{ ...glyph, color: glyphColor }, glyph]), + { local: { x: 0, y: 0 }, world: { x: 0, y: 0 } }, + null, + null, + ); + + expect(commands[0].components.sprite.tintColor).toBe(glyphColor); + expect(commands[1].components.sprite.tintColor).toBe(Color.white); + }); + + it("passes each glyph's embolden to both its effects and fill commands", () => { + const commands: RenderCommand[] = []; + + pushTextRenderCommands( + commands, + buildTextComponent({ outlineWidth: 1 }), + buildTextMesh([{ ...glyph, embolden: 0.1 }, glyph]), + { local: { x: 0, y: 0 }, world: { x: 0, y: 0 } }, + null, + null, + ); + + expect(commands.map((command) => command.components.textEmbolden)).toEqual([ + 0.1, 0, 0.1, 0, + ]); + }); + it("builds textEffects from the text component's outline/shadow fields", () => { const commands: RenderCommand[] = []; const outlineColor = new Color(0, 1, 0, 1); diff --git a/src/text/rendering/glyph-quad.ts b/src/text/rendering/glyph-quad.ts index a415b4d9..60d03d82 100644 --- a/src/text/rendering/glyph-quad.ts +++ b/src/text/rendering/glyph-quad.ts @@ -111,6 +111,7 @@ function pushTextEffectsRenderCommands( sprite: glyphSprite, flip: null, textEffects, + textEmbolden: glyph.embolden, }, }); } @@ -122,7 +123,7 @@ function pushTextEffectsRenderCommands( * entity (see `pushTextRenderCommands`) so a glyph's fill can never be * painted over by a neighboring glyph's outline/shadow. * @param commands - The render command buffer to push into. - * @param textComponent - The entity's `TextEcsComponent` (for `layer` and `color`). + * @param textComponent - The entity's `TextEcsComponent` (for `layer`, and `color` for glyphs outside a `` tag). * @param textMesh - The entity's shaped glyph quads to push commands for. * @param entityPosition - The entity's position; each glyph is offset from it. * @param rotationComponent - The entity's rotation, if it has one. @@ -147,7 +148,7 @@ function pushTextFillRenderCommands( pivot: { x: 0.5, y: 0.5 }, uvOffset: glyph.uvOffset, uvScale: glyph.uvScale, - tintColor: color, + tintColor: glyph.color ?? color, opacityMultiplier: textComponent.opacityMultiplier, renderable: fillRenderable, enabled: true, @@ -164,6 +165,7 @@ function pushTextFillRenderCommands( scale: scaleComponent, sprite: glyphSprite, flip: null, + textEmbolden: glyph.embolden, }, }); } diff --git a/src/text/rendering/index.ts b/src/text/rendering/index.ts index 8f25ccc6..bf17cfdd 100644 --- a/src/text/rendering/index.ts +++ b/src/text/rendering/index.ts @@ -1,3 +1,4 @@ export * from './create-text-renderable.js'; export * from './glyph-quad.js'; export * from './text-effects-instance-data-segment.js'; +export * from './text-embolden-instance-data-segment.js'; diff --git a/src/text/rendering/shaders/index.ts b/src/text/rendering/shaders/index.ts index 901017f3..51628ace 100644 --- a/src/text/rendering/shaders/index.ts +++ b/src/text/rendering/shaders/index.ts @@ -1,7 +1,9 @@ import msdfEffectsFragmentShaderSource from './msdf-effects.frag.glsl?raw'; import msdfFillFragmentShaderSource from './msdf-fill.frag.glsl?raw'; +import msdfFillVertexShaderSource from './msdf-fill.vert.glsl?raw'; import msdfVertexShaderSource from './msdf.vert.glsl?raw'; export const msdfFillFragmentShader = msdfFillFragmentShaderSource; +export const msdfFillVertexShader = msdfFillVertexShaderSource; export const msdfEffectsFragmentShader = msdfEffectsFragmentShaderSource; export const msdfVertexShader = msdfVertexShaderSource; diff --git a/src/text/rendering/shaders/msdf-effects.frag.glsl b/src/text/rendering/shaders/msdf-effects.frag.glsl index 28ded1de..66cfc7e7 100644 --- a/src/text/rendering/shaders/msdf-effects.frag.glsl +++ b/src/text/rendering/shaders/msdf-effects.frag.glsl @@ -9,6 +9,7 @@ uniform float u_distanceRange; // FontAtlasData.distanceRange uniform float u_atlasSize; // FontAtlasData.atlasSize.height (assumes square texels) in vec2 v_texCoord; +in float v_embolden; in vec4 v_outlineColor; in float v_outlineWidth; in vec4 v_shadowColor; @@ -78,7 +79,6 @@ void main() { vec2 uvPerScreenPx = fwidth(v_texCoord); vec2 screenTexSize = vec2(1.0) / uvPerScreenPx; float screenPxRange = max(0.5 * dot(unitRange, screenTexSize), 1.0); - float screenPxDistance = signedDistance * screenPxRange; // The atlas's own encoded budget: the distance field only carries graded // (non-saturated) data up to roughly `screenPxRange / 2` screen pixels @@ -90,7 +90,15 @@ void main() { // doesn't need anything more than this either. float atlasSafeDistance = max(screenPxRange * 0.5 - 0.5, 0.0); - float clampedOutlineWidth = min(v_outlineWidth, atlasSafeDistance); + // A faux-bold (``) glyph's edge sits `emboldenPx` further out, clamped + // exactly as `msdf-fill.frag` clamps it. Both effects are measured from + // that bold edge, so the outline wraps the thickened ink, and the bold + // shift spends part of the atlas budget the effects have left. + float emboldenPx = min(v_embolden * screenPxRange, atlasSafeDistance); + float screenPxDistance = signedDistance * screenPxRange + emboldenPx; + float effectsSafeDistance = atlasSafeDistance - emboldenPx; + + float clampedOutlineWidth = min(v_outlineWidth, effectsSafeDistance); float outlineCoverage = v_outlineWidth > 0.0 ? clamp(screenPxDistance + 0.5 + clampedOutlineWidth, 0.0, 1.0) : 0.0; @@ -103,8 +111,8 @@ void main() { vec2 shadowUv = v_texCoord - clampedShadowOffset * uvPerScreenPx; vec3 shadowMsdf = texture(u_atlas, shadowUv).rgb; float shadowSignedDistance = median(shadowMsdf.r, shadowMsdf.g, shadowMsdf.b) - 0.5; - float shadowScreenPxDistance = shadowSignedDistance * screenPxRange; - float shadowReach = max(min(v_shadowSoftness, atlasSafeDistance), 0.001); + float shadowScreenPxDistance = shadowSignedDistance * screenPxRange + emboldenPx; + float shadowReach = max(min(v_shadowSoftness, effectsSafeDistance), 0.001); float shadowCoverage = clamp(1.0 - (-shadowScreenPxDistance) / shadowReach, 0.0, 1.0); vec4 shadowLayer = vec4(v_shadowColor.rgb, shadowCoverage * v_shadowColor.a); diff --git a/src/text/rendering/shaders/msdf-fill.frag.glsl b/src/text/rendering/shaders/msdf-fill.frag.glsl index 709209e9..90c8d176 100644 --- a/src/text/rendering/shaders/msdf-fill.frag.glsl +++ b/src/text/rendering/shaders/msdf-fill.frag.glsl @@ -10,6 +10,7 @@ uniform float u_atlasSize; // FontAtlasData.atlasSize.height (assumes squa in vec2 v_texCoord; in vec4 v_tint; +in float v_embolden; out vec4 fragColor; float median(float r, float g, float b) { @@ -19,11 +20,11 @@ float median(float r, float g, float b) { // Draws only a glyph's own anti-aliased ink - the same computation // `msdf-effects.frag`'s `fillLayer` used to do as one layer of a combined // shader, now its own draw pass. Kept in its own shader (rather than a -// uniform "mode" switch on one shared program) so it can reuse the plain -// `sprite.vert` and the base `spriteInstanceDataSegment` verbatim - a fill -// quad needs none of the outline/shadow per-instance data `msdf.vert` -// forwards, so glyphs with no effects at all (the common case) never pay -// for it. +// uniform "mode" switch on one shared program) so its vertex shader +// (`msdf-fill.vert`) only forwards the sprite data plus the faux-bold +// embolden - a fill quad needs none of the outline/shadow per-instance data +// `msdf.vert` forwards, so glyphs with no effects at all (the common case) +// never pay for it. // // This pass is always drawn *after* every glyph's outline/shadow ("effects" // pass, see `msdf-effects.frag`) for the same text entity - see @@ -43,6 +44,14 @@ void main() { float screenPxRange = max(0.5 * dot(unitRange, screenTexSize), 1.0); float screenPxDistance = signedDistance * screenPxRange; - float glyphAlpha = clamp(screenPxDistance + 0.5, 0.0, 1.0); + // Faux bold (``) pushes the glyph's edge outwards by `v_embolden` + // distance-field units. The field only carries graded data up to about + // `screenPxRange / 2` screen pixels past the edge, so the shift is + // clamped to that budget - the same clamp `msdf-effects.frag` applies, so + // both passes agree on where the bold edge is. + float atlasSafeDistance = max(screenPxRange * 0.5 - 0.5, 0.0); + float emboldenPx = min(v_embolden * screenPxRange, atlasSafeDistance); + + float glyphAlpha = clamp(screenPxDistance + emboldenPx + 0.5, 0.0, 1.0); fragColor = vec4(v_tint.rgb, v_tint.a * glyphAlpha); } diff --git a/src/text/rendering/shaders/msdf-fill.vert.glsl b/src/text/rendering/shaders/msdf-fill.vert.glsl new file mode 100644 index 00000000..82948044 --- /dev/null +++ b/src/text/rendering/shaders/msdf-fill.vert.glsl @@ -0,0 +1,57 @@ +#version 300 es + +#pragma forge name(msdf-fill.vert) + +in vec2 a_position; // Vertex position (e.g., quad corners) +in vec2 a_texCoord; // Texture coordinate + +// Per-instance attributes (base sprite segment, see `sprite.vert.glsl`): +in vec2 a_instancePos; // Glyph position +in float a_instanceRot; // Glyph rotation (radians) +in vec2 a_instanceScale; // Glyph scale +in vec2 a_instanceSize; // Glyph width/height, in world units +in vec2 a_instancePivot; // Glyph pivot (origin offset) +in vec2 a_instanceTexOffset; // Texture region offset (UV) +in vec2 a_instanceTexSize; // Texture region size (UV) +in vec4 a_instanceTint; // Fill color + +// Per-instance attribute (text embolden segment, see +// `textEmboldenInstanceDataSegment`): the faux-bold edge shift, in +// distance-field units. +in float a_instanceEmbolden; + +// Uniforms for projection/camera: +uniform mat3 u_projection; // 2D projection/camera matrix + +out vec2 v_texCoord; +out vec4 v_tint; +out float v_embolden; + +void main() { + // Identical to `sprite.vert` - glyph quads are positioned, pivoted, + // rotated, and projected exactly like a sprite region already is (see + // `sprite.vert.glsl` for the derivation of each step below). + vec2 normalizedPivot = vec2( + (a_instancePivot.x - 0.5) * 2.0, + -(a_instancePivot.y - 0.5) * 2.0 + ); + + vec2 pivoted = a_position - normalizedPivot; + vec2 scaled = pivoted * a_instanceSize * a_instanceScale * 0.5; + + float c = cos(a_instanceRot); + float s = sin(a_instanceRot); + vec2 rotated = vec2( + c * scaled.x - s * scaled.y, + s * scaled.x + c * scaled.y + ); + + vec2 world = rotated + a_instancePos; + + vec3 projected = u_projection * vec3(world, 1.0); + + gl_Position = vec4(projected.xy, 0.0, 1.0); + v_texCoord = a_instanceTexOffset + a_texCoord * a_instanceTexSize; + v_tint = a_instanceTint; + v_embolden = a_instanceEmbolden; +} diff --git a/src/text/rendering/shaders/msdf.vert.glsl b/src/text/rendering/shaders/msdf.vert.glsl index 8bd496d5..5e7c2bae 100644 --- a/src/text/rendering/shaders/msdf.vert.glsl +++ b/src/text/rendering/shaders/msdf.vert.glsl @@ -15,6 +15,11 @@ in vec2 a_instanceTexOffset; // Texture region offset (UV) in vec2 a_instanceTexSize; // Texture region size (UV) in vec4 a_instanceTint; // tint color +// Per-instance attribute (text embolden segment, see +// `textEmboldenInstanceDataSegment`): the faux-bold edge shift, in +// distance-field units. +in float a_instanceEmbolden; + // Per-instance attributes (text effects segment, see // `textEffectsInstanceDataSegment`): in vec4 a_instanceOutlineColor; @@ -28,6 +33,7 @@ uniform mat3 u_projection; // 2D projection/camera matrix out vec2 v_texCoord; out vec4 v_tint; +out float v_embolden; out vec4 v_outlineColor; out float v_outlineWidth; out vec4 v_shadowColor; @@ -60,6 +66,7 @@ void main() { gl_Position = vec4(projected.xy, 0.0, 1.0); v_texCoord = a_instanceTexOffset + a_texCoord * a_instanceTexSize; v_tint = a_instanceTint; + v_embolden = a_instanceEmbolden; // Passed through unchanged - every vertex of a glyph's quad shares the // same per-instance effect parameters, so no per-vertex computation is diff --git a/src/text/rendering/text-embolden-instance-data-segment.test.ts b/src/text/rendering/text-embolden-instance-data-segment.test.ts new file mode 100644 index 00000000..00d94e91 --- /dev/null +++ b/src/text/rendering/text-embolden-instance-data-segment.test.ts @@ -0,0 +1,61 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { describe, expect, it, vi } from 'vitest'; +import type { InstanceComponents, Renderable } from '../../rendering/index.js'; +import { textEmboldenInstanceDataSegment } from './text-embolden-instance-data-segment.js'; + +describe('textEmboldenInstanceDataSegment', () => { + it('writes the embolden at the segment offset', () => { + const buffer = new Float32Array(4); + + textEmboldenInstanceDataSegment.bindInstanceData( + { textEmbolden: 0.25 } as InstanceComponents, + buffer, + 2, + ); + + expect(buffer[2]).toBe(0.25); + }); + + it('throws when the instance has no embolden', () => { + expect(() => + textEmboldenInstanceDataSegment.bindInstanceData( + {} as InstanceComponents, + new Float32Array(1), + 0, + ), + ).toThrow(/textEmbolden/); + }); + + it('points a_instanceEmbolden at the segment offset within the stride', () => { + const gl = { + FLOAT: 0x1406, + getAttribLocation: vi.fn().mockReturnValue(7), + enableVertexAttribArray: vi.fn(), + vertexAttribPointer: vi.fn(), + vertexAttribDivisor: vi.fn(), + }; + const renderable = { + material: { program: {} }, + floatsPerInstance: 18, + } as unknown as Renderable; + + textEmboldenInstanceDataSegment.setupInstanceAttributes( + gl as unknown as WebGL2RenderingContext, + renderable, + 17, + ); + + expect(gl.getAttribLocation).toHaveBeenCalledWith( + renderable.material.program, + 'a_instanceEmbolden', + ); + expect(gl.vertexAttribPointer).toHaveBeenCalledWith( + 7, + 1, + gl.FLOAT, + false, + 18 * 4, + 17 * 4, + ); + }); +}); diff --git a/src/text/rendering/text-embolden-instance-data-segment.ts b/src/text/rendering/text-embolden-instance-data-segment.ts new file mode 100644 index 00000000..22b21c52 --- /dev/null +++ b/src/text/rendering/text-embolden-instance-data-segment.ts @@ -0,0 +1,59 @@ +import type { + InstanceComponents, + InstanceDataSegment, + Renderable, +} from '../../rendering/index.js'; +import { setupInstanceAttribute } from '../../rendering/index.js'; + +/** The number of floats occupied by a glyph's faux-bold edge shift. */ +export const TEXT_EMBOLDEN_INSTANCE_DATA_FLOATS_PER_INSTANCE = 1; + +function bindTextEmboldenInstanceData( + components: InstanceComponents, + instanceDataBufferArray: Float32Array, + offset: number, +): void { + const { textEmbolden } = components; + + if (textEmbolden === undefined) { + throw new Error( + 'textEmboldenInstanceDataSegment requires InstanceComponents.textEmbolden to be set - only text glyph instances (pushTextRenderCommands) should be bound through a Renderable using this segment.', + ); + } + + instanceDataBufferArray[offset] = textEmbolden; +} + +function setupTextEmboldenInstanceAttributes( + gl: WebGL2RenderingContext, + renderable: Renderable, + offset: number, +): void { + const { program } = renderable.material; + const stride = renderable.floatsPerInstance * 4; + + setupInstanceAttribute( + gl.getAttribLocation(program, 'a_instanceEmbolden'), + gl, + 1, + stride, + offset * 4, + ); +} + +/** + * The instance data segment for a glyph's faux-bold edge shift + * (`GlyphQuad.embolden`, set by a `` rich text tag), read by both MSDF + * vertex shaders (`msdf-fill.vert` and `msdf.vert`) as + * `a_instanceEmbolden`. + * + * Binds `InstanceComponents.textEmbolden`, set by `pushTextRenderCommands` + * for every glyph instance. Per instance rather than a material uniform, so + * bold and regular glyphs of the same `FontAtlas` still batch into one draw + * call. + */ +export const textEmboldenInstanceDataSegment: InstanceDataSegment = { + floatsPerInstance: TEXT_EMBOLDEN_INSTANCE_DATA_FLOATS_PER_INSTANCE, + bindInstanceData: bindTextEmboldenInstanceData, + setupInstanceAttributes: setupTextEmboldenInstanceAttributes, +}; diff --git a/src/text/systems/text-shaping-system.test.ts b/src/text/systems/text-shaping-system.test.ts index 54d686b8..35b0738b 100644 --- a/src/text/systems/text-shaping-system.test.ts +++ b/src/text/systems/text-shaping-system.test.ts @@ -7,7 +7,6 @@ import { RenderContext, ShaderCache, spriteFragmentShader, - spriteVertexShader, } from '../../rendering/index.js'; import { addTextComponent } from '../components/text-component.js'; import { @@ -18,6 +17,7 @@ import type { FontAtlas } from '../font-atlas/font-atlas.js'; import { msdfEffectsFragmentShader, msdfFillFragmentShader, + msdfFillVertexShader, msdfVertexShader, } from '../rendering/shaders/index.js'; import { createTextShapingEcsSystem } from './text-shaping-system.js'; @@ -139,9 +139,9 @@ describe('createTextShapingEcsSystem', () => { vi.spyOn(canvas, 'getContext').mockReturnValue(mockGl); const shaderCache = new ShaderCache([]) - .addShader(new ForgeShaderSource(spriteVertexShader)) .addShader(new ForgeShaderSource(spriteFragmentShader)) .addShader(new ForgeShaderSource(msdfVertexShader)) + .addShader(new ForgeShaderSource(msdfFillVertexShader)) .addShader(new ForgeShaderSource(msdfFillFragmentShader)) .addShader(new ForgeShaderSource(msdfEffectsFragmentShader)); diff --git a/src/text/utilities/index.ts b/src/text/utilities/index.ts index 5807b5a7..2acc6ebf 100644 --- a/src/text/utilities/index.ts +++ b/src/text/utilities/index.ts @@ -1 +1,2 @@ export * from './shape-text.js'; +export * from './parse-rich-text.js'; diff --git a/src/text/utilities/parse-rich-text.test.ts b/src/text/utilities/parse-rich-text.test.ts new file mode 100644 index 00000000..af4a4aa9 --- /dev/null +++ b/src/text/utilities/parse-rich-text.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import { Color } from '../../rendering/color.js'; +import { parseRichText } from './parse-rich-text.js'; + +describe('parseRichText', () => { + it('returns plain text unchanged with no runs', () => { + expect(parseRichText('plain text')).toEqual({ + text: 'plain text', + colorRuns: [], + boldRuns: [], + }); + }); + + it('strips a color tag into a run over the stripped text', () => { + const { text, colorRuns } = parseRichText('a red b'); + + expect(text).toBe('a red b'); + expect(colorRuns).toEqual([ + { start: 2, end: 5, color: new Color(1, 0, 0, 1) }, + ]); + }); + + it('strips a bold tag into a run', () => { + const { text, boldRuns } = parseRichText('bold regular'); + + expect(text).toBe('bold regular'); + expect(boldRuns).toEqual([{ start: 0, end: 4 }]); + }); + + it('keeps sequential color runs separate', () => { + const { text, colorRuns } = parseRichText( + 'red and green', + ); + + expect(text).toBe('red and green'); + expect(colorRuns).toEqual([ + { start: 0, end: 3, color: new Color(1, 0, 0, 1) }, + { start: 8, end: 13, color: new Color(0, 1, 0, 1) }, + ]); + }); + + it('flattens nested colors, the innermost winning', () => { + const { text, colorRuns } = parseRichText( + 'abc', + ); + + expect(text).toBe('abc'); + expect(colorRuns).toEqual([ + { start: 0, end: 1, color: new Color(1, 0, 0, 1) }, + { start: 1, end: 2, color: new Color(0, 0, 1, 1) }, + { start: 2, end: 3, color: new Color(1, 0, 0, 1) }, + ]); + }); + + it('merges nested bold tags into one run', () => { + expect(parseRichText('abc').boldRuns).toEqual([ + { start: 0, end: 3 }, + ]); + }); + + it('applies bold and color independently over overlapping ranges', () => { + const { text, colorRuns, boldRuns } = parseRichText( + 'abc', + ); + + expect(text).toBe('abc'); + expect(boldRuns).toEqual([{ start: 0, end: 2 }]); + expect(colorRuns).toEqual([ + { start: 1, end: 3, color: new Color(0, 1, 0, 1) }, + ]); + }); + + it.each([ + ['#f00', new Color(1, 0, 0, 1)], + ['#f008', new Color(1, 0, 0, 0x88 / 255)], + ['#00FF00', new Color(0, 1, 0, 1)], + ['#0000ff80', new Color(0, 0, 1, 0x80 / 255)], + ])('parses the color value %s, alpha included', (value, expected) => { + expect(parseRichText(`x`).colorRuns).toEqual([ + { start: 0, end: 1, color: expected }, + ]); + }); + + it('runs an unclosed tag to the end of the string', () => { + const { text, colorRuns, boldRuns } = parseRichText( + 'a b c', + ); + + expect(text).toBe('a b c'); + expect(boldRuns).toEqual([{ start: 2, end: 5 }]); + expect(colorRuns).toEqual([ + { start: 4, end: 5, color: new Color(1, 0, 0, 1) }, + ]); + }); + + it.each(['HP < 50%', 'a<3', 'x ', '<>', '', 'tail <'])( + 'keeps "%s" as literal text', + (markup) => { + expect(parseRichText(markup)).toEqual({ + text: markup, + colorRuns: [], + boldRuns: [], + }); + }, + ); + + it.each([ + ['an unknown tag', 'x'], + ['an uppercase tag name', 'x'], + ['a color without a value', 'x'], + ['an invalid color value', 'x'], + ['a malformed hex color', 'x'], + ['bold with a value', 'x'], + ['a closing tag with nothing open', 'x'], + ])('keeps %s as literal text', (_, markup) => { + expect(parseRichText(markup)).toEqual({ + text: markup, + colorRuns: [], + boldRuns: [], + }); + }); + + it('keeps a stray closing tag literal without closing other tags', () => { + const { text, boldRuns } = parseRichText('ab'); + + expect(text).toBe('ab'); + expect(boldRuns).toEqual([{ start: 0, end: 10 }]); + }); + + it('indexes runs by UTF-16 code unit', () => { + const { text, colorRuns } = parseRichText('😀a'); + + expect(text).toBe('😀a'); + expect(colorRuns).toEqual([ + { start: 2, end: 3, color: new Color(1, 0, 0, 1) }, + ]); + }); +}); diff --git a/src/text/utilities/parse-rich-text.ts b/src/text/utilities/parse-rich-text.ts new file mode 100644 index 00000000..b8952156 --- /dev/null +++ b/src/text/utilities/parse-rich-text.ts @@ -0,0 +1,243 @@ +import { Color } from '../../rendering/color.js'; + +/** + * A range of characters in {@link RichText.text}, as UTF-16 code unit + * indices: `start` is inclusive and `end` is exclusive. + */ +export interface TextRun { + /** Index of the run's first character. */ + start: number; + + /** Index one past the run's last character. */ + end: number; +} + +/** A range of characters drawn in a `` tag's color. */ +export interface ColorTextRun extends TextRun { + /** The fill color, replacing `TextEcsComponent.color` (alpha included). */ + color: Color; +} + +/** + * A markup string split into its plain text and the style runs its tags + * apply. Each run list is sorted and non-overlapping: nested tags of the + * same kind are flattened, the innermost one winning. + */ +export interface RichText { + /** The string with every tag removed. This is the text that's shaped. */ + text: string; + + /** The ranges of `text` inside a `` tag. */ + colorRuns: ColorTextRun[]; + + /** The ranges of `text` inside a `` tag. */ + boldRuns: TextRun[]; +} + +/** + * Matches one complete tag starting at the current position: ``, + * `` or ``. Anything else starting with `<` (a lone `<`, + * `<` followed by a space or digit, an unterminated `]*))?>/y; + +const hexColorPattern = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i; + +/** + * Parses a `` tag's value. Accepts `#rgb`, `#rgba`, `#rrggbb` and + * `#rrggbbaa`; a value without alpha is fully opaque. + * @param value - The tag's value, after the `=`. + * @returns The color the value names, or `null` if it isn't one of the + * accepted hex forms. + */ +function parseHexColor(value: string): Color | null { + if (!hexColorPattern.test(value)) { + return null; + } + + const digits = value.slice(1); + const isShortForm = digits.length <= 4; + const channelLength = isShortForm ? 1 : 2; + const channels: number[] = []; + + for (let i = 0; i < digits.length; i += channelLength) { + const channel = digits.slice(i, i + channelLength); + const expanded = isShortForm ? channel + channel : channel; + + channels.push(Number.parseInt(expanded, 16) / 255); + } + + const [r, g, b, a = 1] = channels; + + return new Color(r, g, b, a); +} + +/** A tag that's been opened and not yet closed. */ +interface OpenTag { + name: 'b' | 'color'; + color?: Color; +} + +/** + * Appends `run` to `runs`, extending the last run instead when the two + * touch and carry the same style, so a style split only by a tag that + * didn't change it (`ab`) stays one run. + * @param runs - The run list to append to. + * @param run - The run to append. + * @param isSameStyle - Whether two runs carry the same style. + */ +function appendRun( + runs: T[], + run: T, + isSameStyle: (a: T, b: T) => boolean, +): void { + if (run.end <= run.start) { + return; + } + + const previous = runs.at(-1); + + if (previous?.end === run.start && isSameStyle(previous, run)) { + previous.end = run.end; + + return; + } + + runs.push(run); +} + +/** + * Applies one tag-shaped piece of markup to the stack of open tags. + * @param isClosing - Whether the tag is a closing tag (``). + * @param name - The tag's name. + * @param value - The tag's value, if it has one. + * @param openTags - The stack of currently open tags, modified in place. + * @returns `false` if the markup isn't a tag this parser accepts (an + * unknown name, a missing or invalid value, or a closing tag with nothing + * of that name open), in which case it's literal text and `openTags` is + * unchanged. + */ +function applyTag( + isClosing: boolean, + name: string, + value: string | undefined, + openTags: OpenTag[], +): boolean { + if (name !== 'b' && name !== 'color') { + return false; + } + + if (isClosing) { + // Closes the innermost open tag of the same name, even when another + // kind of tag was opened inside it: bold and color are independent, so + // `ab` still has a clear meaning. + const openIndex = openTags.findLastIndex((tag) => tag.name === name); + + if (value !== undefined || openIndex === -1) { + return false; + } + + openTags.splice(openIndex, 1); + + return true; + } + + if (name === 'b') { + if (value !== undefined) { + return false; + } + + openTags.push({ name }); + + return true; + } + + const color = value === undefined ? null : parseHexColor(value); + + if (!color) { + return false; + } + + openTags.push({ name, color }); + + return true; +} + +/** + * Splits a string containing rich text tags into its plain text and the + * style runs the tags apply over that text's character indices. + * + * Two tags are supported: `...` draws its text bold and + * `...` draws it in a color (`#rgb`, `#rgba` and + * `#rrggbbaa` work too). Tags nest, and the innermost color wins. + * + * Markup that isn't one of those tags is kept as literal text, never an + * error, since text often comes from players or translations: a `<` that + * doesn't start a complete ``, `` or `` + * (`HP < 50%`, `a<3`), an unknown tag (``), an invalid value + * (``) and a closing tag with nothing of its name open. There's + * no escape syntax, so the exact text `` can't be shown. A tag left open + * runs to the end of the string. + * @param markup - The string to parse. + * @returns The plain text and its style runs. + */ +export function parseRichText(markup: string): RichText { + const colorRuns: ColorTextRun[] = []; + const boldRuns: TextRun[] = []; + const openTags: OpenTag[] = []; + let text = ''; + let segmentStart = 0; + let index = 0; + + // Closes the run of plain text written since the last tag, under the + // style the open tags gave it. + const flushSegment = (): void => { + const run = { start: segmentStart, end: text.length }; + const color = openTags.findLast((tag) => tag.color)?.color; + + if (color) { + appendRun(colorRuns, { ...run, color }, (a, b) => a.color === b.color); + } + + if (openTags.some((tag) => tag.name === 'b')) { + appendRun(boldRuns, run, () => true); + } + + segmentStart = text.length; + }; + + while (index < markup.length) { + const nextTagStart = markup.indexOf('<', index); + + if (nextTagStart === -1) { + text += markup.slice(index); + + break; + } + + tagPattern.lastIndex = nextTagStart; + const match = tagPattern.exec(markup); + + text += markup.slice(index, nextTagStart); + flushSegment(); + + if (!match) { + text += '<'; + index = nextTagStart + 1; + + continue; + } + + const [tag, closingSlash, name, value] = match; + + if (!applyTag(closingSlash === '/', name, value, openTags)) { + text += tag; + } + + index = nextTagStart + tag.length; + } + + flushSegment(); + + return { text, colorRuns, boldRuns }; +} diff --git a/src/text/utilities/shape-text.test.ts b/src/text/utilities/shape-text.test.ts index 98edf424..492b45d6 100644 --- a/src/text/utilities/shape-text.test.ts +++ b/src/text/utilities/shape-text.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; +import { Color } from '../../rendering/color.js'; import type { FontAtlasData } from '../font-atlas/font-atlas-data.js'; -import { shapeText } from './shape-text.js'; +import { FAUX_BOLD_EMBOLDEN, shapeText } from './shape-text.js'; const A_CODE_POINT = 65; const V_CODE_POINT = 86; @@ -648,4 +649,125 @@ describe('shapeText', () => { expect(glyphs[4].offset.y).toBeCloseTo(3.5 - 48 - 9); }); }); + + describe('rich text tags', () => { + const red = new Color(1, 0, 0, 1); + + it('leaves untagged glyphs without a color or embolden', () => { + const { glyphs } = shapeText('AV', buildFixtureFontAtlasData(), { + size: 10, + }); + + expect(glyphs.map((glyph) => glyph.color)).toEqual([ + undefined, + undefined, + ]); + expect(glyphs.map((glyph) => glyph.embolden)).toEqual([0, 0]); + }); + + it('colors the glyphs inside a color tag without moving any glyph', () => { + const untagged = shapeText('AV AV', buildFixtureFontAtlasData(), { + size: 10, + }); + const tagged = shapeText( + 'AV AV', + buildFixtureFontAtlasData(), + { size: 10 }, + ); + + expect(tagged.glyphs.map((glyph) => glyph.color)).toEqual([ + undefined, + red, + red, + undefined, + ]); + expect(tagged.glyphs.map((glyph) => glyph.offset)).toEqual( + untagged.glyphs.map((glyph) => glyph.offset), + ); + expect(tagged.bounds).toEqual(untagged.bounds); + }); + + it('kerns across a tag boundary exactly as without the tag', () => { + const untagged = shapeText('AV', buildFixtureFontAtlasData(), { + size: 10, + }); + const tagged = shapeText( + 'AV', + buildFixtureFontAtlasData(), + { size: 10 }, + ); + + expect(tagged.glyphs[1].offset.x).toBeCloseTo( + untagged.glyphs[1].offset.x, + ); + }); + + it("wraps a tagged string as if its tags weren't there", () => { + const options = { size: 10, maxWidth: 20 }; + const untagged = shapeText( + 'AV AV AV', + buildFixtureFontAtlasData(), + options, + ); + const tagged = shapeText( + 'AV AV AV', + buildFixtureFontAtlasData(), + options, + ); + + expect(tagged.glyphs.map((glyph) => glyph.offset)).toEqual( + untagged.glyphs.map((glyph) => glyph.offset), + ); + expect(tagged.glyphs.map((glyph) => glyph.color)).toEqual([ + undefined, + undefined, + red, + red, + red, + red, + ]); + }); + + it('emboldens bold glyphs in the distance field units of the atlas', () => { + const { glyphs } = shapeText('AV', buildFixtureFontAtlasData(), { + size: 10, + }); + + // "A"'s atlas rect is 0.1 * 256 = 25.6 atlas pixels wide for 0.5em of + // plane bounds, so 51.2 atlas pixels per em, over a 4 pixel range. + expect(glyphs[0].embolden).toBeCloseTo((FAUX_BOLD_EMBOLDEN * 51.2) / 4); + expect(glyphs[1].embolden).toBe(0); + }); + + it('widens a bold glyph by the embolden on both sides', () => { + const regular = shapeText('AV', buildFixtureFontAtlasData(), { + size: 10, + }); + const bold = shapeText('AV', buildFixtureFontAtlasData(), { + size: 10, + }); + const emboldenWidth = FAUX_BOLD_EMBOLDEN * 10; + + expect(bold.glyphs[0].offset.x).toBeCloseTo( + regular.glyphs[0].offset.x + emboldenWidth, + ); + expect(bold.glyphs[1].offset.x).toBeCloseTo( + regular.glyphs[1].offset.x + 2 * emboldenWidth, + ); + expect(bold.bounds.width).toBeCloseTo( + regular.bounds.width + 2 * emboldenWidth, + ); + }); + + it('shapes markup it does not recognize as literal text', () => { + const { glyphs } = shapeText('A', buildFixtureFontAtlasData(), { + size: 10, + }); + + // "<", "i", ">" and "/" aren't in the fixture atlas, so only "A" draws - + // and it isn't styled. + expect(glyphs).toHaveLength(1); + expect(glyphs[0].color).toBeUndefined(); + }); + }); }); diff --git a/src/text/utilities/shape-text.ts b/src/text/utilities/shape-text.ts index b82d148c..e874f5b4 100644 --- a/src/text/utilities/shape-text.ts +++ b/src/text/utilities/shape-text.ts @@ -1,8 +1,18 @@ +import type { Color } from '../../rendering/color.js'; import type { GlyphQuad } from '../components/text-mesh-component.js'; import { type FontAtlasData, getKerningPairKey, } from '../font-atlas/font-atlas-data.js'; +import { parseRichText } from './parse-rich-text.js'; + +/** + * How far a `` glyph's ink is thickened on every side, in ems - the + * same synthetic ("faux") bold browsers and FreeType apply when a font has + * no bold cut: the glyph's edge is pushed outwards, and its advance grows + * by the added width so bold letters don't run into each other. + */ +export const FAUX_BOLD_EMBOLDEN = 0.02; /** * Options for {@link shapeText}. Mirrors the shape-relevant subset of @@ -83,6 +93,86 @@ const defaultShapeTextOptions = { horizontalAlignPivot: 0, }; +/** + * The style each character of the shaped (tag-free) string is drawn with, + * indexed by UTF-16 code unit. + */ +interface CharacterStyles { + /** The `` color of each character, or `undefined` outside one. */ + colors: (Color | undefined)[]; + + /** Whether each character is inside a `` tag. */ + bold: boolean[]; + + /** + * The `GlyphQuad.embolden` of a bold glyph (see + * {@link getFauxBoldEmbolden}), or `0` if the string has no bold text. + */ + boldEmbolden: number; +} + +/** + * Parses `text`'s rich text tags into the plain string that's shaped and + * the style of each of its characters. + * @param text - The string to parse. + * @param fontAtlasData - The font atlas the string is shaped against. + * @returns The plain string and its per-character styles. + */ +function resolveCharacterStyles( + text: string, + fontAtlasData: FontAtlasData, +): { + plainText: string; + styles: CharacterStyles; +} { + const { text: plainText, colorRuns, boldRuns } = parseRichText(text); + const colors = new Array(plainText.length); + const bold = new Array(plainText.length).fill(false); + + for (const { start, end, color } of colorRuns) { + colors.fill(color, start, end); + } + + for (const { start, end } of boldRuns) { + bold.fill(true, start, end); + } + + const boldEmbolden = + boldRuns.length > 0 ? getFauxBoldEmbolden(fontAtlasData) : 0; + + return { plainText, styles: { colors, bold, boldEmbolden } }; +} + +/** + * Converts {@link FAUX_BOLD_EMBOLDEN} into the distance field's own units: + * how far the MSDF shaders shift a bold glyph's edge threshold. The field + * spans `distanceRange` atlas pixels, and the generator bakes every glyph + * at the same size, so any glyph's atlas rect against its plane bounds + * gives the atlas's pixels per em. + * @param fontAtlasData - The font atlas to convert for. + * @returns The threshold shift for a bold glyph, or `0` if the atlas has + * no glyph with ink to measure it from. + */ +function getFauxBoldEmbolden(fontAtlasData: FontAtlasData): number { + for (const { planeBounds, atlasBounds } of fontAtlasData.glyphs.values()) { + const planeWidth = planeBounds ? planeBounds.right - planeBounds.left : 0; + + if (!atlasBounds || planeWidth <= 0) { + continue; + } + + const atlasPixelsPerEm = + ((atlasBounds.right - atlasBounds.left) * fontAtlasData.atlasSize.width) / + planeWidth; + + return ( + (FAUX_BOLD_EMBOLDEN * atlasPixelsPerEm) / fontAtlasData.distanceRange + ); + } + + return 0; +} + /** A single word's shaped glyphs, positioned relative to the word's own start (x = 0). */ interface ShapedWord { glyphs: GlyphQuad[]; @@ -112,7 +202,15 @@ interface ShapedLine { * what lets {@link wrapIntoLines} reuse this same walk for both the * word-wrap width check and the glyphs it ultimately emits, with no second * measurement pass. + * + * Each glyph takes its style from the character it was shaped from: + * kerning and measurement only ever see the tag-free string, so a tag + * boundary inside a word changes how a glyph is drawn, never where it + * sits - except that a bold glyph is widened by {@link FAUX_BOLD_EMBOLDEN} + * on both sides. * @param word - The word's code points, with no whitespace. + * @param wordStart - The index of the word's first character in the shaped string, to look its style up by. + * @param styles - The style of every character in the shaped string. * @param fontAtlasData - The font atlas metrics to shape against. * @param size - Font size, in world units. * @param letterSpacing - Extra spacing between adjacent glyphs, in ems. @@ -120,6 +218,8 @@ interface ShapedLine { */ function shapeWord( word: string, + wordStart: number, + styles: CharacterStyles, fontAtlasData: FontAtlasData, size: number, letterSpacing: number, @@ -128,10 +228,14 @@ function shapeWord( let penX = 0; let previousCodePoint: number | null = null; let hasPreviousGlyph = false; + let characterIndex = wordStart; for (const character of word) { const codePoint = character.codePointAt(0) as number; const glyph = fontAtlasData.glyphs.get(codePoint); + const styleIndex = characterIndex; + + characterIndex += character.length; if (!glyph) { previousCodePoint = null; @@ -139,6 +243,9 @@ function shapeWord( continue; } + const isBold = styles.bold[styleIndex]; + const emboldenWidth = isBold ? FAUX_BOLD_EMBOLDEN * size : 0; + // Letter spacing goes *between* glyphs, so it's added before every // glyph but the first rather than after every glyph: spacing after the // last glyph would count towards the word's width, pushing centered @@ -179,9 +286,12 @@ function shapeWord( const atlasBottom = atlasBounds.bottom + insetY; const atlasTop = atlasBounds.top - insetY; + // The quad doesn't grow with the bold ink: plane bounds already + // include `distanceRange / 2` atlas pixels of padding around the ink, + // which is where the thickened edge is drawn. glyphs.push({ offset: { - x: penX + (planeBounds.left * size + glyphWidth / 2), + x: penX + emboldenWidth + (planeBounds.left * size + glyphWidth / 2), y: planeBounds.bottom * size + glyphHeight / 2, }, size: { x: glyphWidth, y: glyphHeight }, @@ -195,10 +305,12 @@ function shapeWord( x: atlasRight - atlasLeft, y: atlasTop - atlasBottom, }, + color: styles.colors[styleIndex], + embolden: isBold ? styles.boldEmbolden : 0, }); } - penX += glyph.advance * size; + penX += glyph.advance * size + 2 * emboldenWidth; previousCodePoint = codePoint; hasPreviousGlyph = true; } @@ -241,8 +353,9 @@ function getWhitespaceAdvance( * already has at least one word, in which case a new line starts with that * word instead. A single word wider than `maxWidth` on its own is never * split mid-word - it simply overflows its own line. - * @param text - The full string to wrap. A single call always returns at - * least one (possibly empty) line. + * @param text - The full, tag-free string to wrap. A single call always + * returns at least one (possibly empty) line. + * @param styles - The style of every character in `text`. * @param fontAtlasData - The font atlas metrics to shape against. * @param size - Font size, in world units. * @param letterSpacing - Extra spacing between adjacent glyphs, in ems. @@ -252,6 +365,7 @@ function getWhitespaceAdvance( */ function wrapIntoLines( text: string, + styles: CharacterStyles, fontAtlasData: FontAtlasData, size: number, letterSpacing: number, @@ -271,14 +385,27 @@ function wrapIntoLines( lineContentWidth = 0; }; + let tokenStart = 0; + for (const token of tokens) { + const wordStart = tokenStart; + + tokenStart += token.length; + if (/^\s/.test(token)) { penX += getWhitespaceAdvance(token, fontAtlasData, size); continue; } - const word = shapeWord(token, fontAtlasData, size, letterSpacing); + const word = shapeWord( + token, + wordStart, + styles, + fontAtlasData, + size, + letterSpacing, + ); // A word whose code points are all missing from the atlas draws nothing // and takes up no space, so it mustn't add a letter space either. @@ -421,14 +548,20 @@ function getVerticalAlignOffset( * single word (nothing to stretch). It has no effect when `maxWidth` is * unset, since every line is then already exactly as wide as the block. * + * `text` may contain rich text tags - `...` and + * `...`, see {@link parseRichText} - which are + * stripped before shaping, so they never affect kerning or wrapping, and + * set the style of the glyphs between them. + * * Code points not present in `fontAtlasData.glyphs` are silently skipped * (no glyph quad, no advance) rather than throwing - a missing glyph in * player-supplied or localized text is a content problem, not a programming * error. - * @param text - The string to shape. + * @param text - The string to shape, which may contain rich text tags. * @param fontAtlasData - The font atlas metrics to shape against. * @param options - Shaping options. * @returns The shaped glyph quads and the block's bounds. + * @throws An error if `text` contains a malformed tag (see {@link parseRichText}). */ export function shapeText( text: string, @@ -445,8 +578,10 @@ export function shapeText( horizontalAlignPivot, } = { ...defaultShapeTextOptions, ...options }; + const { plainText, styles } = resolveCharacterStyles(text, fontAtlasData); const lines = wrapIntoLines( - text, + plainText, + styles, fontAtlasData, size, letterSpacing,