Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .cspell/project-words.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
aabb
aabbs
afterrun
antialiases
Aoboshi
artboard
attw
Expand Down Expand Up @@ -28,6 +29,7 @@ Flaticon
fphysics
fract
frontmatter
fwidth
gamedev
gamepadconnected
Gamepads
Expand All @@ -39,6 +41,7 @@ highp
hitscan
hotspot
hoverable
imageout
impluse
Infima
inigo
Expand All @@ -55,6 +58,9 @@ Mertens
mousedown
mousemove
mouseup
Msdf
msdf
MSDF
Narkowicz
ndot
Nisbet
Expand All @@ -65,6 +71,7 @@ pingpong
Pixabay
prebuild
prestart
pxrange
quilez
rasterizer
readback
Expand Down Expand Up @@ -118,4 +125,5 @@ WASD
webgl
Wenrexa
yoavbls
yorigin
Zoltan
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 8 additions & 2 deletions documentation-site/docs/docs/asset-loading/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
4 changes: 4 additions & 0 deletions documentation-site/docs/docs/rendering/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ 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.
Shaped text (`@forge-game-engine/forge/text`) draws through this same path
- each glyph quad expands into a `SpriteEcsComponent`-shaped render command,
so a label sorts and batches with sprites exactly like a nine-sliced
panel's regions do; 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
Expand Down
8 changes: 8 additions & 0 deletions documentation-site/docs/docs/text/_category_.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"label": "Text",
"position": 11,
"link": {
"type": "doc",
"id": "docs/text/index"
}
}
97 changes: 97 additions & 0 deletions documentation-site/docs/docs/text/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Text

`@forge-game-engine/forge/text` renders text using
[MSDF](https://github.com/Chlumsky/msdf-atlas-gen) (multi-channel signed
distance field) font atlases: a pre-generated texture plus a JSON metrics
file, sampled by a small fragment shader that reconstructs crisp glyph
edges at any scale. Unlike drawing text to a `<canvas>` and uploading it as
a texture, MSDF text batches into the same instanced draw call as sprites
and never re-uploads a texture when the string changes - the glyph
quads it draws are ordinary `SpriteEcsComponent`-shaped render commands
under the hood, so a label interleaves correctly with sprites in the same
depth-sorted draw order (see
[`createRenderEcsSystem`](/Forge/docs/api/functions/createRenderEcsSystem)).

Three pieces work together:

- [`loadFontAtlas`](/Forge/docs/api/functions/loadFontAtlas)
(`/asset-loading`) parses an `msdf-atlas-gen` JSON metrics file and loads
its atlas PNG into a [`FontAtlas`](/Forge/docs/api/interfaces/FontAtlas) -
glyph advances, kerning pairs, and line metrics, all normalized to em
units.
- [`createMsdfTextRenderable`](/Forge/docs/api/functions/createMsdfTextRenderable)
(`/rendering`) builds the GPU-side `Renderable` (the MSDF shader bound to
the atlas texture) for a `FontAtlas`. Call this once per font, not once
per label - every entity sharing the same `Renderable` batches together.
- [`addTextComponent`](/Forge/docs/api/functions/addTextComponent) and
[`createTextShapingEcsSystem`](/Forge/docs/api/functions/createTextShapingEcsSystem)
(`/text`) turn a string into glyph quads: attach a
[`TextEcsComponent`](/Forge/docs/api/interfaces/TextEcsComponent) to an
entity, and the shaping system dirty-tracks it into a
[`TextMeshEcsComponent`](/Forge/docs/api/interfaces/TextMeshEcsComponent)
the render system draws.

Try it in the [Text demo](/Forge/demos/text), which renders word-wrapped,
aligned paragraphs from a real generated Liberation Sans MSDF atlas,
including a live per-frame-updated label showing the shaping system's dirty
tracking in action.

Guides in this section:

- [MSDF Text](./msdf-text.md): generating a font atlas, loading it, and
configuring wrapping, alignment, and line spacing.

## 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 } 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);

// Shape before render, so the render system always sees this frame's glyphs.
world.addSystem(createTextShapingEcsSystem(), SystemRegistrationOrder.late);

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#registration-order) for why shaping needs to
run before `createRenderEcsSystem`.

## Current limitations

