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
3 changes: 3 additions & 0 deletions documentation-site/docs/docs/rendering/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ Guides in this section:
render target into HDR storage and compressing it back to displayable
range, so bloom can react to true HDR brightness (including emissive
maps) instead of an 8-bit ceiling.
- [Nine-Slice Sprites](./nine-slice-sprites.md): slicing a sprite into a 3x3
grid so its corners keep their size while its edges/center stretch or
tile, for UI panels and buttons that resize without distorting.
110 changes: 110 additions & 0 deletions documentation-site/docs/docs/rendering/nine-slice-sprites.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
sidebar_position: 5
---

# Nine-Slice Sprites

A UI panel or button drawn as a single stretched sprite distorts its
corners the moment it's resized: a crisp rounded border turns into a soft,
blurry oval as the sprite grows past its source art's native size.
Nine-slicing (also called "9-patch") fixes this by cutting the sprite into
a 3x3 grid — a border inset from each edge — and only stretching (or
tiling) the four edges and the center, while the four corners are always
drawn at their original, fixed size. Configure it with
[`NineSliceOptions`](/Forge/docs/api/interfaces/NineSliceOptions) on a
[`SpriteEcsComponent`](/Forge/docs/api/interfaces/SpriteEcsComponent):

```ts
import { addPositionComponent } from '@forge-game-engine/forge/common';
import { Vector2 } from '@forge-game-engine/forge/math';
import {
createImageSprite,
spriteId,
} from '@forge-game-engine/forge/rendering';

const panelSprite = createImageSprite(panelImage, renderContext, 0, {
slices: { left: 12, right: 12, top: 12, bottom: 12 },
});

const panel = world.createEntity();
addPositionComponent(world, panel, { world: new Vector2(400, 300) });
world.addComponent(panel, spriteId, panelSprite);

// Resize the panel later (e.g. to fit dynamic text) - the 12px corners
// stay crisp no matter how large the panel grows.
panelSprite.width = 320;
panelSprite.height = 200;
```

Nothing else changes: `panelSprite` is still one `SpriteEcsComponent`, with
one `width`/`height` you resize like any other sprite. The render system
detects `slices` and draws it as up to nine quads instead of one, entirely
transparently to the rest of the ECS (position, rotation, scale, flip, and
layer/depth sorting all work exactly as they do for a normal sprite).

## Choosing insets

`left`/`right`/`top`/`bottom` are measured in the same world units as the
sprite's `width`/`height` (which, for `createImageSprite`, default to the
source image's pixel size — so for a not-yet-resized sprite, an inset of `12`
matches 12 pixels of border art in the source texture). Pick insets that
cover exactly the rounded corner/border artwork in your source image and no
more: too small and the stretched center creeps into the border art; too
large and the fixed corners eat into space that should stretch.

If the sprite is already a non-default size when you configure slicing
(for example you sized it to fit a layout before slicing it), set
`nativeWidth`/`nativeHeight` to the size the border art was authored at.
These default to the sprite's current `width`/`height`, so a sprite that's
never resized after slicing looks identical to before — but they anchor
_where_ the insets fall in the texture's UV space, independent of the
sprite's current, possibly-stretched size. Getting this wrong doesn't
break geometry (corners still render at the fixed inset size); it only
shifts which texture pixels land in the border vs. the center.

## Stretch vs. tile

Each edge and the center independently default to `edgeMode: 'stretch'`
and `centerMode: 'stretch'`: the region's texture is scaled to fill the
available space, which is fine for a flat color or a soft gradient but
smears any repeating detail (a brick or wood-grain edge, a dashed border).
Set the relevant mode to `'tile'` instead to repeat that region's texture
at its native size:

```ts
createImageSprite(panelImage, renderContext, 0, {
slices: {
left: 12,
right: 12,
top: 12,
bottom: 12,
edgeMode: 'tile',
centerMode: 'tile',
nativeWidth: panelImage.width,
nativeHeight: panelImage.height,
},
});
```

Tiling here means **round-repeat**, not a pixel-perfect crop: the number of
repeats is rounded to the nearest whole tile, and every tile is stretched
by a small, usually-imperceptible amount so the tiles fill the available
space evenly with no cropped partial tile at the seam. This trades
sub-pixel size accuracy for never showing a jarring half-tile at the edge
of a region — the same tradeoff CSS's `border-image-repeat: round` makes.
`nativeWidth`/`nativeHeight` matter more here than for `'stretch'`, since
they're also the reference size the repeat count is computed from: without
them (left at their width/height defaults), a sprite tiles as a single,
unrepeated region until you resize it.

## Performance note

