Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
#### Added

- **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:** 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

Expand All @@ -26,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **rendering:** The render system no longer draws sprites, nine-slice regions or text glyphs whose quads are entirely outside a camera's view. Code that disabled sprites only to save drawing them while off screen can be deleted. A material with a custom vertex shader that moves vertices beyond the sprite's quad may be skipped while partly visible
- **rendering:** `createProjectionMatrix` now takes the world-space `Rect` to show, such as `getCameraView(...).bounds`, instead of `(width, height, cameraPosition, zoom, pixelsPerUnit)`
- **ui:** A `'screenPixels'`-unit size or margin, and `UiSafeAreaEcsComponent` insets, now follow the canvas camera's `zoom`, so they keep their on-screen size on a world-space canvas whose camera zooms. A screen-space canvas's root rect now fills its camera's view, so it follows a moved or zoomed UI camera
- **text:** `FontAtlasCache.getOrLoad` takes the URL of both atlas files, `getOrLoad({ metricsUrl, imageUrl })`, instead of finding the image next to the JSON, so atlases imported through Vite, webpack or another bundler that renames files now load. Pass the URLs your bundler gives you for the `.json` and `.png` (with Vite, `import metricsUrl from './my-font.json?url'` and `import imageUrl from './my-font.png'`), and look loaded atlases up with `get(metricsUrl)`. `getOrLoad` rejects if the image's size doesn't match the JSON's `atlasSize`, or if one `metricsUrl` is requested with two different image URLs. Concurrent calls for the same atlas now share one load. `FontAtlasCache` no longer implements `AssetCache` and its `load` method and `assets` map are no longer public; call `getOrLoad` instead. `FontAtlasData` and `FontAtlasFileData` no longer have an `atlasImage` field and `forge-generate-font-atlas` no longer writes one; existing JSON files that have it still load

#### Removed