This is the initial MSDF rendering path from
[issue #584](https://github.com/Forge-Game-Engine/Forge/issues/584). Not
yet implemented:

- **No font shipped with the engine.** Every project currently generates
and hosts its own atlas (see [MSDF Text](./msdf-text.md#generating-an-atlas)).
- **No Canvas2D prototyping escape hatch.** `msdf-atlas-gen` is a real
toolchain step; a documented `fillText`-to-texture fallback for quick
prototyping is planned but not yet built.
- **No text effects** (outline, drop shadow, glow) - these fall out as
extra MSDF shader parameters later.
- **Word wrap only, not character wrap.** A single word wider than
`wrapWidth` is placed on its own line unbroken; see
[`shapeText`](/Forge/docs/api/functions/shapeText).
141 changes: 141 additions & 0 deletions documentation-site/docs/docs/text/msdf-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
---
sidebar_position: 1
---

# MSDF Text

## Generating an atlas

Forge doesn't ship a default font yet (see the
[current limitations](./index.md#current-limitations)), so every project
generates its own 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
```

(The [Text demo](/Forge/demos/text)'s own atlas -
`documentation-site/static/fonts/liberation-sans/` - was generated the same
way, from Liberation Sans; see the `README.md` alongside it for the exact
command used.)

Host the resulting `.json` and `.png` as static assets (e.g. next to your
other sprite sheets) and load them 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,
);
```

`-charset` matters: a code point missing from the atlas is silently
skipped by `shapeText` rather than erroring, so leave out a needed
character and text using it will look wrong in a way that's easy to miss
until someone actually types that character. Generate the charset from
every string your game actually displays (including punctuation and any
non-English text) rather than guessing.

Kerning pairs must be present in the JSON (`msdf-atlas-gen` includes them
by default) or text will look subtly wrong - slightly-too-wide gaps
between specific letter pairs like "AV" or "To" - in a way that's hard to
attribute back to "missing kerning data" later.

## Drawing text

[`createMsdfTextRenderable`](/Forge/docs/api/functions/createMsdfTextRenderable)
builds the GPU-side renderable once per `FontAtlas` - share it across every
entity using that font, the same way one `Renderable` is shared across
every sprite using the same spritesheet:

```ts
import { createMsdfTextRenderable } from '@forge-game-engine/forge/rendering';

const renderable = createMsdfTextRenderable(font, renderContext);
```

Then attach a [`TextEcsComponent`](/Forge/docs/api/interfaces/TextEcsComponent)
with [`addTextComponent`](/Forge/docs/api/functions/addTextComponent):

```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 } });
addTextComponent(world, score, {
text: 'Score: 0',
font,
renderable,
fontSize: 24,
color: new Color(1, 0.9, 0.2, 1),
});
```

Updating `text` (e.g. every time the score changes) is cheap to write -
just assign a new string to `textComponent.text` - and
[`createTextShapingEcsSystem`](/Forge/docs/api/functions/createTextShapingEcsSystem)
only re-shapes the entities whose text actually changed that frame; see
[Dirty tracking](#dirty-tracking).

## Wrapping, alignment, and line spacing

```ts
addTextComponent(world, dialogueEntity, {
text: 'A long line of dialogue that should wrap inside the text box.',
font,
renderable,
fontSize: 18,
wrapWidth: 280,
alignment: 'center',
lineSpacing: 1.2,
});
```

- **`wrapWidth`** (world units) word-wraps `text` to fit, breaking between
words. Omit it for a single line however long `text` is. A single word
wider than `wrapWidth` is placed on its own line unbroken - there's no
character-level breaking yet.
- **`alignment`** (`'left'` | `'center'` | `'right'`, default `'left'`)
positions each line relative to the text block's own width (the widest
line, or `wrapWidth` when set).
- **`lineSpacing`** (default `1`) multiplies `font.lineHeight` for the
vertical gap between line baselines.
- **`pivot`** (default `(0.5, 0.5)`, same convention as
[`SpriteEcsComponent.pivot`](/Forge/docs/api/interfaces/SpriteEcsComponent))
is the text block's origin, normalized to its own size: `(0, 0)` anchors
the block's top-left corner to the entity's position, `(1, 1)` its
bottom-right.
- Explicit `\n` characters in `text` always start a new line, wrapped or not.

## Registration order

Where `createTextShapingEcsSystem` sits in the tick matters:

- **Before `createRenderEcsSystem`.** The render system reads a
`TextMeshEcsComponent`'s glyph quads directly - an entity with a
`TextEcsComponent` but no `TextMeshEcsComponent` yet (never shaped) is
simply skipped, so register shaping with a lower
`SystemRegistrationOrder`
than rendering (`SystemRegistrationOrder.late`, from `@forge-game-engine/forge/ecs`, is a safe default for both).
- **After anything that changes `wrapWidth` at runtime**, if you're
computing it dynamically (e.g. from a resizable container's width)
rather than passing a fixed number.

## Dirty tracking

Re-shaping a paragraph - word-wrapping, kerning lookups, one quad per
character - is real cost, unlike most systems in a typical ECS pipeline
that recompute unconditionally every frame. `createTextShapingEcsSystem`
only re-shapes an entity when `text`, `font`, `fontSize`, `wrapWidth`,
`lineSpacing`, `alignment`, or `pivot` changed since its last shape.
Changing `color`, `enabled`, or `layer` never triggers a re-shape - those
only affect how the already-shaped glyphs are drawn.
4 changes: 4 additions & 0 deletions documentation-site/docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ const config: Config = {
to: 'demos/texture-filtering',
label: 'Texture Filtering',
},
{
to: 'demos/text',
label: 'Text',
},
],
},
{
Expand Down
Loading
Loading