A sliced sprite draws as up to nine separate instances (fewer if any
inset is `0`, or more if a `'tile'` region needs several repeat tiles)
instead of one, so it costs proportionally more per-instance data than a
normal sprite. They still batch into the same instanced draw call as every
other sprite sharing the same `Renderable`, so this only shows up as more
instances in that batch, not extra draw calls — for the handful of panels
and buttons a typical UI needs, this is negligible. It's not a fit for
slicing thousands of sprites per frame (a particle system, say); use it for
UI chrome and other sparse, mostly-static elements.
4 changes: 4 additions & 0 deletions documentation-site/docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ const config: Config = {
to: 'demos/particles',
label: 'Particles',
},
{
to: 'demos/nine-slice',
label: 'Nine-Slice Sprites',
},
],
},
{
Expand Down
29 changes: 29 additions & 0 deletions documentation-site/src/pages/demos/nine-slice/_create-game.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
createCamera,
createCameraEcsSystem,
createRenderEcsSystem,
} from '@forge-game-engine/forge/rendering';
import { createGame, Game } from '@forge-game-engine/forge/utilities';
import { createPanels } from './_create-panels';
import { createPanelEcsSystem } from './_panel.system';

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

export const createNineSliceGame = async (): Promise<Game> => {
const { game, world, renderContext, time } = createGame('demo-game');

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

await createPanels(world, renderContext, renderLayers.foreground);

world.addSystem(createCameraEcsSystem(time));
world.addSystem(createRenderEcsSystem(renderContext));
world.addSystem(createPanelEcsSystem(time));

return game;
};
89 changes: 89 additions & 0 deletions documentation-site/src/pages/demos/nine-slice/_create-panels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { getAssetUrl } from '@site/src/utils/get-asset-url';
import { EcsWorld } from '@forge-game-engine/forge/ecs';
import { positionId } from '@forge-game-engine/forge/common';
import { Vector2 } from '@forge-game-engine/forge/math';
import {
createImageSprite,
RenderContext,
spriteId,
SpriteEcsComponent,
} from '@forge-game-engine/forge/rendering';
import { panelId } from './_panel.component';

/**
* The panel artwork's own border, in texture pixels: a 12px frame (with gold
* corner accents) around a 40x40 tiled grid interior, on a 64x64 image.
*/
const borderInset = 12;
const nativeSize = 64;

const minSize = 64;
const maxSize = 180;

function placePanel(
world: EcsWorld,
sprite: SpriteEcsComponent,
x: number,
): void {
const entity = world.createEntity();

world.addComponent(entity, positionId, {
local: new Vector2(x, 0),
world: new Vector2(x, 0),
});

world.addComponent(entity, spriteId, sprite);
world.addComponent(entity, panelId, { minSize, maxSize });
}

/**
* Builds the three side-by-side comparison panels: a naive single-quad
* stretch, a nine-slice with `'stretch'` edges/center, and a nine-slice with
* `'tile'` edges/center - all sharing the same source artwork and the same
* breathing size animation, so only the slicing config differs.
* @param world - The ECS world to add the panel entities to.
* @param renderContext - The render context used to load and size sprites.
* @param renderLayer - The render layer the panels should be drawn on.
*/
export async function createPanels(
world: EcsWorld,
renderContext: RenderContext,
renderLayer: number,
): Promise<void> {
const panelImage = await renderContext.imageCache.getOrLoad(
getAssetUrl('img/nine-slice/panel.png'),
);

const naiveSprite = createImageSprite(panelImage, renderContext, renderLayer);

const stretchSprite = createImageSprite(panelImage, renderContext, renderLayer, {
slices: {
left: borderInset,
right: borderInset,
top: borderInset,
bottom: borderInset,
nativeWidth: nativeSize,
nativeHeight: nativeSize,
},
});

const tileSprite = createImageSprite(panelImage, renderContext, renderLayer, {
slices: {
left: borderInset,
right: borderInset,
top: borderInset,
bottom: borderInset,
edgeMode: 'tile',
centerMode: 'tile',
nativeWidth: nativeSize,
nativeHeight: nativeSize,
},
});

const { width } = renderContext.canvas;
const spacing = Math.min(width / 3, 260);

placePanel(world, naiveSprite, -spacing);
placePanel(world, stretchSprite, 0);
placePanel(world, tileSprite, spacing);
}
13 changes: 13 additions & 0 deletions documentation-site/src/pages/demos/nine-slice/_panel.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createComponentId } from '@forge-game-engine/forge/ecs';

/**
* Per-entity data driving one panel in the nine-slice demo: the square size
* (in world units) its sprite breathes between, so the same system can
* animate all three comparison panels together.
*/
export interface PanelEcsComponent {
minSize: number;
maxSize: number;
}

