Skip to main content
← All demos

Text Rendering

game.ts
const renderLayers = {
foreground: 1 << 0,
};

// Draw-order (not culling) layers: guide boxes/lines are drawn first so the
// text painted at `content` always ends up on top of them, regardless of
// how their world positions happen to compare.
const drawOrder = {
guide: 0,
content: 1,
};

const margin = 24;
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
* 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).
* @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');

createCamera(world, {
isStatic: true,
cullingMask: renderLayers.foreground,
verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS,
});

const fontAtlasCache = new FontAtlasCache(renderContext.imageCache);
const fontAtlas = await fontAtlasCache.getOrLoad(fontAtlasUrl);

const whiteImage = await renderContext.imageCache.getOrLoad(
'img/White.png',
);
const whiteSprite = createImageSprite(whiteImage, renderContext, {
pixelsPerUnit: 1,
layer: renderLayers.foreground,
});

const { x: width, y: height } = calculateVisibleWorldSize(
renderContext.width,
renderContext.height,
DEMO_VERTICAL_WORLD_UNITS,
);
const usableWidth = width - margin * 2;
const left = -usableWidth / 2;
let y = height / 2 - margin;

y = createHorizontalAlignmentExamples(
world,
fontAtlas,
whiteSprite,
drawOrder.guide,
drawOrder.content,
{ x: left, y },
usableWidth,
);
y -= sectionGap;

y = createVerticalAlignmentExamples(
world,
fontAtlas,
whiteSprite,
drawOrder.guide,
drawOrder.content,
{ x: left, y },
usableWidth,
);
y -= sectionGap;

y = createLineHeightExamples(
world,
fontAtlas,
whiteSprite,
drawOrder.guide,
drawOrder.content,
{ x: left, y },
usableWidth,
);
y -= sectionGap;

y = createLiveMaxWidthExample(
world,
fontAtlas,
whiteSprite,
drawOrder.guide,
drawOrder.content,
{ x: left, y },
usableWidth,
);
y -= sectionGap;

const playground = createPlayground(
world,
fontAtlas,
whiteSprite,
drawOrder.guide,
drawOrder.content,
{ x: left, y },
usableWidth,
);
onPlaygroundReady(playground);
y = playground.bottom;
y -= sectionGap;

y = createEffectsExamples(
world,
fontAtlas,
whiteSprite,
drawOrder.guide,
drawOrder.content,
{ x: left, y },
usableWidth,
);
y -= sectionGap;

createEffectsHeroExample(
world,
fontAtlas,
whiteSprite,
drawOrder.guide,
drawOrder.content,
{ x: left, y },
usableWidth,
);

world.addSystem(createCameraEcsSystem(time));
world.addSystem(createLiveMaxWidthEcsSystem(time));
world.addSystem(createTextShapingEcsSystem(renderContext));
world.addSystem(createRenderEcsSystem(renderContext));

return game;
};

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) positioned against a shared anchor line, 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.