Expand Down
1 change: 0 additions & 1 deletion assets/fonts/default/default.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"formatVersion": 2,
"type": "msdf",
"atlasImage": "default.png",
"atlasSize": {
"width": 512,
"height": 512
Expand Down
2 changes: 1 addition & 1 deletion design/font-atlas-loading.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

| | |
| ------------------------------------- | ------------------------------------------------------------------------------------------------ |
| **Status** | Draft, for review |
| **Status** | Implemented. `documentation-site/docs/docs/text` describes current behavior |
| **Kind** | Defect |
| **Found in** | Galactic Journey demo: `src/ui/create-ui.ts` (fonts served from `public/` to avoid hashed names) |
| **Engine version at time of writing** | `0.25.8` |
Expand Down
8 changes: 5 additions & 3 deletions documentation-site/docs/docs/asset-loading/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ and [`FontAtlasCache`](/Forge/docs/api/classes/FontAtlasCache) (see
[Text](../text/index.md)), plus two supporting building blocks:

- [`AssetCache`](/Forge/docs/api/interfaces/AssetCache): the common
`get` / `load` / `getOrLoad` contract that asset caches implement.
`ImageCache` and `FontAtlasCache` both implement it; if you add a cache
for another asset type (audio buffers, arbitrary JSON data), implement
`get` / `load` / `getOrLoad` contract for caches that load an asset from
a single URL, such as `ImageCache`. If you add a cache for another
single-file asset type (audio buffers, arbitrary JSON data), implement
this interface so it behaves consistently with the rest of the engine.
`FontAtlasCache` loads each atlas from two URLs, so it has its own
`getOrLoad({ metricsUrl, imageUrl })` instead.
- [`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
Expand Down
6 changes: 4 additions & 2 deletions documentation-site/docs/docs/text/generating-a-font-atlas.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@ npm install -g msdf-bmfont-xml
npx forge-generate-font-atlas --font my-font.ttf --charset ascii --out assets/fonts/my-font
```

This writes `assets/fonts/my-font.png` and `assets/fonts/my-font.json`, which
[`FontAtlasCache`](./loading-a-font-atlas.md) loads together at runtime.
This writes `assets/fonts/my-font.png` and `assets/fonts/my-font.json`.
Load them with [`FontAtlasCache`](./loading-a-font-atlas.md), passing the
URL of each. Always replace both files together: the JSON's glyph
positions only match the image from the same run.

| Flag | Default | Meaning |
| -------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
Expand Down
42 changes: 25 additions & 17 deletions documentation-site/docs/docs/text/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,8 @@ Three pieces make up the module:
file, sized to your game's actual `charset`.
- [`FontAtlasCache`](/Forge/docs/api/classes/FontAtlasCache), which loads
that JSON/image pair at runtime into a
[`FontAtlas`](/Forge/docs/api/interfaces/FontAtlas), following the same
[`AssetCache`](/Forge/docs/api/interfaces/AssetCache) contract as the
rest of the engine's asset loading (see
[Asset Loading](../asset-loading/index.md)).
[`FontAtlas`](/Forge/docs/api/interfaces/FontAtlas) (see
[Loading a Font Atlas](./loading-a-font-atlas.md)).
- [`addTextComponent`](/Forge/docs/api/functions/addTextComponent) and
[`createTextShapingEcsSystem`](/Forge/docs/api/functions/createTextShapingEcsSystem),
which draw a string from a loaded `FontAtlas` through the render system
Expand All @@ -31,34 +29,44 @@ Three pieces make up the module:
## Quick start

The engine ships a pre-generated default atlas (Liberation Sans, SIL Open
Font License 1.1) at `assets/fonts/default/` (`default.json`, `default.png`,
and a `License.txt` with the font's attribution) inside the
`@forge-game-engine/forge` package itself, so you can render text with zero
font setup - no `.ttf`, no `forge-generate-font-atlas` run, nothing to
license or commit yourself. `FontAtlasCache.getOrLoad` just needs those
three files reachable at a URL, the same as any atlas you generate
yourself, so copy (or have your build script copy) them from
`node_modules/@forge-game-engine/forge/assets/fonts/default/` into your
project's own served assets directory - most bundlers don't serve
`node_modules` directly:
Font License 1.1) inside the `@forge-game-engine/forge` package, so you can
render text with zero font setup - no `.ttf`, no `forge-generate-font-atlas`
run. Import its two files through the package's `fonts/default` exports and
pass the URLs your bundler gives you (this is Vite; see
[Loading a Font Atlas](./loading-a-font-atlas.md#importing-atlases-through-a-bundler)
for webpack):

```ts
import defaultFontMetricsUrl from '@forge-game-engine/forge/fonts/default/default.json?url';
import defaultFontImageUrl from '@forge-game-engine/forge/fonts/default/default.png';
import { FontAtlasCache } from '@forge-game-engine/forge/text';

const fontAtlasCache = new FontAtlasCache();
const fontAtlas = await fontAtlasCache.getOrLoad('assets/fonts/default.json');
const fontAtlas = await fontAtlasCache.getOrLoad({
metricsUrl: defaultFontMetricsUrl,
imageUrl: defaultFontImageUrl,
});
```

The font's attribution is in `assets/fonts/default/License.txt` in the
package.

When you need your own font (a different look, or characters the default
atlas's ASCII charset doesn't cover), generate one:

```bash
npm install --save-dev msdf-bmfont-xml
npx forge-generate-font-atlas --font my-font.ttf --charset ascii --out assets/fonts/my-font
npx forge-generate-font-atlas --font my-font.ttf --charset ascii --out src/fonts/my-font
```

```ts
const fontAtlas = await fontAtlasCache.getOrLoad('assets/fonts/my-font.json');
import myFontMetricsUrl from './fonts/my-font.json?url';
import myFontImageUrl from './fonts/my-font.png';

const fontAtlas = await fontAtlasCache.getOrLoad({
metricsUrl: myFontMetricsUrl,
imageUrl: myFontImageUrl,
});

console.log(fontAtlas.data.glyphs.get('A'.codePointAt(0)!));
```
86 changes: 73 additions & 13 deletions documentation-site/docs/docs/text/loading-a-font-atlas.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,80 @@ sidebar_position: 2
# Loading a Font Atlas

[`FontAtlasCache`](/Forge/docs/api/classes/FontAtlasCache) loads a
generated atlas's JSON metrics and PNG texture into a
[`FontAtlas`](/Forge/docs/api/interfaces/FontAtlas).
generated atlas's metrics JSON and PNG image into a
[`FontAtlas`](/Forge/docs/api/interfaces/FontAtlas). Pass it the URL of
each file:

```ts
import { FontAtlasCache } from '@forge-game-engine/forge/text';

const fontAtlasCache = new FontAtlasCache();
const fontAtlas = await fontAtlasCache.getOrLoad('assets/fonts/my-font.json');
const fontAtlas = await fontAtlasCache.getOrLoad({
metricsUrl: 'assets/fonts/my-font.json',
imageUrl: 'assets/fonts/my-font.png',
});
```

Any atlas works here, including the default one the engine ships at
`assets/fonts/default/default.json` (see [Text](./index.md)'s Quick start) -
there's nothing default-atlas-specific about loading it.
The two URLs are independent: the image doesn't have to be in the same
directory as the JSON, or keep its original file name.

## Cache keys point at the JSON file
## Importing atlases through a bundler

`getOrLoad('assets/fonts/my-font.json')` fetches that file, then loads
whatever image its `atlasImage` field names, resolved relative to the JSON
file itself (so the `.png` doesn't have to share the exact base name,
though `forge-generate-font-atlas` always names them to match).
Bundlers rename the assets they emit (`my-font.png` becomes something like
`my-font-3f2a9c.png`), so import both files and pass the URLs the bundler
gives you rather than writing paths by hand.

With Vite, import the PNG directly and the JSON with `?url`, which gives
its URL instead of its parsed contents:

```ts
import { FontAtlasCache } from '@forge-game-engine/forge/text';
import myFontMetricsUrl from './fonts/my-font.json?url';
import myFontImageUrl from './fonts/my-font.png';

const fontAtlasCache = new FontAtlasCache();
const fontAtlas = await fontAtlasCache.getOrLoad({
metricsUrl: myFontMetricsUrl,
imageUrl: myFontImageUrl,
});
```

With webpack 5, use `new URL(path, import.meta.url)` for both files. The
path must be a string literal for webpack to find the file:

```ts
const fontAtlas = await fontAtlasCache.getOrLoad({
metricsUrl: new URL('./fonts/my-font.json', import.meta.url).href,
imageUrl: new URL('./fonts/my-font.png', import.meta.url).href,
});
```

If your webpack config runs `file-loader` or `url-loader` on images
(Docusaurus does), those loaders also process `new URL` image requests, and
the emitted `.png` contains JavaScript instead of the image, so it fails to
load. Import the PNG instead (`import myFontImageUrl from
'./fonts/my-font.png'`) and keep `new URL` for the JSON.

The engine's default font (see [Text](./index.md)'s Quick start) is
imported the same way, from the package's `fonts/default` exports:

```ts
import defaultFontMetricsUrl from '@forge-game-engine/forge/fonts/default/default.json?url';
import defaultFontImageUrl from '@forge-game-engine/forge/fonts/default/default.png';
```

## Gotchas

- **Keep the JSON and PNG from the same generator run.** `getOrLoad`
rejects if the image's size doesn't match the JSON's `atlasSize`. Two
different atlases generated at the same texture size still pass that
check, and render garbled glyphs, so regenerate and replace both files
together.
- **One image per metrics URL.** The cache is keyed by `metricsUrl`.
Requesting the same `metricsUrl` with a different `imageUrl` rejects.
- **Requesting an atlas again doesn't reload it.** Repeated and concurrent
`getOrLoad` calls for the same `metricsUrl` share one load, so the
`Promise.all` pattern in the worked example below fetches each file once.

## Reading glyph metrics

Expand Down Expand Up @@ -73,8 +127,14 @@ import { FontAtlasCache } from '@forge-game-engine/forge/text';
const fontAtlasCache = new FontAtlasCache();

const [headingAtlas, bodyAtlas] = await Promise.all([
fontAtlasCache.getOrLoad('assets/fonts/heading.json'),
fontAtlasCache.getOrLoad('assets/fonts/body.json'),
fontAtlasCache.getOrLoad({
metricsUrl: 'assets/fonts/heading.json',
imageUrl: 'assets/fonts/heading.png',
}),
fontAtlasCache.getOrLoad({
metricsUrl: 'assets/fonts/body.json',
imageUrl: 'assets/fonts/body.png',
}),
]);

const capitalA = bodyAtlas.data.glyphs.get('A'.codePointAt(0)!);
Expand Down
5 changes: 4 additions & 1 deletion documentation-site/docs/docs/text/rendering-text.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ import {
} from '@forge-game-engine/forge/text';

const fontAtlasCache = new FontAtlasCache();
const fontAtlas = await fontAtlasCache.getOrLoad('assets/fonts/body.json');
const fontAtlas = await fontAtlasCache.getOrLoad({
metricsUrl: 'assets/fonts/body.json',
imageUrl: 'assets/fonts/body.png',
});

const label = world.createEntity();
addPositionComponent(world, label, { local: { x: 400, y: 300 } });
Expand Down
7 changes: 4 additions & 3 deletions documentation-site/docs/docs/ui/labels-and-text.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ atlas):
import { FontAtlasCache } from '@forge-game-engine/forge/text';
import { createLabel, UiAnchor } from '@forge-game-engine/forge/ui';

const fontAtlas = await new FontAtlasCache().getOrLoad(
'assets/fonts/default.json',
);
const fontAtlas = await new FontAtlasCache().getOrLoad({
metricsUrl: 'assets/fonts/my-font.json',
imageUrl: 'assets/fonts/my-font.png',
});

createLabel(world, panel, {
text: 'Play',
Expand Down
16 changes: 11 additions & 5 deletions documentation-site/src/pages/demos/layout-groups/_create-game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Time,
} from '@forge-game-engine/forge/common';
import { EcsWorld } from '@forge-game-engine/forge/ecs';
import defaultFontImageUrl from '@forge-game-engine/forge/fonts/default/default.png';
import {
actionResetTypes,
Axis2dAction,
Expand Down Expand Up @@ -140,12 +141,9 @@ function createUiInputs(
* (the Menu's buttons, the Options panel's Music slider and Fullscreen
* toggle) is clickable and keyboard/gamepad-focus-navigable via
* `createUiInputs`.
* @param fontAtlasUrl - The URL of the font atlas JSON to load.
* @returns The created game.
*/
export const createLayoutGroupsGame = async (
fontAtlasUrl: string,
): Promise<Game> => {
export const createLayoutGroupsGame = async (): Promise<Game> => {
const { game, world, renderContext, time } = createGame('demo-game');

const camera = createCamera(world, {
Expand All @@ -157,7 +155,15 @@ export const createLayoutGroupsGame = async (
await createBackdrop(world, camera, renderContext);

const fontAtlasCache = new FontAtlasCache(renderContext.imageCache);
const fontAtlas = await fontAtlasCache.getOrLoad(fontAtlasUrl);
const fontAtlas = await fontAtlasCache.getOrLoad({
// Importing the JSON would give its parsed contents, so `new URL` asks
// webpack for its URL instead.
metricsUrl: new URL(
'@forge-game-engine/forge/fonts/default/default.json',
import.meta.url,
).href,
imageUrl: defaultFontImageUrl,
});

const { mouseInputSource, submitInput, navigateInput } = createUiInputs(
world,
Expand Down
12 changes: 2 additions & 10 deletions documentation-site/src/pages/demos/layout-groups/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import React, { JSX, useCallback } from 'react';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import React, { JSX } from 'react';
import { createLayoutGroupsGame } from './_create-game';
import gameCode from '!!raw-loader!./_create-game';
import menuCode from '!!raw-loader!./_create-menu';
Expand All @@ -10,13 +9,6 @@ import optionsFormCode from '!!raw-loader!./_create-options-form';
import { Demo } from '@site/src/components/Demo';

export default function LayoutGroups(): JSX.Element {
const { siteConfig } = useDocusaurusContext();
const fontAtlasUrl = `${siteConfig.baseUrl}fonts/default/default.json`;
const createGame = useCallback(
() => createLayoutGroupsGame(fontAtlasUrl),
[fontAtlasUrl],
);

return (
<Demo
metaData={{
Expand All @@ -26,7 +18,7 @@ export default function LayoutGroups(): JSX.Element {
}}
header="Layout Groups"
blurb="Four panels, each arranged automatically instead of by hand. 'Menu' stacks three buttons with a VerticalLayoutGroupEcsComponent, and shrink-wraps its own size to fit them via a ContentSizeFitterEcsComponent - resize a button and the panel follows. 'Toolbar' spaces a row of icons evenly with a HorizontalLayoutGroupEcsComponent. 'Inventory' places eight cells into a fixed 4-column grid with a GridLayoutGroupEcsComponent. 'Options' uses that same component with columnWidthMode: 'content' instead - the label column sizes itself to whichever of 'Music'/'Fullscreen' is widest, so both rows' controls line up on the same left edge. None of the arranged children set their own anchoredPosition or size - createUiLayoutGroupEcsSystem computes all of it, every frame."
createGame={createGame}
createGame={createLayoutGroupsGame}
codeFiles={[
{ name: 'game.ts', content: gameCode },
{ name: 'create-menu.ts', content: menuCode },
Expand Down
17 changes: 12 additions & 5 deletions documentation-site/src/pages/demos/text/_create-game.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createTransformEcsSystem } from '@forge-game-engine/forge/common';
import defaultFontImageUrl from '@forge-game-engine/forge/fonts/default/default.png';
import {
createCamera,
createCameraEcsSystem,
Expand Down Expand Up @@ -43,17 +44,15 @@ const sectionGap = 16;
* paragraph whose `maxWidth` oscillates every frame to show
* `createTextShapingEcsSystem` reflowing text live, an interactive
* playground, and an outline/soft-shadow showcase, all drawn from one
* shared `FontAtlas` loaded from `fontAtlasUrl`.
* @param fontAtlasUrl - The URL of the font atlas JSON to load (see
* `index.tsx`, which resolves this against the site's configured base URL).
* shared `FontAtlas`: the engine's default font, imported through the
* package's `fonts/default` exports so webpack serves both files.
* @param onPlaygroundReady - Called once the playground's live
* `TextEcsComponent` exists, so `index.tsx`'s controls can mutate it
* directly (mirroring how other demos hand a live component back to React,
* e.g. the space-shooter demo's `onBloomReady`).
* @returns The created game.
*/
export const createTextGame = async (
fontAtlasUrl: string,
onPlaygroundReady: (playground: Playground) => void,
): Promise<Game> => {
const { game, world, renderContext, time } = createGame('demo-game');
Expand All @@ -65,7 +64,15 @@ export const createTextGame = async (
});

const fontAtlasCache = new FontAtlasCache(renderContext.imageCache);
const fontAtlas = await fontAtlasCache.getOrLoad(fontAtlasUrl);
const fontAtlas = await fontAtlasCache.getOrLoad({
// Importing the JSON would give its parsed contents, so `new URL` asks
// webpack for its URL instead.
metricsUrl: new URL(
'@forge-game-engine/forge/fonts/default/default.json',
import.meta.url,
).href,
imageUrl: defaultFontImageUrl,
});

const whiteImage = await renderContext.imageCache.getOrLoad(
getAssetUrl('img/White.png'),
Expand Down
Loading
Loading