export const panelId = createComponentId<PanelEcsComponent>('panel');
31 changes: 31 additions & 0 deletions documentation-site/src/pages/demos/nine-slice/_panel.system.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { EcsSystem } from '@forge-game-engine/forge/ecs';
import { Time } from '@forge-game-engine/forge/common';
import { lerp } from '@forge-game-engine/forge/math';
import { SpriteEcsComponent, spriteId } from '@forge-game-engine/forge/rendering';
import { PanelEcsComponent, panelId } from './_panel.component';

const cycleSeconds = 4;

/**
* Breathes each panel's sprite between `minSize` and `maxSize` in lockstep
* (a smooth sine wave, not a hard ping-pong), so the three comparison panels
* can be watched resizing together: a naive single-quad stretch distorts its
* corners and border, while the nine-sliced panels keep theirs a fixed size.
* @param time - The time instance used to derive the current phase.
* @returns The panel ECS system.
*/
export const createPanelEcsSystem = (
time: Time,
): EcsSystem<[SpriteEcsComponent, PanelEcsComponent]> => ({
query: [spriteId, panelId],
run: (result) => {
const [sprite, panel] = result.components;
const { minSize, maxSize } = panel;

const phase = (Math.sin((time.timeInSeconds * Math.PI * 2) / cycleSeconds) + 1) / 2;
const size = lerp(minSize, maxSize, phase);

sprite.width = size;
sprite.height = size;
},
});
70 changes: 70 additions & 0 deletions documentation-site/src/pages/demos/nine-slice/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import React, { JSX } from 'react';
import { createNineSliceGame } from './_create-game';
import gameCode from '!!raw-loader!./_create-game';
import createPanelsCode from '!!raw-loader!./_create-panels';
import panelComponentCode from '!!raw-loader!./_panel.component';
import panelSystemCode from '!!raw-loader!./_panel.system';

import { Demo } from '@site/src/components/Demo';
import { InteractionInstruction } from '@site/src/components/_InteractionInstruction';

const badgeStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 20,
height: 20,
borderRadius: '50%',
backgroundColor: 'var(--ifm-color-emphasis-300)',
fontSize: 12,
fontWeight: 700,
};

export default function NineSlice(): JSX.Element {
return (
<Demo
metaData={{
title: 'Nine-Slice Sprites',
description:
'A demo comparing a naively-stretched sprite against nine-sliced sprites with stretch and tile edge/center modes.',
}}
header="Nine-Slice Sprites"
blurb="The same 64x64 panel artwork (a 12px border with gold corner accents around a tiled grid) drives all three panels, which all breathe between the same minimum and maximum size together. The left panel is a plain, unsliced sprite: as it grows, its border and corner accents stretch right along with it, turning soft and blurry. The middle panel is nine-sliced with the default 'stretch' mode: its corners stay a crisp, fixed size no matter how large the panel gets, while its edges and center stretch to fill the rest. The right panel uses 'tile' mode instead: its corners are just as fixed, but the edges and center repeat the grid pattern at its native size rather than stretching it, so the grid squares stay square."
createGame={createNineSliceGame}
interactions={
<>
<InteractionInstruction
displayElement={<div style={badgeStyle}>1</div>}
text="Naive stretch (no slicing)"
/>
<InteractionInstruction
displayElement={<div style={badgeStyle}>2</div>}
text="Nine-slice: stretch"
/>
<InteractionInstruction
displayElement={<div style={badgeStyle}>3</div>}
text="Nine-slice: tile"
/>
</>
}
codeFiles={[
{
name: 'game.ts',
content: gameCode,
},
{
name: 'create-panels.ts',
content: createPanelsCode,
},
{
name: 'panel.component.ts',
content: panelComponentCode,
},
{
name: 'panel.system.ts',
content: panelSystemCode,
},
]}
/>
);
}
Binary file added documentation-site/static/img/nine-slice/panel.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions src/rendering/components/sprite-component.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createComponentId } from '../../ecs/ecs-component.js';
import { EcsWorld } from '../../ecs/ecs-world.js';
import { Color, Renderable, Vector2 } from '../../index.js';
import { NineSliceOptions } from '../nine-slice-options.js';

/**
* Fields of {@link SpriteEcsComponent} with no sensible default; callers
Expand Down Expand Up @@ -79,6 +80,15 @@ export interface SpriteDefaultedOptions {
* position).
*/
layer: number;

/**
* Nine-slice ("9-patch") configuration. When set, the render system draws
* this sprite as up to nine regions (four fixed-size corners, four
* stretched/tiled edges, and a stretched/tiled center) instead of a single
* stretched quad, so corner artwork keeps its size as `width`/`height`
* change. Omit for a normal, single-quad sprite.
*/
slices?: NineSliceOptions;
}

export interface SpriteEcsComponent
Expand Down
1 change: 1 addition & 0 deletions src/rendering/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './components/index.js';
export * from './nine-slice-options.js';
export * from './sprite.js';
export * from './systems/index.js';
export * from './shaders/index.js';
Expand Down
Loading
Loading